From 11d665e44604196af5d991ff8333e193af23ac07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopycin=CC=81ski?= Date: Tue, 8 Sep 2026 11:31:08 +0200 Subject: [PATCH] [fleet] Package-managed workflow, agent, skill and index-alias assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature-scoped net-diff squash of the fleet package-asset surface from the SDLC visibility-platform dogfood branch: - New install_state_machine steps: workflow assets, agent assets, skill assets, index aliases; wired into _state_machine_package_install with new INSTALL_STATES - KibanaAssetType/KibanaSavedObjectType extensions for the new asset kinds - Workflow enablement on install (opt-in), package-managed asset immutability, user-disabled state preserved on upgrade, orphaned workflows removed - Install without HTTP request context (fake_request) for background installs - Convention-based workflow placeholder + connector-id substitution, re-applied on package policy update - Uninstall coverage for package workflow/agent/skill assets in deleteKibanaAssets Excludes the alerting-template reconcile and the AB-006 live contract test, which ship in their own rungs. Verified on fresh origin/main: fleet type_check shows no errors introduced by this change — the 4 remaining 'skill' asset-map errors are pre-existing on the source branch (identical in the branch's own type_check logs), and the other 4 are unrelated upstream @kbn/test-jest-helpers drift. --- .../agent-builder-common/agents/crud.ts | 11 + .../agent-builder-common/agents/definition.ts | 1 + .../agent-builder-server/plugin_contract.ts | 31 +- .../context/fixtures/integration.nginx.ts | 3 + .../context/fixtures/integration.okta.ts | 3 + .../fleet/common/experimental_features.ts | 1 + .../shared/fleet/common/services/index.ts | 7 + .../services/kibana_only_integration.test.ts | 188 ++++ .../services/kibana_only_integration.ts | 123 +++ .../package_to_package_policy.test.ts | 4 + .../shared/fleet/common/types/models/epm.ts | 11 + .../fleet/common/types/models/package_spec.ts | 7 + .../plugins/shared/fleet/kibana.jsonc | 4 +- x-pack/platform/plugins/shared/fleet/moon.yml | 4 + ..._incompatible_agent_version_status.test.ts | 36 - .../use_incompatible_agent_version_status.ts | 13 +- .../steps/step_select_agent_policy.test.tsx | 40 +- .../steps/step_select_agent_policy.tsx | 20 +- .../connector_setup_checklist.test.tsx | 97 ++ .../connector_setup_checklist.tsx | 123 +++ .../single_page_layout/index.tsx | 32 +- .../agent_logs/view_logs_button.tsx | 1 + .../integrations/sections/epm/constants.tsx | 9 + .../aws_reusable_connector_form.test.tsx | 2 - .../aws_identity_federation_setup.test.tsx | 160 --- .../aws_identity_federation_setup.tsx | 36 +- .../aws_connect_setup/index.tsx | 8 +- .../azure_reusable_connector_form.test.tsx | 2 - .../form/cloud_connector_selector.test.tsx | 2 - .../form/cloud_connector_selector.tsx | 3 - .../gcp_reusable_connector_form.test.tsx | 2 - .../components/cloud_connector/utils.test.ts | 48 - .../constants/max_concurrency_constants.ts | 1 + .../shared/fleet/server/mocks/index.ts | 64 +- .../plugins/shared/fleet/server/plugin.ts | 6 + .../fleet/server/routes/epm/handlers.ts | 43 +- .../fleet/server/routes/epm/index.test.ts | 3 + .../routes/epm/install_assets_handler.test.ts | 50 + .../routes/epm/install_assets_handler.ts | 18 +- .../server/routes/package_policy/handlers.ts | 3 +- .../fleet/server/services/agents/crud.test.ts | 62 -- .../fleet/server/services/agents/crud.ts | 32 +- .../fleet/server/services/app_context.ts | 20 + .../agentless_policy_integration.test.ts | 126 --- .../agentless_policy_integration.ts | 10 - .../integration_helpers.test.ts | 172 +-- .../cloud_connectors/integration_helpers.ts | 59 -- .../services/epm/ab002_agent_yaml.test.ts | 89 ++ .../epm/elasticsearch/index/install.test.ts | 138 +++ .../epm/elasticsearch/index/install.ts | 134 +++ .../elasticsearch/index_alias/install.test.ts | 118 +++ .../epm/elasticsearch/index_alias/install.ts | 103 ++ .../elasticsearch/index_alias/remove.test.ts | 65 ++ .../epm/elasticsearch/index_alias/remove.ts | 38 + .../services/epm/kibana/assets/install.ts | 16 +- .../server/services/epm/package_service.ts | 4 + .../epm/packages/get_bulk_assets.test.ts | 131 ++- .../services/epm/packages/get_bulk_assets.ts | 63 +- .../_state_machine_package_install.ts | 32 + .../steps/fleet_skill_parse.test.ts | 73 ++ .../steps/fleet_skill_parse.ts | 78 ++ .../install_state_machine/steps/index.ts | 5 + .../steps/step_create_indices.ts | 35 + .../steps/step_install_agent_assets.test.ts | 285 +++++ .../steps/step_install_agent_assets.ts | 181 ++++ .../steps/step_install_index_aliases.ts | 56 + .../steps/step_install_skill_assets.test.ts | 108 ++ .../steps/step_install_skill_assets.ts | 106 ++ .../step_install_workflow_assets.test.ts | 991 ++++++++++++++++++ .../steps/step_install_workflow_assets.ts | 579 ++++++++++ .../steps/workflow_connector_resolution.md | 48 + .../reapply_assets_on_var_change.test.ts | 264 +++++ .../packages/reapply_assets_on_var_change.ts | 138 +++ .../server/services/epm/packages/reinstall.ts | 9 +- .../services/epm/packages/remove.test.ts | 84 ++ .../server/services/epm/packages/remove.ts | 83 +- .../shared/fleet/server/services/index.ts | 1 - .../update_workflow_assets.test.ts | 182 ++++ .../update_workflow_assets.ts | 82 ++ .../server/services/package_policy.test.ts | 91 -- .../fleet/server/services/package_policy.ts | 31 +- .../server/services/package_policy_service.ts | 4 +- .../server/services/security/fake_request.ts | 31 + .../fleet/server/services/security/index.ts | 1 + .../shared/fleet/server/services/setup.ts | 8 +- .../setup/upgrade_package_install_version.ts | 4 + .../tasks/agent_status_change_task.test.ts | 33 - .../server/tasks/agent_status_change_task.ts | 10 +- ...auto_install_content_packages_task.test.ts | 5 + .../auto_install_content_packages_task.ts | 27 +- .../packages_bulk_operations/task_runner.ts | 3 +- ...nstall_packages_for_global_asset_update.ts | 4 + .../run_upgrade_package_install_version.ts | 4 + .../fleet/server/tasks/setup/task_runner.ts | 7 + .../plugins/shared/fleet/tsconfig.json | 6 +- 95 files changed, 5335 insertions(+), 954 deletions(-) create mode 100644 x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.ts create mode 100644 x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.test.tsx create mode 100644 x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.tsx delete mode 100644 x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.test.tsx create mode 100644 x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/ab002_agent_yaml.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_create_indices.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_aliases.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/workflow_connector_resolution.md create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.test.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.ts create mode 100644 x-pack/platform/plugins/shared/fleet/server/services/security/fake_request.ts diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/crud.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/crud.ts index 66a27fae8b56b..a96be873e9837 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/crud.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/crud.ts @@ -25,6 +25,12 @@ export type AgentCreateRequest = Omit< */ type?: string; access_control?: Pick; + /** + * AB-004: when true the agent is created readonly (package-managed). + * Fleet package installs set this so UI edits warn/block, mirroring the + * managed workflow pattern. Carried on update so package upgrades keep it. + */ + readonly?: boolean; }; export type AgentUpdateRequest = Partial< @@ -32,6 +38,11 @@ export type AgentUpdateRequest = Partial< > & { access_control?: Pick; configuration?: Partial; + /** + * AB-004: package upgrades re-assert the managed flag; a reinstall must not + * silently downgrade a package agent to an editable user agent. + */ + readonly?: boolean; }; export type AgentDeleteRequest = Pick; diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/definition.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/definition.ts index e0beb4e3bd220..c9c7c67ad515e 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/definition.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/definition.ts @@ -82,6 +82,7 @@ export interface AgentDefinition { * Optional labels used to organize or filter agents */ labels?: string[]; + /** True when the agent is managed by a package and must not be edited by users. */ /** * Optional avatar eui icon for built-in agents */ diff --git a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/plugin_contract.ts b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/plugin_contract.ts index 8656b67f0d06f..fa04b7ee59007 100644 --- a/x-pack/platform/packages/shared/agent-builder/agent-builder-server/plugin_contract.ts +++ b/x-pack/platform/packages/shared/agent-builder/agent-builder-server/plugin_contract.ts @@ -7,7 +7,11 @@ import type { ZodObject } from '@kbn/zod/v4'; import type { KibanaRequest } from '@kbn/core-http-server'; -import type { AgentCreateRequest, ConversationTemplate } from '@kbn/agent-builder-common'; +import type { + AgentCreateRequest, + ConversationTemplate, + PersistedSkillCreateRequest, +} from '@kbn/agent-builder-common'; import type { ConversationPublicClient } from './conversations'; import type { StaticToolRegistration, ToolRegistry } from './tools'; import type { AttachmentTypeDefinition } from './attachments'; @@ -238,11 +242,36 @@ export interface TopSnippetsConfig { /** * Setup contract of the agentBuilder plugin. */ + +/** + * Internal management API for package-owned persisted agents and skills + * (e.g. Fleet package install/uninstall). + */ +export interface AgentBuilderManagementSetup { + /** Read a single agent, or null when it does not exist. Used to enrich Fleet asset listings. */ + getAgent(agentId: string, request: KibanaRequest): Promise; + createOrUpdateAgent(params: AgentCreateRequest, request: KibanaRequest): Promise; + deletePackageManagedAgent(agentId: string, spaceId: string): Promise; + createOrUpdateSkill(params: PersistedSkillCreateRequest, request: KibanaRequest): Promise; + deletePackageManagedSkill(skillId: string, spaceId: string): Promise; + /** + * List the skills a package owns, so install can reap the ones the current + * archive no longer produces. Package-managed skills are readonly, so an + * orphan left behind by an id-scheme change is otherwise undeletable. + */ + listPackageManagedSkills( + pluginId: string, + spaceId: string + ): Promise>; +} + export interface AgentBuilderPluginSetup { /** * Agents setup contract, which can be used to register built-in agents. */ agents: AgentsSetup; + /** Internal management API for programmatic agent/skill CRUD (Fleet package install). */ + management: AgentBuilderManagementSetup; /** * Tools setup contract, which can be used to register built-in tools. */ diff --git a/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.nginx.ts b/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.nginx.ts index 7f218773aa56d..5ebbb1f2d839c 100644 --- a/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.nginx.ts @@ -57,6 +57,8 @@ export const item: GetInfoResponse['item'] = { ], assets: { kibana: { + workflow: [], + agent: [], alerting_rule_template: [], slo_template: [], dashboard: [ @@ -304,6 +306,7 @@ export const item: GetInfoResponse['item'] = { ml_model: [], knowledge_base: [], esql_view: [], + index_alias: [], }, }, policy_templates: [ diff --git a/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.okta.ts b/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.okta.ts index 7660cd4f1d2cb..61d0f8853f401 100644 --- a/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.okta.ts +++ b/x-pack/platform/plugins/shared/fleet/.storybook/context/fixtures/integration.okta.ts @@ -43,6 +43,8 @@ export const item: GetInfoResponse['item'] = { ], assets: { kibana: { + workflow: [], + agent: [], alerting_rule_template: [], slo_template: [], dashboard: [ @@ -133,6 +135,7 @@ export const item: GetInfoResponse['item'] = { ml_model: [], knowledge_base: [], esql_view: [], + index_alias: [], }, }, policy_templates: [ diff --git a/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts b/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts index df2ff0da18732..0f4a29294b772 100644 --- a/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts +++ b/x-pack/platform/plugins/shared/fleet/common/experimental_features.ts @@ -25,6 +25,7 @@ const _allowedExperimentalValues = { disableAgentlessLegacyAPI: true, // When enabled, the legacy agent/package policy APIs reject agentless create, update, upgrade, and copy. Forces enableAgentlessPoliciesUI on (see below). enableAgentlessPoliciesUI: true, // When enabled, the UI reads/writes agentless integration policies through the managed integrations API. Disable as a kill switch to fall back to the legacy APIs — but disableAgentlessLegacyAPI overrides it (the fallback would 400). enableEsqlViewInstall: false, + enableIndexAliasInstall: false, enableSloTemplates: true, newBrowseIntegrationUx: true, // When enabled integrations, browse integrations page will use the new UX. enableVersionSpecificPolicies: true, // When enabled, version specific policies will be created when packages use agent version conditions diff --git a/x-pack/platform/plugins/shared/fleet/common/services/index.ts b/x-pack/platform/plugins/shared/fleet/common/services/index.ts index 8893a9a658705..c1d14aa33a62b 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/index.ts @@ -157,6 +157,13 @@ export { getOtelCollectorDisplayName, getOtelCollectorConfigName } from './otel_ export { isNamespaceAllowedByPrefixes } from './namespace_prefixes'; export { getAgentlessThroughputIndexPatterns } from './agentless_throughput_helper'; +export { + isKibanaOnlyIntegration, + isConnectorVar, + getConnectorChecklist, + isConnectorSetupComplete, +} from './kibana_only_integration'; +export type { ConnectorChecklistItem } from './kibana_only_integration'; export type { YamlModule } from './yaml_utils'; export { createYamlKeysSorter, toYaml } from './yaml_utils'; diff --git a/x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.test.ts new file mode 100644 index 0000000000000..be0011bc6f1a3 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.test.ts @@ -0,0 +1,188 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { PackageInfo, RegistryPolicyTemplate, RegistryVarsEntry } from '../types'; +import { + isKibanaOnlyIntegration, + isConnectorVar, + getConnectorChecklist, + isConnectorSetupComplete, +} from './kibana_only_integration'; + +const template = (name: string, inputs: unknown[]): RegistryPolicyTemplate => + ({ name, title: name, description: name, inputs } as unknown as RegistryPolicyTemplate); + +const pkg = (templates: RegistryPolicyTemplate[]): Pick => + ({ policy_templates: templates } as Pick); + +const varDef = ( + name: string, + extra: Partial = {} +): RegistryVarsEntry => ({ name, type: 'text', ...extra } as RegistryVarsEntry); + +describe('FLEET-013 · Kibana-only integration helpers', () => { + describe('isKibanaOnlyIntegration', () => { + it('detects a package whose only policy template has empty inputs', () => { + expect(isKibanaOnlyIntegration(pkg([template('sdlc_intel', [])]))).toBe(true); + }); + + it('is false when the template declares inputs', () => { + expect(isKibanaOnlyIntegration(pkg([template('nginx', [{ type: 'logfile' }])]))).toBe(false); + }); + + it('is false when ANY template declares inputs (partial match must not hide the agent step)', () => { + expect( + isKibanaOnlyIntegration( + pkg([template('kibana_only', []), template('agent_based', [{ type: 'logfile' }])]) + ) + ).toBe(false); + }); + + it('is true only when EVERY template has empty inputs', () => { + expect(isKibanaOnlyIntegration(pkg([template('a', []), template('b', [])]))).toBe(true); + }); + + it('is false when inputs is undefined rather than an empty array', () => { + expect( + isKibanaOnlyIntegration(pkg([{ name: 'x', title: 'x' } as RegistryPolicyTemplate])) + ).toBe(false); + }); + + it('is false for a package with no policy templates', () => { + expect(isKibanaOnlyIntegration(pkg([]))).toBe(false); + }); + + it('is false for undefined package info', () => { + expect(isKibanaOnlyIntegration(undefined)).toBe(false); + }); + + it('scopes the check to a named template when given', () => { + const info = pkg([template('kibana_only', []), template('agent_based', [{ type: 'log' }])]); + + expect(isKibanaOnlyIntegration(info, 'kibana_only')).toBe(true); + expect(isKibanaOnlyIntegration(info, 'agent_based')).toBe(false); + }); + + it('is false when the named template does not exist', () => { + expect(isKibanaOnlyIntegration(pkg([template('a', [])]), 'missing')).toBe(false); + }); + }); + + describe('isConnectorVar', () => { + it.each([['github_connector_id'], ['slack_connector']])('recognises %s', (name) => { + expect(isConnectorVar(varDef(name))).toBe(true); + }); + + it.each([['analysis_window_days'], ['connector_timeout'], ['id']])( + 'does not recognise %s', + (name) => { + expect(isConnectorVar(varDef(name))).toBe(false); + } + ); + }); + + describe('getConnectorChecklist', () => { + const packageVars = { + vars: [ + varDef('github_connector_id', { required: true, description: 'GitHub connector' }), + varDef('slack_connector', { required: false, title: 'Slack' }), + varDef('analysis_window_days', { required: true }), + ], + } as Pick; + + it('lists only connector vars, ignoring unrelated package vars', () => { + expect(getConnectorChecklist(packageVars).map((i) => i.name)).toEqual([ + 'github_connector_id', + 'slack_connector', + ]); + }); + + it('marks a connector as configured when a value is present', () => { + const checklist = getConnectorChecklist(packageVars, { github_connector_id: 'abc-123' }); + + expect(checklist.find((i) => i.name === 'github_connector_id')?.configured).toBe(true); + expect(checklist.find((i) => i.name === 'slack_connector')?.configured).toBe(false); + }); + + it('treats an empty or whitespace-only value as not configured', () => { + const checklist = getConnectorChecklist(packageVars, { + github_connector_id: ' ', + slack_connector: '', + }); + + expect(checklist.every((i) => !i.configured)).toBe(true); + }); + + it('carries required and title/description through for rendering', () => { + const [github, slack] = getConnectorChecklist(packageVars); + + expect(github).toMatchObject({ + name: 'github_connector_id', + title: 'github_connector_id', + description: 'GitHub connector', + required: true, + }); + expect(slack).toMatchObject({ title: 'Slack', required: false }); + }); + + it('returns an empty checklist when the package declares no vars', () => { + expect(getConnectorChecklist({ vars: [] } as Pick)).toEqual([]); + expect(getConnectorChecklist(undefined)).toEqual([]); + }); + }); + + describe('isConnectorSetupComplete', () => { + it('is true when every required connector is configured', () => { + expect( + isConnectorSetupComplete([ + { name: 'a', title: 'a', required: true, configured: true }, + { name: 'b', title: 'b', required: false, configured: false }, + ]) + ).toBe(true); + }); + + it('is false when a required connector is missing', () => { + expect( + isConnectorSetupComplete([{ name: 'a', title: 'a', required: true, configured: false }]) + ).toBe(false); + }); + + it('is true for an empty checklist (nothing to configure)', () => { + expect(isConnectorSetupComplete([])).toBe(true); + }); + + it('ignores optional connectors when deciding completeness', () => { + expect( + isConnectorSetupComplete([{ name: 'b', title: 'b', required: false, configured: false }]) + ).toBe(true); + }); + }); + + describe('sdlc_intel manifest shape (regression guard)', () => { + it('classifies the shipped sdlc_intel manifest as Kibana-only with a GitHub connector', () => { + const sdlcIntel = { + policy_templates: [template('sdlc_intel', [])], + vars: [ + varDef('github_connector_id', { required: true }), + varDef('analysis_window_days'), + ], + } as Pick; + + expect(isKibanaOnlyIntegration(sdlcIntel)).toBe(true); + + const checklist = getConnectorChecklist(sdlcIntel); + expect(checklist).toHaveLength(1); + expect(checklist[0]).toMatchObject({ name: 'github_connector_id', required: true }); + expect(isConnectorSetupComplete(checklist)).toBe(false); + + const configured = getConnectorChecklist(sdlcIntel, { github_connector_id: 'gh-1' }); + expect(isConnectorSetupComplete(configured)).toBe(true); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.ts b/x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.ts new file mode 100644 index 0000000000000..185e75c439cd1 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/common/services/kibana_only_integration.ts @@ -0,0 +1,123 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { + PackageInfo, + RegistryPolicyTemplate, + RegistryPolicyIntegrationTemplate, + RegistryVarsEntry, +} from '../types'; + +/** + * FLEET-013 · Kibana-only (agentless ETL) integrations. + * + * A Kibana-only integration ships no Elastic Agent inputs — all of its work runs + * inside Kibana (workflows, connectors, saved objects). It is a distinct category + * from Elastic's *hosted* agentless (`deployment_modes.agentless.enabled`), which + * still runs an agent, just one Elastic operates. + * + * The manifest signal is a policy template that declares no inputs: + * + * ```yaml + * policy_templates: + * - name: sdlc_intel + * inputs: [] # <- no Elastic Agent + * deployment_modes: + * default: agentless + * ``` + * + * Fleet UI uses this to hide Elastic Agent policy steps and to show a + * connector-setup checklist instead. + */ + +/** Suffixes that mark a package var as pointing at a stack connector. */ +const CONNECTOR_VAR_SUFFIXES = ['_connector_id', '_connector'] as const; + +const hasNoInputs = (policyTemplate: RegistryPolicyTemplate): boolean => { + // `inputs` exists only on the integration arm of the RegistryPolicyTemplate + // union. An input-only template is agent-based by definition, so it never + // qualifies as Kibana-only. + const { inputs } = policyTemplate as RegistryPolicyIntegrationTemplate; + return Array.isArray(inputs) && inputs.length === 0; +}; + +/** + * True when the package is Kibana-only: it has at least one policy template and + * every template declares an empty `inputs` array. + * + * Deliberately strict — a package with *some* agent inputs still needs the agent + * policy step, so a partial match must not hide it. + */ +export const isKibanaOnlyIntegration = ( + packageInfo?: Pick, + integrationToEnable?: string +): boolean => { + const templates = packageInfo?.policy_templates; + if (!templates || templates.length === 0) { + return false; + } + + if (integrationToEnable) { + const template = templates.find(({ name }) => name === integrationToEnable); + return template ? hasNoInputs(template) : false; + } + + return templates.every(hasNoInputs); +}; + +/** True when a package var refers to a stack connector (by naming convention). */ +export const isConnectorVar = (varDef: RegistryVarsEntry): boolean => + CONNECTOR_VAR_SUFFIXES.some((suffix) => varDef.name.endsWith(suffix)); + +export interface ConnectorChecklistItem { + /** Package var name, e.g. `github_connector_id`. */ + name: string; + title: string; + description?: string; + required: boolean; + /** True once the var has a non-empty value in the policy. */ + configured: boolean; +} + +/** + * Builds the connector-setup checklist shown in place of the agent policy step. + * + * Only connector vars are listed: they are the actual prerequisite for a + * Kibana-only integration to do any work, and the thing admins most often miss. + */ +export const getConnectorChecklist = ( + packageInfo?: Pick, + currentValues: Record = {} +): ConnectorChecklistItem[] => { + const vars = packageInfo?.vars; + if (!vars || vars.length === 0) { + return []; + } + + return vars.filter(isConnectorVar).map((varDef) => { + const value = currentValues[varDef.name]; + const configured = + value !== undefined && value !== null && String(value).trim().length > 0; + + return { + name: varDef.name, + title: varDef.title ?? varDef.name, + description: varDef.description, + required: varDef.required === true, + configured, + }; + }); +}; + +/** + * True when every *required* connector var is configured — i.e. the integration + * can actually run. Optional connectors do not block enablement. + */ +export const isConnectorSetupComplete = (checklist: ConnectorChecklistItem[]): boolean => + checklist.filter((item) => item.required).every((item) => item.configured); diff --git a/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts b/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts index 30f6cfa9eeb93..727ae0961f7b3 100644 --- a/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/common/services/package_to_package_policy.test.ts @@ -65,6 +65,9 @@ describe('Fleet - packageToPackagePolicy', () => { path: '', assets: { kibana: { + workflow: [], + agent: [], + skill: [], csp_rule_template: [], dashboard: [], visualization: [], @@ -91,6 +94,7 @@ describe('Fleet - packageToPackagePolicy', () => { ml_model: [], knowledge_base: [], esql_view: [], + index_alias: [], }, }, status: 'not_installed', diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts index d2134fa4e358f..2e1c2e64522fa 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/epm.ts @@ -76,6 +76,9 @@ export enum KibanaAssetType { osqueryPackAsset = 'osquery_pack_asset', osquerySavedQuery = 'osquery_saved_query', tag = 'tag', + workflow = 'workflow', + agent = 'agent', + skill = 'skill', } /* @@ -98,6 +101,9 @@ export enum KibanaSavedObjectType { osquerySavedQuery = 'osquery-saved-query', tag = 'tag', alert = 'alert', + workflow = 'workflow', + agent = 'agent', + skill = 'skill', } export enum ElasticsearchAssetType { @@ -111,6 +117,7 @@ export enum ElasticsearchAssetType { mlModel = 'ml_model', knowledgeBase = 'knowledge_base', esqlView = 'esql_view', + indexAlias = 'index_alias', } export type FleetElasticsearchAssetType = Exclude< @@ -783,13 +790,17 @@ export enum INSTALL_STATES { CREATE_RESTART_INSTALLATION = 'create_restart_installation', INSTALL_PRECHECK = 'install_precheck', INSTALL_ESQL_VIEWS = 'install_esql_views', + INSTALL_INDEX_ALIASES = 'install_index_aliases', INSTALL_KIBANA_ASSETS = 'install_kibana_assets', INSTALL_ILM_POLICIES = 'install_ilm_policies', CREATE_ALERTING_ASSETS = 'create_alerting_assets', + CREATE_AGENT_ASSETS = 'create_agent_assets', + CREATE_WORKFLOW_ASSETS = 'create_workflow_assets', INSTALL_ML_MODEL = 'install_ml_model', INSTALL_INDEX_TEMPLATE_PIPELINES = 'install_index_template_pipelines', REMOVE_LEGACY_TEMPLATES = 'remove_legacy_templates', UPDATE_CURRENT_WRITE_INDICES = 'update_current_write_indices', + CREATE_INDICES = 'create_indices', INSTALL_TRANSFORMS = 'install_transforms', DELETE_PREVIOUS_PIPELINES = 'delete_previous_pipelines', SAVE_ARCHIVE_ENTRIES = 'save_archive_entries_from_assets_map', diff --git a/x-pack/platform/plugins/shared/fleet/common/types/models/package_spec.ts b/x-pack/platform/plugins/shared/fleet/common/types/models/package_spec.ts index c31ed1a1d9933..378ba7d6f0654 100644 --- a/x-pack/platform/plugins/shared/fleet/common/types/models/package_spec.ts +++ b/x-pack/platform/plugins/shared/fleet/common/types/models/package_spec.ts @@ -74,6 +74,13 @@ export interface PackageSpecManifest { agent?: { privileges?: { root?: boolean }; }; + workflows?: { + default_enabled?: boolean | string[]; + /** Workflow file dependencies. Each value must be installed before its key. */ + dependencies?: Record; + }; + /** When true, shipped alerting_rule_template assets are materialized as real (disabled) alerting rules on install. Default: false. */ + create_alerting_rules?: boolean; asset_tags?: PackageSpecTags[]; discovery?: { fields?: Array<{ diff --git a/x-pack/platform/plugins/shared/fleet/kibana.jsonc b/x-pack/platform/plugins/shared/fleet/kibana.jsonc index a8d0271fcdb66..7ec3bad11481a 100644 --- a/x-pack/platform/plugins/shared/fleet/kibana.jsonc +++ b/x-pack/platform/plugins/shared/fleet/kibana.jsonc @@ -42,7 +42,9 @@ "automaticImport", "alerting", "sloShared", - "reporting" + "reporting", + "workflowsManagement", + "agentBuilder" ], "requiredBundles": [ "kibanaReact", diff --git a/x-pack/platform/plugins/shared/fleet/moon.yml b/x-pack/platform/plugins/shared/fleet/moon.yml index 24a4d9b016d8f..117731871204e 100644 --- a/x-pack/platform/plugins/shared/fleet/moon.yml +++ b/x-pack/platform/plugins/shared/fleet/moon.yml @@ -18,6 +18,7 @@ project: sourceRoot: x-pack/platform/plugins/shared/fleet dependsOn: - '@kbn/core' + - '@kbn/core-http-server-utils' - '@kbn/tracing-utils' - '@kbn/licensing-plugin' - '@kbn/data-plugin' @@ -124,6 +125,7 @@ dependsOn: - '@kbn/scout' - '@kbn/reporting-common' - '@kbn/reporting-plugin' + - '@kbn/workflows-management-plugin' - '@kbn/user-profile-components' - '@kbn/css-utils' - '@kbn/rison' @@ -136,6 +138,8 @@ dependsOn: - '@kbn/elastic-agent-condition-language' - '@kbn/app-header' - '@kbn/core-http-browser' + - '@kbn/agent-builder-server' + - '@kbn/agent-builder-common' tags: - plugin - prod diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.test.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.test.ts index b8721035210fb..c193015843b02 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.test.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.test.ts @@ -98,40 +98,4 @@ describe('getIncompatibleAgentVersionStatus', () => { ]); expect(result).toEqual({ status: 'ALL', versionCondition: '>=8.0.0' }); }); - - describe('pre-release version coercion', () => { - it('treats a pre-release version as its base version when it satisfies the condition', () => { - const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=9.0.0'), [ - createAgentPolicy([{ version: '9.6.0-SNAPSHOT', count: 1 }]), - ]); - expect(result).toEqual({ status: 'NONE' }); - }); - - it('treats a pre-release version as its base version when it does not satisfy the condition', () => { - const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ - createAgentPolicy([{ version: '7.17.0-SNAPSHOT', count: 1 }]), - ]); - expect(result).toEqual({ status: 'ALL', versionCondition: '>=8.0.0' }); - }); - - it('returns SOME when pre-release agents satisfy the condition but others do not', () => { - const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ - createAgentPolicy([ - { version: '9.0.0-SNAPSHOT', count: 2 }, - { version: '7.17.0', count: 3 }, - ]), - ]); - expect(result).toEqual({ status: 'SOME', versionCondition: '>=8.0.0' }); - }); - - it('returns NONE when all agents are pre-release versions that satisfy the condition', () => { - const result = getIncompatibleAgentVersionStatus(createPackageInfo('>=8.0.0'), [ - createAgentPolicy([ - { version: '8.1.0-SNAPSHOT', count: 2 }, - { version: '9.0.0-SNAPSHOT', count: 3 }, - ]), - ]); - expect(result).toEqual({ status: 'NONE' }); - }); - }); }); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.ts b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.ts index 790ea5b10b2d1..1678e53fb2f27 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.ts +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/hooks/use_incompatible_agent_version_status.ts @@ -8,7 +8,6 @@ import { useMemo } from 'react'; import satisfies from 'semver/functions/satisfies'; -import coerce from 'semver/functions/coerce'; import type { AgentPolicy } from '../types'; import type { PackageInfo } from '../../../../common/types'; @@ -40,12 +39,12 @@ export const getIncompatibleAgentVersionStatus = ( if (!agentPerVersion) { return acc; } - const isIncompatible = (version: string) => { - const coerced = coerce(version); - return !satisfies(coerced ? coerced.version : version, versionCondition); - }; - const hasAllIncompatible = agentPerVersion.every((entry) => isIncompatible(entry.version)); - const hasSomeIncompatible = agentPerVersion.some((entry) => isIncompatible(entry.version)); + const hasAllIncompatible = agentPerVersion.every( + (entry) => !satisfies(entry.version, versionCondition) + ); + const hasSomeIncompatible = agentPerVersion.some( + (entry) => !satisfies(entry.version, versionCondition) + ); return hasAllIncompatible ? 'ALL' : hasSomeIncompatible ? 'SOME' : acc; }, 'NONE'); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx index 39ca6cfc5ddda..e4ad144374a8d 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.test.tsx @@ -13,11 +13,7 @@ import type { PackageInfo } from '../../../../../../../../common'; import type { TestRenderer } from '../../../../../../../mock'; import { createFleetTestRendererMock } from '../../../../../../../mock'; -import { - useGetAgentPolicies, - useMultipleAgentPolicies, - sendBulkGetAgentPolicies, -} from '../../../../../hooks'; +import { useGetAgentPolicies, useMultipleAgentPolicies } from '../../../../../hooks'; import { StepSelectAgentPolicy } from './step_select_agent_policy'; @@ -67,9 +63,6 @@ const useGetAgentPoliciesMock = useGetAgentPolicies as jest.MockedFunction< const useMultipleAgentPoliciesMock = useMultipleAgentPolicies as jest.MockedFunction< typeof useMultipleAgentPolicies >; -const sendBulkGetAgentPoliciesMock = sendBulkGetAgentPolicies as jest.MockedFunction< - typeof sendBulkGetAgentPolicies ->; describe('stepStepSelectAgentPolicy', () => { let testRenderer: TestRenderer; @@ -117,37 +110,6 @@ describe('stepStepSelectAgentPolicy', () => { }); }); - test('should not block save when all agents are incompatible with the integration version requirement', async () => { - useGetAgentPoliciesMock.mockReturnValue({ - data: { items: [{ id: 'policy-1', name: 'Policy 1' }] }, - error: undefined, - isLoading: false, - resendRequest: jest.fn(), - } as any); - sendBulkGetAgentPoliciesMock.mockResolvedValueOnce({ - data: { - items: [ - { - id: 'policy-1', - package_policies: [], - agents_per_version: [{ version: '7.17.0', count: 3 }], - }, - ], - }, - } as any); - - render({ name: 'test-integration', conditions: { agent: { version: '>=8.0.0' } } } as any); - await act(async () => {}); - await act(async () => {}); - - expect( - renderResult.queryByText( - /None of the agents using the selected agent policies are compatible/ - ) - ).not.toBeInTheDocument(); - expect(mockSetHasAgentPolicyError).toHaveBeenCalledWith(false); - }); - test('should select agent policy by default if one exists', async () => { useGetAgentPoliciesMock.mockReturnValue({ data: { items: [{ id: 'policy-1', name: 'Policy 1' }] }, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx index 944cc91f512c7..e1efb584034a9 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/components/steps/step_select_agent_policy.tsx @@ -24,6 +24,7 @@ import { Error } from '../../../../../components'; import type { AgentPolicy, PackageInfo } from '../../../../../types'; import { isPackageLimited, doesAgentPolicyAlreadyIncludePackage } from '../../../../../services'; import { sendBulkGetAgentPolicies } from '../../../../../hooks'; +import { useIncompatibleAgentVersionStatus } from '../../../../../hooks/use_incompatible_agent_version_status'; import { useMultipleAgentPolicies } from '../../../../../hooks'; import { AgentPolicyMultiSelect } from './components/agent_policy_multi_select'; @@ -169,6 +170,12 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ (policy) => !initialSelectedAgentPolicyIds.find((id) => policy.id === id) ); + const incompatibleAgentVersion = useIncompatibleAgentVersionStatus( + packageInfo, + newlySelectedAgentPolicies + ); + const someNewAgentPoliciesHaveAllAgentIncompatible = incompatibleAgentVersion.status === 'ALL'; + // Display agent policies list error if there is one if (agentPoliciesError) { return ( @@ -244,13 +251,24 @@ export const StepSelectAgentPolicy: React.FunctionComponent<{ ) : null } - isInvalid={Boolean(someNewAgentPoliciesHaveLimitedPackage)} + isInvalid={Boolean( + someNewAgentPoliciesHaveLimitedPackage || + someNewAgentPoliciesHaveAllAgentIncompatible + )} error={ someNewAgentPoliciesHaveLimitedPackage ? ( + ) : someNewAgentPoliciesHaveAllAgentIncompatible ? ( + ) : null } > diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.test.tsx new file mode 100644 index 0000000000000..a176c40bdce90 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.test.tsx @@ -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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1"; + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { I18nProvider } from '@kbn/i18n-react'; + +import type { ConnectorChecklistItem } from '../../../../../../../common/services'; +import { ConnectorSetupChecklist } from './connector_setup_checklist'; + +const renderChecklist = (items: ConnectorChecklistItem[]) => + render( + + + + ); + +const item = (overrides: Partial = {}): ConnectorChecklistItem => ({ + name: 'github_connector_id', + title: 'GitHub connector', + required: true, + configured: false, + ...overrides, +}); + +describe('FLEET-013 · ConnectorSetupChecklist', () => { + it('renders nothing when there are no connector vars', () => { + const { container } = renderChecklist([]); + expect(container).toBeEmptyDOMElement(); + }); + + it('lists each connector by title', () => { + renderChecklist([ + item(), + item({ name: 'slack_connector', title: 'Slack connector', required: false }), + ]); + + expect(screen.getByText('GitHub connector')).toBeInTheDocument(); + expect(screen.getByText('Slack connector')).toBeInTheDocument(); + }); + + it('marks a configured connector as done and a pending one as outstanding', () => { + renderChecklist([ + item({ configured: true }), + item({ name: 'slack_connector', title: 'Slack', configured: false }), + ]); + + expect(screen.getByTestId('connectorChecklistConfigured-github_connector_id')).toBeInTheDocument(); + expect(screen.getByTestId('connectorChecklistPending-slack_connector')).toBeInTheDocument(); + }); + + it('warns while a required connector is unconfigured', () => { + renderChecklist([item({ configured: false })]); + + expect(screen.getByTestId('connectorSetupIncomplete')).toBeInTheDocument(); + expect(screen.queryByTestId('connectorSetupComplete')).not.toBeInTheDocument(); + }); + + it('confirms once every required connector is configured', () => { + renderChecklist([ + item({ configured: true }), + item({ name: 'slack_connector', title: 'Slack', required: false, configured: false }), + ]); + + expect(screen.getByTestId('connectorSetupComplete')).toBeInTheDocument(); + expect(screen.queryByTestId('connectorSetupIncomplete')).not.toBeInTheDocument(); + }); + + it('badges required connectors only', () => { + renderChecklist([ + item(), + item({ name: 'slack_connector', title: 'Slack', required: false }), + ]); + + expect(screen.getAllByText('Required')).toHaveLength(1); + }); + + it('states that no Elastic Agent is required', () => { + renderChecklist([item()]); + + expect(screen.getByText(/no Elastic Agent is required/i)).toBeInTheDocument(); + }); + + it('shows a connector description when the manifest provides one', () => { + renderChecklist([item({ description: 'Connector ID for the GitHub inference connector' })]); + + expect( + screen.getByText('Connector ID for the GitHub inference connector') + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.tsx new file mode 100644 index 0000000000000..89f751b816b5d --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/connector_setup_checklist.tsx @@ -0,0 +1,123 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1"; + */ + +import React from 'react'; +import { + EuiCallOut, + EuiIcon, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, + EuiFlexGroup, + EuiFlexItem, + EuiBadge, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +import type { ConnectorChecklistItem } from '../../../../../../../common/services'; +import { isConnectorSetupComplete } from '../../../../../../../common/services'; + +export interface ConnectorSetupChecklistProps { + items: ConnectorChecklistItem[]; +} + +/** + * FLEET-013 · Connector-setup checklist for Kibana-only integrations. + * + * Shown in place of the Elastic Agent policy step. A Kibana-only integration does + * no work until its connectors are wired, so this makes the real prerequisite + * explicit instead of leaving admins in an agent-policy flow that does not apply. + */ +export const ConnectorSetupChecklist: React.FunctionComponent = ({ + items, +}) => { + if (items.length === 0) { + return null; + } + + const complete = isConnectorSetupComplete(items); + + return ( + + +

+ +

+
+ + +

+ +

+
+ + + {items.map((item) => ( +
+ + + + + + {item.title} + + {item.required ? ( + + + {i18n.translate('xpack.fleet.createPackagePolicy.connectorRequiredBadge', { + defaultMessage: 'Required', + })} + + + ) : null} + + {item.description ? ( + +

{item.description}

+
+ ) : null} + +
+ ))} + + +
+ ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx index caec568138f0e..55f6827c8f990 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agent_policy/create_package_policy_page/single_page_layout/index.tsx @@ -38,6 +38,8 @@ import { import { useCancelAddPackagePolicy } from '../hooks'; import { + isKibanaOnlyIntegration, + getConnectorChecklist, checkIntegrationFipsLooseCompatibility, getInheritedNamespace, getRootPrivilegedDataStreams, @@ -108,6 +110,7 @@ import { PostInstallGoogleCloudShellModal } from './components/cloud_security_po import { PostInstallAzureArmTemplateModal } from './components/cloud_security_posture/post_install_azure_arm_template_modal'; import { RootPrivilegesCallout } from './root_callout'; import { useAgentless } from './hooks/setup_technology'; +import { ConnectorSetupChecklist } from './connector_setup_checklist'; export const StepsWithLessPadding = styled(EuiSteps)` .euiStep__content { @@ -713,6 +716,21 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ ] ); + // FLEET-013: connector prerequisites for Kibana-only integrations. + const connectorChecklist = useMemo( + () => + getConnectorChecklist( + packageInfo, + Object.fromEntries( + Object.entries(packagePolicy.vars ?? {}).map(([key, entry]) => [ + key, + (entry as { value?: unknown })?.value, + ]) + ) + ), + [packageInfo, packagePolicy.vars] + ); + const steps: EuiStepProps[] = [ ...(addIntegrationFlyoutProps?.selectIntegrationStep ? [addIntegrationFlyoutProps?.selectIntegrationStep] @@ -747,12 +765,24 @@ export const CreatePackagePolicySinglePage: CreatePackagePolicyParams = ({ /> )} {replaceStepConfigurePackagePolicy || stepConfigurePackagePolicy} + {/* FLEET-013: Kibana-only integrations show a connector checklist + instead of the Elastic Agent policy step. */} + {isKibanaOnlyIntegration(packageInfo, integration) ? ( + <> + + + + ) : null} ), headingElement: 'h2', status: !pkgName ? 'disabled' : undefined, }, - ...(selectedSetupTechnology !== SetupTechnology.AGENTLESS && !addIntegrationFlyoutProps + // FLEET-013: Hide agent policy step for Kibana-only integrations (inputs: []). + // Uses the shared helper so the UI and the manifest contract cannot drift. + ...(!addIntegrationFlyoutProps && + selectedSetupTechnology !== SetupTechnology.AGENTLESS && + !isKibanaOnlyIntegration(packageInfo, integration) ? [ { title: i18n.translate('xpack.fleet.createPackagePolicy.stepSelectAgentPolicyTitle', { diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/view_logs_button.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/view_logs_button.tsx index 17454ee83c302..109c64bb562aa 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/view_logs_button.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/agents/agent_details_page/components/agent_logs/view_logs_button.tsx @@ -49,6 +49,7 @@ export const ViewLogsButton: React.FunctionComponent = ({ const defaultEndTime = getFormattedRange(now); return logsLocator?.getRedirectUrl({ + time: endTime ? endTime : defaultEndTime, timeRange: { from: getTimeRangeStartFromTime(startTime ? startTime : defaultStartTime), to: getTimeRangeEndFromTime(endTime ? endTime : defaultEndTime), diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/constants.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/constants.tsx index a63ed1d73ff93..1bcdb31ef22bd 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/constants.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/sections/epm/constants.tsx @@ -98,6 +98,12 @@ export const AssetTitleMap: Record< osquery_saved_query: i18n.translate('xpack.fleet.epm.assetTitles.osquerySavedQuery', { defaultMessage: 'Osquery saved queries', }), + workflow: i18n.translate('xpack.fleet.epm.assetTitles.workflows', { + defaultMessage: 'Workflows', + }), + agent: i18n.translate('xpack.fleet.epm.assetTitles.agents', { + defaultMessage: 'Agents', + }), // ES ilm_policy: i18n.translate('xpack.fleet.epm.assetTitles.ilmPolicies', { @@ -136,6 +142,9 @@ export const AssetTitleMap: Record< esql_view: i18n.translate('xpack.fleet.epm.assetTitles.esqlViews', { defaultMessage: 'ES|QL views', }), + index_alias: i18n.translate('xpack.fleet.epm.assetTitles.indexAliases', { + defaultMessage: 'Index aliases', + }), }; export const ServiceTitleMap: Record = { diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_reusable_connector_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_reusable_connector_form.test.tsx index fb6d0bca2b814..05ee9f0f6b9b2 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_reusable_connector_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_cloud_connector/aws_reusable_connector_form.test.tsx @@ -169,7 +169,6 @@ describe('AWSReusableConnectorForm', () => { // Verify setCredentials was called with correct values expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'AWS Connector 1', roleArn: 'arn:aws:iam::123456789012:role/Role1', externalId: 'external-id-123', cloudConnectorId: 'connector-1', @@ -191,7 +190,6 @@ describe('AWSReusableConnectorForm', () => { // Verify setCredentials was called with correct values expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'AWS Connector 2', roleArn: 'arn:aws:iam::123456789012:role/Role2', externalId: 'external-id-456', cloudConnectorId: 'connector-2', diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.test.tsx deleted file mode 100644 index 752dc4fcc3fc2..0000000000000 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { act, render, waitFor } from '@testing-library/react'; -import { I18nProvider } from '@kbn/i18n-react'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import { QueryClient, QueryClientProvider } from '@kbn/react-query'; - -import { SINGLE_ACCOUNT } from '../../../../common'; - -import { useGetCloudConnectors } from '../hooks/use_get_cloud_connectors'; -import { useCreateCloudConnector } from '../hooks/use_create_cloud_connector'; - -import { AwsIdentityFederationSetup } from './aws_identity_federation_setup'; - -jest.mock('@kbn/kibana-react-plugin/public'); -jest.mock('../hooks/use_get_cloud_connectors'); -jest.mock('../hooks/use_create_cloud_connector'); - -const mockUseKibana = useKibana as jest.MockedFunction; -const mockUseGetCloudConnectors = useGetCloudConnectors as jest.MockedFunction< - typeof useGetCloudConnectors ->; -const mockUseCreateCloudConnector = useCreateCloudConnector as jest.MockedFunction< - typeof useCreateCloudConnector ->; - -const mockCloudConnectors = [ - { - id: 'connector-1', - name: 'AWS Connector 1', - cloudProvider: 'aws', - accountType: SINGLE_ACCOUNT, - vars: { - role_arn: { value: 'arn:aws:iam::123456789012:role/Role1' }, - external_id: { value: 'external-id-1' }, - }, - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-02T00:00:00Z', - }, -]; - -describe('AwsIdentityFederationSetup', () => { - let queryClient: QueryClient; - const onConnectorIdChange = jest.fn(); - const onReadyChange = jest.fn(); - - const mockGetConnectors = (overrides: Partial> = {}) => { - mockUseGetCloudConnectors.mockReturnValue({ - data: mockCloudConnectors, - isLoading: false, - ...overrides, - } as unknown as ReturnType); - }; - - const renderSetup = ( - props: Partial> = {} - ) => - render( - - - - - - ); - - beforeEach(() => { - jest.clearAllMocks(); - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - - mockUseKibana.mockReturnValue({ - services: { application: { navigateToApp: jest.fn() } }, - } as unknown as ReturnType); - - mockUseCreateCloudConnector.mockReturnValue({ - mutate: jest.fn(), - isLoading: false, - } as unknown as ReturnType); - - mockGetConnectors(); - }); - - it('emits undefined id and name when nothing is selected', () => { - renderSetup(); - expect(onConnectorIdChange).toHaveBeenCalledWith(undefined, undefined); - }); - - describe('initialConnectorId (edit flow)', () => { - it('does not emit while the name for the seeded id is still loading', () => { - mockGetConnectors({ data: undefined, isLoading: true }); - renderSetup({ initialConnectorId: 'connector-1' }); - - // The id is known but its name isn't resolved yet — emitting here would persist - // a connector with no name and render an empty summary row downstream. - expect(onConnectorIdChange).not.toHaveBeenCalled(); - }); - - it('still reports readiness while the name is loading', () => { - mockGetConnectors({ data: undefined, isLoading: true }); - renderSetup({ initialConnectorId: 'connector-1' }); - - // Readiness depends on the id alone and must not be held back by the name. - expect(onReadyChange).toHaveBeenCalledWith(true); - }); - - it('emits id and name once the connector list resolves', async () => { - renderSetup({ initialConnectorId: 'connector-1' }); - - await waitFor(() => { - expect(onConnectorIdChange).toHaveBeenCalledWith('connector-1', 'AWS Connector 1'); - }); - }); - - it('emits the id even when the seeded connector is absent from the list', async () => { - mockGetConnectors({ data: [] }); - renderSetup({ initialConnectorId: 'missing-connector' }); - - // The list finished loading and the id isn't in it, so the name will never arrive — - // emit rather than holding the value back forever. - await waitFor(() => { - expect(onConnectorIdChange).toHaveBeenCalledWith('missing-connector', undefined); - }); - }); - }); - - describe('create flow', () => { - it('emits id and name together after a connector is created', async () => { - let onSuccess: ((connector: { id: string; name: string }) => void) | undefined; - mockUseCreateCloudConnector.mockImplementation((cb) => { - onSuccess = cb as typeof onSuccess; - return { mutate: jest.fn(), isLoading: false } as unknown as ReturnType< - typeof useCreateCloudConnector - >; - }); - - renderSetup(); - onConnectorIdChange.mockClear(); - - // The created connector's name comes straight off the mutation response, so it is - // available before the invalidated connector-list query refetches. - act(() => { - onSuccess?.({ id: 'new-connector', name: 'Freshly Created' }); - }); - - await waitFor(() => { - expect(onConnectorIdChange).toHaveBeenCalledWith('new-connector', 'Freshly Created'); - }); - expect(onConnectorIdChange).not.toHaveBeenCalledWith('new-connector', undefined); - }); - }); -}); diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.tsx index 01597b0e7035c..59dec210e1e9b 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/aws_identity_federation_setup.tsx @@ -41,7 +41,7 @@ export interface AwsIdentityFederationSetupProps { isEditPage?: boolean; initialConnectorId?: string; onReadyChange?: (isReady: boolean) => void; - onConnectorIdChange?: (connectorId: string | undefined, connectorName?: string) => void; + onConnectorIdChange?: (connectorId: string | undefined) => void; } export const AwsIdentityFederationSetup: React.FC = ({ @@ -64,8 +64,8 @@ export const AwsIdentityFederationSetup: React.FC(TABS.NEW_CONNECTION); const [roleArn, setRoleArn] = useState(''); const [connectorName, setConnectorName] = useState(''); - const [selected, setSelected] = useState<{ id: string; name?: string } | undefined>( - initialConnectorId ? { id: initialConnectorId } : undefined + const [selectedConnectorId, setSelectedConnectorId] = useState( + initialConnectorId ); const hasSetInitialTab = useRef(false); @@ -80,24 +80,10 @@ export const AwsIdentityFederationSetup: React.FC { - if (!selected?.id || selected.name) return; - const match = cloudConnectors.find((c) => c.id === selected.id); - if (match) setSelected({ id: match.id, name: match.name }); - }, [cloudConnectors, selected]); - - // A selection made in this component always carries its name, so an id without a name can only - // be the initialConnectorId seed whose name is still being resolved above. Hold the emission - // until it lands, otherwise consumers persist an id with no name and render an empty summary. - const isAwaitingInitialName = !!selected?.id && !selected.name && isLoadingConnectors; - - useEffect(() => { - onReadyChange?.(!!selected?.id); - if (isAwaitingInitialName) return; - onConnectorIdChange?.(selected?.id, selected?.name); - }, [selected, isAwaitingInitialName, onReadyChange, onConnectorIdChange]); + onReadyChange?.(!!selectedConnectorId); + onConnectorIdChange?.(selectedConnectorId); + }, [selectedConnectorId, onReadyChange, onConnectorIdChange]); const cloudFormationUrl = cloud ? getCloudConnectorRemoteRoleTemplate({ @@ -109,7 +95,7 @@ export const AwsIdentityFederationSetup: React.FC { - setSelected({ id: connector.id, name: connector.name }); + setSelectedConnectorId(connector.id); setSelectedTabId(TABS.EXISTING_CONNECTION); setRoleArn(''); setConnectorName(''); @@ -137,7 +123,7 @@ export const AwsIdentityFederationSetup: React.FC { setSelectedTabId(tab.id); if (tab.id === TABS.NEW_CONNECTION) { - setSelected(undefined); + setSelectedConnectorId(undefined); } }; @@ -241,11 +227,11 @@ export const AwsIdentityFederationSetup: React.FC { if (creds.cloudConnectorId) { - setSelected({ id: creds.cloudConnectorId, name: creds.name }); + setSelectedConnectorId(creds.cloudConnectorId); } }} accountType={accountType} diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/index.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/index.tsx index fe4851e9aa2b2..132622983bf35 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/index.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/aws_connect_setup/index.tsx @@ -40,15 +40,13 @@ export interface AwsConnectSetupProps { initialStaticKeys?: Partial; initialTemporaryKeys?: Partial; showIdentityFederation?: boolean; - packageName?: string; - iacTemplateUrl?: string; staticKeysContent?: React.ReactNode; onBack?: () => void; onContinue?: () => void; isContinueButtonLoading?: boolean; continueButtonLabel?: React.ReactNode; continueButtonIconType?: IconType; - onConnectorIdChange?: (connectorId: string | undefined, connectorName?: string) => void; + onConnectorIdChange?: (connectorId: string | undefined) => void; onStaticKeysChange?: (keys: AwsStaticKeyCredentials | undefined) => void; onTemporaryKeysChange?: (keys: AwsTemporaryKeyCredentials | undefined) => void; } @@ -62,8 +60,6 @@ export const AwsConnectSetup: React.FC = ({ initialStaticKeys, initialTemporaryKeys, showIdentityFederation = true, - packageName, - iacTemplateUrl, staticKeysContent, onBack, onContinue, @@ -122,8 +118,6 @@ export const AwsConnectSetup: React.FC = ({ hasInvalidRequiredVars={hasInvalidRequiredVars} isEditPage={isEditPage} initialConnectorId={initialConnectorId} - packageName={packageName} - iacTemplateUrl={iacTemplateUrl} onReadyChange={setIsFormReady} onConnectorIdChange={onConnectorIdChange} /> diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_reusable_connector_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_reusable_connector_form.test.tsx index 84e4ef2dacfd1..ba1ce40be700e 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_reusable_connector_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/azure_cloud_connector/azure_reusable_connector_form.test.tsx @@ -175,7 +175,6 @@ describe('AzureReusableConnectorForm', () => { // Verify setCredentials was called with correct values expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'Azure Connector 1', tenantId: 'tenant-123', clientId: 'client-456', azure_credentials_cloud_connector_id: 'azure-cc-789', @@ -198,7 +197,6 @@ describe('AzureReusableConnectorForm', () => { // Verify setCredentials was called with correct values expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'Azure Connector 2', tenantId: 'tenant-abc', clientId: 'client-def', azure_credentials_cloud_connector_id: 'azure-cc-ghi', diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.test.tsx index ac7c35efcfb36..4b1801789f7a3 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.test.tsx @@ -167,7 +167,6 @@ describe('CloudConnectorSelector', () => { await user.click(screen.getByText('AWS Connector 1')); expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'AWS Connector 1', roleArn: 'arn:aws:iam::123456789012:role/Role1', externalId: 'external-id-1', cloudConnectorId: 'connector-1', @@ -317,7 +316,6 @@ describe('CloudConnectorSelector', () => { await user.click(screen.getByText('GCP Connector 1')); expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'GCP Connector 1', serviceAccount: 'test-service-account@project.iam.gserviceaccount.com', audience: '//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider', diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.tsx index eb0322d2df43a..e8124c7f7be62 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/form/cloud_connector_selector.tsx @@ -172,14 +172,12 @@ export const CloudConnectorSelector = ({ ? connector.vars.external_id.value : connector.vars.external_id?.value; setCredentials({ - name: connector.name, roleArn: connector.vars.role_arn?.value, externalId: externalIdValue, cloudConnectorId: connector.id, }); } else if (isAzureCloudConnectorVars(connector.vars, provider)) { setCredentials({ - name: connector.name, tenantId: connector.vars.tenant_id?.value, clientId: connector.vars.client_id?.value, azure_credentials_cloud_connector_id: @@ -188,7 +186,6 @@ export const CloudConnectorSelector = ({ }); } else if (isGcpCloudConnectorVars(connector.vars, provider)) { setCredentials({ - name: connector.name, serviceAccount: connector.vars.service_account?.value, audience: connector.vars.audience?.value, gcp_credentials_cloud_connector_id: diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/gcp_cloud_connector/gcp_reusable_connector_form.test.tsx b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/gcp_cloud_connector/gcp_reusable_connector_form.test.tsx index 7e2d9623a234e..d1a68ff627893 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/gcp_cloud_connector/gcp_reusable_connector_form.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/gcp_cloud_connector/gcp_reusable_connector_form.test.tsx @@ -185,7 +185,6 @@ describe('GCPReusableConnectorForm', () => { // Verify setCredentials was called with correct values expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'GCP Connector 1', serviceAccount: 'service-account-1@project-123.iam.gserviceaccount.com', audience: '//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool-1/providers/provider-1', @@ -209,7 +208,6 @@ describe('GCPReusableConnectorForm', () => { // Verify setCredentials was called with correct values expect(mockSetCredentials).toHaveBeenCalledWith({ - name: 'GCP Connector 2', serviceAccount: 'service-account-2@project-456.iam.gserviceaccount.com', audience: '//iam.googleapis.com/projects/456/locations/global/workloadIdentityPools/pool-2/providers/provider-2', diff --git a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts index 65fdd477ee776..b98eeb3c3f03a 100644 --- a/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts +++ b/x-pack/platform/plugins/shared/fleet/public/components/cloud_connector/utils.test.ts @@ -22,7 +22,6 @@ import { getCloudConnectorNameError, isCloudConnectorNameValid, CLOUD_CONNECTOR_NAME_MAX_LENGTH, - getAnyCloudConnectorIacTemplateUrl, } from './utils'; import { SINGLE_ACCOUNT, ORGANIZATION_ACCOUNT } from './constants'; import type { CloudConnectorCredentials } from './types'; @@ -982,50 +981,3 @@ describe('Cloud Connector Name Validation', () => { }); }); }); - -describe('getAnyCloudConnectorIacTemplateUrl', () => { - it('returns undefined for undefined input', () => { - expect(getAnyCloudConnectorIacTemplateUrl(undefined)).toBeUndefined(); - }); - - it('returns undefined when package has no var_groups or policy_templates', () => { - expect(getAnyCloudConnectorIacTemplateUrl({} as any)).toBeUndefined(); - }); - - it('returns iac_template_url from var_groups options (primary path — AWS package format)', () => { - const packageInfo = { - var_groups: [ - { - name: 'credentials', - title: 'Credentials', - selector_title: 'Auth method', - options: [ - { - name: 'cloud_connector', - title: 'Federated Identity', - provider: 'aws', - vars: [], - iac_template_url: - 'https://example.com/cloudformation.yaml?account_type=ACCOUNT_TYPE&resource_id=RESOURCE_ID', - }, - ], - }, - ], - } as any; - expect(getAnyCloudConnectorIacTemplateUrl(packageInfo)).toBe( - 'https://example.com/cloudformation.yaml?account_type=ACCOUNT_TYPE&resource_id=RESOURCE_ID' - ); - }); - - it('returns undefined when var_groups exist but no cloud provider option found', () => { - const packageInfo = { - var_groups: [ - { - name: 'g1', - options: [{ name: 'no_cloud_provider', provider: 'other', vars: [] }], - }, - ], - } as any; - expect(getAnyCloudConnectorIacTemplateUrl(packageInfo)).toBeUndefined(); - }); -}); diff --git a/x-pack/platform/plugins/shared/fleet/server/constants/max_concurrency_constants.ts b/x-pack/platform/plugins/shared/fleet/server/constants/max_concurrency_constants.ts index e0e0a4209f922..0a3e86d942b83 100644 --- a/x-pack/platform/plugins/shared/fleet/server/constants/max_concurrency_constants.ts +++ b/x-pack/platform/plugins/shared/fleet/server/constants/max_concurrency_constants.ts @@ -17,6 +17,7 @@ export const MAX_CONCURRENT_CREATE_ACTIONS = 50; export const MAX_CONCURRENT_DATASTREAMS_ILM_OPERATIONS = 50; export const MAX_CONCURRENT_ILM_POLICIES_OPERATIONS = 50; export const MAX_CONCURRENT_ESQL_VIEWS_OPERATIONS = 50; +export const MAX_CONCURRENT_INDEX_ALIAS_OPERATIONS = 50; export const MAX_CONCURRENT_PIPELINES_DELETIONS = 50; export const MAX_CONCURRENT_ML_MODELS_OPERATIONS = 50; export const MAX_CONCURRENT_COMPONENT_TEMPLATES = 50; diff --git a/x-pack/platform/plugins/shared/fleet/server/mocks/index.ts b/x-pack/platform/plugins/shared/fleet/server/mocks/index.ts index eb38bdbaac253..dd57f7d1d8989 100644 --- a/x-pack/platform/plugins/shared/fleet/server/mocks/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/mocks/index.ts @@ -19,10 +19,12 @@ import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { securityMock } from '@kbn/security-plugin/server/mocks'; import { cloudMock } from '@kbn/cloud-plugin/public/mocks'; +import { ManagedWorkflowUpdateForbiddenError } from '@kbn/workflows-management-plugin/server/api/managed_workflow_errors'; import { SPACES_EXTENSION_ID } from '@kbn/core-saved-objects-server'; import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; import { reportingMock } from '@kbn/reporting-plugin/server/mocks'; +import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; @@ -104,6 +106,67 @@ export const createSavedObjectClientMock = () => { return soClientMock; }; +export const createWorkflowsManagementSetupMock = + (): DeeplyMockedKeys => { + const management = { + isWorkflowsAvailable: true, + setSmlIndexAttachment: jest.fn(), + getWorkflow: jest.fn().mockResolvedValue(null), + getWorkflows: jest.fn().mockResolvedValue({ items: [], total: 0, page: 1, size: 20 }), + getWorkflowsSubscribedToTrigger: jest.fn().mockResolvedValue([]), + getHistoryForWorkflow: jest.fn().mockResolvedValue({ items: [], total: 0 }), + getWorkflowsByIds: jest.fn().mockResolvedValue([]), + findExistingWorkflowIds: jest.fn().mockResolvedValue([]), + getWorkflowsSourceByIds: jest.fn().mockResolvedValue([]), + createWorkflow: jest.fn().mockResolvedValue({ id: 'workflow-id' }), + bulkCreateWorkflows: jest.fn().mockResolvedValue({ created: [], failed: [] }), + cloneWorkflow: jest.fn().mockResolvedValue({ id: 'workflow-id' }), + updateWorkflow: jest + .fn() + .mockImplementation(async (id, workflow, spaceId, request, options) => { + const existing = await management.getWorkflow(id, spaceId); + const fields = Object.keys(workflow); + const isEnablementOnly = fields.length === 1 && fields[0] === 'enabled'; + if ( + existing?.managed === true && + !isEnablementOnly && + options?.allowManagedWorkflowMutation !== true + ) { + throw new ManagedWorkflowUpdateForbiddenError(); + } + return { id }; + }), + restoreWorkflowVersion: jest.fn().mockResolvedValue({ id: 'workflow-id' }), + deleteWorkflows: jest.fn().mockResolvedValue({ successfulIds: [], failed: [] }), + disableAllWorkflows: jest.fn().mockResolvedValue(undefined), + runWorkflow: jest.fn().mockResolvedValue({ id: 'execution-id' }), + executeWorkflow: jest.fn().mockResolvedValue({ id: 'execution-id' }), + scheduleWorkflow: jest.fn().mockResolvedValue(undefined), + bulkScheduleWorkflow: jest.fn().mockResolvedValue(undefined), + testWorkflow: jest.fn().mockResolvedValue({ id: 'execution-id' }), + testStep: jest.fn().mockResolvedValue({ id: 'execution-id' }), + getWorkflowExecutions: jest.fn().mockResolvedValue([]), + searchExecutionsView: jest.fn().mockResolvedValue({ items: [], total: 0 }), + getWorkflowExecution: jest.fn().mockResolvedValue({ id: 'execution-id' }), + getChildWorkflowExecutions: jest.fn().mockResolvedValue([]), + getWorkflowExecutionLogs: jest.fn().mockResolvedValue([]), + getStepExecution: jest.fn().mockResolvedValue({ id: 'execution-id' }), + searchStepExecutions: jest.fn().mockResolvedValue({ items: [], total: 0 }), + cancelWorkflowExecution: jest.fn().mockResolvedValue(undefined), + cancelAllActiveWorkflowExecutions: jest.fn().mockResolvedValue(undefined), + resumeWorkflowExecution: jest.fn().mockResolvedValue(undefined), + listWaitingForInputSteps: jest.fn().mockResolvedValue([]), + resumeWorkflowExecutionExternallyViaGet: jest.fn().mockResolvedValue(undefined), + resumeWorkflowExecutionExternallyWithInput: jest.fn().mockResolvedValue(undefined), + getExternalResumeFormPage: jest.fn().mockResolvedValue({}), + listProcessedWaitForInputSteps: jest.fn().mockResolvedValue([]), + listProcessedWaitForInputFacets: jest.fn().mockResolvedValue([]), + markStepAsResponded: jest.fn().mockResolvedValue(undefined), + } as unknown as DeeplyMockedKeys['management']; + + return { management } as unknown as DeeplyMockedKeys; + }; + export interface MockedFleetAppContext extends FleetAppContext { elasticsearch: ReturnType; data: ReturnType; @@ -309,7 +372,6 @@ export const createMockAgentPolicyService = (): jest.Mocked > = async (context, request, response) => { const coreContext = await context.core; + const fleetContext = await context.fleet; const { assetIds } = request.body; const savedObjectsClient = coreContext.savedObjects.getClient({ includedHiddenTypes: [ @@ -311,10 +312,50 @@ export const getBulkAssetsHandler: FleetRequestHandler< ], }); const savedObjectsTypeRegistry = coreContext.savedObjects.typeRegistry; + const workflowsApi = appContextService.getWorkflowsManagementSetup()?.management; + const agentBuilderApi = appContextService.getAgentBuilderSetup()?.management; + const spaceId = fleetContext.spaceId; + const assets = await getBulkAssets( savedObjectsClient, savedObjectsTypeRegistry, - assetIds as AssetSOObject[] + assetIds as AssetSOObject[], + { + externalAssetEnricher: async (externalAssets) => { + const metadata: Record = {}; + + await Promise.all( + externalAssets.map(async ({ id, type }) => { + try { + if (type === KibanaSavedObjectType.workflow && workflowsApi) { + const workflow = await workflowsApi.getWorkflow(id, spaceId); + if (workflow) { + metadata[id] = { + title: workflow.name, + description: workflow.description, + }; + } + } else if (type === KibanaSavedObjectType.agent && agentBuilderApi) { + const agent = (await agentBuilderApi.getAgent(id, request)) as { + name?: string; + description?: string; + } | null; + if (agent) { + metadata[id] = { + title: agent.name, + description: agent.description, + }; + } + } + } catch { + // Fall back to asset id as the display title. + } + }) + ); + + return metadata; + }, + } ); const body: GetBulkAssetsResponse = { diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.test.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.test.ts index 367fa3e67e323..84094c1d4a1f5 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/index.test.ts @@ -191,6 +191,8 @@ describe('schema validation', () => { }; const assets: AssetsGroupedByServiceByType = { kibana: { + workflow: [], + agent: [], alerting_rule_template: [], slo_template: [], dashboard: [], @@ -217,6 +219,7 @@ describe('schema validation', () => { ml_model: [], knowledge_base: [], esql_view: [], + index_alias: [], }, }; packageInfo = { diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.test.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.test.ts new file mode 100644 index 0000000000000..6541bf48edfce --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.test.ts @@ -0,0 +1,50 @@ +/* + * 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. + */ + +jest.mock('../../services', () => ({ + appContextService: { + getSavedObjects: jest.fn(), + }, +})); + +jest.mock('../../services/epm/kibana/assets/install', () => ({ + installKibanaAssetsAndReferences: jest.fn(), + deleteKibanaAssetsAndReferencesForSpace: jest.fn(), +})); + +jest.mock( + '../../services/epm/packages/install_state_machine/steps/step_create_alerting_assets', + () => ({ + stepCreateAlertingAssets: jest.fn(), + }) +); + +import { stepCreateAlertingAssets } from '../../services/epm/packages/install_state_machine/steps/step_create_alerting_assets'; +import { installKibanaAssetsAndReferences } from '../../services/epm/kibana/assets/install'; + +describe('FLEET-002: create_alerting_rules manifest flag', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('stepCreateAlertingAssets is mockable and starts uncalled', () => { + expect(stepCreateAlertingAssets).not.toHaveBeenCalled(); + expect(typeof installKibanaAssetsAndReferences).toBe('function'); + }); + + it('the flag exists on the package spec type', () => { + // Type-level test: create_alerting_rules is an optional boolean on the spec + const spec = { create_alerting_rules: true }; + expect(spec.create_alerting_rules).toBe(true); + }); + + it('the flag defaults to falsy when absent', () => { + const spec: { create_alerting_rules?: boolean } = {}; + expect(spec.create_alerting_rules).toBeUndefined(); + expect(!!spec.create_alerting_rules).toBe(false); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.ts b/x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.ts index 5622bf3812f3f..05106e93128c6 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/epm/install_assets_handler.ts @@ -104,14 +104,16 @@ export const installPackageKibanaAssetsHandler: FleetRequestHandler< packageInstallContext, }); - await stepCreateAlertingAssets({ - logger, - savedObjectsClient: spaceScopedClient, - packageInstallContext, - spaceId: spaceToInstallId, - request, - installAsAdditionalSpace, - }); + if (packageInstallContext.packageInfo?.create_alerting_rules) { + await stepCreateAlertingAssets({ + logger, + savedObjectsClient: spaceScopedClient, + packageInstallContext, + spaceId: spaceToInstallId, + request, + installAsAdditionalSpace, + }); + } } return response.ok({ body: { success: true } }); diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/package_policy/handlers.ts b/x-pack/platform/plugins/shared/fleet/server/routes/package_policy/handlers.ts index 172da6e7159a2..4229c7a5387b0 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/package_policy/handlers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/package_policy/handlers.ts @@ -565,7 +565,8 @@ export const updatePackagePolicyHandler: FleetRequestHandler< request.params.packagePolicyId, newData, { user, force }, - context + context, + request ); return response.ok({ body: { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.test.ts index 0a3fcf9a7793f..32b972d055577 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.test.ts @@ -19,8 +19,6 @@ import { createAppContextStartContractMock } from '../../mocks'; import type { Agent } from '../../types'; import { appContextService } from '../app_context'; import type { AgentStatus } from '../../../common/types'; -import { agentPolicyService } from '../agent_policy'; -import { buildPolicyBaseIdsWithFallbackEsFilter } from '../../../common/services/version_specific_policies_utils'; import { auditLoggingService } from '../audit_logging'; @@ -39,14 +37,6 @@ import { } from './crud'; jest.mock('../audit_logging'); -jest.mock('../agent_policy', () => ({ - agentPolicyService: { - list: jest.fn().mockResolvedValue({ items: [] }), - get: jest.fn().mockResolvedValue(null), - getByIds: jest.fn().mockResolvedValue([]), - getInactivityTimeouts: jest.fn().mockResolvedValue([]), - }, -})); jest.mock('../../../common/services/is_agent_upgradeable', () => ({ isAgentUpgradeAvailable: jest.fn().mockImplementation((agent: Agent) => agent.id.includes('up')), })); @@ -720,58 +710,6 @@ describe('Agents CRUD test', () => { }); }); }); - - describe('showAgentless filter', () => { - const agentlessPolicyIds = ['policy-agentless-1', 'policy-agentless-2']; - - beforeEach(() => { - searchMock.mockResolvedValue(getEsResponse([], 0, 'online')); - }); - - afterEach(() => { - (agentPolicyService.list as jest.Mock).mockReset(); - (agentPolicyService.list as jest.Mock).mockResolvedValue({ items: [] }); - }); - - it('excludes agents on versioned agentless policies using policy_base_id fallback', async () => { - (agentPolicyService.list as jest.Mock).mockResolvedValueOnce({ - items: agentlessPolicyIds.map((id) => ({ id })), - }); - - await getAgentsByKuery(esClientMock, soClientMock, { - showAgentless: false, - showInactive: false, - }); - - // The exclusion is injected as a must_not using `terms` queries (constant clause count) - // rather than KQL which would emit N individual `term` clauses per field. - const query = searchMock.mock.calls.at(-1)[0].query; - expect(query.bool.must_not).toEqual([ - buildPolicyBaseIdsWithFallbackEsFilter(agentlessPolicyIds), - ]); - // Verify the other filters (active + enrolled) are still applied via the filter branch. - const filterStr = JSON.stringify(query.bool.filter); - expect(filterStr).toContain('unenrolled'); - // Explicit field check so the intent of the must_not is obvious. - const queryStr = JSON.stringify(query); - expect(queryStr).toContain('policy_base_id'); - expect(queryStr).toContain('policy-agentless-1'); - expect(queryStr).toContain('policy-agentless-2'); - }); - - it('adds no exclusion clause when there are no agentless policies', async () => { - // agentPolicyService.list returns { items: [] } by default (see mock above). - - await getAgentsByKuery(esClientMock, soClientMock, { - showAgentless: false, - showInactive: false, - }); - - const queryStr = JSON.stringify(searchMock.mock.calls.at(-1)[0].query); - expect(queryStr).not.toContain('policy_base_id'); - expect(queryStr).not.toContain('policy_id'); - }); - }); }); describe('update', () => { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.ts b/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.ts index 956768faea538..c020d3bb4f412 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/agents/crud.ts @@ -10,7 +10,7 @@ import type { estypes } from '@elastic/elasticsearch'; import type { SortResults } from '@elastic/elasticsearch/lib/api/types'; import type { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; import type { KueryNode } from '@kbn/es-query'; -import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query'; +import { fromKueryExpression, toElasticsearchQuery, escapeQuotes } from '@kbn/es-query'; import { DEFAULT_SPACE_ID } from '@kbn/core-spaces-common'; import type { AggregationsAggregationContainer } from '@elastic/elasticsearch/lib/api/types'; @@ -266,25 +266,18 @@ export async function getAgentsByKuery( // Hides agents enrolled in agentless policies by excluding the first 1000 agentless policy IDs // from the search. This limitation is to avoid hitting the `max_clause_count` limit. - // The exclusion is built as an ES DSL `must_not` (using `terms` queries) rather than a KQL - // string so that the clause count stays constant (~4 clauses) regardless of how many policy - // IDs are in the list. KQL compiles field:(v1 or v2 or …) to N individual `term` clauses, - // which would double to ~2001 at the 1000-policy cap. - let agentlessExcludeFilter: ReturnType | null = - null; + // In the future, we should hopefully be able to filter agentless agents using metadata: + // https://github.com/elastic/elastic-agent/issues/7946 if (showAgentless === false) { const agentlessPolicies = await agentPolicyService.list(soClient, { perPage: 1000, kuery: `${LEGACY_AGENT_POLICY_SAVED_OBJECT_TYPE}.supports_agentless:true`, }); if (agentlessPolicies.items.length > 0) { - // Use the policy_base_id-with-fallback ES DSL filter so agents whose policy_id carries a - // version suffix (e.g. "#9.6") are still excluded. The fallback branch covers - // agents enrolled by an older fleet-server that did not yet write policy_base_id. - // Using buildPolicyBaseIdsWithFallbackEsFilter (terms queries) rather than the KQL - // equivalent keeps the clause count at ~4 instead of ~2N. - agentlessExcludeFilter = buildPolicyBaseIdsWithFallbackEsFilter( - agentlessPolicies.items.map((policy) => policy.id) + filters.push( + `NOT policy_id: (${agentlessPolicies.items + .map((policy) => `"${escapeQuotes(policy.id)}"`) + .join(' or ')})` ); } } @@ -364,16 +357,7 @@ export async function getAgentsByKuery( runtime_mappings: runtimeFields, fields: Object.keys(runtimeFields), sort, - query: (() => { - const baseQuery = kueryNode ? toElasticsearchQuery(kueryNode) : undefined; - if (!agentlessExcludeFilter) return baseQuery; - return { - bool: { - ...(baseQuery ? { filter: [baseQuery] } : {}), - must_not: [agentlessExcludeFilter], - }, - }; - })(), + query: kueryNode ? toElasticsearchQuery(kueryNode) : undefined, ...(currentPitId ? { pit: { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/app_context.ts b/x-pack/platform/plugins/shared/fleet/server/services/app_context.ts index af65c61a88e16..ee76f84115394 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/app_context.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/app_context.ts @@ -29,6 +29,8 @@ import type { Logger } from '@kbn/logging'; import type { LockManagerService } from '@kbn/lock-manager'; import type { AlertingServerStart } from '@kbn/alerting-plugin/server'; import type { ReportingStart } from '@kbn/reporting-plugin/server'; +import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; +import type { AgentBuilderPluginSetup } from '@kbn/agent-builder-server'; import { ALL_SPACES_ID } from '@kbn/spaces-plugin/common/constants'; import type { FleetConfigType } from '../../common/types'; @@ -87,6 +89,8 @@ class AppContextService { private fetchUsage?: (signal: AbortSignal) => Promise; private lockManagerService: LockManagerService | undefined; private alertingStart: AlertingServerStart | undefined; + private workflowsManagementSetup: WorkflowsServerPluginSetup | undefined; + private agentBuilderSetup: AgentBuilderPluginSetup | undefined; private includedHiddenTypes: string[] = [ UNINSTALL_TOKENS_SAVED_OBJECT_TYPE, KibanaSavedObjectType.alertingRuleTemplate, @@ -350,6 +354,22 @@ class AppContextService { return this.alertingStart; } + public setWorkflowsManagementSetup(setup?: WorkflowsServerPluginSetup) { + this.workflowsManagementSetup = setup; + } + + public getWorkflowsManagementSetup() { + return this.workflowsManagementSetup; + } + + public setAgentBuilderSetup(setup?: AgentBuilderPluginSetup) { + this.agentBuilderSetup = setup; + } + + public getAgentBuilderSetup() { + return this.agentBuilderSetup; + } + public getReportingStart() { return this.reportingStart; } diff --git a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.test.ts index 5869f3477a767..61b88bd2d3782 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.test.ts @@ -199,130 +199,4 @@ describe('createAndIntegrateCloudConnector — policy group enforcement on reuse expect(result.wasCreated).toBe(false); expect(soClient.find).not.toHaveBeenCalled(); }); - - describe('credential injection on connector reuse', () => { - const buildPolicyWithStreamVars = (streamVars: Record): NewPackagePolicy => - ({ - name: 'test-policy', - namespace: 'default', - cloud_connector_id: 'connector-1', - inputs: [ - { - type: 'aws/metrics', - enabled: true, - streams: [ - { - enabled: true, - data_stream: { type: 'metrics', dataset: 'aws.s3' }, - vars: streamVars, - }, - ], - }, - ], - } as any); - - it('injects role_arn from connector vars into stream vars when not already set', async () => { - const soClient = savedObjectsClientMock.create(); - mockConnectorUsage(soClient, []); - getByIdSpy.mockResolvedValue({ - id: 'connector-1', - name: 'AWS Production', - cloudProvider: 'aws', - vars: { role_arn: { type: 'text', value: 'arn:aws:iam::123:role/elastic' } }, - } as any); - - const result = await createAndIntegrateCloudConnector({ - packagePolicy: buildPolicyWithStreamVars({ role_arn: { type: 'text', value: undefined } }), - agentPolicy: buildAgentPolicy(), - policyName: 'test-policy', - packageInfo: buildPackageInfo('aws_securityhub'), - soClient, - esClient, - logger, - }); - - expect(result.packagePolicy.inputs[0].streams[0].vars?.role_arn).toEqual({ - type: 'text', - value: 'arn:aws:iam::123:role/elastic', - }); - }); - - it('does not overwrite role_arn already set in stream vars', async () => { - const soClient = savedObjectsClientMock.create(); - mockConnectorUsage(soClient, []); - getByIdSpy.mockResolvedValue({ - id: 'connector-1', - name: 'AWS Production', - cloudProvider: 'aws', - vars: { role_arn: { type: 'text', value: 'arn:aws:iam::123:role/elastic' } }, - } as any); - - const existingRoleArn = { type: 'text', value: 'arn:aws:iam::456:role/existing' }; - const result = await createAndIntegrateCloudConnector({ - packagePolicy: buildPolicyWithStreamVars({ role_arn: existingRoleArn }), - agentPolicy: buildAgentPolicy(), - policyName: 'test-policy', - packageInfo: buildPackageInfo('aws_securityhub'), - soClient, - esClient, - logger, - }); - - expect(result.packagePolicy.inputs[0].streams[0].vars?.role_arn).toEqual(existingRoleArn); - }); - - it('leaves stream vars unchanged when connector has no role_arn', async () => { - const soClient = savedObjectsClientMock.create(); - mockConnectorUsage(soClient, []); - getByIdSpy.mockResolvedValue({ - id: 'connector-1', - name: 'AWS Production', - cloudProvider: 'aws', - vars: {}, - } as any); - - const result = await createAndIntegrateCloudConnector({ - packagePolicy: buildPolicyWithStreamVars({ role_arn: { type: 'text', value: undefined } }), - agentPolicy: buildAgentPolicy(), - policyName: 'test-policy', - packageInfo: buildPackageInfo('aws_securityhub'), - soClient, - esClient, - logger, - }); - - expect(result.packagePolicy.inputs[0].streams[0].vars?.role_arn?.value).toBeUndefined(); - }); - - it('sets supports_cloud_connectors to true alongside the backfilled role_arn', async () => { - const soClient = savedObjectsClientMock.create(); - mockConnectorUsage(soClient, []); - getByIdSpy.mockResolvedValue({ - id: 'connector-1', - name: 'AWS Production', - cloudProvider: 'aws', - vars: { role_arn: { type: 'text', value: 'arn:aws:iam::123:role/elastic' } }, - } as any); - - const result = await createAndIntegrateCloudConnector({ - packagePolicy: buildPolicyWithStreamVars({ - role_arn: { type: 'text', value: undefined }, - supports_cloud_connectors: { type: 'bool', value: false }, - }), - agentPolicy: buildAgentPolicy(), - policyName: 'test-policy', - packageInfo: buildPackageInfo('aws_securityhub'), - soClient, - esClient, - logger, - }); - - expect(result.packagePolicy.inputs[0].streams[0].vars?.role_arn?.value).toBe( - 'arn:aws:iam::123:role/elastic' - ); - expect(result.packagePolicy.inputs[0].streams[0].vars?.supports_cloud_connectors?.value).toBe( - true - ); - }); - }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts index b7f3b285980dd..43dcb8547a908 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/agentless_policy_integration.ts @@ -20,7 +20,6 @@ import { updatePackagePolicyWithCloudConnectorSecrets, getCloudConnectorNameFromPackagePolicy, extractAccountType, - injectConnectorVarsIntoPolicy, } from './integration_helpers'; /** @@ -149,15 +148,6 @@ export async function createAndIntegrateCloudConnector(params: { logger.info(`Successfully reused cloud connector: ${existingCloudConnectorId}`); - // Backfill credential vars from the connector into the policy for onboarding flows - // that reuse a connector by ID without supplying credentials in the request. - updatedPackagePolicy = injectConnectorVarsIntoPolicy( - updatedPackagePolicy, - existingConnector.vars, - cloudProvider, - packageInfo - ); - return { packagePolicy: updatedPackagePolicy, cloudConnectorId: existingCloudConnectorId, diff --git a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts index 787558feaded3..632f86f6be382 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.test.ts @@ -6,7 +6,6 @@ */ import type { NewPackagePolicy, PackageInfo } from '../../types'; -import type { PackagePolicyConfigRecord } from '../../../common/types'; import { SINGLE_ACCOUNT, @@ -14,11 +13,7 @@ import { CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE, } from '../../../common/constants/cloud_connector'; -import { - extractAccountType, - validateAccountType, - injectConnectorVarsIntoPolicy, -} from './integration_helpers'; +import { extractAccountType, validateAccountType } from './integration_helpers'; // Mock PackageInfo for input-level storage mode (no package-level vars defined) const mockPackageInfo = { @@ -242,169 +237,4 @@ describe('cloud connector integration helpers', () => { }); }); }); - - describe('injectConnectorVarsIntoPolicy', () => { - // input-mode policy: credentials live in stream vars - const makeInputPolicy = (streamVars: PackagePolicyConfigRecord = {}): NewPackagePolicy => ({ - name: 'test-policy', - namespace: 'default', - policy_ids: [], - enabled: true, - inputs: [ - { - type: 'aws/metrics', - enabled: true, - streams: [ - { - enabled: true, - data_stream: { type: 'metrics', dataset: 'aws.s3' }, - vars: streamVars, - }, - ], - }, - ], - }); - - const awsConnectorVars = { - role_arn: { type: 'text' as const, value: 'arn:aws:iam::123:role/elastic' }, - }; - - it('backfills role_arn into stream vars when entry exists with no value', () => { - // varsReducer always creates an entry object; empty var has value: undefined - const policy = makeInputPolicy({ role_arn: { type: 'text' as const, value: undefined } }); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result.inputs[0].streams[0].vars?.role_arn).toEqual(awsConnectorVars.role_arn); - }); - - it('does not overwrite role_arn already present in stream vars', () => { - const existing = { type: 'text' as const, value: 'arn:aws:iam::456:role/existing' }; - const policy = makeInputPolicy({ role_arn: existing }); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result.inputs[0].streams[0].vars?.role_arn).toEqual(existing); - }); - - it('returns policy unchanged when role_arn key is absent from stream vars', () => { - const policy = makeInputPolicy({}); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result.inputs[0].streams[0].vars).toEqual({}); - }); - - it('returns policy unchanged when no enabled input exists', () => { - const policy: NewPackagePolicy = { ...makeInputPolicy(), inputs: [] }; - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result).toEqual(policy); - }); - - // TODO: extend later for other providers - it('is a no-op for non-AWS providers', () => { - const azureVars = { - tenant_id: { type: 'password' as const, value: { id: 'secret-1', isSecretRef: true } }, - } as any; - const policy = makeInputPolicy({ tenant_id: { type: 'text' as const, value: undefined } }); - const result = injectConnectorVarsIntoPolicy(policy, azureVars, 'azure', mockPackageInfo); - expect(result.inputs[0].streams[0].vars?.tenant_id?.value).toBeUndefined(); - }); - - describe('identity-federation flag', () => { - it('sets supports_cloud_connectors to true when the var exists in stream vars', () => { - const policy = makeInputPolicy({ - role_arn: { type: 'text' as const, value: undefined }, - supports_cloud_connectors: { type: 'bool' as const, value: false }, - }); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result.inputs[0].streams[0].vars?.supports_cloud_connectors?.value).toBe(true); - }); - - it('sets supports_identity_federation to true when the renamed var exists', () => { - const policy = makeInputPolicy({ - role_arn: { type: 'text' as const, value: undefined }, - supports_identity_federation: { type: 'bool' as const, value: false }, - }); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result.inputs[0].streams[0].vars?.supports_identity_federation?.value).toBe(true); - }); - - it('sets both flag names when both are present', () => { - const policy = makeInputPolicy({ - role_arn: { type: 'text' as const, value: undefined }, - supports_cloud_connectors: { type: 'bool' as const, value: false }, - supports_identity_federation: { type: 'bool' as const, value: false }, - }); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result.inputs[0].streams[0].vars?.supports_cloud_connectors?.value).toBe(true); - expect(result.inputs[0].streams[0].vars?.supports_identity_federation?.value).toBe(true); - }); - - it('does not add a flag var that the package did not declare', () => { - const policy = makeInputPolicy({ role_arn: { type: 'text' as const, value: undefined } }); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect('supports_cloud_connectors' in (result.inputs[0].streams[0].vars ?? {})).toBe(false); - expect('supports_identity_federation' in (result.inputs[0].streams[0].vars ?? {})).toBe( - false - ); - }); - - it('sets the flag for non-AWS providers too', () => { - const azureVars = { tenant_id: { type: 'text' as const, value: undefined } } as any; - const policy = makeInputPolicy({ - supports_cloud_connectors: { type: 'bool' as const, value: false }, - }); - const result = injectConnectorVarsIntoPolicy(policy, azureVars, 'azure', mockPackageInfo); - expect(result.inputs[0].streams[0].vars?.supports_cloud_connectors?.value).toBe(true); - }); - - it('overwrites an existing false value (not gated by empty-value guard)', () => { - const policy = makeInputPolicy({ - supports_cloud_connectors: { type: 'bool' as const, value: false }, - }); - const result = injectConnectorVarsIntoPolicy( - policy, - awsConnectorVars, - 'aws', - mockPackageInfo - ); - expect(result.inputs[0].streams[0].vars?.supports_cloud_connectors?.value).toBe(true); - }); - }); - }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts index d78dce084eae8..e03c2977249f9 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/cloud_connectors/integration_helpers.ts @@ -24,8 +24,6 @@ import { SINGLE_ACCOUNT, ORGANIZATION_ACCOUNT, CLOUD_CONNECTOR_DEFAULT_ACCOUNT_TYPE, - SUPPORTS_CLOUD_CONNECTORS_VAR_NAME, - SUPPORTS_IDENTITY_FEDERATION_VAR_NAME, } from '../../../common/constants/cloud_connector'; import type { @@ -201,63 +199,6 @@ export function updatePackagePolicyWithCloudConnectorSecrets( return applyVarsAtTarget(packagePolicy, updatedVars, target); } -/** - * Backfills credential vars and the identity-federation flag from an existing cloud connector into - * a package policy's vars, but only for credential vars not already set by the caller. Used when - * an onboarding flow reuses a connector by ID without supplying credentials in the request — the - * connector holds them. - * - * Currently only AWS role_arn credentials are backfilled (the only plain-text credential in the - * reuse path). The identity-federation flag (supports_cloud_connectors / - * supports_identity_federation) is set for all providers. - * - * TODO: extend credential backfill to Azure and GCP once those onboarding flows adopt connector - * reuse. - */ -export function injectConnectorVarsIntoPolicy( - packagePolicy: NewPackagePolicy, - connectorVars: CloudConnectorVars, - cloudProvider: CloudProvider, - packageInfo: PackageInfo -): NewPackagePolicy { - const mode = getCredentialStorageScope(packageInfo); - const { target, vars: currentVars } = resolveVarTarget(packagePolicy, mode); - - if (!currentVars) { - return packagePolicy; - } - - const updatedVars = { ...currentVars }; - const schema = getCredentialSchema(cloudProvider); - - if (cloudProvider === 'aws') { - const awsVars = connectorVars as AwsCloudConnectorVars; - if (awsVars.role_arn?.value) { - const roleArnKeys = getAllVarKeys(schema.fields.roleArn); - for (const key of roleArnKeys) { - if (key in updatedVars && !updatedVars[key]?.value) { - updatedVars[key] = awsVars.role_arn; - break; // inject into whichever alias the policy uses, not both - } - } - } - } - - // Set the identity-federation flag so the beats AWS input uses the federated auth path - // rather than the container's default execution role. Packages use one of two var names - // (renamed in elastic/integrations#19828); set both if present. - for (const flagName of [ - SUPPORTS_CLOUD_CONNECTORS_VAR_NAME, - SUPPORTS_IDENTITY_FEDERATION_VAR_NAME, - ]) { - if (flagName in updatedVars) { - updatedVars[flagName] = { ...updatedVars[flagName], value: true }; - } - } - - return applyVarsAtTarget(packagePolicy, updatedVars, target); -} - /** * Extracts cloud connector name from package policy variables * Used to name cloud connectors based on user input or generate a default name diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/ab002_agent_yaml.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/ab002_agent_yaml.test.ts new file mode 100644 index 0000000000000..25cd21313d3cf --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/ab002_agent_yaml.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { readFileSync, readdirSync } from 'fs'; +import { join } from 'path'; + +import { parseFleetAgentYaml } from './packages/install_state_machine/steps/step_install_agent_assets'; + +const AGENT_DIR = join( + __dirname, + '..', + '..', + '..', + 'test', + 'fixtures', + 'sdlc_intel', + 'kibana', + 'agent' +); + +// These fixtures are the package's shipped agent assets. Validating them with a +// local YAML parse and a hand-written schema lets them drift away from what Fleet +// actually accepts: an earlier revision used a flat `tools:` list plus a top-level +// `instructions:`, which parseFleetAgentYaml rejects outright, so an upload install +// of this package failed at the agent-asset step while this suite stayed green. +// Validate through the real parser instead. +describe('AB-002: Platform ES|QL tools for package agents', () => { + const files = readdirSync(AGENT_DIR).filter((f) => f.endsWith('.yaml')); + + it('has at least 2 SDLC agent YAML files', () => { + expect(files.length).toBeGreaterThanOrEqual(2); + }); + + for (const file of files) { + describe(`agent: ${file}`, () => { + const content = readFileSync(join(AGENT_DIR, file), 'utf-8'); + const agentId = `fleet-default-sdlc-intel-${file.replace(/\.yaml$/, '')}`; + + it('is accepted by the Fleet agent-asset parser', () => { + expect(() => parseFleetAgentYaml(content, agentId, { pkgName: 'sdlc_intel' })).not.toThrow(); + }); + + it('carries a name, description and instructions', () => { + const agent = parseFleetAgentYaml(content, agentId, { pkgName: 'sdlc_intel' }); + expect(agent.name).toBeTruthy(); + expect(agent.description).toBeTruthy(); + expect(agent.configuration.instructions).toBeTruthy(); + }); + + it('uses only platform tools', () => { + const platformTools = [ + 'platform.core.execute_esql', + 'platform.core.generate_esql', + 'platform.core.integration_knowledge', + 'platform.core.search', + 'platform.core.list_indices', + 'platform.core.get_index_mapping', + ]; + const agent = parseFleetAgentYaml(content, agentId, { pkgName: 'sdlc_intel' }); + const toolIds = (agent.configuration.tools ?? []).flatMap( + (entry: { tool_ids?: string[] }) => entry.tool_ids ?? [] + ); + + expect(toolIds.length).toBeGreaterThan(0); + for (const toolId of toolIds) { + expect(platformTools).toContain(toolId); + } + }); + + it('does not use product builtin tools', () => { + const bannedTools = [/^endpoint\./, /^osquery\./, /^cases\./]; + const agent = parseFleetAgentYaml(content, agentId, { pkgName: 'sdlc_intel' }); + const toolIds = (agent.configuration.tools ?? []).flatMap( + (entry: { tool_ids?: string[] }) => entry.tool_ids ?? [] + ); + + for (const toolId of toolIds) { + for (const banned of bannedTools) { + expect(toolId).not.toMatch(banned); + } + } + }); + }); + } +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.test.ts new file mode 100644 index 0000000000000..ec17e83fee2a7 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.test.ts @@ -0,0 +1,138 @@ +/* + * 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 { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; + +import { ElasticsearchAssetType } from '../../../../types'; +import { createArchiveIteratorFromMap } from '../../archive/archive_iterator'; + +import { + getConcreteIndexNamesFromTemplates, + installConcreteIndicesFromTemplates, + isConcreteIndexPattern, +} from './install'; + +jest.mock('../../packages/es_assets_reference', () => ({ + updateEsAssetReferences: jest.fn( + async (_so: unknown, _pkg: string, current: unknown[], opts: { assetsToAdd?: unknown[] }) => [ + ...(current as unknown[]), + ...((opts.assetsToAdd as unknown[]) ?? []), + ] + ), +})); + +const { updateEsAssetReferences } = jest.requireMock('../../packages/es_assets_reference'); + +describe('isConcreteIndexPattern', () => { + it('accepts fixed index names', () => { + expect(isConcreteIndexPattern('github-intel-teams')).toBe(true); + }); + + it('rejects wildcards', () => { + expect(isConcreteIndexPattern('logs-*-default')).toBe(false); + expect(isConcreteIndexPattern('metrics-system.?')).toBe(false); + }); +}); + +describe('installConcreteIndicesFromTemplates', () => { + const logger = { + debug: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + error: jest.fn(), + } as any; + + const createPackageInstallContext = (templates: Record) => { + const assetsMap = new Map(); + const paths: string[] = []; + for (const [name, body] of Object.entries(templates)) { + const path = `sdlc_intel-0.1.0/elasticsearch/index_template/${name}.json`; + paths.push(path); + assetsMap.set(path, Buffer.from(JSON.stringify(body))); + } + + return { + paths, + packageInfo: { name: 'sdlc_intel' }, + archiveIterator: createArchiveIteratorFromMap(assetsMap), + } as any; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('collects only concrete index patterns', async () => { + const ctx = createPackageInstallContext({ + teams: { index_patterns: ['github-intel-teams'] }, + wildcard: { index_patterns: ['logs-*-default'] }, + people: { index_patterns: ['github-intel-people'] }, + }); + + await expect(getConcreteIndexNamesFromTemplates(ctx)).resolves.toEqual([ + 'github-intel-people', + 'github-intel-teams', + ]); + }); + + it('creates missing indices and tracks EsAssetReference index refs', async () => { + const esClient = { + indices: { + create: jest.fn().mockResolvedValue({ acknowledged: true }), + }, + } as unknown as ElasticsearchClient; + const savedObjectsClient = {} as SavedObjectsClientContract; + const ctx = createPackageInstallContext({ + teams: { index_patterns: ['github-intel-teams'] }, + }); + + const refs = await installConcreteIndicesFromTemplates( + ctx, + esClient, + savedObjectsClient, + logger, + [] + ); + + expect(esClient.indices.create).toHaveBeenCalledWith( + { index: 'github-intel-teams' }, + { ignore: [400] } + ); + expect(updateEsAssetReferences).toHaveBeenCalledWith( + savedObjectsClient, + 'sdlc_intel', + [], + expect.objectContaining({ + assetsToAdd: [{ type: ElasticsearchAssetType.index, id: 'github-intel-teams' }], + }) + ); + expect(refs).toEqual([{ type: ElasticsearchAssetType.index, id: 'github-intel-teams' }]); + }); + + it('does not fail install when create throws', async () => { + const esClient = { + indices: { + create: jest.fn().mockRejectedValue(new Error('cluster_block_exception')), + }, + } as unknown as ElasticsearchClient; + const ctx = createPackageInstallContext({ + teams: { index_patterns: ['github-intel-teams'] }, + }); + + const refs = await installConcreteIndicesFromTemplates( + ctx, + esClient, + {} as SavedObjectsClientContract, + logger, + [] + ); + + expect(refs).toEqual([]); + expect(logger.warn).toHaveBeenCalled(); + expect(updateEsAssetReferences).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.ts new file mode 100644 index 0000000000000..4fcc462b674bb --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index/install.ts @@ -0,0 +1,134 @@ +/* + * 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 { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server'; +import pMap from 'p-map'; + +import type { EsAssetReference } from '../../../../types'; +import { ElasticsearchAssetType } from '../../../../types'; +import { getAssetFromAssetsMap, getPathParts } from '../../archive'; +import { updateEsAssetReferences } from '../../packages/es_assets_reference'; +import { retryTransientEsErrors } from '../retry'; +import type { AssetsMap, PackageInstallContext } from '../../../../../common/types'; +import { MAX_CONCURRENT_COMPONENT_TEMPLATES } from '../../../../constants'; + +/** + * Returns true when an index pattern names a single concrete index (no wildcards). + * Packages that ship fixed-name indices (e.g. github-intel-teams) use concrete patterns; + * data-stream style patterns like logs-*-default are skipped. + */ +export const isConcreteIndexPattern = (pattern: string): boolean => { + return pattern.length > 0 && !pattern.includes('*') && !pattern.includes('?'); +}; + +const isIndexTemplatePath = (path: string): boolean => { + const pathParts = getPathParts(path); + return pathParts.type === ElasticsearchAssetType.indexTemplate; +}; + +/** + * Collect concrete index names from package-shipped index templates. + */ +export const getConcreteIndexNamesFromTemplates = async ( + packageInstallContext: PackageInstallContext +): Promise => { + const templatePaths = packageInstallContext.paths.filter((path) => isIndexTemplatePath(path)); + if (templatePaths.length === 0) { + return []; + } + + const templateAssetsMap: AssetsMap = new Map(); + await packageInstallContext.archiveIterator.traverseEntries( + async (entry) => { + if (!entry.buffer) { + return; + } + templateAssetsMap.set(entry.path, entry.buffer); + }, + (path) => templatePaths.includes(path) + ); + + const indexNames = new Set(); + for (const path of templatePaths) { + const content = JSON.parse(getAssetFromAssetsMap(templateAssetsMap, path).toString('utf8')) as { + index_patterns?: string[]; + }; + for (const pattern of content.index_patterns ?? []) { + if (isConcreteIndexPattern(pattern)) { + indexNames.add(pattern); + } + } + } + + return [...indexNames].sort(); +}; + +/** + * Create empty indices for concrete index_patterns declared on package index templates. + * Idempotent: existing indices are left untouched. Indices are tracked as installed_es + * refs of type `index` so they appear in package asset lists; Fleet uninstall does not + * delete them (user data preservation). + */ +export async function installConcreteIndicesFromTemplates( + packageInstallContext: PackageInstallContext, + esClient: ElasticsearchClient, + savedObjectsClient: SavedObjectsClientContract, + logger: Logger, + esReferences: EsAssetReference[] +): Promise { + const { packageInfo } = packageInstallContext; + const indexNames = await getConcreteIndexNamesFromTemplates(packageInstallContext); + if (indexNames.length === 0) { + return esReferences; + } + + logger.debug( + `Creating ${indexNames.length} concrete indices from package templates for ${packageInfo.name}` + ); + + const createdOrExisting: string[] = []; + + await pMap( + indexNames, + async (indexName) => { + try { + await retryTransientEsErrors( + () => + esClient.indices.create( + { index: indexName }, + { + // 400: resource_already_exists_exception when the index is already present + ignore: [400], + } + ), + { logger } + ); + createdOrExisting.push(indexName); + } catch (err) { + // Do not fail package install if a single index cannot be created; workflows that + // write first can still recover, and reads should use ignore_unavailable. + logger.warn( + `Could not create index ${indexName} for package ${packageInfo.name}: ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + }, + { concurrency: MAX_CONCURRENT_COMPONENT_TEMPLATES } + ); + + if (createdOrExisting.length === 0) { + return esReferences; + } + + return updateEsAssetReferences(savedObjectsClient, packageInfo.name, esReferences, { + assetsToAdd: createdOrExisting.map((id) => ({ + type: ElasticsearchAssetType.index, + id, + })), + }); +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.test.ts new file mode 100644 index 0000000000000..7d30b98074fd5 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.test.ts @@ -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 { + loggingSystemMock, + elasticsearchServiceMock, + savedObjectsClientMock, +} from '@kbn/core/server/mocks'; + +import { ElasticsearchAssetType, type InstallablePackage } from '../../../../../common'; +import { createArchiveIteratorFromMap } from '../../archive/archive_iterator'; +import { updateEsAssetReferences } from '../../packages/es_assets_reference'; + +import { installIndexAliases } from './install'; + +jest.mock('../../packages/es_assets_reference'); + +async function createPackageInstallContext() { + const archiveIterator = createArchiveIteratorFromMap( + new Map([ + [ + 'test-package-1.0.0/elasticsearch/index_alias/alias1.json', + Buffer.from( + JSON.stringify({ + alias: 'alias1', + indices: ['index1'], + }) + ), + ], + [ + 'test-package-1.0.0/elasticsearch/index_alias/alias2.yml', + Buffer.from(`alias: alias2\nindices:\n - index2\n - index3`), + ], + ['test-package-1.0.0/elasticsearch/transform/test.yml', Buffer.from(`test: transform`)], + ['test-package-1.0.0/kibana/dashboard/test.yml', Buffer.from(`test: dashboard`)], + ]) + ); + return { + archiveIterator, + paths: await archiveIterator.getPaths(), + packageInfo: { + name: 'test-package', + version: '1.0.0', + } as unknown as InstallablePackage, + }; +} + +describe('installIndexAliases', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest + .mocked(updateEsAssetReferences) + .mockImplementation(async (_, __, currentAssets, { assetsToAdd }) => { + return [...currentAssets, ...(assetsToAdd ?? [])]; + }); + }); + + it('should install index aliases', async () => { + const logger = loggingSystemMock.createLogger(); + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + const savedObjectsClient = savedObjectsClientMock.create(); + + const esReferences = await installIndexAliases({ + packageInstallContext: await createPackageInstallContext(), + esClient, + logger, + savedObjectsClient, + esReferences: [ + { + id: 'existing_transform', + type: ElasticsearchAssetType.transform, + }, + ], + }); + + expect(esClient.indices.putAlias).toHaveBeenCalledTimes(3); + expect(esClient.indices.putAlias).toHaveBeenCalledWith( + expect.objectContaining({ + index: 'index1', + name: 'alias1', + }) + ); + expect(esClient.indices.putAlias).toHaveBeenCalledWith( + expect.objectContaining({ + index: 'index2', + name: 'alias2', + }) + ); + expect(esClient.indices.putAlias).toHaveBeenCalledWith( + expect.objectContaining({ + index: 'index3', + name: 'alias2', + }) + ); + + expect(esReferences).toEqual([ + expect.objectContaining({ id: 'existing_transform', type: 'transform' }), + expect.objectContaining({ id: 'alias1', type: 'index_alias' }), + expect.objectContaining({ id: 'alias2', type: 'index_alias' }), + ]); + + expect(updateEsAssetReferences).toHaveBeenCalledWith( + expect.anything(), + 'test-package', + expect.anything(), + { + assetsToAdd: [ + { id: 'alias1', type: ElasticsearchAssetType.indexAlias }, + { id: 'alias2', type: ElasticsearchAssetType.indexAlias }, + ], + } + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.ts new file mode 100644 index 0000000000000..374f8e6bfbbcd --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/install.ts @@ -0,0 +1,103 @@ +/* + * 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 pMap from 'p-map'; +import { parse } from 'yaml'; + +import type { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/core/server'; + +import { PackageInvalidArchiveError } from '../../../../errors'; +import type { + AssetsMap, + EsAssetReference, + PackageInstallContext, +} from '../../../../../common/types'; +import { ElasticsearchAssetType } from '../../../../../common/types'; +import { getAssetFromAssetsMap, getPathParts } from '../../archive'; +import { updateEsAssetReferences } from '../../packages/es_assets_reference'; +import { retryTransientEsErrors } from '../retry'; +import { MAX_CONCURRENT_INDEX_ALIAS_OPERATIONS } from '../../../../constants'; + +export interface IndexAlias { + alias: string; + indices: string[]; +} + +export async function installIndexAliases({ + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences, +}: { + packageInstallContext: PackageInstallContext; + esClient: ElasticsearchClient; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; + esReferences: EsAssetReference[]; +}): Promise { + const { packageInfo } = packageInstallContext; + const indexAliasPaths = packageInstallContext.paths.filter((path) => isIndexAlias(path)); + if (!indexAliasPaths.length) return esReferences; + + const indexAliasAssetsMap: AssetsMap = new Map(); + await packageInstallContext.archiveIterator.traverseEntries( + async (entry) => { + if (!entry.buffer) { + return; + } + + indexAliasAssetsMap.set(entry.path, entry.buffer); + }, + (path) => indexAliasPaths.includes(path) + ); + + const indexAliases: IndexAlias[] = indexAliasPaths.map((path) => { + const assetData = getAssetFromAssetsMap(indexAliasAssetsMap, path).toString('utf-8'); + const data = path.endsWith('.yml') ? parse(assetData) : JSON.parse(assetData); + + return { alias: data.alias, indices: data.indices }; + }); + + esReferences = await updateEsAssetReferences(savedObjectsClient, packageInfo.name, esReferences, { + assetsToAdd: indexAliases.map((indexAlias) => ({ + type: ElasticsearchAssetType.indexAlias, + id: indexAlias.alias, + })), + }); + + await pMap( + indexAliases, + async (indexAlias) => { + try { + await retryTransientEsErrors( + () => + Promise.all( + indexAlias.indices.map((index) => + esClient.indices.putAlias({ index, name: indexAlias.alias }) + ) + ), + { logger } + ); + } catch (err) { + throw new PackageInvalidArchiveError( + `Couldn't install index alias ${indexAlias.alias}: ${err.message}` + ); + } + }, + { + concurrency: MAX_CONCURRENT_INDEX_ALIAS_OPERATIONS, + } + ); + + return esReferences; +} + +const isIndexAlias = (path: string) => { + const pathParts = getPathParts(path); + return pathParts.type === ElasticsearchAssetType.indexAlias; +}; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.test.ts new file mode 100644 index 0000000000000..d8b4bb8cd036d --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.test.ts @@ -0,0 +1,65 @@ +/* + * 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 { elasticsearchServiceMock } from '@kbn/core/server/mocks'; + +import { deleteIndexAliases } from './remove'; + +describe('deleteIndexAliases', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should remove alias links for the declared indices', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.indices.getAlias.mockResolvedValue({ + index1: { aliases: { alias1: {} } }, + index2: { aliases: { alias1: {} } }, + }); + + await deleteIndexAliases(esClient, ['alias1']); + + expect(esClient.indices.deleteAlias).toHaveBeenCalledTimes(1); + expect(esClient.indices.deleteAlias).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'alias1', + index: ['index1', 'index2'], + }), + expect.anything() + ); + }); + + it('should remove only this packages alias links when alias is shared', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.indices.getAlias.mockResolvedValue({ + my_index: { aliases: { shared_alias: {} } }, + other_index: { aliases: { shared_alias: {} } }, + }); + + await deleteIndexAliases(esClient, ['shared_alias'], { + indicesToDelete: { shared_alias: ['my_index'] }, + }); + + expect(esClient.indices.deleteAlias).toHaveBeenCalledTimes(1); + expect(esClient.indices.deleteAlias).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'shared_alias', + index: ['my_index'], + }), + expect.anything() + ); + }); + + it('should not attempt deletion when no alias is found', async () => { + const esClient = elasticsearchServiceMock.createElasticsearchClient(); + esClient.indices.getAlias.mockResolvedValue({}); + + await deleteIndexAliases(esClient, ['alias1']); + + expect(esClient.indices.deleteAlias).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.ts new file mode 100644 index 0000000000000..c86d0414f383a --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/elasticsearch/index_alias/remove.ts @@ -0,0 +1,38 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core/server'; + +import pMap from 'p-map'; + +import { MAX_CONCURRENT_INDEX_ALIAS_OPERATIONS } from '../../../../constants'; + +export async function deleteIndexAliases( + esClient: ElasticsearchClient, + idsToDelete: string[], + options: { indicesToDelete?: Record } = {} +) { + await pMap( + idsToDelete, + async (id) => { + const aliases = await esClient.indices.getAlias({ name: id }, { ignore: [404] }); + const indices = Object.keys(aliases); + if (!indices.length) return; + + const safeIndices = options.indicesToDelete?.[id] + ? indices.filter((index) => options.indicesToDelete![id].includes(index)) + : indices; + + if (!safeIndices.length) return; + + return esClient.indices.deleteAlias({ name: id, index: safeIndices }, { ignore: [404] }); + }, + { + concurrency: MAX_CONCURRENT_INDEX_ALIAS_OPERATIONS, + } + ); +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/kibana/assets/install.ts index 45b09edc9b260..e789ce005010e 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/kibana/assets/install.ts @@ -102,6 +102,9 @@ export const KibanaSavedObjectTypeMapping: Record ArchiveAsset[]> = { @@ -553,9 +556,18 @@ function getKibanaAssetsArchiveIterator(packageInstallContext: PackageInstallCon return; } - const asset = JSON.parse(entry.buffer.toString('utf8')); - const assetType = getPathParts(entry.path).type as KibanaAssetType; + // Non-JSON asset types (markdown-backed) are installed by their own steps and must + // never reach JSON.parse below. + if ( + assetType === KibanaAssetType.workflow || + assetType === KibanaAssetType.agent || + assetType === KibanaAssetType.skill + ) { + return; + } + + const asset = JSON.parse(entry.buffer.toString('utf8')); const soType = KibanaSavedObjectTypeMapping[assetType]; if (!validKibanaAssetTypes.has(assetType)) { return; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/package_service.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/package_service.ts index 5131cafb8395e..40c6c90a60f46 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/package_service.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/package_service.ts @@ -98,6 +98,7 @@ export interface PackageClient { keepFailedInstallation?: boolean; useStreaming?: boolean; automaticInstall?: boolean; + request?: KibanaRequest; }): Promise; installCustomIntegration(options: { @@ -249,6 +250,7 @@ class PackageClientImpl implements PackageClient { keepFailedInstallation?: boolean; useStreaming?: boolean; automaticInstall?: boolean; + request?: KibanaRequest; }): Promise { await this.#runPreflight(INSTALL_PACKAGES_AUTHZ); @@ -260,6 +262,7 @@ class PackageClientImpl implements PackageClient { keepFailedInstallation, useStreaming, automaticInstall, + request, } = options; // If pkgVersion isn't specified, find the latest package version @@ -279,6 +282,7 @@ class PackageClientImpl implements PackageClient { keepFailedInstallation, useStreaming, automaticInstall, + request, }); } diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.test.ts index aeec66f3283b4..d09e75ddfe0ea 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.test.ts @@ -9,9 +9,138 @@ import type { ISavedObjectTypeRegistry, SavedObjectsClientContract } from '@kbn/ import { ElasticsearchAssetType, KibanaSavedObjectType } from '../../../../common/types'; -import { getBulkAssets } from './get_bulk_assets'; +import { getBulkAssets, getKibanaLinkForExternalAsset } from './get_bulk_assets'; + +describe('getKibanaLinkForExternalAsset', () => { + it('returns workflow and agent builder links', () => { + expect( + getKibanaLinkForExternalAsset( + KibanaSavedObjectType.workflow, + 'fleet-default-sdlc-intel-github-catalog-repos' + ) + ).toBe('/app/workflows/fleet-default-sdlc-intel-github-catalog-repos'); + expect( + getKibanaLinkForExternalAsset( + KibanaSavedObjectType.agent, + 'fleet-default-sdlc-intel-sdlc-coverage-analysis' + ) + ).toBe('/app/agent_builder/agents/fleet-default-sdlc-intel-sdlc-coverage-analysis/overview'); + }); +}); describe('getBulkAssets', () => { + it('returns external workflow and agent assets with app links without bulkResolve', async () => { + const soClient = { + bulkResolve: jest.fn(), + } as unknown as SavedObjectsClientContract; + + const soTypeRegistry = { + getType: jest.fn(), + } as unknown as ISavedObjectTypeRegistry; + + const assets = await getBulkAssets( + soClient, + soTypeRegistry, + [ + { + id: 'fleet-default-sdlc-intel-github-catalog-repos', + type: KibanaSavedObjectType.workflow, + }, + { + id: 'fleet-default-sdlc-intel-sdlc-coverage-analysis', + type: KibanaSavedObjectType.agent, + }, + ], + { + externalAssetEnricher: async () => ({ + 'fleet-default-sdlc-intel-github-catalog-repos': { + title: 'SDLC GitHub catalog repos (GraphQL)', + description: 'Catalog workflow', + }, + 'fleet-default-sdlc-intel-sdlc-coverage-analysis': { + title: 'SDLC Coverage Analysis', + description: 'Coverage analyst', + }, + }), + } + ); + + expect(soClient.bulkResolve).not.toHaveBeenCalled(); + expect(assets).toEqual([ + { + id: 'fleet-default-sdlc-intel-github-catalog-repos', + type: KibanaSavedObjectType.workflow, + attributes: { + title: 'SDLC GitHub catalog repos (GraphQL)', + description: 'Catalog workflow', + }, + appLink: '/app/workflows/fleet-default-sdlc-intel-github-catalog-repos', + }, + { + id: 'fleet-default-sdlc-intel-sdlc-coverage-analysis', + type: KibanaSavedObjectType.agent, + attributes: { + title: 'SDLC Coverage Analysis', + description: 'Coverage analyst', + }, + appLink: + '/app/agent_builder/agents/fleet-default-sdlc-intel-sdlc-coverage-analysis/overview', + }, + ]); + }); + + it('returns external assets alongside resolved saved-object assets', async () => { + const soClient = { + bulkResolve: jest.fn().mockResolvedValue({ + resolved_objects: [ + { + saved_object: { + id: 'fleet-default-sdlc-intel-overview', + type: KibanaSavedObjectType.dashboard, + attributes: { title: 'SDLC Overview' }, + }, + }, + ], + }), + } as unknown as SavedObjectsClientContract; + const soTypeRegistry = { + getType: jest.fn(), + } as unknown as ISavedObjectTypeRegistry; + + const assets = await getBulkAssets( + soClient, + soTypeRegistry, + [ + { + id: 'fleet-default-sdlc-intel-github-catalog-repos', + type: KibanaSavedObjectType.workflow, + }, + { id: 'fleet-default-sdlc-intel-overview', type: KibanaSavedObjectType.dashboard }, + ], + { + externalAssetEnricher: async () => ({ + 'fleet-default-sdlc-intel-github-catalog-repos': { + title: 'SDLC GitHub catalog repos (GraphQL)', + }, + }), + } + ); + + // Only the saved-object asset may be looked up; the workflow is external. + expect(soClient.bulkResolve).toHaveBeenCalledWith([ + { id: 'fleet-default-sdlc-intel-overview', type: KibanaSavedObjectType.dashboard }, + ]); + + // Both halves must survive the merge, external ones first. + expect(assets.map(({ id }) => id)).toEqual([ + 'fleet-default-sdlc-intel-github-catalog-repos', + 'fleet-default-sdlc-intel-overview', + ]); + expect(assets[0].appLink).toBe( + '/app/workflows/fleet-default-sdlc-intel-github-catalog-repos' + ); + }); + it('uses attributes.name as the display title when attributes.title is unavailable', async () => { const soClient = { bulkResolve: jest.fn().mockResolvedValue({ diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.ts index b93f985e7a27e..53512b57f1517 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/get_bulk_assets.ts @@ -35,6 +35,36 @@ type AlertingRuleTemplateAttributes = DisplayableSOAssetAttributes & { }; }; +export type ExternalAssetMetadata = { + title?: string; + description?: string; +}; + +export type GetBulkAssetsOptions = { + externalAssetEnricher?: ( + assets: AssetSOObject[] + ) => Promise>; +}; + +// Workflow and Agent Builder assets are not Fleet-managed saved objects, so +// bulkResolve reports them as unsupported types. They are resolved separately +// and linked into their own apps. +const EXTERNAL_KIBANA_ASSET_TYPES = new Set([ + KibanaSavedObjectType.workflow, + KibanaSavedObjectType.agent, +]); + +export const getKibanaLinkForExternalAsset = (type: KibanaSavedObjectType, id: string): string => { + switch (type) { + case KibanaSavedObjectType.workflow: + return `/app/workflows/${encodeURIComponent(id)}`; + case KibanaSavedObjectType.agent: + return `/app/agent_builder/agents/${encodeURIComponent(id)}/overview`; + default: + return ''; + } +}; + const getKibanaLinkForESAsset = (type: ElasticsearchAssetType, id: string): string => { switch (type) { case 'index': @@ -109,10 +139,37 @@ const toAssetType = ( export async function getBulkAssets( soClient: SavedObjectsClientContract, soTypeRegistry: ISavedObjectTypeRegistry, - assetIds: AssetSOObject[] + assetIds: AssetSOObject[], + options?: GetBulkAssetsOptions ) { + const externalAssetIds = assetIds.filter(({ type }) => EXTERNAL_KIBANA_ASSET_TYPES.has(type)); + const savedObjectAssetIds = assetIds.filter(({ type }) => !EXTERNAL_KIBANA_ASSET_TYPES.has(type)); + + const externalMetadata = options?.externalAssetEnricher + ? await options.externalAssetEnricher(externalAssetIds) + : {}; + + const externalAssets: GetBulkAssetsResponse['items'] = externalAssetIds.map( + ({ id, type }) => { + const metadata = externalMetadata[id]; + return { + id, + type: type as KibanaSavedObjectType, + attributes: { + title: metadata?.title ?? id, + description: metadata?.description, + }, + appLink: getKibanaLinkForExternalAsset(type as KibanaSavedObjectType, id), + }; + } + ); + + if (savedObjectAssetIds.length === 0) { + return externalAssets; + } + const { resolved_objects: resolvedObjects } = - await soClient.bulkResolve(assetIds); + await soClient.bulkResolve(savedObjectAssetIds); const types: Record = {}; const res: GetBulkAssetsResponse['items'] = resolvedObjects @@ -161,5 +218,5 @@ export async function getBulkAssets( return toAssetType(obj, types[obj.type], appLink); }); - return res; + return [...externalAssets, ...res]; } diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts index 4e2628bd10865..abb96c5360da3 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts @@ -39,6 +39,7 @@ import { stepInstallIndexTemplatePipelines, stepRemoveLegacyTemplates, stepUpdateCurrentWriteIndices, + stepCreateIndices, stepInstallTransforms, stepDeletePreviousPipelines, stepSaveArchiveEntries, @@ -62,7 +63,14 @@ import { import type { StateMachineDefinition, StateMachineStates } from './state_machine'; import { handleState } from './state_machine'; import { stepCreateAlertingAssets } from './steps/step_create_alerting_assets'; +import { stepInstallWorkflowAssets } from './steps/step_install_workflow_assets'; +import { stepInstallAgentAssets } from './steps/step_install_agent_assets'; +import { stepInstallSkillAssets } from './steps/step_install_skill_assets'; import { cleanupEsqlViewsStep, stepInstallEsqlViews } from './steps/step_install_esql_views'; +import { + cleanupIndexAliasesStep, + stepInstallIndexAliases, +} from './steps/step_install_index_aliases'; import { stepResolveDependencies } from './steps/step_resolve_dependencies'; export interface InstallContext extends StateContext { @@ -112,6 +120,12 @@ export const regularStatesDefinition: StateMachineStates = { install_esql_views: { onPreTransition: cleanupEsqlViewsStep, onTransition: stepInstallEsqlViews, + nextState: INSTALL_STATES.INSTALL_INDEX_ALIASES, + onPostTransition: updateLatestExecutedState, + }, + install_index_aliases: { + onPreTransition: cleanupIndexAliasesStep, + onTransition: stepInstallIndexAliases, nextState: INSTALL_STATES.INSTALL_KIBANA_ASSETS, onPostTransition: updateLatestExecutedState, }, @@ -146,6 +160,11 @@ export const regularStatesDefinition: StateMachineStates = { }, update_current_write_indices: { onTransition: stepUpdateCurrentWriteIndices, + nextState: INSTALL_STATES.CREATE_INDICES, + onPostTransition: updateLatestExecutedState, + }, + create_indices: { + onTransition: stepCreateIndices, nextState: INSTALL_STATES.INSTALL_TRANSFORMS, onPostTransition: updateLatestExecutedState, }, @@ -180,6 +199,19 @@ export const regularStatesDefinition: StateMachineStates = { }, create_alerting_assets: { onTransition: stepCreateAlertingAssets, + nextState: INSTALL_STATES.CREATE_AGENT_ASSETS, + onPostTransition: updateLatestExecutedState, + }, + create_agent_assets: { + onTransition: async (context: Parameters[0]) => { + await stepInstallAgentAssets(context); + await stepInstallSkillAssets(context); + }, + nextState: INSTALL_STATES.CREATE_WORKFLOW_ASSETS, + onPostTransition: updateLatestExecutedState, + }, + create_workflow_assets: { + onTransition: stepInstallWorkflowAssets, nextState: INSTALL_STATES.VERIFY_ASSETS, onPostTransition: updateLatestExecutedState, }, diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.test.ts new file mode 100644 index 0000000000000..7c21a8a37a07b --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Under the Elastic License 2.0, the + * GNU AGPLv3, or the SSPLv1, at your election: the "Elastic License 2.0", the + * "GNU Affero General Public License v3.0 only", or the "Server Side Public + * License, v 1"; you may not use this file except in compliance with, at your + * election, the "Elastic License 2.0", the "GNU Affero General Public License + * v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getFleetPackageSkillId, parseFleetSkillYaml } from './fleet_skill_parse'; + +describe('Fleet skill assets (AB-005)', () => { + const SKILL_MD = `--- +name: sdlc-triage +description: Triage incoming SDLC signals +base_path: skills/sdlc_intel +--- +# SDLC Triage + +Prioritize the alert queue by severity. +`; + + it('parses frontmatter and body into a persisted-skill create request', () => { + const req = parseFleetSkillYaml( + { fileName: 'sdlc-triage/SKILL.md', content: SKILL_MD }, + 'fleet-default-sdlc_intel-sdlc-triage', + 'sdlc_intel' + ); + expect(req.name).toBe('sdlc-triage'); + expect(req.description).toBe('Triage incoming SDLC signals'); + expect(req.base_path).toBe('skills/sdlc_intel'); + expect(req.content).toContain('Prioritize the alert queue'); + expect(req.content).not.toContain('---'); + expect(req.plugin_id).toBe('fleet:sdlc_intel'); + }); + + it('derives deterministic, namespaced skill ids that never collide across packages', () => { + const a = getFleetPackageSkillId({ + pkgName: 'sdlc_intel', + spaceId: 'default', + fileName: 'sdlc-triage/SKILL.md', + }); + const b = getFleetPackageSkillId({ + pkgName: 'other_pkg', + spaceId: 'default', + fileName: 'sdlc-triage/SKILL.md', + }); + expect(a).toBe('fleet-default-sdlc_intel-sdlc-triage'); + expect(a).not.toBe(b); + }); + + it('throws on empty content after frontmatter', () => { + expect(() => + parseFleetSkillYaml( + { fileName: 'empty/SKILL.md', content: '---\nname: empty\n---\n' }, + 'x', + 'pkg' + ) + ).toThrow(/empty content/); + }); + + it('falls back to directory name and package defaults when frontmatter is sparse', () => { + const req = parseFleetSkillYaml( + { fileName: 'bare/SKILL.md', content: 'Just body text.' }, + 'id1', + 'pkg' + ); + expect(req.name).toBe('bare'); + expect(req.description).toContain('bare'); + expect(req.plugin_id).toBe('fleet:pkg'); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.ts new file mode 100644 index 0000000000000..7fb9b06b1def8 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/fleet_skill_parse.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Under the Elastic License 2.0, the + * GNU AGPLv3, or the SSPLv1, at your election: the "Elastic License 2.0", the + * "GNU Affero General Public License v3.0 only", and the "Server Side Public + * License, v 1"; you may not use this file except in compliance with, at your + * election, the "Elastic License 2.0", the "GNU Affero General Public License + * v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { PersistedSkillCreateRequest } from '@kbn/agent-builder-common'; + +/** + * AB-005: parse a Fleet skill asset (kibana/skill///SKILL.md) + * into a persisted-skill create request, stamped as package-managed. + */ +export interface FleetSkillAsset { + fileName: string; // e.g. "sdlc-triage/SKILL.md" + content: string; // raw SKILL.md markdown +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; + +/** Minimal YAML flat-key parser for skill frontmatter (key: value lines). */ +export function parseFrontmatter(content: string): Record { + const m = content.match(FRONTMATTER_RE); + if (!m) return {}; + const out: Record = {}; + for (const line of m[1].split(/\r?\n/)) { + const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (kv) out[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, ''); + } + return out; +} + +export function stripFrontmatter(content: string): string { + return content.replace(FRONTMATTER_RE, '').trim(); +} + +/** + * Build a deterministic fleet skill id from package name, space, and skill dir. + */ +export function getFleetPackageSkillId(params: { + pkgName: string; + spaceId: string; + fileName: string; // "sdlc-triage/SKILL.md" +}): string { + const base = params.fileName.replace(/\/SKILL\.md$/i, ''); + return `fleet-${params.spaceId}-${params.pkgName}-${base}`; +} + +/** + * Parse a SKILL.md asset into a PersistedSkillCreateRequest. + * Requires frontmatter `name` and `description`; content is the body. + */ +export function parseFleetSkillYaml( + asset: FleetSkillAsset, + skillId: string, + pkgName: string +): PersistedSkillCreateRequest { + const fm = parseFrontmatter(asset.content); + const name = fm.name || asset.fileName.split('/')[0]; + const description = fm.description || `Skill ${name} from package ${pkgName}`; + const skillContent = stripFrontmatter(asset.content); + if (!skillContent) { + throw new Error(`Fleet skill asset ${asset.fileName} has empty content after frontmatter`); + } + return { + id: skillId, + name, + base_path: fm.base_path || `skills/${pkgName}`, + description, + content: skillContent, + tool_ids: [], + plugin_id: fm.plugin_id || `fleet:${pkgName}`, + readonly: true, + } as PersistedSkillCreateRequest; +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/index.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/index.ts index 071e002e4e643..37b56f79eef1f 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/index.ts @@ -8,10 +8,12 @@ export * from './step_create_restart_installation'; export * from './step_install_kibana_assets'; export * from './step_install_mlmodel'; +export * from './step_install_index_aliases'; export * from './step_install_ilm_policies'; export * from './step_install_index_template_pipelines'; export * from './step_remove_legacy_templates'; export * from './step_update_current_write_indices'; +export * from './step_create_indices'; export * from './step_install_transforms'; export * from './step_delete_previous_pipelines'; export * from './step_save_archive_entries'; @@ -20,4 +22,7 @@ export * from './step_save_system_object'; export * from './step_resolve_kibana_promise'; export * from './update_latest_executed_state'; export * from './step_install_precheck'; +export * from './step_install_workflow_assets'; +export * from './step_install_agent_assets'; export * from './step_verify_assets'; +export * from './step_install_workflow_assets'; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_create_indices.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_create_indices.ts new file mode 100644 index 0000000000000..20f07fbbc2fe9 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_create_indices.ts @@ -0,0 +1,35 @@ +/* + * 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 { installConcreteIndicesFromTemplates } from '../../../elasticsearch/index/install'; +import { withPackageSpan } from '../../utils'; +import type { InstallContext } from '../_state_machine_package_install'; + +/** + * After index templates are installed, create empty concrete indices for packages + * that ship fixed index names (non-wildcard index_patterns). This makes dependent + * workflows and dashboards usable immediately without waiting for a first write. + * + * Existing indices are left untouched. Uninstall does not delete these indices. + */ +export async function stepCreateIndices(context: InstallContext) { + const { packageInstallContext, esClient, savedObjectsClient, logger, installedPkg } = context; + + let esReferences = context.esReferences ?? installedPkg?.attributes.installed_es ?? []; + + esReferences = await withPackageSpan('Create concrete indices from templates', () => + installConcreteIndicesFromTemplates( + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences + ) + ); + + return { esReferences }; +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.test.ts new file mode 100644 index 0000000000000..f2c102f4fef97 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.test.ts @@ -0,0 +1,285 @@ +/* + * 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 { + getFleetPackageAgentId, + parseFleetAgentYaml, + stepInstallAgentAssets, +} from './step_install_agent_assets'; +import { + substituteFleetAgentIds, + substituteFleetAgentIdsWithUnresolved, +} from './step_install_workflow_assets'; +import { savedObjectsClientMock, loggingSystemMock, httpServerMock } from '@kbn/core/server/mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/core-spaces-common'; +import { appContextService } from '../../../../app_context'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; +import { saveKibanaAssetsRefs } from '../../install'; + +jest.mock('../../install', () => ({ + saveKibanaAssetsRefs: jest.fn(), +})); + +describe('parseFleetAgentYaml', () => { + it('parses a valid fleet agent definition', () => { + const yaml = ` +name: SDLC Coverage Analysis +description: Planning coverage analyst +labels: + - sdlc +configuration: + instructions: Analyze planning coverage + tools: + - tool_ids: + - platform.core.execute_esql + connector_ids: + - ai-conn-1 +`; + + const result = parseFleetAgentYaml(yaml, 'fleet-default-sdlc-intel-sdlc-coverage-analysis'); + + expect(result.id).toBe('fleet-default-sdlc-intel-sdlc-coverage-analysis'); + expect(result.name).toBe('SDLC Coverage Analysis'); + expect(result.configuration.tools[0].tool_ids).toContain('platform.core.execute_esql'); + }); +}); + +describe('AB-002: platform-only tool allowlist', () => { + const agentYaml = (toolIds: string[]) => [ + 'name: SDLC test agent', + 'description: test agent', + 'configuration:', + ' tools:', + ' - tool_ids:', + ...toolIds.map((t: string) => ' - ' + t), + '', + ].join('\n'); + + it('accepts platform-owned tools', () => { + const definition = parseFleetAgentYaml( + agentYaml(['platform.core.execute_esql', 'platform.core.integration_knowledge']), + 'fleet-default-sdlc_intel-agent', + { pkgName: 'sdlc_intel' } + ); + expect(definition.configuration.tools[0].tool_ids).toEqual([ + 'platform.core.execute_esql', + 'platform.core.integration_knowledge', + ]); + }); + + it('rejects solution-owned tools so packaged agents cannot depend on a solution plugin', () => { + // A package binding a solution tool would silently reintroduce the + // security_solution coupling that package agents exist to avoid. + expect(() => + parseFleetAgentYaml( + agentYaml(['platform.core.execute_esql', 'security_solution.alerts']), + 'fleet-default-sdlc_intel-agent', + { pkgName: 'sdlc_intel' } + ) + ).toThrow(/security_solution\.alerts/); + }); +}); + +describe('substituteFleetAgentIds', () => { + it('replaces fleet agent placeholders with deterministic fleet ids', () => { + const yaml = ` +steps: + - agent-id: REPLACE_WITH_FLEET_AGENT_sdlc-coverage-analysis +`; + + const result = substituteFleetAgentIds(yaml, { + pkgName: 'sdlc_intel', + spaceId: 'default', + }); + + expect(result).toContain('agent-id: fleet-default-sdlc-intel-sdlc-coverage-analysis'); + expect(result).not.toContain('REPLACE_WITH_FLEET_AGENT_sdlc-coverage-analysis'); + }); +}); + +describe('AB-006 fleet agent id namespace (collision policy)', () => { + it('derives deterministic ids in the fleet-* namespace; fileBase is case-sensitive by convention', () => { + const a = getFleetPackageAgentId({ pkgName: 'sdlc_intel', spaceId: 'default', fileName: 'sdlc-coverage-analysis.yaml' }); + const b = getFleetPackageAgentId({ pkgName: 'sdlc_intel', spaceId: 'default', fileName: 'sdlc-coverage-analysis.yaml' }); + expect(a).toBe(b); + expect(a.startsWith('fleet-')).toBe(true); + expect(a).toContain('sdlc-coverage-analysis'); + }); + + it('different packages never collide (namespace includes pkgName)', () => { + const a = getFleetPackageAgentId({ pkgName: 'sdlc_intel', spaceId: 'default', fileName: 'analyst.yaml' }); + const b = getFleetPackageAgentId({ pkgName: 'other_pkg', spaceId: 'default', fileName: 'analyst.yaml' }); + expect(a).not.toBe(b); + }); + + it('parseFleetAgentYaml stamps managed_by_package label and readonly (AB-004)', () => { + const yaml = ` +name: Test agent +description: tests things +labels: [custom] +configuration: + tools: + - tool_ids: [platform.core.integration_knowledge] +`; + const req = parseFleetAgentYaml(yaml, 'fleet-default-test-agent'); + expect(req.labels).toContain('managed_by_package'); + expect(req.labels).toContain('custom'); + expect(req.readonly).toBe(true); + }); + + it('identifies the owning package by name, not by id prefix (AB-004)', () => { + const yaml = ` +name: Coverage analysis +description: analyses coverage +configuration: + tools: + - tool_ids: [platform.core.integration_knowledge] +`; + + // The live instance stamped "fleet-package:fleet-default-sdlc" here: the + // package name sdlc_intel was unrecoverable, because the label was built by + // slicing the agent id rather than from pkgName. + const req = parseFleetAgentYaml(yaml, 'fleet-default-sdlc-intel-sdlc-coverage-analysis', { + pkgName: 'sdlc_intel', + }); + + expect(req.labels).toContain('fleet-package:sdlc_intel'); + expect(req.labels).not.toContain('fleet-package:fleet-default-sdlc'); + }); + + it('keeps the package label correct when the package name contains a hyphen (AB-004)', () => { + const yaml = ` +name: Agent +description: d +configuration: + tools: + - tool_ids: [platform.core.integration_knowledge] +`; + + const req = parseFleetAgentYaml(yaml, 'fleet-default-my-pkg-some-agent', { + pkgName: 'my-pkg', + }); + + expect(req.labels).toContain('fleet-package:my-pkg'); + }); +}); + +describe('substituteFleetAgentIdsWithUnresolved (AB-006 collision + validation policy)', () => { + it('reports a placeholder as unresolved when no such agent was installed', () => { + const yaml = ` +steps: + - agent-id: REPLACE_WITH_FLEET_AGENT_sdlc-typo-agent +`; + + const { yaml: result, unresolved } = substituteFleetAgentIdsWithUnresolved(yaml, { + pkgName: 'sdlc_intel', + spaceId: 'default', + installedAgentIds: ['fleet-default-sdlc-intel-sdlc-coverage-analysis'], + }); + + expect(unresolved).toEqual(['REPLACE_WITH_FLEET_AGENT_sdlc-typo-agent']); + // a dangling id must NOT be written into the workflow + expect(result).not.toContain('fleet-default-sdlc-intel-sdlc-typo-agent'); + }); + + it('substitutes placeholders that match an installed agent', () => { + const yaml = ` +steps: + - agent-id: REPLACE_WITH_FLEET_AGENT_sdlc-coverage-analysis +`; + + const { yaml: result, unresolved } = substituteFleetAgentIdsWithUnresolved(yaml, { + pkgName: 'sdlc_intel', + spaceId: 'default', + installedAgentIds: ['fleet-default-sdlc-intel-sdlc-coverage-analysis'], + }); + + expect(unresolved).toEqual([]); + expect(result).toContain('agent-id: fleet-default-sdlc-intel-sdlc-coverage-analysis'); + }); + + it('skips validation when the installed agent list is not provided (back-compat)', () => { + const yaml = 'agent-id: REPLACE_WITH_FLEET_AGENT_sdlc-coverage-analysis'; + + const { yaml: result, unresolved } = substituteFleetAgentIdsWithUnresolved(yaml, { + pkgName: 'sdlc_intel', + spaceId: 'default', + }); + + expect(unresolved).toEqual([]); + expect(result).toContain('fleet-default-sdlc-intel-sdlc-coverage-analysis'); + }); + + it('enforces the fleet-* namespace: a placeholder cannot escape it', () => { + const yaml = 'agent-id: REPLACE_WITH_FLEET_AGENT_sdlc-coverage-analysis'; + + const { yaml: result } = substituteFleetAgentIdsWithUnresolved(yaml, { + pkgName: 'sdlc_intel', + spaceId: 'default', + }); + + const id = result.split('agent-id: ')[1].trim(); + expect(id.startsWith('fleet-')).toBe(true); + }); +}); + + +describe('stepInstallAgentAssets (FLEET-007 headless install)', () => { + const pkgName = 'test-package'; + const pkgVersion = '1.2.3'; + const spaceId = DEFAULT_SPACE_ID; + const agentFileName = 'my-agent.yaml'; + const agentId = 'fleet-default-test-package-my-agent'; + const agentYaml = [ + 'name: SDLC Coverage Analysis', + 'description: Planning coverage analyst', + 'configuration:', + ' tools:', + ' - tool_ids:', + ' - platform.core.execute_esql', + ].join('\n'); + + let createOrUpdateAgent: jest.Mock; + beforeEach(() => { + createOrUpdateAgent = jest.fn().mockResolvedValue(undefined); + jest.spyOn(appContextService, 'getAgentBuilderSetup').mockReturnValue({ + management: { createOrUpdateAgent }, + } as never); + jest.mocked(saveKibanaAssetsRefs).mockReset(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + const createContext = (request: unknown = httpServerMock.createKibanaRequest()) => ({ + logger: loggingSystemMock.createLogger(), + savedObjectsClient: savedObjectsClientMock.create(), + spaceId, + request, + packageInstallContext: { + packageInfo: { name: pkgName, version: pkgVersion }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/agent/${agentFileName}`, + Buffer.from(agentYaml), + ], + ]) + ), + }, + }); + + it('creates agent assets when request context is missing (headless install)', async () => { + const context = { ...createContext(), request: undefined }; + await stepInstallAgentAssets(context as never); + expect(createOrUpdateAgent).toHaveBeenCalledTimes(1); + expect(createOrUpdateAgent).toHaveBeenCalledWith( + expect.objectContaining({ id: agentId }), + expect.objectContaining({ isFakeRequest: true, isSystemRequest: true }) + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.ts new file mode 100644 index 0000000000000..c46a157cf9b01 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_agent_assets.ts @@ -0,0 +1,181 @@ +/* + * 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 path from 'path'; + +import { parse as parseYaml } from 'yaml'; +import pMap from 'p-map'; + +import type { AgentCreateRequest } from '@kbn/agent-builder-common'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; + +import { KibanaAssetType, KibanaSavedObjectType } from '../../../../../../common/types'; +import type { KibanaAssetReference } from '../../../../../../common/types'; +import { getPathParts } from '../../../archive'; +import { appContextService } from '../../../../app_context'; +import { saveKibanaAssetsRefs } from '../../install'; +import { withPackageSpan } from '../../utils'; +import { createFleetInternalRequest } from '../../../../security/fake_request'; +import type { InstallContext } from '../_state_machine_package_install'; +import { + getFleetPackageWorkflowId, + resolvePackagePolicyConnectorVars, + substituteWorkflowConnectorIds, +} from './step_install_workflow_assets'; + +export const getFleetPackageAgentId = (params: { + pkgName: string; + spaceId: string; + fileName: string; +}): string => getFleetPackageWorkflowId(params); + +/** AB-002: only platform-owned tools may be bound by a packaged agent. */ +export const PLATFORM_TOOL_PREFIX = 'platform.'; + +interface FleetPackageAgentYaml { + name: string; + description: string; + labels?: string[]; + avatar_color?: string; + avatar_symbol?: string; + configuration: AgentCreateRequest['configuration']; +} + +export const parseFleetAgentYaml = ( + yamlContent: string, + agentId: string, + options?: { pkgName?: string } +): AgentCreateRequest => { + const parsed = parseYaml(yamlContent) as FleetPackageAgentYaml; + + if (!parsed.name || !parsed.description || !parsed.configuration?.tools?.length) { + throw new Error( + `Invalid agent asset ${agentId}: name, description, and configuration.tools are required` + ); + } + // AB-002: package agents may only bind platform-owned tools. Binding a + // solution-owned tool (security_solution.*, observability.*, ...) makes the + // package depend on a solution plugin being installed and enabled, which is the + // coupling package-managed agents exist to avoid. Fail the install loudly rather + // than let the agent resolve to a missing tool at converse time. + const foreignToolIds = parsed.configuration.tools + .flatMap((toolGroup) => toolGroup.tool_ids ?? []) + .filter((toolId) => !toolId.startsWith(PLATFORM_TOOL_PREFIX)); + + if (foreignToolIds.length) { + throw new Error( + `Invalid agent asset ${agentId}: package agents may only use platform tools ` + + `(${PLATFORM_TOOL_PREFIX}*), got ${foreignToolIds.join(', ')}` + ); + } + + return { + id: agentId, + name: parsed.name, + description: parsed.description, + // AB-004: package-managed governance metadata — UI shows the managed badge + // and warns/blocks instruction edits; upgrades overwrite (createOrUpdate). + labels: [ + ...new Set([ + ...(parsed.labels ?? []), + 'managed_by_package', + // Identify the owning package by name. Deriving it by slicing the agent + // id is wrong: the id is `fleet---`, so a positional + // slice yields the id prefix (and breaks outright for hyphenated + // package names), leaving upgrades unable to match agent to package. + ...(options?.pkgName ? [`fleet-package:${options.pkgName}`] : []), + ]), + ], + readonly: true, + avatar_color: parsed.avatar_color, + avatar_symbol: parsed.avatar_symbol, + configuration: parsed.configuration, + }; +}; + +export async function stepInstallAgentAssets( + context: Pick< + InstallContext, + 'logger' | 'savedObjectsClient' | 'packageInstallContext' | 'spaceId' | 'request' + > & { installAsAdditionalSpace?: boolean } +) { + const { logger, savedObjectsClient, packageInstallContext, spaceId, installAsAdditionalSpace } = + context; + const { packageInfo } = packageInstallContext; + const { name: pkgName } = packageInfo; + const agentBuilderApi = appContextService.getAgentBuilderSetup()?.management; + + if (!agentBuilderApi) { + logger.debug(`Skipping agent asset installation for ${pkgName}: agentBuilder unavailable`); + return; + } + + const request = context.request ?? createFleetInternalRequest(); + if (!context.request) { + logger.debug( + `Installing agent assets for ${pkgName} using Fleet internal request (no install request context)` + ); + } + + await withPackageSpan(`Install package agents for ${pkgName}`, async () => { + const agentEntries: Array<{ fileName: string; yaml: string }> = []; + + await packageInstallContext.archiveIterator.traverseEntries( + async (entry) => { + if (!entry.buffer) { + return; + } + + agentEntries.push({ + fileName: path.basename(entry.path), + yaml: entry.buffer.toString('utf8'), + }); + }, + (entryPath) => { + const parts = getPathParts(entryPath); + return parts.service === 'kibana' && parts.type === KibanaAssetType.agent; + } + ); + + if (agentEntries.length === 0) { + return; + } + + const connectorVars = await resolvePackagePolicyConnectorVars( + savedObjectsClient as SavedObjectsClientContract, + pkgName + ); + + const assetRefs: KibanaAssetReference[] = []; + + await pMap( + agentEntries, + async ({ fileName, yaml }) => { + const agentId = getFleetPackageAgentId({ pkgName, spaceId, fileName }); + const agentYaml = substituteWorkflowConnectorIds(yaml, connectorVars); + const definition = parseFleetAgentYaml(agentYaml, agentId, { pkgName }); + + await agentBuilderApi.createOrUpdateAgent(definition, request); + + assetRefs.push({ + id: agentId, + type: KibanaSavedObjectType.agent, + }); + }, + { concurrency: 3 } + ); + + await saveKibanaAssetsRefs( + savedObjectsClient, + pkgName, + assetRefs, + spaceId, + installAsAdditionalSpace, + true + ); + }); +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_aliases.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_aliases.ts new file mode 100644 index 0000000000000..bd5be171768a5 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_aliases.ts @@ -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 { appContextService } from '../../../../app_context'; +import { withPackageSpan } from '../../utils'; +import type { InstallContext } from '../_state_machine_package_install'; +import { cleanupIndexAliases } from '../../remove'; +import { INSTALL_STATES } from '../../../../../../common/types'; +import { installIndexAliases } from '../../../elasticsearch/index_alias/install'; + +export async function stepInstallIndexAliases(context: InstallContext) { + const { savedObjectsClient, esClient, logger, packageInstallContext, installedPkg } = context; + + let esReferences = + context.esReferences ?? context.esReferences ?? installedPkg?.attributes.installed_es ?? []; + + if (appContextService.getExperimentalFeatures().enableIndexAliasInstall !== true) { + return { esReferences }; + } + + esReferences = await withPackageSpan('Install index aliases', () => + installIndexAliases({ + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences, + }) + ); + + return { esReferences }; +} + +export async function cleanupIndexAliasesStep(context: InstallContext) { + const { logger, installedPkg, esClient, retryFromLastState, force, initialState } = context; + + // In case of retry clean up previous installed index aliases + if ( + !force && + retryFromLastState && + initialState === INSTALL_STATES.INSTALL_INDEX_ALIASES && + installedPkg?.attributes?.installed_es && + installedPkg.attributes.installed_es.length > 0 + ) { + const { installed_es: installedEs } = installedPkg.attributes; + + logger.debug('Retry transition - clean up index aliases'); + await withPackageSpan('Retry transition - clean up index aliases', async () => { + await cleanupIndexAliases(installedEs, esClient); + }); + } +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.test.ts new file mode 100644 index 0000000000000..486ce940b6fbd --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Under the Elastic License 2.0, the + * GNU AGPLv3, or the SSPLv1, at your election: the "Elastic License 2.0", the + * "GNU Affero General Public License v3.0 only", or the "Server Side Public + * License, v 1"; you may not use this file except in compliance with, at your + * election, the "Elastic License 2.0", the "GNU Affero General Public License + * v3.0 only", or the "Server Side Public License, v 1". + */ + +import { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; + +import { appContextService } from '../../../../app_context'; +import { stepInstallSkillAssets } from './step_install_skill_assets'; + +jest.mock('../../install', () => ({ + saveKibanaAssetsRefs: jest.fn(), +})); + +const SKILL_MD = [ + '---', + 'name: sdlc-triage', + 'description: Triage SDLC intel signals', + '---', + '# SDLC Triage', + 'Rank findings by severity.', +].join('\n'); + +function buildContext({ + listPackageManagedSkills = jest.fn().mockResolvedValue([]), + deletePackageManagedSkill = jest.fn().mockResolvedValue(true), + createOrUpdateSkill = jest.fn().mockResolvedValue(undefined), +}: { + listPackageManagedSkills?: jest.Mock; + deletePackageManagedSkill?: jest.Mock; + createOrUpdateSkill?: jest.Mock; +} = {}) { + jest.spyOn(appContextService, 'getAgentBuilderSetup').mockReturnValue({ + management: { createOrUpdateSkill, deletePackageManagedSkill, listPackageManagedSkills }, + } as never); + + return { + logger: loggingSystemMock.createLogger(), + savedObjectsClient: savedObjectsClientMock.create(), + spaceId: 'default', + request: {} as never, + packageInstallContext: { + packageInfo: { name: 'sdlc_intel', version: '0.1.0' }, + archiveIterator: { + traverseEntries: async (onEntry: (e: unknown) => Promise) => { + await onEntry({ + path: 'sdlc_intel-0.1.0/kibana/skill/sdlc-triage/SKILL.md', + buffer: Buffer.from(SKILL_MD, 'utf8'), + }); + }, + }, + }, + } as never; +} + +describe('stepInstallSkillAssets', () => { + afterEach(() => jest.restoreAllMocks()); + + it('installs the skill under a path-independent id', async () => { + const createOrUpdateSkill = jest.fn().mockResolvedValue(undefined); + await stepInstallSkillAssets(buildContext({ createOrUpdateSkill })); + + expect(createOrUpdateSkill).toHaveBeenCalledTimes(1); + const [definition] = createOrUpdateSkill.mock.calls[0]; + expect(definition.id).toBe('fleet-default-sdlc_intel-sdlc-triage'); + expect(definition.id).not.toContain('/'); + }); + + it('reaps package-owned skills that the current archive no longer produces', async () => { + // An earlier install of this package wrote an id derived from the raw + // archive path. It is readonly + package-managed, so nothing but install + // can remove it, and package uninstall never will: it is absent from the + // package asset refs. + const listPackageManagedSkills = jest.fn().mockResolvedValue([ + { id: 'fleet-default-sdlc_intel-sdlc-triage', plugin_id: 'fleet:sdlc_intel' }, + { + id: 'fleet-default-sdlc_intel-sdlc_intel-0.1.0/kibana/skill/sdlc-triage', + plugin_id: 'fleet:sdlc_intel', + }, + ]); + const deletePackageManagedSkill = jest.fn().mockResolvedValue(true); + + await stepInstallSkillAssets(buildContext({ listPackageManagedSkills, deletePackageManagedSkill })); + + expect(deletePackageManagedSkill).toHaveBeenCalledTimes(1); + expect(deletePackageManagedSkill).toHaveBeenCalledWith( + 'fleet-default-sdlc_intel-sdlc_intel-0.1.0/kibana/skill/sdlc-triage', + 'default' + ); + }); + + it('never reaps skills owned by another package or by the user', async () => { + const listPackageManagedSkills = jest.fn().mockResolvedValue([ + { id: 'fleet-default-other_pkg-thing', plugin_id: 'fleet:other_pkg' }, + { id: 'user-authored-skill', plugin_id: undefined }, + ]); + const deletePackageManagedSkill = jest.fn().mockResolvedValue(true); + + await stepInstallSkillAssets(buildContext({ listPackageManagedSkills, deletePackageManagedSkill })); + + expect(deletePackageManagedSkill).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.ts new file mode 100644 index 0000000000000..663fa06b58d3e --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_skill_assets.ts @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Under the Elastic License 2.0, the + * GNU AGPLv3, or the SSPLv1, at your election: the "Elastic License 2.0", the + * "GNU Affero General Public License v3.0 only", or the "Server Side Public + * License, v 1"; you may not use this file except in compliance with, at your + * election, the "Elastic License 2.0", the "GNU Affero General Public License + * v3.0 only", or the "Server Side Public License, v 1". + */ + +import path from 'path'; + +import { KibanaAssetType, KibanaSavedObjectType } from '../../../../../../common/types'; +import type { KibanaAssetReference } from '../../../../../../common/types'; +import { getPathParts } from '../../../archive'; +import { appContextService } from '../../../../app_context'; +import { saveKibanaAssetsRefs } from '../../install'; +import { withPackageSpan } from '../../utils'; +import type { InstallContext } from '../_state_machine_package_install'; +import { getFleetPackageSkillId, parseFleetSkillYaml } from './fleet_skill_parse'; + +/** + * AB-005: install `kibana/skill//SKILL.md` assets as persisted, + * package-managed skills (readonly, plugin_id `fleet:`). + */ +export async function stepInstallSkillAssets( + context: Pick< + InstallContext, + 'logger' | 'savedObjectsClient' | 'packageInstallContext' | 'spaceId' | 'request' + > & { installAsAdditionalSpace?: boolean } +) { + const { logger, savedObjectsClient, packageInstallContext, spaceId, installAsAdditionalSpace } = + context; + const { packageInfo } = packageInstallContext; + const { name: pkgName } = packageInfo; + const agentBuilderApi = appContextService.getAgentBuilderSetup()?.management; + + if (!agentBuilderApi) { + logger.debug(`Skipping skill asset installation for ${pkgName}: agentBuilder unavailable`); + return; + } + await withPackageSpan(`Install package skills for ${pkgName}`, async () => { + const skillEntries: Array<{ fileName: string; content: string }> = []; + await packageInstallContext.archiveIterator.traverseEntries( + async (entry) => { + if (!entry.buffer) { + return; + } + // Archive entries are prefixed with `-/`, so anchor on the + // kibana/skill/ segment anywhere in the path rather than at the start. + const rel = entry.path.replace(/^.*?kibana\/skill\//, ''); + skillEntries.push({ + fileName: rel, + content: entry.buffer.toString('utf8'), + }); + }, + (entryPath) => { + const parts = getPathParts(entryPath); + return ( + parts.service === 'kibana' && + parts.type === KibanaAssetType.skill && + path.basename(entryPath).toUpperCase() === 'SKILL.MD' + ); + } + ); + if (skillEntries.length === 0) { + return; + } + const assetRefs: KibanaAssetReference[] = []; + const installedSkillIds = new Set(); + for (const { fileName, content } of skillEntries) { + const skillId = getFleetPackageSkillId({ pkgName, spaceId, fileName }); + const definition = parseFleetSkillYaml({ fileName, content }, skillId, pkgName); + await agentBuilderApi.createOrUpdateSkill(definition, context.request!); + assetRefs.push({ id: skillId, type: KibanaSavedObjectType.skill }); + installedSkillIds.add(skillId); + } + + // Reap skills this package owns that the current archive no longer produces. + // Package-managed skills are readonly, so a user cannot delete one, and + // package uninstall only removes ids recorded in the package asset refs. + // Without this, an id-scheme change strands an undeletable skill forever. + if (typeof agentBuilderApi.listPackageManagedSkills === 'function') { + const pluginId = `fleet:${pkgName}`; + const owned = await agentBuilderApi.listPackageManagedSkills(pluginId, spaceId); + for (const skill of owned) { + if (skill.plugin_id !== pluginId || installedSkillIds.has(skill.id)) { + continue; + } + const deleted = await agentBuilderApi.deletePackageManagedSkill(skill.id, spaceId); + logger.info( + `Reaped stale package skill ${skill.id} for ${pkgName} (deleted=${String(deleted)})` + ); + } + } + await saveKibanaAssetsRefs( + savedObjectsClient as never, + pkgName, + assetRefs, + spaceId, + installAsAdditionalSpace, + true + ); + logger.info(`Installed ${assetRefs.length} skill asset(s) for ${pkgName}`); + }); +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.test.ts new file mode 100644 index 0000000000000..05da51227cf21 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.test.ts @@ -0,0 +1,991 @@ +/* + * 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 { isValidId } from '@kbn/human-readable-id'; +import { loggingSystemMock, savedObjectsClientMock, httpServerMock } from '@kbn/core/server/mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/core-spaces-common'; + +import { appContextService } from '../../../../app_context'; +import { createArchiveIteratorFromMap } from '../../../archive/archive_iterator'; +import { + createAppContextStartContractMock, + createWorkflowsManagementSetupMock, +} from '../../../../../mocks'; +import { saveKibanaAssetsRefs } from '../../install'; + +import { + stepInstallWorkflowAssets, + getFleetPackageWorkflowId, + orderWorkflowEntriesByDependencies, + resolveWorkflowEnabledIntent, + substituteWorkflowConnectorIds, +} from './step_install_workflow_assets'; + +jest.mock('../../install'); + +type StepInstallWorkflowAssetsParam = Parameters[number]; + +const mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + fatal: jest.fn(), + log: jest.fn(), + isLevelEnabled: jest.fn(), + get: jest.fn(), +}; + +describe('orderWorkflowEntriesByDependencies', () => { + const entry = (fileName: string) => ({ fileName, yaml: `name: ${fileName}` }); + + it('installs transitive dependencies before their dependents', () => { + const ordered = orderWorkflowEntriesByDependencies( + [entry('enrich.yaml'), entry('catalog.yaml'), entry('cross-link.yaml')], + { + 'enrich.yaml': ['cross-link'], + 'cross-link.yaml': ['catalog.yaml'], + } + ); + + expect(ordered.map(({ fileName }) => fileName)).toEqual([ + 'catalog.yaml', + 'cross-link.yaml', + 'enrich.yaml', + ]); + }); + + it('preserves archive order for independent workflows', () => { + const ordered = orderWorkflowEntriesByDependencies([entry('b.yaml'), entry('a.yaml')]); + + expect(ordered.map(({ fileName }) => fileName)).toEqual(['b.yaml', 'a.yaml']); + }); + + it('rejects a dependency on a missing workflow', () => { + expect(() => + orderWorkflowEntriesByDependencies([entry('enrich.yaml')], { + 'enrich.yaml': ['missing.yaml'], + }) + ).toThrow('missing asset "missing.yaml"'); + }); + + it('rejects dependency cycles with the cycle chain', () => { + expect(() => + orderWorkflowEntriesByDependencies([entry('a.yaml'), entry('b.yaml')], { + 'a.yaml': ['b.yaml'], + 'b.yaml': ['a.yaml'], + }) + ).toThrow('a.yaml -> b.yaml -> a.yaml'); + }); + + it('rejects dependency declarations for missing owners', () => { + expect(() => + orderWorkflowEntriesByDependencies([entry('catalog.yaml')], { + 'enrich.yaml': ['catalog.yaml'], + }) + ).toThrow('dependencies declared for missing asset "enrich.yaml"'); + }); +}); + +describe('getFleetPackageWorkflowId', () => { + it('normalizes package names with underscores for workflow id validation', () => { + const workflowId = getFleetPackageWorkflowId({ + pkgName: 'sdlc_intel', + spaceId: 'default', + fileName: 'github-catalog-repos.yaml', + }); + + expect(workflowId).toBe('fleet-default-sdlc-intel-github-catalog-repos'); + expect(isValidId(workflowId)).toBe(true); + }); + + it('normalizes space ids with underscores for workflow id validation', () => { + const workflowId = getFleetPackageWorkflowId({ + pkgName: 'sdlc_intel', + spaceId: 'custom_space', + fileName: 'github-catalog-repos.yaml', + }); + + expect(workflowId).toBe('fleet-custom-space-sdlc-intel-github-catalog-repos'); + expect(isValidId(workflowId)).toBe(true); + }); +}); + +describe('substituteWorkflowConnectorIds', () => { + const sampleYaml = ` +consts: + orgLogin: REPLACE_WITH_ORG_LOGIN + githubConnectorId: REPLACE_WITH_GITHUB_CONNECTOR_ID + slackConnectorId: REPLACE_WITH_SLACK_CONNECTOR_ID + salesforceConnectorId: REPLACE_WITH_SALESFORCE_CONNECTOR_ID + caseGithubField: REPLACE_WITH_SALESFORCE_CASE_GITHUB_FIELD + productAreaField: REPLACE_WITH_SALESFORCE_PRODUCT_AREA_FIELD + sdhRepoPattern: REPLACE_WITH_SDH_REPO_PATTERN + sdhLabel: REPLACE_WITH_SDH_LABEL + gdriveConnectorId: REPLACE_WITH_GOOGLE_DRIVE_CONNECTOR_ID + roadmapFolderIds: REPLACE_WITH_GDRIVE_ROADMAP_FOLDER_IDS + aiConnectorId: REPLACE_WITH_AI_CONNECTOR_ID +`; + + beforeEach(() => { + mockLogger.warn.mockClear(); + }); + + it('substitutes connector and org placeholders from package policy vars', () => { + const result = substituteWorkflowConnectorIds(sampleYaml, { + github_connector_id: 'github-conn-1', + slack_connector_id: 'slack-conn-2', + salesforce_connector_id: 'salesforce-conn-3', + salesforce_case_github_field: 'Engineering_Issue_URL__c', + salesforce_product_area_field: 'Product_Area__c', + sdh_repo_pattern: 'sdh-*', + sdh_label: 'sdh', + google_drive_connector_id: 'gdrive-conn-4', + gdrive_roadmap_folder_ids: ['folder-roadmap-1', 'folder-okrs-2'], + ai_connector_id: 'ai-conn-5', + org_login: 'my-org', + }); + + expect(result).toContain('orgLogin: my-org'); + expect(result).toContain('githubConnectorId: github-conn-1'); + expect(result).toContain('slackConnectorId: slack-conn-2'); + expect(result).toContain('salesforceConnectorId: salesforce-conn-3'); + expect(result).toContain('caseGithubField: Engineering_Issue_URL__c'); + expect(result).toContain('productAreaField: Product_Area__c'); + expect(result).toContain('sdhRepoPattern: sdh-*'); + expect(result).toContain('sdhLabel: sdh'); + expect(result).toContain('gdriveConnectorId: gdrive-conn-4'); + expect(result).toContain('roadmapFolderIds: folder-roadmap-1,folder-okrs-2'); + expect(result).toContain('aiConnectorId: ai-conn-5'); + expect(result).not.toContain('REPLACE_WITH_ORG_LOGIN'); + expect(result).not.toContain('REPLACE_WITH_GITHUB_CONNECTOR_ID'); + expect(result).not.toContain('REPLACE_WITH_SLACK_CONNECTOR_ID'); + expect(result).not.toContain('REPLACE_WITH_SALESFORCE_CONNECTOR_ID'); + expect(result).not.toContain('REPLACE_WITH_SALESFORCE_CASE_GITHUB_FIELD'); + expect(result).not.toContain('REPLACE_WITH_SALESFORCE_PRODUCT_AREA_FIELD'); + expect(result).not.toContain('REPLACE_WITH_SDH_REPO_PATTERN'); + expect(result).not.toContain('REPLACE_WITH_SDH_LABEL'); + expect(result).not.toContain('REPLACE_WITH_GOOGLE_DRIVE_CONNECTOR_ID'); + expect(result).not.toContain('REPLACE_WITH_GDRIVE_ROADMAP_FOLDER_IDS'); + expect(result).not.toContain('REPLACE_WITH_AI_CONNECTOR_ID'); + }); + + it('substitutes all placeholders byte-identically for the SDLC package', () => { + const yaml = ` +consts: + orgLogin: REPLACE_WITH_ORG_LOGIN + githubConnectorId: REPLACE_WITH_GITHUB_CONNECTOR_ID + slackConnectorId: REPLACE_WITH_SLACK_CONNECTOR_ID + salesforceConnectorId: REPLACE_WITH_SALESFORCE_CONNECTOR_ID + caseGithubField: REPLACE_WITH_SALESFORCE_CASE_GITHUB_FIELD + productAreaField: REPLACE_WITH_SALESFORCE_PRODUCT_AREA_FIELD + sdhRepoPattern: REPLACE_WITH_SDH_REPO_PATTERN + sdhLabel: REPLACE_WITH_SDH_LABEL + gdriveConnectorId: REPLACE_WITH_GOOGLE_DRIVE_CONNECTOR_ID + roadmapFolderIds: REPLACE_WITH_GDRIVE_ROADMAP_FOLDER_IDS + aiConnectorId: REPLACE_WITH_AI_CONNECTOR_ID +`; + const result = substituteWorkflowConnectorIds(yaml, { + github_connector_id: 'github-conn-1', + slack_connector_id: 'slack-conn-2', + salesforce_connector_id: 'salesforce-conn-3', + salesforce_case_github_field: 'Engineering_Issue_URL__c', + salesforce_product_area_field: 'Product_Area__c', + sdh_repo_pattern: 'sdh-*', + sdh_label: 'sdh', + google_drive_connector_id: 'gdrive-conn-4', + gdrive_roadmap_folder_ids: ['folder-roadmap-1', 'folder-okrs-2'], + ai_connector_id: 'ai-conn-5', + org_login: 'my-org', + }); + + expect(result).toBe(` +consts: + orgLogin: my-org + githubConnectorId: github-conn-1 + slackConnectorId: slack-conn-2 + salesforceConnectorId: salesforce-conn-3 + caseGithubField: Engineering_Issue_URL__c + productAreaField: Product_Area__c + sdhRepoPattern: sdh-* + sdhLabel: sdh + gdriveConnectorId: gdrive-conn-4 + roadmapFolderIds: folder-roadmap-1,folder-okrs-2 + aiConnectorId: ai-conn-5 +`); + }); + + it('substitutes a new var using only the convention', () => { + const result = substituteWorkflowConnectorIds( + 'jiraConnectorId: REPLACE_WITH_JIRA_CONNECTOR_ID', + { + jira_connector_id: 'jira-conn-1', + } + ); + expect(result).toBe('jiraConnectorId: jira-conn-1'); + }); + + it('joins multi-value roadmap folder IDs for workflow substitution', () => { + const result = substituteWorkflowConnectorIds( + 'roadmapFolderIds: REPLACE_WITH_GDRIVE_ROADMAP_FOLDER_IDS', + { + gdrive_roadmap_folder_ids: 'folder-a, folder-b', + } + ); + + expect(result).toContain('roadmapFolderIds: folder-a, folder-b'); + }); + + it('trims leading and trailing whitespace from string vars', () => { + const result = substituteWorkflowConnectorIds('orgLogin: REPLACE_WITH_ORG_LOGIN', { + org_login: ' my-org ', + }); + expect(result).toBe('orgLogin: my-org'); + }); + + it('trims whitespace around array elements and joins with commas', () => { + const result = substituteWorkflowConnectorIds( + 'roadmapFolderIds: REPLACE_WITH_GDRIVE_ROADMAP_FOLDER_IDS', + { + gdrive_roadmap_folder_ids: [' folder-a ', 'folder-b'], + } + ); + expect(result).toBe('roadmapFolderIds: folder-a,folder-b'); + }); + + it('drops empty or whitespace-only array elements', () => { + const result = substituteWorkflowConnectorIds( + 'roadmapFolderIds: REPLACE_WITH_GDRIVE_ROADMAP_FOLDER_IDS', + { + gdrive_roadmap_folder_ids: ['folder-a', '', ' ', 'folder-b'], + } + ); + expect(result).toBe('roadmapFolderIds: folder-a,folder-b'); + }); + + it('leaves placeholder untouched when string var is whitespace-only', () => { + const result = substituteWorkflowConnectorIds('orgLogin: REPLACE_WITH_ORG_LOGIN', { + org_login: ' ', + }); + expect(result).toBe('orgLogin: REPLACE_WITH_ORG_LOGIN'); + }); + + it('substitutes the longest matching placeholder even when a shorter one is a prefix', () => { + const result = substituteWorkflowConnectorIds('orgLogin: REPLACE_WITH_ORG_LOGIN', { + org: 'SHORT', + org_login: 'my-org', + }); + expect(result).toBe('orgLogin: my-org'); + }); + + it('is independent of vars insertion order for prefix placeholders', () => { + const result = substituteWorkflowConnectorIds('orgLogin: REPLACE_WITH_ORG_LOGIN', { + org_login: 'my-org', + org: 'SHORT', + }); + expect(result).toBe('orgLogin: my-org'); + }); + + it('leaves placeholders when vars are missing', () => { + const result = substituteWorkflowConnectorIds(sampleYaml, {}); + + expect(result).toContain('REPLACE_WITH_ORG_LOGIN'); + expect(result).toContain('REPLACE_WITH_GITHUB_CONNECTOR_ID'); + expect(result).toContain('REPLACE_WITH_SLACK_CONNECTOR_ID'); + expect(result).toContain('REPLACE_WITH_SALESFORCE_CONNECTOR_ID'); + expect(result).toContain('REPLACE_WITH_SALESFORCE_CASE_GITHUB_FIELD'); + expect(result).toContain('REPLACE_WITH_SALESFORCE_PRODUCT_AREA_FIELD'); + expect(result).toContain('REPLACE_WITH_SDH_REPO_PATTERN'); + expect(result).toContain('REPLACE_WITH_SDH_LABEL'); + expect(result).toContain('REPLACE_WITH_GOOGLE_DRIVE_CONNECTOR_ID'); + expect(result).toContain('REPLACE_WITH_GDRIVE_ROADMAP_FOLDER_IDS'); + expect(result).toContain('REPLACE_WITH_AI_CONNECTOR_ID'); + }); + + it('warns when a placeholder has no matching var', () => { + substituteWorkflowConnectorIds('orgLogin: REPLACE_WITH_ORG_LOGIN', {}, mockLogger); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Workflow placeholder REPLACE_WITH_ORG_LOGIN has no matching package policy var' + ); + }); + + it('does not warn when all placeholders are resolved', () => { + substituteWorkflowConnectorIds( + 'orgLogin: REPLACE_WITH_ORG_LOGIN', + { org_login: 'my-org' }, + mockLogger + ); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('warns only once per unique unresolved placeholder', () => { + substituteWorkflowConnectorIds( + 'a: REPLACE_WITH_ORG_LOGIN\nb: REPLACE_WITH_ORG_LOGIN', + {}, + mockLogger + ); + + expect(mockLogger.warn).toHaveBeenCalledTimes(1); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Workflow placeholder REPLACE_WITH_ORG_LOGIN has no matching package policy var' + ); + }); + + it('does not warn about placeholders that are not Fleet vars', () => { + const result = substituteWorkflowConnectorIds( + 'custom: REPLACE_WITH_CUSTOM_PLACEHOLDER', + {}, + mockLogger + ); + + expect(result).toContain('REPLACE_WITH_CUSTOM_PLACEHOLDER'); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Workflow placeholder REPLACE_WITH_CUSTOM_PLACEHOLDER has no matching package policy var' + ); + }); + + it('substitutes a second, non-SDLC fixture package using only the convention', () => { + const fixtureYaml = ` +config: + jiraConnectorId: REPLACE_WITH_JIRA_CONNECTOR_ID + confluenceConnectorId: REPLACE_WITH_CONFLUENCE_CONNECTOR_ID + projectKeys: REPLACE_WITH_JIRA_PROJECT_KEYS +`; + + const result = substituteWorkflowConnectorIds(fixtureYaml, { + jira_connector_id: 'jira-conn-1', + confluence_connector_id: 'confluence-conn-2', + jira_project_keys: ['PROJ', 'TEAM'], + }); + + expect(result).toBe(` +config: + jiraConnectorId: jira-conn-1 + confluenceConnectorId: confluence-conn-2 + projectKeys: PROJ,TEAM +`); + }); + + it('does not resolve runtime Liquid policy.vars (install-time is the supported path)', () => { + const yaml = [ + 'consts:', + ' githubConnectorId: "{{ policy.vars.github_connector_id }}"', + ' githubConnectorIdStatic: REPLACE_WITH_GITHUB_CONNECTOR_ID', + ].join('\n'); + + const result = substituteWorkflowConnectorIds(yaml, { + github_connector_id: 'github-conn-1', + }); + + expect(result).toContain('githubConnectorId: "{{ policy.vars.github_connector_id }}"'); + expect(result).toContain('githubConnectorIdStatic: github-conn-1'); + }); + + it('fails the non-SDLC fixture test if the convention logic is removed', () => { + // This is a deliberate break guard: if substitution stops using the convention, + // the second fixture package stops substituting and the test fails. + const fixtureYaml = ` +config: + jiraConnectorId: REPLACE_WITH_JIRA_CONNECTOR_ID +`; + + const result = substituteWorkflowConnectorIds(fixtureYaml, { + jira_connector_id: 'jira-conn-1', + }); + + expect(result).not.toContain('REPLACE_WITH_JIRA_CONNECTOR_ID'); + expect(result).toContain('jiraConnectorId: jira-conn-1'); + }); +}); + +describe('resolveWorkflowEnabledIntent', () => { + it('returns true when default_enabled is true', () => { + expect(resolveWorkflowEnabledIntent(true, 'any.yaml')).toBe(true); + }); + + it('returns false when default_enabled is false', () => { + expect(resolveWorkflowEnabledIntent(false, 'any.yaml')).toBe(false); + }); + + it('returns undefined when default_enabled is absent', () => { + expect(resolveWorkflowEnabledIntent(undefined, 'any.yaml')).toBeUndefined(); + }); + + it('returns true for files listed in the array and false otherwise', () => { + expect(resolveWorkflowEnabledIntent(['enabled.yaml'], 'enabled.yaml')).toBe(true); + expect(resolveWorkflowEnabledIntent(['enabled.yaml'], 'disabled.yaml')).toBe(false); + }); +}); + +describe('stepInstallWorkflowAssets', () => { + const pkgName = 'test-package'; + const pkgVersion = '1.2.3'; + const spaceId = DEFAULT_SPACE_ID; + const workflowFileName = 'my-workflow.yaml'; + const workflowId = 'fleet-default-test-package-my-workflow'; + const workflowYaml = `name: my-workflow\nenabled: true\nsteps: []`; + + let workflowsManagementSetupMock: ReturnType; + let savedObjectsClient: ReturnType; + + beforeEach(() => { + savedObjectsClient = savedObjectsClientMock.create(); + workflowsManagementSetupMock = createWorkflowsManagementSetupMock(); + appContextService.start(createAppContextStartContractMock()); + appContextService.setWorkflowsManagementSetup(workflowsManagementSetupMock); + jest.mocked(saveKibanaAssetsRefs).mockReset(); + }); + + afterEach(() => { + appContextService.stop(); + }); + + const createContext = ( + overrides: Record = {} + ): StepInstallWorkflowAssetsParam => ({ + logger: loggingSystemMock.createLogger(), + savedObjectsClient, + spaceId, + request: httpServerMock.createKibanaRequest(), + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + title: pkgName, + owner: { github: 'elastic/fleet' }, + format_version: '1.0.0', + description: 'test package', + }, + paths: [`${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`], + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(workflowYaml), + ], + ]) + ), + }, + ...overrides, + }); + + it('installs dependency workflows before dependents even when archive order is reversed', async () => { + const upstreamFile = 'catalog.yaml'; + const downstreamFile = 'enrich.yaml'; + const context = createContext({ + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + workflows: { + default_enabled: true, + dependencies: { [downstreamFile]: [upstreamFile] }, + }, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${downstreamFile}`, + Buffer.from('name: enrich\nenabled: false\nsteps: []'), + ], + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${upstreamFile}`, + Buffer.from('name: catalog\nenabled: false\nsteps: []'), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: 'fleet-default-test-package-catalog' }), + spaceId, + expect.anything() + ); + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: 'fleet-default-test-package-enrich' }), + spaceId, + expect.anything() + ); + }); + + it('creates workflow assets when request context is missing', async () => { + const logger = loggingSystemMock.createLogger(); + const context = createContext({ + logger, + request: undefined, + }); + + await stepInstallWorkflowAssets(context); + + expect(logger.debug).toHaveBeenCalledWith( + `Installing workflow assets for ${pkgName} using Fleet internal request (no install request context)` + ); + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenCalledWith( + { id: workflowId, yaml: expect.any(String) }, + spaceId, + expect.objectContaining({ isFakeRequest: true, isSystemRequest: true }) + ); + }); + + it('creates a workflow and stamps managed ownership fields', async () => { + await stepInstallWorkflowAssets(createContext()); + + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenCalledWith( + { id: workflowId, yaml: expect.any(String) }, + spaceId, + expect.anything() + ); + + expect(workflowsManagementSetupMock.management.updateWorkflow).toHaveBeenCalledWith( + workflowId, + expect.objectContaining({ + yaml: expect.any(String), + managed: true, + managedBy: pkgName, + managedVersion: null, + }), + spaceId, + expect.anything(), + { allowManagedWorkflowMutation: true } + ); + }); + + it('reinstalls a managed workflow without throwing ManagedWorkflowUpdateForbiddenError', async () => { + workflowsManagementSetupMock.management.getWorkflow.mockResolvedValue({ + id: workflowId, + managed: true, + name: workflowId, + enabled: true, + createdAt: '2024-01-01T00:00:00Z', + createdBy: 'test-user', + lastUpdatedAt: '2024-01-01T00:00:00Z', + lastUpdatedBy: 'test-user', + definition: null, + yaml: workflowYaml, + valid: true, + }); + + await stepInstallWorkflowAssets(createContext()); + + expect(workflowsManagementSetupMock.management.updateWorkflow).toHaveBeenCalledWith( + workflowId, + expect.objectContaining({ + yaml: expect.any(String), + managed: true, + managedBy: pkgName, + managedVersion: null, + }), + spaceId, + expect.anything(), + { allowManagedWorkflowMutation: true } + ); + }); + + it('FLEET-012: carries forward a placeholder embedded inline, not as a key/value pair', async () => { + // Real case: the Salesforce workflow interpolates the field name inside a SOQL + // string, so there is no `key: value` line to anchor the carry-forward on. + const shipped = + `name: my-workflow\nenabled: true\nsteps:\n` + + ` - with:\n soql: SELECT Id, REPLACE_WITH_SALESFORCE_PRODUCT_AREA_FIELD FROM Case\n`; + const installed = + `name: my-workflow\nenabled: true\nsteps:\n` + + ` - with:\n soql: SELECT Id, Product_Area__c FROM Case\n`; + + workflowsManagementSetupMock.management.getWorkflow.mockResolvedValue({ + id: workflowId, + managed: true, + name: workflowId, + enabled: true, + createdAt: '2024-01-01T00:00:00Z', + createdBy: 'test-user', + lastUpdatedAt: '2024-01-01T00:00:00Z', + lastUpdatedBy: 'test-user', + definition: null, + yaml: installed, + valid: true, + }); + + const context = createContext({ + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [`${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, Buffer.from(shipped)], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + const updateCall = + workflowsManagementSetupMock.management.updateWorkflow.mock.calls.find( + (call: unknown[]) => (call[1] as { yaml?: string })?.yaml !== undefined + ); + expect(updateCall).toBeDefined(); + const updatedYaml = (updateCall![1] as { yaml: string }).yaml; + expect(updatedYaml).toContain('Product_Area__c'); + expect(updatedYaml).not.toContain('REPLACE_WITH_SALESFORCE_PRODUCT_AREA_FIELD'); + }); + + it('FLEET-012: preserves an operator-enabled workflow across a package upgrade', async () => { + // The package ships this workflow disabled (connectors absent by default), but the + // operator enabled it on their deployment. An upgrade must not silently disable it. + const shippedDisabled = `name: my-workflow\nenabled: false\nsteps: []`; + const operatorEnabled = `name: my-workflow\nenabled: true\nsteps: []`; + + workflowsManagementSetupMock.management.getWorkflow.mockResolvedValue({ + id: workflowId, + managed: true, + name: workflowId, + enabled: true, + createdAt: '2024-01-01T00:00:00Z', + createdBy: 'test-user', + lastUpdatedAt: '2024-01-01T00:00:00Z', + lastUpdatedBy: 'test-user', + definition: null, + yaml: operatorEnabled, + valid: true, + }); + + const context = createContext({ + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(shippedDisabled), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + const updateCall = + workflowsManagementSetupMock.management.updateWorkflow.mock.calls.find( + (call: unknown[]) => (call[1] as { yaml?: string })?.yaml !== undefined + ); + expect(updateCall).toBeDefined(); + const updatedYaml = (updateCall![1] as { yaml: string }).yaml; + expect(updatedYaml).toContain('enabled: true'); + }); + + it('FLEET-012: preserves an operator-resolved connector id across a package upgrade', async () => { + // The archive ships REPLACE_WITH_* placeholders. Once an operator resolves them + // (deploy, policy vars), that value is policy-driven state: an upgrade that + // overwrites yaml wholesale silently reverts live workflows to placeholders and + // force-disables them, which is what the DoD forbids. + const resolvedYaml = [ + 'name: my-workflow', + 'enabled: true', + 'connectorId: real-github-connector-id', + 'steps: []', + ].join('\n'); + + workflowsManagementSetupMock.management.getWorkflow.mockResolvedValue({ + id: workflowId, + managed: true, + name: workflowId, + enabled: true, + createdAt: '2024-01-01T00:00:00Z', + createdBy: 'test-user', + lastUpdatedAt: '2024-01-01T00:00:00Z', + lastUpdatedBy: 'test-user', + definition: null, + yaml: resolvedYaml, + valid: true, + }); + + // the archive ships the placeholder; no package policy var resolves it + const shippedYaml = `name: my-workflow\nenabled: true\nconnectorId: REPLACE_WITH_GITHUB_CONNECTOR_ID\nsteps: []`; + await stepInstallWorkflowAssets( + createContext({ + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(shippedYaml), + ], + ]) + ), + }, + }) + ); + + const updateCall = + workflowsManagementSetupMock.management.updateWorkflow.mock.calls.at(-1); + expect(updateCall).toBeDefined(); + const updatedYaml = (updateCall![1] as { yaml: string }).yaml; + expect(updatedYaml).toContain('real-github-connector-id'); + expect(updatedYaml).not.toContain('REPLACE_WITH'); + }); + + it('warns and forces disabled when unresolved placeholders exist and default_enabled is true', async () => { + const logger = loggingSystemMock.createLogger(); + const unresolvedPlaceholder = 'REPLACE_WITH_JIRA_CONNECTOR_ID'; + const workflowWithPlaceholder = `name: my-workflow\nenabled: true\nconnectorId: ${unresolvedPlaceholder}\nsteps: []`; + const context = createContext({ + logger, + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + workflows: { default_enabled: true }, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(workflowWithPlaceholder), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + `Workflow ${workflowId} has unresolved placeholders [${unresolvedPlaceholder}] — forcing disabled` + ) + ); + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ yaml: expect.stringContaining('enabled: false') }), + spaceId, + expect.anything() + ); + }); + + it('does not warn about forcing disabled when unresolved placeholders exist but default_enabled is undefined', async () => { + const logger = loggingSystemMock.createLogger(); + const unresolvedPlaceholder = 'REPLACE_WITH_JIRA_CONNECTOR_ID'; + const workflowWithPlaceholder = `name: my-workflow\nenabled: true\nconnectorId: ${unresolvedPlaceholder}\nsteps: []`; + const context = createContext({ + logger, + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(workflowWithPlaceholder), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('forcing disabled')); + }); + + it('preserves step-level enabled values when applying default_enabled', async () => { + const logger = loggingSystemMock.createLogger(); + const context = createContext({ + logger, + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + workflows: { default_enabled: true }, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(workflowYaml), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + yaml: expect.stringContaining('enabled: true'), + }), + spaceId, + expect.anything() + ); + }); + + it('uses default_enabled false when no unresolved placeholders exist', async () => { + const logger = loggingSystemMock.createLogger(); + const context = createContext({ + logger, + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + workflows: { default_enabled: false }, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(workflowYaml), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('forcing disabled')); + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ yaml: expect.stringContaining('enabled: false') }), + spaceId, + expect.anything() + ); + }); + + it('uses default_enabled true when no unresolved placeholders exist', async () => { + const logger = loggingSystemMock.createLogger(); + const context = createContext({ + logger, + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + workflows: { default_enabled: true }, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(workflowYaml), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + expect(logger.warn).not.toHaveBeenCalledWith(expect.stringContaining('forcing disabled')); + expect(workflowsManagementSetupMock.management.createWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ yaml: expect.stringContaining('enabled: true') }), + spaceId, + expect.anything() + ); + }); + + it('updates a managed workflow with the resolved enablement intent', async () => { + const logger = loggingSystemMock.createLogger(); + workflowsManagementSetupMock.management.getWorkflow.mockResolvedValue({ + id: workflowId, + managed: true, + name: workflowId, + enabled: true, + createdAt: '2024-01-01T00:00:00Z', + createdBy: 'test-user', + lastUpdatedAt: '2024-01-01T00:00:00Z', + lastUpdatedBy: 'test-user', + definition: null, + yaml: workflowYaml, + valid: true, + }); + + const context = createContext({ + logger, + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + workflows: { default_enabled: false }, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(workflowYaml), + ], + ]) + ), + }, + }); + + await stepInstallWorkflowAssets(context); + + expect(workflowsManagementSetupMock.management.updateWorkflow).toHaveBeenCalledWith( + workflowId, + expect.objectContaining({ yaml: expect.stringContaining('enabled: false') }), + spaceId, + expect.anything(), + { allowManagedWorkflowMutation: true } + ); + }); + + it('rejects enabled scheduled-trigger workflows installed with no request context', async () => { + const logger = loggingSystemMock.createLogger(); + const scheduledWorkflowYaml = `name: my-workflow +enabled: true +triggers: + - type: scheduled + with: + every: 5m +steps: []`; + + workflowsManagementSetupMock.management.createWorkflow.mockRejectedValue( + new Error('Unable to clone an API key, request does not contain an authorization header') + ); + + const context = createContext({ + logger, + request: undefined, + packageInstallContext: { + packageInfo: { + name: pkgName, + version: pkgVersion, + workflows: { default_enabled: true }, + }, + archiveIterator: createArchiveIteratorFromMap( + new Map([ + [ + `${pkgName}-${pkgVersion}/kibana/workflow/${workflowFileName}`, + Buffer.from(scheduledWorkflowYaml), + ], + ]) + ), + }, + }); + + await expect(stepInstallWorkflowAssets(context)).rejects.toThrow( + 'Unable to clone an API key, request does not contain an authorization header' + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.ts new file mode 100644 index 0000000000000..27304570215c7 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/step_install_workflow_assets.ts @@ -0,0 +1,579 @@ +/* + * 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 path from 'path'; + +import pMap from 'p-map'; +import { parse, stringify } from 'yaml'; + +import type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; + +import { KibanaAssetType, KibanaSavedObjectType } from '../../../../../../common/types'; +import type { KibanaAssetReference } from '../../../../../../common/types'; +import { getPathParts } from '../../../archive'; +import { appContextService } from '../../../../app_context'; +import { packagePolicyService } from '../../../../package_policy'; +import { createFleetInternalRequest } from '../../../../security/fake_request'; +import { saveKibanaAssetsRefs } from '../../install'; +import { withPackageSpan } from '../../utils'; +import type { InstallContext } from '../_state_machine_package_install'; + +const VAR_PLACEHOLDER_PREFIX = 'REPLACE_WITH_'; + +const formatManifestVarForSubstitution = (value: unknown): string | undefined => { + if (Array.isArray(value)) { + const joined = value + .map((item) => String(item).trim()) + .filter((item) => item.length > 0) + .join(','); + return joined.length > 0 ? joined : undefined; + } + + if (typeof value === 'string' && value.trim().length > 0) { + return value.trim(); + } + + return undefined; +}; + +const getPlaceholderForVarName = (varName: string): string => + `${VAR_PLACEHOLDER_PREFIX}${varName.toUpperCase()}`; + +/** + * Install-time connector/agent placeholder substitution (WF-008). + * Runtime Liquid `{{ policy.vars.* }}` is not a supported resolution path — + * see workflow_connector_resolution.md. + */ +export const substituteWorkflowConnectorIds = ( + yaml: string, + vars: Record, + logger?: Logger +): string => { + const { yaml: result } = substituteWorkflowConnectorIdsWithUnresolved(yaml, vars, logger); + return result; +}; + +export const substituteWorkflowConnectorIdsWithUnresolved = ( + yaml: string, + vars: Record, + logger?: Logger +): { yaml: string; unresolved: string[] } => { + let result = yaml; + + const substitutions = Object.entries(vars) + .map(([varName, value]): [string, string | undefined] => [ + getPlaceholderForVarName(varName), + formatManifestVarForSubstitution(value), + ]) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .sort(([a], [b]) => b.length - a.length); + + for (const [placeholder, formatted] of substitutions) { + result = result.replaceAll(placeholder, formatted); + } + + const placeholderRegex = new RegExp(`${VAR_PLACEHOLDER_PREFIX}[A-Z0-9_]+`, 'g'); + const remaining = [...result.matchAll(placeholderRegex)].map((match) => match[0]); + const unresolved = [...new Set(remaining)]; + + if (logger) { + for (const placeholder of unresolved) { + logger.warn(`Workflow placeholder ${placeholder} has no matching package policy var`); + } + } + + return { yaml: result, unresolved }; +}; + +/** + * FLEET-012: carry forward values an operator already resolved. + * + * The archive always ships `REPLACE_WITH_*` placeholders. Package policy vars + * resolve them at install, but an operator can also resolve them out of band + * (deploy scripts, direct edits). Overwriting yaml wholesale on upgrade reverts + * those live workflows to placeholders and force-disables them, destroying + * policy-driven state the upgrade contract promises to preserve. + * + * For each placeholder still unresolved in the incoming yaml, reuse the value at + * the same key in the currently-installed workflow. Only placeholders are filled: + * every other line of the new version is applied as shipped, so genuine + * definition changes still land. + */ +export const carryForwardResolvedPlaceholders = ( + incomingYaml: string, + existingYaml: string, + logger?: Logger +): { yaml: string; carried: string[] } => { + const placeholderRegex = new RegExp(`${VAR_PLACEHOLDER_PREFIX}[A-Z0-9_]+`, 'g'); + const unresolved = [...new Set([...incomingYaml.matchAll(placeholderRegex)].map((m) => m[0]))]; + if (unresolved.length === 0 || !existingYaml) { + return { yaml: incomingYaml, carried: [] }; + } + let result = incomingYaml; + const carried: string[] = []; + for (const placeholder of unresolved) { + // find `: ` in the incoming yaml, then read the same key + // from the installed yaml. Anchoring on the key avoids guessing at values. + const keyMatch = new RegExp(`^(\\s*)([\\w.-]+):\\s*["']?${placeholder}["']?\\s*$`, 'm').exec( + result + ); + if (!keyMatch) { + continue; + } + const key = keyMatch[2]; + const existingMatch = new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm').exec(existingYaml); + const existingValue = existingMatch?.[1]?.trim().replace(/^["']|["']$/g, ''); + if (!existingValue || existingValue.startsWith(VAR_PLACEHOLDER_PREFIX)) { + continue; + } + result = result.replaceAll(placeholder, existingValue); + carried.push(placeholder); + } + // A placeholder can also sit inline (inside a SOQL string, an expression, a URL) + // rather than as a `key: value` pair. There is no key to anchor on, so diff the + // two documents line by line: when the only difference between the shipped line + // and the installed line is the placeholder, the installed line already holds the + // operator-resolved value. + const stillUnresolved = [...new Set([...result.matchAll(placeholderRegex)].map((m) => m[0]))]; + if (stillUnresolved.length) { + const existingLines = existingYaml.split('\n'); + for (const placeholder of stillUnresolved) { + const shippedLines = result.split('\n'); + let resolvedValue: string | undefined; + for (const shippedLine of shippedLines) { + if (!shippedLine.includes(placeholder)) { + continue; + } + const [prefix, suffix] = shippedLine.split(placeholder, 2); + const candidate = existingLines.find( + (line) => line.startsWith(prefix) && line.endsWith(suffix) && !line.includes(placeholder) + ); + if (candidate) { + resolvedValue = candidate.slice(prefix.length, candidate.length - suffix.length); + break; + } + } + if (resolvedValue) { + result = result.replaceAll(placeholder, resolvedValue); + carried.push(placeholder); + } + } + } + if (carried.length && logger) { + logger.debug( + `Carried forward operator-resolved values for [${carried.join(', ')}] on upgrade` + ); + } + return { yaml: result, carried }; +}; + +export const resolvePackagePolicyConnectorVars = async ( + savedObjectsClient: SavedObjectsClientContract, + pkgName: string +): Promise> => { + try { + const policies = await packagePolicyService.list(savedObjectsClient, { + perPage: 20, + kuery: `ingest-package-policies.package.name:${pkgName}`, + }); + const policy = policies.items.find((item) => item.package?.name === pkgName); + if (!policy?.vars) { + return {}; + } + + return Object.fromEntries( + Object.entries(policy.vars).map(([key, config]) => [key, config.value ?? config]) + ); + } catch { + return {}; + } +}; + +/** + * Workflow IDs must match human-readable slug rules (lowercase alnum + hyphens only). + * Fleet package names often contain underscores (for example `sdlc_intel`). + */ +export const normalizeFleetPackageAssetIdSegment = (segment: string): string => + segment.toLowerCase().replace(/_/g, '-'); + +export const getFleetPackageWorkflowId = (params: { + pkgName: string; + spaceId: string; + fileName: string; +}): string => { + const baseName = params.fileName.replace(/\.ya?ml$/i, ''); + return `fleet-${normalizeFleetPackageAssetIdSegment( + params.spaceId + )}-${normalizeFleetPackageAssetIdSegment(params.pkgName)}-${baseName}`; +}; + +const FLEET_AGENT_PLACEHOLDER_PREFIX = 'REPLACE_WITH_FLEET_AGENT_'; + +/** + * Resolve `REPLACE_WITH_FLEET_AGENT_*` placeholders to deterministic fleet agent + * ids (AB-006). + * + * When `installedAgentIds` is supplied, a placeholder whose resolved id was not + * installed by this package is reported as unresolved and left in place rather + * than substituted. Substituting it anyway would mint a well-formed but dangling + * `fleet-*` id: install succeeds and the workflow only fails later, at run time, + * with a 404 from the agent API. Callers force such workflows disabled, matching + * the connector placeholder policy. + * + * Resolved ids are always confined to the `fleet-` namespace by + * `getFleetPackageWorkflowId`, so a placeholder cannot address an arbitrary + * user-created agent. + */ +export const substituteFleetAgentIdsWithUnresolved = ( + yaml: string, + params: { pkgName: string; spaceId: string; installedAgentIds?: string[] } +): { yaml: string; unresolved: string[] } => { + let result = yaml; + const unresolved: string[] = []; + const known = params.installedAgentIds ? new Set(params.installedAgentIds) : undefined; + const placeholderRegex = new RegExp(`${FLEET_AGENT_PLACEHOLDER_PREFIX}([a-z0-9_-]+)`, 'gi'); + const matches = yaml.matchAll(placeholderRegex); + + for (const match of matches) { + const fileBase = match[1]; + const agentId = getFleetPackageWorkflowId({ + pkgName: params.pkgName, + spaceId: params.spaceId, + fileName: `${fileBase}.yaml`, + }); + + if (known && !known.has(agentId)) { + if (!unresolved.includes(match[0])) { + unresolved.push(match[0]); + } + continue; + } + + result = result.replaceAll(match[0], agentId); + } + + return { yaml: result, unresolved }; +}; + +export const substituteFleetAgentIds = ( + yaml: string, + params: { pkgName: string; spaceId: string; installedAgentIds?: string[] } +): string => substituteFleetAgentIdsWithUnresolved(yaml, params).yaml; + +interface WorkflowEntry { + fileName: string; + yaml: string; +} + +const normalizeWorkflowFileName = (fileName: string): string => + fileName.endsWith('.yaml') || fileName.endsWith('.yml') ? fileName : `${fileName}.yaml`; + +/** + * Order package workflow assets so every declared dependency is installed before + * its dependent. Reject invalid graphs before creating any saved object. + */ +export const orderWorkflowEntriesByDependencies = ( + entries: WorkflowEntry[], + dependencies: Record = {} +): WorkflowEntry[] => { + const byName = new Map(entries.map((entry) => [entry.fileName, entry])); + const visiting = new Set(); + const visited = new Set(); + const ordered: WorkflowEntry[] = []; + + const visit = (fileName: string, chain: string[]): void => { + if (visited.has(fileName)) return; + if (visiting.has(fileName)) { + throw new Error(`Workflow dependency cycle: ${[...chain, fileName].join(' -> ')}`); + } + const entry = byName.get(fileName); + if (!entry) { + throw new Error(`Workflow dependency references missing asset "${fileName}"`); + } + + visiting.add(fileName); + for (const dependency of dependencies[fileName] ?? []) { + visit(normalizeWorkflowFileName(dependency), [...chain, fileName]); + } + visiting.delete(fileName); + visited.add(fileName); + ordered.push(entry); + }; + + for (const dependencyOwner of Object.keys(dependencies)) { + const normalizedOwner = normalizeWorkflowFileName(dependencyOwner); + if (!byName.has(normalizedOwner)) { + throw new Error(`Workflow dependencies declared for missing asset "${normalizedOwner}"`); + } + } + entries.forEach(({ fileName }) => visit(fileName, [])); + return ordered; +}; + +export async function stepInstallWorkflowAssets( + context: Pick< + InstallContext, + 'logger' | 'savedObjectsClient' | 'packageInstallContext' | 'spaceId' | 'request' + > & { installAsAdditionalSpace?: boolean } +) { + const { logger, savedObjectsClient, packageInstallContext, spaceId, installAsAdditionalSpace } = + context; + const { packageInfo } = packageInstallContext; + const { name: pkgName } = packageInfo; + const workflowsApi = appContextService.getWorkflowsManagementSetup()?.management; + + if (!workflowsApi) { + logger.debug( + `Skipping workflow asset installation for ${pkgName}: workflowsManagement unavailable` + ); + return; + } + + const request = context.request ?? createFleetInternalRequest(); + + if (!context.request) { + logger.debug( + `Installing workflow assets for ${pkgName} using Fleet internal request (no install request context)` + ); + } + + await withPackageSpan(`Install package workflows for ${pkgName}`, async () => { + const workflowEntries: WorkflowEntry[] = []; + + await packageInstallContext.archiveIterator.traverseEntries( + async (entry) => { + if (!entry.buffer) { + return; + } + + workflowEntries.push({ + fileName: path.basename(entry.path), + yaml: entry.buffer.toString('utf8'), + }); + }, + (entryPath) => { + const parts = getPathParts(entryPath); + return parts.service === 'kibana' && parts.type === KibanaAssetType.workflow; + } + ); + + if (workflowEntries.length === 0) { + return; + } + + const connectorVars = await resolvePackagePolicyConnectorVars(savedObjectsClient, pkgName); + + const assetRefs: KibanaAssetReference[] = []; + + const orderedWorkflowEntries = orderWorkflowEntriesByDependencies( + workflowEntries, + packageInfo.workflows?.dependencies + ); + + // AB-006: the set of agent ids this package actually installs. Placeholders + // that do not resolve into this set are left unsubstituted and the workflow + // is forced disabled, rather than shipping a dangling fleet-* agent id. + const installedAgentIds: string[] = []; + await packageInstallContext.archiveIterator.traverseEntries( + async (entry) => { + // Directory entries carry no file segment; skip them rather than + // deriving an id from undefined. + const { file: fileName } = getPathParts(entry.path); + if (!fileName) { + return; + } + installedAgentIds.push(getFleetPackageWorkflowId({ pkgName, spaceId, fileName })); + }, + (entryPath) => { + const parts = getPathParts(entryPath); + return parts.service === 'kibana' && parts.type === KibanaAssetType.agent; + } + ); + + await pMap( + orderedWorkflowEntries, + async ({ fileName, yaml }) => { + const workflowId = getFleetPackageWorkflowId({ pkgName, spaceId, fileName }); + const { yaml: substitutedYaml, unresolved } = substituteWorkflowConnectorIdsWithUnresolved( + yaml, + connectorVars, + logger + ); + const { yaml: agentSubstitutedYaml, unresolved: unresolvedAgents } = + substituteFleetAgentIdsWithUnresolved(substitutedYaml, { + pkgName, + spaceId, + installedAgentIds, + }); + let workflowYaml = agentSubstitutedYaml; + const allUnresolved = [...unresolved, ...unresolvedAgents]; + + const workflowDefinition = parse(workflowYaml) as { + enabled?: boolean; + steps?: Array<{ enabled?: boolean }>; + }; + const resolvedIntent = resolveWorkflowEnabledIntent( + packageInfo.workflows?.default_enabled, + fileName + ); + + if (resolvedIntent && allUnresolved.length > 0) { + logger.warn( + `Workflow ${workflowId} has unresolved placeholders [${allUnresolved.join( + ', ' + )}] — forcing disabled` + ); + workflowDefinition.enabled = false; + } else if (resolvedIntent !== undefined) { + workflowDefinition.enabled = resolvedIntent; + } + + workflowYaml = stringify(workflowDefinition); + + const existingWorkflow = await workflowsApi.getWorkflow(workflowId, spaceId); + + const managedWorkflowFields = { + managed: true, + managedBy: pkgName, + managedVersion: null, + }; + + if (existingWorkflow) { + // FLEET-012: Preserve user-disabled state across upgrades. + // If the user explicitly disabled a managed workflow, don't re-enable it. + const existingYaml = existingWorkflow.yaml ?? ''; + // FLEET-012: reuse values the operator already resolved, so an upgrade + // cannot revert a live workflow to REPLACE_WITH_* placeholders. + const { yaml: carriedYaml, carried } = carryForwardResolvedPlaceholders( + workflowYaml, + existingYaml, + logger + ); + if (carried.length) { + workflowYaml = carriedYaml; + const carriedDefinition = parse(workflowYaml) as { enabled?: boolean }; + // Re-resolving removes the unresolved-placeholder disable reason. + if (resolvedIntent !== undefined && carriedDefinition.enabled === false) { + carriedDefinition.enabled = resolvedIntent; + workflowYaml = stringify(carriedDefinition); + } + workflowDefinition.enabled = (parse(workflowYaml) as { enabled?: boolean }).enabled; + } + const existingParsed = parse(existingYaml) as { enabled?: boolean }; + if (existingParsed.enabled === false && workflowDefinition.enabled !== false) { + logger.debug( + `Workflow ${workflowId} was disabled by user — preserving disabled state on upgrade` + ); + workflowDefinition.enabled = false; + workflowYaml = stringify(workflowDefinition); + } else if ( + existingParsed.enabled === true && + workflowDefinition.enabled === false && + resolvedIntent === undefined && + allUnresolved.length === 0 + ) { + // Preservation has to be symmetric. A package ships workflows disabled when + // it cannot know which connectors a deployment has; the operator enables the + // ones that work. Re-disabling those on upgrade silently stops live ingest + // while dashboards keep rendering stale data. Only honour the operator's + // enablement when every placeholder resolved — an unresolved workflow must + // still be forced off. An explicit manifest intent (`default_enabled`, or an + // allowlist entry) always wins: that is the package author deliberately + // setting policy, not an upgrade silently dropping operator state. + logger.debug( + `Workflow ${workflowId} was enabled by user — preserving enabled state on upgrade` + ); + workflowDefinition.enabled = true; + workflowYaml = stringify(workflowDefinition); + } + + await workflowsApi.updateWorkflow( + workflowId, + { yaml: workflowYaml, ...managedWorkflowFields }, + spaceId, + request, + { allowManagedWorkflowMutation: true } + ); + } else { + await workflowsApi.createWorkflow( + { id: workflowId, yaml: workflowYaml }, + spaceId, + request + ); + await workflowsApi.updateWorkflow( + workflowId, + { yaml: workflowYaml, ...managedWorkflowFields }, + spaceId, + request, + { allowManagedWorkflowMutation: true } + ); + } + + assetRefs.push({ + id: workflowId, + type: KibanaSavedObjectType.workflow, + }); + }, + // Dependency order is load-bearing: enabling a downstream scheduled workflow + // before its prerequisites are installed can produce empty or partial results. + { concurrency: 1 } + ); + + await saveKibanaAssetsRefs( + savedObjectsClient, + pkgName, + assetRefs, + spaceId, + installAsAdditionalSpace, + true + ); + // FLEET-012: Reconcile removed workflows — delete managed workflows that + // belong to this package but are no longer in the new archive. + const newAssetIds = new Set(assetRefs.map((r) => r.id)); + const managedWorkflowPrefix = `${spaceId ?? 'default'}-${pkgName}-`; + try { + const finder = savedObjectsClient.createPointInTimeFinder({ + type: KibanaSavedObjectType.workflow, + filter: `${KibanaSavedObjectType.workflow}.attributes.managed: true AND ${KibanaSavedObjectType.workflow}.attributes.managedBy: ${pkgName}`, + perPage: 100, + }); + for await (const soPage of finder.find()) { + for (const so of soPage.saved_objects) { + if (so.id.startsWith(managedWorkflowPrefix) && !newAssetIds.has(so.id)) { + logger.info( + `FLEET-012: removing orphaned workflow ${so.id} (no longer in package archive)` + ); + await savedObjectsClient.delete(KibanaSavedObjectType.workflow, so.id); + } + } + } + await finder.close(); + } catch (err) { + logger.warn(`FLEET-012: failed to reconcile removed workflows: ${err}`); + } + }); +} + +export const resolveWorkflowEnabledIntent = ( + defaultEnabled: boolean | string[] | undefined, + fileName: string +): boolean | undefined => { + if (defaultEnabled === undefined) { + return undefined; + } + + if (typeof defaultEnabled === 'boolean') { + return defaultEnabled; + } + + if (Array.isArray(defaultEnabled)) { + return defaultEnabled.includes(fileName); + } + + return undefined; +}; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/workflow_connector_resolution.md b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/workflow_connector_resolution.md new file mode 100644 index 0000000000000..41973d9f709ef --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/install_state_machine/steps/workflow_connector_resolution.md @@ -0,0 +1,48 @@ +# Workflow connector-id / agent-id resolution + +WF-008 chooses **install-time substitution** as the supported path. +Runtime Liquid (`{{ policy.vars.* }}`) is not implemented and must not be used +for connector-id or agent-id. + +## Decision + +Package workflow YAML uses `REPLACE_WITH_` placeholders. Fleet replaces +them from package-policy vars: + +- at package install (`stepInstallWorkflowAssets`) +- again when package-policy vars change (`updateWorkflowAssets`) + +After substitution the saved workflow contains concrete ids. Execution-time +Liquid still interpolates workflow `consts` / `steps` / `inputs`; it does not +read live Fleet policy vars. + +## Why not runtime Liquid + +1. FLEET-004 already ships install-time substitution plus re-apply on policy + update, which covers connector rotation without a new execution-time secret + surface. +2. Runtime `policy.vars` would require the execution engine to depend on Fleet + policy state on every run, including scheduled runs with no request context. +3. Leaving both paths undocumented would let package authors mix static consts + and live policy lookups and get non-deterministic connector binding. + +## Alignment with FLEET-004 + +- `substituteWorkflowConnectorIds` maps `varName` → `REPLACE_WITH_${VARNAME}`. +- Unresolved placeholders force the workflow disabled at install. +- Policy updates re-run substitution against the original package asset YAML, + not against previously substituted consts. + +## Authoring rule + +```yaml +consts: + githubConnectorId: REPLACE_WITH_GITHUB_CONNECTOR_ID +``` + +Not: + +```yaml +consts: + githubConnectorId: "{{ policy.vars.github_connector_id }}" +``` diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.test.ts new file mode 100644 index 0000000000000..c08dee3d68568 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.test.ts @@ -0,0 +1,264 @@ +/* + * 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 { + hasPackagePolicyVarsChanged, + reapplyPackageWorkflowAssetsOnVarChange, +} from './reapply_assets_on_var_change'; +import { getInstallationObject } from './get'; +import * as Registry from '../registry'; +import { getEsPackage } from '../archive/storage'; +import { stepInstallWorkflowAssets } from './install_state_machine/steps/step_install_workflow_assets'; +import { stepInstallAgentAssets } from './install_state_machine/steps/step_install_agent_assets'; +import { KibanaSavedObjectType } from '../../../types'; + +jest.mock('./get'); +jest.mock('../registry'); +jest.mock('../archive/storage'); +jest.mock('./install_state_machine/steps/step_install_workflow_assets'); +jest.mock('./install_state_machine/steps/step_install_agent_assets'); + +const mockGetInstallationObject = getInstallationObject as jest.MockedFunction< + typeof getInstallationObject +>; +const mockGetPackage = Registry.getPackage as jest.MockedFunction; +const mockGetEsPackage = getEsPackage as jest.MockedFunction; +const mockStepInstallWorkflowAssets = stepInstallWorkflowAssets as jest.MockedFunction< + typeof stepInstallWorkflowAssets +>; +const mockStepInstallAgentAssets = stepInstallAgentAssets as jest.MockedFunction< + typeof stepInstallAgentAssets +>; + +const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +} as any; + +const savedObjectsClient = {} as any; + +const PACKAGE_ASSET_REFS = [{ id: 'asset-1', type: 'epm-packages-assets' }] as any; + +const storedAssetsMap = new Map([ + ['sdlc_intel-0.1.0/manifest.yml', Buffer.from('name: sdlc_intel')], +]); + +const storedEsPackage = { + packageInfo: { name: 'sdlc_intel', version: '0.1.0' }, + paths: ['sdlc_intel-0.1.0/manifest.yml'], + assetsMap: storedAssetsMap, +}; + +const registryArchive = { + packageInfo: { name: 'sdlc_intel', version: '0.1.0' }, + paths: ['/srv/sdlc_intel-0.1.0'], + archiveIterator: { kind: 'registry-archive-iterator' }, +}; + +const installationWith = ( + installedKibana: Array<{ id: string; type: string }>, + packageAssets?: unknown +) => + ({ + id: 'sdlc_intel', + attributes: { + name: 'sdlc_intel', + version: '0.1.0', + installed_kibana: installedKibana, + installed_kibana_space_id: 'default', + package_assets: packageAssets, + }, + } as any); + +const WORKFLOW_AND_AGENT_ASSETS = [ + { id: 'wf-1', type: KibanaSavedObjectType.workflow }, + { id: 'agent-1', type: KibanaSavedObjectType.agent }, +]; + +describe('hasPackagePolicyVarsChanged', () => { + it('returns false when vars are unchanged', () => { + const vars = { connector: { value: 'conn-1' } }; + expect(hasPackagePolicyVarsChanged(vars, { ...vars })).toBe(false); + }); + + it('returns false when both are undefined', () => { + expect(hasPackagePolicyVarsChanged(undefined, undefined)).toBe(false); + }); + + it('returns true when a var value changes', () => { + expect( + hasPackagePolicyVarsChanged( + { connector: { value: 'conn-1' } }, + { connector: { value: 'conn-2' } } + ) + ).toBe(true); + }); + + it('returns true when a var is added or removed', () => { + expect( + hasPackagePolicyVarsChanged({ a: { value: 1 } }, { a: { value: 1 }, b: { value: 2 } }) + ).toBe(true); + expect(hasPackagePolicyVarsChanged(undefined, { a: { value: 1 } })).toBe(true); + }); +}); + +describe('reapplyPackageWorkflowAssetsOnVarChange', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetPackage.mockResolvedValue(registryArchive as any); + mockGetEsPackage.mockResolvedValue(storedEsPackage as any); + }); + + it('no-ops when the package is not installed', async () => { + mockGetInstallationObject.mockResolvedValue(undefined as any); + + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: 'sdlc_intel', + savedObjectsClient, + logger, + }); + + expect(mockGetEsPackage).not.toHaveBeenCalled(); + expect(mockGetPackage).not.toHaveBeenCalled(); + expect(mockStepInstallWorkflowAssets).not.toHaveBeenCalled(); + expect(mockStepInstallAgentAssets).not.toHaveBeenCalled(); + }); + + it('no-ops when the package ships no workflow/agent assets', async () => { + mockGetInstallationObject.mockResolvedValue( + installationWith([{ id: 'dash-1', type: KibanaSavedObjectType.dashboard }], PACKAGE_ASSET_REFS) + ); + + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: 'sdlc_intel', + savedObjectsClient, + logger, + }); + + expect(mockGetEsPackage).not.toHaveBeenCalled(); + expect(mockGetPackage).not.toHaveBeenCalled(); + expect(mockStepInstallWorkflowAssets).not.toHaveBeenCalled(); + expect(mockStepInstallAgentAssets).not.toHaveBeenCalled(); + }); + + // Regression: a package installed from an upload or bundled source is NOT + // resolvable from the remote registry. Resolving via the registry made the + // live hook fail with "sdlc_intel@0.1.0 not found" and silently skip the + // re-apply for exactly the locally-installed packages this hook serves. + it('resolves the archive Fleet stored at install time, not the remote registry', async () => { + mockGetInstallationObject.mockResolvedValue( + installationWith(WORKFLOW_AND_AGENT_ASSETS, PACKAGE_ASSET_REFS) + ); + + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: 'sdlc_intel', + savedObjectsClient, + logger, + }); + + expect(mockGetEsPackage).toHaveBeenCalledWith( + 'sdlc_intel', + '0.1.0', + PACKAGE_ASSET_REFS, + savedObjectsClient + ); + expect(mockGetPackage).not.toHaveBeenCalled(); + expect(mockStepInstallWorkflowAssets).toHaveBeenCalledTimes(1); + }); + + // Regression: getEsPackage returns an assetsMap, not an archiveIterator. + // Passing it through unadapted made the live hook fail with + // "Cannot read properties of undefined (reading 'traverseEntries')". + it('adapts the stored assetsMap into a usable archiveIterator', async () => { + mockGetInstallationObject.mockResolvedValue( + installationWith(WORKFLOW_AND_AGENT_ASSETS, PACKAGE_ASSET_REFS) + ); + + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: 'sdlc_intel', + savedObjectsClient, + logger, + }); + + const context = mockStepInstallWorkflowAssets.mock.calls[0][0] as any; + const { archiveIterator } = context.packageInstallContext; + + expect(archiveIterator).toBeDefined(); + expect(typeof archiveIterator.traverseEntries).toBe('function'); + + const seen: string[] = []; + await archiveIterator.traverseEntries(async ({ path }: { path: string }) => { + seen.push(path); + }); + expect(seen).toEqual(['sdlc_intel-0.1.0/manifest.yml']); + }); + + // Regression: workflow asset installation clones an API key and therefore + // needs real credentials. Passing the credential-less internal request made + // the live hook fail with "Unable to clone an API key, request does not + // contain an authorization header". + it('forwards the authenticated request that triggered the vars change', async () => { + mockGetInstallationObject.mockResolvedValue( + installationWith(WORKFLOW_AND_AGENT_ASSETS, PACKAGE_ASSET_REFS) + ); + const request = { id: 'real-authenticated-request' } as any; + + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: 'sdlc_intel', + savedObjectsClient, + logger, + request, + }); + + expect(mockStepInstallWorkflowAssets).toHaveBeenCalledWith( + expect.objectContaining({ request }) + ); + expect(mockStepInstallAgentAssets).toHaveBeenCalledWith(expect.objectContaining({ request })); + }); + + it('re-applies both workflow and agent steps for the installed space', async () => { + mockGetInstallationObject.mockResolvedValue( + installationWith(WORKFLOW_AND_AGENT_ASSETS, PACKAGE_ASSET_REFS) + ); + + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: 'sdlc_intel', + savedObjectsClient, + logger, + }); + + expect(mockStepInstallWorkflowAssets).toHaveBeenCalledTimes(1); + expect(mockStepInstallAgentAssets).toHaveBeenCalledTimes(1); + expect(mockStepInstallWorkflowAssets).toHaveBeenCalledWith( + expect.objectContaining({ spaceId: 'default', savedObjectsClient, logger }) + ); + }); + + it('falls back to the registry when no stored assets are recorded', async () => { + mockGetInstallationObject.mockResolvedValue( + installationWith(WORKFLOW_AND_AGENT_ASSETS, undefined) + ); + + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: 'sdlc_intel', + savedObjectsClient, + logger, + }); + + expect(mockGetEsPackage).not.toHaveBeenCalled(); + expect(mockGetPackage).toHaveBeenCalledWith('sdlc_intel', '0.1.0', { useStreaming: true }); + expect(mockStepInstallWorkflowAssets).toHaveBeenCalledWith( + expect.objectContaining({ + packageInstallContext: expect.objectContaining({ + archiveIterator: registryArchive.archiveIterator, + }), + }) + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.ts new file mode 100644 index 0000000000000..e0fecdde69bd8 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reapply_assets_on_var_change.ts @@ -0,0 +1,138 @@ +/* + * 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, SavedObjectsClientContract } from '@kbn/core/server'; +import { DEFAULT_SPACE_ID } from '@kbn/core-spaces-common'; + +import { KibanaSavedObjectType } from '../../../types'; + +import * as Registry from '../registry'; +import { getEsPackage } from '../archive/storage'; +import { createArchiveIteratorFromMap } from '../archive/archive_iterator'; + +import { createFleetInternalRequest } from '../../security/fake_request'; + +import { stepInstallAgentAssets } from './install_state_machine/steps/step_install_agent_assets'; +import { stepInstallWorkflowAssets } from './install_state_machine/steps/step_install_workflow_assets'; +import { getInstallationObject } from './get'; + +/** + * FLEET-004: detect whether a package policy's vars changed between revisions. + * + * Vars are an ordered map of name -> { value, ... }; a JSON comparison is sufficient to + * detect any meaningful change a user could make through the update API. + */ +export const hasPackagePolicyVarsChanged = ( + oldVars: Record | undefined, + newVars: Record | undefined +): boolean => JSON.stringify(oldVars ?? {}) !== JSON.stringify(newVars ?? {}); + +/** + * FLEET-004: re-apply workflow/agent placeholder substitution for an installed package + * after one of its package policies' vars changed (e.g. a rotated connector id), so the + * already-installed workflow/agent assets are updated in place without a full reinstall. + * + * The install steps re-read the current package policy vars themselves via + * `resolvePackagePolicyConnectorVars`, so re-invoking them after the updated policy has + * been persisted applies the new values. Reuses the createOrUpdate path, which preserves + * operator-managed edits (carry-forward) for any value the new vars no longer resolve. + * + * No-ops for packages that do not ship workflow/agent assets. Callers must catch failures + * so a re-apply error never breaks the package policy update itself. + */ +export const reapplyPackageWorkflowAssetsOnVarChange = async ({ + pkgName, + savedObjectsClient, + logger, + request, +}: { + pkgName: string; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; + /** + * The authenticated request that triggered the vars change, when there is one. + * Workflow asset installation clones an API key and therefore needs real + * credentials; the credential-less internal request only works for callers + * that run outside an HTTP context. + */ + request?: KibanaRequest; +}): Promise => { + const installationSO = await getInstallationObject({ savedObjectsClient, pkgName }); + if (!installationSO) { + logger.debug(`FLEET-004 re-apply: ${pkgName} is not installed, skipping`); + return; + } + const installation = installationSO.attributes; + + const shipsWorkflowOrAgentAssets = installation.installed_kibana?.some( + (asset) => + asset.type === KibanaSavedObjectType.workflow || asset.type === KibanaSavedObjectType.agent + ); + if (!shipsWorkflowOrAgentAssets) { + logger.debug( + `FLEET-004 re-apply: ${pkgName} ships no workflow/agent assets, skipping substitution` + ); + return; + } + + // Prefer the archive Fleet stored at install time. Packages installed from an + // upload or a bundled source are not resolvable from the remote registry, and + // a registry lookup for them fails with "@ not found" — which + // would silently skip the re-apply for exactly the locally-installed packages + // this hook exists to serve. Fall back to the registry only when no stored + // assets are recorded. + let packageInstallContext; + const packageAssetRefs = installation.package_assets; + + if (packageAssetRefs?.length) { + const esPackage = await getEsPackage( + pkgName, + installation.version, + packageAssetRefs, + savedObjectsClient + ); + if (esPackage) { + // getEsPackage returns an assetsMap, not an archiveIterator; adapt it the + // same way the install-assets route does. + packageInstallContext = { + packageInfo: esPackage.packageInfo, + paths: esPackage.paths, + archiveIterator: createArchiveIteratorFromMap(esPackage.assetsMap), + }; + } + } + + if (!packageInstallContext) { + const { packageInfo, paths, archiveIterator } = await Registry.getPackage( + pkgName, + installation.version, + { useStreaming: true } + ); + packageInstallContext = { packageInfo, paths, archiveIterator }; + } + const spaceId = installation.installed_kibana_space_id ?? DEFAULT_SPACE_ID; + const effectiveRequest = request ?? createFleetInternalRequest(); + + await stepInstallWorkflowAssets({ + logger, + savedObjectsClient, + packageInstallContext, + spaceId, + request: effectiveRequest, + }); + await stepInstallAgentAssets({ + logger, + savedObjectsClient, + packageInstallContext, + spaceId, + request: effectiveRequest, + }); + + logger.info( + `FLEET-004: re-applied workflow/agent assets for ${pkgName} after a package policy vars change` + ); +}; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reinstall.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reinstall.ts index 994f771d16a1f..8b781d100e117 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reinstall.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/reinstall.ts @@ -5,7 +5,11 @@ * 2.0. */ -import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; +import type { + ElasticsearchClient, + KibanaRequest, + SavedObjectsClientContract, +} from '@kbn/core/server'; import { DEFAULT_SPACE_ID } from '@kbn/core-spaces-common'; import type { Installation } from '../../../types'; @@ -21,10 +25,12 @@ export async function reinstallPackageForInstallation({ soClient, esClient, installation, + request, }: { soClient: SavedObjectsClientContract; esClient: ElasticsearchClient; installation: Installation; + request?: KibanaRequest; }) { if (installation.install_source === 'upload' || installation.install_source === 'bundled') { // If there is a matching bundled package @@ -50,6 +56,7 @@ export async function reinstallPackageForInstallation({ }), esClient, spaceId: installation.installed_kibana_space_id || DEFAULT_SPACE_ID, + request, // Force install the package will update the index template and the datastream write indices force: true, }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.test.ts index c851b15c683e4..02c96a69c14b1 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.test.ts @@ -7,6 +7,7 @@ import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { ElasticsearchAssetType, PACKAGES_SAVED_OBJECT_TYPE } from '../../../../common'; +import { KibanaSavedObjectType } from '../../../types'; import { packagePolicyService, appContextService } from '../..'; import { auditLoggingService } from '../../audit_logging'; @@ -16,6 +17,7 @@ import { removeInstallation, cleanupAssets, cleanupDependenciesStep, + deleteKibanaAssets, } from './remove'; import { deletePackageKnowledgeBase } from './knowledge_base_index'; import { getInstallation } from './get'; @@ -506,3 +508,85 @@ describe('cleanupAssets', () => { }); }); }); + +describe('deleteKibanaAssets: package workflow/agent/skill uninstall', () => { + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as any; + const soClient = { + find: jest.fn().mockResolvedValue({ saved_objects: [] }), + bulkResolve: jest.fn().mockResolvedValue({ resolved_objects: [] }), + delete: jest.fn(), + } as any; + const deleteWorkflows = jest.fn().mockResolvedValue(undefined); + const deletePackageManagedAgent = jest.fn().mockResolvedValue(undefined); + const deletePackageManagedSkill = jest.fn().mockResolvedValue(undefined); + + beforeEach(() => { + deleteWorkflows.mockClear(); + deletePackageManagedAgent.mockClear(); + deletePackageManagedSkill.mockClear(); + (appContextService as any).getSavedObjects = jest.fn().mockReturnValue({ + getUnsafeInternalClient: jest.fn().mockReturnValue(soClient), + }); + (appContextService as any).getWorkflowsManagementSetup = jest.fn().mockReturnValue({ + management: { deleteWorkflows }, + }); + (appContextService as any).getAgentBuilderSetup = jest.fn().mockReturnValue({ + management: { deletePackageManagedAgent, deletePackageManagedSkill }, + }); + }); + + it('deletes all workflow assets via workflowsManagement with force', async () => { + await deleteKibanaAssets({ + installedObjects: [ + { id: 'wf-1', type: KibanaSavedObjectType.workflow }, + { id: 'wf-2', type: KibanaSavedObjectType.workflow }, + ], + logger, + spaceId: 'default', + }); + expect(deleteWorkflows).toHaveBeenCalledTimes(1); + expect(deleteWorkflows).toHaveBeenCalledWith( + ['wf-1', 'wf-2'], + 'default', + expect.anything(), + { force: true } + ); + }); + + it('deletes agent and skill assets via agentBuilder per asset', async () => { + await deleteKibanaAssets({ + installedObjects: [ + { id: 'agent-1', type: KibanaSavedObjectType.agent }, + { id: 'skill-1', type: KibanaSavedObjectType.skill }, + ], + logger, + spaceId: 'default', + }); + expect(deletePackageManagedAgent).toHaveBeenCalledTimes(1); + expect(deletePackageManagedAgent).toHaveBeenCalledWith('agent-1', 'default'); + expect(deletePackageManagedSkill).toHaveBeenCalledTimes(1); + expect(deletePackageManagedSkill).toHaveBeenCalledWith('skill-1', 'default'); + }); + + it('skips gracefully without throwing when management APIs are unavailable', async () => { + (appContextService as any).getWorkflowsManagementSetup = jest.fn().mockReturnValue(undefined); + (appContextService as any).getAgentBuilderSetup = jest.fn().mockReturnValue(undefined); + await expect( + deleteKibanaAssets({ + installedObjects: [ + { id: 'wf-1', type: KibanaSavedObjectType.workflow }, + { id: 'agent-1', type: KibanaSavedObjectType.agent }, + ], + logger, + spaceId: 'default', + }) + ).resolves.toBeUndefined(); + expect(deleteWorkflows).not.toHaveBeenCalled(); + expect(deletePackageManagedAgent).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.ts b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.ts index 8e7caed9d73d0..367decfe4622b 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/epm/packages/remove.ts @@ -53,11 +53,13 @@ import { FleetError, PackageRemovalError } from '../../../errors'; import { populatePackagePolicyAssignedAgentsCount } from '../../package_policies/populate_package_policy_assigned_agents_count'; import { deleteEsqlViews } from '../elasticsearch/esql_views/remove'; +import { deleteIndexAliases } from '../elasticsearch/index_alias/remove'; import type { PackageSpecConditions } from '../../../../common'; import { getInstallation, getPackageInfo, kibanaSavedObjectTypes } from '.'; import { updateUninstallFailedAttempts } from './uninstall_errors_helpers'; import { deletePackageKnowledgeBase } from './knowledge_base_index'; +import { createFleetInternalRequest } from '../../security'; const MAX_ASSETS_TO_DELETE = 1000; @@ -250,6 +252,71 @@ export async function deleteKibanaAssets({ logger.debug(`Deleting Kibana assets in namespace: ${namespace}`); } + const workflowAssets = installedObjects.filter( + (asset) => asset.type === KibanaSavedObjectType.workflow + ); + const agentAssets = installedObjects.filter( + (asset) => asset.type === KibanaSavedObjectType.agent + ); + const skillAssets = installedObjects.filter( + (asset) => asset.type === KibanaSavedObjectType.skill + ); + const savedObjectAssets = installedObjects.filter( + (asset) => + asset.type !== KibanaSavedObjectType.workflow && asset.type !== KibanaSavedObjectType.agent + ); + + const workflowsApi = appContextService.getWorkflowsManagementSetup()?.management; + if (workflowAssets.length > 0 && workflowsApi) { + const workflowIds = workflowAssets.map((asset) => asset.id); + logger.debug(`Deleting ${workflowIds.length} workflow assets via workflowsManagement`); + try { + await workflowsApi.deleteWorkflows(workflowIds, spaceId, createFleetInternalRequest(), { + force: true, + }); + } catch (err) { + logger.warn(`Failed to delete workflow assets: ${err}`); + } + } else if (workflowAssets.length > 0) { + logger.debug( + `Skipping deletion of ${workflowAssets.length} workflow assets: workflowsManagement unavailable` + ); + } + + const agentBuilderApi = appContextService.getAgentBuilderSetup()?.management; + if (agentAssets.length > 0 && agentBuilderApi) { + logger.debug(`Deleting ${agentAssets.length} agent assets via agentBuilder`); + for (const asset of agentAssets) { + try { + await agentBuilderApi.deletePackageManagedAgent(asset.id, spaceId); + } catch (err) { + logger.warn(`Failed to delete agent asset ${asset.id}: ${err}`); + } + } + } else if (agentAssets.length > 0) { + logger.debug( + `Skipping deletion of ${agentAssets.length} agent assets: agentBuilder unavailable` + ); + } + + if (skillAssets.length > 0 && agentBuilderApi) { + logger.debug(`Deleting ${skillAssets.length} skill assets via agentBuilder`); + for (const asset of skillAssets) { + try { + await agentBuilderApi.deletePackageManagedSkill(asset.id, spaceId); + } catch (err) { + logger.warn(`Failed to delete skill asset ${asset.id}: ${err}`); + } + } + } else if (skillAssets.length > 0) { + logger.debug( + `Skipping deletion of ${skillAssets.length} skill assets: agentBuilder unavailable` + ); + } + if (savedObjectAssets.length === 0) { + return; + } + const minKibana = packageSpecConditions?.kibana?.version ? minVersion(packageSpecConditions.kibana.version) : null; @@ -259,10 +326,10 @@ export async function deleteKibanaAssets({ // and delete the assets directly. Otherwise, we need to resolve the assets // which might create high memory pressure if a package has a lot of assets. if (minKibana && minKibana.major >= 8) { - await bulkDeleteSavedObjects(installedObjects, namespace, savedObjectsClient, logger); + await bulkDeleteSavedObjects(savedObjectAssets, namespace, savedObjectsClient, logger); } else { const { resolved_objects: resolvedObjects } = await savedObjectsClient.bulkResolve( - installedObjects, + savedObjectAssets, { namespace } ); @@ -334,6 +401,8 @@ export const deleteESAsset = async ( return deleteMlModel(esClient, [id]); } else if (assetType === ElasticsearchAssetType.esqlView) { return deleteEsqlViews(esClient, [id]); + } else if (assetType === ElasticsearchAssetType.indexAlias) { + return deleteIndexAliases(esClient, [id]); } }; @@ -606,6 +675,16 @@ export function cleanupEsqlViews( return deleteEsqlViews(esClient, idsToDelete); } +export function cleanupIndexAliases( + installedObjects: EsAssetReference[], + esClient: ElasticsearchClient +) { + const idsToDelete = installedObjects + .filter((asset) => asset.type === ElasticsearchAssetType.indexAlias) + .map((asset) => asset.id); + return deleteIndexAliases(esClient, idsToDelete); +} + /** * This function deletes assets for a given installation and updates the package SO accordingly. * diff --git a/x-pack/platform/plugins/shared/fleet/server/services/index.ts b/x-pack/platform/plugins/shared/fleet/server/services/index.ts index 5b4f059a53ec4..db78a27dd2eaf 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/index.ts @@ -22,7 +22,6 @@ export interface AgentPolicyServiceInterface { delete: (typeof agentPolicyService)['delete']; getFullAgentPolicy: (typeof agentPolicyService)['getFullAgentPolicy']; getByIds: (typeof agentPolicyService)['getByIds']; - bumpRevision: (typeof agentPolicyService)['bumpRevision']; turnOffAgentTamperProtections: (typeof agentPolicyService)['turnOffAgentTamperProtections']; fetchAllAgentPolicyIds: (typeof agentPolicyService)['fetchAllAgentPolicyIds']; fetchAllAgentPolicies: (typeof agentPolicyService)['fetchAllAgentPolicies']; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.test.ts new file mode 100644 index 0000000000000..f83e6a0403a87 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.test.ts @@ -0,0 +1,182 @@ +/* + * 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, savedObjectsClientMock } from '@kbn/core/server/mocks'; + +import { appContextService } from '../app_context'; +import { updateWorkflowAssets } from './update_workflow_assets'; + +const mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + fatal: jest.fn(), + log: jest.fn(), + isLevelEnabled: jest.fn(), + get: jest.fn().mockReturnValue({ + warn: jest.fn(), + debug: jest.fn(), + info: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + fatal: jest.fn(), + log: jest.fn(), + isLevelEnabled: jest.fn(), + get: jest.fn(), + }), +} as any; + +describe('updateWorkflowAssets', () => { + const createSoClient = () => { + const soClient = savedObjectsClientMock.create(); + soClient.getCurrentNamespace.mockReturnValue('default'); + return soClient; + }; + + const createManagementApi = (overrides: any = {}) => ({ + getWorkflow: jest.fn(), + updateWorkflow: jest.fn(), + ...overrides, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('replaces stale connector ids in installed workflow assets', async () => { + const soClient = createSoClient(); + const updateWorkflow = jest.fn(); + const getWorkflow = jest.fn().mockResolvedValue({ id: 'existing' }); + + jest.spyOn(appContextService, 'getWorkflowsManagementSetup').mockReturnValue({ + management: createManagementApi({ getWorkflow, updateWorkflow }), + } as any); + + const assetsMap = new Map([ + [ + 'sdlc_intel-1.0.0/kibana/workflow/sync_issues.yaml', + Buffer.from(` +consts: + githubConnectorId: REPLACE_WITH_GITHUB_CONNECTOR_ID +`), + ], + ]); + + await updateWorkflowAssets({ + savedObjectsClient: soClient, + packageInfo: { name: 'sdlc_intel', version: '1.0.0' } as any, + assetsMap, + vars: { github_connector_id: 'new-github-conn' }, + request: httpServerMock.createKibanaRequest(), + logger: mockLogger, + }); + + expect(updateWorkflow).toHaveBeenCalledTimes(1); + const [id, partial, spaceId] = updateWorkflow.mock.calls[0]; + expect(id).toBe('fleet-default-sdlc-intel-sync_issues'); + expect(spaceId).toBe('default'); + expect(partial.yaml).toContain('githubConnectorId: new-github-conn'); + expect(partial.yaml).not.toContain('REPLACE_WITH_GITHUB_CONNECTOR_ID'); + }); + + it('silently skips when the workflow does not exist (install owns creation)', async () => { + const soClient = createSoClient(); + const updateWorkflow = jest.fn(); + const getWorkflow = jest.fn().mockResolvedValue(null); + + jest.spyOn(appContextService, 'getWorkflowsManagementSetup').mockReturnValue({ + management: createManagementApi({ getWorkflow, updateWorkflow }), + } as any); + + const assetsMap = new Map([ + [ + 'sdlc_intel-1.0.0/kibana/workflow/sync_issues.yaml', + Buffer.from(` +consts: + githubConnectorId: REPLACE_WITH_GITHUB_CONNECTOR_ID +`), + ], + ]); + + await updateWorkflowAssets({ + savedObjectsClient: soClient, + packageInfo: { name: 'sdlc_intel', version: '1.0.0' } as any, + assetsMap, + vars: { github_connector_id: 'new-github-conn' }, + request: httpServerMock.createKibanaRequest(), + logger: mockLogger, + }); + + expect(updateWorkflow).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('workflow does not exist') + ); + }); + + it('silently skips when workflowsManagement is unavailable', async () => { + const soClient = createSoClient(); + jest.spyOn(appContextService, 'getWorkflowsManagementSetup').mockReturnValue(undefined); + + await updateWorkflowAssets({ + savedObjectsClient: soClient, + packageInfo: { name: 'sdlc_intel', version: '1.0.0' } as any, + assetsMap: new Map(), + vars: {}, + request: httpServerMock.createKibanaRequest(), + logger: mockLogger, + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('workflowsManagement unavailable') + ); + }); + + it('substitutes fleet agent placeholders with workflow ids', async () => { + const soClient = createSoClient(); + const updateWorkflow = jest.fn(); + const getWorkflow = jest.fn().mockResolvedValue({ id: 'existing' }); + + jest.spyOn(appContextService, 'getWorkflowsManagementSetup').mockReturnValue({ + management: createManagementApi({ getWorkflow, updateWorkflow }), + } as any); + + const assetsMap = new Map([ + [ + 'sdlc_intel-1.0.0/kibana/workflow/parent.yaml', + Buffer.from(` +steps: + - agent: REPLACE_WITH_FLEET_AGENT_CHILD +`), + ], + [ + 'sdlc_intel-1.0.0/kibana/workflow/child.yaml', + Buffer.from(` +consts: + value: child-workflow +`), + ], + ]); + + await updateWorkflowAssets({ + savedObjectsClient: soClient, + packageInfo: { name: 'sdlc_intel', version: '1.0.0' } as any, + assetsMap, + vars: {}, + request: httpServerMock.createKibanaRequest(), + logger: mockLogger, + }); + + expect(updateWorkflow).toHaveBeenCalledTimes(2); + const parentCall = updateWorkflow.mock.calls.find( + ([id]) => id === 'fleet-default-sdlc-intel-parent' + ); + expect(parentCall).toBeDefined(); + expect(parentCall[1].yaml).toContain('agent: fleet-default-sdlc-intel-CHILD'); + }); +}); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.ts new file mode 100644 index 0000000000000..bcf74a5fbba47 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policies/update_workflow_assets.ts @@ -0,0 +1,82 @@ +/* + * 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 path from 'path'; + +import pMap from 'p-map'; + +import type { KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; +import type { Logger } from '@kbn/logging'; +import { DEFAULT_SPACE_ID } from '@kbn/core-spaces-common'; + +import type { AssetsMap, PackageInfo } from '../../../common/types'; +import { KibanaAssetType } from '../../../common/types'; +import { getPathParts } from '../epm/archive'; +import { appContextService } from '../app_context'; +import { + getFleetPackageWorkflowId, + substituteFleetAgentIds, + substituteWorkflowConnectorIds, +} from '../epm/packages/install_state_machine/steps/step_install_workflow_assets'; + +export interface UpdateWorkflowAssetsOptions { + savedObjectsClient: SavedObjectsClientContract; + packageInfo: PackageInfo; + assetsMap: AssetsMap; + vars: Record; + request: KibanaRequest; + logger: Logger; +} + +export async function updateWorkflowAssets(options: UpdateWorkflowAssetsOptions): Promise { + const { savedObjectsClient, packageInfo, assetsMap, vars, request, logger } = options; + const pkgName = packageInfo.name; + const spaceId = savedObjectsClient.getCurrentNamespace() ?? DEFAULT_SPACE_ID; + + const workflowsApi = appContextService.getWorkflowsManagementSetup()?.management; + + if (!workflowsApi) { + logger.debug(`Skipping workflow asset update for ${pkgName}: workflowsManagement unavailable`); + return; + } + + const workflowEntries = [...assetsMap.entries()].flatMap(([assetPath, buffer]) => { + if (!buffer) { + return []; + } + + const parts = getPathParts(assetPath); + if (parts.service !== 'kibana' || parts.type !== KibanaAssetType.workflow) { + return []; + } + + return [{ fileName: path.basename(assetPath), yaml: buffer.toString('utf8') }]; + }); + + if (workflowEntries.length === 0) { + return; + } + + await pMap( + workflowEntries, + async ({ fileName, yaml }) => { + const workflowId = getFleetPackageWorkflowId({ pkgName, spaceId, fileName }); + let workflowYaml = substituteWorkflowConnectorIds(yaml, vars, logger); + workflowYaml = substituteFleetAgentIds(workflowYaml, { pkgName, spaceId }); + + const existingWorkflow = await workflowsApi.getWorkflow(workflowId, spaceId); + + if (!existingWorkflow) { + logger.debug(`Skipping workflow asset update for ${workflowId}: workflow does not exist`); + return; + } + + await workflowsApi.updateWorkflow(workflowId, { yaml: workflowYaml }, spaceId, request); + }, + { concurrency: 3 } + ); +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts index e7d5787481542..dbea7cb088a99 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.test.ts @@ -2239,49 +2239,6 @@ describe('Package policy service', () => { }); describe('getByIDs', () => { - it('should request only the specified package policy fields', async () => { - const soClient = createSavedObjectClientMock(); - soClient.bulkGet.mockResolvedValueOnce({ - saved_objects: [ - { - id: 'test-package-policy', - version: 'WzEsMV0=', - attributes: { - condition: "'agent.id' == 'agent-1'", - policy_ids: ['agent-policy-1'], - revision: 2, - }, - references: [], - type: LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE, - }, - ], - }); - - const fields = ['condition', 'policy_ids', 'revision']; - const result = await packagePolicyService.getByIDs(soClient, ['test-package-policy'], { - fields, - }); - - expect(soClient.bulkGet).toHaveBeenCalledWith([ - { - id: 'test-package-policy', - type: LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE, - fields, - namespaces: undefined, - }, - ]); - expect(result).toEqual([ - expect.objectContaining({ - id: 'test-package-policy', - version: 'WzEsMV0=', - condition: "'agent.id' == 'agent-1'", - policy_ids: ['agent-policy-1'], - revision: 2, - }), - ]); - expect(result[0]).not.toHaveProperty('name'); - }); - it('should call audit logger', async () => { const soClient = createSavedObjectClientMock(); soClient.bulkGet.mockResolvedValueOnce({ @@ -6291,21 +6248,6 @@ describe('Package policy service', () => { }); }); - it('does not bump associated agent policies when bumpRevision is false', async () => { - const savedObjectsClient = createSavedObjectClientMock(); - setupSOClientMocks(savedObjectsClient); - const elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - await packagePolicyService.bulkUpdate( - savedObjectsClient, - elasticsearchClient, - testedPackagePolicies, - { force: true, bumpRevision: false } - ); - - expect(mockAgentPolicyService.bumpRevision).not.toHaveBeenCalled(); - }); - it('should remove protections if policy_ids is changed, only affected policies', async () => { const savedObjectsClient = createSavedObjectClientMock(); @@ -6758,39 +6700,6 @@ describe('Package policy service', () => { ); }); - it('should not bump associated agent policies when bumpRevision is false', async () => { - const soClient = createSavedObjectClientMock(); - const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; - - soClient.bulkGet.mockResolvedValue({ - saved_objects: [ - { - id: 'test', - type: 'abcd', - references: [], - version: 'test', - attributes: createPackagePolicyMock(), - }, - ], - }); - soClient.get.mockResolvedValueOnce({ ...mockPackagePolicy }); - mockAgentPolicyGet(); - mockAgentPolicyService.bumpRevision.mockClear(); - - const idToDelete = 'c6d16e42-c32d-4dce-8a88-113cfe276ad1'; - soClient.bulkDelete.mockResolvedValue({ - statuses: [ - { id: idToDelete, type: LEGACY_PACKAGE_POLICY_SAVED_OBJECT_TYPE, success: true }, - ], - }); - - await packagePolicyService.delete(soClient, esClient, [idToDelete], { - bumpRevision: false, - }); - - expect(mockAgentPolicyService.bumpRevision).not.toHaveBeenCalled(); - }); - it('should allow to delete orphaned package policies from ES index', async () => { const soClient = createSavedObjectClientMock(); const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts index ccd7e20d9583c..c1b53648deaa8 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy.ts @@ -170,6 +170,10 @@ import { getAuthzFromRequest, doesNotHaveRequiredFleetAuthz } from './security'; import { agentPolicyService, getAgentPolicySavedObjectType } from './agent_policy'; import { getPackageInfo, ensureInstalledPackage, getInstallationObject } from './epm/packages'; +import { + hasPackagePolicyVarsChanged, + reapplyPackageWorkflowAssetsOnVarChange, +} from './epm/packages/reapply_assets_on_var_change'; import { getAssetsDataFromAssetsMap } from './epm/packages/assets'; import { compileTemplate, @@ -1643,7 +1647,8 @@ class PackagePolicyClientImpl implements PackagePolicyClient { bumpRevision?: boolean; asyncDeploy?: boolean; }, - context?: RequestHandlerContext + context?: RequestHandlerContext, + request?: KibanaRequest ): Promise { const logger = this.getLogger('update'); @@ -1908,6 +1913,25 @@ class PackagePolicyClientImpl implements PackagePolicyClient { const newPolicy = (await this.get(soClient, id)) as PackagePolicy; + // FLEET-004: when a package policy's vars change (e.g. a rotated connector id), re-apply + // workflow/agent placeholder substitution so the already-installed assets update in place + // without a full reinstall. Failures are logged and never break the package policy update. + if ( + oldPackagePolicy.package && + hasPackagePolicyVarsChanged(oldPackagePolicy.vars, restOfPackagePolicy.vars) + ) { + await reapplyPackageWorkflowAssetsOnVarChange({ + pkgName: oldPackagePolicy.package.name, + savedObjectsClient: soClient, + logger, + request, + }).catch((error) => { + logger.warn( + `FLEET-004: failed to re-apply workflow/agent assets for ${oldPackagePolicy.package?.name} after package policy ${id} vars change: ${error?.message}` + ); + }); + } + // if we have moved to an input package we need to create the index templates // for the package policy as input packages create index templates per package policy if ( @@ -3797,7 +3821,8 @@ class PackagePolicyClientWithAuthz extends PackagePolicyClientImpl { skipUniqueNameVerification?: boolean | undefined; } | undefined, - context?: RequestHandlerContext + context?: RequestHandlerContext, + request?: KibanaRequest ): Promise { await this.#runPreflight({ fleetAuthz: { @@ -3805,7 +3830,7 @@ class PackagePolicyClientWithAuthz extends PackagePolicyClientImpl { }, }); - return super.update(soClient, esClient, id, packagePolicyUpdate, options, context); + return super.update(soClient, esClient, id, packagePolicyUpdate, options, context, request); } async create( diff --git a/x-pack/platform/plugins/shared/fleet/server/services/package_policy_service.ts b/x-pack/platform/plugins/shared/fleet/server/services/package_policy_service.ts index 3f9f58f1c173b..47a15b22a4d40 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/package_policy_service.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/package_policy_service.ts @@ -201,7 +201,9 @@ export interface PackagePolicyClient { bumpRevision?: boolean; }, /** Request context so update callbacks can use the caller's Elasticsearch client. */ - context?: RequestHandlerContext + context?: RequestHandlerContext, + /** Authenticated request so update callbacks can clone the caller's API key. */ + request?: KibanaRequest ): Promise; delete( diff --git a/x-pack/platform/plugins/shared/fleet/server/services/security/fake_request.ts b/x-pack/platform/plugins/shared/fleet/server/services/security/fake_request.ts new file mode 100644 index 0000000000000..f9f1d17717b96 --- /dev/null +++ b/x-pack/platform/plugins/shared/fleet/server/services/security/fake_request.ts @@ -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 type { KibanaRequest } from '@kbn/core/server'; +import type { FakeRawRequest } from '@kbn/core-http-server'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; + +const FLEET_INTERNAL_REQUEST_HEADERS: FakeRawRequest['headers'] = { + 'kbn-system-request': 'true', +}; + +/** + * Builds a minimal, unauthenticated fake request for Fleet-owned internal + * install paths that execute outside of an HTTP request context (e.g. Task + * Manager tasks that are not scheduled with an API key). + * + * Do not use this to impersonate a user. It intentionally carries no + * credentials and is marked as a system/internal request. + */ +export function createFleetInternalRequest(): KibanaRequest { + const fakeRawRequest: FakeRawRequest = { + headers: FLEET_INTERNAL_REQUEST_HEADERS, + auth: { isAuthenticated: false }, + }; + + return kibanaRequestFactory(fakeRawRequest); +} diff --git a/x-pack/platform/plugins/shared/fleet/server/services/security/index.ts b/x-pack/platform/plugins/shared/fleet/server/services/security/index.ts index 3ec048c00b3f7..907bb4ad9d7ad 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/security/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/security/index.ts @@ -16,5 +16,6 @@ export { getAuthzFromRequest, doesNotHaveRequiredFleetAuthz, } from './security'; +export { createFleetInternalRequest } from './fake_request'; export type { MessageSigningServiceInterface } from './message_signing_service'; export { MessageSigningService } from './message_signing_service'; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/setup.ts b/x-pack/platform/plugins/shared/fleet/server/services/setup.ts index d7b1ef09972e5..2b26cc1c7deab 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/setup.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/setup.ts @@ -44,6 +44,7 @@ import { awaitIfPending } from './setup_utils'; import { isPackageInstalled } from './epm/packages/install'; import type { UpgradeManagedPackagePoliciesResult } from './setup/managed_package_policies'; import { setupUpgradeManagedPackagePolicies } from './setup/managed_package_policies'; +import { createFleetInternalRequest } from './security'; import { upgradePackageInstallVersion } from './setup/upgrade_package_install_version'; import { upgradeAgentPolicySchemaVersion } from './setup/upgrade_agent_policy_schema_version'; import { migrateSettingsToFleetServerHost } from './fleet_server_host'; @@ -260,7 +261,12 @@ async function createSetupSideEffects( type: 'upgradePackageInstallVersion', }); } else { - await upgradePackageInstallVersion({ soClient, esClient, logger }); + await upgradePackageInstallVersion({ + soClient, + esClient, + logger, + request: createFleetInternalRequest(), + }); } stepSpan?.end(); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/setup/upgrade_package_install_version.ts b/x-pack/platform/plugins/shared/fleet/server/services/setup/upgrade_package_install_version.ts index e1e7059e0e51a..456b3985a4b6b 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/setup/upgrade_package_install_version.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/setup/upgrade_package_install_version.ts @@ -9,6 +9,8 @@ import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/ import pMap from 'p-map'; import type { Logger } from '@kbn/logging'; +import type { KibanaRequest } from '@kbn/core/server'; + import { MAX_CONCURRENT_EPM_PACKAGES_INSTALLATIONS, PACKAGES_SAVED_OBJECT_TYPE, @@ -30,10 +32,12 @@ export async function upgradePackageInstallVersion({ soClient, esClient, logger, + request, }: { soClient: SavedObjectsClientContract; esClient: ElasticsearchClient; logger: Logger; + request?: KibanaRequest; }) { const currentKibanaVersion = appContextService.getKibanaVersion(); const outdatedInstallations = await findOutdatedInstallations(soClient, currentKibanaVersion); diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.test.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.test.ts index c6b682f0468a1..a744ea9025c4f 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.test.ts @@ -385,39 +385,6 @@ describe('AgentStatusChangeTask', () => { expect(operations[3].data_stream.namespace).toBe('default'); }); - it('should correctly resolve agentless flag and policy_namespace for agents with versioned policy_id', async () => { - // Agents enrolled via version-specific policies have policy_id: "#" but - // policy_base_id: "". The maps (agentlessPolicies / policyNamespaceMap) are keyed - // by the base id, so lookups must use policy_base_id, not policy_id. - const agents = [ - { - id: 'agent-versioned', - policy_id: 'agentless-policy-1#9.6', // versioned — mismatches the map key - policy_base_id: 'agentless-policy-1', // base id — matches the map key - status: 'online', - namespaces: ['default'], - local_metadata: { host: { hostname: 'host-versioned' } }, - }, - ] as unknown as Agent[]; - - mockedFetchAllAgentsByKuery - .mockResolvedValueOnce(getMockFetchAllAgentsByKuery(agents)) - .mockResolvedValue(getMockFetchAllAgentsByKuery([])); - - await runTask(); - - const bulkCall = esClient.bulk.mock.calls[0][0]; - const operations = bulkCall.operations as any[]; - const doc = operations[1]; - - // The fix: policy_base_id is used for the agentlessPolicies lookup. - expect(doc.agentless).toBe(true); - // The fix: policy_base_id is used for the policyNamespaceMap lookup. - expect(doc.policy_namespace).toBe('default'); - // policy_id in the emitted doc still carries the full versioned id (as stored on the agent). - expect(doc.policy_id).toBe('agentless-policy-1#9.6'); - }); - it('should do nothing when no agents changed status', async () => { const agents = [] as unknown as Agent[]; mockedFetchAllAgentsByKuery diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.ts index 65fc54f4dfae0..c817b4967fcc6 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/agent_status_change_task.ts @@ -302,12 +302,8 @@ export class AgentStatusChangeTask { policyNamespaceMap: Map | undefined ) => { const bulkBody = agentsToUpdate.flatMap((agent) => { - // Use policy_base_id (always the plain UUID) for map lookups so that agents whose - // policy_id carries a version suffix (e.g. "#9.6") are still matched against - // the maps that are keyed by base id. Falls back to policy_id for agents enrolled by - // an older fleet-server that did not yet write policy_base_id. - const basePolicyId = agent.policy_base_id ?? agent.policy_id; - const policyNamespace = (basePolicyId && policyNamespaceMap?.get(basePolicyId)) || 'default'; + const policyNamespace = + (agent.policy_id && policyNamespaceMap?.get(agent.policy_id)) || 'default'; const body = { '@timestamp': new Date().toISOString(), data_stream: AGENT_STATUS_CHANGE_DATA_STREAM, @@ -319,7 +315,7 @@ export class AgentStatusChangeTask { policy_namespace: policyNamespace, space_id: agent.namespaces, hostname: agent.local_metadata?.host?.hostname, - agentless: (basePolicyId && agentlessPolicies?.includes(basePolicyId)) ?? false, + agentless: (agent.policy_id && agentlessPolicies?.includes(agent.policy_id)) ?? false, }; return [ diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.test.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.test.ts index 3bcde8889ec38..2e2115788d28a 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.test.ts @@ -165,6 +165,7 @@ describe('AutoInstallContentPackagesTask', () => { pkgName: 'kubernetes_otel', pkgVersion: '1.1.0', useStreaming: true, + request: expect.anything(), automaticInstall: true, }); expect(packageClientMock.installPackage).toHaveBeenCalledTimes(2); @@ -186,6 +187,7 @@ describe('AutoInstallContentPackagesTask', () => { pkgName: 'kubernetes_otel', pkgVersion: '1.1.0', useStreaming: true, + request: expect.anything(), automaticInstall: true, }); expect(packageClientMock.installPackage).toHaveBeenCalledTimes(1); @@ -219,6 +221,7 @@ describe('AutoInstallContentPackagesTask', () => { pkgName: 'kubernetes_otel', pkgVersion: '1.1.0', useStreaming: true, + request: expect.anything(), automaticInstall: true, }); }); @@ -265,6 +268,7 @@ describe('AutoInstallContentPackagesTask', () => { pkgName: 'test_package', pkgVersion: '1.1.0', useStreaming: true, + request: expect.anything(), automaticInstall: true, }); expect(packageClientMock.installPackage).not.toHaveBeenCalledWith( @@ -316,6 +320,7 @@ describe('AutoInstallContentPackagesTask', () => { pkgName: 'test_package', pkgVersion: '1.1.0', useStreaming: true, + request: expect.anything(), automaticInstall: true, }); expect(packageClientMock.installPackage).not.toHaveBeenCalledWith( diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.ts index 633df6956a559..007d8372a6732 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/auto_install_content_packages_task.ts @@ -4,9 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { CoreSetup, ElasticsearchClient, Logger, LoggerFactory } from '@kbn/core/server'; +import type { KibanaRequest } from '@kbn/core/server'; import pMap from 'p-map'; import semverGt from 'semver/functions/gt'; -import type { CoreSetup, ElasticsearchClient, Logger, LoggerFactory } from '@kbn/core/server'; import type { ConcreteTaskInstance, TaskManagerSetupContract, @@ -19,6 +20,7 @@ import type { DiscoveryDataset } from '../../common/types'; import type { PackageClient } from '../services'; import { appContextService, dataStreamService } from '../services'; +import { createFleetInternalRequest } from '../services/security'; import * as Registry from '../services/epm/registry'; import { MAX_CONCURRENT_EPM_PACKAGES_INSTALLATIONS, SO_SEARCH_LIMIT } from '../constants'; @@ -74,10 +76,16 @@ export class AutoInstallContentPackagesTask { [TYPE]: { title: TITLE, timeout: TIMEOUT, - createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + createTaskRunner: ({ + taskInstance, + fakeRequest, + }: { + taskInstance: ConcreteTaskInstance; + fakeRequest?: KibanaRequest; + }) => { return { run: async () => { - return this.runTask(taskInstance, core); + return this.runTask(taskInstance, core, fakeRequest); }, cancel: async () => {}, }; @@ -124,7 +132,11 @@ export class AutoInstallContentPackagesTask { this.logger.debug(`[AutoInstallContentPackagesTask] runTask ended${msg ? ': ' + msg : ''}`); } - public runTask = async (taskInstance: ConcreteTaskInstance, core: CoreSetup) => { + public runTask = async ( + taskInstance: ConcreteTaskInstance, + core: CoreSetup, + fakeRequest?: KibanaRequest + ) => { if (!appContextService.getExperimentalFeatures().enableAutoInstallContentPackages) { this.logger.debug( '[AutoInstallContentPackagesTask] Aborting runTask: auto install content packages feature is disabled' @@ -149,6 +161,7 @@ export class AutoInstallContentPackagesTask { const packageClient = packageService.asInternalUser; const esClient = coreStart.elasticsearch.client.asInternalUser; const soClient = appContextService.getInternalUserSOClientWithoutSpaceExtension(); + const request = fakeRequest ?? createFleetInternalRequest(); const prerelease = await getPrereleaseFromSettings(soClient); @@ -205,7 +218,7 @@ export class AutoInstallContentPackagesTask { .join(', ')}` ); - await this.installPackages(packageClient, packagesToInstall); + await this.installPackages(packageClient, packagesToInstall, request); } this.endRun('success'); @@ -222,7 +235,8 @@ export class AutoInstallContentPackagesTask { private async installPackages( packageClient: PackageClient, - packagesToInstall: Array<{ name: string; version: string }> + packagesToInstall: Array<{ name: string; version: string }>, + request: KibanaRequest ) { await pMap( packagesToInstall, @@ -233,6 +247,7 @@ export class AutoInstallContentPackagesTask { pkgVersion: version, useStreaming: true, // Use streaming for content packages automaticInstall: true, + request, }); } catch (error) { this.logger.warn( diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/packages_bulk_operations/task_runner.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/packages_bulk_operations/task_runner.ts index 2ac9f77725e65..88e91ad6ae876 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/packages_bulk_operations/task_runner.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/packages_bulk_operations/task_runner.ts @@ -13,6 +13,7 @@ import type { import type { KibanaRequest } from '@kbn/core/server'; import { appContextService } from '../../services'; +import { createFleetInternalRequest } from '../../services/security'; import { type BulkUpgradeTaskParams, _runBulkUpgradeTask } from './run_bulk_upgrade'; import { type BulkUninstallTaskParams, _runBulkUninstallTask } from './run_bulk_uninstall'; @@ -63,7 +64,7 @@ export function registerPackagesBulkOperationTask(taskManager: TaskManagerSetupC signal, logger, taskParams: taskParams as BulkUpgradeTaskParams, - request: fakeRequest!, + request: fakeRequest ?? createFleetInternalRequest(), }); } else if (taskParams.type === 'bulk_rollback') { results = await _runBulkRollbackTask({ diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_reinstall_packages_for_global_asset_update.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_reinstall_packages_for_global_asset_update.ts index 1062230a6e3ad..c66233b454fa0 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_reinstall_packages_for_global_asset_update.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_reinstall_packages_for_global_asset_update.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { KibanaRequest } from '@kbn/core/server'; import apm from 'elastic-apm-node'; import type { Logger } from '@kbn/logging'; import pMap from 'p-map'; @@ -16,6 +17,7 @@ import { getInstallations, reinstallPackageForInstallation } from '../../service interface RunReinstallPackagesParams { signal: AbortSignal; logger: Logger; + request: KibanaRequest; } /** @@ -28,6 +30,7 @@ interface RunReinstallPackagesParams { export async function runReinstallPackagesForGlobalAssetUpdate({ signal, logger, + request, }: RunReinstallPackagesParams): Promise { const soClient = appContextService.getInternalUserSOClientWithoutSpaceExtension(); const esClient = appContextService.getInternalUserESClient(); @@ -64,6 +67,7 @@ export async function runReinstallPackagesForGlobalAssetUpdate({ soClient, esClient, installation, + request, }); successCount++; logger.debug(`Successfully reinstalled package ${installation.name}`); diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_upgrade_package_install_version.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_upgrade_package_install_version.ts index 049b7ac4e00ac..3c86b912973c8 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_upgrade_package_install_version.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/setup/run_upgrade_package_install_version.ts @@ -6,6 +6,7 @@ */ import type { Logger } from '@kbn/logging'; +import type { KibanaRequest } from '@kbn/core/server'; import pMap from 'p-map'; import { appContextService } from '../../services'; @@ -22,6 +23,7 @@ import { reinstallPackageForInstallation } from '../../services/epm/packages'; interface RunUpgradePackageInstallVersionParams { signal: AbortSignal; logger: Logger; + request: KibanaRequest; } /** @@ -30,6 +32,7 @@ interface RunUpgradePackageInstallVersionParams { export async function runUpgradePackageInstallVersion({ signal, logger, + request, }: RunUpgradePackageInstallVersionParams): Promise { const soClient = appContextService.getInternalUserSOClientWithoutSpaceExtension(); const esClient = appContextService.getInternalUserESClient(); @@ -82,6 +85,7 @@ export async function runUpgradePackageInstallVersion({ soClient, esClient, installation, + request, }); logger.debug(`Successfully upgraded package install version for ${installation.name}`); } catch (err: any) { diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/setup/task_runner.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/setup/task_runner.ts index 0166c3fd7c52c..24aa5873f8d12 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/setup/task_runner.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/setup/task_runner.ts @@ -9,8 +9,10 @@ import type { ConcreteTaskInstance, TaskManagerSetupContract, } from '@kbn/task-manager-plugin/server'; +import type { KibanaRequest } from '@kbn/core/server'; import { appContextService } from '../../services'; +import { createFleetInternalRequest } from '../../services/security'; import { TASK_TIMEOUT, TASK_TITLE, TASK_TYPE, type SetupTaskParams } from './utils'; import { runBackportPackagePolicyInputId } from './run_backport_package_policy_input_id'; @@ -30,11 +32,14 @@ export function registerSetupTasks(taskManager: TaskManagerSetupContract) { createTaskRunner: ({ taskInstance, signal, + fakeRequest, }: { taskInstance: ConcreteTaskInstance; signal: AbortSignal; + fakeRequest?: KibanaRequest; }) => { const logger = appContextService.getLogger(); + const request = fakeRequest ?? createFleetInternalRequest(); return { run: async () => { @@ -56,11 +61,13 @@ export function registerSetupTasks(taskManager: TaskManagerSetupContract) { await runUpgradePackageInstallVersion({ signal, logger, + request, }); } else if (taskParams.type === 'reinstallPackagesForGlobalAssetUpdate') { await runReinstallPackagesForGlobalAssetUpdate({ signal, logger, + request, }); } else { throw new Error(`Unknown setup operation: ${taskParams.type}`); diff --git a/x-pack/platform/plugins/shared/fleet/tsconfig.json b/x-pack/platform/plugins/shared/fleet/tsconfig.json index 5d7f830030c5a..bd9b62b528c58 100644 --- a/x-pack/platform/plugins/shared/fleet/tsconfig.json +++ b/x-pack/platform/plugins/shared/fleet/tsconfig.json @@ -22,6 +22,7 @@ ], "kbn_references": [ "@kbn/core", + "@kbn/core-http-server-utils", "@kbn/tracing-utils", // add references to other TypeScript projects the plugin depends on @@ -137,6 +138,7 @@ "@kbn/scout", "@kbn/reporting-common", "@kbn/reporting-plugin", + "@kbn/workflows-management-plugin", "@kbn/user-profile-components", "@kbn/css-utils", "@kbn/rison", @@ -148,6 +150,8 @@ "@kbn/human-readable-id", "@kbn/elastic-agent-condition-language", "@kbn/app-header", - "@kbn/core-http-browser" + "@kbn/core-http-browser", + "@kbn/agent-builder-server", + "@kbn/agent-builder-common" ] }