diff --git a/packages/@aws-cdk/assets/lib/asset.ts b/packages/@aws-cdk/assets/lib/asset.ts index ffef5ebcb3cef..ba26f3a737269 100644 --- a/packages/@aws-cdk/assets/lib/asset.ts +++ b/packages/@aws-cdk/assets/lib/asset.ts @@ -124,7 +124,7 @@ export class Asset extends cdk.Construct { // for tooling to be able to package and upload a directory to the // s3 bucket and plug in the bucket name and key in the correct // parameters. - const asset: cxapi.AssetMetadataEntry = { + const asset: cxapi.FileAssetMetadataEntry = { path: this.assetPath, id: this.uniqueId, packaging: props.packaging, diff --git a/packages/@aws-cdk/cx-api/lib/cxapi.ts b/packages/@aws-cdk/cx-api/lib/cxapi.ts index 56a96d12855be..1ab3431299f9b 100644 --- a/packages/@aws-cdk/cx-api/lib/cxapi.ts +++ b/packages/@aws-cdk/cx-api/lib/cxapi.ts @@ -79,7 +79,13 @@ export const DEFAULT_ACCOUNT_CONTEXT_KEY = 'aws:cdk:toolkit:default-account'; export const DEFAULT_REGION_CONTEXT_KEY = 'aws:cdk:toolkit:default-region'; export const ASSET_METADATA = 'aws:cdk:asset'; -export interface AssetMetadataEntry { + +export interface FileAssetMetadataEntry { + /** + * Requested packaging style + */ + packaging: 'zip' | 'file'; + /** * Path on disk to the asset */ @@ -90,11 +96,6 @@ export interface AssetMetadataEntry { */ id: string; - /** - * Requested packaging style - */ - packaging: 'zip' | 'file'; - /** * Name of parameter where S3 bucket should be passed in */ @@ -106,6 +107,35 @@ export interface AssetMetadataEntry { s3KeyParameter: string; } +export interface ContainerImageAssetMetadataEntry { + /** + * Type of asset + */ + packaging: 'container-image'; + + /** + * Path on disk to the asset + */ + path: string; + + /** + * Logical identifier for the asset + */ + id: string; + + /** + * Name of the parameter that takes the repository name + */ + repositoryParameter: string; + + /** + * Name of the parameter that takes the tag + */ + tagParameter: string; +} + +export type AssetMetadataEntry = FileAssetMetadataEntry | ContainerImageAssetMetadataEntry; + /** * Metadata key used to print INFO-level messages by the toolkit when an app is syntheized. */ diff --git a/packages/aws-cdk/lib/api/toolkit-info.ts b/packages/aws-cdk/lib/api/toolkit-info.ts index 55465bd3747d6..99e0692d3e1f8 100644 --- a/packages/aws-cdk/lib/api/toolkit-info.ts +++ b/packages/aws-cdk/lib/api/toolkit-info.ts @@ -21,12 +21,15 @@ export interface Uploaded { } export class ToolkitInfo { + public readonly sdk: SDK; + constructor(private readonly props: { sdk: SDK, bucketName: string, bucketEndpoint: string, environment: cxapi.Environment - }) { } + }) { + } public get bucketUrl() { return `https://${this.props.bucketEndpoint}`; @@ -73,6 +76,86 @@ export class ToolkitInfo { return { filename, key, changed: true }; } + /** + * Prepare an ECR repository for uploading to using Docker + */ + public async prepareEcrRepository(id: string, imageTag: string): Promise { + const ecr = await this.props.sdk.ecr(this.props.environment, Mode.ForWriting); + + // Create the repository if it doesn't exist yet + const repositoryName = 'cdk/' + id.replace(/[:/]/g, '-').toLowerCase(); + + let repository; + try { + debug(`${repositoryName}: checking for repository.`); + const describeResponse = await ecr.describeRepositories({ repositoryNames: [repositoryName] }).promise(); + repository = describeResponse.repositories![0]; + } catch (e) { + if (e.code !== 'RepositoryNotFoundException') { throw e; } + } + + if (repository) { + try { + debug(`${repositoryName}: checking for image ${imageTag}`); + await ecr.describeImages({ repositoryName, imageIds: [{ imageTag }] }).promise(); + + // If we got here, the image already exists. Nothing else needs to be done. + return { + alreadyExists: true, + repositoryUri: repository.repositoryUri!, + repositoryArn: repository.repositoryArn!, + }; + } catch (e) { + if (e.code !== 'ImageNotFoundException') { throw e; } + } + } else { + debug(`${repositoryName}: creating`); + const response = await ecr.createRepository({ repositoryName }).promise(); + repository = response.repository!; + + // Better put a lifecycle policy on this so as to not cost too much money + await ecr.putLifecyclePolicy({ + repositoryName, + lifecyclePolicyText: JSON.stringify(DEFAULT_REPO_LIFECYCLE) + }).promise(); + } + + // The repo exists, image just needs to be uploaded. Get auth to do so. + + debug(`Fetching ECR authorization token`); + const authData = (await ecr.getAuthorizationToken({ }).promise()).authorizationData || []; + if (authData.length === 0) { + throw new Error('No authorization data received from ECR'); + } + const token = Buffer.from(authData[0].authorizationToken!, 'base64').toString('ascii'); + const [username, password] = token.split(':'); + + return { + alreadyExists: false, + repositoryUri: repository.repositoryUri!, + repositoryArn: repository.repositoryArn!, + username, + password, + endpoint: authData[0].proxyEndpoint!, + }; + } +} + +export type EcrRepositoryInfo = CompleteEcrRepositoryInfo | UploadableEcrRepositoryInfo; + +export interface CompleteEcrRepositoryInfo { + repositoryUri: string; + repositoryArn: string; + alreadyExists: true; +} + +export interface UploadableEcrRepositoryInfo { + repositoryUri: string; + repositoryArn: string; + alreadyExists: false; + username: string; + password: string; + endpoint: string; } async function objectExists(s3: aws.S3, bucket: string, key: string) { @@ -114,3 +197,18 @@ function getOutputValue(stack: aws.CloudFormation.Stack, output: string): string } return result; } + +const DEFAULT_REPO_LIFECYCLE = { + rules: [ + { + rulePriority: 100, + description: 'Retain only 5 images', + selection: { + tagStatus: 'any', + countType: 'imageCountMoreThan', + countNumber: 5, + }, + action: { type: 'expire' } + } + ] +}; \ No newline at end of file diff --git a/packages/aws-cdk/lib/api/util/sdk.ts b/packages/aws-cdk/lib/api/util/sdk.ts index 9b795e0ef1858..9c8ca43ac7177 100644 --- a/packages/aws-cdk/lib/api/util/sdk.ts +++ b/packages/aws-cdk/lib/api/util/sdk.ts @@ -104,6 +104,13 @@ export class SDK { }); } + public async ecr(environment: Environment, mode: Mode): Promise { + return new AWS.ECR({ + region: environment.region, + credentials: await this.credentialsCache.get(environment.account, mode) + }); + } + public async defaultRegion(): Promise { return await getCLICompatibleDefaultRegion(this.profile); } diff --git a/packages/aws-cdk/lib/assets.ts b/packages/aws-cdk/lib/assets.ts index a264b2a3eeee8..7a30f8500b5fe 100644 --- a/packages/aws-cdk/lib/assets.ts +++ b/packages/aws-cdk/lib/assets.ts @@ -1,10 +1,12 @@ -import { ASSET_METADATA, ASSET_PREFIX_SEPARATOR, AssetMetadataEntry, StackMetadata, SynthesizedStack } from '@aws-cdk/cx-api'; +// tslint:disable-next-line:max-line-length +import { ASSET_METADATA, ASSET_PREFIX_SEPARATOR, AssetMetadataEntry, FileAssetMetadataEntry, StackMetadata, SynthesizedStack } from '@aws-cdk/cx-api'; import { CloudFormation } from 'aws-sdk'; import fs = require('fs-extra'); import os = require('os'); import path = require('path'); import { ToolkitInfo } from './api/toolkit-info'; import { zipDirectory } from './archive'; +import { prepareContainerAsset } from './docker'; import { debug, success } from './logging'; export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: ToolkitInfo): Promise { @@ -35,12 +37,15 @@ async function prepareAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo) return await prepareZipAsset(asset, toolkitInfo); case 'file': return await prepareFileAsset(asset, toolkitInfo); + case 'container-image': + return await prepareContainerAsset(asset, toolkitInfo); default: - throw new Error(`Unsupported packaging type: ${asset.packaging}`); + // tslint:disable-next-line:max-line-length + throw new Error(`Unsupported packaging type: ${(asset as any).packaging}. You might need to upgrade your aws-cdk toolkit to support this asset type.`); } } -async function prepareZipAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise { +async function prepareZipAsset(asset: FileAssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise { debug('Preparing zip asset from directory:', asset.path); const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-assets')); try { @@ -60,7 +65,7 @@ async function prepareZipAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitIn * @param contentType Content-type to use when uploading to S3 (none will be specified by default) */ async function prepareFileAsset( - asset: AssetMetadataEntry, + asset: FileAssetMetadataEntry, toolkitInfo: ToolkitInfo, filePath?: string, contentType?: string): Promise { diff --git a/packages/aws-cdk/lib/docker.ts b/packages/aws-cdk/lib/docker.ts new file mode 100644 index 0000000000000..14f7bc2df5ec7 --- /dev/null +++ b/packages/aws-cdk/lib/docker.ts @@ -0,0 +1,220 @@ +import { ContainerImageAssetMetadataEntry } from '@aws-cdk/cx-api'; +import { CloudFormation } from 'aws-sdk'; +import crypto = require('crypto'); +import { ToolkitInfo } from './api/toolkit-info'; +import { debug, print } from './logging'; +import { shell } from './os'; +import { PleaseHold } from './util/please-hold'; + +/** + * Build and upload a Docker image + * + * Permanently identifying images is a bit of a bust. Newer Docker version use + * a digest (sha256:xxxx) as an image identifier, which is pretty good to avoid + * spurious rebuilds. However, this digest is calculated over a manifest that + * includes metadata that is liable to change. For example, as soon as we + * push the Docker image to a repository, the digest changes. This makes the + * digest worthless to determe whether we already pushed an image, for example. + * + * As a workaround, we calculate our own digest over parts of the manifest that + * are unlikely to change, and tag based on that. + */ +export async function prepareContainerAsset(asset: ContainerImageAssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise { + debug(' 👑 Preparing Docker image asset:', asset.path); + + const buildHold = new PleaseHold(` ⌛ Building Docker image for ${asset.path}; this may take a while.`); + try { + buildHold.start(); + + const command = ['docker', + 'build', + '--quiet', + asset.path]; + const imageId = (await shell(command, { quiet: true })).trim(); + buildHold.stop(); + + const tag = await calculateImageFingerprint(imageId); + + debug(` ⌛ Image has tag ${tag}, preparing ECR repository`); + const ecr = await toolkitInfo.prepareEcrRepository(asset.id, tag); + + if (ecr.alreadyExists) { + debug(' 👑 Image already uploaded.'); + } else { + // Login and push + debug(` ⌛ Image needs to be uploaded first.`); + + await shell(['docker', 'login', + '--username', ecr.username, + '--password', ecr.password, + ecr.endpoint]); + + const qualifiedImageName = `${ecr.repositoryUri}:${tag}`; + await shell(['docker', 'tag', imageId, qualifiedImageName]); + + // There's no way to make this quiet, so we can't use a PleaseHold. Print a header message. + print(` ⌛ Pusing Docker image for ${asset.path}; this may take a while.`); + await shell(['docker', 'push', qualifiedImageName]); + debug(` 👑 Docker image for ${asset.path} pushed.`); + } + + return [ + { ParameterKey: asset.repositoryParameter, ParameterValue: ecr.repositoryArn }, + { ParameterKey: asset.tagParameter, ParameterValue: tag }, + ]; + } catch (e) { + if (e.code === 'ENOENT') { + // tslint:disable-next-line:max-line-length + throw new Error('Error building Docker image asset; you need to have Docker installed in order to be able to build image assets. Please install Docker and try again.'); + } + throw e; + } finally { + buildHold.stop(); + } +} + +/** + * Calculate image fingerprint. + * + * The fingerprint has a high likelihood to be the same across repositories. + * (As opposed to Docker's built-in image digest, which changes as soon + * as the image is uploaded since it includes the tags that an image has). + * + * The fingerprint will be used as a tag to identify a particular image. + */ +async function calculateImageFingerprint(imageId: string) { + const manifestString = await shell(['docker', 'inspect', imageId], { quiet: true }); + const manifest = JSON.parse(manifestString)[0]; + + // Id can change + delete manifest.Id; + + // Repository-based identifiers are out + delete manifest.RepoTags; + delete manifest.RepoDigests; + + // Metadata that has no bearing on the image contents + delete manifest.Created; + + // We're interested in the image itself, not any running instaces of it + delete manifest.Container; + delete manifest.ContainerConfig; + + // We're not interested in the Docker version used to create this image + delete manifest.DockerVersion; + + return crypto.createHash('sha256').update(JSON.stringify(manifest)).digest('hex'); +} + +/** + * Example of a Docker manifest + * + * [ + * { + * "Id": "sha256:3a90542991d03007fd1d8f3b3a6ab04ebb02386785430fe48a867768a048d828", + * "RepoTags": [ + * "993655754359.dkr.ecr.us-east-1.amazonaws.com/cdk/awsecsintegimage7c15b8c6:latest" + * ], + * "RepoDigests": [ + * "993655754359.dkr.ecr.us-east-1.amazo....5e50c0cfc3f2355191934b05df68cd3339a044959111ffec2e14765" + * ], + * "Parent": "sha256:465720f8f43c9c0aff5dcc731d4e368a3927cae4e885442d4ba0bf8a867b7561", + * "Comment": "", + * "Created": "2018-10-17T10:16:40.775888476Z", + * "Container": "20f145d2e7fbf126ca9f4422497b932bc96b5faa038dc032de1e246f64e03a66", + * "ContainerConfig": { + * "Hostname": "9b48b580a312", + * "Domainname": "", + * "User": "", + * "AttachStdin": false, + * "AttachStdout": false, + * "AttachStderr": false, + * "ExposedPorts": { + * "8000/tcp": {} + * }, + * "Tty": false, + * "OpenStdin": false, + * "StdinOnce": false, + * "Env": [ + * "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + * "LANG=C.UTF-8", + * "GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D", + * "PYTHON_VERSION=3.6.6", + * "PYTHON_PIP_VERSION=18.1" + * ], + * "Cmd": [ + * "/bin/sh", + * "-c", + * "#(nop) ", + * "CMD [\"/bin/sh\" \"-c\" \"python3 index.py\"]" + * ], + * "ArgsEscaped": true, + * "Image": "sha256:465720f8f43c9c0aff5dcc731d4e368a3927cae4e885442d4ba0bf8a867b7561", + * "Volumes": null, + * "WorkingDir": "/code", + * "Entrypoint": null, + * "OnBuild": [], + * "Labels": {} + * }, + * "DockerVersion": "17.03.2-ce", + * "Author": "", + * "Config": { + * "Hostname": "9b48b580a312", + * "Domainname": "", + * "User": "", + * "AttachStdin": false, + * "AttachStdout": false, + * "AttachStderr": false, + * "ExposedPorts": { + * "8000/tcp": {} + * }, + * "Tty": false, + * "OpenStdin": false, + * "StdinOnce": false, + * "Env": [ + * "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + * "LANG=C.UTF-8", + * "GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D", + * "PYTHON_VERSION=3.6.6", + * "PYTHON_PIP_VERSION=18.1" + * ], + * "Cmd": [ + * "/bin/sh", + * "-c", + * "python3 index.py" + * ], + * "ArgsEscaped": true, + * "Image": "sha256:465720f8f43c9c0aff5dcc731d4e368a3927cae4e885442d4ba0bf8a867b7561", + * "Volumes": null, + * "WorkingDir": "/code", + * "Entrypoint": null, + * "OnBuild": [], + * "Labels": {} + * }, + * "Architecture": "amd64", + * "Os": "linux", + * "Size": 917730468, + * "VirtualSize": 917730468, + * "GraphDriver": { + * "Name": "aufs", + * "Data": null + * }, + * "RootFS": { + * "Type": "layers", + * "Layers": [ + * "sha256:f715ed19c28b66943ac8bc12dbfb828e8394de2530bbaf1ecce906e748e4fdff", + * "sha256:8bb25f9cdc41e7d085033af15a522973b44086d6eedd24c11cc61c9232324f77", + * "sha256:08a01612ffca33483a1847c909836610610ce523fb7e1aca880140ee84df23e9", + * "sha256:1191b3f5862aa9231858809b7ac8b91c0b727ce85c9b3279932f0baacc92967d", + * "sha256:9978d084fd771e0b3d1acd7f3525d1b25288ababe9ad8ed259b36101e4e3addd", + * "sha256:2f4f74d3821ecbdd60b5d932452ea9e30cecf902334165c4a19837f6ee636377", + * "sha256:003bb6178bc3218242d73e51d5e9ab2f991dc607780194719c6bd4c8c412fe8c", + * "sha256:15b32d849da2239b1af583f9381c7a75d7aceba12f5ddfffa7a059116cf05ab9", + * "sha256:6e5c5f6bf043bc634378b1e4b61af09be74741f2ac80204d7a373713b1fd5a40", + * "sha256:3260e00e353bfb765b25597d13868c2ef64cb3d509875abcfb58c4e9bf7f4ee2", + * "sha256:f3274b75856311e92e14a1270c78737c86456d6353fe4a83bd2e81bcd2a996ea" + * ] + * } + * } + * ] + */ \ No newline at end of file diff --git a/packages/aws-cdk/lib/os.ts b/packages/aws-cdk/lib/os.ts new file mode 100644 index 0000000000000..7fd8c56b6317d --- /dev/null +++ b/packages/aws-cdk/lib/os.ts @@ -0,0 +1,98 @@ +import child_process = require("child_process"); +import colors = require('colors/safe'); +import { debug } from "./logging"; + +export interface ShellOptions extends child_process.SpawnOptions { + quiet?: boolean; +} + +/** + * OS helpers + * + * Shell function which both prints to stdout and collects the output into a + * string. + */ +export async function shell(command: string[], options: ShellOptions = {}): Promise { + debug(`Executing ${colors.blue(renderCommandLine(command))}`); + const child = child_process.spawn(command[0], command.slice(1), { + ...options, + stdio: [ 'ignore', 'pipe', 'inherit' ] + }); + + return new Promise((resolve, reject) => { + const stdout = new Array(); + + // Both write to stdout and collect + child.stdout.on('data', chunk => { + if (!options.quiet) { + process.stdout.write(chunk); + } + stdout.push(chunk); + }); + + child.once('error', reject); + + child.once('exit', code => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf-8')); + } else { + reject(new Error(`${renderCommandLine(command)} exited with error code ${code}`)); + } + }); + }); +} + +/** + * Render the given command line as a string + * + * Probably missing some cases but giving it a good effort. + */ +function renderCommandLine(cmd: string[]) { + if (process.platform !== 'win32') { + return doRender(cmd, hasAnyChars(' ', '\\', '!', '"', "'", '&', '$'), posixEscape); + } else { + return doRender(cmd, hasAnyChars(' ', '"', '&', '^', '%'), windowsEscape); + } +} + +/** + * Render a UNIX command line + */ +function doRender(cmd: string[], needsEscaping: (x: string) => boolean, doEscape: (x: string) => string): string { + return cmd.map(x => needsEscaping(x) ? doEscape(x) : x).join(' '); +} + +/** + * Return a predicate that checks if a string has any of the indicated chars in it + */ +function hasAnyChars(...chars: string[]): (x: string) => boolean { + return (str: string) => { + return chars.some(c => str.indexOf(c) !== -1); + }; +} + +/** + * Escape a shell argument for POSIX shells + * + * Wrapping in single quotes and escaping single quotes inside will do it for us. + */ +function posixEscape(x: string) { + // Turn ' -> '"'"' + x = x.replace("'", "'\"'\"'"); + return `'${x}'`; +} + +/** + * Escape a shell argument for cmd.exe + * + * This is how to do it right, but I'm not following everything: + * + * https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + */ +function windowsEscape(x: string): string { + // First surround by double quotes, ignore the part about backslashes + x = `"${x}"`; + // Now escape all special characters + const shellMeta = new Set(['"', '&', '^', '%']); + return x.split('').map(c => shellMeta.has(x) ? '^' + c : c).join(''); +} \ No newline at end of file diff --git a/packages/aws-cdk/lib/util/please-hold.ts b/packages/aws-cdk/lib/util/please-hold.ts new file mode 100644 index 0000000000000..bc3f7185fd79a --- /dev/null +++ b/packages/aws-cdk/lib/util/please-hold.ts @@ -0,0 +1,26 @@ +import colors = require('colors/safe'); +import { print } from "../logging"; + +/** + * Print a message to the logger in case the operation takes a long time + */ +export class PleaseHold { + private handle?: NodeJS.Timer; + + constructor(private readonly message: string, private readonly timeoutSec = 10) { + } + + public start() { + this.handle = setTimeout(this.printMessage.bind(this), this.timeoutSec * 1000); + } + + public stop() { + if (this.handle) { + clearTimeout(this.handle); + } + } + + private printMessage() { + print(colors.yellow(this.message)); + } +} \ No newline at end of file