[RAM] Window Maintenance Client/Saved Object and Mapping/REST APIs (#153411)

## Summary
Resolves: https://github.com/elastic/kibana/issues/152270
Specs:
https://docs.google.com/document/u/1/d/1-QblF6P19W9o5-10Us3bfgN80GRfjSIhybHrvJS_ObA/edit

This PR implements the following:
- New maintenance window SO
- New maintenance window client in the alerting plugin (generates and
queries maintenance window events, and other CRUD functionality around
the SO)
- New maintenance window REST APIs 
- Kibana privileges for reading/writing maintenance window

This PR does not include integration with task runner, a new PR will be
created to do that work.
 
## APIs:

```
Find all maintenance windows in current space
GET `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/_find`
body: {}
```

```
Create maintenance window:
POST `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window`
body: {
  title: string,
  duration: number,
  r_rule: RRule
}
```

```
Update maintenance window by ID:
POST `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}`,
body: {
  title?: string,
  duration?: number,
  enabled?: boolean,
  r_rule?: RRule,
}
```

```
Get maintenance window by ID:
GET `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}`,
```

```
Delete maintenance window by ID:
DELETE `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}`,
```

```
Archive maintenance window by ID:
POST `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}/_archive`,
body: {
  archive: boolean
}
```

```
Finish maintenance window by ID:
POST `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}/_finish`,
```


## Maintenance window response schema:
```
{
  id: string;
  title: string;
  enabled: boolean;
  duration: number;
  expirationDate: string;
  events: DateRange[];
  rRule: RRuleParams;
  status: 'running' | 'upcoming' | 'finished' | 'archived';
  startDate: string | null;
  endDate: string | null;
  createdBy: string | null;
  updatedBy: string | null;
  createdAt: string;
  updatedAt: string;
}
```

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jiawei Wu 2023-04-13 13:02:28 -07:00 committed by GitHub
parent 83f1fb4f26
commit 3b07f96b44
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
79 changed files with 4898 additions and 135 deletions

View file

