diff --git a/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts b/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts index da5ee0bcc8e5c..74dc880477ff9 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/bulk_install_packages.ts @@ -110,7 +110,7 @@ export async function bulkInstallPackages({ result: { assets: [...installedEs, ...installedKibana], status: 'already_installed', - installType: installedPackageResult.installType, + installType: 'unknown', } as InstallResult, }; } diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts index 2a5ae1346d6b3..97817b063b730 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.test.ts @@ -26,6 +26,7 @@ import { handleInstallPackageFailure, installAssetsForInputPackagePolicy, installPackage, + isPackageVersionOrLaterInstalled, } from './install'; import * as install from './_install_package'; import { getBundledPackageByPkgKey } from './bundled_packages'; @@ -715,3 +716,110 @@ describe('handleInstallPackageFailure', () => { ); }); }); + +describe('isPackageVersionOrLaterInstalled', () => { + beforeEach(() => { + jest.mocked(getInstallationObject).mockReset(); + }); + it('should return true if package is installed in the same version as expected', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { name: 'test', version: '1.0.0', install_status: 'installed' }, + } as any); + const res = await isPackageVersionOrLaterInstalled({ + savedObjectsClient, + pkgName: 'test', + pkgVersion: '1.0.0', + }); + + expect(res).toEqual( + expect.objectContaining({ + package: expect.objectContaining({ + name: 'test', + version: '1.0.0', + install_status: 'installed', + }), + }) + ); + }); + + it('should return true if package is installed in an higher version as expected', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { name: 'test', version: '1.2.0', install_status: 'installed' }, + } as any); + const res = await isPackageVersionOrLaterInstalled({ + savedObjectsClient, + pkgName: 'test', + pkgVersion: '1.0.0', + }); + + expect(res).toEqual( + expect.objectContaining({ + package: expect.objectContaining({ + name: 'test', + version: '1.2.0', + install_status: 'installed', + }), + }) + ); + }); + + it('should return false if package is installed in an lower version as expected', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { name: 'test', version: '0.9.0', install_status: 'installed' }, + } as any); + const res = await isPackageVersionOrLaterInstalled({ + savedObjectsClient, + pkgName: 'test', + pkgVersion: '1.0.0', + }); + + expect(res).toEqual(false); + }); + + it('should retry if package is currently installing', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { name: 'test', version: '1.0.0', install_status: 'installing' }, + } as any); + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { name: 'test', version: '1.0.0', install_status: 'installing' }, + } as any); + jest.mocked(getInstallationObject).mockResolvedValueOnce({ + attributes: { name: 'test', version: '1.0.0', install_status: 'installed' }, + } as any); + + const res = await isPackageVersionOrLaterInstalled({ + savedObjectsClient, + pkgName: 'test', + pkgVersion: '1.0.0', + }); + + expect(res).toEqual( + expect.objectContaining({ + package: expect.objectContaining({ + name: 'test', + version: '1.0.0', + install_status: 'installed', + }), + }) + ); + + expect(getInstallationObject).toBeCalledTimes(3); + }); + + it('should throw on unexpected error', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + jest.mocked(getInstallationObject).mockRejectedValueOnce(new Error('test unexpected error')); + + const res = isPackageVersionOrLaterInstalled({ + savedObjectsClient, + pkgName: 'test', + pkgVersion: '1.0.0', + }); + + await expect(res).rejects.toThrowError('test unexpected error'); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 073f93be81d6c..43b0c9d68a04c 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -87,6 +87,8 @@ import { addErrorToLatestFailedAttempts } from './install_errors_helpers'; import { installIndexTemplatesAndPipelines } from './install_index_template_pipeline'; import { optimisticallyAddEsAssetReferences } from './es_assets_reference'; +const MAX_ENSURE_INSTALL_TIME = 60 * 1000; + export async function isPackageInstalled(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; @@ -95,27 +97,53 @@ export async function isPackageInstalled(options: { return installedPackage !== undefined; } +// Error used to retry in isPackageVersionOrLaterInstalled +class CurrentlyInstallingError extends Error {} + +/** + * Check if a package is currently installed, + * if the package is currently installing it will retry until MAX_ENSURE_INSTALL_TIME is reached + */ export async function isPackageVersionOrLaterInstalled(options: { savedObjectsClient: SavedObjectsClientContract; pkgName: string; pkgVersion: string; -}): Promise<{ package: Installation; installType: InstallType } | false> { - const { savedObjectsClient, pkgName, pkgVersion } = options; - const installedPackageObject = await getInstallationObject({ savedObjectsClient, pkgName }); - const installedPackage = installedPackageObject?.attributes; - if ( - installedPackage && - (installedPackage.version === pkgVersion || semverLt(pkgVersion, installedPackage.version)) - ) { - let installType: InstallType; - try { - installType = getInstallType({ pkgVersion, installedPkg: installedPackageObject }); - } catch (e) { - installType = 'unknown'; +}): Promise<{ package: Installation } | false> { + return pRetry( + async () => { + const { savedObjectsClient, pkgName, pkgVersion } = options; + const installedPackageObject = await getInstallationObject({ savedObjectsClient, pkgName }); + const installedPackage = installedPackageObject?.attributes; + if ( + installedPackage && + (installedPackage.version === pkgVersion || semverLt(pkgVersion, installedPackage.version)) + ) { + if (installedPackage.install_status === 'installing') { + throw new CurrentlyInstallingError( + `Package ${pkgName}-${pkgVersion} is currently installing` + ); + } else if (installedPackage.install_status === 'install_failed') { + return false; + } + + return { package: installedPackage }; + } + return false; + }, + { + maxRetryTime: MAX_ENSURE_INSTALL_TIME, + onFailedAttempt: (error) => { + if (!(error instanceof CurrentlyInstallingError)) { + throw error; + } + }, } - return { package: installedPackage, installType }; - } - return false; + ).catch((err): false => { + if (err instanceof CurrentlyInstallingError) { + return false; + } + throw err; + }); } export async function ensureInstalledPackage(options: { @@ -147,6 +175,7 @@ export async function ensureInstalledPackage(options: { pkgName: pkgKeyProps.name, pkgVersion: pkgKeyProps.version, }); + if (installedPackageResult) { return installedPackageResult.package; }