[Fleet] Implement state machine behavior for package install (#178657)

Closes https://github.com/elastic/kibana/issues/175592

## Summary
Implement state machine behavior for package install. It keeps track of
the current step and save it in the SO , then exposes it in
the`installationInfo` property.

- Implemented a generic state machine function that can automatically
handle state transitions based on a simple data structure:
https://github.com/elastic/kibana/pull/178657/files#diff-f350d9630cd1f22cd1b3e70c9e95388d72dc877190bbeb33c739cb0433949e95R1-R88.
In theory, this state machine could be reused for something else, since
is generic enough and it's decoupled from the transition functions that
we pass to it.
- The state transitions passed to the state machine are defined in
[services/epm/packages/install_steps.ts](5f09e58ae7/x-pack/plugins/fleet/server/services/epm/packages/install_steps.ts)
and are based off the existing steps in
10d5167fa7/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts (L61)
I simply divided that long function in smaller steps and wrapped them to
accept a common parameter, based off
[InstallContext](https://github.com/elastic/kibana/pull/178657/files#diff-39d1f59e77a329eb06241c220156e5cf2d350649bb548707b0b0f54365ea91bfR49-R72)
- Defined a **feature flag** `enablePackagesStateMachine` and called the
new
[installPackageWitStateMachine](https://github.com/elastic/kibana/pull/178657/files#diff-cf9cec44de2ad0a6a3b74cca05e5308231d57d5c3e180ae4bc5114c2bf9af4ebR466-R483)
only when it's enabled.
- For now this new function is only applied to
`InstallPackageFromRegistry`, so `upload` and `bundled` case don't use
it yet.

### Testing
- Enable `enablePackagesStateMachine` in kibana.dev.yml
- Try to install an integration from registry, either from API or UI.
For instance

```
POST kbn:api/fleet/epm/packages/nginx/1.20.0
```
The installation process should succeed and the installationInfo
property will expose the `latest_executed_state` along with the error.

<details>
  <summary>Screenshots</summary>

### Logging
With `logger.debug` enabled:
![Screenshot 2024-03-27 at 16 12
33](75fb4af8-675e-483e-a51f-eb4adbf9d2aa)

![Screenshot 2024-03-27 at 16 12
48](74092f6d-528c-4e8f-85ee-85e2852487b8)

### InstallationInfo object
Content of `installationInfo` property when install process was
successful:
![Screenshot 2024-03-27 at 16 13
54](c2535c8f-24f7-4b6c-8f58-dadf4c9b4b28)

### Errors during install process

I manually triggered an error inside `stepInstallIndexTemplatePipelines`
and it's reported in the `installationInfo` property along with the
latest executed step (latest successful state) and error message:

![Screenshot 2024-03-27 at 17 26
29](47d77330-bcbb-4608-9e42-c9f46e8831a1)


</details>



### Checklist
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Cristina Amico 2024-04-08 14:31:00 +02:00 committed by GitHub
parent 9f8433e564
commit 3a31ee0872
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 5698 additions and 242 deletions

View file

@ -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',

View file

@ -290,6 +290,7 @@
"installed_kibana_space_id",
"internal",
"keep_policies_up_to_date",
"latest_executed_state",
"latest_install_failed_attempts",
"name",
"package_assets",

View file

@ -1003,6 +1003,10 @@
"index": false,
"type": "boolean"
},
"latest_executed_state": {
"enabled": false,
"type": "object"
},
"latest_install_failed_attempts": {
"enabled": false,
"type": "object"

View file

@ -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",

View file

@ -28,6 +28,7 @@ export const allowedExperimentalValues = Object.freeze<Record<string, boolean>>(
agentless: false,
enableStrictKQLValidation: false,
subfeaturePrivileges: false,
enablePackagesStateMachine: true,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -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": [

View file

@ -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:

View file

@ -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:

View file

@ -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<T> {
name: T;
started_at: string;
error?: string;
}
export type InstallLatestExecutedState = LatestExecutedState<StateNames>;
export interface StateContext<T> {
[key: string]: any;
latestExecutedState?: LatestExecutedState<T>;
}
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 {

View file

@ -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;

View file

@ -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 {

View file

@ -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,

View file

@ -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<Installation>;
spaceId: string;
assetTags?: PackageSpecTags[];

View file

@ -161,7 +161,6 @@ export async function _installPackage({
pkgName,
pkgTitle,
packageInstallContext,
paths,
installedPkg,
logger,
spaceId,

View file

@ -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,
});
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: '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',
});
});
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 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 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 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',
});
});
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 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' })
);
});
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({
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',
})
);
});
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,
packageName: 'apache',
status: 'failure',
});
});
expect(response.status).toEqual('already_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,
});
it('should allow to install fleet_server if internal.fleetServerStandalone is configured', async () => {
jest.mocked(appContextService.getConfig).mockReturnValueOnce({
internal: {
fleetServerStandalone: true,
},
} as any);
const response = await installPackage({
spaceId: DEFAULT_SPACE_ID,
installSource: 'registry',
pkgkey: 'fleet_server-2.0.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(response.status).toEqual('installed');
});
it('should send telemetry on update success', async () => {
jest
.mocked(getInstallationObject)
.mockResolvedValueOnce({ attributes: { version: '1.2.0' } } 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);
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,
});
jest.spyOn(licenseService, 'hasAtLeast').mockReturnValue(true);
await installPackage({
spaceId: 'test',
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(appContextService.getInternalUserSOClientForSpaceId).toBeCalledWith('test');
expect(appContextService.getSavedObjectsTagging().createTagClient).toBeCalledWith(
expect.objectContaining({
client: mockedTaggingSo,
})
);
expect(
appContextService.getSavedObjectsTagging().createInternalAssignmentService
).toBeCalledWith(
expect.objectContaining({
client: mockedTaggingSo,
})
);
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 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' })
);
});
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',
})
);
});
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 allow to install fleet_server if internal.fleetServerStandalone is configured', async () => {
jest.mocked(appContextService.getConfig).mockReturnValueOnce({
internal: {
fleetServerStandalone: true,
},
} as any);
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');
});
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,
})
);
});
});
describe('with enablePackagesStateMachine = true', () => {
beforeEach(() => {
mockGetBundledPackageByPkgKey.mockResolvedValue(undefined);
jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({
enablePackagesStateMachine: true,
} as any);
});
afterEach(() => {
(install._installPackage as jest.Mock).mockClear();
// jest.resetAllMocks();
});
afterAll(() => {
jest.mocked(appContextService.getExperimentalFeatures).mockReturnValue({
enablePackagesStateMachine: false,
} as any);
});
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,
});
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 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 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',
});
});
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 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',
})
);
});
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');
});
// failing
it('should allow to install fleet_server if internal.fleetServerStandalone is configured', async () => {
jest.mocked(appContextService.getConfig).mockReturnValueOnce({
internal: {
fleetServerStandalone: true,
},
} as any);
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');
});
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',

