Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ResponseOps][MW] Public PATCH maintenance window #213694

Draft
wants to merge 1 commit into
base: mw-public-api
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* 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 { updateMaintenanceWindowRequestBodySchema } from './schemas/latest';
export type {
UpdateMaintenanceWindowRequestBody,
UpdateMaintenanceWindowRequestParams,
UpdateMaintenanceWindowResponse,
} from './types/latest';

export {
updateMaintenanceWindowRequestBodySchema as updateMaintenanceWindowRequestBodySchemaV1,
updateMaintenanceWindowRequestParamsSchema as updateMaintenanceWindowRequestParamsSchemaV1,
} from './schemas/v1';

export type {
UpdateMaintenanceWindowRequestParams as UpdateMaintenanceWindowRequestParamsV1,
UpdateMaintenanceWindowRequestBody as UpdateMaintenanceWindowRequestBodyV1,
UpdateMaintenanceWindowResponse as UpdateMaintenanceWindowResponseV1,
} from './types/v1';
Original file line number Diff line number Diff line change
@@ -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 { updateMaintenanceWindowRequestBodySchema } from './v1';
export { updateMaintenanceWindowRequestParamsSchema } from './v1';
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 { schema } from '@kbn/config-schema';

// TODO schedule schema
const scheduleSchema = schema.object({
duration: schema.number(),
start: schema.string(),
recurring: schema.maybe(
schema.object({
end: schema.maybe(schema.string()),
every: schema.maybe(schema.string()),
onWeekDay: schema.maybe(schema.arrayOf(schema.string())),
onMonthDay: schema.maybe(schema.arrayOf(schema.number())),
onMonth: schema.maybe(schema.arrayOf(schema.string())),
occurrences: schema.maybe(schema.number()),
})
),
});

export const updateMaintenanceWindowRequestBodySchema = schema.object({
title: schema.maybe(
schema.string({
meta: {
description:
'The name of the maintenance window. While this name does not have to be unique, a distinctive name can help you identify a specific maintenance window.',
},
})
),
enabled: schema.maybe(
schema.boolean({
meta: {
description:
'Whether the current maintenance window is enabled. Disabled maintenance windows do not suppress notifications.',
},
defaultValue: true,
})
),
scope: schema.maybe(
schema.object({
query: schema.object({
kql: schema.string({
meta: { description: 'A filter written in Kibana Query Language (KQL).' },
}),
}),
})
),
schedule: schema.maybe(schema.object({ custom: schema.maybe(scheduleSchema) })),
});

