Skip to content

Commit

Permalink
[8.12] [Fleet] Fix get file handler for bundled package (#172182) (#1…
Browse files Browse the repository at this point in the history
…73058) (#173912)

# Backport

This will backport the following commits from `main` to `8.12`:
- [[Fleet] Fix get file handler for bundled package (#172182)
(#173058)](#173058)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Nicolas
Chaulet","email":"[email protected]"},"sourceCommit":{"committedDate":"2023-12-12T16:05:46Z","message":"[Fleet]
Fix get file handler for bundled package (#172182)
(#173058)","sha":"fa3b6f4c9da88e6ddbd1fae7c502d00353c55134","branchLabelMapping":{"^v8.13.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Fleet","backport:prev-minor","v8.13.0"],"number":173058,"url":"https://github.com/elastic/kibana/pull/173058","mergeCommit":{"message":"[Fleet]
Fix get file handler for bundled package (#172182)
(#173058)","sha":"fa3b6f4c9da88e6ddbd1fae7c502d00353c55134"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.13.0","labelRegex":"^v8.13.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/173058","number":173058,"mergeCommit":{"message":"[Fleet]
Fix get file handler for bundled package (#172182)
(#173058)","sha":"fa3b6f4c9da88e6ddbd1fae7c502d00353c55134"}}]}]
BACKPORT-->

Co-authored-by: Nicolas Chaulet <[email protected]>
  • Loading branch information
kibanamachine and nchaulet authored Dec 22, 2023
1 parent 84f9679 commit a2630f0
Show file tree
Hide file tree
Showing 5 changed files with 389 additions and 84 deletions.
245 changes: 245 additions & 0 deletions x-pack/plugins/fleet/server/routes/epm/file_handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
/*
* 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 { loggingSystemMock } from '@kbn/core-logging-server-mocks';
import { httpServerMock } from '@kbn/core-http-server-mocks';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
import { Headers } from 'node-fetch';

import { getBundledPackageByPkgKey } from '../../services/epm/packages/bundled_packages';
import { getFile, getInstallation } from '../../services/epm/packages/get';
import type { FleetRequestHandlerContext } from '../..';
import { appContextService } from '../../services';
import { unpackBufferEntries, getArchiveEntry } from '../../services/epm/archive';
import { getAsset } from '../../services/epm/archive/storage';

import { getFileHandler } from './file_handler';

jest.mock('../../services/app_context');
jest.mock('../../services/epm/archive');
jest.mock('../../services/epm/archive/storage');
jest.mock('../../services/epm/packages/bundled_packages');
jest.mock('../../services/epm/packages/get');

const mockedGetBundledPackageByPkgKey = jest.mocked(getBundledPackageByPkgKey);
const mockedGetInstallation = jest.mocked(getInstallation);
const mockedGetFile = jest.mocked(getFile);
const mockedGetArchiveEntry = jest.mocked(getArchiveEntry);
const mockedUnpackBufferEntries = jest.mocked(unpackBufferEntries);
const mockedGetAsset = jest.mocked(getAsset);

function mockContext() {
const mockSavedObjectsClient = savedObjectsClientMock.create();
const mockElasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
return {
fleet: {
internalSOClient: async () => mockSavedObjectsClient,
},
core: {
savedObjects: {
client: mockSavedObjectsClient,
},
elasticsearch: {
client: {
asInternalUser: mockElasticsearchClient,
},
},
},
} as unknown as FleetRequestHandlerContext;
}

describe('getFileHandler', () => {
beforeEach(() => {
const logger = loggingSystemMock.createLogger();
jest.mocked(appContextService).getLogger.mockReturnValue(logger);
mockedGetBundledPackageByPkgKey.mockReset();
mockedUnpackBufferEntries.mockReset();
mockedGetFile.mockReset();
mockedGetInstallation.mockReset();
mockedGetArchiveEntry.mockReset();
mockedGetAsset.mockReset();
});

it('should return the file for bundled package and an existing file', async () => {
mockedGetBundledPackageByPkgKey.mockResolvedValue({
getBuffer: () => Promise.resolve(),
} as any);
const request = httpServerMock.createKibanaRequest({
params: {
pkgName: 'test',
pkgVersion: '1.0.0',
filePath: 'README.md',
},
});
const buffer = Buffer.from(`TEST`);
mockedUnpackBufferEntries.mockResolvedValue([
{
path: 'test-1.0.0/README.md',
buffer,
},
]);
const response = httpServerMock.createResponseFactory();
const context = mockContext();
await getFileHandler(context, request, response);

expect(response.custom).toBeCalledWith(
expect.objectContaining({
statusCode: 200,
body: buffer,
headers: expect.objectContaining({
'content-type': 'text/markdown; charset=utf-8',
}),
})
);
});

it('should a 404 for bundled package with a non existing file', async () => {
mockedGetBundledPackageByPkgKey.mockResolvedValue({
getBuffer: () => Promise.resolve(),
} as any);
const request = httpServerMock.createKibanaRequest({
params: {
pkgName: 'test',
pkgVersion: '1.0.0',
filePath: 'idonotexists.md',
},
});
mockedUnpackBufferEntries.mockResolvedValue([
{
path: 'test-1.0.0/README.md',
buffer: Buffer.from(`TEST`),
},
]);
const response = httpServerMock.createResponseFactory();
const context = mockContext();
await getFileHandler(context, request, response);

expect(response.custom).toBeCalledWith(
expect.objectContaining({
statusCode: 404,
body: 'bundled package file not found: idonotexists.md',
})
);
});

it('should proxy registry 200 for non bundled and non installed package', async () => {
const request = httpServerMock.createKibanaRequest({
params: {
pkgName: 'test',
pkgVersion: '1.0.0',
filePath: 'idonotexists.md',
},
});
const response = httpServerMock.createResponseFactory();
const context = mockContext();

mockedGetFile.mockResolvedValue({
status: 200,
// @ts-expect-error
body: 'test',
headers: new Headers({
raw: '',
'content-type': 'text/markdown',
}),
});

await getFileHandler(context, request, response);

expect(response.custom).toBeCalledWith(
expect.objectContaining({
statusCode: 200,
body: 'test',
headers: expect.objectContaining({
'content-type': 'text/markdown',
}),
})
);
});

it('should proxy registry 404 for non bundled and non installed package', async () => {
const request = httpServerMock.createKibanaRequest({
params: {
pkgName: 'test',
pkgVersion: '1.0.0',
filePath: 'idonotexists.md',
},
});
const response = httpServerMock.createResponseFactory();
const context = mockContext();

mockedGetFile.mockResolvedValue({
status: 404,
// @ts-expect-error
body: 'not found',
headers: new Headers({
raw: '',
'content-type': 'text',
}),
});

await getFileHandler(context, request, response);

expect(response.custom).toBeCalledWith(
expect.objectContaining({
statusCode: 404,
body: 'not found',
headers: expect.objectContaining({
'content-type': 'text',
}),
})
);
});

it('should return the file from installation for installed package', async () => {
const request = httpServerMock.createKibanaRequest({
params: {
pkgName: 'test',
pkgVersion: '1.0.0',
filePath: 'README.md',
},
});
const response = httpServerMock.createResponseFactory();
const context = mockContext();

mockedGetInstallation.mockResolvedValue({ version: '1.0.0' } as any);
mockedGetArchiveEntry.mockReturnValue(Buffer.from('test'));

await getFileHandler(context, request, response);

expect(response.custom).toBeCalledWith(
expect.objectContaining({
statusCode: 200,
headers: expect.objectContaining({
'content-type': 'text/markdown; charset=utf-8',
}),
})
);
});

it('should a 404 if the file from installation do not exists for installed package', async () => {
const request = httpServerMock.createKibanaRequest({
params: {
pkgName: 'test',
pkgVersion: '1.0.0',
filePath: 'README.md',
},
});
const response = httpServerMock.createResponseFactory();
const context = mockContext();

mockedGetInstallation.mockResolvedValue({ version: '1.0.0' } as any);
await getFileHandler(context, request, response);

expect(response.custom).toBeCalledWith(
expect.objectContaining({
statusCode: 404,
body: 'installed package file not found: README.md',
})
);
});
});
141 changes: 141 additions & 0 deletions x-pack/plugins/fleet/server/routes/epm/file_handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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 path from 'path';

import type { TypeOf } from '@kbn/config-schema';
import mime from 'mime-types';
import type { ResponseHeaders, KnownHeaders, HttpResponseOptions } from '@kbn/core/server';

import type { GetFileRequestSchema, FleetRequestHandler } from '../../types';
import { getFile, getInstallation } from '../../services/epm/packages';
import { defaultFleetErrorHandler } from '../../errors';
import { getArchiveEntry } from '../../services/epm/archive';
import { getAsset } from '../../services/epm/archive/storage';
import { getBundledPackageByPkgKey } from '../../services/epm/packages/bundled_packages';
import { pkgToPkgKey } from '../../services/epm/registry';
import { unpackBufferEntries } from '../../services/epm/archive';

const CACHE_CONTROL_10_MINUTES_HEADER: HttpResponseOptions['headers'] = {
'cache-control': 'max-age=600',
};
export const getFileHandler: FleetRequestHandler<
TypeOf<typeof GetFileRequestSchema.params>
> = async (context, request, response) => {
try {
const { pkgName, pkgVersion, filePath } = request.params;
const savedObjectsClient = (await context.fleet).internalSoClient;

const installation = await getInstallation({ savedObjectsClient, pkgName });
const useLocalFile = pkgVersion === installation?.version;
const assetPath = `${pkgName}-${pkgVersion}/${filePath}`;

if (useLocalFile) {
const fileBuffer = getArchiveEntry(assetPath);
// only pull local installation if we don't have it cached
const storedAsset = !fileBuffer && (await getAsset({ savedObjectsClient, path: assetPath }));

// error, if neither is available
if (!fileBuffer && !storedAsset) {
return response.custom({
body: `installed package file not found: ${filePath}`,
statusCode: 404,
});
}

// if storedAsset is not available, fileBuffer *must* be
// b/c we error if we don't have at least one, and storedAsset is the least likely
const { buffer, contentType } = storedAsset
? {
contentType: storedAsset.media_type,
buffer: storedAsset.data_utf8
? Buffer.from(storedAsset.data_utf8, 'utf8')
: Buffer.from(storedAsset.data_base64, 'base64'),
}
: {
contentType: mime.contentType(path.extname(assetPath)),
buffer: fileBuffer,
};

if (!contentType) {
return response.custom({
body: `unknown content type for file: ${filePath}`,
statusCode: 400,
});
}

return response.custom({
body: buffer,
statusCode: 200,
headers: {
...CACHE_CONTROL_10_MINUTES_HEADER,
'content-type': contentType,
},
});
}

const bundledPackage = await getBundledPackageByPkgKey(
pkgToPkgKey({ name: pkgName, version: pkgVersion })
);
if (bundledPackage) {
const bufferEntries = await unpackBufferEntries(
await bundledPackage.getBuffer(),
'application/zip'
);

const fileBuffer = bufferEntries.find((entry) => entry.path === assetPath)?.buffer;

if (!fileBuffer) {
return response.custom({
body: `bundled package file not found: ${filePath}`,
statusCode: 404,
});
}

// if storedAsset is not available, fileBuffer *must* be
// b/c we error if we don't have at least one, and storedAsset is the least likely
const { buffer, contentType } = {
contentType: mime.contentType(path.extname(assetPath)),
buffer: fileBuffer,
};

if (!contentType) {
return response.custom({
body: `unknown content type for file: ${filePath}`,
statusCode: 400,
});
}

return response.custom({
body: buffer,
statusCode: 200,
headers: {
...CACHE_CONTROL_10_MINUTES_HEADER,
'content-type': contentType,
},
});
} else {
const registryResponse = await getFile(pkgName, pkgVersion, filePath);
const headersToProxy: KnownHeaders[] = ['content-type'];
const proxiedHeaders = headersToProxy.reduce((headers, knownHeader) => {
const value = registryResponse.headers.get(knownHeader);
if (value !== null) {
headers[knownHeader] = value;
}
return headers;
}, {} as ResponseHeaders);

return response.custom({
body: registryResponse.body,
statusCode: registryResponse.status,
headers: { ...CACHE_CONTROL_10_MINUTES_HEADER, ...proxiedHeaders },
});
}
} catch (error) {
return defaultFleetErrorHandler({ error, response });
}
};
Loading

0 comments on commit a2630f0

Please sign in to comment.