View file

@ -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<Installation>;
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<InstallResult> {
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()}]`, {

View file

@ -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<typeof installKibanaAssetsAndReferences>;
function sleep(millis: number) {
return new Promise((resolve) => setTimeout(resolve, millis));
}
describe('_stateMachineInstallPackage', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
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<Installation> = {
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);
});
});

View file

@ -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<StateNames> {
savedObjectsClient: SavedObjectsClientContract;
savedObjectsImporter: Pick<ISavedObjectsImporter, 'import' | 'resolveImportErrors'>;
savedObjectTagAssignmentService: IAssignmentService;
savedObjectTagClient: ITagsClient;
esClient: ElasticsearchClient;
logger: Logger;
installedPkg?: SavedObject<Installation>;
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<KibanaAssetReference[]>;
}
/*
* _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<AssetReference[]> {
const installStates: StateMachineDefinition<StateNames> = {
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;
}
}
}

View file

@ -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<typeof createAppContextStartContractMock>;
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(),
},
})
);
});
});

View file

@ -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<T extends string> = Record<T, State>;
/*
* 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<T extends string> {
context: StateContext<string>;
states: StateMachineStates<T>;
}
/*
* 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<string>,
context: StateContext<string>
): Promise<StateContext<string>> {
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<string> = {
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<string>;
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<string>,
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}`);
}
}
}

