packages/cdk-assets/lib/asset-manifest.ts (220 lines of code) (raw):
import * as fs from 'fs';
import * as path from 'path';
import type {
AssetManifest as AssetManifestSchema,
DockerImageDestination,
DockerImageSource,
FileDestination,
FileSource,
} from '@aws-cdk/cloud-assembly-schema';
import {
Manifest,
} from '@aws-cdk/cloud-assembly-schema';
/**
* A manifest of assets
*/
export class AssetManifest {
/**
* The default name of the asset manifest in a cdk.out directory
*/
public static readonly DEFAULT_FILENAME = 'assets.json';
/**
* Load an asset manifest from the given file
*/
public static fromFile(fileName: string) {
try {
const obj = Manifest.loadAssetManifest(fileName);
return new AssetManifest(path.dirname(fileName), obj);
} catch (e: any) {
throw new Error(`Cannot read asset manifest '${fileName}': ${e.message}`);
}
}
/**
* Load an asset manifest from the given file or directory
*
* If the argument given is a directoy, the default asset file name will be used.
*/
public static fromPath(filePath: string) {
let st;
try {
st = fs.statSync(filePath);
} catch (e: any) {
throw new Error(`Cannot read asset manifest at '${filePath}': ${e.message}`);
}
if (st.isDirectory()) {
return AssetManifest.fromFile(path.join(filePath, AssetManifest.DEFAULT_FILENAME));
}
return AssetManifest.fromFile(filePath);
}
/**
* The directory where the manifest was found
*/
public readonly directory: string;
constructor(
directory: string,
private readonly manifest: AssetManifestSchema,
) {
this.directory = directory;
}
/**
* Select a subset of assets and destinations from this manifest.
*
* Only assets with at least 1 selected destination are retained.
*
* If selection is not given, everything is returned.
*/
public select(selection?: DestinationPattern[]): AssetManifest {
if (selection === undefined) {
return this;
}
const ret: AssetManifestSchema & Required<Pick<AssetManifestSchema, AssetType>> = {
version: this.manifest.version,
dockerImages: {},
files: {},
};
for (const assetType of ASSET_TYPES) {
for (const [assetId, asset] of Object.entries(this.manifest[assetType] || {})) {
const filteredDestinations = filterDict(asset.destinations, (_, destId) =>
selection.some((sel) => sel.matches(new DestinationIdentifier(assetId, destId))),
);
if (Object.keys(filteredDestinations).length > 0) {
ret[assetType][assetId] = {
...asset,
destinations: filteredDestinations,
};
}
}
}
return new AssetManifest(this.directory, ret);
}
/**
* Describe the asset manifest as a list of strings
*/
public list() {
return [
...describeAssets('file', this.manifest.files || {}),
...describeAssets('docker-image', this.manifest.dockerImages || {}),
];
function describeAssets(
type: string,
assets: Record<string, { source: any; destinations: Record<string, any> }>,
) {
const ret = new Array<string>();
for (const [assetId, asset] of Object.entries(assets || {})) {
ret.push(`${assetId} ${type} ${JSON.stringify(asset.source)}`);
const destStrings = Object.entries(asset.destinations).map(
([destId, dest]) => ` ${assetId}:${destId} ${JSON.stringify(dest)}`,
);
ret.push(...prefixTreeChars(destStrings, ' '));
}
return ret;
}
}
/**
* List of assets per destination
*
* Returns one asset for every publishable destination. Multiple asset
* destinations may share the same asset source.
*/
public get entries(): IManifestEntry[] {
return [
...makeEntries(this.manifest.files || {}, FileManifestEntry),
...makeEntries(this.manifest.dockerImages || {}, DockerImageManifestEntry),
];
}
/**
* List of file assets, splat out to destinations
*/
public get files(): FileManifestEntry[] {
return makeEntries(this.manifest.files || {}, FileManifestEntry);
}
}
function makeEntries<A, B, C>(
assets: Record<string, { source: A; displayName?: string; destinations: Record<string, B> }>,
ctor: new (id: DestinationIdentifier, displayName: string | undefined, source: A, destination: B) => C,
): C[] {
const ret = new Array<C>();
for (const [assetId, asset] of Object.entries(assets)) {
for (const [destId, destination] of Object.entries(asset.destinations)) {
ret.push(new ctor(new DestinationIdentifier(assetId, destId), asset.displayName, asset.source, destination));
}
}
return ret;
}
type AssetType = 'files' | 'dockerImages';
const ASSET_TYPES: AssetType[] = ['files', 'dockerImages'];
/**
* A single asset from an asset manifest'
*/
export interface IManifestEntry {
/**
* The identifier of the asset and its destination
*/
readonly id: DestinationIdentifier;
/**
* The type of asset
*/
readonly type: string;
/**
* Type-dependent source data
*/
readonly genericSource: unknown;
/**
* Type-dependent destination data
*/
readonly genericDestination: unknown;
/**
* Return a display name for this asset
*
* The `includeDestination` parameter controls whether or not to include the
* destination ID in the display name.
*
* - Pass `false` if you are displaying notifications about building the
* asset, or if you are describing the work of building the asset and publishing
* to all destinations at the same time.
* - Pass `true` if you are displaying notifications about publishing to a
* specific destination.
*/
displayName(includeDestination: boolean): string;
}
/**
* A manifest entry for a file asset
*/
export class FileManifestEntry implements IManifestEntry {
public readonly genericSource: unknown;
public readonly genericDestination: unknown;
public readonly type = 'file';
constructor(
/** Identifier for this asset */
public readonly id: DestinationIdentifier,
private readonly _displayName: string | undefined,
/** Source of the file asset */
public readonly source: FileSource,
/** Destination for the file asset */
public readonly destination: FileDestination,
) {
this.genericSource = source;
this.genericDestination = destination;
}
public displayName(includeDestination: boolean): string {
if (includeDestination) {
return this._displayName ? `${this._displayName} (${this.id.destinationId})` : `${this.id}`;
} else {
return this._displayName ? this._displayName : this.id.assetId;
}
}
}
/**
* A manifest entry for a docker image asset
*/
export class DockerImageManifestEntry implements IManifestEntry {
public readonly genericSource: unknown;
public readonly genericDestination: unknown;
public readonly type = 'docker-image';
constructor(
/** Identifier for this asset */
public readonly id: DestinationIdentifier,
private readonly _displayName: string | undefined,
/** Source of the file asset */
public readonly source: DockerImageSource,
/** Destination for the file asset */
public readonly destination: DockerImageDestination,
) {
this.genericSource = source;
this.genericDestination = destination;
}
public displayName(includeDestination: boolean): string {
if (includeDestination) {
return this._displayName ? `${this._displayName} (${this.id.destinationId})` : `${this.id}`;
} else {
return this._displayName ? this._displayName : this.id.assetId;
}
}
}
/**
* Identify an asset destination in an asset manifest
*
* This class is used to identify both an asset to be built as well as a
* destination where an asset will be published. However, when reasoning about
* building assets the destination part can be ignored, because the same asset
* being sent to multiple destinations will only need to be built once and their
* assetIds are all the same.
*
* When stringified, this will be a combination of the source and destination
* IDs; if a string representation of the source is necessary, use `id.assetId`
* instead.
*/
export class DestinationIdentifier {
/**
* Identifies the asset, by source.
*
* The assetId will be the same between assets that represent
* the same physical file or image.
*/
public readonly assetId: string;
/**
* Identifies the destination where this asset will be published
*/
public readonly destinationId: string;
constructor(assetId: string, destinationId: string) {
this.assetId = assetId;
this.destinationId = destinationId;
}
/**
* Return a string representation for this asset identifier
*/
public toString() {
return this.destinationId ? `${this.assetId}:${this.destinationId}` : this.assetId;
}
}
function filterDict<A>(
xs: Record<string, A>,
pred: (x: A, key: string) => boolean,
): Record<string, A> {
const ret: Record<string, A> = {};
for (const [key, value] of Object.entries(xs)) {
if (pred(value, key)) {
ret[key] = value;
}
}
return ret;
}
/**
* A filter pattern for an destination identifier
*/
export class DestinationPattern {
/**
* Parse a ':'-separated string into an asset/destination identifier
*/
public static parse(s: string) {
if (!s) {
throw new Error('Empty string is not a valid destination identifier');
}
const parts = s.split(':').map((x) => (x !== '*' ? x : undefined));
if (parts.length === 1) {
return new DestinationPattern(parts[0]);
}
if (parts.length === 2) {
return new DestinationPattern(parts[0] || undefined, parts[1] || undefined);
}
throw new Error(`Asset identifier must contain at most 2 ':'-separated parts, got '${s}'`);
}
/**
* Identifies the asset, by source.
*/
public readonly assetId?: string;
/**
* Identifies the destination where this asset will be published
*/
public readonly destinationId?: string;
constructor(assetId?: string, destinationId?: string) {
this.assetId = assetId;
this.destinationId = destinationId;
}
/**
* Whether or not this pattern matches the given identifier
*/
public matches(id: DestinationIdentifier) {
return (
(this.assetId === undefined || this.assetId === id.assetId) &&
(this.destinationId === undefined || this.destinationId === id.destinationId)
);
}
/**
* Return a string representation for this asset identifier
*/
public toString() {
return `${this.assetId ?? '*'}:${this.destinationId ?? '*'}`;
}
}
/**
* Prefix box-drawing characters to make lines look like a hanging tree
*/
function prefixTreeChars(xs: string[], prefix = '') {
const ret = new Array<string>();
for (let i = 0; i < xs.length; i++) {
const isLast = i === xs.length - 1;
const boxChar = isLast ? '└' : '├';
ret.push(`${prefix}${boxChar}${xs[i]}`);
}
return ret;
}