feat(slo): Delete SLO (#140760)

This commit is contained in:
Kevin Delemme 2022-09-20 10:34:48 -04:00 committed by GitHub
parent 0f7cfd16f7
commit d29521e897
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 605 additions and 244 deletions

View file

@ -13,3 +13,5 @@ export const SLO_RESOURCES_VERSION = 1;
export const getSLODestinationIndexName = (spaceId: string) =>
`${SLO_INDEX_TEMPLATE_NAME}-v${SLO_RESOURCES_VERSION}-${spaceId}`;
export const getSLOTransformId = (sloId: string) => `slo-${sloId}`;

View file

@ -0,0 +1,17 @@
/*
* 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.
*/
/* eslint-disable max-classes-per-file */
export class ObservabilityError extends Error {
constructor(message?: string) {
super(message);
this.name = this.constructor.name;
}
}
export class SLONotFound extends ObservabilityError {}

View file

@ -0,0 +1,16 @@
/*
* 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 { ObservabilityError, SLONotFound } from './errors';
export function getHTTPResponseCode(error: ObservabilityError): number {
if (error instanceof SLONotFound) {
return 404;
}
return 400;
}

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 './errors';
export * from './handler';

View file

@ -17,6 +17,7 @@ import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server';
import { SpacesServiceStart } from '@kbn/spaces-plugin/server';
import { ObservabilityRequestHandlerContext } from '../types';
import { AbstractObservabilityServerRouteRepository } from './types';
import { getHTTPResponseCode, ObservabilityError } from '../errors';
export function registerRoutes({
repository,
@ -71,6 +72,24 @@ export function registerRoutes({
return response.ok({ body: data });
} catch (error) {
if (error instanceof ObservabilityError) {
logger.error(error.message);
return response.customError({
statusCode: getHTTPResponseCode(error),
body: {
message: error.message,
},
});
}
if (Boom.isBoom(error)) {
logger.error(error.output.payload.message);
return response.customError({
statusCode: error.output.statusCode,
body: { message: error.output.payload.message },
});
}
logger.error(error);
const opts = {
statusCode: 500,
@ -79,16 +98,12 @@ export function registerRoutes({
},
};
if (Boom.isBoom(error)) {
opts.statusCode = error.output.statusCode;
}
if (error instanceof errors.RequestAbortedError) {
opts.statusCode = 499;
opts.body.message = 'Client closed request';
}
return response.custom(opts);
return response.customError(opts);
}
}
);

View file

@ -7,8 +7,9 @@
import {
CreateSLO,
DeleteSLO,
DefaultResourceInstaller,
DefaultTransformInstaller,
DefaultTransformManager,
KibanaSavedObjectsSLORepository,
} from '../../services/slo';
import {
@ -17,7 +18,7 @@ import {
TransformGenerator,
} from '../../services/slo/transform_generators';
import { SLITypes } from '../../types/models';
import { createSLOParamsSchema } from '../../types/schema';
import { createSLOParamsSchema, deleteSLOParamsSchema } from '../../types/schema';
import { createObservabilityServerRoute } from '../create_observability_server_route';
const transformGenerators: Record<SLITypes, TransformGenerator> = {
@ -36,10 +37,15 @@ const createSLORoute = createObservabilityServerRoute({
const soClient = (await context.core).savedObjects.client;
const spaceId = spacesService.getSpaceId(request);
const resourceInstaller = new DefaultResourceInstaller(esClient, logger);
const resourceInstaller = new DefaultResourceInstaller(esClient, logger, spaceId);
const repository = new KibanaSavedObjectsSLORepository(soClient);
const transformInstaller = new DefaultTransformInstaller(transformGenerators, esClient, logger);
const createSLO = new CreateSLO(resourceInstaller, repository, transformInstaller, spaceId);
const transformManager = new DefaultTransformManager(
transformGenerators,
esClient,
logger,
spaceId
);
const createSLO = new CreateSLO(resourceInstaller, repository, transformManager);
const response = await createSLO.execute(params.body);
@ -47,4 +53,29 @@ const createSLORoute = createObservabilityServerRoute({
},
});
export const slosRouteRepository = createSLORoute;
const deleteSLORoute = createObservabilityServerRoute({
endpoint: 'DELETE /api/observability/slos/{id}',
options: {
tags: [],
},
params: deleteSLOParamsSchema,
handler: async ({ context, request, params, logger, spacesService }) => {
const esClient = (await context.core).elasticsearch.client.asCurrentUser;
const soClient = (await context.core).savedObjects.client;
const spaceId = spacesService.getSpaceId(request);
const repository = new KibanaSavedObjectsSLORepository(soClient);
const transformManager = new DefaultTransformManager(
transformGenerators,
esClient,
logger,
spaceId
);
const deleteSLO = new DeleteSLO(repository, transformManager, esClient);
await deleteSLO.execute(params.path.id);
},
});
export const slosRouteRepository = { ...createSLORoute, ...deleteSLORoute };

View file

@ -10,57 +10,60 @@ import { createAPMTransactionErrorRateIndicator, createSLOParams } from './fixtu
import {
createResourceInstallerMock,
createSLORepositoryMock,
createTransformInstallerMock,
createTransformManagerMock,
} from './mocks';
import { ResourceInstaller } from './resource_installer';
import { SLORepository } from './slo_repository';
import { TransformInstaller } from './transform_installer';
import { TransformManager } from './transform_manager';
const SPACE_ID = 'some-space-id';
describe('createSLO', () => {
describe('CreateSLO', () => {
let mockResourceInstaller: jest.Mocked<ResourceInstaller>;
let mockRepository: jest.Mocked<SLORepository>;
let mockTransformInstaller: jest.Mocked<TransformInstaller>;
let mockTransformManager: jest.Mocked<TransformManager>;
let createSLO: CreateSLO;
beforeEach(() => {
mockResourceInstaller = createResourceInstallerMock();
mockRepository = createSLORepositoryMock();
mockTransformInstaller = createTransformInstallerMock();
createSLO = new CreateSLO(
mockResourceInstaller,
mockRepository,
mockTransformInstaller,
SPACE_ID
);
mockTransformManager = createTransformManagerMock();
createSLO = new CreateSLO(mockResourceInstaller, mockRepository, mockTransformManager);
});
describe('happy path', () => {
it('calls the expected services', async () => {
const sloParams = createSLOParams(createAPMTransactionErrorRateIndicator());
mockTransformManager.install.mockResolvedValue('slo-transform-id');
const response = await createSLO.execute(sloParams);
expect(mockResourceInstaller.ensureCommonResourcesInstalled).toHaveBeenCalledWith(SPACE_ID);
expect(mockResourceInstaller.ensureCommonResourcesInstalled).toHaveBeenCalled();
expect(mockRepository.save).toHaveBeenCalledWith(
expect.objectContaining({ ...sloParams, id: expect.any(String) })
);
expect(mockTransformInstaller.installAndStartTransform).toHaveBeenCalledWith(
expect.objectContaining({ ...sloParams, id: expect.any(String) }),
SPACE_ID
expect(mockTransformManager.install).toHaveBeenCalledWith(
expect.objectContaining({ ...sloParams, id: expect.any(String) })
);
expect(mockTransformManager.start).toHaveBeenCalledWith('slo-transform-id');
expect(response).toEqual(expect.objectContaining({ id: expect.any(String) }));
});
});
describe('unhappy path', () => {
it('deletes the SLO saved objects when transform installation fails', async () => {
mockTransformInstaller.installAndStartTransform.mockRejectedValue(
new Error('Transform Error')
);
it('deletes the SLO when transform installation fails', async () => {
mockTransformManager.install.mockRejectedValue(new Error('Transform install error'));
const sloParams = createSLOParams(createAPMTransactionErrorRateIndicator());
await expect(createSLO.execute(sloParams)).rejects.toThrowError('Transform Error');
await expect(createSLO.execute(sloParams)).rejects.toThrowError('Transform install error');
expect(mockRepository.deleteById).toBeCalled();
});
it('removes the transform and deletes the SLO when transform start fails', async () => {
mockTransformManager.install.mockResolvedValue('slo-transform-id');
mockTransformManager.start.mockRejectedValue(new Error('Transform start error'));
const sloParams = createSLOParams(createAPMTransactionErrorRateIndicator());
await expect(createSLO.execute(sloParams)).rejects.toThrowError('Transform start error');
expect(mockTransformManager.uninstall).toBeCalledWith('slo-transform-id');
expect(mockRepository.deleteById).toBeCalled();
});
});

View file

@ -10,31 +10,41 @@ import uuid from 'uuid';
import { SLO } from '../../types/models';
import { ResourceInstaller } from './resource_installer';
import { SLORepository } from './slo_repository';
import { TransformInstaller } from './transform_installer';
import { TransformManager } from './transform_manager';
import { CreateSLOParams, CreateSLOResponse } from '../../types/schema';
export class CreateSLO {
constructor(
private resourceInstaller: ResourceInstaller,
private repository: SLORepository,
private transformInstaller: TransformInstaller,
private spaceId: string
private transformManager: TransformManager
) {}
public async execute(sloParams: CreateSLOParams): Promise<CreateSLOResponse> {
const slo = this.toSLO(sloParams);
await this.resourceInstaller.ensureCommonResourcesInstalled(this.spaceId);
await this.resourceInstaller.ensureCommonResourcesInstalled();
await this.repository.save(slo);
let sloTransformId;
try {
await this.transformInstaller.installAndStartTransform(slo, this.spaceId);
sloTransformId = await this.transformManager.install(slo);
} catch (err) {
await this.repository.deleteById(slo.id);
throw err;
}
try {
await this.transformManager.start(sloTransformId);
} catch (err) {
await Promise.all([
this.transformManager.uninstall(sloTransformId),
this.repository.deleteById(slo.id),
]);
throw err;
}
return this.toResponse(slo);
}

View file

@ -0,0 +1,51 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core/server';
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import { getSLOTransformId } from '../../assets/constants';
import { DeleteSLO } from './delete_slo';
import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo';
import { createSLORepositoryMock, createTransformManagerMock } from './mocks';
import { SLORepository } from './slo_repository';
import { TransformManager } from './transform_manager';
describe('DeleteSLO', () => {
let mockRepository: jest.Mocked<SLORepository>;
let mockTransformManager: jest.Mocked<TransformManager>;
let mockEsClient: jest.Mocked<ElasticsearchClient>;
let deleteSLO: DeleteSLO;
beforeEach(() => {
mockRepository = createSLORepositoryMock();
mockTransformManager = createTransformManagerMock();
mockEsClient = elasticsearchServiceMock.createElasticsearchClient();
deleteSLO = new DeleteSLO(mockRepository, mockTransformManager, mockEsClient);
});
describe('happy path', () => {
it('removes the transform, the roll up data and the SLO from the repository', async () => {
const slo = createSLO(createAPMTransactionErrorRateIndicator());
mockRepository.findById.mockResolvedValueOnce(slo);
await deleteSLO.execute(slo.id);
expect(mockTransformManager.stop).toHaveBeenCalledWith(getSLOTransformId(slo.id));
expect(mockTransformManager.uninstall).toHaveBeenCalledWith(getSLOTransformId(slo.id));
expect(mockEsClient.deleteByQuery).toHaveBeenCalledWith(
expect.objectContaining({
query: {
match: {
'slo.id': slo.id,
},
},
})
);
expect(mockRepository.deleteById).toHaveBeenCalledWith(slo.id);
});
});
});

View file

@ -0,0 +1,43 @@
/*
* 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 { ElasticsearchClient } from '@kbn/core/server';
import { getSLOTransformId, SLO_INDEX_TEMPLATE_NAME } from '../../assets/constants';
import { SLO } from '../../types/models';
import { SLORepository } from './slo_repository';
import { TransformManager } from './transform_manager';
export class DeleteSLO {
constructor(
private repository: SLORepository,
private transformManager: TransformManager,
private esClient: ElasticsearchClient
) {}
public async execute(sloId: string): Promise<void> {
const slo = await this.repository.findById(sloId);
const sloTransformId = getSLOTransformId(sloId);
await this.transformManager.stop(sloTransformId);
await this.transformManager.uninstall(sloTransformId);
await this.deleteRollupData(slo);
await this.repository.deleteById(sloId);
}
private async deleteRollupData(slo: SLO): Promise<void> {
await this.esClient.deleteByQuery({
index: slo.settings.destination_index ?? `${SLO_INDEX_TEMPLATE_NAME}*`,
wait_for_completion: false,
query: {
match: {
'slo.id': slo.id,
},
},
});
}
}

View file

@ -7,5 +7,6 @@
export * from './resource_installer';
export * from './slo_repository';
export * from './transform_installer';
export * from './transform_manager';
export * from './create_slo';
export * from './delete_slo';

View file

@ -7,7 +7,7 @@
import { ResourceInstaller } from '../resource_installer';
import { SLORepository } from '../slo_repository';
import { TransformInstaller } from '../transform_installer';
import { TransformManager } from '../transform_manager';
const createResourceInstallerMock = (): jest.Mocked<ResourceInstaller> => {
return {
@ -15,9 +15,12 @@ const createResourceInstallerMock = (): jest.Mocked<ResourceInstaller> => {
};
};
const createTransformInstallerMock = (): jest.Mocked<TransformInstaller> => {
const createTransformManagerMock = (): jest.Mocked<TransformManager> => {
return {
installAndStartTransform: jest.fn(),
install: jest.fn(),
uninstall: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
};
};
@ -29,4 +32,4 @@ const createSLORepositoryMock = (): jest.Mocked<SLORepository> => {
};
};
export { createResourceInstallerMock, createTransformInstallerMock, createSLORepositoryMock };
export { createResourceInstallerMock, createTransformManagerMock, createSLORepositoryMock };

View file

@ -22,7 +22,11 @@ describe('resourceInstaller', () => {
it('installs the common resources', async () => {
const mockClusterClient = elasticsearchServiceMock.createElasticsearchClient();
mockClusterClient.indices.existsIndexTemplate.mockResponseOnce(false);
const installer = new DefaultResourceInstaller(mockClusterClient, loggerMock.create());
const installer = new DefaultResourceInstaller(
mockClusterClient,
loggerMock.create(),
'space-id'
);
await installer.ensureCommonResourcesInstalled();
@ -51,7 +55,11 @@ describe('resourceInstaller', () => {
mockClusterClient.ingest.getPipeline.mockResponseOnce({
[SLO_INGEST_PIPELINE_NAME]: { _meta: { version: SLO_RESOURCES_VERSION } },
} as IngestGetPipelineResponse);
const installer = new DefaultResourceInstaller(mockClusterClient, loggerMock.create());
const installer = new DefaultResourceInstaller(
mockClusterClient,
loggerMock.create(),
'space-id'
);
await installer.ensureCommonResourcesInstalled();

View file

@ -25,13 +25,17 @@ import { getSLOIndexTemplate } from '../../assets/index_templates/slo_index_temp
import { getSLOPipelineTemplate } from '../../assets/ingest_templates/slo_pipeline_template';
export interface ResourceInstaller {
ensureCommonResourcesInstalled(spaceId: string): Promise<void>;
ensureCommonResourcesInstalled(): Promise<void>;
}
export class DefaultResourceInstaller implements ResourceInstaller {
constructor(private esClient: ElasticsearchClient, private logger: Logger) {}
constructor(
private esClient: ElasticsearchClient,
private logger: Logger,
private spaceId: string
) {}
public async ensureCommonResourcesInstalled(spaceId: string = 'default'): Promise<void> {
public async ensureCommonResourcesInstalled(): Promise<void> {
const alreadyInstalled = await this.areResourcesAlreadyInstalled();
if (alreadyInstalled) {
@ -61,7 +65,7 @@ export class DefaultResourceInstaller implements ResourceInstaller {
await this.createOrUpdateIngestPipelineTemplate(
getSLOPipelineTemplate(
SLO_INGEST_PIPELINE_NAME,
this.getPipelinePrefix(SLO_RESOURCES_VERSION, spaceId)
this.getPipelinePrefix(SLO_RESOURCES_VERSION, this.spaceId)
)
);
} catch (err) {

View file

@ -6,24 +6,16 @@
*/
import { SavedObject } from '@kbn/core-saved-objects-common';
import { SavedObjectsClientContract } from '@kbn/core/server';
import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from '@kbn/core/server';
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
import { SLO, StoredSLO } from '../../types/models';
import { SO_SLO_TYPE } from '../../saved_objects';
import { KibanaSavedObjectsSLORepository } from './slo_repository';
import { createSLO } from './fixtures/slo';
import { createAPMTransactionDurationIndicator, createSLO } from './fixtures/slo';
import { SLONotFound } from '../../errors';
const anSLO = createSLO({
type: 'slo.apm.transaction_duration',
params: {
environment: 'irrelevant',
service: 'irrelevant',
transaction_type: 'irrelevant',
transaction_name: 'irrelevant',
'threshold.us': 200000,
},
});
const SOME_SLO = createSLO(createAPMTransactionDurationIndicator());
function aStoredSLO(slo: SLO): SavedObject<StoredSLO> {
return {
@ -45,38 +37,61 @@ describe('KibanaSavedObjectsSLORepository', () => {
soClientMock = savedObjectsClientMock.create();
});
describe('validation', () => {
it('findById throws when an SLO is not found', async () => {
soClientMock.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError());
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
await expect(repository.findById('inexistant-slo-id')).rejects.toThrowError(
new SLONotFound('SLO [inexistant-slo-id] not found')
);
});
it('deleteById throws when an SLO is not found', async () => {
soClientMock.delete.mockRejectedValueOnce(
SavedObjectsErrorHelpers.createGenericNotFoundError()
);
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
await expect(repository.deleteById('inexistant-slo-id')).rejects.toThrowError(
new SLONotFound('SLO [inexistant-slo-id] not found')
);
});
});
it('saves the SLO', async () => {
soClientMock.create.mockResolvedValueOnce(aStoredSLO(anSLO));
soClientMock.create.mockResolvedValueOnce(aStoredSLO(SOME_SLO));
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
const savedSLO = await repository.save(anSLO);
const savedSLO = await repository.save(SOME_SLO);
expect(savedSLO).toEqual(anSLO);
expect(savedSLO).toEqual(SOME_SLO);
expect(soClientMock.create).toHaveBeenCalledWith(
SO_SLO_TYPE,
expect.objectContaining({
...anSLO,
...SOME_SLO,
updated_at: expect.anything(),
created_at: expect.anything(),
})
}),
{ id: SOME_SLO.id }
);
});
it('finds an existing SLO', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
soClientMock.get.mockResolvedValueOnce(aStoredSLO(anSLO));
soClientMock.get.mockResolvedValueOnce(aStoredSLO(SOME_SLO));
const foundSLO = await repository.findById(anSLO.id);
const foundSLO = await repository.findById(SOME_SLO.id);
expect(foundSLO).toEqual(anSLO);
expect(soClientMock.get).toHaveBeenCalledWith(SO_SLO_TYPE, anSLO.id);
expect(foundSLO).toEqual(SOME_SLO);
expect(soClientMock.get).toHaveBeenCalledWith(SO_SLO_TYPE, SOME_SLO.id);
});
it('removes an SLO', async () => {
it('deletes an SLO', async () => {
const repository = new KibanaSavedObjectsSLORepository(soClientMock);
await repository.deleteById(anSLO.id);
await repository.deleteById(SOME_SLO.id);
expect(soClientMock.delete).toHaveBeenCalledWith(SO_SLO_TYPE, anSLO.id);
expect(soClientMock.delete).toHaveBeenCalledWith(SO_SLO_TYPE, SOME_SLO.id);
});
});