export const updateMaintenanceWindowRequestParamsSchema = schema.object({
id: schema.string(),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* 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 type {
UpdateMaintenanceWindowRequestParams,
UpdateMaintenanceWindowRequestBody,
UpdateMaintenanceWindowResponse,
} from './v1';
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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 type { TypeOf } from '@kbn/config-schema';
import { MaintenanceWindowResponseV1 } from '../../../response';
import {
updateMaintenanceWindowRequestBodySchemaV1,
updateMaintenanceWindowRequestParamsSchemaV1,
} from '..';

export type UpdateMaintenanceWindowRequestParams = TypeOf<
typeof updateMaintenanceWindowRequestParamsSchemaV1
>;
export type UpdateMaintenanceWindowRequestBody = TypeOf<
typeof updateMaintenanceWindowRequestBodySchemaV1
>;
export type UpdateMaintenanceWindowResponse = MaintenanceWindowResponseV1;
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { bulkGetMaintenanceWindowRoute as bulkGetMaintenanceWindowRouteInternal
import { getMaintenanceWindowRoute } from './maintenance_window/apis/external/get/get_maintenance_window_route';
import { createMaintenanceWindowRoute } from './maintenance_window/apis/external/create/create_maintenance_window_route';
import { deleteMaintenanceWindowRoute } from './maintenance_window/apis/external/delete/delete_maintenance_window_route';
import { updateMaintenanceWindowRoute } from './maintenance_window/apis/external/update/update_maintenance_window_route';

import { registerRulesValueSuggestionsRoute } from './suggestions/values_suggestion_rules';
import { registerFieldsRoute } from './suggestions/fields_rules';
Expand Down Expand Up @@ -154,6 +155,7 @@ export function defineRoutes(opts: RouteOptions) {
getMaintenanceWindowRoute(router, licenseState);
createMaintenanceWindowRoute(router, licenseState);
deleteMaintenanceWindowRoute(router, licenseState);
updateMaintenanceWindowRoute(router, licenseState);

// backfill APIs
scheduleBackfillRoute(router, licenseState);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* 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 { transformUpdateBody } from './latest';

export { transformUpdateBody as transformUpdateBodyV1 } from './v1';
Original file line number Diff line number Diff line change
@@ -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 { transformUpdateBody } from './v1';
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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 { UpdateMaintenanceWindowRequestBodyV1 } from '../../../../../../../common/routes/maintenance_window/external/apis/update';
import { UpdateMaintenanceWindowParams } from '../../../../../../application/maintenance_window/methods/update/types';

/**
* This function converts from the external, human readable, Maintenance Window creation/POST
* type expected by the public APIs, to the internal type used by the client.
*/
export const transformUpdateBody = (
updateBody: UpdateMaintenanceWindowRequestBodyV1
): UpdateMaintenanceWindowParams['data'] => {
// TODO categoryIds
// const kql = updateBody.scope?.query.kql;

// TODO schedule schema
// const customSchedule = updateBody.schedule?.custom;

return {
title: updateBody.title,
enabled: updateBody.enabled,

// TODO categoryIds
// Updating the scope depends on the removal of categoryIds from the client
// See: https://github.com/elastic/kibana/issues/197530
// scopedQuery: kql ? { kql, filters: [] } : null,

// TODO schedule schema
// duration: customSchedule?.duration,
// rRule: {
// dtstart: customSchedule?.start ?? '',
// tzid: 'UTC',
// },
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/*
* 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 { httpServiceMock } from '@kbn/core/server/mocks';
import { licenseStateMock } from '../../../../../lib/license_state.mock';
import { verifyApiAccess } from '../../../../../lib/license_api_access';
import { mockHandlerArguments } from '../../../../_mock_handler_arguments';
import { maintenanceWindowClientMock } from '../../../../../maintenance_window_client.mock';
import { updateMaintenanceWindowRoute } from './update_maintenance_window_route';
import { getMockMaintenanceWindow } from '../../../../../data/maintenance_window/test_helpers';
import { MaintenanceWindowStatus } from '../../../../../../common';
import { MaintenanceWindow } from '../../../../../application/maintenance_window/types';
import {
UpdateMaintenanceWindowRequestBody,
UpdateMaintenanceWindowRequestParams,
} from '../../../../../../common/routes/maintenance_window/external/apis/update';
import { transformMaintenanceWindowToResponseV1 } from '../common/transforms';

const maintenanceWindowClient = maintenanceWindowClientMock.create();

jest.mock('../../../../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));

const mockMaintenanceWindow = {
...getMockMaintenanceWindow(),
eventStartTime: new Date().toISOString(),
eventEndTime: new Date().toISOString(),
status: MaintenanceWindowStatus.Running,
id: 'test-id',
} as MaintenanceWindow;

const updateRequestBody = {
title: 'test-maintenance-window',
enabled: false,

// TODO schedule schema
// schedule: {
// custom: {
// start: '2026-02-07T09:17:06.790Z',
// duration: 60 * 60 * 1000, // 1 hr
// },
// },

// TODO categoryIds
// Updating the scope depends on the removal of categoryIds from the client
// See: https://github.com/elastic/kibana/issues/197530
// scope: { query: { kql: "_id: '1234'" } },
} as UpdateMaintenanceWindowRequestBody;

const updateRequestParams = {
id: 'foo-bar',
} as UpdateMaintenanceWindowRequestParams;

describe('updateMaintenanceWindowRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});

test('should update a maintenance window', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

updateMaintenanceWindowRoute(router, licenseState);

maintenanceWindowClient.update.mockResolvedValueOnce(mockMaintenanceWindow);
const [config, handler] = router.patch.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: updateRequestParams, body: updateRequestBody }
);

expect(config.path).toEqual('/api/alerting/maintenance_window/{id}');
expect(config.options).toMatchInlineSnapshot(`
Object {
"access": "public",
"summary": "Update a maintenance window.",
}
`);

expect(config.security).toMatchInlineSnapshot(`
Object {
"authz": Object {
"requiredPrivileges": Array [
"write-maintenance-window",
],
},
}
`);

await handler(context, req, res);

expect(maintenanceWindowClient.update).toHaveBeenLastCalledWith({
id: 'foo-bar',
data: {
title: 'test-maintenance-window',
enabled: false,

// TODO categoryIds
// scopedQuery: {
// filters: [],
// kql: "_id: '1234'",
// },

// TODO schedule schema
// duration: 60 * 60 * 1000, // 1 hr
// rRule: {
// dtstart: '2026-02-07T09:17:06.790Z',
// tzid: 'UTC',
// },
},
});
expect(res.ok).toHaveBeenLastCalledWith({
body: transformMaintenanceWindowToResponseV1(mockMaintenanceWindow),
});
});

test('ensures the license allows for creating maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

updateMaintenanceWindowRoute(router, licenseState);

maintenanceWindowClient.update.mockResolvedValueOnce(mockMaintenanceWindow);
const [, handler] = router.patch.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: updateRequestParams, body: updateRequestBody }
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});

test('ensures the license check prevents updating maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

updateMaintenanceWindowRoute(router, licenseState);

(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
const [, handler] = router.patch.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: updateRequestParams, body: updateRequestBody }
);
await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});

test('ensures only platinum license can access API', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();

updateMaintenanceWindowRoute(router, licenseState);

(licenseState.ensureLicenseForMaintenanceWindow as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});

const [, handler] = router.patch.mock.calls[0];
const [context, req, res] = mockHandlerArguments({ maintenanceWindowClient }, { body: {} });
await expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});
Loading