diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts index 29f11c340f3e9..a2ab5850e2051 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts @@ -16,3 +16,4 @@ export const ALERTING_V2_INTERNAL_SUGGEST_USER_PROFILES_API_PATH = '/api/alerting/v2/internal/user_profiles/_suggest' as const; export const ALERTING_V2_RULE_DOCTOR_INSIGHTS_API_PATH = '/api/alerting/v2/rule_doctor/insights' as const; +export const ALERTING_V2_RULE_DOCTOR_RUN_API_PATH = '/api/alerting/v2/rule_doctor/run' as const; diff --git a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc index 4f98e2190f87e..097b0abf45d82 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc +++ b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc @@ -21,6 +21,7 @@ "security", "encryptedSavedObjects", "workflowsManagement", + "workflowsExtensions", "expressions", "uiActions", "fieldFormats", diff --git a/x-pack/platform/plugins/shared/alerting_v2/moon.yml b/x-pack/platform/plugins/shared/alerting_v2/moon.yml index 6c2df3a6956a5..09c6311b843f0 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/moon.yml +++ b/x-pack/platform/plugins/shared/alerting_v2/moon.yml @@ -103,6 +103,12 @@ dependsOn: - '@kbn/unified-doc-viewer-plugin' - '@kbn/core-user-profile-browser' - '@kbn/user-profile-components' + - '@kbn/core-logging-server-mocks' + - '@kbn/core-ui-settings-server-mocks' + - '@kbn/core-saved-objects-server-mocks' + - '@kbn/core-ui-settings-server' + - '@kbn/management-settings-ids' + - '@kbn/workflows-extensions' - '@kbn/esql-types' - '@kbn/agent-builder-plugin' - '@kbn/agent-builder-server' diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.test.ts index 0d42085f4ef0a..9b189be1ddcf6 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.test.ts @@ -7,6 +7,7 @@ import type { ElasticsearchClient } from '@kbn/core/server'; import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mocks'; import { @@ -14,6 +15,7 @@ import { type RuleDoctorInsightDoc, } from '../../resources/indices/rule_doctor_insights'; import type { LoggerServiceContract } from '../services/logger_service/logger_service'; +import { createMockResourceManager } from '../services/resource_service/resource_manager.mock'; import { RuleDoctorInsightsClient } from './rule_doctor_insights_client'; const mockLoggerService: jest.Mocked = { @@ -37,8 +39,8 @@ const makeInsight = (overrides: Partial = {}): RuleDoctorI justification: '', rule_ids: ['rule-1', 'rule-2'], data: {}, - current: null, - proposed: null, + current: {}, + proposed: {}, space_id: 'default', ...overrides, }); @@ -260,4 +262,31 @@ describe('RuleDoctorInsightsClient', () => { ); }); }); + + describe('ensureIndex', () => { + it('calls ensureResourceRegistered when resourceManager is provided', async () => { + const resourceManager = createMockResourceManager(); + resourceManager.ensureResourceRegistered.mockResolvedValue(undefined); + const rawLogger = loggingSystemMock.createLogger(); + + const clientWithRM = new RuleDoctorInsightsClient( + esClient, + mockLoggerService, + resourceManager, + rawLogger + ); + await clientWithRM.ensureIndex(); + + expect(resourceManager.ensureResourceRegistered).toHaveBeenCalledWith( + `index:${RULE_DOCTOR_INSIGHTS_INDEX}`, + expect.objectContaining({ initialize: expect.any(Function) }), + { optional: true } + ); + }); + + it('is a no-op when resourceManager is not provided', async () => { + const clientWithoutRM = new RuleDoctorInsightsClient(esClient, mockLoggerService); + await expect(clientWithoutRM.ensureIndex()).resolves.toBeUndefined(); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.ts index fb54c398bf9bf..c87c5e79c9317 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/rule_doctor_insights_client.ts @@ -7,16 +7,26 @@ import Boom from '@hapi/boom'; import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; -import type { ElasticsearchClient } from '@kbn/core/server'; +import type { ElasticsearchClient, Logger } from '@kbn/core/server'; import { inject, injectable } from 'inversify'; import type { LoggerServiceContract } from '../services/logger_service/logger_service'; import { LoggerServiceToken } from '../services/logger_service/logger_service'; +import type { ResourceManagerContract } from '../services/resource_service/resource_manager'; +import { IndexInitializer } from '../services/resource_service/index_initializer'; import { RULE_DOCTOR_INSIGHTS_INDEX, + getRuleDoctorInsightsResourceDefinition, + ruleDoctorInsightStatus, type RuleDoctorInsightDoc, type RuleDoctorInsightStatus, } from '../../resources/indices/rule_doctor_insights'; -import type { ListInsightsParams, ListInsightsResult, BulkIndexInsightsResult } from './types'; +import type { + ListInsightsParams, + ListInsightsResult, + BulkIndexInsightsResult, + BulkDismissInsightsResult, + PersistFindingsResult, +} from './types'; const DEFAULT_PAGE_SIZE = 20; @@ -24,9 +34,20 @@ const DEFAULT_PAGE_SIZE = 20; export class RuleDoctorInsightsClient { constructor( private readonly esClient: ElasticsearchClient, - @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract + @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, + private readonly resourceManager?: ResourceManagerContract, + private readonly rawLogger?: Logger ) {} + public async ensureIndex(): Promise { + if (!this.resourceManager || !this.rawLogger) { + return; + } + const def = getRuleDoctorInsightsResourceDefinition(); + const initializer = new IndexInitializer(this.rawLogger, this.esClient, def); + await this.resourceManager.ensureResourceRegistered(def.key, initializer, { optional: true }); + } + public async listInsights(params: ListInsightsParams): Promise { const { from = 0, size = DEFAULT_PAGE_SIZE } = params; @@ -140,6 +161,58 @@ export class RuleDoctorInsightsClient { return { indexed, failed }; } + public async bulkDismissInsights( + insightIds: string[], + spaceId: string + ): Promise { + if (insightIds.length === 0) { + return { dismissed: 0, failed: 0 }; + } + + const operations = insightIds.flatMap((insightId) => [ + { update: { _index: RULE_DOCTOR_INSIGHTS_INDEX, _id: `${spaceId}:${insightId}` } }, + { doc: { status: ruleDoctorInsightStatus.dismissed } }, + ]); + + const response = await this.esClient.bulk({ operations, refresh: 'wait_for' }); + + const failed = response.items.filter((item) => item.update?.error).length; + const dismissed = insightIds.length - failed; + + if (response.errors) { + this.logger.warn({ + message: `RuleDoctorInsightsClient: failed to dismiss ${failed} insights`, + }); + } + + this.logger.debug({ + message: `RuleDoctorInsightsClient: dismissed ${dismissed} insights (${failed} failed)`, + }); + + return { dismissed, failed }; + } + + public async persistFindings(params: { + insights: RuleDoctorInsightDoc[]; + dismissIds: string[]; + spaceId: string; + }): Promise { + const { insights, dismissIds, spaceId } = params; + + const indexResult = await this.bulkIndexInsights(insights); + const dismissResult = await this.bulkDismissInsights(dismissIds, spaceId); + + this.logger.debug({ + message: `RuleDoctorInsightsClient: persisted ${indexResult.indexed} insights (${indexResult.failed} failed), dismissed ${dismissResult.dismissed}`, + }); + + return { + indexed: indexResult.indexed, + failed: indexResult.failed + dismissResult.failed, + dismissed: dismissResult.dismissed, + }; + } + private buildFilterQuery(params: ListInsightsParams): QueryDslQueryContainer { const filters: QueryDslQueryContainer[] = [{ term: { space_id: params.spaceId } }]; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/types.ts index 3e747202f98d7..792fa8c157a66 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_doctor_insights_client/types.ts @@ -29,3 +29,14 @@ export interface BulkIndexInsightsResult { indexed: number; failed: number; } + +export interface BulkDismissInsightsResult { + dismissed: number; + failed: number; +} + +export interface PersistFindingsResult { + indexed: number; + failed: number; + dismissed: number; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.mock.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.mock.ts index f0b5c2dc16d33..fb8e385558e86 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.mock.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.mock.ts @@ -13,8 +13,10 @@ export function createMockResourceManager() { registerResource: jest.fn(), startInitialization: jest.fn(), waitUntilReady: jest.fn(), + isRegistered: jest.fn(), isReady: jest.fn(), ensureResourceReady: jest.fn(), + ensureResourceRegistered: jest.fn(), } satisfies ResourceManagerMock; return resourceManager; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.test.ts index 62e5bd7b969d0..525667d8cb88a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.test.ts @@ -94,6 +94,45 @@ describe('ResourceManager', () => { } }); + it('isRegistered returns true for registered resources and false for unknown keys', () => { + const manager = createManager(); + const init = createInitializer(async () => {}); + + expect(manager.isRegistered('r1')).toBe(false); + + manager.registerResource('r1', init); + expect(manager.isRegistered('r1')).toBe(true); + expect(manager.isRegistered('unknown')).toBe(false); + }); + + describe('ensureResourceRegistered', () => { + it('registers and initializes a new resource', async () => { + const manager = createManager(); + const init = createInitializer(async () => {}); + + await manager.ensureResourceRegistered('lazy', init); + + expect(manager.isRegistered('lazy')).toBe(true); + expect(manager.isReady('lazy')).toBe(true); + expect(init.initialize).toHaveBeenCalledTimes(1); + }); + + it('skips registration when the resource already exists', async () => { + const manager = createManager(); + const first = createInitializer(async () => {}); + const second = createInitializer(async () => {}); + + manager.registerResource('r1', first); + manager.startInitialization(); + await manager.waitUntilReady(); + + await manager.ensureResourceRegistered('r1', second); + + expect(first.initialize).toHaveBeenCalledTimes(1); + expect(second.initialize).not.toHaveBeenCalled(); + }); + }); + it('throws if ensureResourceReady is called for an unregistered resource', async () => { const manager = createManager(); await expect(manager.ensureResourceReady('missing')).rejects.toThrow( diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.ts index d5588edf04927..b25783d11814d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/resource_service/resource_manager.ts @@ -41,8 +41,14 @@ export interface ResourceManagerContract { ): void; startInitialization(options?: { resourceKeys?: string[] }): void; waitUntilReady(): Promise; + isRegistered(key: string): boolean; isReady(key: string): boolean; ensureResourceReady(key: string): Promise; + ensureResourceRegistered( + key: string, + initializer: IResourceInitializer, + options?: RegisterResourceOptions + ): Promise; } @injectable() @@ -132,6 +138,10 @@ export class ResourceManager implements ResourceManagerContract { } } + public isRegistered(key: string): boolean { + return this.resources.has(key); + } + public isReady(key: string): boolean { return this.resources.get(key)?.status === 'ready'; } @@ -145,6 +155,23 @@ export class ResourceManager implements ResourceManagerContract { await this.initResource(key); } + /** + * Register a resource if not already registered, then ensure it is ready. + * + * Safe to call from concurrent requests: only the first caller registers; + * subsequent callers coalesce on the existing initialization promise. + */ + public async ensureResourceRegistered( + key: string, + initializer: IResourceInitializer, + options?: RegisterResourceOptions + ): Promise { + if (!this.resources.has(key)) { + this.registerResource(key, initializer, options); + } + await this.ensureResourceReady(key); + } + private async initResource(key: string): Promise { const state = this.resources.get(key); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/resources/indices/rule_doctor_insights.ts b/x-pack/platform/plugins/shared/alerting_v2/server/resources/indices/rule_doctor_insights.ts index 3d9207e37eba4..7a1c9a5822d99 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/resources/indices/rule_doctor_insights.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/resources/indices/rule_doctor_insights.ts @@ -68,23 +68,17 @@ export const ruleDoctorInsightDocSchema = z.object({ justification: z .string() .describe('Reasoning for why this insight was raised and the proposed action'), - rule_ids: z - .array(z.string()) - .optional() - .default([]) - .describe('IDs of the alerting rules involved'), + rule_ids: z.array(z.string()).default([]).describe('IDs of the alerting rules involved'), data: z .record(z.string(), z.any()) .optional() .describe('Arbitrary structured data supporting the insight'), - current: z - .record(z.string(), z.unknown()) - .nullable() - .describe('Current rule configuration snapshot'), + current: z.record(z.string(), z.unknown()).describe('Current rule configuration snapshot'), proposed: z .record(z.string(), z.unknown()) - .nullable() - .describe('Proposed rule configuration after applying the action'), + .describe( + 'Object keyed by rule_id with post-action config for each rule (null for deleted rules)' + ), diffs: z .array( z.object({ diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts index 5ac690635a165..3946b38cc2c35 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts @@ -12,4 +12,5 @@ export { ALERTING_V2_MATCHER_VALUE_SUGGESTIONS_API_PATH, ALERTING_V2_INTERNAL_SUGGEST_USER_PROFILES_API_PATH, ALERTING_V2_RULE_DOCTOR_INSIGHTS_API_PATH, + ALERTING_V2_RULE_DOCTOR_RUN_API_PATH, } from '@kbn/alerting-v2-constants'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/rule_doctor/run_rule_doctor_route.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/rule_doctor/run_rule_doctor_route.test.ts new file mode 100644 index 0000000000000..ba4704ab464d4 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/rule_doctor/run_rule_doctor_route.test.ts @@ -0,0 +1,194 @@ +/* + * 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 { httpServerMock } from '@kbn/core-http-server-mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { uiSettingsServiceMock } from '@kbn/core-ui-settings-server-mocks'; +import { savedObjectsServiceMock } from '@kbn/core-saved-objects-server-mocks'; +import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; +import { RULE_DOCTOR_DEDUP_WORKFLOW_ID } from '../../workflows/load_workflows'; +import { createRouteDependencies } from '../test_utils'; +import { RunRuleDoctorRoute } from './run_rule_doctor_route'; +import type { SpaceContext } from '../rule_doctor_insights/space_context'; + +describe('RunRuleDoctorRoute', () => { + const workflowsManagement = { + scheduleWorkflow: jest.fn(), + getWorkflow: jest.fn(), + createWorkflow: jest.fn(), + updateWorkflow: jest.fn(), + } as unknown as jest.Mocked; + + const spaceContext: SpaceContext = { spaceId: 'default' }; + const logger = loggingSystemMock.createLogger(); + const uiSettings = uiSettingsServiceMock.createStartContract(); + const savedObjects = savedObjectsServiceMock.createStartContract(); + const insightsClient = { + ensureIndex: jest.fn().mockResolvedValue(undefined), + } as any; + + const mockUiSettingsClient = uiSettingsServiceMock.createClient(); + mockUiSettingsClient.get.mockResolvedValue('mock-connector-id'); + uiSettings.asScopedToClient.mockReturnValue(mockUiSettingsClient); + + const persistedWorkflow = { + id: RULE_DOCTOR_DEDUP_WORKFLOW_ID, + name: 'rule_doctor_deduplication', + enabled: true, + definition: null, + yaml: 'version: "1"\nname: rule_doctor_deduplication', + valid: true, + createdAt: '2026-01-01T00:00:00Z', + createdBy: 'system', + lastUpdatedAt: '2026-01-01T00:00:00Z', + lastUpdatedBy: 'system', + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUiSettingsClient.get.mockResolvedValue('mock-connector-id'); + uiSettings.asScopedToClient.mockReturnValue(mockUiSettingsClient); + }); + + it('schedules existing workflow and returns 202', async () => { + const { ctx, response } = createRouteDependencies(); + const request = httpServerMock.createKibanaRequest({ + body: { type: 'deduplication' }, + }); + + (workflowsManagement.getWorkflow as jest.Mock).mockResolvedValue(persistedWorkflow); + (workflowsManagement.scheduleWorkflow as jest.Mock).mockResolvedValueOnce('wf-exec-123'); + + const route = new RunRuleDoctorRoute( + ctx, + request, + workflowsManagement, + spaceContext, + uiSettings, + savedObjects, + logger, + insightsClient + ); + await route.handle(); + + expect(workflowsManagement.getWorkflow).toHaveBeenCalledWith( + RULE_DOCTOR_DEDUP_WORKFLOW_ID, + 'default' + ); + expect(workflowsManagement.scheduleWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + id: RULE_DOCTOR_DEDUP_WORKFLOW_ID, + enabled: true, + }), + 'default', + expect.objectContaining({ + space_id: 'default', + execution_id: expect.any(String), + connector_id: 'mock-connector-id', + }), + request, + 'rule_doctor' + ); + expect(response.accepted).toHaveBeenCalledWith({ + body: expect.objectContaining({ + execution_id: expect.any(String), + type: 'deduplication', + }), + }); + }); + + it('creates workflow on first run when not yet persisted', async () => { + const { ctx, response } = createRouteDependencies(); + const request = httpServerMock.createKibanaRequest({ + body: { type: 'deduplication' }, + }); + + (workflowsManagement.getWorkflow as jest.Mock) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(persistedWorkflow); + (workflowsManagement.scheduleWorkflow as jest.Mock).mockResolvedValueOnce('wf-exec-456'); + + const route = new RunRuleDoctorRoute( + ctx, + request, + workflowsManagement, + spaceContext, + uiSettings, + savedObjects, + logger, + insightsClient + ); + await route.handle(); + + expect(workflowsManagement.createWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + id: RULE_DOCTOR_DEDUP_WORKFLOW_ID, + yaml: expect.any(String), + }), + 'default', + request + ); + expect(workflowsManagement.updateWorkflow).toHaveBeenCalledWith( + RULE_DOCTOR_DEDUP_WORKFLOW_ID, + expect.objectContaining({ enabled: true }), + 'default', + request + ); + expect(response.accepted).toHaveBeenCalled(); + }); + + it('ensures insights index is ready before scheduling', async () => { + const { ctx, response } = createRouteDependencies(); + const request = httpServerMock.createKibanaRequest({ + body: { type: 'deduplication' }, + }); + + (workflowsManagement.getWorkflow as jest.Mock).mockResolvedValue(persistedWorkflow); + (workflowsManagement.scheduleWorkflow as jest.Mock).mockResolvedValueOnce('wf-exec-789'); + + const route = new RunRuleDoctorRoute( + ctx, + request, + workflowsManagement, + spaceContext, + uiSettings, + savedObjects, + logger, + insightsClient + ); + await route.handle(); + + expect(insightsClient.ensureIndex).toHaveBeenCalled(); + expect(response.accepted).toHaveBeenCalled(); + }); + + it('returns an error when scheduleWorkflow fails', async () => { + const { ctx, response } = createRouteDependencies(); + const request = httpServerMock.createKibanaRequest({ + body: { type: 'deduplication' }, + }); + + (workflowsManagement.getWorkflow as jest.Mock).mockResolvedValue(persistedWorkflow); + (workflowsManagement.scheduleWorkflow as jest.Mock).mockRejectedValueOnce( + new Error('workflow engine unavailable') + ); + + const route = new RunRuleDoctorRoute( + ctx, + request, + workflowsManagement, + spaceContext, + uiSettings, + savedObjects, + logger, + insightsClient + ); + await route.handle(); + + expect(response.customError).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 500 })); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/rule_doctor/run_rule_doctor_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/rule_doctor/run_rule_doctor_route.ts new file mode 100644 index 0000000000000..bc9501dc5a557 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/rule_doctor/run_rule_doctor_route.ts @@ -0,0 +1,107 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import type { KibanaRequest, RouteSecurity } from '@kbn/core-http-server'; +import type { Logger } from '@kbn/logging'; +import type { UiSettingsServiceStart } from '@kbn/core-ui-settings-server'; +import type { SavedObjectsServiceStart } from '@kbn/core-saved-objects-server'; +import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; +import { inject, injectable } from 'inversify'; +import { CoreStart, Request } from '@kbn/core-di-server'; +import { Logger as DILogger } from '@kbn/core-di'; +import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; +import { z } from '@kbn/zod/v4'; +import { GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR } from '@kbn/management-settings-ids'; +import { ALERTING_V2_RULE_DOCTOR_RUN_API_PATH } from '@kbn/alerting-v2-constants'; +import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; +import { BaseAlertingRoute } from '../base_alerting_route'; +import { AlertingRouteContext } from '../alerting_route_context'; +import { SpaceContext } from '../rule_doctor_insights/space_context'; +import { WorkflowsManagementApiToken } from '../../lib/dispatcher/steps/dispatch_step_tokens'; +import { ensureRuleDoctorAnalysisWorkflow } from '../../workflows/load_workflows'; +import { InsightsClientInternalToken } from '../../lib/rule_doctor_insights_client/tokens'; +import type { RuleDoctorInsightsClient } from '../../lib/rule_doctor_insights_client/rule_doctor_insights_client'; + +const runRuleDoctorBodySchema = z.object({ + type: z.enum(['deduplication']).describe('The type of Rule Doctor analysis to run'), +}); + +@injectable() +export class RunRuleDoctorRoute extends BaseAlertingRoute { + static method = 'post' as const; + static path = ALERTING_V2_RULE_DOCTOR_RUN_API_PATH; + static security: RouteSecurity = { + authz: { + requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.ruleDoctor.write], + }, + }; + static routeOptions = { + summary: 'Run a Rule Doctor analysis', + } as const; + static validate = { + request: { + body: buildRouteValidationWithZod(runRuleDoctorBodySchema), + }, + }; + + protected readonly routeName = 'run rule doctor'; + + constructor( + @inject(AlertingRouteContext) ctx: AlertingRouteContext, + @inject(Request) + private readonly request: KibanaRequest< + unknown, + unknown, + z.infer + >, + @inject(WorkflowsManagementApiToken) + private readonly workflowsManagement: WorkflowsServerPluginSetup['management'], + @inject(SpaceContext) private readonly spaceContext: SpaceContext, + @inject(CoreStart('uiSettings')) private readonly uiSettings: UiSettingsServiceStart, + @inject(CoreStart('savedObjects')) private readonly savedObjects: SavedObjectsServiceStart, + @inject(DILogger) private readonly logger: Logger, + @inject(InsightsClientInternalToken) + private readonly insightsClient: RuleDoctorInsightsClient + ) { + super(ctx); + } + + protected async execute() { + const { type } = this.request.body; + const executionId = uuidv4(); + const spaceId = this.spaceContext.spaceId; + const connectorId = await this.getDefaultConnectorId(); + + await this.insightsClient.ensureIndex(); + const workflow = await ensureRuleDoctorAnalysisWorkflow( + type, + this.workflowsManagement, + spaceId, + this.request, + this.logger + ); + + await this.workflowsManagement.scheduleWorkflow( + workflow, + spaceId, + { space_id: spaceId, execution_id: executionId, connector_id: connectorId }, + this.request, + 'rule_doctor' + ); + + return this.ctx.response.accepted({ + body: { execution_id: executionId, type }, + }); + } + + private async getDefaultConnectorId(): Promise { + const soClient = this.savedObjects.getScopedClient(this.request); + const client = this.uiSettings.asScopedToClient(soClient); + return client.get(GEN_AI_SETTINGS_DEFAULT_AI_CONNECTOR); + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts index 276ad2ee107c2..4e5079156bef2 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts @@ -17,6 +17,7 @@ import { registerSavedObjects } from '../saved_objects'; import { ALERTING_V2_EXPERIMENTAL_FEATURES_SETTING_ID } from '../../common/advanced_settings'; import { alertingV2UiSettings } from '../ui_settings/advanced_settings'; import { EsServiceInternalToken } from '../lib/services/es_service/tokens'; +import { registerRuleDoctorStepTypes } from '../step_types'; import { createRuleAttachmentType } from '../agent_builder/attachments/rule_attachment_type'; import { buildScopedRulesClientFactory } from '../agent_builder/scoped_rules_client_factory'; import { createRuleSmlType } from '../agent_builder/sml/rule_sml_type'; @@ -74,6 +75,12 @@ export function bindOnSetup({ bind }: ContainerModuleLoadOptions) { // Trigger task registration via onActivation callbacks container.getAll(TaskDefinition); + registerRuleDoctorStepTypes( + container.get( + PluginSetup('workflowsExtensions') + ) + ); + const agentBuilderToken = PluginSetup>('agentBuilder'); const agentContextLayerToken = diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts index 944689d6d9c2c..5ece8acbef9f7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts @@ -44,6 +44,7 @@ import { SuggestUserProfilesRoute } from '../routes/suggestions/suggest_user_pro import { ListInsightsRoute } from '../routes/rule_doctor_insights/list_insights_route'; import { GetInsightRoute } from '../routes/rule_doctor_insights/get_insight_route'; import { UpdateInsightStatusRoute } from '../routes/rule_doctor_insights/update_insight_status_route'; +import { RunRuleDoctorRoute } from '../routes/rule_doctor/run_rule_doctor_route'; /** * TODO: https://github.com/elastic/rna-program/issues/426 @@ -91,6 +92,7 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(ListInsightsRoute); bind(Route).toConstantValue(GetInsightRoute); bind(Route).toConstantValue(UpdateInsightStatusRoute); + bind(Route).toConstantValue(RunRuleDoctorRoute); // TODO(rna-program#426): remove this binding before GA. bind(Route).toConstantValue(ResetResourcesRoute); } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index b92a1364a0205..b845794095cf3 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { PluginSetup, PluginStart } from '@kbn/core-di'; +import { Logger, PluginSetup, PluginStart } from '@kbn/core-di'; import { CoreStart, Request, SavedObjectsClientFactory } from '@kbn/core-di-server'; import type { ContainerModuleLoadOptions } from 'inversify'; import { AlertActionsClient } from '../lib/alert_actions_client'; @@ -264,7 +264,9 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { .toDynamicValue(({ get }) => { const loggerService = get(LoggerServiceToken); const esClient = get(EsServiceInternalToken); - return new RuleDoctorInsightsClient(esClient, loggerService); + const resourceManager = get(ResourceManager); + const logger = get(Logger); + return new RuleDoctorInsightsClient(esClient, loggerService, resourceManager, logger); }) .inSingletonScope(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/step_types/index.ts b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/index.ts new file mode 100644 index 0000000000000..1ad50bf16d880 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; +import { validateRulesStepDefinition } from './validate_rules'; +import { persistFindingsStepDefinition } from './persist_findings'; + +export function registerRuleDoctorStepTypes( + workflowsExtensions: WorkflowsExtensionsServerPluginSetup +): void { + workflowsExtensions.registerStepDefinition(validateRulesStepDefinition); + workflowsExtensions.registerStepDefinition(persistFindingsStepDefinition); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/step_types/persist_findings.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/persist_findings.test.ts new file mode 100644 index 0000000000000..f590ccf83544c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/persist_findings.test.ts @@ -0,0 +1,149 @@ +/* + * 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 { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { RULE_DOCTOR_INSIGHTS_INDEX } from '../resources/indices/rule_doctor_insights'; +import { persistFindingsStepDefinition } from './persist_findings'; + +const mockEsClient = { + bulk: jest.fn(), +}; + +interface MockInput { + insights: unknown[]; + dismiss_ids: string[]; + space_id: string; +} + +const createMockContext = () => ({ + input: { + insights: [], + dismiss_ids: [], + space_id: 'default', + } as MockInput, + config: {}, + rawInput: {} as any, + contextManager: { + getScopedEsClient: () => mockEsClient, + getContext: jest.fn(), + renderInputTemplate: jest.fn(), + getFakeRequest: jest.fn(), + }, + logger: loggingSystemMock.createLogger(), + abortSignal: new AbortController().signal, + stepId: 'persist_results', + stepType: 'alerting_v2.persist_findings', +}); + +const makeInsight = (overrides: Record = {}) => ({ + '@timestamp': '2026-04-28T00:00:00.000Z', + insight_id: 'insight-1', + execution_id: 'exec-1', + status: 'open' as const, + type: 'deduplication', + action: 'merge', + impact: 'high' as const, + confidence: 'high' as const, + title: 'Duplicate rules', + summary: 'Rules are duplicates', + justification: 'Same query and conditions', + rule_ids: ['rule-1', 'rule-2'], + current: {}, + proposed: {}, + space_id: 'default', + ...overrides, +}); + +describe('persist_findings step', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has the correct step id', () => { + expect(persistFindingsStepDefinition.id).toBe('alerting_v2.persist_findings'); + }); + + it('bulk indexes insights via the insights client', async () => { + const context = createMockContext(); + const insight = makeInsight(); + context.input.insights = [insight]; + + mockEsClient.bulk.mockResolvedValueOnce({ + errors: false, + items: [{ index: { _id: 'default:insight-1', status: 201 } }], + }); + + const result = await persistFindingsStepDefinition.handler(context as any); + + expect(mockEsClient.bulk).toHaveBeenCalledWith({ + operations: [ + { index: { _index: RULE_DOCTOR_INSIGHTS_INDEX, _id: 'default:insight-1' } }, + insight, + ], + refresh: false, + }); + expect(result.output).toEqual({ indexed: 1, failed: 0, dismissed: 0 }); + }); + + it('dismisses insights via the insights client', async () => { + const context = createMockContext(); + context.input.dismiss_ids = ['old-insight-1', 'old-insight-2']; + + mockEsClient.bulk.mockResolvedValueOnce({ + errors: false, + items: [ + { update: { _id: 'default:old-insight-1', status: 200 } }, + { update: { _id: 'default:old-insight-2', status: 200 } }, + ], + }); + + const result = await persistFindingsStepDefinition.handler(context as any); + + expect(mockEsClient.bulk).toHaveBeenCalledWith({ + operations: [ + { update: { _index: RULE_DOCTOR_INSIGHTS_INDEX, _id: 'default:old-insight-1' } }, + { doc: { status: 'dismissed' } }, + { update: { _index: RULE_DOCTOR_INSIGHTS_INDEX, _id: 'default:old-insight-2' } }, + { doc: { status: 'dismissed' } }, + ], + refresh: 'wait_for', + }); + expect(result.output).toEqual({ indexed: 0, failed: 0, dismissed: 2 }); + }); + + it('returns zeros when no insights or dismiss_ids provided', async () => { + const context = createMockContext(); + + const result = await persistFindingsStepDefinition.handler(context as any); + + expect(mockEsClient.bulk).not.toHaveBeenCalled(); + expect(result.output).toEqual({ indexed: 0, failed: 0, dismissed: 0 }); + }); + + it('reports failed count on bulk errors', async () => { + const context = createMockContext(); + context.input.insights = [makeInsight(), makeInsight({ insight_id: 'insight-2' })]; + + mockEsClient.bulk.mockResolvedValueOnce({ + errors: true, + items: [ + { index: { _id: 'default:insight-1', status: 201 } }, + { + index: { + _id: 'default:insight-2', + status: 400, + error: { type: 'mapper_parsing_exception', reason: 'bad field' }, + }, + }, + ], + }); + + const result = await persistFindingsStepDefinition.handler(context as any); + + expect(result.output).toEqual({ indexed: 1, failed: 1, dismissed: 0 }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/step_types/persist_findings.ts b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/persist_findings.ts new file mode 100644 index 0000000000000..ea9f1afbdf50f --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/persist_findings.ts @@ -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 { z } from '@kbn/zod/v4'; +import { StepCategory } from '@kbn/workflows'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server/step_registry/types'; +import { RuleDoctorInsightsClient } from '../lib/rule_doctor_insights_client/rule_doctor_insights_client'; +import { + ruleDoctorInsightDocSchema, + type RuleDoctorInsightDoc, +} from '../resources/indices/rule_doctor_insights'; +import type { LoggerServiceContract } from '../lib/services/logger_service/logger_service'; + +const persistFindingsInputSchema = z.object({ + insights: z.array(z.unknown()).describe('Insight documents to validate and persist'), + dismiss_ids: z + .array(z.string()) + .optional() + .default([]) + .describe('Insight IDs to mark as dismissed'), + space_id: z.string().describe('Kibana space ID for composite document IDs'), +}); + +const persistFindingsOutputSchema = z.object({ + indexed: z.number().describe('Number of insights successfully indexed'), + failed: z.number().describe('Number of insights that failed to index'), + dismissed: z.number().describe('Number of insights dismissed'), +}); + +function adaptLogger(stepLogger: { + debug: (...args: any[]) => void; + info: (...args: any[]) => void; + warn: (...args: any[]) => void; + error: (...args: any[]) => void; +}): LoggerServiceContract { + return { + debug: ({ message }) => stepLogger.debug(message), + info: ({ message }) => stepLogger.info(message), + warn: ({ message }) => stepLogger.warn(message), + error: ({ error }) => stepLogger.error(error.message, error), + }; +} + +export const persistFindingsStepDefinition = createServerStepDefinition({ + id: 'alerting_v2.persist_findings', + label: 'Persist Rule Doctor Findings', + description: 'Bulk indexes validated insights and dismisses stale ones in the Rule Doctor index', + category: StepCategory.Elasticsearch, + inputSchema: persistFindingsInputSchema, + outputSchema: persistFindingsOutputSchema, + handler: async (context) => { + const { insights = [], dismiss_ids: dismissIds = [], space_id: spaceId } = context.input; + const esClient = context.contextManager.getScopedEsClient(); + const logger = adaptLogger(context.logger); + const client = new RuleDoctorInsightsClient(esClient, logger); + + const validated: RuleDoctorInsightDoc[] = []; + let schemaFailed = 0; + + for (const raw of insights) { + const result = ruleDoctorInsightDocSchema.safeParse(raw); + if (result.success) { + validated.push(result.data); + } else { + schemaFailed++; + logger.warn({ + message: `persist_findings: dropping insight that failed validation: ${result.error.message}`, + }); + } + } + + const persistResult = await client.persistFindings({ + insights: validated, + dismissIds, + spaceId, + }); + + return { + output: { + indexed: persistResult.indexed, + failed: persistResult.failed + schemaFailed, + dismissed: persistResult.dismissed, + }, + }; + }, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/step_types/validate_rules.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/validate_rules.test.ts new file mode 100644 index 0000000000000..25956f4886ef1 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/validate_rules.test.ts @@ -0,0 +1,116 @@ +/* + * 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 { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { validateRulesStepDefinition } from './validate_rules'; + +interface MockInput { + insights: unknown[]; + space_id: string; + execution_id: string; +} + +const createMockContext = () => ({ + input: { + insights: [], + space_id: 'default', + execution_id: 'exec-123', + } as MockInput, + config: {}, + rawInput: {} as any, + contextManager: {} as any, + logger: loggingSystemMock.createLogger(), + abortSignal: new AbortController().signal, + stepId: 'validate_insights', + stepType: 'alerting_v2.validate_rules', +}); + +describe('validate_rules step', () => { + it('has the correct step id', () => { + expect(validateRulesStepDefinition.id).toBe('alerting_v2.validate_rules'); + }); + + it('validates well-formed insights and enriches with space_id/execution_id', async () => { + const context = createMockContext(); + context.input.insights = [ + { + type: 'deduplication', + action: 'merge', + impact: 'high', + confidence: 'high', + title: 'Duplicate rules A and B', + summary: 'Rules A and B monitor the same metric', + justification: 'Both rules query the same index with identical conditions', + rule_ids: ['rule-1', 'rule-2'], + current: {}, + proposed: {}, + }, + ]; + + const result = await validateRulesStepDefinition.handler(context as any); + + expect(result.output!.validInsights).toHaveLength(1); + expect(result.output!.invalidInsights).toHaveLength(0); + + const insight = result.output!.validInsights[0] as Record; + expect(insight.space_id).toBe('default'); + expect(insight.execution_id).toBe('exec-123'); + expect(insight.status).toBe('open'); + expect(insight.insight_id).toMatch(/^insight-/); + expect(insight['@timestamp']).toBeDefined(); + }); + + it('rejects insights missing required fields', async () => { + const context = createMockContext(); + context.input.insights = [ + { + type: 'deduplication', + }, + ]; + + const result = await validateRulesStepDefinition.handler(context as any); + + expect(result.output!.validInsights).toHaveLength(0); + expect(result.output!.invalidInsights).toHaveLength(1); + expect(result.output!.invalidInsights[0].error).toBeDefined(); + }); + + it('returns empty arrays for empty input', async () => { + const context = createMockContext(); + + const result = await validateRulesStepDefinition.handler(context as any); + + expect(result.output!.validInsights).toHaveLength(0); + expect(result.output!.invalidInsights).toHaveLength(0); + }); + + it('preserves existing insight_id if provided', async () => { + const context = createMockContext(); + context.input.insights = [ + { + insight_id: 'my-custom-id', + type: 'deduplication', + action: 'merge', + impact: 'medium', + confidence: 'medium', + title: 'Test', + summary: 'Test summary', + justification: 'Test justification', + rule_ids: [], + current: {}, + proposed: {}, + }, + ]; + + const result = await validateRulesStepDefinition.handler(context as any); + + expect(result.output!.validInsights).toHaveLength(1); + expect((result.output!.validInsights[0] as Record).insight_id).toBe( + 'my-custom-id' + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/step_types/validate_rules.ts b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/validate_rules.ts new file mode 100644 index 0000000000000..27533e04de79a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/step_types/validate_rules.ts @@ -0,0 +1,78 @@ +/* + * 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 { v4 as uuidv4 } from 'uuid'; +import { z } from '@kbn/zod/v4'; +import { StepCategory } from '@kbn/workflows'; +import { createServerStepDefinition } from '@kbn/workflows-extensions/server/step_registry/types'; +import { + ruleDoctorInsightDocSchema, + type RuleDoctorInsightDoc, +} from '../resources/indices/rule_doctor_insights'; + +const validateRulesInputSchema = z.object({ + insights: z.array(z.unknown()).describe('Raw AI-generated insight objects to validate'), + space_id: z.string().describe('Kibana space ID to assign to validated insights'), + execution_id: z.string().describe('Execution ID for this analysis run'), +}); + +const validateRulesOutputSchema = z.object({ + validInsights: z.array(z.unknown()).describe('Insights that passed schema validation'), + invalidInsights: z + .array( + z.object({ + raw: z.unknown(), + error: z.string(), + }) + ) + .describe('Insights that failed validation with error details'), + hasInvalid: z.boolean().describe('Whether any insights failed validation'), +}); + +export const validateRulesStepDefinition = createServerStepDefinition({ + id: 'alerting_v2.validate_rules', + label: 'Validate Rule Doctor Insights', + description: 'Validates AI-generated insight objects against the Rule Doctor insight schema', + category: StepCategory.Data, + inputSchema: validateRulesInputSchema, + outputSchema: validateRulesOutputSchema, + handler: async (context) => { + const { insights, space_id: spaceId, execution_id: executionId } = context.input; + const validInsights: RuleDoctorInsightDoc[] = []; + const invalidInsights: Array<{ raw: unknown; error: string }> = []; + + for (const raw of insights) { + const rawObj = (raw != null && typeof raw === 'object' ? raw : {}) as Record; + const enriched = { + ...rawObj, + space_id: spaceId, + execution_id: executionId, + insight_id: rawObj.insight_id || `insight-${uuidv4().slice(0, 8)}`, + '@timestamp': rawObj['@timestamp'] || new Date().toISOString(), + status: rawObj.status || 'open', + }; + + const result = ruleDoctorInsightDocSchema.safeParse(enriched); + if (result.success) { + validInsights.push(result.data); + } else { + invalidInsights.push({ + raw, + error: result.error.message, + }); + } + } + + context.logger.debug( + `Validated ${validInsights.length} insights, ${invalidInsights.length} invalid` + ); + + return { + output: { validInsights, invalidInsights, hasInvalid: invalidInsights.length > 0 }, + }; + }, +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/types.ts index 3e91f0ddf9801..4a668cf204676 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/types.ts @@ -21,6 +21,7 @@ import type { EncryptedSavedObjectsPluginStart, } from '@kbn/encrypted-saved-objects-plugin/server'; import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; +import type { WorkflowsExtensionsServerPluginSetup } from '@kbn/workflows-extensions/server'; import type { IEventLogService } from '@kbn/event-log-plugin/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import type { AgentBuilderPluginSetup } from '@kbn/agent-builder-plugin/server'; @@ -41,6 +42,7 @@ export interface AlertingServerSetupDependencies { spaces: SpacesPluginSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; workflowsManagement: WorkflowsServerPluginSetup; + workflowsExtensions: WorkflowsExtensionsServerPluginSetup; eventLog: IEventLogService; usageCollection?: UsageCollectionSetup; agentBuilder?: AgentBuilderPluginSetup; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/workflows/load_workflows.ts b/x-pack/platform/plugins/shared/alerting_v2/server/workflows/load_workflows.ts new file mode 100644 index 0000000000000..796e61ff5dfe5 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/workflows/load_workflows.ts @@ -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 type { KibanaRequest, Logger } from '@kbn/core/server'; +import type { WorkflowExecutionEngineModel } from '@kbn/workflows'; +import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; +import DEDUPLICATION_WORKFLOW_YAML from './rule_doctor_deduplication.yaml'; + +export const RULE_DOCTOR_DEDUP_WORKFLOW_ID = 'rule-doctor-deduplication'; + +const WORKFLOW_REGISTRY: Record = { + deduplication: { id: RULE_DOCTOR_DEDUP_WORKFLOW_ID, yaml: DEDUPLICATION_WORKFLOW_YAML }, +}; + +export async function ensureRuleDoctorAnalysisWorkflow( + type: string, + managementApi: WorkflowsServerPluginSetup['management'], + spaceId: string, + request: KibanaRequest, + logger: Logger +): Promise { + const entry = WORKFLOW_REGISTRY[type]; + if (!entry) { + throw new Error(`Unknown rule doctor analysis type: ${type}`); + } + return ensureWorkflow(entry.id, entry.yaml, managementApi, spaceId, request, logger); +} + +export async function ensureWorkflow( + workflowId: string, + yaml: string, + managementApi: WorkflowsServerPluginSetup['management'], + spaceId: string, + request: KibanaRequest, + logger: Logger +): Promise { + const existing = await managementApi.getWorkflow(workflowId, spaceId); + + if (existing) { + if (existing.yaml !== yaml || !existing.enabled || !existing.valid) { + await managementApi.updateWorkflow(workflowId, { yaml, enabled: true }, spaceId, request); + logger.info(`Updated workflow ${workflowId}`); + } + } else { + await managementApi.createWorkflow({ yaml, id: workflowId }, spaceId, request); + await managementApi.updateWorkflow(workflowId, { yaml, enabled: true }, spaceId, request); + logger.info(`Created workflow ${workflowId}`); + } + + const workflow = (await managementApi.getWorkflow(workflowId, spaceId))!; + + return { + id: workflow.id, + name: workflow.name, + enabled: true, + definition: workflow.definition ?? undefined, + yaml: workflow.yaml, + }; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/workflows/rule_doctor_deduplication.yaml b/x-pack/platform/plugins/shared/alerting_v2/server/workflows/rule_doctor_deduplication.yaml new file mode 100644 index 0000000000000..72ff9898c8fae --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/workflows/rule_doctor_deduplication.yaml @@ -0,0 +1,312 @@ +version: '1' +name: rule_doctor_deduplication +description: > + Analyzes alerting rules in a space to identify duplicates and near-duplicates, + producing actionable insights for merging or removing redundant rules. +enabled: true + +triggers: + - type: manual + +settings: + timeout: '5m' + +steps: + - name: fetch_rules + type: kibana.request + with: + method: GET + path: '/api/alerting/v2/rules' + query: + perPage: 200 + + - name: set_rules_context + type: data.set + with: + rule_count: '{{ steps.fetch_rules.output.total }}' + + - name: analyze_duplicates + type: ai.prompt + with: + connector-id: '{{ inputs.connector_id }}' + prompt: | + You are Rule Doctor, an expert system for analyzing Kibana alerting rules. + + ## Task + Analyze the following alerting rules for duplicates and overlaps. Two rules overlap when they + monitor the same or very similar metrics/conditions on the same infrastructure, even if their + ES|QL queries are worded differently. + + ## Rules to analyze + {{ steps.fetch_rules.output.items | json }} + + ## Analysis criteria + Compare each pair of rules on: + - `evaluation` — do the queries target the same index patterns, metrics, or fields? + - `metadata.tags` — shared domain tags? + - `schedule` — similar execution intervals? + - `metadata.name` — similar naming patterns? + + Two rules are duplicates if they would fire on largely the same conditions for the same + infrastructure, even if one uses a slightly different threshold or filtering approach. + Rules with the same schedule but monitoring completely different things are NOT duplicates. + + For each duplicate group: + - Populate `rule_ids` with the exact `id` values from the rule objects above. Use the raw UUID + (e.g. "c34ab297-50fe-450e-adde-44619c28c5c7"). Do NOT add any prefix like "alerting_rule:". + - Set `title` to a short plain-text description of the duplicate group. + - Set `summary` explaining why these rules overlap. + - Set `justification` with detailed reasoning about which fields/conditions overlap. + - Set `current` to an object keyed by the rule's `id` (raw UUID) containing each rule's key fields (name, query/params, schedule, tags). + - Set `proposed` to an object keyed by the rule's `id` (raw UUID). For the retained/merged rule, the value is the new configuration (name, query/params, schedule, tags). For rules being deleted, the value is null. + - Set `diffs` to an array describing the field-level differences between current rules and the proposed configuration. + + If no duplicates are found, return an empty insights array. + + ## Output style + All text fields (title, summary, justification) must be plain text only — no markdown, + no bold, no tables, no special formatting. Keep justification to 2-3 sentences. + schema: + type: object + properties: + insights: + type: array + items: + type: object + properties: + type: + type: string + enum: [deduplication] + action: + type: string + enum: [merge, delete] + impact: + type: string + enum: [low, medium, high] + confidence: + type: string + enum: [low, medium, high] + title: + type: string + summary: + type: string + justification: + type: string + rule_ids: + type: array + items: + type: string + current: + type: object + description: Object keyed by rule_id with each rule's key config fields + proposed: + type: object + description: Object keyed by rule_id with post-action config for each rule (null for deleted rules) + diffs: + type: array + items: + type: object + properties: + field: + type: string + description: Dot-delimited field path + previous: + description: Value before the proposed change + proposed: + description: Value after the proposed change + required: [field, previous, proposed] + description: Field-level differences between current and proposed + required: [type, action, impact, confidence, title, summary, justification, rule_ids] + required: [insights] + timeout: 300s + + - name: validate_insights + type: alerting_v2.validate_rules + with: + insights: '${{ steps.analyze_duplicates.output.content.insights }}' + space_id: '{{ inputs.space_id }}' + execution_id: '{{ inputs.execution_id }}' + + - name: store_validation + type: data.set + with: + has_invalid: '${{ steps.validate_insights.output.hasInvalid }}' + invalid_insights: '${{ steps.validate_insights.output.invalidInsights }}' + valid_insights: '${{ steps.validate_insights.output.validInsights }}' + correction_attempt: 0 + loop_valid_insights: [] + + - name: correction_loop + type: while + condition: 'variables.has_invalid:true AND variables.correction_attempt < 3' + max-iterations: 3 + steps: + - name: increment_attempt + type: data.set + with: + correction_attempt: '${{ variables.correction_attempt | plus: 1 }}' + + - name: correct_insights + type: ai.prompt + with: + connector-id: '{{ inputs.connector_id }}' + prompt: | + You are Rule Doctor's insight correction agent. + + ## Task + Some insights generated from alerting rule analysis failed schema validation. + Fix each invalid insight using the validation errors below. + + ## Invalid insights with errors + {{ variables.invalid_insights | json }} + + Each entry has: + - "raw": the original insight object that failed validation + - "error": the validation error message + + ## Required schema for each insight + Each insight must have ALL of these fields: + - type: string (must be "deduplication") + - action: string (must be "merge" or "delete") + - impact: string (must be "low", "medium", or "high") + - confidence: string (must be "low", "medium", or "high") + - title: string (short plain-text description) + - summary: string (plain-text explanation) + - justification: string (plain-text detailed reasoning, 2-3 sentences) + - rule_ids: array of strings (rule id values) + - current: object keyed by rule_id with each rule's key config fields (optional) + - proposed: object keyed by rule_id with post-action config for each rule, null for deleted rules (optional) + - diffs: array of {field, previous, proposed} objects (optional) + + ## Instructions + - Fix validation errors in each insight + - Ensure all required fields are present with correct types + - All text must be plain text only — no markdown formatting + - If an insight cannot be fixed, omit it entirely + - Return the corrected insights as an array + schema: + type: object + properties: + insights: + type: array + items: + type: object + properties: + type: + type: string + enum: [deduplication] + action: + type: string + enum: [merge, delete] + impact: + type: string + enum: [low, medium, high] + confidence: + type: string + enum: [low, medium, high] + title: + type: string + summary: + type: string + justification: + type: string + rule_ids: + type: array + items: + type: string + current: + type: object + description: Object keyed by rule_id with each rule's key config fields + proposed: + type: object + description: Object keyed by rule_id with post-action config for each rule (null for deleted rules) + diffs: + type: array + items: + type: object + properties: + field: + type: string + previous: + description: Value before the proposed change + proposed: + description: Value after the proposed change + required: [field, previous, proposed] + required: [type, action, impact, confidence, title, summary, justification, rule_ids] + required: [insights] + timeout: 120s + + - name: revalidate_insights + type: alerting_v2.validate_rules + with: + insights: '${{ steps.correct_insights.output.content.insights }}' + space_id: '{{ inputs.space_id }}' + execution_id: '{{ inputs.execution_id }}' + + - name: update_validation + type: data.set + with: + has_invalid: '${{ steps.revalidate_insights.output.hasInvalid }}' + invalid_insights: '${{ steps.revalidate_insights.output.invalidInsights }}' + loop_valid_insights: '${{ variables.loop_valid_insights | concat: steps.revalidate_insights.output.validInsights }}' + + - name: merge_all_valid + type: data.set + with: + valid_insights: '${{ variables.valid_insights | concat: variables.loop_valid_insights }}' + + - name: fetch_open_insights + type: elasticsearch.request + with: + method: POST + path: '/.rule-doctor-insights/_search' + body: + size: 100 + query: + bool: + filter: + - term: + space_id: '{{ inputs.space_id }}' + - term: + status: open + - term: + type: deduplication + + - name: evaluate_existing + type: ai.prompt + if: '{{ steps.fetch_open_insights.output.hits.total.value > 0 }}' + with: + connector-id: '{{ inputs.connector_id }}' + prompt: | + You are evaluating existing Rule Doctor deduplication insights against new analysis results. + + ## New insights + {{ variables.valid_insights | json }} + + ## Currently open existing insights + {{ steps.fetch_open_insights.output.hits.hits | json }} + + For each existing insight, determine if it should be dismissed because: + 1. A new insight covers the same issue (overlapping or duplicate recommendation) + 2. The insight references rules that now appear in a new, updated recommendation + 3. The insight is no longer relevant based on the latest analysis + + Only dismiss insights you are confident are stale or superseded. If unsure, keep them open. + If there are no existing insights, or none should be dismissed, return an empty array. + + Return the insight_id of each existing insight to dismiss. + schema: + type: object + properties: + dismissals: + type: array + items: + type: string + description: The insight_id of the existing insight to dismiss + + - name: persist_results + type: alerting_v2.persist_findings + with: + insights: '${{ variables.valid_insights }}' + dismiss_ids: '${{ steps.evaluate_existing.output.content.dismissals | default: [] }}' + space_id: '{{ inputs.space_id }}' diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/services/insights_api_service.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/services/insights_api_service.ts index da593ccb54b20..ede97ef08e0da 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/services/insights_api_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/services/insights_api_service.ts @@ -26,8 +26,8 @@ const INSIGHT_DEFAULTS: Omit justification: 'Generated for Scout API tests', rule_ids: [], data: {}, - current: null, - proposed: null, + current: {}, + proposed: {}, space_id: 'default', }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/run_rule_doctor.spec.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/run_rule_doctor.spec.ts new file mode 100644 index 0000000000000..e106bd1e54204 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/api/tests/run_rule_doctor.spec.ts @@ -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 { expect } from '@kbn/scout/api'; +import { tags } from '@kbn/scout'; +import type { RoleApiCredentials } from '@kbn/scout'; +import { RULE_DOCTOR_DEDUP_WORKFLOW_ID } from '../../../../server/workflows/load_workflows'; +import { apiTest, testData } from '../fixtures'; +import { RULE_DOCTOR_RUN_API_PATH } from '../../common/constants'; + +apiTest.describe('Rule Doctor run API', { tag: tags.stateful.classic }, () => { + let adminCredentials: RoleApiCredentials; + let viewerCredentials: RoleApiCredentials; + + apiTest.beforeAll(async ({ requestAuth }) => { + adminCredentials = await requestAuth.getApiKeyForAdmin(); + viewerCredentials = await requestAuth.getApiKeyForViewer(); + }); + + apiTest.afterAll(async ({ apiClient }) => { + await apiClient.delete(`/api/workflows/workflow/${RULE_DOCTOR_DEDUP_WORKFLOW_ID}?force=true`, { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.apiKeyHeader }, + }); + }); + + apiTest('should accept a deduplication run and return 202', async ({ apiClient }) => { + const response = await apiClient.post(RULE_DOCTOR_RUN_API_PATH, { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.apiKeyHeader }, + body: { type: 'deduplication' }, + responseType: 'json', + }); + + expect(response).toHaveStatusCode(202); + expect(response.body.execution_id).toBeDefined(); + expect(response.body.type).toBe('deduplication'); + }); + + apiTest('should reject an invalid analysis type with 400', async ({ apiClient }) => { + const response = await apiClient.post(RULE_DOCTOR_RUN_API_PATH, { + headers: { ...testData.COMMON_HEADERS, ...adminCredentials.apiKeyHeader }, + body: { type: 'invalid_type' }, + responseType: 'json', + }); + + expect(response).toHaveStatusCode(400); + expect(response.body.statusCode).toBe(400); + expect(typeof response.body.message).toBe('string'); + }); + + apiTest('should return 403 for a viewer without write privileges', async ({ apiClient }) => { + const response = await apiClient.post(RULE_DOCTOR_RUN_API_PATH, { + headers: { ...testData.COMMON_HEADERS, ...viewerCredentials.apiKeyHeader }, + body: { type: 'deduplication' }, + responseType: 'json', + }); + + expect(response).toHaveStatusCode(403); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/common/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/common/constants.ts index b72f883032b3f..d50c2f9927f62 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/common/constants.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting_v2/common/constants.ts @@ -14,6 +14,7 @@ export const COMMON_HEADERS = { export { ALERTING_V2_RULE_API_PATH as RULE_API_PATH, ALERTING_V2_RULE_DOCTOR_INSIGHTS_API_PATH as INSIGHTS_API_PATH, + ALERTING_V2_RULE_DOCTOR_RUN_API_PATH as RULE_DOCTOR_RUN_API_PATH, } from '@kbn/alerting-v2-constants'; export { ALERT_EVENTS_DATA_STREAM } from '../../../server/resources/datastreams/alert_events'; export { ALERT_ACTIONS_DATA_STREAM } from '../../../server/resources/datastreams/alert_actions'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 3e974db56d906..13a2c5ae6f4e4 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -99,6 +99,12 @@ "@kbn/unified-doc-viewer-plugin", "@kbn/core-user-profile-browser", "@kbn/user-profile-components", + "@kbn/core-logging-server-mocks", + "@kbn/core-ui-settings-server-mocks", + "@kbn/core-saved-objects-server-mocks", + "@kbn/core-ui-settings-server", + "@kbn/management-settings-ids", + "@kbn/workflows-extensions", "@kbn/esql-types", "@kbn/agent-builder-plugin", "@kbn/agent-builder-server",