View file

@ -6,9 +6,11 @@
*/
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
import { StoredSLO, SLO } from '../../types/models';
import { SO_SLO_TYPE } from '../../saved_objects';
import { SLONotFound } from '../../errors';
export interface SLORepository {
save(slo: SLO): Promise<SLO>;
@ -21,22 +23,40 @@ export class KibanaSavedObjectsSLORepository implements SLORepository {
async save(slo: SLO): Promise<SLO> {
const now = new Date().toISOString();
const savedSLO = await this.soClient.create<StoredSLO>(SO_SLO_TYPE, {
...slo,
created_at: now,
updated_at: now,
});
const savedSLO = await this.soClient.create<StoredSLO>(
SO_SLO_TYPE,
{
...slo,
created_at: now,
updated_at: now,
},
{ id: slo.id }
);
return toSLOModel(savedSLO.attributes);
}
async findById(id: string): Promise<SLO> {
const slo = await this.soClient.get<StoredSLO>(SO_SLO_TYPE, id);
return toSLOModel(slo.attributes);
try {
const slo = await this.soClient.get<StoredSLO>(SO_SLO_TYPE, id);
return toSLOModel(slo.attributes);
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
throw new SLONotFound(`SLO [${id}] not found`);
}
throw err;
}
}
async deleteById(id: string): Promise<void> {
await this.soClient.delete(SO_SLO_TYPE, id);
try {
await this.soClient.delete(SO_SLO_TYPE, id);
} catch (err) {
if (SavedObjectsErrorHelpers.isNotFoundError(err)) {
throw new SLONotFound(`SLO [${id}] not found`);
}
throw err;
}
}
}

