Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Fleet][EPM] Unified install and archive #83384

Merged
merged 13 commits into from
Nov 17, 2020
64 changes: 50 additions & 14 deletions x-pack/plugins/fleet/server/services/epm/archive/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,57 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pkgToPkgKey } from '../registry/index';
import { ArchiveEntry } from './index';
import { InstallSource, ArchivePackage, RegistryPackage } from '../../../../common';

const cache: Map<string, Buffer> = new Map();
export const cacheGet = (key: string) => cache.get(key);
export const cacheSet = (key: string, value: Buffer) => cache.set(key, value);
export const cacheHas = (key: string) => cache.has(key);
export const cacheClear = () => cache.clear();
export const cacheDelete = (key: string) => cache.delete(key);
const archiveEntryCache: Map<ArchiveEntry['path'], ArchiveEntry['buffer']> = new Map();
export const getArchiveEntry = (key: string) => archiveEntryCache.get(key);
export const setArchiveEntry = (key: string, value: Buffer) => archiveEntryCache.set(key, value);
export const hasArchiveEntry = (key: string) => archiveEntryCache.has(key);
export const clearArchiveEntries = () => archiveEntryCache.clear();
export const deleteArchiveEntry = (key: string) => archiveEntryCache.delete(key);

const archiveFilelistCache: Map<string, string[]> = new Map();
export const getArchiveFilelist = (name: string, version: string) =>
archiveFilelistCache.get(pkgToPkgKey({ name, version }));
export interface SharedKey {
name: string;
version: string;
installSource: InstallSource;
}
type SharedKeyString = string;

export const setArchiveFilelist = (name: string, version: string, paths: string[]) =>
archiveFilelistCache.set(pkgToPkgKey({ name, version }), paths);
type ArchiveFilelist = string[];
const archiveFilelistCache: Map<SharedKeyString, ArchiveFilelist> = new Map();
export const getArchiveFilelist = (keyArgs: SharedKey) =>
archiveFilelistCache.get(sharedKey(keyArgs));

export const deleteArchiveFilelist = (name: string, version: string) =>
archiveFilelistCache.delete(pkgToPkgKey({ name, version }));
export const setArchiveFilelist = (keyArgs: SharedKey, paths: string[]) =>
archiveFilelistCache.set(sharedKey(keyArgs), paths);

export const deleteArchiveFilelist = (keyArgs: SharedKey) =>
archiveFilelistCache.delete(sharedKey(keyArgs));

const packageInfoCache: Map<SharedKeyString, ArchivePackage | RegistryPackage> = new Map();
const sharedKey = ({ name, version, installSource }: SharedKey) =>
`${name}-${version}-${installSource}`;

export const getPackageInfo = (args: SharedKey) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should name this something different as we already have a getPackageInfo https://github.com/elastic/kibana/pull/83384/files#diff-baf8294e3f474e4f2436b376ab8b1c1c8a611c0d0455ff88e78563c613deeb4dL106

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's ok for the names to be the same since they're in different services. I also think one might replace/wrap the other eventually. I'll leave as-is and we can rename later if we want.

const packageInfo = packageInfoCache.get(sharedKey(args));
if (args.installSource === 'registry') {
return packageInfo as RegistryPackage;
} else if (args.installSource === 'upload') {
return packageInfo as ArchivePackage;
} else {
throw new Error(`Unknown installSource: ${args.installSource}`);
}
};

export const setPackageInfo = ({
name,
version,
installSource,
packageInfo,
}: SharedKey & { packageInfo: ArchivePackage | RegistryPackage }) => {
const key = sharedKey({ name, version, installSource });
return packageInfoCache.set(key, packageInfo);
};

