diff --git a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts index cdc47c69caef3..124515299efc7 100644 --- a/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts +++ b/packages/core/saved-objects/core-saved-objects-base-server-internal/src/constants.ts @@ -159,7 +159,7 @@ export const HASH_TO_VERSION_MAP = { 'endpoint:user-artifact-manifest|7502b5c5bc923abe8aa5ccfd636e8c3d': '10.0.0', 'enterprise_search_telemetry|3d1b76c39bfb2cc8296b024d73854724': '10.0.0', 'epm-packages-assets|44621b2f6052ef966da47b7c3a00f33b': '10.0.0', - 'epm-packages|c1e2020399dbebba2448096ca007c668': '10.1.0', + 'epm-packages|8ce219acd0f6f3529237d52193866afb': '10.2.0', 'event_loop_delays_daily|5df7e292ddd5028e07c1482e130e6654': '10.0.0', 'event-annotation-group|df07b1a361c32daf4e6842c1d5521dbe': '10.0.0', 'exception-list-agnostic|8a1defe5981db16792cb9a772e84bb9a': '10.0.0', diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 5a26aca6bacc1..a66ab72d98aa3 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -290,6 +290,7 @@ "installed_kibana_space_id", "internal", "keep_policies_up_to_date", + "latest_executed_state", "latest_install_failed_attempts", "name", "package_assets", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 246c02b7dfec3..00d9a5f82fd1c 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1003,6 +1003,10 @@ "index": false, "type": "boolean" }, + "latest_executed_state": { + "enabled": false, + "type": "object" + }, "latest_install_failed_attempts": { "enabled": false, "type": "object" diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index a466bda78df4f..7ec28e55fad21 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -85,7 +85,7 @@ describe('checking migration metadata changes on all registered SO types', () => "dashboard": "211e9ca30f5a95d5f3c27b1bf2b58e6cfa0c9ae9", "endpoint:user-artifact-manifest": "1c3533161811a58772e30cdc77bac4631da3ef2b", "enterprise_search_telemetry": "9ac912e1417fc8681e0cd383775382117c9e3d3d", - "epm-packages": "c23d3d00c051a08817335dba26f542b64b18a56a", + "epm-packages": "f8ee125b57df31fd035dc04ad81aef475fd2f5bd", "epm-packages-assets": "7a3e58efd9a14191d0d1a00b8aaed30a145fd0b1", "event-annotation-group": "715ba867d8c68f3c9438052210ea1c30a9362582", "event_loop_delays_daily": "01b967e8e043801357503de09199dfa3853bab88", diff --git a/x-pack/plugins/fleet/common/experimental_features.ts b/x-pack/plugins/fleet/common/experimental_features.ts index 8271f0403beda..48e77dbe1988d 100644 --- a/x-pack/plugins/fleet/common/experimental_features.ts +++ b/x-pack/plugins/fleet/common/experimental_features.ts @@ -28,6 +28,7 @@ export const allowedExperimentalValues = Object.freeze>( agentless: false, enableStrictKQLValidation: false, subfeaturePrivileges: false, + enablePackagesStateMachine: true, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index a526294d963fa..1ed749fe31dea 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -6302,6 +6302,34 @@ } } }, + "latest_executed_state": { + "description": "Latest successfully executed state in package install state machine", + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": [ + "create_restart_installation", + "install_kibana_assets", + "install_ilm_policies", + "install_ml_model", + "install_index_template_pipelines", + "remove_legacy_templates", + "update_current_write_indices", + "install_transforms", + "delete_previous_pipelines", + "save_archive_entries_from_assets_map", + "update_so" + ] + }, + "started_at": { + "type": "string" + }, + "error": { + "type": "string" + } + } + }, "verification_status": { "type": "string", "enum": [ diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index f76e50c095aed..03bb90fd84d73 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -3970,6 +3970,28 @@ components: type: string stack: type: string + latest_executed_state: + description: Latest successfully executed state in package install state machine + type: object + properties: + name: + type: string + enum: + - create_restart_installation + - install_kibana_assets + - install_ilm_policies + - install_ml_model + - install_index_template_pipelines + - remove_legacy_templates + - update_current_write_indices + - install_transforms + - delete_previous_pipelines + - save_archive_entries_from_assets_map + - update_so + started_at: + type: string + error: + type: string verification_status: type: string enum: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/installation_info.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/installation_info.yaml index c5db5f12d4cc3..b8d82bc669d04 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/installation_info.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/installation_info.yaml @@ -66,6 +66,28 @@ properties: type: string stack: type: string + latest_executed_state: + description: Latest successfully executed state in package install state machine + type: object + properties: + name: + type: string + enum: + - create_restart_installation + - install_kibana_assets + - install_ilm_policies + - install_ml_model + - install_index_template_pipelines + - remove_legacy_templates + - update_current_write_indices + - install_transforms + - delete_previous_pipelines + - save_archive_entries_from_assets_map + - update_so + started_at: + type: string + error: + type: string verification_status: type: string enum: diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index b1bea249ee9de..a62833dfdcfb5 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -37,6 +37,7 @@ export type InstallType = 'reinstall' | 'reupdate' | 'rollback' | 'update' | 'in export type InstallSource = 'registry' | 'upload' | 'bundled' | 'custom'; export type EpmPackageInstallStatus = 'installed' | 'installing' | 'install_failed'; +export type InstallResultStatus = 'installed' | 'already_installed'; export type ServiceName = 'kibana' | 'elasticsearch'; export type AgentAssetType = typeof agentAssetTypes; @@ -548,6 +549,36 @@ export interface InstallFailedAttempt { }; } +export enum INSTALL_STATES { + CREATE_RESTART_INSTALLATION = 'create_restart_installation', + INSTALL_KIBANA_ASSETS = 'install_kibana_assets', + INSTALL_ILM_POLICIES = 'install_ilm_policies', + 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', + INSTALL_TRANSFORMS = 'install_transforms', + DELETE_PREVIOUS_PIPELINES = 'delete_previous_pipelines', + SAVE_ARCHIVE_ENTRIES = 'save_archive_entries_from_assets_map', + RESOLVE_KIBANA_PROMISE = 'resolve_kibana_promise', + UPDATE_SO = 'update_so', +} +type StatesKeys = keyof typeof INSTALL_STATES; +export type StateNames = typeof INSTALL_STATES[StatesKeys]; + +export interface LatestExecutedState { + name: T; + started_at: string; + error?: string; +} + +export type InstallLatestExecutedState = LatestExecutedState; + +export interface StateContext { + [key: string]: any; + latestExecutedState?: LatestExecutedState; +} + export interface Installation { installed_kibana: KibanaAssetReference[]; installed_es: EsAssetReference[]; @@ -568,6 +599,7 @@ export interface Installation { internal?: boolean; removable?: boolean; latest_install_failed_attempts?: InstallFailedAttempt[]; + latest_executed_state?: InstallLatestExecutedState; } export interface PackageUsageStats { diff --git a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts index 7300bd5449333..4882c1c0652e6 100644 --- a/x-pack/plugins/fleet/common/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/common/types/rest_spec/epm.ts @@ -18,6 +18,7 @@ import type { EpmPackageInstallStatus, SimpleSOAssetType, AssetSOObject, + InstallResultStatus, } from '../models/epm'; export interface GetCategoriesRequest { @@ -154,7 +155,7 @@ export interface IBulkInstallPackageHTTPError { export interface InstallResult { assets?: AssetReference[]; - status?: 'installed' | 'already_installed'; + status?: InstallResultStatus; error?: Error; installType: InstallType; installSource: InstallSource; diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 5c14cf3d3bac8..47324cfc493f1 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -612,6 +612,7 @@ const soToInstallationInfo = (pkg: PackageListItem | PackageInfo) => { verification_key_id: attributes.verification_key_id, experimental_data_stream_features: attributes.experimental_data_stream_features, latest_install_failed_attempts: attributes.latest_install_failed_attempts, + latest_executed_state: attributes.latest_executed_state, }; return { diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index 80665f381e871..4aef23990ffec 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -530,6 +530,7 @@ export const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({ }, }, latest_install_failed_attempts: { type: 'object', enabled: false }, + latest_executed_state: { type: 'object', enabled: false }, installed_kibana: { dynamic: false, properties: {}, @@ -571,6 +572,16 @@ export const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({ }, ], }, + '2': { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + latest_executed_state: { type: 'object', enabled: false }, + }, + }, + ], + }, }, migrations: { '7.14.0': migrateInstallationToV7140, diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 47c4da20b9d05..2956cb5fe20c2 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -172,7 +172,6 @@ export async function installKibanaAssetsAndReferences({ pkgName, pkgTitle, packageInstallContext, - paths, installedPkg, spaceId, assetTags, @@ -185,7 +184,6 @@ export async function installKibanaAssetsAndReferences({ pkgName: string; pkgTitle: string; packageInstallContext: PackageInstallContext; - paths: string[]; installedPkg?: SavedObject; spaceId: string; assetTags?: PackageSpecTags[]; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index 8b8a44b55e222..4a6cb0306a9cb 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -161,7 +161,6 @@ export async function _installPackage({ pkgName, pkgTitle, packageInstallContext, - paths, installedPkg, logger, spaceId, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index 97817b063b730..bbaa10728754b 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -29,6 +29,7 @@ import { isPackageVersionOrLaterInstalled, } from './install'; import * as install from './_install_package'; +import * as installStateMachine from './install_state_machine/_state_machine_package_install'; import { getBundledPackageByPkgKey } from './bundled_packages'; import { getInstalledPackageWithAssets, getInstallationObject } from './get'; @@ -57,6 +58,7 @@ jest.mock('../../app_context', () => { getConfig: jest.fn(() => ({})), getSavedObjectsTagging: jest.fn(() => mockedSavedObjectTagging), getInternalUserSOClientForSpaceId: jest.fn(), + getExperimentalFeatures: jest.fn(), }, }; }); @@ -79,6 +81,11 @@ jest.mock('./_install_package', () => { _installPackage: jest.fn(() => Promise.resolve()), }; }); +jest.mock('./install_state_machine/_state_machine_package_install', () => { + return { + _stateMachineInstallPackage: jest.fn(() => Promise.resolve()), + }; +}); jest.mock('../kibana/index_pattern/install', () => { return { installIndexPatterns: jest.fn(() => Promise.resolve()), @@ -161,246 +168,504 @@ describe('install', () => { jest.mocked(Registry.getPackage).mockImplementation(() => Promise.resolve({ packageInfo: { license: 'basic', conditions: { elastic: { subscription: 'basic' } } }, + paths: [], } as any) ); mockGetBundledPackageByPkgKey.mockReset(); (install._installPackage as jest.Mock).mockClear(); + (installStateMachine._stateMachineInstallPackage as jest.Mock).mockClear(); jest.mocked(appContextService.getInternalUserSOClientForSpaceId).mockReset(); }); describe('registry', () => { - beforeEach(() => { - mockGetBundledPackageByPkgKey.mockResolvedValue(undefined); - }); - - it('should send telemetry on install failure, out of date', async () => { - await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'apache-1.1.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + describe('with enablePackagesStateMachine = false', () => { + beforeEach(() => { + mockGetBundledPackageByPkgKey.mockResolvedValue(undefined); + jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({ + enablePackagesStateMachine: false, + } as any); }); - expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { - currentVersion: 'not_installed', - dryRun: false, - errorMessage: 'apache-1.1.0 is out-of-date and cannot be installed or updated', - eventType: 'package-install', - installType: 'install', - newVersion: '1.1.0', - packageName: 'apache', - status: 'failure', + it('should send telemetry on install failure, out of date', async () => { + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.1.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + errorMessage: 'apache-1.1.0 is out-of-date and cannot be installed or updated', + eventType: 'package-install', + installType: 'install', + newVersion: '1.1.0', + packageName: 'apache', + status: 'failure', + }); }); - }); - it('should send telemetry on install failure, license error', async () => { - jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(false); - await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'apache-1.3.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + it('should send telemetry on install failure, license error', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(false); + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + errorMessage: 'Installation requires basic license', + eventType: 'package-install', + installType: 'install', + newVersion: '1.3.0', + packageName: 'apache', + status: 'failure', + }); }); - expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { - currentVersion: 'not_installed', - dryRun: false, - errorMessage: 'Installation requires basic license', - eventType: 'package-install', - installType: 'install', - newVersion: '1.3.0', - packageName: 'apache', - status: 'failure', + it('should send telemetry on install success', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + eventType: 'package-install', + installType: 'install', + newVersion: '1.3.0', + packageName: 'apache', + status: 'success', + }); }); - }); - it('should send telemetry on install success', async () => { - jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); - await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'apache-1.3.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + it('should send telemetry on update success', async () => { + jest + .mocked(getInstallationObject) + .mockResolvedValueOnce({ attributes: { version: '1.2.0' } } as any); + + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: '1.2.0', + dryRun: false, + eventType: 'package-install', + installType: 'update', + newVersion: '1.3.0', + packageName: 'apache', + status: 'success', + }); }); - expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { - currentVersion: 'not_installed', - dryRun: false, - eventType: 'package-install', - installType: 'install', - newVersion: '1.3.0', - packageName: 'apache', - status: 'success', + it('should send telemetry on install failure, async error', async () => { + jest.mocked(install._installPackage).mockRejectedValue(new Error('error')); + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + errorMessage: 'error', + eventType: 'package-install', + installType: 'install', + newVersion: '1.3.0', + packageName: 'apache', + status: 'failure', + }); }); - }); - it('should send telemetry on update success', async () => { - jest - .mocked(getInstallationObject) - .mockResolvedValueOnce({ attributes: { version: '1.2.0' } } as any); + it('should install from bundled package if one exists', async () => { + (install._installPackage as jest.Mock).mockResolvedValue({}); + mockGetBundledPackageByPkgKey.mockResolvedValue({ + name: 'test_package', + version: '1.0.0', + getBuffer: async () => Buffer.from('test_package'), + }); + + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'test_package-1.0.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.error).toBeUndefined(); + + expect(install._installPackage).toHaveBeenCalledWith( + expect.objectContaining({ installSource: 'bundled' }) + ); + }); - jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); - await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'apache-1.3.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + it('should fetch latest version if version not provided', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'test_package', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.status).toEqual('installed'); + + expect(sendTelemetryEvents).toHaveBeenCalledWith( + expect.anything(), + undefined, + expect.objectContaining({ + newVersion: '1.3.0', + }) + ); }); - expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { - currentVersion: '1.2.0', - dryRun: false, - eventType: 'package-install', - installType: 'update', - newVersion: '1.3.0', - packageName: 'apache', - status: 'success', + it('should do nothing if same version is installed', async () => { + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { + version: '1.2.0', + install_status: 'installed', + installed_es: [], + installed_kibana: [], + }, + } as any); + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.2.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.status).toEqual('already_installed'); }); - }); - it('should send telemetry on install failure, async error', async () => { - jest.mocked(install._installPackage).mockRejectedValue(new Error('error')); - jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + it('should allow to install fleet_server if internal.fleetServerStandalone is configured', async () => { + jest.mocked(appContextService.getConfig).mockReturnValueOnce({ + internal: { + fleetServerStandalone: true, + }, + } as any); - await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'apache-1.3.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'fleet_server-2.0.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.status).toEqual('installed'); }); - expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { - currentVersion: 'not_installed', - dryRun: false, - errorMessage: 'error', - eventType: 'package-install', - installType: 'install', - newVersion: '1.3.0', - packageName: 'apache', - status: 'failure', + it('should use a scoped to package space soClient for tagging', async () => { + const mockedTaggingSo = savedObjectsClientMock.create(); + jest + .mocked(appContextService.getInternalUserSOClientForSpaceId) + .mockReturnValue(mockedTaggingSo); + jest + .mocked(getInstallationObject) + .mockResolvedValueOnce({ attributes: { version: '1.2.0' } } as any); + + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + await installPackage({ + spaceId: 'test', + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(appContextService.getInternalUserSOClientForSpaceId).toBeCalledWith('test'); + expect(appContextService.getSavedObjectsTagging().createTagClient).toBeCalledWith( + expect.objectContaining({ + client: mockedTaggingSo, + }) + ); + expect( + appContextService.getSavedObjectsTagging().createInternalAssignmentService + ).toBeCalledWith( + expect.objectContaining({ + client: mockedTaggingSo, + }) + ); }); }); - it('should install from bundled package if one exists', async () => { - (install._installPackage as jest.Mock).mockResolvedValue({}); - mockGetBundledPackageByPkgKey.mockResolvedValue({ - name: 'test_package', - version: '1.0.0', - getBuffer: async () => Buffer.from('test_package'), + describe('with enablePackagesStateMachine = true', () => { + beforeEach(() => { + mockGetBundledPackageByPkgKey.mockResolvedValue(undefined); + jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({ + enablePackagesStateMachine: true, + } as any); }); - - const response = await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'test_package-1.0.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + afterEach(() => { + (install._installPackage as jest.Mock).mockClear(); + // jest.resetAllMocks(); + }); + afterAll(() => { + jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({ + enablePackagesStateMachine: false, + } as any); }); - expect(response.error).toBeUndefined(); - - expect(install._installPackage).toHaveBeenCalledWith( - expect.objectContaining({ installSource: 'bundled' }) - ); - }); + it('should send telemetry on install failure, out of date', async () => { + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.1.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + errorMessage: 'apache-1.1.0 is out-of-date and cannot be installed or updated', + eventType: 'package-install', + installType: 'install', + newVersion: '1.1.0', + packageName: 'apache', + status: 'failure', + }); + }); - it('should fetch latest version if version not provided', async () => { - jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); - const response = await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'test_package', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + it('should send telemetry on install failure, license error', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(false); + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + errorMessage: 'Installation requires basic license', + eventType: 'package-install', + installType: 'install', + newVersion: '1.3.0', + packageName: 'apache', + status: 'failure', + }); }); - expect(response.status).toEqual('installed'); + it('should send telemetry on install success', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + eventType: 'package-install', + installType: 'install', + newVersion: '1.3.0', + packageName: 'apache', + status: 'success', + }); + }); - expect(sendTelemetryEvents).toHaveBeenCalledWith( - expect.anything(), - undefined, - expect.objectContaining({ + it('should send telemetry on update success', async () => { + jest + .mocked(getInstallationObject) + .mockResolvedValueOnce({ attributes: { version: '1.2.0', installed_kibana: [] } } as any); + + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: '1.2.0', + dryRun: false, + eventType: 'package-install', + installType: 'update', newVersion: '1.3.0', - }) - ); - }); + packageName: 'apache', + status: 'success', + }); + }); - it('should do nothing if same version is installed', async () => { - jest.mocked(getInstallationObject).mockResolvedValueOnce({ - attributes: { - version: '1.2.0', - install_status: 'installed', - installed_es: [], - installed_kibana: [], - }, - } as any); - jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); - const response = await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'apache-1.2.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + it('should send telemetry on install failure, async error', async () => { + jest + .mocked(installStateMachine._stateMachineInstallPackage) + .mockRejectedValue(new Error('error')); + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + + await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(sendTelemetryEvents).toHaveBeenCalledWith(expect.anything(), undefined, { + currentVersion: 'not_installed', + dryRun: false, + errorMessage: 'error', + eventType: 'package-install', + installType: 'install', + newVersion: '1.3.0', + packageName: 'apache', + status: 'failure', + }); }); - expect(response.status).toEqual('already_installed'); - }); + it('should install from bundled package if one exists', async () => { + (installStateMachine._stateMachineInstallPackage as jest.Mock).mockResolvedValue({}); + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + mockGetBundledPackageByPkgKey.mockResolvedValue({ + name: 'test_package', + version: '1.0.0', + getBuffer: async () => Buffer.from('test_package'), + }); + + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'test_package-1.0.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.error).toBeUndefined(); + + expect(install._installPackage).toHaveBeenCalledWith( + expect.objectContaining({ installSource: 'bundled' }) + ); + }); - it('should allow to install fleet_server if internal.fleetServerStandalone is configured', async () => { - jest.mocked(appContextService.getConfig).mockReturnValueOnce({ - internal: { - fleetServerStandalone: true, - }, - } as any); + it('should fetch latest version if version not provided', async () => { + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'test_package', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.status).toEqual('installed'); + + expect(sendTelemetryEvents).toHaveBeenCalledWith( + expect.anything(), + undefined, + expect.objectContaining({ + newVersion: '1.3.0', + }) + ); + }); - const response = await installPackage({ - spaceId: DEFAULT_SPACE_ID, - installSource: 'registry', - pkgkey: 'fleet_server-2.0.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + it('should do nothing if same version is installed', async () => { + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { + version: '1.2.0', + install_status: 'installed', + installed_es: [], + installed_kibana: [], + }, + } as any); + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'apache-1.2.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(response.status).toEqual('already_installed'); }); - expect(response.status).toEqual('installed'); - }); + // failing + it('should allow to install fleet_server if internal.fleetServerStandalone is configured', async () => { + jest.mocked(appContextService.getConfig).mockReturnValueOnce({ + internal: { + fleetServerStandalone: true, + }, + } as any); - it('should use a scopped to package space soClient for tagging', async () => { - const mockedTaggingSo = savedObjectsClientMock.create(); - jest - .mocked(appContextService.getInternalUserSOClientForSpaceId) - .mockReturnValue(mockedTaggingSo); - jest - .mocked(getInstallationObject) - .mockResolvedValueOnce({ attributes: { version: '1.2.0' } } as any); + const response = await installPackage({ + spaceId: DEFAULT_SPACE_ID, + installSource: 'registry', + pkgkey: 'fleet_server-2.0.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); - jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); - await installPackage({ - spaceId: 'test', - installSource: 'registry', - pkgkey: 'apache-1.3.0', - savedObjectsClient: savedObjectsClientMock.create(), - esClient: {} as ElasticsearchClient, + expect(response.status).toEqual('installed'); }); - expect(appContextService.getInternalUserSOClientForSpaceId).toBeCalledWith('test'); - expect(appContextService.getSavedObjectsTagging().createTagClient).toBeCalledWith( - expect.objectContaining({ - client: mockedTaggingSo, - }) - ); - expect( - appContextService.getSavedObjectsTagging().createInternalAssignmentService - ).toBeCalledWith( - expect.objectContaining({ - client: mockedTaggingSo, - }) - ); + it('should use a scoped to package space soClient for tagging', async () => { + const mockedTaggingSo = savedObjectsClientMock.create(); + jest + .mocked(appContextService.getInternalUserSOClientForSpaceId) + .mockReturnValue(mockedTaggingSo); + jest + .mocked(getInstallationObject) + .mockResolvedValueOnce({ attributes: { version: '1.2.0', installed_kibana: [] } } as any); + + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); + await installPackage({ + spaceId: 'test', + installSource: 'registry', + pkgkey: 'apache-1.3.0', + savedObjectsClient: savedObjectsClientMock.create(), + esClient: {} as ElasticsearchClient, + }); + + expect(appContextService.getInternalUserSOClientForSpaceId).toBeCalledWith('test'); + expect(appContextService.getSavedObjectsTagging().createTagClient).toBeCalledWith( + expect.objectContaining({ + client: mockedTaggingSo, + }) + ); + expect( + appContextService.getSavedObjectsTagging().createInternalAssignmentService + ).toBeCalledWith( + expect.objectContaining({ + client: mockedTaggingSo, + }) + ); + }); }); }); @@ -453,6 +718,7 @@ describe('install', () => { it('should send telemetry on install failure, async error', async () => { jest.mocked(install._installPackage).mockRejectedValue(new Error('error')); + jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true); await installPackage({ spaceId: DEFAULT_SPACE_ID, installSource: 'upload', diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 43b0c9d68a04c..c8c2e542bfe8b 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -38,6 +38,7 @@ import type { NewPackagePolicy, PackageInfo, PackageVerificationResult, + InstallResultStatus, } from '../../../types'; import { AUTO_UPGRADE_POLICIES_PACKAGES, @@ -70,6 +71,8 @@ import { sendTelemetryEvents, UpdateEventType } from '../../upgrade_sender'; import { auditLoggingService } from '../../audit_logging'; import { getFilteredInstallPackages } from '../filtered_packages'; +import { _stateMachineInstallPackage } from './install_state_machine/_state_machine_package_install'; + import { formatVerificationResultForSO } from './package_verification'; import { getInstallation, getInstallationObject } from './get'; import { removeInstallation } from './remove'; @@ -458,24 +461,44 @@ async function installPackageFromRegistry({ }` ); } - - return await installPackageCommon({ - pkgName, - pkgVersion, - installSource, - installedPkg, - installType, - savedObjectsClient, - esClient, - spaceId, - force, - packageInstallContext, - paths, - verificationResult, - authorizationHeader, - ignoreMappingUpdateErrors, - skipDataStreamRollover, - }); + const { enablePackagesStateMachine } = appContextService.getExperimentalFeatures(); + if (enablePackagesStateMachine) { + return await installPackageWitStateMachine({ + pkgName, + pkgVersion, + installSource, + installedPkg, + installType, + savedObjectsClient, + esClient, + spaceId, + force, + packageInstallContext, + paths, + verificationResult, + authorizationHeader, + ignoreMappingUpdateErrors, + skipDataStreamRollover, + }); + } else { + return await installPackageCommon({ + pkgName, + pkgVersion, + installSource, + installedPkg, + installType, + savedObjectsClient, + esClient, + spaceId, + force, + packageInstallContext, + paths, + verificationResult, + authorizationHeader, + ignoreMappingUpdateErrors, + skipDataStreamRollover, + }); + } } catch (e) { sendEvent({ ...telemetryEvent, @@ -607,7 +630,6 @@ async function installPackageCommon(options: { .createTagClient({ client: savedObjectClientWithSpace }); // try installing the package, if there was an error, call error handler and rethrow - // @ts-expect-error status is string instead of InstallResult.status 'installed' | 'already_installed' return await _installPackage({ savedObjectsClient, savedObjectsImporter, @@ -637,7 +659,187 @@ async function installPackageCommon(options: { ...telemetryEvent!, status: 'success', }); - return { assets, status: 'installed', installType, installSource }; + return { assets, status: 'installed' as InstallResultStatus, installType, installSource }; + }) + .catch(async (err: Error) => { + logger.warn(`Failure to install package [${pkgName}]: [${err.toString()}]`, { + error: { stack_trace: err.stack }, + }); + await handleInstallPackageFailure({ + savedObjectsClient, + error: err, + pkgName, + pkgVersion, + installedPkg, + spaceId, + esClient, + authorizationHeader, + }); + sendEvent({ + ...telemetryEvent!, + errorMessage: err.message, + }); + return { error: err, installType, installSource }; + }); + } catch (e) { + sendEvent({ + ...telemetryEvent, + errorMessage: e.message, + }); + return { + error: e, + installType, + installSource, + }; + } finally { + span?.end(); + } +} + +async function installPackageWitStateMachine(options: { + pkgName: string; + pkgVersion: string; + installSource: InstallSource; + installedPkg?: SavedObject; + installType: InstallType; + savedObjectsClient: SavedObjectsClientContract; + esClient: ElasticsearchClient; + spaceId: string; + force?: boolean; + packageInstallContext: PackageInstallContext; + paths: string[]; + verificationResult?: PackageVerificationResult; + telemetryEvent?: PackageUpdateEvent; + authorizationHeader?: HTTPAuthorizationHeader | null; + ignoreMappingUpdateErrors?: boolean; + skipDataStreamRollover?: boolean; +}): Promise { + const packageInfo = options.packageInstallContext.packageInfo; + + const { + pkgName, + pkgVersion, + installSource, + installedPkg, + installType, + savedObjectsClient, + force, + esClient, + spaceId, + verificationResult, + authorizationHeader, + ignoreMappingUpdateErrors, + skipDataStreamRollover, + packageInstallContext, + } = options; + let { telemetryEvent } = options; + const logger = appContextService.getLogger(); + logger.info( + `Install with enablePackagesStateMachine - Starting installation of ${pkgName}@${pkgVersion} from ${installSource} ` + ); + + // Workaround apm issue with async spans: https://github.com/elastic/apm-agent-nodejs/issues/2611 + await Promise.resolve(); + const span = apm.startSpan( + `Install package from ${installSource} ${pkgName}@${pkgVersion}`, + 'package' + ); + + if (!telemetryEvent) { + telemetryEvent = getTelemetryEvent(pkgName, pkgVersion); + telemetryEvent.installType = installType; + telemetryEvent.currentVersion = installedPkg?.attributes.version || 'not_installed'; + } + + try { + span?.addLabels({ + packageName: pkgName, + packageVersion: pkgVersion, + installType, + }); + + const filteredPackages = getFilteredInstallPackages(); + if (filteredPackages.includes(pkgName)) { + throw new FleetUnauthorizedError(`${pkgName} installation is not authorized`); + } + + // if the requested version is the same as installed version, check if we allow it based on + // current installed package status and force flag, if we don't allow it, + // just return the asset references from the existing installation + if ( + installedPkg?.attributes.version === pkgVersion && + installedPkg?.attributes.install_status === 'installed' + ) { + if (!force) { + logger.debug(`${pkgName}-${pkgVersion} is already installed, skipping installation`); + return { + assets: [ + ...installedPkg.attributes.installed_es, + ...installedPkg.attributes.installed_kibana, + ], + status: 'already_installed', + installType, + installSource, + }; + } + } + const elasticSubscription = getElasticSubscription(packageInfo); + if (!licenseService.hasAtLeast(elasticSubscription)) { + logger.error(`Installation requires ${elasticSubscription} license`); + const err = new FleetError(`Installation requires ${elasticSubscription} license`); + sendEvent({ + ...telemetryEvent, + errorMessage: err.message, + }); + return { error: err, installType, installSource }; + } + + // Saved object client need to be scopped with the package space for saved object tagging + const savedObjectClientWithSpace = appContextService.getInternalUserSOClientForSpaceId(spaceId); + + const savedObjectsImporter = appContextService + .getSavedObjects() + .createImporter(savedObjectClientWithSpace, { importSizeLimit: 15_000 }); + + const savedObjectTagAssignmentService = appContextService + .getSavedObjectsTagging() + .createInternalAssignmentService({ client: savedObjectClientWithSpace }); + + const savedObjectTagClient = appContextService + .getSavedObjectsTagging() + .createTagClient({ client: savedObjectClientWithSpace }); + + // try installing the package, if there was an error, call error handler and rethrow + return await _stateMachineInstallPackage({ + savedObjectsClient, + savedObjectsImporter, + savedObjectTagAssignmentService, + savedObjectTagClient, + esClient, + logger, + installedPkg, + packageInstallContext, + installType, + spaceId, + verificationResult, + installSource, + authorizationHeader, + force, + ignoreMappingUpdateErrors, + skipDataStreamRollover, + }) + .then(async (assets) => { + logger.debug(`Removing old assets from previous versions of ${pkgName}`); + await removeOldAssets({ + soClient: savedObjectsClient, + pkgName: packageInfo.name, + currentVersion: packageInfo.version, + }); + sendEvent({ + ...telemetryEvent!, + status: 'success', + }); + return { assets, status: 'installed' as InstallResultStatus, installType, installSource }; }) .catch(async (err: Error) => { logger.warn(`Failure to install package [${pkgName}]: [${err.toString()}]`, { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.test.ts new file mode 100644 index 0000000000000..c77433774a5cf --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.test.ts @@ -0,0 +1,438 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { + PackageSavedObjectConflictError, + ConcurrentInstallOperationError, +} from '../../../../errors'; + +import type { Installation } from '../../../../../common'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common'; + +import { appContextService } from '../../../app_context'; +import { createAppContextStartContractMock } from '../../../../mocks'; +import { saveArchiveEntriesFromAssetsMap } from '../../archive/storage'; +import { installILMPolicy } from '../../elasticsearch/ilm/install'; +import { installIlmForDataStream } from '../../elasticsearch/datastream_ilm/install'; + +jest.mock('../../elasticsearch/template/template'); +jest.mock('../../kibana/assets/install'); +jest.mock('../../kibana/index_pattern/install'); +jest.mock('../install'); +jest.mock('../get'); +jest.mock('../install_index_template_pipeline'); + +jest.mock('../../archive/storage'); +jest.mock('../../elasticsearch/ilm/install'); +jest.mock('../../elasticsearch/datastream_ilm/install'); + +import { updateCurrentWriteIndices } from '../../elasticsearch/template/template'; +import { installKibanaAssetsAndReferences } from '../../kibana/assets/install'; + +import { MAX_TIME_COMPLETE_INSTALL } from '../../../../../common/constants'; + +import { restartInstallation } from '../install'; +import { installIndexTemplatesAndPipelines } from '../install_index_template_pipeline'; + +import { _stateMachineInstallPackage } from './_state_machine_package_install'; + +const mockedInstallIndexTemplatesAndPipelines = + installIndexTemplatesAndPipelines as jest.MockedFunction< + typeof installIndexTemplatesAndPipelines + >; +const mockedUpdateCurrentWriteIndices = updateCurrentWriteIndices as jest.MockedFunction< + typeof updateCurrentWriteIndices +>; +const mockedInstallKibanaAssetsAndReferences = + installKibanaAssetsAndReferences as jest.MockedFunction; + +function sleep(millis: number) { + return new Promise((resolve) => setTimeout(resolve, millis)); +} + +describe('_stateMachineInstallPackage', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + + soClient.update.mockImplementation(async (type, id, attributes) => { + return { id, attributes } as any; + }); + soClient.get.mockImplementation(async (type, id) => { + return { id, attributes: {} } as any; + }); + + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + jest.mocked(installILMPolicy).mockReset(); + jest.mocked(installIlmForDataStream).mockReset(); + jest.mocked(installIlmForDataStream).mockResolvedValue({ + esReferences: [], + installedIlms: [], + }); + jest.mocked(saveArchiveEntriesFromAssetsMap).mockResolvedValue({ + saved_objects: [], + }); + jest.mocked(restartInstallation).mockReset(); + }); + + it('Handles errors from installKibanaAssets', async () => { + // force errors from this function + mockedInstallKibanaAssetsAndReferences.mockImplementation(async () => { + throw new Error('mocked async error A: should be caught'); + }); + + // pick any function between when those are called and when await Promise.all is defined later + // and force it to take long enough for the errors to occur + // @ts-expect-error about call signature + mockedUpdateCurrentWriteIndices.mockImplementation(async () => await sleep(1000)); + mockedInstallIndexTemplatesAndPipelines.mockResolvedValue({ + installedTemplates: [], + esReferences: [], + }); + + const installationPromise = _stateMachineInstallPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + // if we have a .catch this will fail nicely (test pass) + // otherwise the test will fail with either of the mocked errors + await expect(installationPromise).rejects.toThrow('mocked'); + await expect(installationPromise).rejects.toThrow('should be caught'); + }); + + it('Do not install ILM policies if disabled in config', async () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + // force errors from this function + mockedInstallKibanaAssetsAndReferences.mockResolvedValue([]); + // pick any function between when those are called and when await Promise.all is defined later + // and force it to take long enough for the errors to occur + // @ts-expect-error about call signature + mockedUpdateCurrentWriteIndices.mockImplementation(async () => await sleep(1000)); + mockedInstallIndexTemplatesAndPipelines.mockResolvedValue({ + installedTemplates: [], + esReferences: [], + }); + await _stateMachineInstallPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(installILMPolicy).not.toBeCalled(); + expect(installIlmForDataStream).not.toBeCalled(); + }); + + it('Installs ILM policies if not disabled in config', async () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: false, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + // force errors from this function + mockedInstallKibanaAssetsAndReferences.mockResolvedValue([]); + // pick any function between when those are called and when await Promise.all is defined later + // and force it to take long enough for the errors to occur + // @ts-expect-error about call signature + mockedUpdateCurrentWriteIndices.mockImplementation(async () => await sleep(1000)); + mockedInstallIndexTemplatesAndPipelines.mockResolvedValue({ + installedTemplates: [], + esReferences: [], + }); + await _stateMachineInstallPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(installILMPolicy).toBeCalled(); + expect(installIlmForDataStream).toBeCalled(); + }); + + describe('When package is stuck in `installing`', () => { + const mockInstalledPackageSo: SavedObject = { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: [] as any, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + + beforeEach(() => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + }); + + describe('When timeout is reached', () => { + it('restarts installation', async () => { + await _stateMachineInstallPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + paths: [], + assetsMap: new Map(), + packageInfo: { + name: mockInstalledPackageSo.attributes.name, + version: mockInstalledPackageSo.attributes.version, + title: mockInstalledPackageSo.attributes.name, + } as any, + }, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date( + Date.now() - MAX_TIME_COMPLETE_INSTALL * 2 + ).toISOString(), + }, + }, + }); + + expect(restartInstallation).toBeCalled(); + }); + }); + + describe('When timeout is not reached', () => { + describe('With no force flag', () => { + it('throws concurrent installation error', async () => { + const installPromise = _stateMachineInstallPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + paths: [], + assetsMap: new Map(), + packageInfo: { + name: mockInstalledPackageSo.attributes.name, + version: mockInstalledPackageSo.attributes.version, + title: mockInstalledPackageSo.attributes.name, + } as any, + }, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }, + }); + + await expect(installPromise).rejects.toThrowError(ConcurrentInstallOperationError); + }); + }); + + describe('With force flag provided', () => { + it('restarts installation', async () => { + await _stateMachineInstallPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + paths: [], + assetsMap: new Map(), + packageInfo: { + name: mockInstalledPackageSo.attributes.name, + version: mockInstalledPackageSo.attributes.version, + title: mockInstalledPackageSo.attributes.name, + } as any, + }, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }, + force: true, + }); + + expect(restartInstallation).toBeCalled(); + }); + }); + }); + }); + + it('Surfaces saved object conflicts error', async () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: false, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + + mockedInstallKibanaAssetsAndReferences.mockRejectedValueOnce( + new PackageSavedObjectConflictError('test') + ); + + const installPromise = _stateMachineInstallPackage({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + await expect(installPromise).rejects.toThrowError(PackageSavedObjectConflictError); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts new file mode 100644 index 0000000000000..d66334b315a42 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/_state_machine_package_install.ts @@ -0,0 +1,179 @@ +/* + * 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, + SavedObject, + SavedObjectsClientContract, + ISavedObjectsImporter, +} from '@kbn/core/server'; +import { SavedObjectsErrorHelpers } from '@kbn/core/server'; + +import type { IAssignmentService, ITagsClient } from '@kbn/saved-objects-tagging-plugin/server'; + +import { PackageSavedObjectConflictError } from '../../../../errors'; + +import type { HTTPAuthorizationHeader } from '../../../../../common/http_authorization_header'; +import { INSTALL_STATES } from '../../../../../common/types'; +import type { PackageInstallContext, StateNames, StateContext } from '../../../../../common/types'; +import type { PackageAssetReference } from '../../../../types'; + +import type { + Installation, + InstallType, + InstallSource, + PackageVerificationResult, + EsAssetReference, + KibanaAssetReference, + IndexTemplateEntry, + AssetReference, +} from '../../../../types'; + +import { + stepCreateRestartInstallation, + stepInstallKibanaAssets, + stepInstallILMPolicies, + stepInstallMlModel, + stepInstallIndexTemplatePipelines, + stepRemoveLegacyTemplates, + stepUpdateCurrentWriteIndices, + stepInstallTransforms, + stepDeletePreviousPipelines, + stepSaveArchiveEntries, + stepResolveKibanaPromise, + stepSaveSystemObject, + updateLatestExecutedState, +} from './steps'; +import type { StateMachineDefinition } from './state_machine'; +import { handleState } from './state_machine'; + +export interface InstallContext extends StateContext { + savedObjectsClient: SavedObjectsClientContract; + savedObjectsImporter: Pick; + savedObjectTagAssignmentService: IAssignmentService; + savedObjectTagClient: ITagsClient; + esClient: ElasticsearchClient; + logger: Logger; + installedPkg?: SavedObject; + packageInstallContext: PackageInstallContext; + installType: InstallType; + installSource: InstallSource; + spaceId: string; + force?: boolean; + verificationResult?: PackageVerificationResult; + authorizationHeader?: HTTPAuthorizationHeader | null; + ignoreMappingUpdateErrors?: boolean; + skipDataStreamRollover?: boolean; + + indexTemplates?: IndexTemplateEntry[]; + packageAssetRefs?: PackageAssetReference[]; + // output values + esReferences?: EsAssetReference[]; + kibanaAssetPromise?: Promise; +} +/* + * _stateMachineInstallPackage installs packages using the generic state machine in ./state_machine + * installStates is the data structure providing the state machine definition + * Usually the install process starts with `create_restart_installation` and continues based on nextState parameter in the definition + * The `onTransition` functions are the steps executed to go from one state to another, and all accept an `InstallContext` object as input parameter + * After each transition `updateLatestExecutedState` is executed, it updates the executed state in the SO + */ +export async function _stateMachineInstallPackage( + context: InstallContext +): Promise { + const installStates: StateMachineDefinition = { + context, + states: { + create_restart_installation: { + nextState: 'install_kibana_assets', + onTransition: stepCreateRestartInstallation, + onPostTransition: updateLatestExecutedState, + }, + install_kibana_assets: { + onTransition: stepInstallKibanaAssets, + nextState: 'install_ilm_policies', + onPostTransition: updateLatestExecutedState, + }, + install_ilm_policies: { + onTransition: stepInstallILMPolicies, + nextState: 'install_ml_model', + onPostTransition: updateLatestExecutedState, + }, + install_ml_model: { + onTransition: stepInstallMlModel, + nextState: 'install_index_template_pipelines', + onPostTransition: updateLatestExecutedState, + }, + install_index_template_pipelines: { + onTransition: stepInstallIndexTemplatePipelines, + nextState: 'remove_legacy_templates', + onPostTransition: updateLatestExecutedState, + }, + remove_legacy_templates: { + onTransition: stepRemoveLegacyTemplates, + nextState: 'update_current_write_indices', + onPostTransition: updateLatestExecutedState, + }, + update_current_write_indices: { + onTransition: stepUpdateCurrentWriteIndices, + nextState: 'install_transforms', + onPostTransition: updateLatestExecutedState, + }, + install_transforms: { + onTransition: stepInstallTransforms, + nextState: 'delete_previous_pipelines', + onPostTransition: updateLatestExecutedState, + }, + delete_previous_pipelines: { + onTransition: stepDeletePreviousPipelines, + nextState: 'save_archive_entries_from_assets_map', + onPostTransition: updateLatestExecutedState, + }, + save_archive_entries_from_assets_map: { + onTransition: stepSaveArchiveEntries, + nextState: 'resolve_kibana_promise', + onPostTransition: updateLatestExecutedState, + }, + resolve_kibana_promise: { + onTransition: stepResolveKibanaPromise, + nextState: 'update_so', + onPostTransition: updateLatestExecutedState, + }, + update_so: { + onTransition: stepSaveSystemObject, + nextState: 'end', + onPostTransition: updateLatestExecutedState, + }, + }, + }; + try { + const { installedKibanaAssetsRefs, esReferences } = await handleState( + INSTALL_STATES.CREATE_RESTART_INSTALLATION, + installStates, + installStates.context + ); + return [ + ...(installedKibanaAssetsRefs as KibanaAssetReference[]), + ...(esReferences as EsAssetReference[]), + ]; + } catch (err) { + const { packageInfo } = installStates.context.packageInstallContext; + const { name: pkgName, version: pkgVersion } = packageInfo; + + if (SavedObjectsErrorHelpers.isConflictError(err)) { + throw new PackageSavedObjectConflictError( + `Saved Object conflict encountered while installing ${pkgName || 'unknown'}-${ + pkgVersion || 'unknown' + }. There may be a conflicting Saved Object saved to another Space. Original error: ${ + err.message + }` + ); + } else { + throw err; + } + } +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/state_machine.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/state_machine.test.ts new file mode 100644 index 0000000000000..f6e1f8fba5a20 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/state_machine.test.ts @@ -0,0 +1,542 @@ +/* + * 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 { createAppContextStartContractMock } from '../../../../mocks'; +import { appContextService } from '../../..'; + +import { handleState } from './state_machine'; + +const getTestDefinition = ( + mockOnTransition1: any, + mockOnTransition2: any, + mockOnTransition3: any, + context?: any, + onPostTransition?: any +) => { + return { + context, + states: { + state1: { + onTransition: mockOnTransition1, + onPostTransition, + nextState: 'state2', + }, + state2: { + onTransition: mockOnTransition2, + onPostTransition, + nextState: 'state3', + }, + state3: { + onTransition: mockOnTransition3, + onPostTransition, + nextState: 'end', + }, + }, + }; +}; + +describe('handleState', () => { + let mockContract: ReturnType; + beforeEach(async () => { + // prevents `Logger not set.` and other appContext errors + mockContract = createAppContextStartContractMock(); + appContextService.start(mockContract); + }); + afterEach(() => { + jest.resetAllMocks(); + appContextService.stop(); + }); + + it('should execute all the state machine transitions based on the provided data structure', async () => { + const mockOnTransitionState1 = jest.fn(); + const mockOnTransitionState2 = jest.fn(); + const mockOnTransitionState3 = jest.fn(); + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3 + ); + + await handleState('state1', testDefinition, testDefinition.context); + expect(mockOnTransitionState1).toHaveBeenCalledTimes(1); + expect(mockOnTransitionState2).toHaveBeenCalledTimes(1); + expect(mockOnTransitionState3).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state1 with status: success - nextState: state2' + ); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state2 with status: success - nextState: state3' + ); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state3 with status: success - nextState: end' + ); + }); + + it('should call the onTransition function with context data and the return value is saved for the next iteration', async () => { + const mockOnTransitionState1 = jest.fn().mockReturnValue({ arrayData: ['test1', 'test2'] }); + const mockOnTransitionState2 = jest + .fn() + .mockImplementation(() => Promise.resolve({ promiseData: {} })); + const mockOnTransitionState3 = jest.fn().mockReturnValue({ lastData: ['test3'] }); + const contextData = { testData: 'test' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData + ); + + await handleState('state1', testDefinition, testDefinition.context); + expect(mockOnTransitionState1).toHaveBeenCalledWith({ testData: 'test' }); + expect(mockOnTransitionState2).toHaveBeenCalledWith( + expect.objectContaining({ + testData: 'test', + arrayData: ['test1', 'test2'], + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + }, + }) + ); + expect(mockOnTransitionState3).toHaveBeenCalledWith( + expect.objectContaining({ + testData: 'test', + arrayData: ['test1', 'test2'], + promiseData: {}, + latestExecutedState: { + name: 'state2', + started_at: expect.anything(), + }, + }) + ); + + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state1 with status: success - nextState: state2' + ); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state2 with status: success - nextState: state3' + ); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state3 with status: success - nextState: end' + ); + }); + + it('should save the return data from transitions also when return type is function', async () => { + const mockOnTransitionState1 = jest.fn().mockReturnValue({ arrayData: ['test1', 'test2'] }); + const state2Result = () => { + return { + result: 'test', + }; + }; + const mockOnTransitionState2 = jest.fn().mockImplementation(() => { + return state2Result; + }); + const mockOnTransitionState3 = jest.fn(); + const contextData = { testData: 'test' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData + ); + + await handleState('state1', testDefinition, testDefinition.context); + expect(mockOnTransitionState1).toHaveBeenCalledWith({ testData: 'test' }); + expect(mockOnTransitionState2).toHaveBeenCalledWith( + expect.objectContaining({ + testData: 'test', + arrayData: ['test1', 'test2'], + state2Result, + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + }, + }) + ); + expect(mockOnTransitionState3).toHaveBeenCalledWith( + expect.objectContaining({ + testData: 'test', + arrayData: ['test1', 'test2'], + state2Result, + latestExecutedState: { + name: 'state2', + started_at: expect.anything(), + }, + }) + ); + + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state1 with status: success - nextState: state2' + ); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state2 with status: success - nextState: state3' + ); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executed state: state3 with status: success - nextState: end' + ); + }); + + it('should return updated context data', async () => { + const mockOnTransitionState1 = jest + .fn() + .mockImplementation(() => Promise.resolve({ promiseData: {} })); + const state2Result = () => { + return { + result: 'test', + }; + }; + const mockOnTransitionState2 = jest.fn().mockImplementation(() => { + return state2Result; + }); + const mockOnTransitionState3 = jest.fn().mockReturnValue({ lastData: ['test3'] }); + const contextData = { testData: 'test' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData + ); + + const updatedContext = await handleState('state1', testDefinition, testDefinition.context); + expect(mockOnTransitionState1).toHaveBeenCalledWith({ testData: 'test' }); + expect(mockOnTransitionState2).toHaveBeenCalledWith( + expect.objectContaining({ + testData: 'test', + promiseData: {}, + state2Result, + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + }, + }) + ); + expect(mockOnTransitionState3).toHaveBeenCalledWith( + expect.objectContaining({ + testData: 'test', + promiseData: {}, + state2Result, + latestExecutedState: { + name: 'state2', + started_at: expect.anything(), + }, + }) + ); + + expect(updatedContext).toEqual( + expect.objectContaining({ + testData: 'test', + promiseData: {}, + state2Result, + lastData: ['test3'], + latestExecutedState: { + name: 'state3', + started_at: expect.anything(), + }, + }) + ); + }); + + it('should update a variable in the context at every call and return the updated value', async () => { + const mockOnTransitionState1 = jest.fn().mockReturnValue({ runningVal: 'test1' }); + const mockOnTransitionState2 = jest + .fn() + .mockImplementation(() => Promise.resolve({ runningVal: 'test2' })); + const mockOnTransitionState3 = jest.fn().mockReturnValue({ runningVal: 'test3' }); + const contextData = { runningVal: [], fixedVal: 'something' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData + ); + + const updatedContext = await handleState('state1', testDefinition, testDefinition.context); + expect(mockOnTransitionState1).toHaveBeenCalledWith({ runningVal: [], fixedVal: 'something' }); + expect(mockOnTransitionState2).toHaveBeenCalledWith( + expect.objectContaining({ + runningVal: 'test1', + fixedVal: 'something', + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + }, + }) + ); + expect(mockOnTransitionState3).toHaveBeenCalledWith( + expect.objectContaining({ + runningVal: 'test2', + fixedVal: 'something', + latestExecutedState: { + name: 'state2', + started_at: expect.anything(), + }, + }) + ); + expect(updatedContext).toEqual( + expect.objectContaining({ + fixedVal: 'something', + runningVal: 'test3', + latestExecutedState: { + name: 'state3', + started_at: expect.anything(), + }, + }) + ); + }); + + it('should execute the transition starting from the provided state', async () => { + const mockOnTransitionState1 = jest.fn().mockReturnValue({ runningVal: 'test1' }); + const mockOnTransitionState2 = jest + .fn() + .mockImplementation(() => Promise.resolve({ runningVal: 'test2' })); + const mockOnTransitionState3 = jest.fn().mockReturnValue({ runningVal: 'test3' }); + const contextData = { runningVal: [], fixedVal: 'something' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData + ); + + const updatedContext = await handleState('state2', testDefinition, testDefinition.context); + + expect(mockOnTransitionState1).toHaveBeenCalledTimes(0); + expect(mockOnTransitionState2).toHaveBeenCalledWith( + expect.objectContaining({ + runningVal: [], + fixedVal: 'something', + }) + ); + expect(mockOnTransitionState3).toHaveBeenCalledWith( + expect.objectContaining({ + runningVal: 'test2', + fixedVal: 'something', + latestExecutedState: { + name: 'state2', + started_at: expect.anything(), + }, + }) + ); + expect(updatedContext).toEqual( + expect.objectContaining({ + fixedVal: 'something', + runningVal: 'test3', + latestExecutedState: { + name: 'state3', + started_at: expect.anything(), + }, + }) + ); + }); + + it('should throw and return updated context with latest error when a state returns error', async () => { + const error = new Error('Installation failed'); + const mockOnTransitionState1 = jest.fn().mockRejectedValue(error); + const mockOnTransitionState2 = jest.fn(); + const mockOnTransitionState3 = jest.fn(); + const contextData = { fixedVal: 'something' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData + ); + const promise = handleState('state1', testDefinition, testDefinition.context); + await expect(promise).rejects.toThrowError('Installation failed'); + + expect(mockOnTransitionState1).toHaveBeenCalledTimes(1); + expect(mockOnTransitionState2).toHaveBeenCalledTimes(0); + expect(mockOnTransitionState3).toHaveBeenCalledTimes(0); + expect(mockContract.logger?.warn).toHaveBeenCalledWith( + 'Error during execution of state "state1" with status "failed": Installation failed' + ); + }); + + it('should execute postTransition function after the transition is complete', async () => { + const mockOnTransitionState1 = jest.fn(); + const mockOnTransitionState2 = jest.fn(); + const mockOnTransitionState3 = jest.fn(); + const mockPostTransition = jest.fn(); + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + undefined, + mockPostTransition + ); + await handleState('state1', testDefinition, testDefinition.context); + + expect(mockOnTransitionState1).toHaveBeenCalled(); + expect(mockPostTransition).toHaveBeenCalled(); + expect(mockOnTransitionState2).toHaveBeenCalled(); + expect(mockPostTransition).toHaveBeenCalled(); + expect(mockOnTransitionState3).toHaveBeenCalled(); + expect(mockPostTransition).toHaveBeenCalled(); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executing post transition function: mockConstructor' + ); + }); + + it('should execute postTransition function after the transition passing the updated context', async () => { + const mockOnTransitionState1 = jest.fn().mockReturnValue({ runningVal: 'test1' }); + const mockOnTransitionState2 = jest + .fn() + .mockImplementation(() => Promise.resolve({ runningVal: 'test2' })); + const mockOnTransitionState3 = jest.fn().mockReturnValue({ runningVal: 'test3' }); + const mockPostTransition = jest.fn(); + const contextData = { fixedVal: 'something' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData, + mockPostTransition + ); + const updatedContext = await handleState('state1', testDefinition, testDefinition.context); + + expect(mockOnTransitionState1).toHaveBeenCalled(); + expect(mockPostTransition).toHaveBeenCalled(); + expect(mockOnTransitionState2).toHaveBeenCalled(); + expect(mockPostTransition).toHaveBeenCalled(); + expect(mockOnTransitionState3).toHaveBeenCalled(); + expect(updatedContext).toEqual( + expect.objectContaining({ + fixedVal: 'something', + runningVal: 'test3', + latestExecutedState: { + name: 'state3', + started_at: expect.anything(), + }, + }) + ); + expect(mockPostTransition).toHaveBeenCalledWith(updatedContext); + expect(mockContract.logger?.debug).toHaveBeenCalledWith( + 'Executing post transition function: mockConstructor' + ); + }); + + it('should execute postTransition correctly also when a transition throws', async () => { + const error = new Error('Installation failed'); + const mockOnTransitionState1 = jest.fn().mockReturnValue({ result1: 'test' }); + const mockOnTransitionState2 = jest.fn().mockRejectedValue(error); + const mockOnTransitionState3 = jest.fn(); + const mockPostTransition = jest.fn(); + const contextData = { testData: 'test' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData, + mockPostTransition + ); + const promise = handleState('state1', testDefinition, testDefinition.context); + await expect(promise).rejects.toThrowError('Installation failed'); + + expect(mockOnTransitionState1).toHaveBeenCalledTimes(1); + expect(mockPostTransition).toHaveBeenCalledWith( + expect.objectContaining({ + result1: 'test', + testData: 'test', + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + error: + 'Error during execution of state "state2" with status "failed": Installation failed', + }, + }) + ); + expect(mockOnTransitionState2).toHaveBeenCalledTimes(1); + expect(mockPostTransition).toHaveBeenCalledWith( + expect.objectContaining({ + result1: 'test', + testData: 'test', + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + }, + }) + ); + expect(mockOnTransitionState3).toHaveBeenCalledTimes(0); + }); + + it('should log a warning when postTransition exits with errors and continue executing the states', async () => { + const error = new Error('Installation failed'); + const mockOnTransitionState1 = jest.fn().mockReturnValue({ result1: 'test' }); + const mockOnTransitionState2 = jest.fn(); + const mockOnTransitionState3 = jest.fn(); + const mockPostTransition = jest.fn().mockRejectedValue(error); + const contextData = { testData: 'test' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData, + mockPostTransition + ); + const updatedContext = await handleState('state1', testDefinition, testDefinition.context); + + expect(mockOnTransitionState1).toHaveBeenCalledTimes(1); + expect(mockPostTransition).toHaveBeenCalledWith( + expect.objectContaining({ + result1: 'test', + testData: 'test', + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + }, + }) + ); + expect(mockOnTransitionState2).toHaveBeenCalledTimes(1); + expect(mockOnTransitionState3).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.warn).toHaveBeenCalledWith( + 'Error during execution of post transition function: Installation failed' + ); + + expect(updatedContext).toEqual( + expect.objectContaining({ + testData: 'test', + result1: 'test', + latestExecutedState: { + name: 'state3', + started_at: expect.anything(), + }, + }) + ); + }); + + it('should exit and log a warning when the provided OnTransition is not a function', async () => { + const mockOnTransitionState1 = jest.fn().mockReturnValue({ result1: 'test' }); + const mockOnTransitionState2 = undefined; + const mockOnTransitionState3 = jest.fn(); + + const contextData = { testData: 'test' }; + const testDefinition = getTestDefinition( + mockOnTransitionState1, + mockOnTransitionState2, + mockOnTransitionState3, + contextData + ); + const updatedContext = await handleState('state1', testDefinition, testDefinition.context); + + expect(mockOnTransitionState1).toHaveBeenCalledTimes(1); + expect(mockOnTransitionState3).toHaveBeenCalledTimes(0); + expect(mockContract.logger?.warn).toHaveBeenCalledWith( + 'Execution of state "state2" with status "failed": provided onTransition is not a valid function' + ); + + expect(updatedContext).toEqual( + expect.objectContaining({ + testData: 'test', + result1: 'test', + latestExecutedState: { + name: 'state1', + started_at: expect.anything(), + }, + }) + ); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/state_machine.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/state_machine.ts new file mode 100644 index 0000000000000..c70a99e272361 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/state_machine.ts @@ -0,0 +1,139 @@ +/* + * 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 { Logger } from '@kbn/core/server'; + +import { appContextService } from '../../../app_context'; +import type { StateContext, LatestExecutedState } from '../../../../../common/types'; +export interface State { + onTransition: any; + nextState?: string; + currentStatus?: string; + onPostTransition?: any; +} + +export type StatusName = 'success' | 'failed' | 'pending'; +export type StateMachineStates = Record; +/* + * Data structure defining the state machine + * { + * context: {}, + * states: { + * state1: { + * onTransition: onState1Transition, + * onPostTransition: onPostTransition, + * nextState: 'state2', + * }, + * state2: { + * onTransition: onState2Transition, + * onPostTransition: onPostTransition,, + * nextState: 'state3', + * }, + * state3: { + * onTransition: onState3Transition, + * onPostTransition: onPostTransition, + * nextState: 'end', + * } + * } + */ +export interface StateMachineDefinition { + context: StateContext; + states: StateMachineStates; +} +/* + * Generic state machine implemented to handle state transitions, based on a provided data structure + * currentStateName: iniital state + * definition: data structure defined as a StateMachineDefinition + * context: object keeping the state between transitions. All the transition functions accept it as input parameter and write to it + * + * It recursively traverses all the states until it finds the last state. + */ +export async function handleState( + currentStateName: string, + definition: StateMachineDefinition, + context: StateContext +): Promise> { + const logger = appContextService.getLogger(); + const { states } = definition; + const currentState = states[currentStateName]; + let currentStatus = 'pending'; + let stateResult; + let updatedContext = { ...context }; + if (typeof currentState.onTransition === 'function') { + logger.debug( + `Current state ${currentStateName}: running transition ${currentState.onTransition.name}` + ); + try { + // inject information about the state into context + const startedAt = new Date(Date.now()).toISOString(); + const latestExecutedState: LatestExecutedState = { + name: currentStateName, + started_at: startedAt, + }; + stateResult = await currentState.onTransition.call(undefined, updatedContext); + // check if is a function/promise + if (typeof stateResult === 'function') { + const promiseName = `${currentStateName}Result`; + updatedContext[promiseName] = stateResult; + updatedContext = { ...updatedContext, latestExecutedState }; + } else { + updatedContext = { + ...updatedContext, + ...stateResult, + latestExecutedState, + }; + } + currentStatus = 'success'; + logger.debug( + `Executed state: ${currentStateName} with status: ${currentStatus} - nextState: ${currentState.nextState}` + ); + } catch (error) { + currentStatus = 'failed'; + const errorMessage = `Error during execution of state "${currentStateName}" with status "${currentStatus}": ${error.message}`; + const latestStateWithError = { + ...updatedContext.latestExecutedState, + error: errorMessage, + } as LatestExecutedState; + updatedContext = { ...updatedContext, latestExecutedState: latestStateWithError }; + logger.warn(errorMessage); + + // execute post transition function when transition failed too + await executePostTransition(logger, updatedContext, currentState); + + // bubble up the error + throw error; + } + } else { + currentStatus = 'failed'; + logger.warn( + `Execution of state "${currentStateName}" with status "${currentStatus}": provided onTransition is not a valid function` + ); + } + // execute post transition function + await executePostTransition(logger, updatedContext, currentState); + + if (currentStatus === 'success' && currentState?.nextState && currentState?.nextState !== 'end') { + return await handleState(currentState.nextState, definition, updatedContext); + } else { + return updatedContext; + } +} + +async function executePostTransition( + logger: Logger, + updatedContext: StateContext, + currentState: State +) { + if (typeof currentState.onPostTransition === 'function') { + try { + await currentState.onPostTransition.call(undefined, updatedContext); + logger.debug(`Executing post transition function: ${currentState.onPostTransition.name}`); + } catch (error) { + logger.warn(`Error during execution of post transition function: ${error.message}`); + } + } +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/index.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/index.ts new file mode 100644 index 0000000000000..c34c4f566715b --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './step_create_restart_installation'; +export * from './step_install_kibana_assets'; +export * from './step_install_mlmodel'; +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_install_transforms'; +export * from './step_delete_previous_pipelines'; +export * from './step_save_archive_entries'; +export * from './step_save_system_object'; +export * from './step_resolve_kibana_promise'; +export * from './update_latest_executed_state'; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.test.ts new file mode 100644 index 0000000000000..9323841daba00 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.test.ts @@ -0,0 +1,198 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { + savedObjectsClientMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '@kbn/core/server/mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { + MAX_TIME_COMPLETE_INSTALL, + PACKAGES_SAVED_OBJECT_TYPE, +} from '../../../../../../common/constants'; + +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; + +import { INSTALL_STATES } from '../../../../../../common/types'; + +import { auditLoggingService } from '../../../../audit_logging'; +import { restartInstallation, createInstallation } from '../../install'; +import type { Installation } from '../../../../../../common'; + +import { stepCreateRestartInstallation } from './step_create_restart_installation'; + +jest.mock('../../../../audit_logging'); +jest.mock('../../install'); + +const mockedRestartInstallation = jest.mocked(restartInstallation); +const mockedCreateInstallation = createInstallation as jest.Mocked; + +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; + +describe('stepCreateRestartInstallation', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const logger = loggingSystemMock.createLogger(); + const mockInstalledPackageSo: SavedObject = { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: [] as any, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(() => { + mockedAuditLoggingService.writeCustomSoAuditLog.mockReset(); + soClient.update.mockReset(); + // mockedCreateInstallation.mockReset(); + }); + + it('Should call createInstallation if no installedPkg is available', async () => { + await stepCreateRestartInstallation({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + latestExecutedState: { + name: INSTALL_STATES.SAVE_ARCHIVE_ENTRIES, + started_at: new Date(Date.now() - MAX_TIME_COMPLETE_INSTALL * 2).toISOString(), + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + expect(logger.debug).toHaveBeenCalledWith(`Package install - Create installation`); + expect(mockedCreateInstallation).toHaveBeenCalledTimes(1); + }); + + it('Should call restartInstallation if installedPkg is available and force = true', async () => { + await stepCreateRestartInstallation({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }, + force: true, + latestExecutedState: { + name: INSTALL_STATES.SAVE_ARCHIVE_ENTRIES, + started_at: new Date(Date.now() - MAX_TIME_COMPLETE_INSTALL * 2).toISOString(), + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + expect(mockedRestartInstallation).toHaveBeenCalledTimes(1); + }); + + it('Should call restartInstallation and throw if installedPkg is available and force is not provided', async () => { + const promise = stepCreateRestartInstallation({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }, + latestExecutedState: { + name: INSTALL_STATES.SAVE_ARCHIVE_ENTRIES, + started_at: new Date(Date.now() - MAX_TIME_COMPLETE_INSTALL * 2).toISOString(), + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + await expect(promise).rejects.toThrowError( + 'Concurrent installation or upgrade of xyz-4.5.6 detected, aborting.' + ); + }); + expect(mockedRestartInstallation).toHaveBeenCalledTimes(0); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.ts new file mode 100644 index 0000000000000..58daa6c379134 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_create_restart_installation.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ConcurrentInstallOperationError } from '../../../../../errors'; +import { MAX_TIME_COMPLETE_INSTALL } from '../../../../../constants'; + +import { restartInstallation, createInstallation } from '../../install'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepCreateRestartInstallation(context: InstallContext) { + const { + savedObjectsClient, + logger, + installSource, + packageInstallContext, + spaceId, + force, + verificationResult, + installedPkg, + } = context; + const { packageInfo } = packageInstallContext; + const { name: pkgName, version: pkgVersion } = packageInfo; + + // if some installation already exists + if (installedPkg) { + const isStatusInstalling = installedPkg.attributes.install_status === 'installing'; + const hasExceededTimeout = + Date.now() - Date.parse(installedPkg.attributes.install_started_at) < + MAX_TIME_COMPLETE_INSTALL; + logger.debug(`Package install - Install status ${installedPkg.attributes.install_status}`); + + // if the installation is currently running, don't try to install + // instead, only return already installed assets + if (isStatusInstalling && hasExceededTimeout) { + // If this is a forced installation, ignore the timeout and restart the installation anyway + logger.debug(`Package install - Installation is running and has exceeded timeout`); + + if (force) { + logger.debug(`Package install - Forced installation, restarting`); + await restartInstallation({ + savedObjectsClient, + pkgName, + pkgVersion, + installSource, + verificationResult, + }); + } else { + throw new ConcurrentInstallOperationError( + `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ + pkgVersion || 'unknown' + } detected, aborting.` + ); + } + } else { + // if no installation is running, or the installation has been running longer than MAX_TIME_COMPLETE_INSTALL + // (it might be stuck) update the saved object and proceed + logger.debug( + `Package install - no installation running or the installation has been running longer than ${MAX_TIME_COMPLETE_INSTALL}, restarting` + ); + await restartInstallation({ + savedObjectsClient, + pkgName, + pkgVersion, + installSource, + verificationResult, + }); + } + } else { + logger.debug(`Package install - Create installation`); + + await createInstallation({ + savedObjectsClient, + packageInfo, + installSource, + spaceId, + verificationResult, + }); + } +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.test.ts new file mode 100644 index 0000000000000..7d8a251433bb5 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.test.ts @@ -0,0 +1,481 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; +import { ElasticsearchAssetType } from '../../../../../types'; + +import type { EsAssetReference, Installation } from '../../../../../../common'; +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { + isTopLevelPipeline, + deletePreviousPipelines, +} from '../../../elasticsearch/ingest_pipeline'; + +import { stepDeletePreviousPipelines } from './step_delete_previous_pipelines'; + +jest.mock('../../../elasticsearch/ingest_pipeline'); + +const mockedDeletePreviousPipelines = deletePreviousPipelines as jest.MockedFunction< + typeof deletePreviousPipelines +>; +const mockedIsTopLevelPipeline = isTopLevelPipeline as jest.MockedFunction< + typeof isTopLevelPipeline +>; + +describe('stepDeletePreviousPipelines', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const getMockInstalledPackageSo = ( + installedEs: EsAssetReference[] = [] + ): SavedObject => { + return { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: installedEs, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + }; + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(mockedDeletePreviousPipelines).mockReset(); + jest.mocked(mockedIsTopLevelPipeline).mockReset(); + }); + + describe('Should call deletePreviousPipelines', () => { + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'test-package', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + beforeEach(async () => { + jest.mocked(mockedDeletePreviousPipelines).mockResolvedValue([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ]); + }); + + it('if installType is update', async () => { + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + installedPkg.attributes.name, + installedPkg.attributes.version, + [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ] + ); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('if installType is reupdate', async () => { + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'reupdate', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + installedPkg.attributes.name, + installedPkg.attributes.version, + [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ] + ); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('if installType is rollback', async () => { + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'rollback', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + installedPkg.attributes.name, + installedPkg.attributes.version, + [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ] + ); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + }); + + describe('Should not call deletePreviousPipelines', () => { + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'test-package', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + beforeEach(async () => { + jest.mocked(mockedDeletePreviousPipelines).mockResolvedValue([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ]); + }); + + it('if installType is update and installedPkg is not present', async () => { + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).not.toBeCalled(); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('if installType is reupdate and installedPkg is not present', async () => { + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installType: 'reupdate', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).not.toBeCalled(); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('if installType is rollback and installedPkg is not present', async () => { + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installType: 'rollback', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).not.toBeCalled(); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('if installType type is of different type', async () => { + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + jest.mocked(mockedIsTopLevelPipeline).mockImplementation(() => true); + + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { ...packageInstallContext, paths: ['some/path/1', 'some/path/2'] }, + installType: 'install', + installedPkg, + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).not.toBeCalled(); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('if installedPkg is present and there is a top level pipeline', async () => { + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + jest.mocked(mockedIsTopLevelPipeline).mockImplementation(() => true); + + const res = await stepDeletePreviousPipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { ...packageInstallContext, paths: ['some/path/1', 'some/path/2'] }, + installType: 'update', + installedPkg, + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedDeletePreviousPipelines).not.toBeCalled(); + expect(res).toEqual({ + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.ts new file mode 100644 index 0000000000000..eb80ef16dbcb0 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_delete_previous_pipelines.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 { + isTopLevelPipeline, + deletePreviousPipelines, +} from '../../../elasticsearch/ingest_pipeline'; + +import { withPackageSpan } from '../../utils'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepDeletePreviousPipelines(context: InstallContext) { + const { + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences, + installType, + installedPkg, + } = context; + const { packageInfo, paths } = packageInstallContext; + const { name: pkgName } = packageInfo; + let updatedESReferences; + // If this is an update or retrying an update, delete the previous version's pipelines + // Top-level pipeline assets will not be removed on upgrade as of ml model package addition which requires previous + // assets to remain installed. This is a temporary solution - more robust solution tracked here https://github.com/elastic/kibana/issues/115035 + if ( + paths.filter((path) => isTopLevelPipeline(path)).length === 0 && + (installType === 'update' || installType === 'reupdate') && + installedPkg + ) { + logger.debug(`Package install - installType ${installType} Deleting previous ingest pipelines`); + updatedESReferences = await withPackageSpan('Delete previous ingest pipelines', () => + deletePreviousPipelines( + esClient, + savedObjectsClient, + pkgName, + installedPkg!.attributes.version, + esReferences || [] + ) + ); + } else if (installType === 'rollback' && installedPkg) { + // pipelines from a different version may have been installed during a failed update + logger.debug(`Package install - installType ${installType} Deleting previous ingest pipelines`); + updatedESReferences = await withPackageSpan('Delete previous ingest pipelines', () => + deletePreviousPipelines( + esClient, + savedObjectsClient, + pkgName, + installedPkg!.attributes.install_version, + esReferences || [] + ) + ); + } else { + // if none of the previous cases, return the original esReferences + updatedESReferences = esReferences; + } + return { esReferences: updatedESReferences }; +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.test.ts new file mode 100644 index 0000000000000..210a6b882ceed --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.test.ts @@ -0,0 +1,374 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; + +import type { Installation } from '../../../../../../common'; + +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { installILMPolicy } from '../../../elasticsearch/ilm/install'; +import { installIlmForDataStream } from '../../../elasticsearch/datastream_ilm/install'; +import { ElasticsearchAssetType } from '../../../../../types'; + +jest.mock('../../../archive/storage'); +jest.mock('../../../elasticsearch/ilm/install'); +jest.mock('../../../elasticsearch/datastream_ilm/install'); + +import { stepInstallILMPolicies } from './step_install_ilm_policies'; + +describe('stepInstallILMPolicies', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const mockInstalledPackageSo: SavedObject = { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: [ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + ] as any, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }; + + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(installILMPolicy).mockReset(); + jest.mocked(installIlmForDataStream).mockReset(); + }); + + it('Should not install ILM policies if disabled in config', async () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + await stepInstallILMPolicies({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(installILMPolicy).not.toBeCalled(); + expect(installIlmForDataStream).not.toBeCalled(); + }); + + it('Should not install ILM policies if disabled in config and should return esReferences form installedPkg', async () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const res = await stepInstallILMPolicies({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + installed_es: [ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + ], + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(installILMPolicy).not.toBeCalled(); + expect(installIlmForDataStream).not.toBeCalled(); + expect(res?.esReferences).toEqual([ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + ]); + }); + + it('Should installs ILM policies if not disabled in config', async () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: false, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + jest.mocked(installILMPolicy).mockResolvedValue([]); + jest.mocked(installIlmForDataStream).mockResolvedValue({ + esReferences: [ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + ], + installedIlms: [], + }); + const res = await stepInstallILMPolicies({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(installILMPolicy).toHaveBeenCalled(); + expect(installIlmForDataStream).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(Object), + expect.any(Object), + [] + ); + expect(res?.esReferences).toEqual([ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + ]); + }); + + it('should return updated esReferences', async () => { + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: false, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + jest.mocked(installILMPolicy).mockResolvedValue([ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + { + id: 'endpoint.metadata_current-default-0.2.0', + type: ElasticsearchAssetType.transform, + }, + ] as any); + jest.mocked(installIlmForDataStream).mockResolvedValue({ + esReferences: [ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + { + id: 'endpoint.metadata_current-default-0.2.0', + type: ElasticsearchAssetType.transform, + }, + ], + installedIlms: [], + }); + + const res = await stepInstallILMPolicies({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg: { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(installILMPolicy).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(Object), + expect.any(Object), + [ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + ] + ); + expect(installIlmForDataStream).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(Object), + expect.any(Object), + [ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + { + id: 'endpoint.metadata_current-default-0.2.0', + type: ElasticsearchAssetType.transform, + }, + ] + ); + expect(res?.esReferences).toEqual([ + { + id: 'metrics-endpoint.policy-0.1.0-dev.0', + type: ElasticsearchAssetType.ingestPipeline, + }, + { + id: 'endpoint.metadata_current-default-0.1.0', + type: ElasticsearchAssetType.transform, + }, + { + id: 'endpoint.metadata_current-default-0.2.0', + type: ElasticsearchAssetType.transform, + }, + ]); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.ts new file mode 100644 index 0000000000000..0e0d4ca2779f2 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_ilm_policies.ts @@ -0,0 +1,45 @@ +/* + * 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 '../../../..'; + +import { installIlmForDataStream } from '../../../elasticsearch/datastream_ilm/install'; +import { installILMPolicy } from '../../../elasticsearch/ilm/install'; + +import { withPackageSpan } from '../../utils'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepInstallILMPolicies(context: InstallContext) { + const { logger, packageInstallContext, esClient, savedObjectsClient, installedPkg } = context; + + // Array that gets updated by each operation. This allows each operation to accurately update the + // installation object with its references without requiring a refresh of the SO index on each update (faster). + let esReferences = installedPkg?.attributes.installed_es ?? []; + + // currently only the base package has an ILM policy + // at some point ILM policies can be installed/modified + // per data stream and we should then save them + const isILMPoliciesDisabled = + appContextService.getConfig()?.internal?.disableILMPolicies ?? false; + if (!isILMPoliciesDisabled) { + esReferences = await withPackageSpan('Install ILM policies', () => + installILMPolicy(packageInstallContext, esClient, savedObjectsClient, logger, esReferences) + ); + ({ esReferences } = await withPackageSpan('Install Data Stream ILM policies', () => + installIlmForDataStream( + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences + ) + )); + } + // always return esReferences even when isILMPoliciesDisabled is false as it's the first time we are writing to it + return { esReferences }; +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.test.ts new file mode 100644 index 0000000000000..92a76eada06ec --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.test.ts @@ -0,0 +1,592 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; +import { ElasticsearchAssetType } from '../../../../../types'; + +import type { EsAssetReference, Installation } from '../../../../../../common'; +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { installIndexTemplatesAndPipelines } from '../../install_index_template_pipeline'; + +jest.mock('../../install_index_template_pipeline'); + +import { stepInstallIndexTemplatePipelines } from './step_install_index_template_pipelines'; +const mockedInstallIndexTemplatesAndPipelines = + installIndexTemplatesAndPipelines as jest.MockedFunction< + typeof installIndexTemplatesAndPipelines + >; + +describe('stepInstallIndexTemplatePipelines', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const getMockInstalledPackageSo = ( + installedEs: EsAssetReference[] = [] + ): SavedObject => { + return { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: installedEs, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + }; + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(mockedInstallIndexTemplatesAndPipelines).mockReset(); + }); + + it('Should call installIndexTemplatesAndPipelines if packageInfo type is integration', async () => { + mockedInstallIndexTemplatesAndPipelines.mockResolvedValue({ + installedTemplates: [ + { + templateName: 'template-01', + indexTemplate: { + priority: 1, + index_patterns: [], + template: { + settings: {}, + mappings: {}, + }, + data_stream: { hidden: false }, + composed_of: [], + _meta: {}, + }, + }, + ], + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ]); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + const res = await stepInstallIndexTemplatePipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(mockedInstallIndexTemplatesAndPipelines).toHaveBeenCalledWith({ + installedPkg: installedPkg.attributes, + packageInstallContext: expect.any(Object), + esClient: expect.any(Object), + savedObjectsClient: expect.any(Object), + logger: expect.any(Object), + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + expect(res).toEqual({ + indexTemplates: [ + { + templateName: 'template-01', + indexTemplate: { + priority: 1, + index_patterns: [], + template: { + settings: {}, + mappings: {}, + }, + data_stream: { hidden: false }, + composed_of: [], + _meta: {}, + }, + }, + ], + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('Should call installIndexTemplatesAndPipelines if packageInfo type is input and installedPkg exists', async () => { + mockedInstallIndexTemplatesAndPipelines.mockResolvedValue({ + installedTemplates: [ + { + templateName: 'template-01', + indexTemplate: { + priority: 1, + index_patterns: [], + template: { + settings: {}, + mappings: {}, + }, + data_stream: { hidden: false }, + composed_of: [], + _meta: {}, + }, + }, + ], + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'input', + categories: ['cloud'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'template_0001', + title: 'Template 1', + description: 'Template 1', + inputs: [ + { + type: 'logs', + title: 'Log', + description: 'Log Input', + vars: [ + { + name: 'path', + type: 'text', + }, + { + name: 'path_2', + type: 'text', + }, + ], + }, + ], + }, + ], + } as any, + assetsMap: new Map(), + paths: [], + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'type-template_0001', + type: ElasticsearchAssetType.indexTemplate, + }, + ]); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + const res = await stepInstallIndexTemplatePipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(res).toEqual({ + indexTemplates: [ + { + templateName: 'template-01', + indexTemplate: { + priority: 1, + index_patterns: [], + template: { + settings: {}, + mappings: {}, + }, + data_stream: { hidden: false }, + composed_of: [], + _meta: {}, + }, + }, + ], + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + }); + + it('Should not call installIndexTemplatesAndPipelines if packageInfo type is input and no data streams are found', async () => { + mockedInstallIndexTemplatesAndPipelines.mockResolvedValue({ + installedTemplates: [ + { + templateName: 'template-01', + indexTemplate: { + priority: 1, + index_patterns: [], + template: { + settings: {}, + mappings: {}, + }, + data_stream: { hidden: false }, + composed_of: [], + _meta: {}, + }, + }, + ], + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + { + id: 'something-01', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'input', + categories: ['cloud'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'template_0001', + title: 'Template 1', + description: 'Template 1', + inputs: [ + { + type: 'logs', + title: 'Log', + description: 'Log Input', + vars: [ + { + name: 'path', + type: 'text', + }, + { + name: 'path_2', + type: 'text', + }, + ], + }, + ], + }, + ], + } as any, + assetsMap: new Map(), + paths: [], + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ]); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + await stepInstallIndexTemplatePipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + expect(mockedInstallIndexTemplatesAndPipelines).not.toBeCalled(); + }); + + it('Should not call installIndexTemplatesAndPipelines if packageInfo type is input and installedPkg does not exist', async () => { + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'input', + categories: ['cloud'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + policy_templates: [ + { + name: 'template_0001', + title: 'Template 1', + description: 'Template 1', + inputs: [ + { + type: 'logs', + title: 'Log', + description: 'Log Input', + vars: [ + { + name: 'path', + type: 'text', + }, + { + name: 'path_2', + type: 'text', + }, + ], + }, + ], + }, + ], + } as any, + assetsMap: new Map(), + paths: [], + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + + await stepInstallIndexTemplatePipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + expect(mockedInstallIndexTemplatesAndPipelines).not.toBeCalled(); + }); + + it('Should not call installIndexTemplatesAndPipelines if packageInfo type is undefined', async () => { + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: undefined, + categories: ['cloud'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + assetsMap: new Map(), + paths: [], + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + + await stepInstallIndexTemplatePipelines({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [], + }); + expect(mockedInstallIndexTemplatesAndPipelines).not.toBeCalled(); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.ts new file mode 100644 index 0000000000000..e2b6918b722cf --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_index_template_pipelines.ts @@ -0,0 +1,66 @@ +/* + * 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 { getNormalizedDataStreams } from '../../../../../../common/services'; + +import { installIndexTemplatesAndPipelines } from '../../install_index_template_pipeline'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepInstallIndexTemplatePipelines(context: InstallContext) { + const { esClient, savedObjectsClient, packageInstallContext, logger, installedPkg } = context; + const { packageInfo } = packageInstallContext; + const esReferences = context.esReferences ?? []; + + if (packageInfo.type === 'integration') { + logger.debug( + `Package install - Installing index templates and pipelines, packageInfo.type: ${packageInfo.type}` + ); + const { installedTemplates, esReferences: templateEsReferences } = + await installIndexTemplatesAndPipelines({ + installedPkg: installedPkg ? installedPkg.attributes : undefined, + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences, + }); + return { + esReferences: templateEsReferences, + indexTemplates: installedTemplates, + }; + } + + if (packageInfo.type === 'input' && installedPkg) { + // input packages create their data streams during package policy creation + // we must use installed_es to infer which streams exist first then + // we can install the new index templates + logger.debug(`Package install - packageInfo.type: ${packageInfo.type}`); + const dataStreamNames = installedPkg.attributes.installed_es + .filter((ref) => ref.type === 'index_template') + // index templates are named {type}-{dataset}, remove everything before first hyphen + .map((ref) => ref.id.replace(/^[^-]+-/, '')); + + const dataStreams = dataStreamNames.flatMap((dataStreamName) => + getNormalizedDataStreams(packageInfo, dataStreamName) + ); + + if (dataStreams.length) { + const { installedTemplates, esReferences: templateEsReferences } = + await installIndexTemplatesAndPipelines({ + installedPkg: installedPkg ? installedPkg.attributes : undefined, + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences, + onlyForDataStreams: dataStreams, + }); + return { esReferences: templateEsReferences, indexTemplates: installedTemplates }; + } + } +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.test.ts new file mode 100644 index 0000000000000..e13e3c9b095b2 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.test.ts @@ -0,0 +1,111 @@ +/* + * 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 { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { installKibanaAssetsAndReferences } from '../../../kibana/assets/install'; + +jest.mock('../../../kibana/assets/install'); + +import { stepInstallKibanaAssets } from './step_install_kibana_assets'; + +const mockedInstallKibanaAssetsAndReferences = + installKibanaAssetsAndReferences as jest.MockedFunction; + +describe('stepInstallKibanaAssets', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + + soClient.update.mockImplementation(async (type, id, attributes) => { + return { id, attributes } as any; + }); + soClient.get.mockImplementation(async (type, id) => { + return { id, attributes: {} } as any; + }); + }); + + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + + it('Should call installKibanaAssetsAndReferences', async () => { + const installationPromise = stepInstallKibanaAssets({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + await expect(installationPromise).resolves.not.toThrowError(); + expect(mockedInstallKibanaAssetsAndReferences).toBeCalledTimes(1); + }); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + + it('Should correctly handle errors', async () => { + // force errors from this function + mockedInstallKibanaAssetsAndReferences.mockImplementation(async () => { + throw new Error('mocked async error A: should be caught'); + }); + + const installationPromise = stepInstallKibanaAssets({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + await expect(installationPromise).resolves.not.toThrowError(); + await expect(installationPromise).resolves.not.toThrowError(); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.ts new file mode 100644 index 0000000000000..56649c04428ac --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_kibana_assets.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { installKibanaAssetsAndReferences } from '../../../kibana/assets/install'; + +import { withPackageSpan } from '../../utils'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepInstallKibanaAssets(context: InstallContext) { + const { + savedObjectsClient, + savedObjectsImporter, + savedObjectTagAssignmentService, + savedObjectTagClient, + logger, + installedPkg, + packageInstallContext, + spaceId, + } = context; + const { packageInfo } = packageInstallContext; + const { name: pkgName, title: pkgTitle } = packageInfo; + + const kibanaAssetPromise = withPackageSpan('Install Kibana assets', () => + installKibanaAssetsAndReferences({ + savedObjectsClient, + savedObjectsImporter, + savedObjectTagAssignmentService, + savedObjectTagClient, + pkgName, + pkgTitle, + packageInstallContext, + installedPkg, + logger, + spaceId, + assetTags: packageInfo?.asset_tags, + }) + ); + // Necessary to avoid async promise rejection warning + // See https://stackoverflow.com/questions/40920179/should-i-refrain-from-handling-promise-rejection-asynchronously + kibanaAssetPromise.catch(() => {}); + + return { kibanaAssetPromise }; +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.test.ts new file mode 100644 index 0000000000000..ac67f8abfaccb --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.test.ts @@ -0,0 +1,155 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; +import { ElasticsearchAssetType } from '../../../../../types'; + +import type { EsAssetReference, Installation } from '../../../../../../common'; +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { installMlModel } from '../../../elasticsearch/ml_model'; + +import { stepInstallMlModel } from './step_install_mlmodel'; + +jest.mock('../../../elasticsearch/ml_model'); + +const mockedInstallMlModel = installMlModel as jest.MockedFunction; + +describe('stepInstallMlModel', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const getMockInstalledPackageSo = ( + installedEs: EsAssetReference[] = [] + ): SavedObject => { + return { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: installedEs, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + }; + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(mockedInstallMlModel).mockReset(); + }); + + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'test-package', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + paths: ['some/path/1', 'some/path/2'], + assetsMap: new Map(), + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + + it('Should update esReferences', async () => { + jest.mocked(mockedInstallMlModel).mockResolvedValue([]); + const res = await stepInstallMlModel({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + expect(mockedInstallMlModel).toHaveBeenCalled(); + expect(res.esReferences).toEqual([]); + }); + + it('Should call installTransforms and return updated esReferences', async () => { + jest.mocked(mockedInstallMlModel).mockResolvedValue([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ]); + const res = await stepInstallMlModel({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [], + }); + expect(mockedInstallMlModel).toHaveBeenCalled(); + expect(res.esReferences).toEqual([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ]); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.ts new file mode 100644 index 0000000000000..31d571fee4505 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_mlmodel.ts @@ -0,0 +1,22 @@ +/* + * 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 { installMlModel } from '../../../elasticsearch/ml_model'; + +import { withPackageSpan } from '../../utils'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepInstallMlModel(context: InstallContext) { + const { logger, packageInstallContext, esClient, savedObjectsClient } = context; + let esReferences = context.esReferences ?? []; + + esReferences = await withPackageSpan('Install ML models', () => + installMlModel(packageInstallContext, esClient, savedObjectsClient, logger, esReferences) + ); + return { esReferences }; +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.test.ts new file mode 100644 index 0000000000000..63ea9c203bf43 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; +import { ElasticsearchAssetType } from '../../../../../types'; + +import type { EsAssetReference, Installation } from '../../../../../../common'; +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { installTransforms } from '../../../elasticsearch/transform/install'; + +import { stepInstallTransforms } from './step_install_transforms'; + +jest.mock('../../../elasticsearch/transform/install'); + +const mockedInstallTransforms = installTransforms as jest.MockedFunction; + +describe('stepInstallTransforms', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const getMockInstalledPackageSo = ( + installedEs: EsAssetReference[] = [] + ): SavedObject => { + return { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: installedEs, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + }; + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(mockedInstallTransforms).mockReset(); + }); + + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'test-package', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + paths: ['some/path/1', 'some/path/2'], + assetsMap: new Map(), + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + + it('Should update esReferences', async () => { + jest.mocked(mockedInstallTransforms).mockResolvedValue({ + installedTransforms: [], + esReferences: [], + }); + const res = await stepInstallTransforms({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + expect(mockedInstallTransforms).toHaveBeenCalled(); + expect(res.esReferences).toEqual([]); + }); + + it('Should call installTransforms and return updated esReferences', async () => { + jest.mocked(mockedInstallTransforms).mockResolvedValue({ + installedTransforms: [], + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + const res = await stepInstallTransforms({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [], + }); + expect(mockedInstallTransforms).toHaveBeenCalled(); + expect(res.esReferences).toEqual([ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ]); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.ts new file mode 100644 index 0000000000000..cd7d7404db5ad --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_install_transforms.ts @@ -0,0 +1,37 @@ +/* + * 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 { installTransforms } from '../../../elasticsearch/transform/install'; + +import { withPackageSpan } from '../../utils'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepInstallTransforms(context: InstallContext) { + const { + packageInstallContext, + esClient, + savedObjectsClient, + logger, + force, + authorizationHeader, + } = context; + let esReferences = context.esReferences ?? []; + + ({ esReferences } = await withPackageSpan('Install transforms', () => + installTransforms({ + packageInstallContext, + esClient, + savedObjectsClient, + logger, + esReferences, + force, + authorizationHeader, + }) + )); + return { esReferences }; +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.test.ts new file mode 100644 index 0000000000000..39e7159596ba8 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.test.ts @@ -0,0 +1,155 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { + savedObjectsClientMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '@kbn/core/server/mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; +import { ElasticsearchAssetType } from '../../../../../types'; + +import type { EsAssetReference, Installation } from '../../../../../../common'; +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { removeLegacyTemplates } from '../../../elasticsearch/template/remove_legacy'; + +import { stepRemoveLegacyTemplates } from './step_remove_legacy_templates'; + +jest.mock('../../../elasticsearch/template/remove_legacy'); + +const mockedRemoveLegacyTemplates = removeLegacyTemplates as jest.MockedFunction< + typeof removeLegacyTemplates +>; + +describe('stepRemoveLegacyTemplates', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const logger = loggingSystemMock.createLogger(); + + const getMockInstalledPackageSo = ( + installedEs: EsAssetReference[] = [] + ): SavedObject => { + return { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: installedEs, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + }; + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(mockedRemoveLegacyTemplates).mockReset(); + }); + + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'test-package', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + paths: ['some/path/1', 'some/path/2'], + assetsMap: new Map(), + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + + it('Should call removeLegacyTemplates', async () => { + await stepRemoveLegacyTemplates({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + expect(mockedRemoveLegacyTemplates).toHaveBeenCalled(); + }); + + it('Should catch the error when removeLegacyTemplates fails', async () => { + jest.mocked(mockedRemoveLegacyTemplates).mockRejectedValue(Error('Error!')); + await stepRemoveLegacyTemplates({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + expect(mockedRemoveLegacyTemplates).toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith('Error removing legacy templates: Error!'); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.ts new file mode 100644 index 0000000000000..0c70989a67096 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_remove_legacy_templates.ts @@ -0,0 +1,20 @@ +/* + * 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 { removeLegacyTemplates } from '../../../elasticsearch/template/remove_legacy'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepRemoveLegacyTemplates(context: InstallContext) { + const { esClient, packageInstallContext, logger } = context; + const { packageInfo } = packageInstallContext; + try { + await removeLegacyTemplates({ packageInfo, esClient, logger }); + } catch (e) { + logger.warn(`Error removing legacy templates: ${e.message}`); + } +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_resolve_kibana_promise.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_resolve_kibana_promise.ts new file mode 100644 index 0000000000000..72782438c20b6 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_resolve_kibana_promise.ts @@ -0,0 +1,15 @@ +/* + * 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 { InstallContext } from '../_state_machine_package_install'; + +export async function stepResolveKibanaPromise(context: InstallContext) { + const { kibanaAssetPromise } = context; + const installedKibanaAssetsRefs = await kibanaAssetPromise; + + return { installedKibanaAssetsRefs }; +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.test.ts new file mode 100644 index 0000000000000..3515fd304b356 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.test.ts @@ -0,0 +1,184 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; +import { ElasticsearchAssetType } from '../../../../../types'; + +import type { EsAssetReference, Installation } from '../../../../../../common'; +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { saveArchiveEntriesFromAssetsMap } from '../../../archive/storage'; + +import { stepSaveArchiveEntries } from './step_save_archive_entries'; + +jest.mock('../../../archive/storage'); + +const mockedSaveArchiveEntriesFromAssetsMap = + saveArchiveEntriesFromAssetsMap as jest.MockedFunction; + +describe('stepSaveArchiveEntries', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const getMockInstalledPackageSo = ( + installedEs: EsAssetReference[] = [] + ): SavedObject => { + return { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: installedEs, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + }; + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(mockedSaveArchiveEntriesFromAssetsMap).mockReset(); + }); + + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'test-package', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + paths: ['some/path/1', 'some/path/2'], + assetsMap: new Map([ + [ + 'endpoint-0.16.0-dev.0/elasticsearch/transform/metadata_current/default.json', + Buffer.from('{"content": "data"}'), + ], + ]), + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + + it('Should return empty packageAssetRefs if saved_objects were not found', async () => { + jest.mocked(mockedSaveArchiveEntriesFromAssetsMap).mockResolvedValue({ + saved_objects: [], + }); + const res = await stepSaveArchiveEntries({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(res).toEqual({ + packageAssetRefs: [], + }); + }); + + it('Should return packageAssetRefs', async () => { + jest.mocked(mockedSaveArchiveEntriesFromAssetsMap).mockResolvedValue({ + saved_objects: [ + { + id: 'test', + attributes: { + package_name: 'test-package', + package_version: '1.0.0', + install_source: 'registry', + asset_path: 'some/path', + media_type: '', + data_utf8: '', + data_base64: '', + }, + type: '', + references: [], + }, + ], + }); + const res = await stepSaveArchiveEntries({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'update', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [ + { + id: 'something', + type: ElasticsearchAssetType.ilmPolicy, + }, + ], + }); + + expect(res).toEqual({ + packageAssetRefs: [ + { + id: 'test', + type: 'epm-packages-assets', + }, + ], + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.ts new file mode 100644 index 0000000000000..ca65b04e55303 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_archive_entries.ts @@ -0,0 +1,39 @@ +/* + * 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 { ASSETS_SAVED_OBJECT_TYPE } from '../../../../../constants'; +import type { PackageAssetReference } from '../../../../../types'; + +import { saveArchiveEntriesFromAssetsMap } from '../../../archive/storage'; + +import { withPackageSpan } from '../../utils'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepSaveArchiveEntries(context: InstallContext) { + const { packageInstallContext, savedObjectsClient, installSource } = context; + + const { packageInfo } = packageInstallContext; + + const packageAssetResults = await withPackageSpan('Update archive entries', () => + saveArchiveEntriesFromAssetsMap({ + savedObjectsClient, + assetsMap: packageInstallContext?.assetsMap, + paths: packageInstallContext?.paths, + packageInfo, + installSource, + }) + ); + const packageAssetRefs: PackageAssetReference[] = packageAssetResults.saved_objects.map( + (result) => ({ + id: result.id, + type: ASSETS_SAVED_OBJECT_TYPE, + }) + ); + + return { packageAssetRefs }; +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.test.ts new file mode 100644 index 0000000000000..e91826c99793c --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { SavedObjectsClientContract, ElasticsearchClient } from '@kbn/core/server'; +import { + savedObjectsClientMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '@kbn/core/server/mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; + +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; + +import { auditLoggingService } from '../../../../audit_logging'; +import { packagePolicyService } from '../../../../package_policy'; + +import { stepSaveSystemObject } from './step_save_system_object'; + +jest.mock('../../../../audit_logging'); +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; + +jest.mock('../../../../package_policy'); +const mockedPackagePolicyService = packagePolicyService as jest.Mocked; + +describe('updateLatestExecutedState', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const logger = loggingSystemMock.createLogger(); + + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + + afterEach(() => { + mockedAuditLoggingService.writeCustomSoAuditLog.mockReset(); + soClient.get.mockReset(); + soClient.update.mockReset(); + }); + + it('Should save the SO and should not call packagePolicy upgrade if keep_policies_up_to_date = false', async () => { + soClient.get.mockResolvedValue({ + id: 'test-integration', + attributes: { + title: 'title', + name: 'test-integration', + version: '1.0.0', + install_source: 'registry', + install_status: 'installed', + package_assets: [], + }, + } as any); + + await stepSaveSystemObject({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'test-integration', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(soClient.update.mock.calls).toEqual([ + [ + 'epm-packages', + 'test-integration', + { + install_format_schema_version: '1.2.0', + install_status: 'installed', + install_version: '1.0.0', + latest_install_failed_attempts: [], + package_assets: undefined, + version: '1.0.0', + }, + ], + ]); + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: 'test-integration', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + expect(mockedPackagePolicyService.upgrade).not.toBeCalled(); + }); + + it('Should call packagePolicy upgrade if keep_policies_up_to_date = true', async () => { + soClient.get.mockResolvedValue({ + id: 'test-integration', + attributes: { + title: 'title', + name: 'test-integration', + version: '1.0.0', + install_source: 'registry', + install_status: 'installed', + package_assets: [], + keep_policies_up_to_date: true, + }, + } as any); + mockedPackagePolicyService.listIds.mockReturnValue({ + items: ['packagePolicy1', 'packagePolicy2'], + } as any); + + await stepSaveSystemObject({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'test-integration', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(soClient.update.mock.calls).toEqual([ + [ + 'epm-packages', + 'test-integration', + { + install_format_schema_version: '1.2.0', + install_status: 'installed', + install_version: '1.0.0', + latest_install_failed_attempts: [], + package_assets: undefined, + version: '1.0.0', + }, + ], + ]); + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: 'test-integration', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + expect(packagePolicyService.upgrade).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + ['packagePolicy1', 'packagePolicy2'] + ); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.ts new file mode 100644 index 0000000000000..f7bca891da6f7 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_save_system_object.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 { + PACKAGES_SAVED_OBJECT_TYPE, + PACKAGE_POLICY_SAVED_OBJECT_TYPE, + SO_SEARCH_LIMIT, + FLEET_INSTALL_FORMAT_VERSION, +} from '../../../../../constants'; +import type { Installation } from '../../../../../types'; + +import { packagePolicyService } from '../../../..'; + +import { auditLoggingService } from '../../../../audit_logging'; + +import { withPackageSpan } from '../../utils'; + +import { clearLatestFailedAttempts } from '../../install_errors_helpers'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepSaveSystemObject(context: InstallContext) { + const { + packageInstallContext, + savedObjectsClient, + logger, + esClient, + installedPkg, + packageAssetRefs, + } = context; + const { packageInfo } = packageInstallContext; + const { name: pkgName, version: pkgVersion } = packageInfo; + + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + + await withPackageSpan('Update install status', () => + savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { + version: pkgVersion, + install_version: pkgVersion, + install_status: 'installed', + package_assets: packageAssetRefs, + install_format_schema_version: FLEET_INSTALL_FORMAT_VERSION, + latest_install_failed_attempts: clearLatestFailedAttempts( + pkgVersion, + installedPkg?.attributes.latest_install_failed_attempts ?? [] + ), + }) + ); + + // Need to refetch the installation again to retrieve all the attributes + const updatedPackage = await savedObjectsClient.get( + PACKAGES_SAVED_OBJECT_TYPE, + pkgName + ); + logger.debug(`Package install - Install status ${updatedPackage?.attributes?.install_status}`); + // If the package is flagged with the `keep_policies_up_to_date` flag, upgrade its + // associated package policies after installation + if (updatedPackage.attributes.keep_policies_up_to_date) { + await withPackageSpan('Upgrade package policies', async () => { + const policyIdsToUpgrade = await packagePolicyService.listIds(savedObjectsClient, { + page: 1, + perPage: SO_SEARCH_LIMIT, + kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`, + }); + logger.debug( + `Package install - Package is flagged with keep_policies_up_to_date, upgrading its associated package policies ${policyIdsToUpgrade}` + ); + await packagePolicyService.upgrade(savedObjectsClient, esClient, policyIdsToUpgrade.items); + }); + } + logger.debug( + `Install status ${updatedPackage?.attributes?.install_status} - Installation complete!` + ); +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.test.ts new file mode 100644 index 0000000000000..c7f3c040b7966 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObject, +} from '@kbn/core/server'; +import { savedObjectsClientMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import type { IndicesGetIndexTemplateIndexTemplateItem } from '@elastic/elasticsearch/lib/api/types'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../../common/constants'; + +import type { EsAssetReference, Installation } from '../../../../../../common'; +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; +import { updateCurrentWriteIndices } from '../../../elasticsearch/template/template'; + +import { stepUpdateCurrentWriteIndices } from './step_update_current_write_indices'; + +jest.mock('../../../elasticsearch/template/template'); + +const mockedUpdateCurrentWriteIndices = updateCurrentWriteIndices as jest.MockedFunction< + typeof updateCurrentWriteIndices +>; + +const createMockTemplate = ({ name, composedOf = [] }: { name: string; composedOf?: string[] }) => + ({ + name, + index_template: { + composed_of: composedOf, + }, + } as IndicesGetIndexTemplateIndexTemplateItem); + +describe('stepUpdateCurrentWriteIndices', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const getMockInstalledPackageSo = ( + installedEs: EsAssetReference[] = [] + ): SavedObject => { + return { + id: 'mocked-package', + attributes: { + name: 'test-package', + version: '1.0.0', + install_status: 'installing', + install_version: '1.0.0', + install_started_at: new Date().toISOString(), + install_source: 'registry', + verification_status: 'verified', + installed_kibana: [] as any, + installed_es: installedEs, + es_index_patterns: {}, + }, + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + }; + }; + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(async () => { + jest.mocked(mockedUpdateCurrentWriteIndices).mockReset(); + }); + + const packageInstallContext = { + packageInfo: { + title: 'title', + name: 'test-package', + version: '1.0.0', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + } as any, + paths: ['some/path/1', 'some/path/2'], + assetsMap: new Map(), + }; + appContextService.start( + createAppContextStartContractMock({ + internal: { + disableILMPolicies: true, + fleetServerStandalone: false, + onlyAllowAgentUpgradeToKnownVersions: false, + retrySetupOnBoot: false, + registry: { + kibanaVersionCheckEnabled: true, + capabilities: [], + excludePackages: [], + }, + }, + }) + ); + const mockInstalledPackageSo = getMockInstalledPackageSo(); + const installedPkg = { + ...mockInstalledPackageSo, + attributes: { + ...mockInstalledPackageSo.attributes, + install_started_at: new Date(Date.now() - 1000).toISOString(), + }, + }; + + it('Should call updateCurrentWriteIndices', async () => { + await stepUpdateCurrentWriteIndices({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [], + }); + + expect(mockedUpdateCurrentWriteIndices).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + [], + { ignoreMappingUpdateErrors: undefined, skipDataStreamRollover: undefined } + ); + }); + + it('Should call updateCurrentWriteIndices with passed parameters', async () => { + const indexTemplates = [createMockTemplate({ name: 'tmpl1' })] as any; + await stepUpdateCurrentWriteIndices({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger: loggerMock.create(), + packageInstallContext, + installedPkg, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + esReferences: [], + indexTemplates, + ignoreMappingUpdateErrors: true, + skipDataStreamRollover: true, + }); + + expect(mockedUpdateCurrentWriteIndices).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + indexTemplates, + { ignoreMappingUpdateErrors: true, skipDataStreamRollover: true } + ); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.ts new file mode 100644 index 0000000000000..094f1110d9021 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/step_update_current_write_indices.ts @@ -0,0 +1,25 @@ +/* + * 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 { updateCurrentWriteIndices } from '../../../elasticsearch/template/template'; + +import { withPackageSpan } from '../../utils'; + +import type { InstallContext } from '../_state_machine_package_install'; + +export async function stepUpdateCurrentWriteIndices(context: InstallContext) { + const { esClient, logger, ignoreMappingUpdateErrors, skipDataStreamRollover, indexTemplates } = + context; + + // update current backing indices of each data stream + await withPackageSpan('Update write indices', () => + updateCurrentWriteIndices(esClient, logger, indexTemplates || [], { + ignoreMappingUpdateErrors, + skipDataStreamRollover, + }) + ); +} diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.test.ts new file mode 100644 index 0000000000000..afce673348d7e --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.test.ts @@ -0,0 +1,195 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '@kbn/core/server'; +import type { + SavedObjectsClientContract, + ElasticsearchClient, + SavedObjectsUpdateResponse, +} from '@kbn/core/server'; +import { + savedObjectsClientMock, + elasticsearchServiceMock, + loggingSystemMock, +} from '@kbn/core/server/mocks'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common/constants'; + +import { + MAX_TIME_COMPLETE_INSTALL, + PACKAGES_SAVED_OBJECT_TYPE, +} from '../../../../../../common/constants'; + +import { appContextService } from '../../../../app_context'; +import { createAppContextStartContractMock } from '../../../../../mocks'; + +import { INSTALL_STATES } from '../../../../../../common/types'; + +import { auditLoggingService } from '../../../../audit_logging'; + +import type { PackagePolicySOAttributes } from '../../../../../types'; + +import { updateLatestExecutedState } from './update_latest_executed_state'; + +jest.mock('../../../../audit_logging'); +const mockedAuditLoggingService = auditLoggingService as jest.Mocked; + +describe('updateLatestExecutedState', () => { + let soClient: jest.Mocked; + let esClient: jest.Mocked; + const logger = loggingSystemMock.createLogger(); + + beforeEach(async () => { + soClient = savedObjectsClientMock.create(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + appContextService.start(createAppContextStartContractMock()); + }); + afterEach(() => { + mockedAuditLoggingService.writeCustomSoAuditLog.mockReset(); + soClient.update.mockReset(); + }); + + it('Updates the SO after each transition', async () => { + await updateLatestExecutedState({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + latestExecutedState: { + name: INSTALL_STATES.SAVE_ARCHIVE_ENTRIES, + started_at: new Date(Date.now() - MAX_TIME_COMPLETE_INSTALL * 2).toISOString(), + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(soClient.update.mock.calls).toEqual( + expect.objectContaining([ + [ + 'epm-packages', + 'xyz', + { + latest_executed_state: { + name: 'save_archive_entries_from_assets_map', + started_at: expect.anything(), + }, + }, + ], + ]) + ); + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: 'xyz', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + }); + + it('Should not update the SO if the context contains concurrent installation error', async () => { + await updateLatestExecutedState({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + latestExecutedState: { + name: INSTALL_STATES.SAVE_ARCHIVE_ENTRIES, + started_at: new Date(Date.now() - MAX_TIME_COMPLETE_INSTALL * 2).toISOString(), + error: `Concurrent installation or upgrade of xyz-4.5.6 detected.`, + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(soClient.update.mock.calls).toEqual([]); + expect(mockedAuditLoggingService.writeCustomSoAuditLog).not.toHaveBeenCalled(); + }); + + it('Should log error if the update failed', async () => { + soClient.update.mockImplementation( + async ( + _type: string, + _id: string + ): Promise> => { + throw SavedObjectsErrorHelpers.createConflictError('abc', '123'); + } + ); + + await updateLatestExecutedState({ + savedObjectsClient: soClient, + // @ts-ignore + savedObjectsImporter: jest.fn(), + esClient, + logger, + packageInstallContext: { + assetsMap: new Map(), + paths: [], + packageInfo: { + title: 'title', + name: 'xyz', + version: '4.5.6', + description: 'test', + type: 'integration', + categories: ['cloud', 'custom'], + format_version: 'string', + release: 'experimental', + conditions: { kibana: { version: 'x.y.z' } }, + owner: { github: 'elastic/fleet' }, + }, + }, + latestExecutedState: { + name: INSTALL_STATES.SAVE_ARCHIVE_ENTRIES, + started_at: new Date(Date.now() - MAX_TIME_COMPLETE_INSTALL * 2).toISOString(), + }, + installType: 'install', + installSource: 'registry', + spaceId: DEFAULT_SPACE_ID, + }); + + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'update', + id: 'xyz', + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to update SO with latest executed state: Error: Saved object [abc/123] conflict' + ); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.ts b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.ts new file mode 100644 index 0000000000000..55d7997ad58f7 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/epm/packages/install_state_machine/steps/update_latest_executed_state.ts @@ -0,0 +1,39 @@ +/* + * 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 { SavedObjectsErrorHelpers } from '@kbn/core/server'; + +import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../constants'; + +import { auditLoggingService } from '../../../../audit_logging'; + +import type { InstallContext } from '../_state_machine_package_install'; + +// Function invoked after each transition +export const updateLatestExecutedState = async (context: InstallContext) => { + const { logger, savedObjectsClient, packageInstallContext, latestExecutedState } = context; + const { packageInfo } = packageInstallContext; + const { name: pkgName } = packageInfo; + + try { + // If the error is of type ConcurrentInstallationError, don't save it in the SO + if (latestExecutedState?.error?.includes('Concurrent installation or upgrade')) return; + + auditLoggingService.writeCustomSoAuditLog({ + action: 'update', + id: pkgName, + savedObjectType: PACKAGES_SAVED_OBJECT_TYPE, + }); + return await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { + latest_executed_state: latestExecutedState, + }); + } catch (err) { + if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { + logger.error(`Failed to update SO with latest executed state: ${err}`); + } + } +}; diff --git a/x-pack/plugins/fleet/server/types/index.tsx b/x-pack/plugins/fleet/server/types/index.tsx index bec381d311937..da4d793989e8b 100644 --- a/x-pack/plugins/fleet/server/types/index.tsx +++ b/x-pack/plugins/fleet/server/types/index.tsx @@ -97,6 +97,8 @@ export type { ActionStatusOptions, PackageSpecTags, AssetsMap, + InstallResultStatus, + InstallLatestExecutedState, } from '../../common/types'; export { ElasticsearchAssetType, KibanaAssetType, KibanaSavedObjectType } from '../../common/types'; export { dataTypes } from '../../common/constants'; diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts b/x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts index 5192b8a4e914b..5f4c5b784a280 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_error_rollback.ts @@ -27,7 +27,10 @@ export default function (providerContext: FtrProviderContext) { }; const uninstallPackage = async (pkg: string, version: string) => { - await supertest.delete(`/api/fleet/epm/packages/${pkg}/${version}`).set('kbn-xsrf', 'xxxx'); + await supertest + .delete(`/api/fleet/epm/packages/${pkg}/${version}`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true }); }; const getPackageInfo = async (pkg: string, version: string) => { diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index 9c0b5cd8a426e..96e5e95e720ad 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -778,6 +778,10 @@ const expectAssetsInstalled = ({ install_started_at: res.attributes.install_started_at, install_source: 'registry', latest_install_failed_attempts: [], + latest_executed_state: { + name: 'update_so', + started_at: res.attributes.latest_executed_state.started_at, + }, install_format_schema_version: FLEET_INSTALL_FORMAT_VERSION, verification_status: 'unknown', verification_key_id: null, diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index cd3898a58c6a7..fe584f9cd04f7 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -486,6 +486,10 @@ export default function (providerContext: FtrProviderContext) { install_source: 'registry', install_format_schema_version: FLEET_INSTALL_FORMAT_VERSION, latest_install_failed_attempts: [], + latest_executed_state: { + name: 'update_so', + started_at: res.attributes.latest_executed_state.started_at, + }, verification_status: 'unknown', verification_key_id: null, }); diff --git a/x-pack/test/fleet_api_integration/config.base.ts b/x-pack/test/fleet_api_integration/config.base.ts index 5626ee4d85d6e..fd9d8e08779c0 100644 --- a/x-pack/test/fleet_api_integration/config.base.ts +++ b/x-pack/test/fleet_api_integration/config.base.ts @@ -76,6 +76,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { 'agentTamperProtectionEnabled', 'enableStrictKQLValidation', 'subfeaturePrivileges', + 'enablePackagesStateMachine', ])}`, `--logging.loggers=${JSON.stringify([ ...getKibanaCliLoggers(xPackAPITestsConfig.get('kbnTestServer.serverArgs')),