mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
feat(composite_slo): implement create route (#158474)
This commit is contained in:
parent
cf7ed17163
commit
50e113dc42
23 changed files with 838 additions and 10 deletions
|
@ -327,6 +327,7 @@ enabled:
|
|||
- x-pack/test/monitoring_api_integration/config.ts
|
||||
- x-pack/test/observability_api_integration/basic/config.ts
|
||||
- x-pack/test/observability_api_integration/trial/config.ts
|
||||
- x-pack/test/observability_api_integration/apis/config.ts
|
||||
- x-pack/test/observability_functional/with_rac_write.config.ts
|
||||
- x-pack/test/plugin_api_integration/config.ts
|
||||
- x-pack/test/plugin_functional/config.ts
|
||||
|
|
|
@ -49,6 +49,10 @@ class Duration {
|
|||
return !this.isShorterThan(other);
|
||||
}
|
||||
|
||||
isEqual(other: Duration): boolean {
|
||||
return this.value === other.value && this.unit === other.unit;
|
||||
}
|
||||
|
||||
format(): string {
|
||||
return `${this.value}${this.unit}`;
|
||||
}
|
||||
|
|
|
@ -7,10 +7,16 @@
|
|||
|
||||
import * as t from 'io-ts';
|
||||
|
||||
import { compositeSloSchema, compositeSloIdSchema } from '@kbn/slo-schema';
|
||||
import {
|
||||
compositeSloSchema,
|
||||
compositeSloIdSchema,
|
||||
weightedAverageSourceSchema,
|
||||
} from '@kbn/slo-schema';
|
||||
|
||||
type CompositeSLO = t.TypeOf<typeof compositeSloSchema>;
|
||||
type CompositeSLOId = t.TypeOf<typeof compositeSloIdSchema>;
|
||||
type StoredCompositeSLO = t.OutputOf<typeof compositeSloSchema>;
|
||||
|
||||
export type { CompositeSLO, CompositeSLOId, StoredCompositeSLO };
|
||||
type WeightedAverageSource = t.TypeOf<typeof weightedAverageSourceSchema>;
|
||||
|
||||
export type { CompositeSLO, CompositeSLOId, StoredCompositeSLO, WeightedAverageSource };
|
||||
|
|
|
@ -11,3 +11,4 @@ export * from './error_budget';
|
|||
export * from './indicators';
|
||||
export * from './slo';
|
||||
export * from './time_window';
|
||||
export * from './composite_slo';
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
*/
|
||||
|
||||
import * as t from 'io-ts';
|
||||
import { timeWindowSchema } from '@kbn/slo-schema';
|
||||
import { rollingTimeWindowSchema, timeWindowSchema } from '@kbn/slo-schema';
|
||||
|
||||
type TimeWindow = t.TypeOf<typeof timeWindowSchema>;
|
||||
type RollingTimeWindow = t.TypeOf<typeof rollingTimeWindowSchema>;
|
||||
|
||||
export type { TimeWindow };
|
||||
export type { RollingTimeWindow, TimeWindow };
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './validate_composite_slo';
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
createWeightedAverageSource,
|
||||
createCompositeSLO,
|
||||
} from '../../../services/composite_slo/fixtures/composite_slo';
|
||||
import { createSLO } from '../../../services/slo/fixtures/slo';
|
||||
import {
|
||||
sevenDaysRolling,
|
||||
thirtyDaysRolling,
|
||||
weeklyCalendarAligned,
|
||||
} from '../../../services/slo/fixtures/time_window';
|
||||
import { validateCompositeSLO } from '.';
|
||||
|
||||
describe('validateCompositeSLO', () => {
|
||||
it("throws when specified combined SLOs don't match the actual SLO revision", () => {
|
||||
const sloOne = createSLO({ revision: 3 });
|
||||
const sloTwo = createSLO({ revision: 2 });
|
||||
const compositeSlo = createCompositeSLO({
|
||||
sources: [
|
||||
createWeightedAverageSource({ id: sloOne.id, revision: sloOne.revision }),
|
||||
createWeightedAverageSource({ id: sloTwo.id, revision: 1 }),
|
||||
],
|
||||
});
|
||||
expect(() => validateCompositeSLO(compositeSlo, [sloOne, sloTwo])).toThrowError(
|
||||
'One or many source SLOs are not matching the specified id and revision.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when specified combined SLOs refers to a non-existant SLO', () => {
|
||||
const sloOne = createSLO({ revision: 3 });
|
||||
const compositeSlo = createCompositeSLO({
|
||||
sources: [
|
||||
createWeightedAverageSource({ id: sloOne.id, revision: sloOne.revision }),
|
||||
createWeightedAverageSource({ id: 'non-existant' }),
|
||||
],
|
||||
});
|
||||
expect(() => validateCompositeSLO(compositeSlo, [sloOne])).toThrowError(
|
||||
'One or many source SLOs are not matching the specified id and revision.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the time window is not the same accros all combined SLOs', () => {
|
||||
const sloOne = createSLO({ timeWindow: sevenDaysRolling() });
|
||||
const sloTwo = createSLO({ timeWindow: weeklyCalendarAligned() });
|
||||
const compositeSlo = createCompositeSLO({
|
||||
timeWindow: sevenDaysRolling(),
|
||||
sources: [
|
||||
createWeightedAverageSource({ id: sloOne.id, revision: sloOne.revision }),
|
||||
createWeightedAverageSource({ id: sloTwo.id, revision: sloTwo.revision }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => validateCompositeSLO(compositeSlo, [sloOne, sloTwo])).toThrowError(
|
||||
'Invalid time window. Every source SLO must use the same time window as the composite.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the time window duration is not the same accros all combined SLOs', () => {
|
||||
const sloOne = createSLO({ timeWindow: sevenDaysRolling() });
|
||||
const sloTwo = createSLO({ timeWindow: thirtyDaysRolling() });
|
||||
const compositeSlo = createCompositeSLO({
|
||||
timeWindow: sevenDaysRolling(),
|
||||
sources: [
|
||||
createWeightedAverageSource({ id: sloOne.id, revision: sloOne.revision }),
|
||||
createWeightedAverageSource({ id: sloTwo.id, revision: sloTwo.revision }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => validateCompositeSLO(compositeSlo, [sloOne, sloTwo])).toThrowError(
|
||||
'Invalid time window. Every source SLO must use the same time window as the composite.'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the budgeting method is not the same accros all combined SLOs', () => {
|
||||
const sloOne = createSLO({ budgetingMethod: 'occurrences' });
|
||||
const sloTwo = createSLO({ budgetingMethod: 'timeslices' });
|
||||
const compositeSlo = createCompositeSLO({
|
||||
budgetingMethod: 'occurrences',
|
||||
sources: [
|
||||
createWeightedAverageSource({ id: sloOne.id, revision: sloOne.revision }),
|
||||
createWeightedAverageSource({ id: sloTwo.id, revision: sloTwo.revision }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => validateCompositeSLO(compositeSlo, [sloOne, sloTwo])).toThrowError(
|
||||
'Invalid budgeting method. Every source SLO must use the same budgeting method as the composite.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('happy flow', () => {
|
||||
it('throws nothing', () => {
|
||||
const sloOne = createSLO({
|
||||
budgetingMethod: 'occurrences',
|
||||
timeWindow: sevenDaysRolling(),
|
||||
revision: 2,
|
||||
});
|
||||
const sloTwo = createSLO({
|
||||
budgetingMethod: 'occurrences',
|
||||
timeWindow: sevenDaysRolling(),
|
||||
revision: 3,
|
||||
});
|
||||
const compositeSlo = createCompositeSLO({
|
||||
budgetingMethod: 'occurrences',
|
||||
timeWindow: sevenDaysRolling(),
|
||||
sources: [
|
||||
createWeightedAverageSource({ id: sloOne.id, revision: sloOne.revision }),
|
||||
createWeightedAverageSource({ id: sloTwo.id, revision: sloTwo.revision }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(() => validateCompositeSLO(compositeSlo, [sloOne, sloTwo])).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { rollingTimeWindowSchema } from '@kbn/slo-schema';
|
||||
import { IllegalArgumentError } from '../../../errors';
|
||||
import { SLO } from '../../models';
|
||||
import { CompositeSLO } from '../../models/composite_slo';
|
||||
|
||||
export function validateCompositeSLO(compositeSlo: CompositeSLO, sloList: SLO[]) {
|
||||
assertMatchingSloList(compositeSlo, sloList);
|
||||
assertSameBudgetingMethod(compositeSlo, sloList);
|
||||
assertSameRollingTimeWindow(compositeSlo, sloList);
|
||||
}
|
||||
|
||||
function assertMatchingSloList(compositeSlo: CompositeSLO, sloList: SLO[]) {
|
||||
const everyCombinedSloMatches = compositeSlo.sources.every((sourceSlo) =>
|
||||
sloList.find((slo) => sourceSlo.id === slo.id && sourceSlo.revision === slo.revision)
|
||||
);
|
||||
|
||||
if (!everyCombinedSloMatches) {
|
||||
throw new IllegalArgumentError(
|
||||
'One or many source SLOs are not matching the specified id and revision.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameBudgetingMethod(compositeSlo: CompositeSLO, sloList: SLO[]) {
|
||||
const haveSameBudgetingMethod = sloList.every(
|
||||
(slo) => slo.budgetingMethod === compositeSlo.budgetingMethod
|
||||
);
|
||||
if (!haveSameBudgetingMethod) {
|
||||
throw new IllegalArgumentError(
|
||||
'Invalid budgeting method. Every source SLO must use the same budgeting method as the composite.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSameRollingTimeWindow(compositeSlo: CompositeSLO, sloList: SLO[]) {
|
||||
const haveSameTimeWindow = sloList.every(
|
||||
(slo) =>
|
||||
rollingTimeWindowSchema.is(slo.timeWindow) &&
|
||||
slo.timeWindow.duration.isEqual(compositeSlo.timeWindow.duration)
|
||||
);
|
||||
if (!haveSameTimeWindow) {
|
||||
throw new IllegalArgumentError(
|
||||
'Invalid time window. Every source SLO must use the same time window as the composite.'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ export class ObservabilityError extends Error {
|
|||
|
||||
export class SLONotFound extends ObservabilityError {}
|
||||
export class SLOIdConflict extends ObservabilityError {}
|
||||
export class CompositeSLOIdConflict extends ObservabilityError {}
|
||||
export class InternalQueryError extends ObservabilityError {}
|
||||
export class NotSupportedError extends ObservabilityError {}
|
||||
export class IllegalArgumentError extends ObservabilityError {}
|
||||
|
|
|
@ -14,6 +14,11 @@ import {
|
|||
updateCompositeSLOParamsSchema,
|
||||
} from '@kbn/slo-schema';
|
||||
|
||||
import {
|
||||
CreateCompositeSLO,
|
||||
KibanaSavedObjectsCompositeSLORepository,
|
||||
} from '../../services/composite_slo';
|
||||
import { KibanaSavedObjectsSLORepository } from '../../services/slo';
|
||||
import { ObservabilityRequestHandlerContext } from '../../types';
|
||||
import { createObservabilityServerRoute } from '../create_observability_server_route';
|
||||
|
||||
|
@ -30,10 +35,18 @@ const createCompositeSLORoute = createObservabilityServerRoute({
|
|||
tags: ['access:slo_write'],
|
||||
},
|
||||
params: createCompositeSLOParamsSchema,
|
||||
handler: async ({ context }) => {
|
||||
handler: async ({ context, params }) => {
|
||||
await assertLicenseAtLeastPlatinum(context);
|
||||
|
||||
throw notImplemented();
|
||||
const soClient = (await context.core).savedObjects.client;
|
||||
|
||||
const compositeSloRepository = new KibanaSavedObjectsCompositeSLORepository(soClient);
|
||||
const sloRepository = new KibanaSavedObjectsSLORepository(soClient);
|
||||
const createCompositeSLO = new CreateCompositeSLO(compositeSloRepository, sloRepository);
|
||||
|
||||
const response = await createCompositeSLO.execute(params.body);
|
||||
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SavedObjectsClientContract, SavedObjectsFindResponse } from '@kbn/core/server';
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { compositeSloSchema } from '@kbn/slo-schema';
|
||||
|
||||
import { CompositeSLO, StoredCompositeSLO } from '../../domain/models';
|
||||
import { CompositeSLOIdConflict } from '../../errors';
|
||||
import { SO_COMPOSITE_SLO_TYPE } from '../../saved_objects';
|
||||
import { KibanaSavedObjectsCompositeSLORepository } from './composite_slo_repository';
|
||||
import { aStoredCompositeSLO, createCompositeSLO } from './fixtures/composite_slo';
|
||||
|
||||
function createFindResponse(
|
||||
compositeSloList: CompositeSLO[]
|
||||
): SavedObjectsFindResponse<StoredCompositeSLO> {
|
||||
return {
|
||||
page: 1,
|
||||
per_page: 25,
|
||||
total: compositeSloList.length,
|
||||
saved_objects: compositeSloList.map((compositeSlo) => ({
|
||||
id: compositeSlo.id,
|
||||
attributes: compositeSloSchema.encode(compositeSlo),
|
||||
type: SO_COMPOSITE_SLO_TYPE,
|
||||
references: [],
|
||||
score: 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe('KibanaSavedObjectsCompositeSLORepository', () => {
|
||||
let soClientMock: jest.Mocked<SavedObjectsClientContract>;
|
||||
|
||||
beforeEach(() => {
|
||||
soClientMock = savedObjectsClientMock.create();
|
||||
});
|
||||
|
||||
describe('saving a composite SLO', () => {
|
||||
it('saves the new composite SLO', async () => {
|
||||
const compositeSlo = createCompositeSLO({ id: 'my-composite-id' });
|
||||
soClientMock.find.mockResolvedValueOnce(createFindResponse([]));
|
||||
soClientMock.create.mockResolvedValueOnce(aStoredCompositeSLO(compositeSlo));
|
||||
const repository = new KibanaSavedObjectsCompositeSLORepository(soClientMock);
|
||||
|
||||
const savedCompositeSlo = await repository.save(compositeSlo);
|
||||
|
||||
expect(savedCompositeSlo).toEqual(compositeSlo);
|
||||
expect(soClientMock.find).toHaveBeenCalledWith({
|
||||
type: SO_COMPOSITE_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
filter: `composite-slo.attributes.id:(${compositeSlo.id})`,
|
||||
});
|
||||
expect(soClientMock.create).toHaveBeenCalledWith(
|
||||
SO_COMPOSITE_SLO_TYPE,
|
||||
compositeSloSchema.encode(compositeSlo),
|
||||
{
|
||||
id: undefined,
|
||||
overwrite: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the Composite SLO id already exists and "throwOnConflict" is true', async () => {
|
||||
const compositeSlo = createCompositeSLO({ id: 'my-composite-id' });
|
||||
soClientMock.find.mockResolvedValueOnce(createFindResponse([compositeSlo]));
|
||||
const repository = new KibanaSavedObjectsCompositeSLORepository(soClientMock);
|
||||
|
||||
await expect(repository.save(compositeSlo, { throwOnConflict: true })).rejects.toThrowError(
|
||||
new CompositeSLOIdConflict(`Composite SLO [${compositeSlo.id}] already exists`)
|
||||
);
|
||||
expect(soClientMock.find).toHaveBeenCalledWith({
|
||||
type: SO_COMPOSITE_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
filter: `composite-slo.attributes.id:(${compositeSlo.id})`,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the existing SLO', async () => {
|
||||
const compositeSlo = createCompositeSLO({ id: 'my-composite-id' });
|
||||
soClientMock.find.mockResolvedValueOnce(createFindResponse([compositeSlo]));
|
||||
soClientMock.create.mockResolvedValueOnce(aStoredCompositeSLO(compositeSlo));
|
||||
const repository = new KibanaSavedObjectsCompositeSLORepository(soClientMock);
|
||||
|
||||
const savedCompositeSLO = await repository.save(compositeSlo);
|
||||
|
||||
expect(savedCompositeSLO).toEqual(compositeSlo);
|
||||
expect(soClientMock.find).toHaveBeenCalledWith({
|
||||
type: SO_COMPOSITE_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
filter: `composite-slo.attributes.id:(${compositeSlo.id})`,
|
||||
});
|
||||
expect(soClientMock.create).toHaveBeenCalledWith(
|
||||
SO_COMPOSITE_SLO_TYPE,
|
||||
compositeSloSchema.encode(compositeSlo),
|
||||
{
|
||||
id: 'my-composite-id',
|
||||
overwrite: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import { compositeSloSchema } from '@kbn/slo-schema';
|
||||
import { fold } from 'fp-ts/lib/Either';
|
||||
import { pipe } from 'fp-ts/lib/pipeable';
|
||||
import * as t from 'io-ts';
|
||||
|
||||
import { CompositeSLO, StoredCompositeSLO } from '../../domain/models/composite_slo';
|
||||
import { CompositeSLOIdConflict } from '../../errors';
|
||||
import { SO_COMPOSITE_SLO_TYPE } from '../../saved_objects';
|
||||
|
||||
export interface CompositeSLORepository {
|
||||
save(compositeSlo: CompositeSLO, options?: { throwOnConflict: boolean }): Promise<CompositeSLO>;
|
||||
}
|
||||
|
||||
export class KibanaSavedObjectsCompositeSLORepository implements CompositeSLORepository {
|
||||
constructor(private soClient: SavedObjectsClientContract) {}
|
||||
|
||||
async save(
|
||||
compositeSlo: CompositeSLO,
|
||||
options = { throwOnConflict: false }
|
||||
): Promise<CompositeSLO> {
|
||||
let existingSavedObjectId;
|
||||
const findResponse = await this.soClient.find<StoredCompositeSLO>({
|
||||
type: SO_COMPOSITE_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
filter: `composite-slo.attributes.id:(${compositeSlo.id})`,
|
||||
});
|
||||
|
||||
if (findResponse.total === 1) {
|
||||
if (options.throwOnConflict) {
|
||||
throw new CompositeSLOIdConflict(`Composite SLO [${compositeSlo.id}] already exists`);
|
||||
}
|
||||
|
||||
existingSavedObjectId = findResponse.saved_objects[0].id;
|
||||
}
|
||||
|
||||
const createResponse = await this.soClient.create<StoredCompositeSLO>(
|
||||
SO_COMPOSITE_SLO_TYPE,
|
||||
toStoredCompositeSLO(compositeSlo),
|
||||
{
|
||||
id: existingSavedObjectId,
|
||||
overwrite: true,
|
||||
}
|
||||
);
|
||||
|
||||
return toCompositeSLO(createResponse.attributes);
|
||||
}
|
||||
}
|
||||
|
||||
function toStoredCompositeSLO(compositeSlo: CompositeSLO): StoredCompositeSLO {
|
||||
return compositeSloSchema.encode(compositeSlo);
|
||||
}
|
||||
|
||||
function toCompositeSLO(storedCompositeSlo: StoredCompositeSLO): CompositeSLO {
|
||||
return pipe(
|
||||
compositeSloSchema.decode(storedCompositeSlo),
|
||||
fold(() => {
|
||||
throw new Error(`Invalid stored composite SLO [${storedCompositeSlo.id}]`);
|
||||
}, t.identity)
|
||||
);
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
CreateCompositeSLOParams,
|
||||
CreateCompositeSLOResponse,
|
||||
CreateSLOResponse,
|
||||
} from '@kbn/slo-schema';
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
|
||||
import { CompositeSLO } from '../../domain/models/composite_slo';
|
||||
import { validateCompositeSLO } from '../../domain/services/composite_slo';
|
||||
import { SLORepository } from '../slo/slo_repository';
|
||||
import { CompositeSLORepository } from './composite_slo_repository';
|
||||
|
||||
export class CreateCompositeSLO {
|
||||
constructor(
|
||||
private compositeSloRepository: CompositeSLORepository,
|
||||
private sloRepository: SLORepository
|
||||
) {}
|
||||
|
||||
public async execute(params: CreateCompositeSLOParams): Promise<CreateSLOResponse> {
|
||||
const compositeSlo = toCompositeSLO(params);
|
||||
const sloList = await this.sloRepository.findAllByIds(
|
||||
compositeSlo.sources.map((slo) => slo.id)
|
||||
);
|
||||
validateCompositeSLO(compositeSlo, sloList);
|
||||
|
||||
await this.compositeSloRepository.save(compositeSlo, { throwOnConflict: true });
|
||||
|
||||
return toResponse(compositeSlo);
|
||||
}
|
||||
}
|
||||
|
||||
function toCompositeSLO(params: CreateCompositeSLOParams): CompositeSLO {
|
||||
const now = new Date();
|
||||
return {
|
||||
...params,
|
||||
id: params.id ?? uuidv1(),
|
||||
tags: params.tags ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function toResponse(compositeSlo: CompositeSLO): CreateCompositeSLOResponse {
|
||||
return {
|
||||
id: compositeSlo.id,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
import { SavedObject } from '@kbn/core-saved-objects-server';
|
||||
|
||||
import { compositeSloSchema } from '@kbn/slo-schema';
|
||||
import { SO_COMPOSITE_SLO_TYPE } from '../../../saved_objects';
|
||||
import { CompositeSLO, StoredCompositeSLO, WeightedAverageSource } from '../../../domain/models';
|
||||
import { sevenDaysRolling } from '../../slo/fixtures/time_window';
|
||||
|
||||
export const createWeightedAverageSource = (
|
||||
params: Partial<WeightedAverageSource> = {}
|
||||
): WeightedAverageSource => {
|
||||
return cloneDeep({
|
||||
id: uuidv1(),
|
||||
revision: 1,
|
||||
weight: 1,
|
||||
...params,
|
||||
});
|
||||
};
|
||||
|
||||
const defaultCompositeSLO: Omit<CompositeSLO, 'id' | 'createdAt' | 'updatedAt'> = {
|
||||
name: 'some composite slo',
|
||||
timeWindow: sevenDaysRolling(),
|
||||
budgetingMethod: 'occurrences',
|
||||
objective: {
|
||||
target: 0.95,
|
||||
},
|
||||
compositeMethod: 'weightedAverage',
|
||||
sources: [createWeightedAverageSource(), createWeightedAverageSource()],
|
||||
tags: ['critical', 'k8s'],
|
||||
};
|
||||
|
||||
export const createCompositeSLO = (params: Partial<CompositeSLO> = {}): CompositeSLO => {
|
||||
const now = new Date();
|
||||
return cloneDeep({
|
||||
...defaultCompositeSLO,
|
||||
id: uuidv1(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...params,
|
||||
});
|
||||
};
|
||||
|
||||
export const aStoredCompositeSLO = (
|
||||
compositeSlo: CompositeSLO
|
||||
): SavedObject<StoredCompositeSLO> => {
|
||||
return {
|
||||
id: uuidv1(),
|
||||
attributes: compositeSloSchema.encode(compositeSlo),
|
||||
type: SO_COMPOSITE_SLO_TYPE,
|
||||
references: [],
|
||||
};
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './create_composite_slo';
|
||||
export * from './composite_slo_repository';
|
|
@ -5,8 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { TimeWindow } from '../../../domain/models/time_window';
|
||||
import { oneWeek, sevenDays, sixHours } from './duration';
|
||||
import { RollingTimeWindow, TimeWindow } from '../../../domain/models/time_window';
|
||||
import { oneWeek, sevenDays, sixHours, thirtyDays } from './duration';
|
||||
|
||||
export function sixHoursRolling(): TimeWindow {
|
||||
return {
|
||||
|
@ -15,12 +15,18 @@ export function sixHoursRolling(): TimeWindow {
|
|||
};
|
||||
}
|
||||
|
||||
export function sevenDaysRolling(): TimeWindow {
|
||||
export function sevenDaysRolling(): RollingTimeWindow {
|
||||
return {
|
||||
duration: sevenDays(),
|
||||
isRolling: true,
|
||||
};
|
||||
}
|
||||
export function thirtyDaysRolling(): RollingTimeWindow {
|
||||
return {
|
||||
duration: thirtyDays(),
|
||||
isRolling: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function weeklyCalendarAligned(): TimeWindow {
|
||||
return {
|
||||
|
|
|
@ -644,6 +644,45 @@ export default async function ({ readConfigFile }) {
|
|||
],
|
||||
},
|
||||
},
|
||||
|
||||
slo_all: {
|
||||
kibana: [
|
||||
{
|
||||
feature: {
|
||||
slo: ['all'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
elasticsearch: {
|
||||
cluster: ['all'],
|
||||
indices: [
|
||||
{
|
||||
names: ['*'],
|
||||
privileges: ['all'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
slo_read_only: {
|
||||
kibana: [
|
||||
{
|
||||
feature: {
|
||||
slo: ['read'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
elasticsearch: {
|
||||
cluster: ['all'],
|
||||
indices: [
|
||||
{
|
||||
names: ['*'],
|
||||
privileges: ['all'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultRoles: ['superuser'],
|
||||
},
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../common/ftr_provider_context';
|
||||
import { createCompositeSLOInput } from '../../fixtures/composite_slo';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
|
||||
describe('create >', () => {
|
||||
const security = getService('security');
|
||||
|
||||
before(async () => {
|
||||
await security.testUser.setRoles(['slo_all']);
|
||||
await kibanaServer.importExport.load(
|
||||
'x-pack/test/observability_api_integration/fixtures/kbn_archiver/saved_objects/slo.json'
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await security.testUser.restoreDefaults();
|
||||
await kibanaServer.importExport.unload(
|
||||
'x-pack/test/observability_api_integration/fixtures/kbn_archiver/saved_objects/slo.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a 400 with invalid payload', async () => {
|
||||
await supertest
|
||||
.post(`/api/observability/composite_slos`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({})
|
||||
.expect(400)
|
||||
.then((resp) => {
|
||||
expect(resp.body.error).to.eql('Bad Request');
|
||||
expect(resp.body.message).to.contain('Invalid value undefined supplied to');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a 400 when the source SLOs are not found', async () => {
|
||||
await supertest
|
||||
.post(`/api/observability/composite_slos`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(createCompositeSLOInput({ sources: [{ id: 'inexistant', revision: 1, weight: 1 }] }))
|
||||
.expect(400)
|
||||
.then((resp) => {
|
||||
expect(resp.body.error).to.eql('Bad Request');
|
||||
expect(resp.body.message).to.contain(
|
||||
'One or many source SLOs are not matching the specified id and revision.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a 400 when the source SLOs' time window don't match", async () => {
|
||||
await supertest
|
||||
.post(`/api/observability/composite_slos`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
createCompositeSLOInput({
|
||||
timeWindow: {
|
||||
duration: '30d',
|
||||
isRolling: true,
|
||||
},
|
||||
sources: [
|
||||
{ id: 'f9072790-f97c-11ed-895c-170d13e61076', revision: 2, weight: 1 },
|
||||
{ id: 'f6694b30-f97c-11ed-895c-170d13e61076', revision: 1, weight: 2 },
|
||||
],
|
||||
})
|
||||
)
|
||||
.expect(400)
|
||||
.then((resp) => {
|
||||
expect(resp.body.error).to.eql('Bad Request');
|
||||
expect(resp.body.message).to.contain(
|
||||
'Invalid time window. Every source SLO must use the same time window as the composite.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a 400 when the source SLOs' budgeting method don't match", async () => {
|
||||
await supertest
|
||||
.post(`/api/observability/composite_slos`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
createCompositeSLOInput({
|
||||
budgetingMethod: 'timeslices',
|
||||
sources: [
|
||||
{ id: 'f9072790-f97c-11ed-895c-170d13e61076', revision: 2, weight: 1 },
|
||||
{ id: 'f6694b30-f97c-11ed-895c-170d13e61076', revision: 1, weight: 2 },
|
||||
],
|
||||
})
|
||||
)
|
||||
.expect(400)
|
||||
.then((resp) => {
|
||||
expect(resp.body.error).to.eql('Bad Request');
|
||||
expect(resp.body.message).to.contain(
|
||||
'Invalid budgeting method. Every source SLO must use the same budgeting method as the composite.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
it('returns a 200', async () => {
|
||||
await supertest
|
||||
.post(`/api/observability/composite_slos`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
createCompositeSLOInput({
|
||||
sources: [
|
||||
{ id: 'f9072790-f97c-11ed-895c-170d13e61076', revision: 2, weight: 1 },
|
||||
{ id: 'f6694b30-f97c-11ed-895c-170d13e61076', revision: 1, weight: 2 },
|
||||
],
|
||||
})
|
||||
)
|
||||
.expect(200)
|
||||
.then((resp) => {
|
||||
expect(resp.body.id).to.be.ok();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../common/ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('composite_slo', () => {
|
||||
loadTestFile(require.resolve('./create'));
|
||||
});
|
||||
}
|
14
x-pack/test/observability_api_integration/apis/config.ts
Normal file
14
x-pack/test/observability_api_integration/apis/config.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { createTestConfig } from '../common/config';
|
||||
|
||||
export default createTestConfig({
|
||||
license: 'trial',
|
||||
name: 'X-Pack Observability API integration test',
|
||||
testFiles: [require.resolve('./composite_slo')],
|
||||
});
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
import { CreateCompositeSLOInput } from '@kbn/slo-schema';
|
||||
|
||||
const defaultCompositeSLOInput: CreateCompositeSLOInput = {
|
||||
name: 'some composite slo',
|
||||
timeWindow: {
|
||||
duration: '7d',
|
||||
isRolling: true,
|
||||
},
|
||||
budgetingMethod: 'occurrences',
|
||||
objective: {
|
||||
target: 0.95,
|
||||
},
|
||||
compositeMethod: 'weightedAverage',
|
||||
sources: [
|
||||
{ id: uuidv1(), revision: 1, weight: 1 },
|
||||
{ id: uuidv1(), revision: 2, weight: 2 },
|
||||
],
|
||||
tags: ['critical', 'k8s'],
|
||||
};
|
||||
|
||||
export function createCompositeSLOInput(
|
||||
data: Partial<CreateCompositeSLOInput> = {}
|
||||
): CreateCompositeSLOInput {
|
||||
return cloneDeep({ ...defaultCompositeSLOInput, ...data });
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"attributes": {
|
||||
"budgetingMethod": "occurrences",
|
||||
"createdAt": "2023-05-23T15:17:46.761Z",
|
||||
"description": "",
|
||||
"enabled": true,
|
||||
"id": "f9072790-f97c-11ed-895c-170d13e61076",
|
||||
"indicator": {
|
||||
"params": {
|
||||
"filter": "host : \"5c89fd95-ed02-43f4-945f-a6793bfc8ae9\"",
|
||||
"good": "latency <= 100",
|
||||
"index": "service-logs-latency",
|
||||
"timestampField": "@timestamp",
|
||||
"total": ""
|
||||
},
|
||||
"type": "sli.kql.custom"
|
||||
},
|
||||
"name": "slo two used in composite one",
|
||||
"objective": {
|
||||
"target": 0.94
|
||||
},
|
||||
"revision": 2,
|
||||
"settings": {
|
||||
"frequency": "1m",
|
||||
"syncDelay": "1m"
|
||||
},
|
||||
"tags": [],
|
||||
"timeWindow": {
|
||||
"duration": "7d",
|
||||
"isRolling": true
|
||||
},
|
||||
"updatedAt": "2023-05-23T15:18:31.650Z"
|
||||
},
|
||||
"coreMigrationVersion": "8.8.0",
|
||||
"created_at": "2023-05-23T15:18:31.653Z",
|
||||
"id": "f907c3d0-f97c-11ed-895c-170d13e61076",
|
||||
"managed": false,
|
||||
"references": [],
|
||||
"type": "slo",
|
||||
"updated_at": "2023-05-23T15:18:31.653Z",
|
||||
"version": "WzIwNDkwLDFd"
|
||||
}
|
||||
|
||||
{
|
||||
"attributes": {
|
||||
"budgetingMethod": "occurrences",
|
||||
"createdAt": "2023-05-23T15:17:42.371Z",
|
||||
"description": "",
|
||||
"enabled": true,
|
||||
"id": "f6694b30-f97c-11ed-895c-170d13e61076",
|
||||
"indicator": {
|
||||
"params": {
|
||||
"filter": "host : \"5c89fd95-ed02-43f4-945f-a6793bfc8ae9\"",
|
||||
"good": "latency <= 150",
|
||||
"index": "service-logs-latency",
|
||||
"timestampField": "@timestamp",
|
||||
"total": ""
|
||||
},
|
||||
"type": "sli.kql.custom"
|
||||
},
|
||||
"name": "slo one used in composite one",
|
||||
"objective": {
|
||||
"target": 0.94
|
||||
},
|
||||
"revision": 1,
|
||||
"settings": {
|
||||
"frequency": "1m",
|
||||
"syncDelay": "1m"
|
||||
},
|
||||
"tags": [],
|
||||
"timeWindow": {
|
||||
"duration": "7d",
|
||||
"isRolling": true
|
||||
},
|
||||
"updatedAt": "2023-05-23T15:18:39.734Z"
|
||||
},
|
||||
"coreMigrationVersion": "8.8.0",
|
||||
"created_at": "2023-05-23T15:18:39.737Z",
|
||||
"id": "f66a0e80-f97c-11ed-895c-170d13e61076",
|
||||
"managed": false,
|
||||
"references": [],
|
||||
"type": "slo",
|
||||
"updated_at": "2023-05-23T15:18:39.737Z",
|
||||
"version": "WzIwNDg5LDFd"
|
||||
}
|
|
@ -124,6 +124,7 @@
|
|||
"@kbn/observability-shared-plugin",
|
||||
"@kbn/maps-vector-tile-utils",
|
||||
"@kbn/server-route-repository",
|
||||
"@kbn/core-http-common"
|
||||
"@kbn/core-http-common",
|
||||
"@kbn/slo-schema"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue