feat(composite_slo): implement create route (#158474)

This commit is contained in:
Kevin Delemme 2023-05-26 10:40:14 -04:00 committed by GitHub
parent cf7ed17163
commit 50e113dc42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 838 additions and 10 deletions

View file

@ -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

View file

@ -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}`;
}

View file

@ -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 };

View file

@ -11,3 +11,4 @@ export * from './error_budget';
export * from './indicators';
export * from './slo';
export * from './time_window';
export * from './composite_slo';

View file

@ -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 };

View file

@ -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';

View file

@ -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();
});
});
});

View file

@ -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.'
);
}
}

View file

@ -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 {}

View file

@ -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;
},
});

View file

@ -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,
}
);
});
});
});

View file

@ -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)
);
}

View file

@ -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,
};
}

View file

@ -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: [],
};
};

View file

@ -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';

View file

@ -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 {

View file

@ -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'],
},

View file

@ -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();
});
});
});
});
}

View 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 { FtrProviderContext } from '../../common/ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('composite_slo', () => {
loadTestFile(require.resolve('./create'));
});
}

View 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')],
});

View file

@ -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 });
}

View file

@ -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"
}

View file

@ -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"
]
}