packages/cdk-assets/lib/private/handlers/container-images.ts (226 lines of code) (raw):
import * as path from 'path';
import type { DockerImageDestination } from '@aws-cdk/cloud-assembly-schema';
import { destinationToClientOptions } from './client-options';
import type { DockerImageManifestEntry } from '../../asset-manifest';
import type { IECRClient } from '../../aws';
import { EventType, shellEventPublisherFromEventEmitter } from '../../progress';
import type { IAssetHandler, IHandlerHost, IHandlerOptions } from '../asset-handler';
import type { Docker } from '../docker';
import { replaceAwsPlaceholders } from '../placeholders';
import { shell } from '../shell';
interface ContainerImageAssetHandlerInit {
readonly ecr: IECRClient;
readonly repoUri: string;
readonly imageUri: string;
readonly destinationAlreadyExists: boolean;
}
export class ContainerImageAssetHandler implements IAssetHandler {
private init?: ContainerImageAssetHandlerInit;
constructor(
private readonly workDir: string,
private readonly asset: DockerImageManifestEntry,
private readonly host: IHandlerHost,
private readonly options: IHandlerOptions,
) {
}
public async build(): Promise<void> {
const initOnce = await this.initOnce();
if (initOnce.destinationAlreadyExists) {
return;
}
if (this.host.aborted) {
return;
}
const dockerForBuilding = await this.host.dockerFactory.forBuild({
repoUri: initOnce.repoUri,
eventEmitter: (m: string) => this.host.emitMessage(EventType.DEBUG, m),
ecr: initOnce.ecr,
subprocessOutputDestination: this.options.subprocessOutputDestination,
});
const builder = new ContainerImageBuilder(
dockerForBuilding,
this.workDir,
this.asset,
this.host,
);
const localTagName = await builder.build();
if (localTagName === undefined || this.host.aborted) {
return;
}
if (this.host.aborted) {
return;
}
await dockerForBuilding.tag(localTagName, initOnce.imageUri);
}
public async isPublished(): Promise<boolean> {
try {
const initOnce = await this.initOnce({ quiet: true });
return initOnce.destinationAlreadyExists;
} catch (e: any) {
this.host.emitMessage(EventType.DEBUG, `${e.message}`);
}
return false;
}
public async publish(): Promise<void> {
const initOnce = await this.initOnce();
if (initOnce.destinationAlreadyExists) {
return;
}
if (this.host.aborted) {
return;
}
const dockerForPushing = await this.host.dockerFactory.forEcrPush({
repoUri: initOnce.repoUri,
eventEmitter: this.host.emitMessage,
ecr: initOnce.ecr,
subprocessOutputDestination: this.options.subprocessOutputDestination,
});
if (this.host.aborted) {
return;
}
this.host.emitMessage(EventType.UPLOAD, `Push ${initOnce.imageUri}`);
await dockerForPushing.push({
tag: initOnce.imageUri,
});
}
private async initOnce(
options: { quiet?: boolean } = {},
): Promise<ContainerImageAssetHandlerInit> {
if (this.init) {
return this.init;
}
const destination = await replaceAwsPlaceholders(this.asset.destination, this.host.aws);
const ecr = await this.host.aws.ecrClient({
...destinationToClientOptions(destination),
quiet: options.quiet,
});
const account = async () => (await this.host.aws.discoverCurrentAccount())?.accountId;
const repoUri = await repositoryUri(ecr, destination.repositoryName);
if (!repoUri) {
throw new Error(
`No ECR repository named '${destination.repositoryName}' in account ${await account()}. Is this account bootstrapped?`,
);
}
const imageUri = `${repoUri}:${destination.imageTag}`;
this.init = {
imageUri,
ecr,
repoUri,
destinationAlreadyExists: await this.destinationAlreadyExists(ecr, destination, imageUri),
};
return this.init;
}
/**
* Check whether the image already exists in the ECR repo
*
* Use the fields from the destination to do the actual check. The imageUri
* should correspond to that, but is only used to print Docker image location
* for user benefit (the format is slightly different).
*/
private async destinationAlreadyExists(
ecr: IECRClient,
destination: DockerImageDestination,
imageUri: string,
): Promise<boolean> {
this.host.emitMessage(EventType.CHECK, `Check ${imageUri}`);
if (await imageExists(ecr, destination.repositoryName, destination.imageTag)) {
this.host.emitMessage(EventType.FOUND, `Found ${imageUri}`);
return true;
}
return false;
}
}
class ContainerImageBuilder {
constructor(
private readonly docker: Docker,
private readonly workDir: string,
private readonly asset: DockerImageManifestEntry,
private readonly host: IHandlerHost,
) {
}
async build(): Promise<string | undefined> {
return this.asset.source.executable
? this.buildExternalAsset(this.asset.source.executable)
: this.buildDirectoryAsset();
}
/**
* Build a (local) Docker asset from a directory with a Dockerfile
*
* Tags under a deterministic, unique, local identifier wich will skip
* the build if it already exists.
*/
private async buildDirectoryAsset(): Promise<string | undefined> {
const localTagName = `cdkasset-${this.asset.id.assetId.toLowerCase()}`;
if (!(await this.isImageCached(localTagName))) {
if (this.host.aborted) {
return undefined;
}
await this.buildImage(localTagName);
}
return localTagName;
}
/**
* Build a (local) Docker asset by running an external command
*
* External command is responsible for deduplicating the build if possible,
* and is expected to return the generated image identifier on stdout.
*/
private async buildExternalAsset(
executable: string[],
cwd?: string,
): Promise<string | undefined> {
const assetPath = cwd ?? this.workDir;
this.host.emitMessage(EventType.BUILD, `Building Docker image using command '${executable}'`);
if (this.host.aborted) {
return undefined;
}
const shellEventPublisher = shellEventPublisherFromEventEmitter(this.host.emitMessage);
return (
await shell(executable, {
cwd: assetPath,
shellEventPublisher,
subprocessOutputDestination: 'ignore',
})
).trim();
}
private async buildImage(localTagName: string): Promise<void> {
const source = this.asset.source;
if (!source.directory) {
throw new Error(
`'directory' is expected in the DockerImage asset source, got: ${JSON.stringify(source)}`,
);
}
const fullPath = path.resolve(this.workDir, source.directory);
this.host.emitMessage(EventType.BUILD, `Building Docker image at ${fullPath}`);
await this.docker.build({
directory: fullPath,
tag: localTagName,
buildArgs: source.dockerBuildArgs,
buildSecrets: source.dockerBuildSecrets,
buildSsh: source.dockerBuildSsh,
target: source.dockerBuildTarget,
file: source.dockerFile,
networkMode: source.networkMode,
platform: source.platform,
outputs: source.dockerOutputs,
cacheFrom: source.cacheFrom,
cacheTo: source.cacheTo,
cacheDisabled: source.cacheDisabled,
});
}
private async isImageCached(localTagName: string): Promise<boolean> {
if (await this.docker.exists(localTagName)) {
this.host.emitMessage(EventType.CACHED, `Cached ${localTagName}`);
return true;
}
return false;
}
}
async function imageExists(ecr: IECRClient, repositoryName: string, imageTag: string) {
try {
await ecr.describeImages({
repositoryName,
imageIds: [{ imageTag }],
});
return true;
} catch (e: any) {
if (e.name !== 'ImageNotFoundException') {
throw e;
}
return false;
}
}
/**
* Return the URI for the repository with the given name
*
* Returns undefined if the repository does not exist.
*/
async function repositoryUri(ecr: IECRClient, repositoryName: string): Promise<string | undefined> {
try {
const response = await ecr.describeRepositories({
repositoryNames: [repositoryName],
});
return (response.repositories || [])[0]?.repositoryUri;
} catch (e: any) {
if (e.name !== 'RepositoryNotFoundException') {
throw e;
}
return undefined;
}
}