@ -112,6 +112,7 @@ describe('checking migration metadata changes on all registered SO types', () =>
"legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8",
"lens": "2f6a8231591e3d62a83506b19e165774d74588ea",
"lens-ui-telemetry": "d6c4e330d170eefc6214dbf77a53de913fa3eebc",
"maintenance-window": "a9777f4e71381c56b4422bf8d30f626bde301c79",
"map": "7902b2e2a550e0b73fd5aa6c4e2ba3a4e6558877",
"metrics-explorer-view": "713dbf1ab5e067791d19170f715eb82cf07ebbcc",
"ml-job": "12e21f1b1adfcc1052dc0b10c7459de875653b94",

View file

@ -79,6 +79,7 @@ const previouslyRegisteredTypes = [
'legacy-url-alias',
'lens',
'lens-ui-telemetry',
'maintenance-window',
'map',
'maps-telemetry',
'metrics-explorer-view',

View file

@ -41,6 +41,8 @@ export * from './rule_notify_when_type';
export * from './parse_duration';
export * from './execution_log_types';
export * from './rule_snooze_type';
export * from './rrule_type';
export * from './maintenance_window';
export * from './default_rule_aggregation';
export * from './rule_tags_aggregation';

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 { Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { RRuleParams } from './rrule_type';
export enum MaintenanceWindowStatus {
Running = 'running',
Upcoming = 'upcoming',
Finished = 'finished',
Archived = 'archived',
}
export interface MaintenanceWindowModificationMetadata {
createdBy: string | null;
updatedBy: string | null;
createdAt: string;
updatedAt: string;
}
export interface DateRange {
gte: string;
lte: string;
}
export interface MaintenanceWindowSOProperties {
title: string;
enabled: boolean;
duration: number;
expirationDate: string;
events: DateRange[];
rRule: RRuleParams;
}
export type MaintenanceWindowSOAttributes = MaintenanceWindowSOProperties &
MaintenanceWindowModificationMetadata;
export type MaintenanceWindow = MaintenanceWindowSOAttributes & {
status: MaintenanceWindowStatus;
eventStartTime: string | null;
eventEndTime: string | null;
id: string;
};
export interface MaintenanceWindowClientContext {
getModificationMetadata: () => Promise<MaintenanceWindowModificationMetadata>;
savedObjectsClient: SavedObjectsClientContract;
logger: Logger;
}
export const MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE = 'maintenance-window';
export const MAINTENANCE_WINDOW_FEATURE_ID = 'maintenanceWindow';
export const MAINTENANCE_WINDOW_API_PRIVILEGES = {
READ_MAINTENANCE_WINDOW: 'read-maintenance-window',
WRITE_MAINTENANCE_WINDOW: 'write-maintenance-window',
};

View file

@ -0,0 +1,30 @@
/*
* 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 { WeekdayStr } from 'rrule';
export type RRuleParams = Partial<RRuleRecord> & Pick<RRuleRecord, 'dtstart' | 'tzid'>;
// An iCal RRULE to define a recurrence schedule, see https://github.com/jakubroztocil/rrule for the spec
export interface RRuleRecord {
dtstart: string;
tzid: string;
freq?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
until?: string;
count?: number;
interval?: number;
wkst?: WeekdayStr;
byweekday?: Array<string | number>;
bymonth?: number[];
bysetpos?: number[];
bymonthday: number[];
byyearday: number[];
byweekno: number[];
byhour: number[];
byminute: number[];
bysecond: number[];
}

View file

@ -5,13 +5,11 @@
* 2.0.
*/
import type { WeekdayStr } from 'rrule';
type SnoozeRRule = Partial<RRuleRecord> & Pick<RRuleRecord, 'dtstart' | 'tzid'>;
import { RRuleParams } from './rrule_type';
export interface RuleSnoozeSchedule {
duration: number;
rRule: SnoozeRRule;
rRule: RRuleParams;
// For scheduled/recurring snoozes, `id` uniquely identifies them so that they can be displayed, modified, and deleted individually
id?: string;
skipRecurrences?: string[];
@ -21,27 +19,7 @@ export interface RuleSnoozeSchedule {
// RuleSnooze = RuleSnoozeSchedule[] throws typescript errors across the whole lib
export type RuleSnooze = Array<{
duration: number;
rRule: SnoozeRRule;
rRule: RRuleParams;
id?: string;
skipRecurrences?: string[];
}>;
// An iCal RRULE to define a recurrence schedule, see https://github.com/jakubroztocil/rrule for the spec
export interface RRuleRecord {
dtstart: string;
tzid: string;
freq?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
until?: string;
count?: number;
interval?: number;
wkst?: WeekdayStr;
byweekday?: Array<string | number>;
bymonth?: number[];
bysetpos?: number[];
bymonthday: number[];
byyearday: number[];
byweekno: number[];
byhour: number[];
byminute: number[];
bysecond: number[];
}

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 './parse_by_weekday';

View file

@ -0,0 +1,13 @@
/*
* 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 { ByWeekday, rrulestr } from 'rrule';
export function parseByWeekday(byweekday: Array<string | number>): ByWeekday[] {
const rRuleString = `RRULE:BYDAY=${byweekday.join(',')}`;
const parsedRRule = rrulestr(rRuleString);
return parsedRRule.origOptions.byweekday as ByWeekday[];
}

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { RRule, ByWeekday, Weekday, rrulestr } from 'rrule';
import { RRule, Weekday } from 'rrule';
import { RuleSnoozeSchedule } from '../../types';
import { parseByWeekday } from '../rrule';
import { utcToLocalUtc, localUtcToUtc } from './timezone_helpers';
const MAX_TIMESTAMP = 8640000000000000;
@ -63,9 +64,3 @@ export function isSnoozeActive(snooze: RuleSnoozeSchedule) {
return null;
}
export function parseByWeekday(byweekday: Array<string | number>): ByWeekday[] {
const rRuleString = `RRULE:BYDAY=${byweekday.join(',')}`;
const parsedRRule = rrulestr(rRuleString);
return parsedRRule.origOptions.byweekday as ByWeekday[];
}

View file

@ -7,7 +7,8 @@
import { RRule, Weekday } from 'rrule';
import { RuleSnoozeSchedule } from '../../types';
import { isSnoozeActive, parseByWeekday } from './is_snooze_active';
import { isSnoozeActive } from './is_snooze_active';
import { parseByWeekday } from '../rrule';
export function isSnoozeExpired(snooze: RuleSnoozeSchedule) {
if (isSnoozeActive(snooze)) {

View file

@ -0,0 +1,31 @@
/*
* 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 { MaintenanceWindowClientApi } from './types';
type Schema = MaintenanceWindowClientApi;
export type MaintenanceWindowClientMock = jest.Mocked<Schema>;
const createMaintenanceWindowClientMock = () => {
const mocked: MaintenanceWindowClientMock = {
create: jest.fn(),
update: jest.fn(),
find: jest.fn(),
get: jest.fn(),
archive: jest.fn(),
getActiveMaintenanceWindows: jest.fn(),
finish: jest.fn(),
delete: jest.fn(),
};
return mocked;
};
export const maintenanceWindowClientMock: {
create: () => MaintenanceWindowClientMock;
} = {
create: createMaintenanceWindowClientMock,
};

View file

@ -0,0 +1,140 @@
/*
* 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 moment from 'moment-timezone';
import { RRule } from 'rrule';
import { generateMaintenanceWindowEvents } from './generate_maintenance_window_events';
describe('generateMaintenanceWindowEvents', () => {
beforeAll(() => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
});
it('should generate events for rrule repeating daily', () => {
// Create rrule with expiration date at: 2023-03-13T00:00:00.000Z
const result = generateMaintenanceWindowEvents({
duration: 1 * 60 * 60 * 1000,
expirationDate: moment(new Date('2023-02-27T00:00:00.000Z'))
.tz('UTC')
.add(2, 'weeks')
.toISOString(),
rRule: {
tzid: 'UTC',
freq: RRule.DAILY,
interval: 1,
dtstart: '2023-02-27T00:00:00.000Z',
},
});
expect(result.length).toEqual(15);
// Ensure start date is correct
expect(result[0].gte).toEqual('2023-02-27T00:00:00.000Z');
expect(result[0].lte).toEqual('2023-02-27T01:00:00.000Z');
// Ensure it is incrementing by the correct frequency (daily)
expect(result[1].gte).toEqual('2023-02-28T00:00:00.000Z');
expect(result[1].lte).toEqual('2023-02-28T01:00:00.000Z');
// Ensure end date is correct
expect(result[result.length - 1].gte).toEqual('2023-03-13T00:00:00.000Z');
expect(result[result.length - 1].lte).toEqual('2023-03-13T01:00:00.000Z');
});
it('should generate events for rrule repeating weekly', () => {
// Create rrule with expiration date at: 2023-04-03T00:00:00.000Z
const result = generateMaintenanceWindowEvents({
duration: 1 * 60 * 60 * 1000,
expirationDate: moment(new Date('2023-02-27T00:00:00.000Z'))
.tz('UTC')
.add(5, 'weeks')
.toISOString(),
rRule: {
tzid: 'UTC',
freq: RRule.WEEKLY,
interval: 1,
dtstart: '2023-02-27T00:00:00.000Z',
},
});
expect(result.length).toEqual(6);
// Ensure start date is correct
expect(result[0].gte).toEqual('2023-02-27T00:00:00.000Z');
expect(result[0].lte).toEqual('2023-02-27T01:00:00.000Z');
// Ensure it is incrementing by the correct frequency (weekly)
expect(result[1].gte).toEqual('2023-03-06T00:00:00.000Z');
expect(result[1].lte).toEqual('2023-03-06T01:00:00.000Z');
// Ensure end date is correct
expect(result[result.length - 1].gte).toEqual('2023-04-03T00:00:00.000Z');
expect(result[result.length - 1].lte).toEqual('2023-04-03T01:00:00.000Z');
});
it('should generate events for rrule repeating monthly', () => {
// Create rrule with expiration date at: 2023-10-27T00:00:00.000Z
const result = generateMaintenanceWindowEvents({
duration: 1 * 60 * 60 * 1000,
expirationDate: moment(new Date('2023-02-27T00:00:00.000Z'))
.tz('UTC')
.add(8, 'months')
.toISOString(),
rRule: {
tzid: 'UTC',
freq: RRule.MONTHLY,
interval: 1,
dtstart: '2023-02-27T00:00:00.000Z',
},
});
expect(result.length).toEqual(9);
// Ensure start date is correct
expect(result[0].gte).toEqual('2023-02-27T00:00:00.000Z');
expect(result[0].lte).toEqual('2023-02-27T01:00:00.000Z');
// Ensure it is incrementing by the correct frequency (monthly)
expect(result[1].gte).toEqual('2023-03-27T00:00:00.000Z');
expect(result[1].lte).toEqual('2023-03-27T01:00:00.000Z');
// Ensure end date is correct
expect(result[result.length - 1].gte).toEqual('2023-10-27T00:00:00.000Z');
expect(result[result.length - 1].lte).toEqual('2023-10-27T01:00:00.000Z');
});
it('should generate events for rrule repeating by specific days', () => {
const result = generateMaintenanceWindowEvents({
duration: 1 * 60 * 60 * 1000,
expirationDate: moment(new Date('2023-02-27T00:00:00.000Z'))
.tz('UTC')
.add(5, 'weeks')
.toISOString(),
rRule: {
tzid: 'UTC',
freq: RRule.WEEKLY,
interval: 1,
byweekday: ['TU', 'TH'],
dtstart: '2023-02-27T00:00:00.000Z',
},
});
expect(result.length).toEqual(10); // 5 weeks x 2 times a week = 10
// Ensure start date is correct (Tuesday)
expect(result[0].gte).toEqual('2023-02-28T00:00:00.000Z');
expect(result[0].lte).toEqual('2023-02-28T01:00:00.000Z');
// Ensure it is incrementing by the correct frequency (Thursday)
expect(result[1].gte).toEqual('2023-03-02T00:00:00.000Z');
expect(result[1].lte).toEqual('2023-03-02T01:00:00.000Z');
// Ensure end date is correct (Thursday)
expect(result[result.length - 1].gte).toEqual('2023-03-30T00:00:00.000Z');
expect(result[result.length - 1].lte).toEqual('2023-03-30T01:00:00.000Z');
});
});

View file

@ -0,0 +1,99 @@
/*
* 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 _ from 'lodash';
import moment from 'moment-timezone';
import { RRule, Weekday } from 'rrule';
import { parseByWeekday } from '../lib/rrule';
import { RRuleParams, MaintenanceWindowSOAttributes, DateRange } from '../../common';
import { utcToLocalUtc, localUtcToUtc } from '../lib/snooze';
export interface GenerateMaintenanceWindowEventsParams {
rRule: RRuleParams;
expirationDate: string;
duration: number;
}
export const generateMaintenanceWindowEvents = ({
rRule,
expirationDate,
duration,
}: GenerateMaintenanceWindowEventsParams) => {
const { dtstart, until, wkst, byweekday, tzid, ...rest } = rRule;
const startDate = utcToLocalUtc(new Date(dtstart), tzid);
const endDate = utcToLocalUtc(new Date(expirationDate), tzid);
const rRuleOptions = {
...rest,
dtstart: startDate,
until: until ? utcToLocalUtc(new Date(until), tzid) : null,
wkst: wkst ? Weekday.fromStr(wkst) : null,
byweekday: byweekday ? parseByWeekday(byweekday) : null,
};
try {
const recurrenceRule = new RRule(rRuleOptions);
const occurrenceDates = recurrenceRule.between(startDate, endDate, true);
return occurrenceDates.map((date) => {
const utcDate = localUtcToUtc(date, tzid);
return {
gte: utcDate.toISOString(),
lte: moment.utc(utcDate).add(duration, 'ms').toISOString(),
};
});
} catch (e) {
throw new Error(`Failed to process RRule ${rRule}. Error: ${e}`);
}
};
/**
* Checks to see if we should regenerate maintenance window events.
* Don't regenerate old events if the underlying RRule/duration did not change.
*/
export const shouldRegenerateEvents = ({
maintenanceWindow,
rRule,
duration,
}: {
maintenanceWindow: MaintenanceWindowSOAttributes;
rRule?: RRuleParams;
duration?: number;
}): boolean => {
// If the rRule fails a deep equality check (there is a change), we should regenerate events
if (rRule && !_.isEqual(rRule, maintenanceWindow.rRule)) {
return true;
}
// If the duration changes, we should regenerate events
if (typeof duration === 'number' && duration !== maintenanceWindow.duration) {
return true;
}
return false;
};
/**
* Updates and merges the old events with the new events to preserve old modified events,
* Unless the maintenance window was archived, then the old events are trimmed.
*/
export const mergeEvents = ({
oldEvents,
newEvents,
}: {
oldEvents: DateRange[];
newEvents: DateRange[];
}) => {
// If new events have more entries (expiration date got pushed), we merge the old into the new
if (newEvents.length > oldEvents.length) {
return [...oldEvents, ...newEvents.slice(-(newEvents.length - oldEvents.length))];
}
// If new events have less entries (maintenance window got archived), we trim the old events
if (oldEvents.length > newEvents.length) {
return oldEvents.slice(0, newEvents.length);
}
return oldEvents;
};

View file

@ -0,0 +1,252 @@
/*
* 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 moment from 'moment';
import {
getMaintenanceWindowDateAndStatus,
findRecentEventWithStatus,
} from './get_maintenance_window_date_and_status';
import { DateRange, MaintenanceWindowStatus } from '../../common';
const events: DateRange[] = [
{
gte: '2023-03-25T00:00:00.000Z',
lte: '2023-03-25T01:00:00.000Z',
},
{
gte: '2023-03-26T00:00:00.000Z',
lte: '2023-03-26T01:00:00.000Z',
},
{
gte: '2023-03-27T00:00:00.000Z',
lte: '2023-03-27T01:00:00.000Z',
},
{
gte: '2023-03-28T00:00:00.000Z',
lte: '2023-03-28T01:00:00.000Z',
},
{
gte: '2023-03-29T00:00:00.000Z',
lte: '2023-03-29T01:00:00.000Z',
},
{
gte: '2023-03-30T00:00:00.000Z',
lte: '2023-03-30T01:00:00.000Z',
},
{
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
];
describe('getMaintenanceWindowDateAndStatus', () => {
it('should return finished if there is are no events', () => {
jest.useFakeTimers().setSystemTime(new Date('2023-03-25T00:30:00.000Z'));
const result = getMaintenanceWindowDateAndStatus({
events: [],
dateToCompare: new Date(),
expirationDate: moment().add(1, 'year').toDate(),
});
expect(result.eventStartTime).toEqual(null);
expect(result.eventEndTime).toEqual(null);
expect(result.status).toEqual('finished');
});
it('should return archived if expiration date is before or equal to now', () => {
jest.useFakeTimers().setSystemTime(new Date('2023-03-23T00:30:00.000Z'));
let result = getMaintenanceWindowDateAndStatus({
events,
dateToCompare: new Date(),
expirationDate: moment().subtract(1, 'minute').toDate(),
});
expect(result.eventStartTime).toEqual('2023-03-25T00:00:00.000Z');
expect(result.eventEndTime).toEqual('2023-03-25T01:00:00.000Z');
expect(result.status).toEqual('archived');
jest.useFakeTimers().setSystemTime(new Date('2023-03-28T00:30:00.000Z'));
result = getMaintenanceWindowDateAndStatus({
events,
dateToCompare: new Date(),
expirationDate: moment().subtract(1, 'minute').toDate(),
});
expect(result.eventStartTime).toEqual('2023-03-28T00:00:00.000Z');
expect(result.eventEndTime).toEqual('2023-03-28T01:00:00.000Z');
expect(result.status).toEqual('archived');
jest.useFakeTimers().setSystemTime(new Date('2023-04-28T00:30:00.000Z'));
result = getMaintenanceWindowDateAndStatus({
events,
dateToCompare: new Date(),
expirationDate: moment().subtract(1, 'minute').toDate(),
});
expect(result.eventStartTime).toEqual('2023-03-31T00:00:00.000Z');
expect(result.eventEndTime).toEqual('2023-03-31T01:00:00.000Z');
expect(result.status).toEqual('archived');
});
});
describe('findRecentEventWithStatus', () => {
it('should find the status if event is running', () => {
jest.useFakeTimers().setSystemTime(new Date('2023-03-25T00:30:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-25T00:00:00.000Z',
lte: '2023-03-25T01:00:00.000Z',
},
index: 0,
status: MaintenanceWindowStatus.Running,
});
jest.useFakeTimers().setSystemTime(new Date('2023-03-27T00:30:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-27T00:00:00.000Z',
lte: '2023-03-27T01:00:00.000Z',
},
index: 2,
status: MaintenanceWindowStatus.Running,
});
jest.useFakeTimers().setSystemTime(new Date('2023-03-29T00:30:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-29T00:00:00.000Z',
lte: '2023-03-29T01:00:00.000Z',
},
index: 4,
status: MaintenanceWindowStatus.Running,
});
jest.useFakeTimers().setSystemTime(new Date('2023-03-30T00:30:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-30T00:00:00.000Z',
lte: '2023-03-30T01:00:00.000Z',
},
index: 5,
status: MaintenanceWindowStatus.Running,
});
});
it('should find the status if event is upcoming', () => {
jest.useFakeTimers().setSystemTime(new Date('2023-03-24T05:00:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-25T00:00:00.000Z',
lte: '2023-03-25T01:00:00.000Z',
},
index: 0,
status: MaintenanceWindowStatus.Upcoming,
});
jest.useFakeTimers().setSystemTime(new Date('2023-03-26T05:00:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-27T00:00:00.000Z',
lte: '2023-03-27T01:00:00.000Z',
},
index: 2,
status: MaintenanceWindowStatus.Upcoming,
});
jest.useFakeTimers().setSystemTime(new Date('2023-03-27T05:00:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-28T00:00:00.000Z',
lte: '2023-03-28T01:00:00.000Z',
},
index: 3,
status: MaintenanceWindowStatus.Upcoming,
});
jest.useFakeTimers().setSystemTime(new Date('2023-03-29T05:00:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-30T00:00:00.000Z',
lte: '2023-03-30T01:00:00.000Z',
},
index: 5,
status: MaintenanceWindowStatus.Upcoming,
});
});
it('should find the status if event is finished', () => {
jest.useFakeTimers().setSystemTime(new Date('2023-04-01T05:00:00.000Z'));
expect(findRecentEventWithStatus(events, new Date())).toEqual({
event: {
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
index: 6,
status: MaintenanceWindowStatus.Finished,
});
});
it('should find the status if there is only 1 event', () => {
jest.useFakeTimers().setSystemTime(new Date('2023-01-01T00:00:00.000Z'));
expect(
findRecentEventWithStatus(
[
{
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
],
new Date()
)
).toEqual({
event: {
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
index: 0,
status: MaintenanceWindowStatus.Upcoming,
});
jest.useFakeTimers().setSystemTime(new Date('2023-03-31T00:30:00.000Z'));
expect(
findRecentEventWithStatus(
[
{
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
],
new Date()
)
).toEqual({
event: {
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
index: 0,
status: MaintenanceWindowStatus.Running,
});
jest.useFakeTimers().setSystemTime(new Date('2023-04-20T00:00:00.000Z'));
expect(
findRecentEventWithStatus(
[
{
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
],
new Date()
)
).toEqual({
event: {
gte: '2023-03-31T00:00:00.000Z',
lte: '2023-03-31T01:00:00.000Z',
},
index: 0,
status: MaintenanceWindowStatus.Finished,
});
});
});

View file

@ -0,0 +1,131 @@
/*
* 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 moment from 'moment';
import { DateRange, MaintenanceWindowStatus } from '../../common';
export interface DateSearchResult {
event: DateRange;
index: number;
status: MaintenanceWindowStatus;
}
export interface MaintenanceWindowDateAndStatus {
eventStartTime: string | null;
eventEndTime: string | null;
status: MaintenanceWindowStatus;
index?: number;
}
// Returns the most recent/relevant event and the status for a maintenance window
export const getMaintenanceWindowDateAndStatus = ({
events,
dateToCompare,
expirationDate,
}: {
events: DateRange[];
dateToCompare: Date;
expirationDate: Date;
}): MaintenanceWindowDateAndStatus => {
// No events, status is finished
if (!events.length) {
return {
eventStartTime: null,
eventEndTime: null,
status: MaintenanceWindowStatus.Finished,
};
}
const { event, status, index } = findRecentEventWithStatus(events, dateToCompare);
// Past expiration, show the last event, but status is now archived
if (moment.utc(expirationDate).isBefore(dateToCompare)) {
return {
eventStartTime: event.gte,
eventEndTime: event.lte,
status: MaintenanceWindowStatus.Archived,
index,
};
}
return {
eventStartTime: event.gte,
eventEndTime: event.lte,
status,
index,
};
};
// Binary date search to find the closest (or running) event relative to an arbitrary date
export const findRecentEventWithStatus = (
events: DateRange[],
dateToCompare: Date
): DateSearchResult => {
const result = binaryDateSearch(events, dateToCompare, 0, events.length - 1)!;
// Has running or upcoming event, just return the event
if (
result.status === MaintenanceWindowStatus.Running ||
result.status === MaintenanceWindowStatus.Upcoming
) {
return result;
}
// At the last event and it's finished, no more events are schedule so just return
if (result.status === MaintenanceWindowStatus.Finished && result.index === events.length - 1) {
return result;
}
return {
event: events[result.index + 1],
status: MaintenanceWindowStatus.Upcoming,
index: result.index + 1,
};
};
// Get the maintenance window status of any particular event relative to an arbitrary date
const getEventStatus = (event: DateRange, dateToCompare: Date): MaintenanceWindowStatus => {
if (moment.utc(event.gte).isAfter(dateToCompare)) {
return MaintenanceWindowStatus.Upcoming;
}
if (moment.utc(event.lte).isSameOrBefore(dateToCompare)) {
return MaintenanceWindowStatus.Finished;
}
return MaintenanceWindowStatus.Running;
};
const binaryDateSearch = (
events: DateRange[],
dateToCompare: Date,
startIndex: number,
endIndex: number,
lastIndex?: number
): DateSearchResult | undefined => {
// Base case, take the last event it checked to see what the relative status to that event is
if (startIndex > endIndex && typeof lastIndex === 'number') {
const event = events[lastIndex];
if (event) {
return {
event,
status: getEventStatus(event, dateToCompare),
index: lastIndex!,
};
}
}
const midIndex = startIndex + Math.floor((endIndex - startIndex) / 2);
const midEvent = events[midIndex];
const midEventStatus = getEventStatus(midEvent, dateToCompare);
switch (midEventStatus) {
case MaintenanceWindowStatus.Running:
return {
event: midEvent,
status: MaintenanceWindowStatus.Running,
index: midIndex,
};
case MaintenanceWindowStatus.Upcoming:
return binaryDateSearch(events, dateToCompare, startIndex, midIndex - 1, midIndex);
case MaintenanceWindowStatus.Finished:
return binaryDateSearch(events, dateToCompare, midIndex + 1, endIndex, midIndex);
}
};

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 { MaintenanceWindowSOAttributes } from '../../common';
import { getMaintenanceWindowDateAndStatus } from './get_maintenance_window_date_and_status';
export interface GetMaintenanceWindowFromRawParams {
id: string;
attributes: MaintenanceWindowSOAttributes;
}
export const getMaintenanceWindowFromRaw = ({
id,
attributes,
}: GetMaintenanceWindowFromRawParams) => {
const { events, expirationDate } = attributes;
const { eventStartTime, eventEndTime, status } = getMaintenanceWindowDateAndStatus({
events,
expirationDate: new Date(expirationDate),
dateToCompare: new Date(),
});
return {
...attributes,
id,
eventStartTime,
eventEndTime,
status,
};
};

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 './maintenance_window_client';
export * from './generate_maintenance_window_events';

View file

@ -0,0 +1,76 @@
/*
* 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 { Logger, SavedObjectsClientContract } from '@kbn/core/server';
import { create, CreateParams } from './methods/create';
import { get, GetParams } from './methods/get';
import { update, UpdateParams } from './methods/update';
import { find, FindResult } from './methods/find';
import { deleteMaintenanceWindow, DeleteParams } from './methods/delete';
import { archive, ArchiveParams } from './methods/archive';
import {
getActiveMaintenanceWindows,
ActiveParams,
} from './methods/get_active_maintenance_windows';
import { finish, FinishParams } from './methods/finish';
import {
MaintenanceWindow,
MaintenanceWindowModificationMetadata,
MaintenanceWindowClientContext,
} from '../../common';
export interface MaintenanceWindowClientConstructorOptions {
readonly logger: Logger;
readonly savedObjectsClient: SavedObjectsClientContract;
readonly getUserName: () => Promise<string | null>;
}
export class MaintenanceWindowClient {
private readonly logger: Logger;
private readonly savedObjectsClient: SavedObjectsClientContract;
private readonly getUserName: () => Promise<string | null>;
private readonly context: MaintenanceWindowClientContext;
constructor(options: MaintenanceWindowClientConstructorOptions) {
this.logger = options.logger;
this.savedObjectsClient = options.savedObjectsClient;
this.getUserName = options.getUserName;
this.context = {
logger: this.logger,
savedObjectsClient: this.savedObjectsClient,
getModificationMetadata: this.getModificationMetadata.bind(this),
};
}
private async getModificationMetadata(): Promise<MaintenanceWindowModificationMetadata> {
const createTime = Date.now();
const userName = await this.getUserName();
return {
createdBy: userName,
updatedBy: userName,
createdAt: new Date(createTime).toISOString(),
updatedAt: new Date(createTime).toISOString(),
};
}
public create = (params: CreateParams): Promise<MaintenanceWindow> =>
create(this.context, params);
public get = (params: GetParams): Promise<MaintenanceWindow> => get(this.context, params);
public update = (params: UpdateParams): Promise<MaintenanceWindow> =>
update(this.context, params);
public find = (): Promise<FindResult> => find(this.context);
public delete = (params: DeleteParams): Promise<{}> =>
deleteMaintenanceWindow(this.context, params);
public archive = (params: ArchiveParams): Promise<MaintenanceWindow> =>
archive(this.context, params);
public finish = (params: FinishParams): Promise<MaintenanceWindow> =>
finish(this.context, params);
public getActiveMaintenanceWindows = (params: ActiveParams): Promise<MaintenanceWindow[]> =>
getActiveMaintenanceWindows(this.context, params);
}

View file

@ -0,0 +1,190 @@
/*
* 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 moment from 'moment-timezone';
import { RRule } from 'rrule';
import { archive } from './archive';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObjectsUpdateResponse, SavedObject } from '@kbn/core/server';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
import { getMockMaintenanceWindow } from './test_helpers';
const savedObjectsClient = savedObjectsClientMock.create();
const firstTimestamp = '2023-02-26T00:00:00.000Z';
const secondTimestamp = '2023-03-26T00:00:00.000Z';
const updatedMetadata = {
createdAt: '2023-03-26T00:00:00.000Z',
updatedAt: '2023-03-26T00:00:00.000Z',
createdBy: 'updated-user',
updatedBy: 'updated-user',
};
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - archive', () => {
beforeEach(() => {
mockContext.getModificationMetadata.mockResolvedValueOnce(updatedMetadata);
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should archive maintenance windows', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.update.mockResolvedValueOnce({
attributes: {
...mockMaintenanceWindow,
...updatedMetadata,
},
} as unknown as SavedObjectsUpdateResponse);
// Move to some time in the future
jest.useFakeTimers().setSystemTime(new Date(secondTimestamp));
await archive(mockContext, { id: 'test-id', archive: true });
expect(savedObjectsClient.get).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id'
);
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
{
...mockMaintenanceWindow,
events: [
{ gte: '2023-02-26T00:00:00.000Z', lte: '2023-02-26T01:00:00.000Z' },
{ gte: '2023-03-05T00:00:00.000Z', lte: '2023-03-05T01:00:00.000Z' },
],
expirationDate: new Date().toISOString(),
...updatedMetadata,
},
{ version: '123' }
);
});
it('should unarchive maintenance window', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: {
...mockMaintenanceWindow,
expirationDate: new Date().toISOString(),
},
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.update.mockResolvedValueOnce({
attributes: {
...mockMaintenanceWindow,
...updatedMetadata,
},
} as unknown as SavedObjectsUpdateResponse);
// Move to some time in the future
jest.useFakeTimers().setSystemTime(new Date(secondTimestamp));
await archive(mockContext, { id: 'test-id', archive: false });
expect(savedObjectsClient.get).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id'
);
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
{
...mockMaintenanceWindow,
events: [
{ gte: '2023-02-26T00:00:00.000Z', lte: '2023-02-26T01:00:00.000Z' },
{ gte: '2023-03-05T00:00:00.000Z', lte: '2023-03-05T01:00:00.000Z' },
],
expirationDate: moment.utc().add(1, 'year').toISOString(),
...updatedMetadata,
},
{ version: '123' }
);
});
it('should preserve finished events when archiving', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const modifiedEvents = [
{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T00:12:34.000Z' },
{ gte: '2023-04-01T23:00:00.000Z', lte: '2023-04-01T23:43:21.000Z' },
{ gte: '2023-04-08T23:00:00.000Z', lte: '2023-04-09T00:00:00.000Z' },
{ gte: '2023-04-15T23:00:00.000Z', lte: '2023-04-15T23:30:00.000Z' },
{ gte: '2023-04-22T23:00:00.000Z', lte: '2023-04-23T00:00:00.000Z' },
];
const mockMaintenanceWindow = getMockMaintenanceWindow({
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: RRule.WEEKLY,
count: 5,
},
events: modifiedEvents,
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').add(2, 'week').toISOString(),
});
savedObjectsClient.get.mockResolvedValue({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.update.mockResolvedValue({
attributes: {
...mockMaintenanceWindow,
...updatedMetadata,
},
id: 'test-id',
} as unknown as SavedObjectsUpdateResponse);
jest.useFakeTimers().setSystemTime(new Date('2023-04-16T00:00:00.000Z'));
await archive(mockContext, { id: 'test-id', archive: true });
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
{
...mockMaintenanceWindow,
events: modifiedEvents.slice(0, 4),
expirationDate: new Date().toISOString(),
...updatedMetadata,
},
{ version: '123' }
);
});
});

View file

@ -0,0 +1,99 @@
/*
* 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 moment from 'moment';
import Boom from '@hapi/boom';
import {
generateMaintenanceWindowEvents,
mergeEvents,
} from '../generate_maintenance_window_events';
import { getMaintenanceWindowFromRaw } from '../get_maintenance_window_from_raw';
import {
MaintenanceWindowSOAttributes,
MaintenanceWindow,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
MaintenanceWindowClientContext,
} from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
export interface ArchiveParams {
id: string;
archive: boolean;
}
const getArchivedExpirationDate = (shouldArchive: boolean) => {
if (shouldArchive) {
return new Date().toISOString();
}
return moment.utc().add(1, 'year').toISOString();
};
export async function archive(
context: MaintenanceWindowClientContext,
params: ArchiveParams
): Promise<MaintenanceWindow> {
return await retryIfConflicts(
context.logger,
`maintenanceWindowClient.archive('${params.id})`,
async () => {
return await archiveWithOCC(context, params);
}
);
}
async function archiveWithOCC(
context: MaintenanceWindowClientContext,
params: ArchiveParams
): Promise<MaintenanceWindow> {
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { id, archive: shouldArchive } = params;
const modificationMetadata = await getModificationMetadata();
const expirationDate = getArchivedExpirationDate(shouldArchive);
try {
const { attributes, version } = await savedObjectsClient.get<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
id
);
const events = mergeEvents({
newEvents: generateMaintenanceWindowEvents({
rRule: attributes.rRule,
duration: attributes.duration,
expirationDate,
}),
oldEvents: attributes.events,
});
const result = await savedObjectsClient.update<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
id,
{
...attributes,
events,
expirationDate,
...modificationMetadata,
},
{
version,
}
);
return getMaintenanceWindowFromRaw({
attributes: {
...attributes,
...result.attributes,
},
id,
});
} catch (e) {
const errorMessage = `Failed to archive maintenance window by id: ${id}, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

View file

@ -0,0 +1,86 @@
/*
* 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 moment from 'moment-timezone';
import { create } from './create';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObject } from '@kbn/core/server';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
import { getMockMaintenanceWindow } from './test_helpers';
const savedObjectsClient = savedObjectsClientMock.create();
const updatedMetadata = {
createdAt: '2023-03-26T00:00:00.000Z',
updatedAt: '2023-03-26T00:00:00.000Z',
createdBy: 'updated-user',
updatedBy: 'updated-user',
};
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - create', () => {
beforeEach(() => {
mockContext.getModificationMetadata.mockResolvedValueOnce(updatedMetadata);
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should create maintenance window with the correct parameters', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
savedObjectsClient.create.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
const result = await create(mockContext, {
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule,
});
expect(savedObjectsClient.create).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
expect.objectContaining({
title: mockMaintenanceWindow.title,
duration: mockMaintenanceWindow.duration,
rRule: mockMaintenanceWindow.rRule,
enabled: true,
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
...updatedMetadata,
}),
{
id: expect.any(String),
}
);
expect(result).toEqual(
expect.objectContaining({
id: 'test-id',
})
);
});
});

View file

@ -0,0 +1,64 @@
/*
* 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 moment from 'moment';
import Boom from '@hapi/boom';
import { SavedObjectsUtils } from '@kbn/core/server';
import { getMaintenanceWindowFromRaw } from '../get_maintenance_window_from_raw';
import { generateMaintenanceWindowEvents } from '../generate_maintenance_window_events';
import {
MaintenanceWindowSOAttributes,
MaintenanceWindow,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
RRuleParams,
MaintenanceWindowClientContext,
} from '../../../common';
export interface CreateParams {
title: string;
duration: number;
rRule: RRuleParams;
}
export async function create(
context: MaintenanceWindowClientContext,
params: CreateParams
): Promise<MaintenanceWindow> {
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { title, duration, rRule } = params;
const id = SavedObjectsUtils.generateId();
const expirationDate = moment().utc().add(1, 'year').toISOString();
const modificationMetadata = await getModificationMetadata();
try {
const events = generateMaintenanceWindowEvents({ rRule, expirationDate, duration });
const result = await savedObjectsClient.create<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
{
title,
enabled: true,
expirationDate,
rRule,
duration,
events,
...modificationMetadata,
},
{
id,
}
);
return getMaintenanceWindowFromRaw({
attributes: result.attributes,
id: result.id,
});
} catch (e) {
const errorMessage = `Failed to create maintenance window, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

View file

@ -0,0 +1,56 @@
/*
* 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 { deleteMaintenanceWindow } from './delete';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
const savedObjectsClient = savedObjectsClientMock.create();
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - delete', () => {
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should delete maintenance window by id', async () => {
savedObjectsClient.delete.mockResolvedValueOnce({});
const result = await deleteMaintenanceWindow(mockContext, { id: 'test-id' });
expect(savedObjectsClient.delete).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id'
);
expect(result).toEqual({});
});
it('should errors when deletion fails', async () => {
savedObjectsClient.delete.mockRejectedValueOnce('something went wrong');
await expect(async () => {
await deleteMaintenanceWindow(mockContext, { id: 'test-id' });
}).rejects.toThrowError();
expect(mockContext.logger.error).toHaveBeenLastCalledWith(
'Failed to delete maintenance window by id: test-id, Error: something went wrong'
);
});
});

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 Boom from '@hapi/boom';
import {
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
MaintenanceWindowClientContext,
} from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
export interface DeleteParams {
id: string;
}
export async function deleteMaintenanceWindow(
context: MaintenanceWindowClientContext,
params: DeleteParams
): Promise<{}> {
return await retryIfConflicts(
context.logger,
`maintenanceWindowClient.delete('${params.id}')`,
async () => await deleteWithOCC(context, params)
);
}
async function deleteWithOCC(
context: MaintenanceWindowClientContext,
params: DeleteParams
): Promise<{}> {
const { savedObjectsClient, logger } = context;
const { id } = params;
try {
return await savedObjectsClient.delete(MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE, id);
} catch (e) {
const errorMessage = `Failed to delete maintenance window by id: ${id}, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

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 { find } from './find';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObjectsFindResponse } from '@kbn/core/server';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
import { getMockMaintenanceWindow } from './test_helpers';
const savedObjectsClient = savedObjectsClientMock.create();
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - find', () => {
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should find maintenance windows', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
savedObjectsClient.find.mockResolvedValueOnce({
saved_objects: [
{
attributes: getMockMaintenanceWindow({ expirationDate: new Date().toISOString() }),
id: 'test-1',
},
{
attributes: getMockMaintenanceWindow({ expirationDate: new Date().toISOString() }),
id: 'test-2',
},
],
} as unknown as SavedObjectsFindResponse);
const result = await find(mockContext);
expect(savedObjectsClient.find).toHaveBeenLastCalledWith({
type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
});
expect(result.data.length).toEqual(2);
expect(result.data[0].id).toEqual('test-1');
expect(result.data[1].id).toEqual('test-2');
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 Boom from '@hapi/boom';
import { getMaintenanceWindowFromRaw } from '../get_maintenance_window_from_raw';
import {
MaintenanceWindowSOAttributes,
MaintenanceWindow,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
MaintenanceWindowClientContext,
} from '../../../common';
export interface FindResult {
data: MaintenanceWindow[];
}
export async function find(context: MaintenanceWindowClientContext): Promise<FindResult> {
const { savedObjectsClient, logger } = context;
try {
const result = await savedObjectsClient.find<MaintenanceWindowSOAttributes>({
type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
});
return {
data: result.saved_objects.map((so) =>
getMaintenanceWindowFromRaw({
attributes: so.attributes,
id: so.id,
})
),
};
} catch (e) {
const errorMessage = `Failed to find maintenance window, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

View file

@ -0,0 +1,183 @@
/*
* 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 moment from 'moment-timezone';
import { RRule } from 'rrule';
import { finish } from './finish';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObjectsUpdateResponse, SavedObject } from '@kbn/core/server';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
import { getMockMaintenanceWindow } from './test_helpers';
const savedObjectsClient = savedObjectsClientMock.create();
const firstTimestamp = '2023-02-26T00:00:00.000Z';
const updatedMetadata = {
createdAt: '2023-03-26T00:00:00.000Z',
updatedAt: '2023-03-26T00:00:00.000Z',
createdBy: 'updated-user',
updatedBy: 'updated-user',
};
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - finish', () => {
beforeEach(() => {
mockContext.getModificationMetadata.mockResolvedValueOnce(updatedMetadata);
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should finish the currently running maintenance window event', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
duration: 60 * 60 * 1000,
expirationDate: moment.utc().add(1, 'year').toISOString(),
rRule: {
tzid: 'UTC',
dtstart: moment().utc().toISOString(),
freq: RRule.WEEKLY,
count: 2,
},
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.update.mockResolvedValueOnce({
attributes: {
...mockMaintenanceWindow,
...updatedMetadata,
events: [
{ gte: '2023-02-26T00:00:00.000Z', lte: '2023-02-26T00:30:00.000Z' },
{ gte: '2023-03-05T00:00:00.000Z', lte: '2023-03-05T01:00:00.000Z' },
],
},
id: 'test-id',
} as unknown as SavedObjectsUpdateResponse);
// Move 30 mins into the future
jest.useFakeTimers().setSystemTime(moment.utc(firstTimestamp).add(30, 'minute').toDate());
const result = await finish(mockContext, { id: 'test-id' });
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
expect.objectContaining({
expirationDate: moment.utc().add(1, 'year').toISOString(),
events: [
// Event ends 30 mins earlier, just like expected}
{ gte: '2023-02-26T00:00:00.000Z', lte: '2023-02-26T00:30:00.000Z' },
{ gte: '2023-03-05T00:00:00.000Z', lte: '2023-03-05T01:00:00.000Z' },
],
}),
{ version: '123' }
);
expect(result.status).toEqual('upcoming');
expect(result.eventStartTime).toEqual('2023-03-05T00:00:00.000Z');
expect(result.eventEndTime).toEqual('2023-03-05T01:00:00.000Z');
});
it('should keep events that were finished in the past', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const modifiedEvents = [
{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T00:12:34.000Z' },
{ gte: '2023-04-01T23:00:00.000Z', lte: '2023-04-01T23:43:21.000Z' },
];
const mockMaintenanceWindow = getMockMaintenanceWindow({
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: RRule.WEEKLY,
count: 5,
},
events: modifiedEvents,
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').add(2, 'week').toISOString(),
});
savedObjectsClient.get.mockResolvedValue({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.update.mockResolvedValue({
attributes: {
...mockMaintenanceWindow,
...updatedMetadata,
},
id: 'test-id',
} as unknown as SavedObjectsUpdateResponse);
jest.useFakeTimers().setSystemTime(new Date('2023-04-15T23:30:00.000Z'));
await finish(mockContext, { id: 'test-id' });
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
expect.objectContaining({
events: [
...modifiedEvents,
{ gte: '2023-04-08T23:00:00.000Z', lte: '2023-04-09T00:00:00.000Z' },
{ gte: '2023-04-15T23:00:00.000Z', lte: '2023-04-15T23:30:00.000Z' },
{ gte: '2023-04-22T23:00:00.000Z', lte: '2023-04-23T00:00:00.000Z' },
],
}),
{ version: '123' }
);
});
it('should throw if trying to finish a maintenance window that is not running', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
duration: 60 * 60 * 1000, //
expirationDate: moment.utc().add(1, 'year').toISOString(),
rRule: {
tzid: 'UTC',
dtstart: moment().utc().toISOString(),
freq: RRule.WEEKLY,
count: 2,
},
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
// Move 2 hours into the future
jest.useFakeTimers().setSystemTime(moment.utc(firstTimestamp).add(2, 'hours').toDate());
await expect(async () => {
await finish(mockContext, { id: 'test-id' });
}).rejects.toThrowError();
expect(mockContext.logger.error).toHaveBeenLastCalledWith(
'Failed to finish maintenance window by id: test-id, Error: Error: Cannot finish maintenance window that is not running'
);
});
});

View file

@ -0,0 +1,127 @@
/*
* 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 moment from 'moment';
import Boom from '@hapi/boom';
import {
generateMaintenanceWindowEvents,
mergeEvents,
} from '../generate_maintenance_window_events';
import { getMaintenanceWindowDateAndStatus } from '../get_maintenance_window_date_and_status';
import { getMaintenanceWindowFromRaw } from '../get_maintenance_window_from_raw';
import {
MaintenanceWindowSOAttributes,
MaintenanceWindow,
DateRange,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
MaintenanceWindowClientContext,
MaintenanceWindowStatus,
} from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
export interface FinishParams {
id: string;
}
export async function finish(
context: MaintenanceWindowClientContext,
params: FinishParams
): Promise<MaintenanceWindow> {
return await retryIfConflicts(
context.logger,
`maintenanceWindowClient.finish('${params.id})`,
async () => {
return await finishWithOCC(context, params);
}
);
}
async function finishWithOCC(
context: MaintenanceWindowClientContext,
params: FinishParams
): Promise<MaintenanceWindow> {
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { id } = params;
const modificationMetadata = await getModificationMetadata();
const now = new Date();
const expirationDate = moment.utc(now).add(1, 'year').toDate();
try {
const {
attributes,
version,
id: fetchedId,
} = await savedObjectsClient.get<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
id
);
// Generate new events with new expiration date
const newEvents = generateMaintenanceWindowEvents({
rRule: attributes.rRule,
duration: attributes.duration,
expirationDate: expirationDate.toISOString(),
});
// Merge it with the old events
const events = mergeEvents({
newEvents,
oldEvents: attributes.events,
});
// Find the current event and status of the maintenance window
const { status, index } = getMaintenanceWindowDateAndStatus({
events,
dateToCompare: now,
expirationDate,
});
// Throw if the maintenance window is not running, or event doesn't exist
if (status !== MaintenanceWindowStatus.Running) {
throw Boom.badRequest('Cannot finish maintenance window that is not running');
}
if (typeof index !== 'number' || !events[index]) {
throw Boom.badRequest('Cannot find maintenance window event to finish');
}
// Update the running event to finish now
const eventToFinish: DateRange = {
gte: events[index].gte,
lte: now.toISOString(),
};
// Update the events with the new finished event
const eventsWithFinishedEvent = [...events];
eventsWithFinishedEvent[index] = eventToFinish;
const result = await savedObjectsClient.update<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
fetchedId,
{
events: eventsWithFinishedEvent,
expirationDate: expirationDate.toISOString(),
...modificationMetadata,
},
{
version,
}
);
return getMaintenanceWindowFromRaw({
attributes: {
...attributes,
...result.attributes,
},
id: result.id,
});
} catch (e) {
const errorMessage = `Failed to finish maintenance window by id: ${id}, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

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 { get } from './get';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObject } from '@kbn/core/server';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
import { getMockMaintenanceWindow } from './test_helpers';
const savedObjectsClient = savedObjectsClientMock.create();
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - get', () => {
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should get maintenance window by id', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: new Date().toISOString(),
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
id: 'test-id',
} as unknown as SavedObject);
const result = await get(mockContext, { id: 'test-id' });
expect(savedObjectsClient.get).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id'
);
expect(result.id).toEqual('test-id');
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 Boom from '@hapi/boom';
import { getMaintenanceWindowFromRaw } from '../get_maintenance_window_from_raw';
import {
MaintenanceWindowSOAttributes,
MaintenanceWindow,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
MaintenanceWindowClientContext,
} from '../../../common';
export interface GetParams {
id: string;
}
export async function get(
context: MaintenanceWindowClientContext,
params: GetParams
): Promise<MaintenanceWindow> {
const { savedObjectsClient, logger } = context;
const { id } = params;
try {
const result = await savedObjectsClient.get<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
id
);
return getMaintenanceWindowFromRaw({
attributes: result.attributes,
id: result.id,
});
} catch (e) {
const errorMessage = `Failed to get maintenance window by id: ${id}, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

View file

@ -0,0 +1,219 @@
/*
* 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 { getActiveMaintenanceWindows } from './get_active_maintenance_windows';
import { toElasticsearchQuery } from '@kbn/es-query';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObjectsFindResponse } from '@kbn/core/server';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
import { getMockMaintenanceWindow } from './test_helpers';
const savedObjectsClient = savedObjectsClientMock.create();
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - getActiveMaintenanceWindows', () => {
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should return active maintenance windows', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
savedObjectsClient.find.mockResolvedValueOnce({
saved_objects: [
{
attributes: getMockMaintenanceWindow({ expirationDate: new Date().toISOString() }),
id: 'test-1',
},
{
attributes: getMockMaintenanceWindow({ expirationDate: new Date().toISOString() }),
id: 'test-2',
},
],
} as unknown as SavedObjectsFindResponse);
const startDate = new Date().toISOString();
const result = await getActiveMaintenanceWindows(mockContext, {
start: startDate,
interval: '1h',
});
const findCallParams = savedObjectsClient.find.mock.calls[0][0];
expect(findCallParams.type).toEqual(MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE);
expect(toElasticsearchQuery(findCallParams.filter)).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"maintenance-window.attributes.events": Object {
"gte": "2023-02-26T00:00:00.000Z",
},
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"maintenance-window.attributes.events": Object {
"lte": "2023-02-26T01:00:00.000Z",
},
},
},
],
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"maintenance-window.attributes.enabled": "true",
},
},
],
},
},
],
},
}
`);
expect(result).toEqual([
expect.objectContaining({
id: 'test-1',
}),
expect.objectContaining({
id: 'test-2',
}),
]);
});
it('should return empty array if there are no active maintenance windows', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
savedObjectsClient.find.mockResolvedValueOnce({
saved_objects: [],
} as unknown as SavedObjectsFindResponse);
const startDate = new Date().toISOString();
const result = await getActiveMaintenanceWindows(mockContext, {
start: startDate,
interval: '4d',
});
const findCallParams = savedObjectsClient.find.mock.calls[0][0];
expect(findCallParams.type).toEqual(MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE);
expect(toElasticsearchQuery(findCallParams.filter)).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"filter": Array [
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"maintenance-window.attributes.events": Object {
"gte": "2023-02-26T00:00:00.000Z",
},
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"range": Object {
"maintenance-window.attributes.events": Object {
"lte": "2023-03-02T00:00:00.000Z",
},
},
},
],
},
},
],
},
},
Object {
"bool": Object {
"minimum_should_match": 1,
"should": Array [
Object {
"match": Object {
"maintenance-window.attributes.enabled": "true",
},
},
],
},
},
],
},
}
`);
expect(result).toEqual([]);
});
it('should log and throw if an error is thrown', async () => {
jest.useFakeTimers().setSystemTime(new Date('2023-02-26T00:00:00.000Z'));
savedObjectsClient.find.mockRejectedValueOnce('something went wrong');
const startDate = new Date().toISOString();
await expect(async () => {
await getActiveMaintenanceWindows(mockContext, {
start: startDate,
interval: '4d',
});
}).rejects.toThrowError();
expect(mockContext.logger.error).toHaveBeenLastCalledWith(
'Failed to find active maintenance window by interval: 4d with start date: 2023-02-26T00:00:00.000Z, Error: something went wrong'
);
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 Boom from '@hapi/boom';
import moment from 'moment';
import { nodeBuilder, fromKueryExpression } from '@kbn/es-query';
import { getMaintenanceWindowFromRaw } from '../get_maintenance_window_from_raw';
import {
MaintenanceWindow,
MaintenanceWindowSOAttributes,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
parseDuration,
MaintenanceWindowClientContext,
} from '../../../common';
export interface MaintenanceWindowAggregationResult {
maintenanceWindow: {
buckets: Array<{
key_as_string: string;
key: string;
doc_count: number;
}>;
};
}
export interface ActiveParams {
start?: string;
interval: string;
}
export async function getActiveMaintenanceWindows(
context: MaintenanceWindowClientContext,
params: ActiveParams
): Promise<MaintenanceWindow[]> {
const { savedObjectsClient, logger } = context;
const { start, interval } = params;
const startDate = start ? new Date(start) : new Date();
const duration = parseDuration(interval);
const endDate = moment.utc(startDate).add(duration, 'ms').toDate();
const startDateISO = startDate.toISOString();
const endDateISO = endDate.toISOString();
const filter = nodeBuilder.and([
nodeBuilder.and([
fromKueryExpression(`maintenance-window.attributes.events >= "${startDateISO}"`),
fromKueryExpression(`maintenance-window.attributes.events <= "${endDateISO}"`),
]),
nodeBuilder.is('maintenance-window.attributes.enabled', 'true'),
]);
try {
const result = await savedObjectsClient.find<MaintenanceWindowSOAttributes>({
type: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
filter,
});
return result.saved_objects.map((so) =>
getMaintenanceWindowFromRaw({
attributes: so.attributes,
id: so.id,
})
);
} catch (e) {
const errorMessage = `Failed to find active maintenance window by interval: ${interval} with start date: ${startDate.toISOString()}, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

View file

@ -0,0 +1,41 @@
/*
* 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 { RRule } from 'rrule';
import { MaintenanceWindowSOAttributes } from '../../../common';
export const getMockMaintenanceWindow = (
overwrites?: Partial<MaintenanceWindowSOAttributes>
): MaintenanceWindowSOAttributes => {
return {
title: 'test-title',
duration: 60 * 60 * 1000,
enabled: true,
rRule: {
tzid: 'UTC',
dtstart: '2023-02-26T00:00:00.000Z',
freq: RRule.WEEKLY,
count: 2,
},
events: [
{
gte: '2023-02-26T00:00:00.000Z',
lte: '2023-02-26T01:00:00.000Z',
},
{
gte: '2023-03-05T00:00:00.000Z',
lte: '2023-03-05T01:00:00.000Z',
},
],
createdAt: '2023-02-26T00:00:00.000Z',
updatedAt: '2023-02-26T00:00:00.000Z',
createdBy: 'test-user',
updatedBy: 'test-user',
expirationDate: new Date().toISOString(),
...overwrites,
};
};

View file

@ -0,0 +1,209 @@
/*
* 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 moment from 'moment-timezone';
import { RRule } from 'rrule';
import { update } from './update';
import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { SavedObjectsUpdateResponse, SavedObject } from '@kbn/core/server';
import {
MaintenanceWindowClientContext,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../../common';
import { getMockMaintenanceWindow } from './test_helpers';
const savedObjectsClient = savedObjectsClientMock.create();
const firstTimestamp = '2023-02-26T00:00:00.000Z';
const secondTimestamp = '2023-03-26T00:00:00.000Z';
const updatedAttributes = {
title: 'updated-title',
enabled: false,
duration: 2 * 60 * 60 * 1000,
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: RRule.WEEKLY,
count: 2,
},
};
const updatedMetadata = {
createdAt: '2023-03-26T00:00:00.000Z',
updatedAt: '2023-03-26T00:00:00.000Z',
createdBy: 'updated-user',
updatedBy: 'updated-user',
};
const mockContext: jest.Mocked<MaintenanceWindowClientContext> = {
logger: loggingSystemMock.create().get(),
getModificationMetadata: jest.fn(),
savedObjectsClient,
};
describe('MaintenanceWindowClient - update', () => {
beforeEach(() => {
mockContext.getModificationMetadata.mockResolvedValueOnce(updatedMetadata);
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => {
jest.useRealTimers();
});
it('should call update with the correct parameters', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date()).tz('UTC').add(1, 'year').toISOString(),
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.update.mockResolvedValueOnce({
attributes: {
...mockMaintenanceWindow,
...updatedAttributes,
...updatedMetadata,
},
id: 'test-id',
} as unknown as SavedObjectsUpdateResponse);
jest.useFakeTimers().setSystemTime(new Date(secondTimestamp));
const result = await update(mockContext, {
id: 'test-id',
...updatedAttributes,
});
expect(savedObjectsClient.get).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id'
);
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
{
...updatedAttributes,
events: [
{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T02:00:00.000Z' },
{ gte: '2023-04-01T23:00:00.000Z', lte: '2023-04-02T01:00:00.000Z' }, // Daylight savings
],
expirationDate: moment(new Date(secondTimestamp)).tz('UTC').add(1, 'year').toISOString(),
...updatedMetadata,
},
{ version: '123' }
);
// Only these 3 properties are worth asserting since the rest come from mocks
expect(result).toEqual(
expect.objectContaining({
id: 'test-id',
status: 'finished',
eventStartTime: '2023-03-05T00:00:00.000Z',
eventEndTime: '2023-03-05T01:00:00.000Z',
})
);
});
it('should not regenerate all events if rrule and duration did not change', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const modifiedEvents = [
{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T00:12:34.000Z' },
{ gte: '2023-04-01T23:00:00.000Z', lte: '2023-04-01T23:43:21.000Z' },
];
const mockMaintenanceWindow = getMockMaintenanceWindow({
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: RRule.WEEKLY,
count: 5,
},
events: modifiedEvents,
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').add(2, 'week').toISOString(),
});
savedObjectsClient.get.mockResolvedValue({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
savedObjectsClient.update.mockResolvedValue({
attributes: {
...mockMaintenanceWindow,
...updatedAttributes,
...updatedMetadata,
},
id: 'test-id',
} as unknown as SavedObjectsUpdateResponse);
// Update without changing duration or rrule
await update(mockContext, { id: 'test-id' });
// Events keep the previous modified events, but adds on the new events
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
expect.objectContaining({
events: [...modifiedEvents, expect.any(Object), expect.any(Object), expect.any(Object)],
}),
{ version: '123' }
);
// Update with changing rrule
await update(mockContext, {
id: 'test-id',
rRule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: RRule.WEEKLY,
count: 2,
},
});
// All events are regenerated
expect(savedObjectsClient.update).toHaveBeenLastCalledWith(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
'test-id',
expect.objectContaining({
events: [
{ gte: '2023-03-26T00:00:00.000Z', lte: '2023-03-26T01:00:00.000Z' },
{ gte: '2023-04-01T23:00:00.000Z', lte: '2023-04-02T00:00:00.000Z' },
],
}),
{ version: '123' }
);
});
it('should throw if updating a maintenance window that has expired', async () => {
jest.useFakeTimers().setSystemTime(new Date(firstTimestamp));
const mockMaintenanceWindow = getMockMaintenanceWindow({
expirationDate: moment(new Date(firstTimestamp)).tz('UTC').subtract(1, 'year').toISOString(),
});
savedObjectsClient.get.mockResolvedValueOnce({
attributes: mockMaintenanceWindow,
version: '123',
id: 'test-id',
} as unknown as SavedObject);
await expect(async () => {
await update(mockContext, { id: 'test-id', ...updatedAttributes });
}).rejects.toThrowError();
expect(mockContext.logger.error).toHaveBeenLastCalledWith(
'Failed to update maintenance window by id: test-id, Error: Error: Cannot edit archived maintenance windows'
);
});
});

View file

@ -0,0 +1,110 @@
/*
* 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 moment from 'moment';
import Boom from '@hapi/boom';
import { getMaintenanceWindowFromRaw } from '../get_maintenance_window_from_raw';
import {
generateMaintenanceWindowEvents,
shouldRegenerateEvents,
mergeEvents,
} from '../generate_maintenance_window_events';
import {
MaintenanceWindow,
MaintenanceWindowSOAttributes,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
RRuleParams,
MaintenanceWindowClientContext,
} from '../../../common';
import { retryIfConflicts } from '../../lib/retry_if_conflicts';
export interface UpdateParams {
id: string;
title?: string;
enabled?: boolean;
duration?: number;
rRule?: RRuleParams;
}
export async function update(
context: MaintenanceWindowClientContext,
params: UpdateParams
): Promise<MaintenanceWindow> {
return await retryIfConflicts(
context.logger,
`maintenanceWindowClient.update('${params.id})`,
async () => {
return await updateWithOCC(context, params);
}
);
}
async function updateWithOCC(
context: MaintenanceWindowClientContext,
params: UpdateParams
): Promise<MaintenanceWindow> {
const { savedObjectsClient, getModificationMetadata, logger } = context;
const { id, title, enabled, duration, rRule } = params;
try {
const {
attributes,
version,
id: fetchedId,
} = await savedObjectsClient.get<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
id
);
if (moment.utc(attributes.expirationDate).isBefore(new Date())) {
throw Boom.badRequest('Cannot edit archived maintenance windows');
}
const expirationDate = moment.utc().add(1, 'year').toISOString();
const modificationMetadata = await getModificationMetadata();
let events = generateMaintenanceWindowEvents({
rRule: rRule || attributes.rRule,
duration: typeof duration === 'number' ? duration : attributes.duration,
expirationDate,
});
if (!shouldRegenerateEvents({ maintenanceWindow: attributes, rRule, duration })) {
events = mergeEvents({ oldEvents: attributes.events, newEvents: events });
}
const result = await savedObjectsClient.update<MaintenanceWindowSOAttributes>(
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
fetchedId,
{
...attributes,
title,
enabled: typeof enabled === 'boolean' ? enabled : attributes.enabled,
expirationDate,
duration,
rRule,
events,
...modificationMetadata,
},
{
version,
}
);
return getMaintenanceWindowFromRaw({
attributes: {
...attributes,
...result.attributes,
},
id: result.id,
});
} catch (e) {
const errorMessage = `Failed to update maintenance window by id: ${id}, Error: ${e}`;
logger.error(errorMessage);
throw Boom.boomify(e, { message: errorMessage });
}
}

View file

@ -0,0 +1,161 @@
/*
* 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 { Request } from '@hapi/hapi';
import { CoreKibanaRequest } from '@kbn/core/server';
import {
MaintenanceWindowClientFactory,
MaintenanceWindowClientFactoryOpts,
} from './maintenance_window_client_factory';
import {
savedObjectsClientMock,
savedObjectsServiceMock,
loggingSystemMock,
} from '@kbn/core/server/mocks';
import { AuthenticatedUser } from '@kbn/security-plugin/common/model';
import { securityMock } from '@kbn/security-plugin/server/mocks';
import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server';
import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '../common';
jest.mock('./maintenance_window_client');
const savedObjectsClient = savedObjectsClientMock.create();
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
const securityPluginStart = securityMock.createStart();
const maintenanceWindowClientFactoryParams: jest.Mocked<MaintenanceWindowClientFactoryOpts> = {
logger: loggingSystemMock.create().get(),
savedObjectsService,
};
const fakeRequest = {
app: {},
headers: {},
getBasePath: () => '',
path: '/',
route: { settings: {} },
url: {
href: '/',
},
raw: {
req: {
url: '/',
},
},
getSavedObjectsClient: () => savedObjectsClient,
} as unknown as Request;
beforeEach(() => {
jest.resetAllMocks();
});
test('creates a maintenance window client with proper constructor arguments when security is enabled', async () => {
const factory = new MaintenanceWindowClientFactory();
factory.initialize({
securityPluginStart,
...maintenanceWindowClientFactoryParams,
});
const request = CoreKibanaRequest.from(fakeRequest);
savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient);
factory.createWithAuthorization(request);
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
includedHiddenTypes: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE],
});
const { MaintenanceWindowClient } = jest.requireMock('./maintenance_window_client');
expect(MaintenanceWindowClient).toHaveBeenCalledWith({
logger: maintenanceWindowClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
});
});
test('creates a maintenance window client with proper constructor arguments', async () => {
const factory = new MaintenanceWindowClientFactory();
factory.initialize(maintenanceWindowClientFactoryParams);
const request = CoreKibanaRequest.from(fakeRequest);
savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient);
factory.createWithAuthorization(request);
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
includedHiddenTypes: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE],
});
const { MaintenanceWindowClient } = jest.requireMock('./maintenance_window_client');
expect(MaintenanceWindowClient).toHaveBeenCalledWith({
logger: maintenanceWindowClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
});
});
test('creates an unauthorized maintenance window client', async () => {
const factory = new MaintenanceWindowClientFactory();
factory.initialize({
securityPluginStart,
...maintenanceWindowClientFactoryParams,
});
const request = CoreKibanaRequest.from(fakeRequest);
savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient);
factory.create(request);
expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, {
excludedExtensions: [SECURITY_EXTENSION_ID],
includedHiddenTypes: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE],
});
const { MaintenanceWindowClient } = jest.requireMock('./maintenance_window_client');
expect(MaintenanceWindowClient).toHaveBeenCalledWith({
logger: maintenanceWindowClientFactoryParams.logger,
savedObjectsClient,
getUserName: expect.any(Function),
});
});
test('getUserName() returns null when security is disabled', async () => {
const factory = new MaintenanceWindowClientFactory();
factory.initialize(maintenanceWindowClientFactoryParams);
const request = CoreKibanaRequest.from(fakeRequest);
factory.createWithAuthorization(request);
const constructorCall = jest.requireMock('./maintenance_window_client').MaintenanceWindowClient
.mock.calls[0][0];
const userNameResult = await constructorCall.getUserName();
expect(userNameResult).toEqual(null);
});
test('getUserName() returns a name when security is enabled', async () => {
const factory = new MaintenanceWindowClientFactory();
factory.initialize({
securityPluginStart,
...maintenanceWindowClientFactoryParams,
});
const request = CoreKibanaRequest.from(fakeRequest);
factory.createWithAuthorization(request);
const constructorCall = jest.requireMock('./maintenance_window_client').MaintenanceWindowClient
.mock.calls[0][0];
securityPluginStart.authc.getCurrentUser.mockReturnValueOnce({
username: 'testname',
} as unknown as AuthenticatedUser);
const userNameResult = await constructorCall.getUserName();
expect(userNameResult).toEqual('testname');
});

View file

@ -0,0 +1,67 @@
/*
* 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 {
KibanaRequest,
Logger,
SavedObjectsServiceStart,
SECURITY_EXTENSION_ID,
} from '@kbn/core/server';
import { SecurityPluginStart } from '@kbn/security-plugin/server';
import { MaintenanceWindowClient } from './maintenance_window_client';
import { MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE } from '../common';
export interface MaintenanceWindowClientFactoryOpts {
logger: Logger;
savedObjectsService: SavedObjectsServiceStart;
securityPluginStart?: SecurityPluginStart;
}
export class MaintenanceWindowClientFactory {
private isInitialized = false;
private logger!: Logger;
private savedObjectsService!: SavedObjectsServiceStart;
private securityPluginStart?: SecurityPluginStart;
public initialize(options: MaintenanceWindowClientFactoryOpts) {
if (this.isInitialized) {
throw new Error('MaintenanceWindowClientFactory already initialized');
}
this.isInitialized = true;
this.logger = options.logger;
this.savedObjectsService = options.savedObjectsService;
this.securityPluginStart = options.securityPluginStart;
}
private createMaintenanceWindowClient(request: KibanaRequest, withAuth: boolean) {
const { securityPluginStart } = this;
const savedObjectsClient = this.savedObjectsService.getScopedClient(request, {
includedHiddenTypes: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE],
...(withAuth ? {} : { excludedExtensions: [SECURITY_EXTENSION_ID] }),
});
return new MaintenanceWindowClient({
logger: this.logger,
savedObjectsClient,
async getUserName() {
if (!securityPluginStart || !request) {
return null;
}
const user = securityPluginStart.authc.getCurrentUser(request);
return user ? user.username : null;
},
});
}
public createWithAuthorization(request: KibanaRequest) {
return this.createMaintenanceWindowClient(request, true);
}
public create(request: KibanaRequest) {
return this.createMaintenanceWindowClient(request, false);
}
}

View file

@ -0,0 +1,56 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { KibanaFeatureConfig } from '@kbn/features-plugin/common';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server';
import {
MAINTENANCE_WINDOW_FEATURE_ID,
MAINTENANCE_WINDOW_API_PRIVILEGES,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../common';
export const maintenanceWindowFeature: KibanaFeatureConfig = {
id: MAINTENANCE_WINDOW_FEATURE_ID,
name: i18n.translate('xpack.alerting.feature.maintenanceWindowFeatureName', {
defaultMessage: 'Maintenance Window',
}),
category: DEFAULT_APP_CATEGORIES.management,
app: [],
management: {
insightsAndAlerting: ['triggersActions'],
},
privileges: {
all: {
app: [],
api: [
MAINTENANCE_WINDOW_API_PRIVILEGES.READ_MAINTENANCE_WINDOW,
MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW,
],
management: {
insightsAndAlerting: ['triggersActions'],
},
savedObject: {
all: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE],
read: [],
},
ui: ['show', 'save'],
},
read: {
app: [],
api: [MAINTENANCE_WINDOW_API_PRIVILEGES.READ_MAINTENANCE_WINDOW],
management: {
insightsAndAlerting: ['triggersActions'],
},
savedObject: {
all: [],
read: [MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE],
},
ui: ['show'],
},
},
};

View file

@ -59,6 +59,7 @@ import { RuleTypeRegistry } from './rule_type_registry';
import { TaskRunnerFactory } from './task_runner';
import { RulesClientFactory } from './rules_client_factory';
import { RulesSettingsClientFactory } from './rules_settings_client_factory';
import { MaintenanceWindowClientFactory } from './maintenance_window_client_factory';
import { ILicenseState, LicenseState } from './lib/license_state';
import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID } from './types';
import { defineRoutes } from './routes';
@ -94,6 +95,7 @@ import {
errorResult,
} from './alerts_service';
import { rulesSettingsFeature } from './rules_settings_feature';
import { maintenanceWindowFeature } from './maintenance_window_feature';
export const EVENT_LOG_PROVIDER = 'alerting';
export const EVENT_LOG_ACTIONS = {
@ -187,6 +189,7 @@ export class AlertingPlugin {
private readonly rulesClientFactory: RulesClientFactory;
private readonly alertingAuthorizationClientFactory: AlertingAuthorizationClientFactory;
private readonly rulesSettingsClientFactory: RulesSettingsClientFactory;
private readonly maintenanceWindowClientFactory: MaintenanceWindowClientFactory;
private readonly telemetryLogger: Logger;
private readonly kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
private eventLogService?: IEventLogService;
@ -205,6 +208,7 @@ export class AlertingPlugin {
this.alertsService = null;
this.alertingAuthorizationClientFactory = new AlertingAuthorizationClientFactory();
this.rulesSettingsClientFactory = new RulesSettingsClientFactory();
this.maintenanceWindowClientFactory = new MaintenanceWindowClientFactory();
this.telemetryLogger = initializerContext.logger.get('usage');
this.kibanaVersion = initializerContext.env.packageInfo.version;
this.inMemoryMetrics = new InMemoryMetrics(initializerContext.logger.get('in_memory_metrics'));
@ -232,6 +236,8 @@ export class AlertingPlugin {
plugins.features.registerKibanaFeature(rulesSettingsFeature);
plugins.features.registerKibanaFeature(maintenanceWindowFeature);
this.isESOCanEncrypt = plugins.encryptedSavedObjects.canEncrypt;
if (!this.isESOCanEncrypt) {
@ -417,6 +423,7 @@ export class AlertingPlugin {
rulesClientFactory,
alertingAuthorizationClientFactory,
rulesSettingsClientFactory,
maintenanceWindowClientFactory,
security,
licenseState,
} = this;
@ -471,6 +478,12 @@ export class AlertingPlugin {
securityPluginStart: plugins.security,
});
maintenanceWindowClientFactory.initialize({
logger: this.logger,
savedObjectsService: core.savedObjects,
securityPluginStart: plugins.security,
});
const getRulesClientWithRequest = (request: KibanaRequest) => {
if (isESOCanEncrypt !== true) {
throw new Error(
@ -544,7 +557,12 @@ export class AlertingPlugin {
private createRouteHandlerContext = (
core: CoreSetup<AlertingPluginsStart, unknown>
): IContextProvider<AlertingRequestHandlerContext, 'alerting'> => {
const { ruleTypeRegistry, rulesClientFactory, rulesSettingsClientFactory } = this;
const {
ruleTypeRegistry,
rulesClientFactory,
rulesSettingsClientFactory,
maintenanceWindowClientFactory,
} = this;
return async function alertsRouteHandlerContext(context, request) {
const [{ savedObjects }] = await core.getStartServices();
return {
@ -554,6 +572,9 @@ export class AlertingPlugin {
getRulesSettingsClient: () => {
return rulesSettingsClientFactory.createWithAuthorization(request);
},
getMaintenanceWindowClient: () => {
return maintenanceWindowClientFactory.createWithAuthorization(request);
},
listTypes: ruleTypeRegistry!.list.bind(ruleTypeRegistry!),
getFrameworkHealth: async () =>
await getHealth(savedObjects.createInternalRepository(['alert'])),

View file

@ -11,6 +11,10 @@ import type { MethodKeysOf } from '@kbn/utility-types';
import { httpServerMock } from '@kbn/core/server/mocks';
import { rulesClientMock, RulesClientMock } from '../rules_client.mock';
import { rulesSettingsClientMock, RulesSettingsClientMock } from '../rules_settings_client.mock';
import {
maintenanceWindowClientMock,
MaintenanceWindowClientMock,
} from '../maintenance_window_client.mock';
import { AlertsHealth, RuleType } from '../../common';
import type { AlertingRequestHandlerContext } from '../types';
@ -18,12 +22,14 @@ export function mockHandlerArguments(
{
rulesClient = rulesClientMock.create(),
rulesSettingsClient = rulesSettingsClientMock.create(),
maintenanceWindowClient = maintenanceWindowClientMock.create(),
listTypes: listTypesRes = [],
getFrameworkHealth,
areApiKeysEnabled,
}: {
rulesClient?: RulesClientMock;
rulesSettingsClient?: RulesSettingsClientMock;
maintenanceWindowClient?: MaintenanceWindowClientMock;
listTypes?: RuleType[];
getFrameworkHealth?: jest.MockInstance<Promise<AlertsHealth>, []> &
(() => Promise<AlertsHealth>);
@ -47,6 +53,9 @@ export function mockHandlerArguments(
getRulesSettingsClient() {
return rulesSettingsClient || rulesSettingsClientMock.create();
},
getMaintenanceWindowClient() {
return maintenanceWindowClient || maintenanceWindowClientMock.create();
},
getFrameworkHealth,
areApiKeysEnabled: areApiKeysEnabled ? areApiKeysEnabled : () => Promise.resolve(true),
},

View file

@ -46,6 +46,14 @@ import { getFlappingSettingsRoute } from './get_flapping_settings';
import { updateFlappingSettingsRoute } from './update_flapping_settings';
import { getRuleTagsRoute } from './get_rule_tags';
import { createMaintenanceWindowRoute } from './maintenance_window/create_maintenance_window';
import { getMaintenanceWindowRoute } from './maintenance_window/get_maintenance_window';
import { updateMaintenanceWindowRoute } from './maintenance_window/update_maintenance_window';
import { deleteMaintenanceWindowRoute } from './maintenance_window/delete_maintenance_window';
import { findMaintenanceWindowsRoute } from './maintenance_window/find_maintenance_windows';
import { archiveMaintenanceWindowRoute } from './maintenance_window/archive_maintenance_window';
import { finishMaintenanceWindowRoute } from './maintenance_window/finish_maintenance_window';
export interface RouteOptions {
router: IRouter<AlertingRequestHandlerContext>;
licenseState: ILicenseState;
@ -93,4 +101,11 @@ export function defineRoutes(opts: RouteOptions) {
getFlappingSettingsRoute(router, licenseState);
updateFlappingSettingsRoute(router, licenseState);
getRuleTagsRoute(router, licenseState);
createMaintenanceWindowRoute(router, licenseState);
getMaintenanceWindowRoute(router, licenseState);
updateMaintenanceWindowRoute(router, licenseState);
deleteMaintenanceWindowRoute(router, licenseState);
findMaintenanceWindowsRoute(router, licenseState);
archiveMaintenanceWindowRoute(router, licenseState);
finishMaintenanceWindowRoute(router, licenseState);
}

View file

@ -22,3 +22,8 @@ export { rewriteActionsReq, rewriteActionsRes } from './rewrite_actions';
export { actionsSchema } from './actions_schema';
export { rewriteRule, rewriteRuleLastRun } from './rewrite_rule';
export { rewriteNamespaces } from './rewrite_namespaces';
export { rRuleSchema } from './rrule_schema';
export {
rewriteMaintenanceWindowRes,
rewritePartialMaintenanceBodyRes,
} from './rewrite_maintenance_window';

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 { RewriteResponseCase } from '.';
import { MaintenanceWindow } from '../../../common';
export const rewriteMaintenanceWindowRes: RewriteResponseCase<MaintenanceWindow> = ({
expirationDate,
rRule,
createdBy,
updatedBy,
createdAt,
updatedAt,
eventStartTime,
eventEndTime,
...rest
}) => ({
...rest,
expiration_date: expirationDate,
r_rule: rRule,
created_by: createdBy,
updated_by: updatedBy,
created_at: createdAt,
updated_at: updatedAt,
event_start_time: eventStartTime,
event_end_time: eventEndTime,
});
export const rewritePartialMaintenanceBodyRes: RewriteResponseCase<Partial<MaintenanceWindow>> = ({
expirationDate,
rRule,
createdBy,
updatedBy,
createdAt,
updatedAt,
eventStartTime,
eventEndTime,
...rest
}) => ({
...rest,
expiration_date: expirationDate,
r_rule: rRule,
created_by: createdBy,
updated_by: updatedBy,
created_at: createdAt,
updated_at: updatedAt,
event_start_time: eventStartTime,
event_end_time: eventEndTime,
});

View file

@ -0,0 +1,48 @@
/*
* 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';
import { createValidateRruleBy } from '../../lib/validate_rrule_by';
import { validateSnoozeStartDate, validateSnoozeEndDate } from '../../lib/validate_snooze_date';
export const rRuleSchema = schema.object({
dtstart: schema.string({ validate: validateSnoozeStartDate }),
tzid: schema.string(),
freq: schema.maybe(
schema.oneOf([schema.literal(0), schema.literal(1), schema.literal(2), schema.literal(3)])
),
interval: schema.maybe(
schema.number({
validate: (interval: number) => {
if (interval < 1) return 'rRule interval must be > 0';
},
})
),
until: schema.maybe(schema.string({ validate: validateSnoozeEndDate })),
count: schema.maybe(
schema.number({
validate: (count: number) => {
if (count < 1) return 'rRule count must be > 0';
},
})
),
byweekday: schema.maybe(
schema.arrayOf(schema.string(), {
validate: createValidateRruleBy('byweekday'),
})
),
bymonthday: schema.maybe(
schema.arrayOf(schema.number(), {
validate: createValidateRruleBy('bymonthday'),
})
),
bymonth: schema.maybe(
schema.arrayOf(schema.number(), {
validate: createValidateRruleBy('bymonth'),
})
),
});

View file

@ -0,0 +1,117 @@
/*
* 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 { archiveMaintenanceWindowRoute } from './archive_maintenance_window';
import { getMockMaintenanceWindow } from '../../maintenance_window_client/methods/test_helpers';
import { MaintenanceWindowStatus } from '../../../common';
import { rewritePartialMaintenanceBodyRes } from '../lib';
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',
};
describe('archiveMaintenanceWindowRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should archive the maintenance window', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
archiveMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.archive.mockResolvedValueOnce(mockMaintenanceWindow);
const [config, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{
params: {
id: 'test-id',
},
body: {
archive: true,
},
}
);
expect(config.path).toEqual('/internal/alerting/rules/maintenance_window/{id}/_archive');
expect(config.options?.tags?.[0]).toEqual('access:write-maintenance-window');
await handler(context, req, res);
expect(maintenanceWindowClient.archive).toHaveBeenLastCalledWith({
id: 'test-id',
archive: true,
});
expect(res.ok).toHaveBeenLastCalledWith({
body: rewritePartialMaintenanceBodyRes(mockMaintenanceWindow),
});
});
test('ensures the license allows for archiving maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
archiveMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.archive.mockResolvedValueOnce(mockMaintenanceWindow);
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{
params: {
id: 'test-id',
},
body: {
archive: true,
},
}
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
test('ensures the license check prevents for archiving maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
archiveMaintenanceWindowRoute(router, licenseState);
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{
params: {
id: 'test-id',
},
body: {
archive: true,
},
}
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});

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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../../lib';
import { verifyAccessAndContext, rewritePartialMaintenanceBodyRes } from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../types';
import { MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../common';
const paramSchema = schema.object({
id: schema.string(),
});
const bodySchema = schema.object({
archive: schema.boolean(),
});
export const archiveMaintenanceWindowRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}/_archive`,
validate: {
params: paramSchema,
body: bodySchema,
},
options: {
tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient();
const { id } = req.params;
const { archive } = req.body;
const maintenanceWindow = await maintenanceWindowClient.archive({
id,
archive,
});
return res.ok({
body: rewritePartialMaintenanceBodyRes(maintenanceWindow),
});
})
)
);
};

View file

@ -0,0 +1,99 @@
/*
* 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 { createMaintenanceWindowRoute, rewriteQueryReq } from './create_maintenance_window';
import { getMockMaintenanceWindow } from '../../maintenance_window_client/methods/test_helpers';
import { MaintenanceWindowStatus } from '../../../common';
import { rewritePartialMaintenanceBodyRes } from '../lib';
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',
};
const createParams = {
title: 'test-title',
duration: 1000,
r_rule: mockMaintenanceWindow.rRule,
};
describe('createMaintenanceWindowRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should create the maintenance window', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
createMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.create.mockResolvedValueOnce(mockMaintenanceWindow);
const [config, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ body: createParams }
);
expect(config.path).toEqual('/internal/alerting/rules/maintenance_window');
expect(config.options?.tags?.[0]).toEqual('access:write-maintenance-window');
await handler(context, req, res);
expect(maintenanceWindowClient.create).toHaveBeenLastCalledWith(rewriteQueryReq(createParams));
expect(res.ok).toHaveBeenLastCalledWith({
body: rewritePartialMaintenanceBodyRes(mockMaintenanceWindow),
});
});
test('ensures the license allows for creating maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
createMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.create.mockResolvedValueOnce(mockMaintenanceWindow);
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ body: createParams }
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
test('ensures the license check prevents for creating maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
createMaintenanceWindowRoute(router, licenseState);
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ body: createParams }
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});

View file

@ -0,0 +1,63 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../../lib';
import {
verifyAccessAndContext,
rRuleSchema,
RewriteRequestCase,
rewriteMaintenanceWindowRes,
} from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../types';
import { MaintenanceWindowSOProperties, MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../common';
const bodySchema = schema.object({
title: schema.string(),
duration: schema.number(),
r_rule: rRuleSchema,
});
type MaintenanceWindowCreateBody = Omit<
MaintenanceWindowSOProperties,
'events' | 'expirationDate' | 'enabled' | 'archived'
>;
export const rewriteQueryReq: RewriteRequestCase<MaintenanceWindowCreateBody> = ({
r_rule: rRule,
...rest
}) => ({
...rest,
rRule,
});
export const createMaintenanceWindowRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window`,
validate: {
body: bodySchema,
},
options: {
tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient();
const maintenanceWindow = await maintenanceWindowClient.create(rewriteQueryReq(req.body));
return res.ok({
body: rewriteMaintenanceWindowRes(maintenanceWindow),
});
})
)
);
};

View file

@ -0,0 +1,90 @@
/*
* 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 { deleteMaintenanceWindowRoute } from './delete_maintenance_window';
import { getMockMaintenanceWindow } from '../../maintenance_window_client/methods/test_helpers';
import { MaintenanceWindowStatus } from '../../../common';
const maintenanceWindowClient = maintenanceWindowClientMock.create();
jest.mock('../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
const mockMaintenanceWindow = {
...getMockMaintenanceWindow(),
startDate: new Date().toISOString(),
endDate: new Date().toISOString(),
status: MaintenanceWindowStatus.Running,
id: 'test-id',
};
describe('deleteMaintenanceWindowRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should delete the maintenance window', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
deleteMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.delete.mockResolvedValueOnce(mockMaintenanceWindow);
const [config, handler] = router.delete.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
expect(config.path).toEqual('/internal/alerting/rules/maintenance_window/{id}');
expect(config.options?.tags?.[0]).toEqual('access:write-maintenance-window');
await handler(context, req, res);
expect(maintenanceWindowClient.delete).toHaveBeenLastCalledWith({ id: 'test-id' });
expect(res.noContent).toHaveBeenCalledTimes(1);
});
test('ensures the license allows for deleting maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
deleteMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.delete.mockResolvedValueOnce(mockMaintenanceWindow);
const [, handler] = router.delete.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
test('ensures the license check prevents for deleting maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
deleteMaintenanceWindowRoute(router, licenseState);
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
const [, handler] = router.delete.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../../lib';
import { verifyAccessAndContext } from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../types';
import { MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../common';
const paramSchema = schema.object({
id: schema.string(),
});
export const deleteMaintenanceWindowRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.delete(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}`,
validate: {
params: paramSchema,
},
options: {
tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient();
const { id } = req.params;
await maintenanceWindowClient.delete({ id });
return res.noContent();
})
)
);
};

View file

@ -0,0 +1,98 @@
/*
* 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 { findMaintenanceWindowsRoute } from './find_maintenance_windows';
import { getMockMaintenanceWindow } from '../../maintenance_window_client/methods/test_helpers';
import { MaintenanceWindowStatus } from '../../../common';
import { rewriteMaintenanceWindowRes } from '../lib';
const maintenanceWindowClient = maintenanceWindowClientMock.create();
jest.mock('../../lib/license_api_access', () => ({
verifyApiAccess: jest.fn(),
}));
const mockMaintenanceWindows = {
data: [
{
...getMockMaintenanceWindow(),
eventStartTime: new Date().toISOString(),
eventEndTime: new Date().toISOString(),
status: MaintenanceWindowStatus.Running,
id: 'test-id1',
},
{
...getMockMaintenanceWindow(),
eventStartTime: new Date().toISOString(),
eventEndTime: new Date().toISOString(),
status: MaintenanceWindowStatus.Running,
id: 'test-id2',
},
],
};
describe('findMaintenanceWindowsRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should find the maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
findMaintenanceWindowsRoute(router, licenseState);
maintenanceWindowClient.find.mockResolvedValueOnce(mockMaintenanceWindows);
const [config, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments({ maintenanceWindowClient }, { body: {} });
expect(config.path).toEqual('/internal/alerting/rules/maintenance_window/_find');
expect(config.options?.tags?.[0]).toEqual('access:read-maintenance-window');
await handler(context, req, res);
expect(maintenanceWindowClient.find).toHaveBeenCalled();
expect(res.ok).toHaveBeenLastCalledWith({
body: {
data: mockMaintenanceWindows.data.map((data) => rewriteMaintenanceWindowRes(data)),
total: 2,
},
});
});
test('ensures the license allows for finding maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
findMaintenanceWindowsRoute(router, licenseState);
maintenanceWindowClient.find.mockResolvedValueOnce(mockMaintenanceWindows);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments({ maintenanceWindowClient }, { body: {} });
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
test('ensures the license check prevents for finding maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
findMaintenanceWindowsRoute(router, licenseState);
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments({ maintenanceWindowClient }, { body: {} });
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { ILicenseState } from '../../lib';
import { verifyAccessAndContext, rewriteMaintenanceWindowRes } from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../types';
import { MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../common';
export const findMaintenanceWindowsRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/_find`,
validate: {},
options: {
tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.READ_MAINTENANCE_WINDOW}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient();
const result = await maintenanceWindowClient.find();
return res.ok({
body: {
data: result.data.map((maintenanceWindow) =>
rewriteMaintenanceWindowRes(maintenanceWindow)
),
total: result.data.length,
},
});
})
)
);
};

View file

@ -0,0 +1,93 @@
/*
* 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 { finishMaintenanceWindowRoute } from './finish_maintenance_window';
import { getMockMaintenanceWindow } from '../../maintenance_window_client/methods/test_helpers';
import { MaintenanceWindowStatus } from '../../../common';
import { rewritePartialMaintenanceBodyRes } from '../lib';
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',
};
describe('finishMaintenanceWindowRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should finish the maintenance window', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
finishMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.finish.mockResolvedValueOnce(mockMaintenanceWindow);
const [config, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
expect(config.path).toEqual('/internal/alerting/rules/maintenance_window/{id}/_finish');
expect(config.options?.tags?.[0]).toEqual('access:write-maintenance-window');
await handler(context, req, res);
expect(maintenanceWindowClient.finish).toHaveBeenLastCalledWith({ id: 'test-id' });
expect(res.ok).toHaveBeenLastCalledWith({
body: rewritePartialMaintenanceBodyRes(mockMaintenanceWindow),
});
});
test('ensures the license allows for finishing maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
finishMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.finish.mockResolvedValueOnce(mockMaintenanceWindow);
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
test('ensures the license check prevents for finishing maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
finishMaintenanceWindowRoute(router, licenseState);
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});

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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../../lib';
import { verifyAccessAndContext, rewritePartialMaintenanceBodyRes } from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../types';
import { MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../common';
const paramSchema = schema.object({
id: schema.string(),
});
export const finishMaintenanceWindowRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}/_finish`,
validate: {
params: paramSchema,
},
options: {
tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient();
const maintenanceWindow = await maintenanceWindowClient.finish(req.params);
return res.ok({
body: rewritePartialMaintenanceBodyRes(maintenanceWindow),
});
})
)
);
};

View file

@ -0,0 +1,93 @@
/*
* 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 { getMaintenanceWindowRoute } from './get_maintenance_window';
import { getMockMaintenanceWindow } from '../../maintenance_window_client/methods/test_helpers';
import { MaintenanceWindowStatus } from '../../../common';
import { rewritePartialMaintenanceBodyRes } from '../lib';
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',
};
describe('getMaintenanceWindowRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should get the maintenance window', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.get.mockResolvedValueOnce(mockMaintenanceWindow);
const [config, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
expect(config.path).toEqual('/internal/alerting/rules/maintenance_window/{id}');
expect(config.options?.tags?.[0]).toEqual('access:read-maintenance-window');
await handler(context, req, res);
expect(maintenanceWindowClient.get).toHaveBeenLastCalledWith({ id: 'test-id' });
expect(res.ok).toHaveBeenLastCalledWith({
body: rewritePartialMaintenanceBodyRes(mockMaintenanceWindow),
});
});
test('ensures the license allows for getting maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.get.mockResolvedValueOnce(mockMaintenanceWindow);
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
test('ensures the license check prevents for getting maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
getMaintenanceWindowRoute(router, licenseState);
(verifyApiAccess as jest.Mock).mockImplementation(() => {
throw new Error('Failure');
});
const [, handler] = router.get.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{ params: { id: 'test-id' } }
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});

View file

@ -0,0 +1,44 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../../lib';
import { verifyAccessAndContext, rewriteMaintenanceWindowRes } from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../types';
import { MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../common';
const paramSchema = schema.object({
id: schema.string(),
});
export const getMaintenanceWindowRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.get(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}`,
validate: {
params: paramSchema,
},
options: {
tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.READ_MAINTENANCE_WINDOW}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient();
const { id } = req.params;
const maintenanceWindow = await maintenanceWindowClient.get({ id });
return res.ok({
body: rewriteMaintenanceWindowRes(maintenanceWindow),
});
})
)
);
};

View file

@ -0,0 +1,118 @@
/*
* 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 { RRule } from 'rrule';
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, rewriteQueryReq } from './update_maintenance_window';
import { getMockMaintenanceWindow } from '../../maintenance_window_client/methods/test_helpers';
import { MaintenanceWindowStatus } from '../../../common';
import { rewritePartialMaintenanceBodyRes } from '../lib';
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',
};
const updateParams = {
title: 'new-title',
duration: 5000,
enabled: false,
r_rule: {
tzid: 'CET',
dtstart: '2023-03-26T00:00:00.000Z',
freq: RRule.WEEKLY,
count: 10,
},
};
describe('updateMaintenanceWindowRoute', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should update the maintenance window', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
updateMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.update.mockResolvedValueOnce(mockMaintenanceWindow);
const [config, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{
params: { id: 'test-id' },
body: updateParams,
}
);
expect(config.path).toEqual('/internal/alerting/rules/maintenance_window/{id}');
expect(config.options?.tags?.[0]).toEqual('access:write-maintenance-window');
await handler(context, req, res);
expect(maintenanceWindowClient.update).toHaveBeenLastCalledWith({
id: 'test-id',
...rewriteQueryReq(updateParams),
});
expect(res.ok).toHaveBeenLastCalledWith({
body: rewritePartialMaintenanceBodyRes(mockMaintenanceWindow),
});
});
test('ensures the license allows for updating maintenance windows', async () => {
const licenseState = licenseStateMock.create();
const router = httpServiceMock.createRouter();
updateMaintenanceWindowRoute(router, licenseState);
maintenanceWindowClient.update.mockResolvedValueOnce(mockMaintenanceWindow);
const [, handler] = router.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{
params: { id: 'test-id' },
body: updateParams,
}
);
await handler(context, req, res);
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
});
test('ensures the license check prevents for 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.post.mock.calls[0];
const [context, req, res] = mockHandlerArguments(
{ maintenanceWindowClient },
{
params: { id: 'test-id' },
body: updateParams,
}
);
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
});
});

View file

@ -0,0 +1,74 @@
/*
* 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 { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState } from '../../lib';
import {
verifyAccessAndContext,
rRuleSchema,
RewriteRequestCase,
rewritePartialMaintenanceBodyRes,
} from '../lib';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../../types';
import { MaintenanceWindowSOProperties, MAINTENANCE_WINDOW_API_PRIVILEGES } from '../../../common';
const paramSchema = schema.object({
id: schema.string(),
});
const bodySchema = schema.object({
title: schema.maybe(schema.string()),
enabled: schema.maybe(schema.boolean()),
duration: schema.maybe(schema.number()),
r_rule: schema.maybe(rRuleSchema),
});
interface MaintenanceWindowUpdateBody {
title?: MaintenanceWindowSOProperties['title'];
enabled?: MaintenanceWindowSOProperties['enabled'];
duration?: MaintenanceWindowSOProperties['duration'];
rRule?: MaintenanceWindowSOProperties['rRule'];
}
export const rewriteQueryReq: RewriteRequestCase<MaintenanceWindowUpdateBody> = ({
r_rule: rRule,
...rest
}) => ({
...rest,
...(rRule ? { rRule } : {}),
});
export const updateMaintenanceWindowRoute = (
router: IRouter<AlertingRequestHandlerContext>,
licenseState: ILicenseState
) => {
router.post(
{
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/maintenance_window/{id}`,
validate: {
body: bodySchema,
params: paramSchema,
},
options: {
tags: [`access:${MAINTENANCE_WINDOW_API_PRIVILEGES.WRITE_MAINTENANCE_WINDOW}`],
},
},
router.handleLegacyErrors(
verifyAccessAndContext(licenseState, async function (context, req, res) {
const maintenanceWindowClient = (await context.alerting).getMaintenanceWindowClient();
const maintenanceWindow = await maintenanceWindowClient.update({
id: req.params.id,
...rewriteQueryReq(req.body),
});
return res.ok({
body: rewritePartialMaintenanceBodyRes(maintenanceWindow),
});
})
)
);
};

View file

@ -8,11 +8,9 @@
import { IRouter } from '@kbn/core/server';
import { schema } from '@kbn/config-schema';
import { ILicenseState, RuleMutedError } from '../lib';
import { verifyAccessAndContext } from './lib';
import { verifyAccessAndContext, rRuleSchema } from './lib';
import { SnoozeOptions } from '../rules_client';
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
import { validateSnoozeStartDate, validateSnoozeEndDate } from '../lib/validate_snooze_date';
import { createValidateRruleBy } from '../lib/validate_rrule_by';
import { validateSnoozeSchedule } from '../lib/validate_snooze_schedule';
const paramSchema = schema.object({
@ -23,43 +21,7 @@ export const snoozeScheduleSchema = schema.object(
{
id: schema.maybe(schema.string()),
duration: schema.number(),
rRule: schema.object({
dtstart: schema.string({ validate: validateSnoozeStartDate }),
tzid: schema.string(),
freq: schema.maybe(
schema.oneOf([schema.literal(0), schema.literal(1), schema.literal(2), schema.literal(3)])
),
interval: schema.maybe(
schema.number({
validate: (interval: number) => {
if (interval < 1) return 'rRule interval must be > 0';
},
})
),
until: schema.maybe(schema.string({ validate: validateSnoozeEndDate })),
count: schema.maybe(
schema.number({
validate: (count: number) => {
if (count < 1) return 'rRule count must be > 0';
},
})
),
byweekday: schema.maybe(
schema.arrayOf(schema.string(), {
validate: createValidateRruleBy('byweekday'),
})
),
bymonthday: schema.maybe(
schema.arrayOf(schema.number(), {
validate: createValidateRruleBy('bymonthday'),
})
),
bymonth: schema.maybe(
schema.arrayOf(schema.number(), {
validate: createValidateRruleBy('bymonth'),
})
),
}),
rRule: rRuleSchema,
},
{ validate: validateSnoozeSchedule }
);

View file

@ -93,7 +93,7 @@ export class RulesSettingsClient {
/**
* Helper function to ensure that a rules-settings saved object always exists.
* Enabled the creation of the saved object is done lazily during retrieval.
* Ensures the creation of the saved object is done lazily during retrieval.
*/
private async getOrCreate(): Promise<SavedObject<RulesSettings>> {
try {

View file

@ -15,6 +15,7 @@ import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-p
import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common';
import { alertMappings } from './mappings';
import { rulesSettingsMappings } from './rules_settings_mappings';
import { maintenanceWindowMappings } from './maintenance_window_mapping';
import { getMigrations } from './migrations';
import { transformRulesForExport } from './transform_rule_for_export';
import { RawRule } from '../types';
@ -22,7 +23,10 @@ import { getImportWarnings } from './get_import_warnings';
import { isRuleExportable } from './is_rule_exportable';
import { RuleTypeRegistry } from '../rule_type_registry';
export { partiallyUpdateAlert } from './partially_update_alert';
import { RULES_SETTINGS_SAVED_OBJECT_TYPE } from '../../common';
import {
RULES_SETTINGS_SAVED_OBJECT_TYPE,
MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
} from '../../common';
// Use caution when removing items from this array! Any field which has
// ever existed in the rule SO must be included in this array to prevent
@ -125,6 +129,13 @@ export function setupSavedObjects(
mappings: rulesSettingsMappings,
});
savedObjects.registerType({
name: MAINTENANCE_WINDOW_SAVED_OBJECT_TYPE,
hidden: true,
namespaceType: 'multiple-isolated',
mappings: maintenanceWindowMappings,
});
// Encrypted attributes
encryptedSavedObjects.registerType({
type: 'alert',

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 { SavedObjectsTypeMappingDefinition } from '@kbn/core/server';
export const maintenanceWindowMappings: SavedObjectsTypeMappingDefinition = {
dynamic: false,
properties: {
enabled: {
type: 'boolean',
},
events: {
type: 'date_range',
format: 'epoch_millis||strict_date_optional_time',
},
// NO NEED TO BE INDEXED
// title: {
// type: 'text',
// fields: {
// keyword: {
// type: 'keyword',
// },
// },
// },
// duration: {
// type: 'long',
// },
// expirationDate: {
// type: 'date',
// },
// rRule: rRuleMappingsField,
// createdBy: {
// index: false,
// type: 'keyword',
// },
// updatedBy: {
// index: false,
// type: 'keyword',
// },
// createdAt: {
// index: false,
// type: 'date',
// },
// updatedAt: {
// index: false,
// type: 'date',
// },
},
};

View file

@ -6,6 +6,7 @@
*/
import { SavedObjectsTypeMappingDefinition } from '@kbn/core/server';
import { rRuleMappingsField } from './rrule_mappings_field';
export const alertMappings: SavedObjectsTypeMappingDefinition = {
dynamic: false,
@ -216,61 +217,7 @@ export const alertMappings: SavedObjectsTypeMappingDefinition = {
type: 'date',
format: 'strict_date_time',
},
rRule: {
type: 'nested',
properties: {
freq: {
type: 'keyword',
},
dtstart: {
type: 'date',
format: 'strict_date_time',
},
tzid: {
type: 'keyword',
},
until: {
type: 'date',
format: 'strict_date_time',
},
count: {
type: 'long',
},
interval: {
type: 'long',
},
wkst: {
type: 'keyword',
},
byweekday: {
type: 'keyword',
},
bymonth: {
type: 'short',
},
bysetpos: {
type: 'long',
},
bymonthday: {
type: 'short',
},
byyearday: {
type: 'short',
},
byweekno: {
type: 'short',
},
byhour: {
type: 'long',
},
byminute: {
type: 'long',
},
bysecond: {
type: 'long',
},
},
},
rRule: rRuleMappingsField,
},
},
nextRun: {

View file

@ -0,0 +1,64 @@
/*
* 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 { SavedObjectsFieldMapping } from '@kbn/core/server';
export const rRuleMappingsField: SavedObjectsFieldMapping = {
type: 'nested',
properties: {
freq: {
type: 'keyword',
},
dtstart: {
type: 'date',
format: 'strict_date_time',
},
tzid: {
type: 'keyword',
},
until: {
type: 'date',
format: 'strict_date_time',
},
count: {
type: 'long',
},
interval: {
type: 'long',
},
wkst: {
type: 'keyword',
},
byweekday: {
type: 'keyword',
},
bymonth: {
type: 'short',
},
bysetpos: {
type: 'long',
},
bymonthday: {
type: 'short',
},
byyearday: {
type: 'short',
},
byweekno: {
type: 'short',
},
byhour: {
type: 'long',
},
byminute: {
type: 'long',
},
bysecond: {
type: 'long',
},
},
};

View file

@ -27,6 +27,7 @@ import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry';
import { PluginSetupContract, PluginStartContract } from './plugin';
import { RulesClient } from './rules_client';
import { RulesSettingsClient, RulesSettingsFlappingClient } from './rules_settings_client';
import { MaintenanceWindowClient } from './maintenance_window_client';
export * from '../common';
import {
Rule,
@ -64,6 +65,7 @@ export type { RuleTypeParams };
export interface AlertingApiRequestHandlerContext {
getRulesClient: () => RulesClient;
getRulesSettingsClient: () => RulesSettingsClient;
getMaintenanceWindowClient: () => MaintenanceWindowClient;
listTypes: RuleTypeRegistry['list'];
getFrameworkHealth: () => Promise<AlertsHealth>;
areApiKeysEnabled: () => Promise<boolean>;
@ -403,6 +405,8 @@ export type RulesClientApi = PublicMethodsOf<RulesClient>;
export type RulesSettingsClientApi = PublicMethodsOf<RulesSettingsClient>;
export type RulesSettingsFlappingClientApi = PublicMethodsOf<RulesSettingsFlappingClient>;
export type MaintenanceWindowClientApi = PublicMethodsOf<MaintenanceWindowClient>;
export interface PublicMetricsSetters {
setLastRunMetricsTotalSearchDurationMs: (totalSearchDurationMs: number) => void;
setLastRunMetricsTotalIndexingDurationMs: (totalIndexingDurationMs: number) => void;

View file

@ -12,6 +12,7 @@ interface ObjectToRemove {
id: string;
type: string;
plugin: string;
isInternal?: boolean;
}
export class ObjectRemover {
@ -26,16 +27,19 @@ export class ObjectRemover {
spaceId: ObjectToRemove['spaceId'],
id: ObjectToRemove['id'],
type: ObjectToRemove['type'],
plugin: ObjectToRemove['plugin']
plugin: ObjectToRemove['plugin'],
isInternal?: ObjectToRemove['isInternal']
) {
this.objectsToRemove.push({ spaceId, id, type, plugin });
this.objectsToRemove.push({ spaceId, id, type, plugin, isInternal });
}
async removeAll() {
await Promise.all(
this.objectsToRemove.map(({ spaceId, id, type, plugin }) => {
this.objectsToRemove.map(({ spaceId, id, type, plugin, isInternal }) => {
return this.supertest
.delete(`${getUrlPrefix(spaceId)}/api/${plugin}/${type}/${id}`)
.delete(
`${getUrlPrefix(spaceId)}/${isInternal ? 'internal' : 'api'}/${plugin}/${type}/${id}`
)
.set('kbn-xsrf', 'foo')
.expect(204);
})

View file

@ -11,5 +11,6 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context';
export default function alertingApiIntegrationTests({ loadTestFile }: FtrProviderContext) {
describe('alerting api integration security and spaces enabled - Group 3', function () {
loadTestFile(require.resolve('./alerting'));
loadTestFile(require.resolve('./maintenance_window'));
});
}

View file

@ -0,0 +1,88 @@
/*
* 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 moment from 'moment';
import expect from '@kbn/expect';
import { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function updateMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('archiveMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
},
};
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle archive maintenance window request appropriately', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams);
objectRemover.add(
space.id,
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
const response = await supertestWithoutAuth
.post(
`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}/_archive`
)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send({ archive: true });
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'global_read at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(200);
expect(
moment
.utc(createdMaintenanceWindow.expirationDate)
.isAfter(response.body.expirationDate)
);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -0,0 +1,80 @@
/*
* 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 { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function createMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('createMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
},
};
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle create maintenance window request appropriately', async () => {
const response = await supertestWithoutAuth
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send(createParams);
if (response.body.id) {
objectRemover.add(
space.id,
response.body.id,
'rules/maintenance_window',
'alerting',
true
);
}
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'global_read at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(200);
expect(response.body.title).to.eql('test-maintenance-window');
expect(response.body.duration).to.eql(3600000);
expect(response.body.r_rule.dtstart).to.eql(createParams.r_rule.dtstart);
expect(response.body.events.length).to.be.greaterThan(0);
expect(response.body.status).to.eql('running');
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -0,0 +1,90 @@
/*
* 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 { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function deleteMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('deleteMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
},
};
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle delete maintenance window request appropriately', async () => {
const { body: maintenanceWindowBody } = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams);
const response = await supertestWithoutAuth
.delete(
`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window/${
maintenanceWindowBody.id
}`
)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'global_read at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
objectRemover.add(
space.id,
maintenanceWindowBody.id,
'rules/maintenance_window',
'alerting',
true
);
break;
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(204);
const getResponse = await supertest
.get(
`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window/${
maintenanceWindowBody.id
}`
)
.set('kbn-xsrf', 'foo');
expect(getResponse.body.statusCode).to.eql(404);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -0,0 +1,97 @@
/*
* 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 { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function findMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('findMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
},
};
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
afterEach(() => objectRemover.removeAll());
it('should handle update maintenance window request appropriately', async () => {
const { body: createdMaintenanceWindow1 } = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams);
const { body: createdMaintenanceWindow2 } = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams);
objectRemover.add(
space.id,
createdMaintenanceWindow1.id,
'rules/maintenance_window',
'alerting',
true
);
objectRemover.add(
space.id,
createdMaintenanceWindow2.id,
'rules/maintenance_window',
'alerting',
true
);
const response = await supertestWithoutAuth
.get(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window/_find`)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send({});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'global_read at space1':
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.body.total).to.eql(2);
expect(response.statusCode).to.eql(200);
expect(response.body.data[0].title).to.eql('test-maintenance-window');
expect(response.body.data[0].duration).to.eql(3600000);
expect(response.body.data[0].r_rule.dtstart).to.eql(createParams.r_rule.dtstart);
expect(response.body.data[0].events.length).to.be.greaterThan(0);
expect(response.body.data[0].status).to.eql('running');
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -0,0 +1,84 @@
/*
* 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 { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function findMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('finishMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
},
};
afterEach(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle finish maintenance window request appropriately', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams);
objectRemover.add(
space.id,
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
expect(createdMaintenanceWindow.status).to.eql('running');
const response = await supertestWithoutAuth
.post(
`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}/_finish`
)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send();
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'global_read at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.body.status).to.eql('upcoming');
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -0,0 +1,85 @@
/*
* 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 { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function getMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('getMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
},
};
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should get maintenance window correctly', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams);
objectRemover.add(
space.id,
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
const response = await supertestWithoutAuth
.get(
`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}`
)
.auth(user.username, user.password);
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'global_read at space1':
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(200);
expect(response.body.title).to.eql('test-maintenance-window');
expect(response.body.duration).to.eql(3600000);
expect(response.body.r_rule.dtstart).to.eql(createParams.r_rule.dtstart);
expect(response.body.events.length).to.be.greaterThan(0);
expect(response.body.status).to.eql('running');
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -0,0 +1,32 @@
/*
* 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';
import { setupSpacesAndUsers, tearDown } from '../../../setup';
// eslint-disable-next-line import/no-default-export
export default function maintenanceWindowTests({ loadTestFile, getService }: FtrProviderContext) {
describe('Maintenance Window - Group 3', () => {
describe('maintenance window', () => {
before(async () => {
await setupSpacesAndUsers(getService);
});
after(async () => {
await tearDown(getService);
});
loadTestFile(require.resolve('./get_maintenance_window'));
loadTestFile(require.resolve('./create_maintenance_window'));
loadTestFile(require.resolve('./update_maintenance_window'));
loadTestFile(require.resolve('./delete_maintenance_window'));
loadTestFile(require.resolve('./archive_maintenance_window'));
loadTestFile(require.resolve('./finish_maintenance_window'));
loadTestFile(require.resolve('./find_maintenance_windows'));
});
});
}

View file

@ -0,0 +1,92 @@
/*
* 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 { UserAtSpaceScenarios } from '../../../scenarios';
import { getUrlPrefix, ObjectRemover } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function updateMaintenanceWindowTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('updateMaintenanceWindow', () => {
const objectRemover = new ObjectRemover(supertest);
const createParams = {
title: 'test-maintenance-window',
duration: 60 * 60 * 1000, // 1 hr
r_rule: {
dtstart: new Date().toISOString(),
tzid: 'UTC',
freq: 2, // weekly
},
};
after(() => objectRemover.removeAll());
for (const scenario of UserAtSpaceScenarios) {
const { user, space } = scenario;
describe(scenario.id, () => {
it('should handle update maintenance window request appropriately', async () => {
const { body: createdMaintenanceWindow } = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window`)
.set('kbn-xsrf', 'foo')
.send(createParams);
objectRemover.add(
space.id,
createdMaintenanceWindow.id,
'rules/maintenance_window',
'alerting',
true
);
const response = await supertestWithoutAuth
.post(
`${getUrlPrefix(space.id)}/internal/alerting/rules/maintenance_window/${
createdMaintenanceWindow.id
}`
)
.set('kbn-xsrf', 'foo')
.auth(user.username, user.password)
.send({
...createParams,
enabled: true,
title: 'updated-title',
duration: 2 * 60 * 60 * 1000, // 2 hrs
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'global_read at space1':
case 'space_1_all at space2':
case 'space_1_all_with_restricted_fixture at space1':
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: 'Forbidden',
statusCode: 403,
});
break;
case 'superuser at space1':
case 'space_1_all at space1':
expect(response.statusCode).to.eql(200);
expect(response.body.title).to.eql('updated-title');
expect(response.body.duration).to.eql(7200000);
expect(response.body.r_rule.dtstart).to.eql(createParams.r_rule.dtstart);
expect(response.body.events.length).to.be.greaterThan(0);
expect(response.body.status).to.eql('running');
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
});
}
});
}

View file

@ -56,6 +56,7 @@ const GlobalRead: User = {
alertsRestrictedFixture: ['read'],
actionsSimulators: ['read'],
rulesSettings: ['read', READ_FLAPPING_SETTINGS_SUB_FEATURE_ID],
maintenanceWindow: ['read'],
},
spaces: ['*'],
},
@ -84,6 +85,7 @@ const Space1All: User = {
alertsFixture: ['all'],
actionsSimulators: ['all'],
rulesSettings: ['all', ALL_FLAPPING_SETTINGS_SUB_FEATURE_ID],
maintenanceWindow: ['all'],
},
spaces: ['space1'],
},

View file

@ -116,6 +116,7 @@ export default function ({ getService }: FtrProviderContext) {
'generalCases',
'infrastructure',
'logs',
'maintenanceWindow',
'maps',
'osquery',
'rulesSettings',

View file

@ -102,6 +102,7 @@ export default function ({ getService }: FtrProviderContext) {
'allFlappingSettings',
'readFlappingSettings',
],
maintenanceWindow: ['all', 'read', 'minimal_all', 'minimal_read'],
},
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
};

View file

@ -49,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) {
filesManagement: ['all', 'read', 'minimal_all', 'minimal_read'],
filesSharedImage: ['all', 'read', 'minimal_all', 'minimal_read'],
rulesSettings: ['all', 'read', 'minimal_all', 'minimal_read'],
maintenanceWindow: ['all', 'read', 'minimal_all', 'minimal_read'],
},
global: ['all', 'read'],
space: ['all', 'read'],
@ -173,6 +174,7 @@ export default function ({ getService }: FtrProviderContext) {
'allFlappingSettings',
'readFlappingSettings',
],
maintenanceWindow: ['all', 'read', 'minimal_all', 'minimal_read'],
},
reserved: ['fleet-setup', 'ml_user', 'ml_admin', 'ml_apm_user', 'monitoring'],
};