export const deletePackageInfo = (args: SharedKey) => packageInfoCache.delete(sharedKey(args));
69 changes: 30 additions & 39 deletions x-pack/plugins/fleet/server/services/epm/archive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,68 +4,57 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { ArchivePackage, AssetParts } from '../../../../common/types';
import { AssetParts, InstallSource } from '../../../../common/types';
import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors';
import {
cacheGet,
cacheSet,
cacheDelete,
SharedKey,
getArchiveEntry,
setArchiveEntry,
deleteArchiveEntry,
getArchiveFilelist,
setArchiveFilelist,
deleteArchiveFilelist,
deletePackageInfo,
} from './cache';
import { getBufferExtractor } from './extract';
import { parseAndVerifyArchiveEntries } from './validation';

export * from './cache';
export { untarBuffer, unzipBuffer, getBufferExtractor } from './extract';
export { getBufferExtractor, untarBuffer, unzipBuffer } from './extract';
export { parseAndVerifyArchiveBuffer as parseAndVerifyArchiveEntries } from './validation';

export interface ArchiveEntry {
path: string;
buffer?: Buffer;
}

export async function getArchivePackage({
archiveBuffer,
export async function unpackBufferToCache({
name,
version,
contentType,
archiveBuffer,
installSource,
}: {
archiveBuffer: Buffer;
name: string;
version: string;
contentType: string;
}): Promise<{ paths: string[]; archivePackageInfo: ArchivePackage }> {
const entries = await unpackArchiveEntries(archiveBuffer, contentType);
const { archivePackageInfo } = await parseAndVerifyArchiveEntries(entries);
const paths = addEntriesToMemoryStore(entries);

setArchiveFilelist(archivePackageInfo.name, archivePackageInfo.version, paths);

return {
paths,
archivePackageInfo,
};
}

export async function unpackArchiveToCache(
archiveBuffer: Buffer,
contentType: string
): Promise<string[]> {
const entries = await unpackArchiveEntries(archiveBuffer, contentType);
return addEntriesToMemoryStore(entries);
}

function addEntriesToMemoryStore(entries: ArchiveEntry[]) {
archiveBuffer: Buffer;
installSource: InstallSource;
}): Promise<string[]> {
const entries = await unpackBufferEntries(archiveBuffer, contentType);
const paths: string[] = [];
entries.forEach((entry) => {
const { path, buffer } = entry;
if (buffer) {
cacheSet(path, buffer);
setArchiveEntry(path, buffer);
paths.push(path);
}
});
setArchiveFilelist({ name, version, installSource }, paths);

return paths;
}

export async function unpackArchiveEntries(
export async function unpackBufferEntries(
archiveBuffer: Buffer,
contentType: string
): Promise<ArchiveEntry[]> {
Expand Down Expand Up @@ -96,16 +85,18 @@ export async function unpackArchiveEntries(
return entries;
}

export const deletePackageCache = (name: string, version: string) => {
export const deletePackageCache = ({ name, version, installSource }: SharedKey) => {
// get cached archive filelist
const paths = getArchiveFilelist(name, version);
const paths = getArchiveFilelist({ name, version, installSource });

// delete cached archive filelist
deleteArchiveFilelist(name, version);
deleteArchiveFilelist({ name, version, installSource });

// delete cached archive files
// this has been populated in unpackArchiveToCache()
paths?.forEach((path) => cacheDelete(path));
// this has been populated in unpackBufferToCache()
paths?.forEach(deleteArchiveEntry);

deletePackageInfo({ name, version, installSource });
};

export function getPathParts(path: string): AssetParts {
Expand Down Expand Up @@ -139,7 +130,7 @@ export function getPathParts(path: string): AssetParts {
}

export function getAsset(key: string) {
const buffer = cacheGet(key);
const buffer = getArchiveEntry(key);
if (buffer === undefined) throw new Error(`Cannot find asset ${key}`);

return buffer;
Expand Down
14 changes: 8 additions & 6 deletions x-pack/plugins/fleet/server/services/epm/archive/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
RegistryVarsEntry,
} from '../../../../common/types';
import { PackageInvalidArchiveError } from '../../../errors';
import { ArchiveEntry } from './index';
import { unpackBufferEntries } from './index';
import { pkgToPkgKey } from '../registry';

const MANIFESTS: Record<string, Buffer> = {};
Expand All @@ -24,22 +24,24 @@ const MANIFEST_NAME = 'manifest.yml';
// TODO: everything below performs verification of manifest.yml files, and hence duplicates functionality already implemented in the
// package registry. At some point this should probably be replaced (or enhanced) with verification based on
// https://github.com/elastic/package-spec/
export async function parseAndVerifyArchiveEntries(
entries: ArchiveEntry[]
): Promise<{ paths: string[]; archivePackageInfo: ArchivePackage }> {
export async function parseAndVerifyArchiveBuffer(
archiveBuffer: Buffer,
contentType: string
): Promise<{ paths: string[]; packageInfo: ArchivePackage }> {
const entries = await unpackBufferEntries(archiveBuffer, contentType);
const paths: string[] = [];
entries.forEach(({ path, buffer }) => {
paths.push(path);
if (path.endsWith(MANIFEST_NAME) && buffer) MANIFESTS[path] = buffer;
});

return {
archivePackageInfo: parseAndVerifyArchive(paths),
packageInfo: parseAndVerifyArchive(paths),
paths,
};
}

export function parseAndVerifyArchive(paths: string[]): ArchivePackage {
function parseAndVerifyArchive(paths: string[]): ArchivePackage {
// The top-level directory must match pkgName-pkgVersion, and no other top-level files or directories may be present
const toplevelDir = paths[0].split('/')[0];
paths.forEach((path) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,6 @@ export async function installIndexPatterns(
savedObjectsClient,
installationStatuses.Installed
);
// TODO: move to install package
// cache all installed packages if they don't exist
const packagePromises = installedPackages.map((pkg) =>
// TODO: this hard-codes 'registry' as installSource, so uploaded packages are ignored
// and their fields will be removed from the generated index patterns after this runs.
Registry.ensureCachedArchiveInfo(pkg.pkgName, pkg.pkgVersion, 'registry')
);
await Promise.all(packagePromises);

const packageVersionsToFetch = [...installedPackages];
if (pkgName && pkgVersion) {
Expand Down
10 changes: 4 additions & 6 deletions x-pack/plugins/fleet/server/services/epm/packages/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/

import { InstallablePackage } from '../../../types';
import * as Registry from '../registry';
import { ArchiveEntry, getArchiveFilelist, getAsset } from '../archive';

// paths from RegistryPackage are routes to the assets on EPR
Expand All @@ -21,7 +20,8 @@ export function getAssets(
datasetName?: string
): string[] {
const assets: string[] = [];
const paths = getArchiveFilelist(packageInfo.name, packageInfo.version);
const { name, version } = packageInfo;
const paths = getArchiveFilelist({ name, version, installSource: 'registry' });
// TODO: might be better to throw a PackageCacheError here
if (!paths || paths.length === 0) return assets;

Expand All @@ -47,15 +47,13 @@ export function getAssets(
return assets;
}

// ASK: Does getAssetsData need an installSource now?
// if so, should it be an Installation vs InstallablePackage or add another argument?
export async function getAssetsData(
packageInfo: InstallablePackage,
filter = (path: string): boolean => true,
datasetName?: string
): Promise<ArchiveEntry[]> {
// TODO: Needs to be called to fill the cache but should not be required

await Registry.ensureCachedArchiveInfo(packageInfo.name, packageInfo.version, 'registry');

// Gather all asset data
const assets = getAssets(packageInfo, filter, datasetName);
const entries: ArchiveEntry[] = assets.map((path) => {
Expand Down
6 changes: 1 addition & 5 deletions x-pack/plugins/fleet/server/services/epm/packages/get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,7 @@ export async function getPackageInfo(options: {
pkgVersion: string;
}): Promise<PackageInfo> {
const { savedObjectsClient, pkgName, pkgVersion } = options;
const [
savedObject,
latestPackage,
{ paths: assets, registryPackageInfo: item },
] = await Promise.all([
const [savedObject, latestPackage, { paths: assets, packageInfo: item }] = await Promise.all([
getInstallationObject({ savedObjectsClient, pkgName }),
Registry.fetchFindLatestPackage(pkgName),
Registry.getRegistryPackage(pkgName, pkgVersion),
Expand Down
42 changes: 28 additions & 14 deletions x-pack/plugins/fleet/server/services/epm/packages/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
KibanaAssetType,
} from '../../../types';
import * as Registry from '../registry';
import { setPackageInfo, parseAndVerifyArchiveEntries, unpackBufferToCache } from '../archive';
import {
getInstallation,
getInstallationObject,
Expand All @@ -43,7 +44,6 @@ import {
} from '../../../errors';
import { getPackageSavedObjects } from './get';
import { appContextService } from '../../app_context';
import { getArchivePackage } from '../archive';
import { _installPackage } from './_install_package';

export async function installLatestPackage(options: {
Expand Down Expand Up @@ -245,29 +245,26 @@ async function installPackageFromRegistry({
}: InstallRegistryPackageParams): Promise<AssetReference[]> {
// TODO: change epm API to /packageName/version so we don't need to do this
const { pkgName, pkgVersion } = Registry.splitPkgKey(pkgkey);
// TODO: calls to getInstallationObject, Registry.fetchInfo, and Registry.fetchFindLatestPackge
// and be replaced by getPackageInfo after adjusting for it to not group/use archive assets
const latestPackage = await Registry.fetchFindLatestPackage(pkgName);
// get the currently installed package
const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName });

const installType = getInstallType({ pkgVersion, installedPkg });

// let the user install if using the force flag or needing to reinstall or install a previous version due to failed update
const installOutOfDateVersionOk =
installType === 'reinstall' || installType === 'reupdate' || installType === 'rollback';

const latestPackage = await Registry.fetchFindLatestPackage(pkgName);
if (semverLt(pkgVersion, latestPackage.version) && !force && !installOutOfDateVersionOk) {
throw new PackageOutdatedError(`${pkgkey} is out-of-date and cannot be installed or updated`);
}

const { paths, registryPackageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion);
const { paths, packageInfo } = await Registry.getRegistryPackage(pkgName, pkgVersion);

return _installPackage({
savedObjectsClient,
callCluster,
installedPkg,
paths,
packageInfo: registryPackageInfo,
packageInfo,
installType,
installSource: 'registry',
});
Expand All @@ -290,27 +287,44 @@ async function installPackageByUpload({
archiveBuffer,
contentType,
}: InstallUploadedArchiveParams): Promise<AssetReference[]> {
const { paths, archivePackageInfo } = await getArchivePackage({ archiveBuffer, contentType });
const { packageInfo } = await parseAndVerifyArchiveEntries(archiveBuffer, contentType);

const installedPkg = await getInstallationObject({
savedObjectsClient,
pkgName: archivePackageInfo.name,
pkgName: packageInfo.name,
});
const installType = getInstallType({ pkgVersion: archivePackageInfo.version, installedPkg });

const installType = getInstallType({ pkgVersion: packageInfo.version, installedPkg });
if (installType !== 'install') {
throw new PackageOperationNotSupportedError(
`Package upload only supports fresh installations. Package ${archivePackageInfo.name} is already installed, please uninstall first.`
`Package upload only supports fresh installations. Package ${packageInfo.name} is already installed, please uninstall first.`
);
}

const installSource = 'upload';
const paths = await unpackBufferToCache({
name: packageInfo.name,
version: packageInfo.version,
installSource,
archiveBuffer,
contentType,
});

setPackageInfo({
name: packageInfo.name,
version: packageInfo.version,
installSource,
packageInfo,
});

return _installPackage({
savedObjectsClient,
callCluster,
installedPkg,
paths,
packageInfo: archivePackageInfo,
packageInfo,
installType,
installSource: 'upload',
installSource,
});
}

Expand Down
Loading