View file

@ -10,7 +10,11 @@ import {
MappingRuntimeFieldType,
TransformPutTransformRequest,
} from '@elastic/elasticsearch/lib/api/types';
import { getSLODestinationIndexName, SLO_INGEST_PIPELINE_NAME } from '../../../assets/constants';
import {
getSLODestinationIndexName,
getSLOTransformId,
SLO_INGEST_PIPELINE_NAME,
} from '../../../assets/constants';
import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template';
import {
SLO,
@ -38,7 +42,7 @@ export class ApmTransactionDurationTransformGenerator implements TransformGenera
}
private buildTransformId(slo: APMTransactionDurationSLO): string {
return `slo-${slo.id}`;
return getSLOTransformId(slo.id);
}
private buildSource(slo: APMTransactionDurationSLO) {

View file

@ -12,7 +12,11 @@ import {
} from '@elastic/elasticsearch/lib/api/types';
import { getSLOTransformTemplate } from '../../../assets/transform_templates/slo_transform_template';
import { TransformGenerator } from '.';
import { getSLODestinationIndexName, SLO_INGEST_PIPELINE_NAME } from '../../../assets/constants';
import {
getSLODestinationIndexName,
getSLOTransformId,
SLO_INGEST_PIPELINE_NAME,
} from '../../../assets/constants';
import {
apmTransactionErrorRateSLOSchema,
APMTransactionErrorRateSLO,
@ -40,7 +44,7 @@ export class ApmTransactionErrorRateTransformGenerator implements TransformGener
}
private buildTransformId(slo: APMTransactionErrorRateSLO): string {
return `slo-${slo.id}`;
return getSLOTransformId(slo.id);
}
private buildSource(slo: APMTransactionErrorRateSLO) {

View file

@ -1,102 +0,0 @@
/*
* 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.
*/
/* eslint-disable max-classes-per-file */
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { ElasticsearchClient } from '@kbn/core/server';
import { MockedLogger } from '@kbn/logging-mocks';
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import { DefaultTransformInstaller } from './transform_installer';
import {
ApmTransactionErrorRateTransformGenerator,
TransformGenerator,
} from './transform_generators';
import { SLO, SLITypes } from '../../types/models';
import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo';
describe('TransformerGenerator', () => {
let esClientMock: jest.Mocked<ElasticsearchClient>;
let loggerMock: jest.Mocked<MockedLogger>;
beforeEach(() => {
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
loggerMock = loggingSystemMock.createLogger();
});
describe('Unhappy path', () => {
it('throws when no generator exists for the slo indicator type', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_duration': new DummyTransformGenerator(),
};
const service = new DefaultTransformInstaller(generators, esClientMock, loggerMock);
await expect(
service.installAndStartTransform(
createSLO({
type: 'slo.apm.transaction_error_rate',
params: {
environment: 'irrelevant',
service: 'irrelevant',
transaction_name: 'irrelevant',
transaction_type: 'irrelevant',
},
})
)
).rejects.toThrowError('Unsupported SLO type: slo.apm.transaction_error_rate');
});
it('throws when transform generator fails', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_duration': new FailTransformGenerator(),
};
const service = new DefaultTransformInstaller(generators, esClientMock, loggerMock);
await expect(
service.installAndStartTransform(
createSLO({
type: 'slo.apm.transaction_duration',
params: {
environment: 'irrelevant',
service: 'irrelevant',
transaction_name: 'irrelevant',
transaction_type: 'irrelevant',
'threshold.us': 250000,
},
})
)
).rejects.toThrowError('Some error');
});
});
it('installs and starts the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
};
const service = new DefaultTransformInstaller(generators, esClientMock, loggerMock);
await service.installAndStartTransform(createSLO(createAPMTransactionErrorRateIndicator()));
expect(esClientMock.transform.putTransform).toHaveBeenCalledTimes(1);
expect(esClientMock.transform.startTransform).toHaveBeenCalledTimes(1);
});
});
class DummyTransformGenerator implements TransformGenerator {
getTransformParams(slo: SLO): TransformPutTransformRequest {
return {} as TransformPutTransformRequest;
}
}
class FailTransformGenerator implements TransformGenerator {
getTransformParams(slo: SLO): TransformPutTransformRequest {
throw new Error('Some error');
}
}

