packages/cdk-assets/lib/private/docker.ts (219 lines of code) (raw):
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { cdkCredentialsConfig, obtainEcrCredentials } from './docker-credentials';
import type { ShellOptions, ProcessFailedError } from './shell';
import { shell } from './shell';
import { createCriticalSection } from './util';
import type { IECRClient } from '../aws';
import type { SubprocessOutputDestination } from './asset-handler';
import type { EventEmitter } from '../progress';
import { shellEventPublisherFromEventEmitter } from '../progress';
interface BuildOptions {
readonly directory: string;
/**
* Tag the image with a given repoName:tag combination
*/
readonly tag: string;
readonly target?: string;
readonly file?: string;
readonly buildArgs?: Record<string, string>;
readonly buildSecrets?: Record<string, string>;
readonly buildSsh?: string;
readonly networkMode?: string;
readonly platform?: string;
readonly outputs?: string[];
readonly cacheFrom?: DockerCacheOption[];
readonly cacheTo?: DockerCacheOption;
readonly cacheDisabled?: boolean;
}
interface PushOptions {
readonly tag: string;
}
export interface DockerCredentialsConfig {
readonly version: string;
readonly domainCredentials: Record<string, DockerDomainCredentials>;
}
export interface DockerDomainCredentials {
readonly secretsManagerSecretId?: string;
readonly ecrRepository?: string;
}
enum InspectImageErrorCode {
Docker = 1,
Podman = 125,
}
export interface DockerCacheOption {
readonly type: string;
readonly params?: { [key: string]: string };
}
export class Docker {
private configDir: string | undefined = undefined;
constructor(
private readonly eventEmitter: EventEmitter,
private readonly subprocessOutputDestination: SubprocessOutputDestination,
) {
}
/**
* Whether an image with the given tag exists
*/
public async exists(tag: string) {
try {
await this.execute(['inspect', tag], {
subprocessOutputDestination: 'ignore',
});
return true;
} catch (e: any) {
const error: ProcessFailedError = e;
/**
* The only error we expect to be thrown will have this property and value.
* If it doesn't, it's unrecognized so re-throw it.
*/
if (error.code !== 'PROCESS_FAILED') {
throw error;
}
/**
* If we know the shell command above returned an error, check to see
* if the exit code is one we know to actually mean that the image doesn't
* exist.
*/
switch (error.exitCode) {
case InspectImageErrorCode.Docker:
case InspectImageErrorCode.Podman:
// Docker and Podman will return this exit code when an image doesn't exist, return false
// context: https://github.com/aws/aws-cdk/issues/16209
return false;
default:
// This is an error but it's not an exit code we recognize, throw.
throw error;
}
}
}
public async build(options: BuildOptions) {
const buildCommand = [
'build',
...flatten(
Object.entries(options.buildArgs || {}).map(([k, v]) => ['--build-arg', `${k}=${v}`]),
),
...flatten(
Object.entries(options.buildSecrets || {}).map(([k, v]) => ['--secret', `id=${k},${v}`]),
),
...(options.buildSsh ? ['--ssh', options.buildSsh] : []),
'--tag',
options.tag,
...(options.target ? ['--target', options.target] : []),
...(options.file ? ['--file', options.file] : []),
...(options.networkMode ? ['--network', options.networkMode] : []),
...(options.platform ? ['--platform', options.platform] : []),
...(options.outputs ? options.outputs.map((output) => [`--output=${output}`]) : []),
...(options.cacheFrom
? [
...options.cacheFrom
.map((cacheFrom) => ['--cache-from', this.cacheOptionToFlag(cacheFrom)])
.flat(),
]
: []),
...(options.cacheTo ? ['--cache-to', this.cacheOptionToFlag(options.cacheTo)] : []),
...(options.cacheDisabled ? ['--no-cache'] : []),
'.',
];
await this.execute(buildCommand, {
cwd: options.directory,
subprocessOutputDestination: this.subprocessOutputDestination,
env: {
BUILDX_NO_DEFAULT_ATTESTATIONS: '1', // Docker Build adds provenance attestations by default that confuse cdk-assets
},
});
}
/**
* Get credentials from ECR and run docker login
*/
public async login(ecr: IECRClient) {
const credentials = await obtainEcrCredentials(ecr, this.eventEmitter);
// Use --password-stdin otherwise docker will complain. Loudly.
await this.execute(
['login', '--username', credentials.username, '--password-stdin', credentials.endpoint.replace(/^https?:\/\/|\/$/g, '')],
{
input: credentials.password,
// Need to ignore otherwise Docker will complain
// 'WARNING! Your password will be stored unencrypted'
// doesn't really matter since it's a token.
subprocessOutputDestination: 'ignore',
},
);
}
public async tag(sourceTag: string, targetTag: string) {
await this.execute(['tag', sourceTag, targetTag]);
}
public async push(options: PushOptions) {
await this.execute(['push', options.tag], {
subprocessOutputDestination: this.subprocessOutputDestination,
});
}
/**
* If a CDK Docker Credentials file exists, creates a new Docker config directory.
* Sets up `docker-credential-cdk-assets` to be the credential helper for each domain in the CDK config.
* All future commands (e.g., `build`, `push`) will use this config.
*
* See https://docs.docker.com/engine/reference/commandline/login/#credential-helpers for more details on cred helpers.
*
* @returns true if CDK config was found and configured, false otherwise
*/
public configureCdkCredentials(): boolean {
const config = cdkCredentialsConfig();
if (!config) {
return false;
}
this.configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdkDockerConfig'));
const domains = Object.keys(config.domainCredentials);
const credHelpers = domains.reduce((map: Record<string, string>, domain) => {
map[domain] = 'cdk-assets'; // Use docker-credential-cdk-assets for this domain
return map;
}, {});
fs.writeFileSync(path.join(this.configDir, 'config.json'), JSON.stringify({ credHelpers }), {
encoding: 'utf-8',
});
return true;
}
/**
* Removes any configured Docker config directory.
* All future commands (e.g., `build`, `push`) will use the default config.
*
* This is useful after calling `configureCdkCredentials` to reset to default credentials.
*/
public resetAuthPlugins() {
this.configDir = undefined;
}
private async execute(args: string[], options: Omit<ShellOptions, 'shellEventPublisher'> = {}) {
const configArgs = this.configDir ? ['--config', this.configDir] : [];
const pathToCdkAssets = path.resolve(__dirname, '..', '..', 'bin');
const shellEventPublisher = shellEventPublisherFromEventEmitter(this.eventEmitter);
try {
await shell([getDockerCmd(), ...configArgs, ...args], {
...options,
shellEventPublisher: shellEventPublisher,
env: {
...process.env,
...options.env,
PATH: `${pathToCdkAssets}${path.delimiter}${options.env?.PATH ?? process.env.PATH}`,
},
});
} catch (e: any) {
if (e.code === 'ENOENT') {
throw new Error(
"Unable to execute 'docker' in order to build a container asset. Please install 'docker' and try again.",
);
}
throw e;
}
}
private cacheOptionToFlag(option: DockerCacheOption): string {
let flag = `type=${option.type}`;
if (option.params) {
flag +=
',' +
Object.entries(option.params)
.map(([k, v]) => `${k}=${v}`)
.join(',');
}
return flag;
}
}
export interface DockerFactoryOptions {
readonly repoUri: string;
readonly ecr: IECRClient;
readonly eventEmitter: EventEmitter;
readonly subprocessOutputDestination: SubprocessOutputDestination;
}
/**
* Helps get appropriately configured Docker instances during the container
* image publishing process.
*/
export class DockerFactory {
private enterLoggedInDestinationsCriticalSection = createCriticalSection();
private loggedInDestinations = new Set<string>();
/**
* Gets a Docker instance for building images.
*/
public async forBuild(options: DockerFactoryOptions): Promise<Docker> {
const docker = new Docker(options.eventEmitter, options.subprocessOutputDestination);
// Default behavior is to login before build so that the Dockerfile can reference images in the ECR repo
// However, if we're in a pipelines environment (for example),
// we may have alternative credentials to the default ones to use for the build itself.
// If the special config file is present, delay the login to the default credentials until the push.
// If the config file is present, we will configure and use those credentials for the build.
let cdkDockerCredentialsConfigured = docker.configureCdkCredentials();
if (!cdkDockerCredentialsConfigured) {
await this.loginOncePerDestination(docker, options);
}
return docker;
}
/**
* Gets a Docker instance for pushing images to ECR.
*/
public async forEcrPush(options: DockerFactoryOptions) {
const docker = new Docker(options.eventEmitter, options.subprocessOutputDestination);
await this.loginOncePerDestination(docker, options);
return docker;
}
private async loginOncePerDestination(docker: Docker, options: DockerFactoryOptions) {
// Changes: 012345678910.dkr.ecr.us-west-2.amazonaws.com/tagging-test
// To this: 012345678910.dkr.ecr.us-west-2.amazonaws.com
const repositoryDomain = options.repoUri.split('/')[0];
// Ensure one-at-a-time access to loggedInDestinations.
await this.enterLoggedInDestinationsCriticalSection(async () => {
if (this.loggedInDestinations.has(repositoryDomain)) {
return;
}
await docker.login(options.ecr);
this.loggedInDestinations.add(repositoryDomain);
});
}
}
function getDockerCmd(): string {
return process.env.CDK_DOCKER ?? 'docker';
}
function flatten(x: string[][]) {
return Array.prototype.concat([], ...x);
}