Skip to content

Commit 09c4079

Browse files
[Fleet] Improve installation of bundled packages in airgapped environments (#230992)
Closes #224954 ## Summary Improve installation of bundled packages in airgapped environments. - Add a fallback fetch of bundled packages / cached packages when possible - Improve error handling when EPR is not available - Fetch bundled packages as fallback when is possible (regardless of EPR being available or not) Approach explained [here](#224954 (comment)) ### Testing steps Configuration in `kibana.dev.yml`: ``` xpack.fleet: isAirGapped: true registryUrl: http://not-reachable developer.bundledPackageLocation: "/Users/<USERNAME>/Dev/kibana/x-pack/platform/plugins/shared/fleet/target/bundled_packages" ``` Bundled packages (APM, synthetics, etc) need to be present in the above folder. Download them from EPR - Put an older version of APM (for instance 7.16) in the folder and install it going to `app/integrations/apm-7.16/overview`, add it to a new agent policy. I tested with a policy that doesn't have `system` installed - temporarily remove the configs `isAirGapped` and `registryUrl` to be able to reach the registry and add a newer version of the package to `bundled_packages` folder (I used apm-8.2.0) - To trigger the package upgrade, go to `app/integrations/apm-8.2.0/overview` - Put back the configs in `kibana.dev.yml` and now upgrade the package and the policies - The upgrade should work properly and the policy revision is aligned <img width="1862" height="965" alt="Screenshot 2025-08-11 at 11 13 29" src="https://github.com/user-attachments/assets/690068f9-e43e-4685-b341-b3e4139e421e" /> <img width="1866" height="1159" alt="Screenshot 2025-08-11 at 11 13 58" src="https://github.com/user-attachments/assets/9a12e223-6215-4759-ab89-eb554d6bc63e" /> **NOTE**: When I tested with a policy that has also other packages installed (like system), the policy recompile still fails with a connection error. I'm not quite sure how to solve this, since Fleet needs to reach EPR to fetch the packages that are not bundled. ### Checklist Check the PR satisfies following conditions. - [ ] [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 --------- Co-authored-by: Elastic Machine <[email protected]>
1 parent 62e4165 commit 09c4079

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)