View file

@ -1,56 +0,0 @@
/*
* 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 { errors } from '@elastic/elasticsearch';
import { ElasticsearchClient, Logger } from '@kbn/core/server';
import { SLO, SLITypes } from '../../types/models';
import { TransformGenerator } from './transform_generators';
export interface TransformInstaller {
installAndStartTransform(slo: SLO, spaceId: string): Promise<void>;
}
export class DefaultTransformInstaller implements TransformInstaller {
constructor(
private generators: Record<SLITypes, TransformGenerator>,
private esClient: ElasticsearchClient,
private logger: Logger
) {}
async installAndStartTransform(slo: SLO, spaceId: string = 'default'): Promise<void> {
const generator = this.generators[slo.indicator.type];
if (!generator) {
this.logger.error(`No transform generator found for ${slo.indicator.type} SLO type`);
throw new Error(`Unsupported SLO type: ${slo.indicator.type}`);
}
const transformParams = generator.getTransformParams(slo, spaceId);
try {
await this.esClient.transform.putTransform(transformParams);
} catch (err) {
// swallow the error if the transform already exists.
const isAlreadyExistError =
err instanceof errors.ResponseError &&
err?.body?.error?.type === 'resource_already_exists_exception';
if (!isAlreadyExistError) {
this.logger.error(`Cannot create transform for ${slo.indicator.type} SLO type: ${err}`);
throw err;
}
}
try {
await this.esClient.transform.startTransform(
{ transform_id: transformParams.transform_id },
{ ignore: [409] }
);
} catch (err) {
this.logger.error(`Cannot start transform id ${transformParams.transform_id}: ${err}`);
throw err;
}
}
}

View file

@ -0,0 +1,174 @@
/*
* 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.
*/
/* eslint-disable max-classes-per-file */
import { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { ElasticsearchClient } from '@kbn/core/server';
import { MockedLogger } from '@kbn/logging-mocks';
import { TransformPutTransformRequest } from '@elastic/elasticsearch/lib/api/types';
import { DefaultTransformManager } from './transform_manager';
import {
ApmTransactionErrorRateTransformGenerator,
TransformGenerator,
} from './transform_generators';
import { SLO, SLITypes } from '../../types/models';
import { createAPMTransactionErrorRateIndicator, createSLO } from './fixtures/slo';
const SPACE_ID = 'space-id';
describe('TransformManager', () => {
let esClientMock: jest.Mocked<ElasticsearchClient>;
let loggerMock: jest.Mocked<MockedLogger>;
beforeEach(() => {
esClientMock = elasticsearchServiceMock.createElasticsearchClient();
loggerMock = loggingSystemMock.createLogger();
});
describe('Install', () => {
describe('Unhappy path', () => {
it('throws when no generator exists for the slo indicator type', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_duration': new DummyTransformGenerator(),
};
const service = new DefaultTransformManager(generators, esClientMock, loggerMock, SPACE_ID);
await expect(
service.install(
createSLO({
type: 'slo.apm.transaction_error_rate',
params: {
environment: 'irrelevant',
service: 'irrelevant',
transaction_name: 'irrelevant',
transaction_type: 'irrelevant',
},
})
)
).rejects.toThrowError('Unsupported SLO type: slo.apm.transaction_error_rate');
});
it('throws when transform generator fails', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_duration': new FailTransformGenerator(),
};
const transformManager = new DefaultTransformManager(
generators,
esClientMock,
loggerMock,
SPACE_ID
);
await expect(
transformManager.install(
createSLO({
type: 'slo.apm.transaction_duration',
params: {
environment: 'irrelevant',
service: 'irrelevant',
transaction_name: 'irrelevant',
transaction_type: 'irrelevant',
'threshold.us': 250000,
},
})
)
).rejects.toThrowError('Some error');
});
});
it('installs the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(
generators,
esClientMock,
loggerMock,
SPACE_ID
);
const slo = createSLO(createAPMTransactionErrorRateIndicator());
const transformId = await transformManager.install(slo);
expect(esClientMock.transform.putTransform).toHaveBeenCalledTimes(1);
expect(transformId).toBe(`slo-${slo.id}`);
});
});
describe('Start', () => {
it('starts the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(
generators,
esClientMock,
loggerMock,
SPACE_ID
);
await transformManager.start('slo-transform-id');
expect(esClientMock.transform.startTransform).toHaveBeenCalledTimes(1);
});
});
describe('Stop', () => {
it('stops the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(
generators,
esClientMock,
loggerMock,
SPACE_ID
);
await transformManager.stop('slo-transform-id');
expect(esClientMock.transform.stopTransform).toHaveBeenCalledTimes(1);
});
});
describe('Uninstall', () => {
it('uninstalls the transform', async () => {
// @ts-ignore defining only a subset of the possible SLI
const generators: Record<SLITypes, TransformGenerator> = {
'slo.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
};
const transformManager = new DefaultTransformManager(
generators,
esClientMock,
loggerMock,
SPACE_ID
);
await transformManager.uninstall('slo-transform-id');
expect(esClientMock.transform.deleteTransform).toHaveBeenCalledTimes(1);
});
});
});
class DummyTransformGenerator implements TransformGenerator {
getTransformParams(slo: SLO): TransformPutTransformRequest {
return {} as TransformPutTransformRequest;
}
}
class FailTransformGenerator implements TransformGenerator {
getTransformParams(slo: SLO): TransformPutTransformRequest {
throw new Error('Some error');
}
}