View file

@ -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';

View file

@ -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<typeof createInstallation>;
const mockedAuditLoggingService = auditLoggingService as jest.Mocked<typeof auditLoggingService>;
describe('stepCreateRestartInstallation', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const logger = loggingSystemMock.createLogger();
const mockInstalledPackageSo: SavedObject<Installation> = {
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);
});

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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,
});
}
}

View file

@ -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<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const getMockInstalledPackageSo = (
installedEs: EsAssetReference[] = []
): SavedObject<Installation> => {
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,
},
],
});
});
});
});

View file

@ -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 };
}

View file

@ -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<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const mockInstalledPackageSo: SavedObject<Installation> = {
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,
},
]);
});
});

View file

@ -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 };
}

View file

@ -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<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const getMockInstalledPackageSo = (
installedEs: EsAssetReference[] = []
): SavedObject<Installation> => {
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();
});
});

View file

@ -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 };
}
}
}

View file

@ -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<typeof installKibanaAssetsAndReferences>;
describe('stepInstallKibanaAssets', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
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();
});
});

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { 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 };
}

View file

@ -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<typeof installMlModel>;
describe('stepInstallMlModel', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const getMockInstalledPackageSo = (
installedEs: EsAssetReference[] = []
): SavedObject<Installation> => {
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,
},
]);
});
});

View file

@ -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 };
}

View file

@ -0,0 +1,161 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import 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<typeof installTransforms>;
describe('stepInstallTransforms', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const getMockInstalledPackageSo = (
installedEs: EsAssetReference[] = []
): SavedObject<Installation> => {
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,
},
]);
});
});

View file

@ -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 };
}

View file

@ -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<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const logger = loggingSystemMock.createLogger();
const getMockInstalledPackageSo = (
installedEs: EsAssetReference[] = []
): SavedObject<Installation> => {
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!');
});
});

View file

@ -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}`);
}
}

View file

@ -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 };
}

View file

@ -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<typeof saveArchiveEntriesFromAssetsMap>;
describe('stepSaveArchiveEntries', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const getMockInstalledPackageSo = (
installedEs: EsAssetReference[] = []
): SavedObject<Installation> => {
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',
},
],
});
});
});

View file

@ -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 };
}

View file

@ -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<typeof auditLoggingService>;
jest.mock('../../../../package_policy');
const mockedPackagePolicyService = packagePolicyService as jest.Mocked<typeof packagePolicyService>;
describe('updateLatestExecutedState', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
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']
);
});
});

View file

@ -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<Installation>(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<Installation>(
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!`
);
}

View file

@ -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<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
const getMockInstalledPackageSo = (
installedEs: EsAssetReference[] = []
): SavedObject<Installation> => {
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 }
);
});
});

View file

@ -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,
})
);
}

View file

@ -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<typeof auditLoggingService>;
describe('updateLatestExecutedState', () => {
let soClient: jest.Mocked<SavedObjectsClientContract>;
let esClient: jest.Mocked<ElasticsearchClient>;
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<SavedObjectsUpdateResponse<PackagePolicySOAttributes>> => {
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'
);
});
});

View file

@ -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}`);
}
}
};

View file

@ -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';

View file

@ -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) => {

View file

@ -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,

View file

@ -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,
});

View file

@ -76,6 +76,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'agentTamperProtectionEnabled',
'enableStrictKQLValidation',
'subfeaturePrivileges',
'enablePackagesStateMachine',
])}`,
`--logging.loggers=${JSON.stringify([
...getKibanaCliLoggers(xPackAPITestsConfig.get('kbnTestServer.serverArgs')),