Skip to content

Commit 62f1b22

Browse files
kibanamachinecriamicoelasticmachine
authored
[9.1] [Fleet] Improve installation of bundled packages in airgapped environments (#230992) (#231714)
# Backport This will backport the following commits from `main` to `9.1`: - [[Fleet] Improve installation of bundled packages in airgapped environments (#230992)](#230992) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Cristina Amico","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-08-13T21:39:32Z","message":"[Fleet] Improve installation of bundled packages in airgapped environments (#230992)\n\nCloses https://github.com/elastic/kibana/issues/224954\n\n## Summary\n\nImprove installation of bundled packages in airgapped environments.\n\n- Add a fallback fetch of bundled packages / cached packages when\npossible\n- Improve error handling when EPR is not available\n- Fetch bundled packages as fallback when is possible (regardless of EPR\nbeing available or not)\n\nApproach explained\n[here](https://github.com/elastic/kibana/issues/224954#issuecomment-3164337500)\n\n### Testing steps\n\nConfiguration in `kibana.dev.yml`:\n```\nxpack.fleet:\n isAirGapped: true\n registryUrl: http://not-reachable\n developer.bundledPackageLocation: \"/Users/<USERNAME>/Dev/kibana/x-pack/platform/plugins/shared/fleet/target/bundled_packages\"\n```\nBundled packages (APM, synthetics, etc) need to be present in the above\nfolder. Download them from EPR\n\n- Put an older version of APM (for instance 7.16) in the folder and\ninstall it going to `app/integrations/apm-7.16/overview`, add it to a\nnew agent policy. I tested with a policy that doesn't have `system`\ninstalled\n- temporarily remove the configs `isAirGapped` and `registryUrl` to be\nable to reach the registry and add a newer version of the package to\n`bundled_packages` folder (I used apm-8.2.0)\n- To trigger the package upgrade, go to\n`app/integrations/apm-8.2.0/overview`\n- Put back the configs in `kibana.dev.yml` and now upgrade the package\nand the policies\n- The upgrade should work properly and the policy revision is aligned\n<img width=\"1862\" height=\"965\" alt=\"Screenshot 2025-08-11 at 11 13 29\"\nsrc=\"https://github.com/user-attachments/assets/690068f9-e43e-4685-b341-b3e4139e421e\"\n/>\n<img width=\"1866\" height=\"1159\" alt=\"Screenshot 2025-08-11 at 11 13 58\"\nsrc=\"https://github.com/user-attachments/assets/9a12e223-6215-4759-ab89-eb554d6bc63e\"\n/>\n\n**NOTE**: When I tested with a policy that has also other packages\ninstalled (like system), the policy recompile still fails with a\nconnection error. I'm not quite sure how to solve this, since Fleet\nneeds to reach EPR to fetch the packages that are not bundled.\n\n\n### Checklist\n\nCheck the PR satisfies following conditions. \n\n- [ ]\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\nwas added for features that require explanation or tutorials\n- [ ] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>","sha":"09c40790bdc685a9d89ce56efb281e13cf8793e1","branchLabelMapping":{"^v9.2.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Fleet","backport:prev-minor","v9.2.0"],"title":"[Fleet] Improve installation of bundled packages in airgapped environments","number":230992,"url":"https://github.com/elastic/kibana/pull/230992","mergeCommit":{"message":"[Fleet] Improve installation of bundled packages in airgapped environments (#230992)\n\nCloses https://github.com/elastic/kibana/issues/224954\n\n## Summary\n\nImprove installation of bundled packages in airgapped environments.\n\n- Add a fallback fetch of bundled packages / cached packages when\npossible\n- Improve error handling when EPR is not available\n- Fetch bundled packages as fallback when is possible (regardless of EPR\nbeing available or not)\n\nApproach explained\n[here](https://github.com/elastic/kibana/issues/224954#issuecomment-3164337500)\n\n### Testing steps\n\nConfiguration in `kibana.dev.yml`:\n```\nxpack.fleet:\n isAirGapped: true\n registryUrl: http://not-reachable\n developer.bundledPackageLocation: \"/Users/<USERNAME>/Dev/kibana/x-pack/platform/plugins/shared/fleet/target/bundled_packages\"\n```\nBundled packages (APM, synthetics, etc) need to be present in the above\nfolder. Download them from EPR\n\n- Put an older version of APM (for instance 7.16) in the folder and\ninstall it going to `app/integrations/apm-7.16/overview`, add it to a\nnew agent policy. I tested with a policy that doesn't have `system`\ninstalled\n- temporarily remove the configs `isAirGapped` and `registryUrl` to be\nable to reach the registry and add a newer version of the package to\n`bundled_packages` folder (I used apm-8.2.0)\n- To trigger the package upgrade, go to\n`app/integrations/apm-8.2.0/overview`\n- Put back the configs in `kibana.dev.yml` and now upgrade the package\nand the policies\n- The upgrade should work properly and the policy revision is aligned\n<img width=\"1862\" height=\"965\" alt=\"Screenshot 2025-08-11 at 11 13 29\"\nsrc=\"https://github.com/user-attachments/assets/690068f9-e43e-4685-b341-b3e4139e421e\"\n/>\n<img width=\"1866\" height=\"1159\" alt=\"Screenshot 2025-08-11 at 11 13 58\"\nsrc=\"https://github.com/user-attachments/assets/9a12e223-6215-4759-ab89-eb554d6bc63e\"\n/>\n\n**NOTE**: When I tested with a policy that has also other packages\ninstalled (like system), the policy recompile still fails with a\nconnection error. I'm not quite sure how to solve this, since Fleet\nneeds to reach EPR to fetch the packages that are not bundled.\n\n\n### Checklist\n\nCheck the PR satisfies following conditions. \n\n- [ ]\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\nwas added for features that require explanation or tutorials\n- [ ] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>","sha":"09c40790bdc685a9d89ce56efb281e13cf8793e1"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.2.0","branchLabelMappingKey":"^v9.2.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/230992","number":230992,"mergeCommit":{"message":"[Fleet] Improve installation of bundled packages in airgapped environments (#230992)\n\nCloses https://github.com/elastic/kibana/issues/224954\n\n## Summary\n\nImprove installation of bundled packages in airgapped environments.\n\n- Add a fallback fetch of bundled packages / cached packages when\npossible\n- Improve error handling when EPR is not available\n- Fetch bundled packages as fallback when is possible (regardless of EPR\nbeing available or not)\n\nApproach explained\n[here](https://github.com/elastic/kibana/issues/224954#issuecomment-3164337500)\n\n### Testing steps\n\nConfiguration in `kibana.dev.yml`:\n```\nxpack.fleet:\n isAirGapped: true\n registryUrl: http://not-reachable\n developer.bundledPackageLocation: \"/Users/<USERNAME>/Dev/kibana/x-pack/platform/plugins/shared/fleet/target/bundled_packages\"\n```\nBundled packages (APM, synthetics, etc) need to be present in the above\nfolder. Download them from EPR\n\n- Put an older version of APM (for instance 7.16) in the folder and\ninstall it going to `app/integrations/apm-7.16/overview`, add it to a\nnew agent policy. I tested with a policy that doesn't have `system`\ninstalled\n- temporarily remove the configs `isAirGapped` and `registryUrl` to be\nable to reach the registry and add a newer version of the package to\n`bundled_packages` folder (I used apm-8.2.0)\n- To trigger the package upgrade, go to\n`app/integrations/apm-8.2.0/overview`\n- Put back the configs in `kibana.dev.yml` and now upgrade the package\nand the policies\n- The upgrade should work properly and the policy revision is aligned\n<img width=\"1862\" height=\"965\" alt=\"Screenshot 2025-08-11 at 11 13 29\"\nsrc=\"https://github.com/user-attachments/assets/690068f9-e43e-4685-b341-b3e4139e421e\"\n/>\n<img width=\"1866\" height=\"1159\" alt=\"Screenshot 2025-08-11 at 11 13 58\"\nsrc=\"https://github.com/user-attachments/assets/9a12e223-6215-4759-ab89-eb554d6bc63e\"\n/>\n\n**NOTE**: When I tested with a policy that has also other packages\ninstalled (like system), the policy recompile still fails with a\nconnection error. I'm not quite sure how to solve this, since Fleet\nneeds to reach EPR to fetch the packages that are not bundled.\n\n\n### Checklist\n\nCheck the PR satisfies following conditions. \n\n- [ ]\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\nwas added for features that require explanation or tutorials\n- [ ] [Unit or functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere updated or added to match the most common scenarios\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>","sha":"09c40790bdc685a9d89ce56efb281e13cf8793e1"}}]}] BACKPORT--> Co-authored-by: Cristina Amico <[email protected]> Co-authored-by: Elastic Machine <[email protected]>
1 parent b1a0809 commit 62f1b22

File tree

3 files changed

+384
-50
lines changed

3 files changed

+384
-50
lines changed

x-pack/platform/plugins/shared/fleet/server/services/epm/registry/index.test.ts

Lines changed: 241 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
* 2.0; you may not use this file except in compliance with the Elastic License
55
* 2.0.
66
*/
7-
87
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
98

10-
import { PackageNotFoundError, RegistryResponseError } from '../../../errors';
9+
import {
10+
FleetError,
11+
PackageNotFoundError,
12+
RegistryConnectionError,
13+
RegistryResponseError,
14+
} from '../../../errors';
1115
import * as Archive from '../archive';
1216

1317
import {
@@ -18,18 +22,26 @@ import {
1822
getLicensePath,
1923
fetchCategories,
2024
fetchList,
25+
getPackage,
2126
} from '.';
2227

23-
const mockLoggerFactory = loggingSystemMock.create();
24-
const mockLogger = mockLoggerFactory.get('mock logger');
28+
const mockLogger = loggingSystemMock.create().get();
29+
2530
const mockGetConfig = jest.fn();
2631

2732
const mockGetBundledPackageByName = jest.fn();
2833
const mockFetchUrl = jest.fn();
34+
const mockGetResponseStreamWithSize = jest.fn();
35+
const mockStreamToBuffer = jest.fn();
36+
const mockVerifyPackageArchiveSignature = jest.fn();
37+
const mockGetPackageAssetsMapCache = jest.fn();
2938

3039
const MockArchive = Archive as jest.Mocked<typeof Archive>;
3140

3241
jest.mock('../archive');
42+
jest.mock('./requests');
43+
jest.mock('../streams');
44+
jest.mock('../packages/cache');
3345

3446
jest.mock('../..', () => ({
3547
appContextService: {
@@ -43,12 +55,31 @@ jest.mock('../..', () => ({
4355

4456
jest.mock('./requests', () => ({
4557
fetchUrl: (url: string) => mockFetchUrl(url),
58+
getResponseStreamWithSize: (url: string) => mockGetResponseStreamWithSize(url),
59+
}));
60+
61+
jest.mock('../streams', () => ({
62+
streamToBuffer: (stream: NodeJS.ReadableStream, size?: number) =>
63+
mockStreamToBuffer(stream, size),
4664
}));
4765

4866
jest.mock('../packages/bundled_packages', () => ({
4967
getBundledPackageByName: (name: string) => mockGetBundledPackageByName(name),
5068
}));
5169

70+
jest.mock('../packages/package_verification', () => ({
71+
verifyPackageArchiveSignature: (
72+
pkgName: string,
73+
pkgVersion: string,
74+
pkgArchiveBuffer: Buffer | undefined,
75+
logger: Logger
76+
) => mockVerifyPackageArchiveSignature(pkgName, pkgVersion, pkgArchiveBuffer, logger),
77+
}));
78+
79+
jest.mock('../packages/cache', () => ({
80+
getPackageAssetsMapCache: () => mockGetPackageAssetsMapCache(),
81+
}));
82+
5283
describe('splitPkgKey', () => {
5384
it('throws an error if there is nothing before the delimiter', () => {
5485
expect(() => {
@@ -225,6 +256,15 @@ describe('fetchInfo', () => {
225256
});
226257
});
227258

259+
it('falls back to bundled package when isAirGapped config == true', async () => {
260+
mockGetConfig.mockReturnValue({
261+
isAirGapped: true,
262+
});
263+
264+
const fetchedInfo = await fetchInfo('test-package', '1.0.0');
265+
expect(fetchedInfo).toBeTruthy();
266+
});
267+
228268
it('falls back to bundled package when one exists', async () => {
229269
const fetchedInfo = await fetchInfo('test-package', '1.0.0');
230270
expect(fetchedInfo).toBeTruthy();
@@ -237,15 +277,6 @@ describe('fetchInfo', () => {
237277
expect(e).toBeInstanceOf(PackageNotFoundError);
238278
}
239279
});
240-
241-
it('falls back to bundled package when isAirGapped config == true', async () => {
242-
mockGetConfig.mockReturnValue({
243-
isAirGapped: true,
244-
});
245-
246-
const fetchedInfo = await fetchInfo('test-package', '1.0.0');
247-
expect(fetchedInfo).toBeTruthy();
248-
});
249280
});
250281

251282
describe('fetchCategories', () => {
@@ -378,3 +409,200 @@ describe('fetchList', () => {
378409
expect(callUrl.searchParams.get('kibana.version')).toBeNull();
379410
});
380411
});
412+
413+
describe('getPackage', () => {
414+
const bundledPackage = {
415+
name: 'testpkg',
416+
version: '1.0.0',
417+
getBuffer: () => Promise.resolve(Buffer.from('testpkg')),
418+
};
419+
const registryPackage = {
420+
name: 'testpkg',
421+
version: '1.0.1',
422+
getBuffer: () => Promise.resolve(Buffer.from('testpkg')),
423+
};
424+
afterEach(() => {
425+
jest.resetAllMocks();
426+
});
427+
428+
it('should return bundled package if isAirGapped = true', async () => {
429+
mockFetchUrl.mockResolvedValue(JSON.stringify([registryPackage]));
430+
mockGetResponseStreamWithSize.mockResolvedValue({ stream: {}, size: 1000 });
431+
mockStreamToBuffer.mockResolvedValue(Buffer.from('testpkg'));
432+
mockVerifyPackageArchiveSignature.mockResolvedValue('verified');
433+
mockGetConfig.mockReturnValue({
434+
isAirGapped: true,
435+
enabled: true,
436+
agents: { enabled: true, elasticsearch: {} },
437+
});
438+
MockArchive.unpackBufferToAssetsMap.mockReturnValue({
439+
assetsMap: new Map(),
440+
paths: [],
441+
archiveIterator: {},
442+
} as any);
443+
MockArchive.generatePackageInfoFromArchiveBuffer.mockReturnValue({
444+
packageInfo: { name: 'testpkg', version: '1.0.0' },
445+
} as any);
446+
447+
mockGetBundledPackageByName.mockResolvedValue(bundledPackage);
448+
const result = await getPackage('testpkg', '1.0.1');
449+
expect(result).toEqual({
450+
archiveIterator: {},
451+
assetsMap: new Map(),
452+
packageInfo: {
453+
name: 'testpkg',
454+
version: '1.0.0',
455+
},
456+
paths: [],
457+
verificationResult: 'verified',
458+
});
459+
});
460+
461+
it('should return registry package', async () => {
462+
mockFetchUrl.mockResolvedValue(JSON.stringify([registryPackage]));
463+
mockGetResponseStreamWithSize.mockResolvedValue({ stream: {}, size: 1000 });
464+
mockStreamToBuffer.mockResolvedValue(Buffer.from('testpkg'));
465+
mockVerifyPackageArchiveSignature.mockResolvedValue('verified');
466+
MockArchive.unpackBufferToAssetsMap.mockReturnValue({
467+
assetsMap: new Map(),
468+
paths: [],
469+
archiveIterator: {},
470+
} as any);
471+
MockArchive.generatePackageInfoFromArchiveBuffer.mockReturnValue({
472+
packageInfo: { name: 'testpkg', version: '1.0.1' },
473+
} as any);
474+
475+
mockGetBundledPackageByName.mockResolvedValue(undefined);
476+
const result = await getPackage('testpkg', '1.0.1');
477+
expect(result).toEqual({
478+
archiveIterator: {},
479+
assetsMap: new Map(),
480+
packageInfo: {
481+
name: 'testpkg',
482+
version: '1.0.1',
483+
},
484+
paths: [],
485+
verificationResult: 'verified',
486+
});
487+
});
488+
489+
it('should throw if there is an error in fetchArchiveBuffer', async () => {
490+
mockFetchUrl.mockResolvedValue(JSON.stringify([registryPackage]));
491+
mockGetResponseStreamWithSize.mockRejectedValueOnce(new FleetError('Error fetching package'));
492+
mockStreamToBuffer.mockResolvedValue(Buffer.from('testpkg'));
493+
mockVerifyPackageArchiveSignature.mockResolvedValue('verified');
494+
MockArchive.unpackBufferToAssetsMap.mockReturnValue({
495+
assetsMap: new Map(),
496+
paths: [],
497+
archiveIterator: {},
498+
} as any);
499+
MockArchive.generatePackageInfoFromArchiveBuffer.mockReturnValue({
500+
packageInfo: { name: 'testpkg', version: '1.0.1' },
501+
} as any);
502+
503+
mockGetBundledPackageByName.mockResolvedValue(bundledPackage);
504+
await expect(getPackage('testpkg', '1.0.1')).rejects.toThrowError(
505+
new FleetError('Error fetching package')
506+
);
507+
});
508+
509+
it('should try to retrieve from cache if there is a RegistryConnectionError and no bundled package', async () => {
510+
mockFetchUrl.mockResolvedValue(JSON.stringify([registryPackage]));
511+
mockGetResponseStreamWithSize.mockRejectedValueOnce(
512+
new RegistryConnectionError('Error connecting to EPR')
513+
);
514+
mockStreamToBuffer.mockResolvedValue(Buffer.from('testpkg'));
515+
mockVerifyPackageArchiveSignature.mockResolvedValue('verified');
516+
MockArchive.unpackBufferToAssetsMap.mockReturnValue({
517+
assetsMap: new Map([
518+
['test-1.0.0/LICENSE.txt', Buffer.from('')],
519+
['test-1.0.0/changelog.yml', Buffer.from('')],
520+
['test-1.0.0/manifest.yml', Buffer.from('')],
521+
['test-1.0.0/docs/README.md', Buffer.from('')],
522+
]),
523+
paths: [],
524+
archiveIterator: {},
525+
} as any);
526+
MockArchive.getPackageInfo.mockReturnValue({ name: 'testpkg', version: '1.0.1' } as any);
527+
mockGetPackageAssetsMapCache.mockReturnValue({
528+
name: 'test',
529+
} as any);
530+
mockGetBundledPackageByName.mockResolvedValue(bundledPackage);
531+
const result = await getPackage('testpkg', '1.0.1');
532+
expect(result).toEqual({
533+
archiveIterator: expect.any(Object),
534+
assetsMap: new Map([
535+
['test-1.0.0/LICENSE.txt', Buffer.from('')],
536+
['test-1.0.0/changelog.yml', Buffer.from('')],
537+
['test-1.0.0/manifest.yml', Buffer.from('')],
538+
['test-1.0.0/docs/README.md', Buffer.from('')],
539+
]),
540+
packageInfo: {
541+
name: 'testpkg',
542+
version: '1.0.1',
543+
},
544+
paths: [],
545+
verificationResult: undefined,
546+
});
547+
});
548+
549+
it('should falls back to bundled package if there is a RegistryConnectionError', async () => {
550+
mockFetchUrl.mockResolvedValue(JSON.stringify([registryPackage]));
551+
mockGetResponseStreamWithSize.mockRejectedValueOnce(
552+
new RegistryConnectionError('Error connecting to EPR')
553+
);
554+
mockStreamToBuffer.mockResolvedValue(Buffer.from('testpkg'));
555+
mockVerifyPackageArchiveSignature.mockResolvedValue('verified');
556+
MockArchive.unpackBufferToAssetsMap.mockReturnValue({
557+
assetsMap: new Map([
558+
['test-1.0.0/LICENSE.txt', Buffer.from('')],
559+
['test-1.0.0/changelog.yml', Buffer.from('')],
560+
['test-1.0.0/manifest.yml', Buffer.from('')],
561+
['test-1.0.0/docs/README.md', Buffer.from('')],
562+
]),
563+
paths: [],
564+
archiveIterator: {},
565+
} as any);
566+
MockArchive.getPackageInfo.mockReturnValue({ name: 'testpkg', version: '1.0.1' } as any);
567+
mockGetPackageAssetsMapCache.mockReturnValue(new Map());
568+
mockGetBundledPackageByName.mockResolvedValue(bundledPackage);
569+
const result = await getPackage('testpkg', '1.0.1');
570+
expect(result).toEqual({
571+
archiveIterator: expect.any(Object),
572+
assetsMap: new Map([
573+
['test-1.0.0/LICENSE.txt', Buffer.from('')],
574+
['test-1.0.0/changelog.yml', Buffer.from('')],
575+
['test-1.0.0/manifest.yml', Buffer.from('')],
576+
['test-1.0.0/docs/README.md', Buffer.from('')],
577+
]),
578+
packageInfo: {
579+
name: 'testpkg',
580+
version: '1.0.1',
581+
},
582+
paths: [],
583+
verificationResult: undefined,
584+
});
585+
});
586+
587+
it('should throw if there is a RegistryConnectionError and could not find bundled package nor retrieve from cache ', async () => {
588+
mockFetchUrl.mockResolvedValue(JSON.stringify([registryPackage]));
589+
mockGetResponseStreamWithSize.mockRejectedValueOnce(
590+
new RegistryConnectionError('Error connecting to EPR')
591+
);
592+
mockStreamToBuffer.mockResolvedValue(Buffer.from('testpkg'));
593+
mockVerifyPackageArchiveSignature.mockResolvedValue('verified');
594+
MockArchive.unpackBufferToAssetsMap.mockReturnValue({
595+
assetsMap: new Map(),
596+
paths: [],
597+
archiveIterator: {},
598+
} as any);
599+
MockArchive.generatePackageInfoFromArchiveBuffer.mockReturnValue({
600+
packageInfo: undefined,
601+
} as any);
602+
603+
mockGetBundledPackageByName.mockResolvedValue(undefined);
604+
await expect(getPackage('testpkg', '1.0.1')).rejects.toThrowError(
605+
new PackageNotFoundError('[email protected] not found')
606+
);
607+
});
608+
});

0 commit comments

Comments
 (0)