View file

@ -0,0 +1,83 @@
/*
* 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 { ElasticsearchClient, Logger } from '@kbn/core/server';
import { SLO, SLITypes } from '../../types/models';
import { TransformGenerator } from './transform_generators';
type TransformId = string;
export interface TransformManager {
install(slo: SLO): Promise<TransformId>;
start(transformId: TransformId): Promise<void>;
stop(transformId: TransformId): Promise<void>;
uninstall(transformId: TransformId): Promise<void>;
}
export class DefaultTransformManager implements TransformManager {
constructor(
private generators: Record<SLITypes, TransformGenerator>,
private esClient: ElasticsearchClient,
private logger: Logger,
private spaceId: string
) {}
async install(slo: SLO): Promise<TransformId> {
const generator = this.generators[slo.indicator.type];
if (!generator) {
this.logger.error(`No transform generator found for ${slo.indicator.type} SLO type`);
throw new Error(`Unsupported SLO type: ${slo.indicator.type}`);
}
const transformParams = generator.getTransformParams(slo, this.spaceId);
try {
await this.esClient.transform.putTransform(transformParams);
} catch (err) {
this.logger.error(`Cannot create transform for ${slo.indicator.type} SLO type: ${err}`);
throw err;
}
return transformParams.transform_id;
}
async start(transformId: TransformId): Promise<void> {
try {
await this.esClient.transform.startTransform(
{ transform_id: transformId },
{ ignore: [409] }
);
} catch (err) {
this.logger.error(`Cannot start transform id ${transformId}: ${err}`);
throw err;
}
}
async stop(transformId: TransformId): Promise<void> {
try {
await this.esClient.transform.stopTransform(
{ transform_id: transformId, wait_for_completion: true },
{ ignore: [404] }
);
} catch (err) {
this.logger.error(`Cannot stop transform id ${transformId}: ${err}`);
throw err;
}
}
async uninstall(transformId: TransformId): Promise<void> {
try {
await this.esClient.transform.deleteTransform(
{ transform_id: transformId, force: true },
{ ignore: [404] }
);
} catch (err) {
this.logger.error(`Cannot delete transform id ${transformId}: ${err}`);
throw err;
}
}
}

View file

@ -85,3 +85,9 @@ export type CreateSLOResponse = t.TypeOf<typeof createSLOResponseSchema>;
export const createSLOParamsSchema = t.type({
body: createSLOBodySchema,
});
export const deleteSLOParamsSchema = t.type({
path: t.type({
id: t.string,
}),
});