diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index 888901a8c33bf..ee21d8c17111b 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -29,6 +29,7 @@ export interface ISDK { route53(): AWS.Route53; ecr(): AWS.ECR; elbv2(): AWS.ELBv2; + secretsManager(): AWS.SecretsManager; } /** @@ -113,6 +114,10 @@ export class SDK implements ISDK { return this.wrapServiceErrorHandling(new AWS.ELBv2(this.config)); } + public secretsManager(): AWS.SecretsManager { + return this.wrapServiceErrorHandling(new AWS.SecretsManager(this.config)); + } + public async currentAccount(): Promise { // Get/refresh if necessary before we can access `accessKeyId` await this.forceCredentialRetrieval(); diff --git a/packages/aws-cdk/lib/util/asset-publishing.ts b/packages/aws-cdk/lib/util/asset-publishing.ts index eb929bf03b4f3..3635d2b639002 100644 --- a/packages/aws-cdk/lib/util/asset-publishing.ts +++ b/packages/aws-cdk/lib/util/asset-publishing.ts @@ -59,6 +59,10 @@ class PublishingAws implements cdk_assets.IAws { return (await this.sdk(options)).ecr(); } + public async secretsManagerClient(options: cdk_assets.ClientOptions): Promise { + return (await this.sdk(options)).secretsManager(); + } + /** * Get an SDK appropriate for the given client options */ diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index edb7be0445626..90392ee14f3c9 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -108,6 +108,7 @@ export class MockSdk implements ISDK { public readonly route53 = jest.fn(); public readonly ecr = jest.fn(); public readonly elbv2 = jest.fn(); + public readonly secretsManager = jest.fn(); public currentAccount(): Promise { return Promise.resolve({ accountId: '123456789012', partition: 'aws' }); diff --git a/packages/cdk-assets/README.md b/packages/cdk-assets/README.md index c40afcd00c42d..60379e343a330 100644 --- a/packages/cdk-assets/README.md +++ b/packages/cdk-assets/README.md @@ -42,7 +42,7 @@ itself in the following behaviors: image in the local Docker cache) already exists named after the asset's ID, it will not be packaged, but will be uploaded directly to the destination location. - + For assets build by external utilities, the contract is such that cdk-assets expects the utility to manage dedupe detection as well as path/image tag generation. This means that cdk-assets will call the external utility every time generation @@ -153,3 +153,36 @@ on the AWS SDK (through environment variables or `~/.aws/...` config files). * If `${AWS::Region}` is used, it will principally be replaced with the value in the `region` key. If the default region is intended, leave the `region` key out of the manifest at all. + +## Docker image credentials + +For Docker image asset publishing, `cdk-assets` will `docker login` with +credentials from ECR GetAuthorizationToken prior to building and publishing, so +that the Dockerfile can reference images in the account's ECR repo. + +`cdk-assets` can also be configured to read credentials from both ECR and +SecretsManager prior to build by creating a credential configuration at +'~/.cdk/cdk-docker-creds.json' (override this location by setting the +CDK_DOCKER_CREDS_FILE environment variable). The credentials file has the +following format: + +```json +{ + "version": "1.0", + "domainCredentials": { + "domain1.example.com": { + "secretsManagerSecretId": "mySecret", // Can be the secret ID or full ARN + "roleArn": "arn:aws:iam::0123456789012:role/my-role" // (Optional) role with permissions to the secret + }, + "domain2.example.com": { + "ecrRepository": true, + "roleArn": "arn:aws:iam::0123456789012:role/my-role" // (Optional) role with permissions to the repo + } + } +} +``` + +If the credentials file is present, `docker` will be configured to use the +`docker-credential-cdk-assets` credential helper for each of the domains listed +in the file. This helper will assume the role provided (if present), and then fetch +the login credentials from either SecretsManager or ECR. diff --git a/packages/cdk-assets/bin/docker-credential-cdk-assets b/packages/cdk-assets/bin/docker-credential-cdk-assets new file mode 100755 index 0000000000000..3829057860102 --- /dev/null +++ b/packages/cdk-assets/bin/docker-credential-cdk-assets @@ -0,0 +1,2 @@ +#!/usr/bin/env node +require('./docker-credential-cdk-assets.js'); diff --git a/packages/cdk-assets/bin/docker-credential-cdk-assets.ts b/packages/cdk-assets/bin/docker-credential-cdk-assets.ts new file mode 100644 index 0000000000000..b04f2ba8510bc --- /dev/null +++ b/packages/cdk-assets/bin/docker-credential-cdk-assets.ts @@ -0,0 +1,48 @@ +/** + * Docker Credential Helper to retrieve credentials based on an external configuration file. + * Supports loading credentials from ECR repositories and from Secrets Manager, + * optionally via an assumed role. + * + * The only operation currently supported by this credential helper at this time is the `get` + * command, which receives a domain name as input on stdin and returns a Username/Secret in + * JSON format on stdout. + * + * IMPORTANT - The credential helper must not output anything else besides the final credentials + * in any success case; doing so breaks docker's parsing of the output and causes the login to fail. + */ + +import * as fs from 'fs'; +import { DefaultAwsClient } from '../lib'; + +import { cdkCredentialsConfig, cdkCredentialsConfigFile, fetchDockerLoginCredentials } from '../lib/private/docker-credentials'; + +async function main() { + // Expected invocation is [node, docker-credential-cdk-assets, get] with input fed via STDIN + // For other valid docker commands (store, list, erase), we no-op. + if (process.argv.length !== 3 || process.argv[2] !== 'get') { + process.exit(0); + } + + const config = cdkCredentialsConfig(); + if (!config) { + throw new Error(`unable to find CDK Docker credentials at: ${cdkCredentialsConfigFile()}`); + } + + // Read the domain to fetch from stdin + let rawDomain = fs.readFileSync(0, { encoding: 'utf-8' }).trim(); + // Paranoid handling to ensure new URL() doesn't throw if the schema is missing. + // Not convinced docker will ever pass in a url like 'index.docker.io/v1', but just in case... + rawDomain = rawDomain.includes('://') ? rawDomain : `https://${rawDomain}`; + const domain = new URL(rawDomain).hostname; + + const credentials = await fetchDockerLoginCredentials(new DefaultAwsClient(), config, domain); + + // Write the credentials back to stdout + fs.writeFileSync(1, JSON.stringify(credentials)); +} + +main().catch(e => { + // eslint-disable-next-line no-console + console.error(e.stack); + process.exitCode = 1; +}); diff --git a/packages/cdk-assets/bin/publish.ts b/packages/cdk-assets/bin/publish.ts index e8d251cf82b97..a6a9a81af22ee 100644 --- a/packages/cdk-assets/bin/publish.ts +++ b/packages/cdk-assets/bin/publish.ts @@ -1,10 +1,8 @@ -import * as os from 'os'; import { - AssetManifest, AssetPublishing, ClientOptions, DestinationPattern, EventType, IAws, + AssetManifest, AssetPublishing, DefaultAwsClient, DestinationPattern, EventType, IPublishProgress, IPublishProgressListener, } from '../lib'; -import { Account } from '../lib/aws'; -import { log, LogLevel, VERSION } from './logging'; +import { log, LogLevel } from './logging'; export async function publish(args: { path: string; @@ -56,109 +54,3 @@ class ConsoleProgress implements IPublishProgressListener { log(EVENT_TO_LEVEL[type], `[${event.percentComplete}%] ${type}: ${event.message}`); } } - -/** - * AWS client using the AWS SDK for JS with no special configuration - */ -class DefaultAwsClient implements IAws { - private readonly AWS: typeof import('aws-sdk'); - private account?: Account; - - constructor(profile?: string) { - // Force AWS SDK to look in ~/.aws/credentials and potentially use the configured profile. - process.env.AWS_SDK_LOAD_CONFIG = '1'; - process.env.AWS_STS_REGIONAL_ENDPOINTS = 'regional'; - process.env.AWS_NODEJS_CONNECTION_REUSE_ENABLED = '1'; - if (profile) { - process.env.AWS_PROFILE = profile; - } - - // We need to set the environment before we load this library for the first time. - // eslint-disable-next-line @typescript-eslint/no-require-imports - this.AWS = require('aws-sdk'); - } - - public async s3Client(options: ClientOptions) { - return new this.AWS.S3(await this.awsOptions(options)); - } - - public async ecrClient(options: ClientOptions) { - return new this.AWS.ECR(await this.awsOptions(options)); - } - - public async discoverPartition(): Promise { - return (await this.discoverCurrentAccount()).partition; - } - - public async discoverDefaultRegion(): Promise { - return this.AWS.config.region || 'us-east-1'; - } - - public async discoverCurrentAccount(): Promise { - if (this.account === undefined) { - const sts = new this.AWS.STS(); - const response = await sts.getCallerIdentity().promise(); - if (!response.Account || !response.Arn) { - log('error', `Unrecognized reponse from STS: '${JSON.stringify(response)}'`); - throw new Error('Unrecognized reponse from STS'); - } - this.account = { - accountId: response.Account!, - partition: response.Arn!.split(':')[1], - }; - } - - return this.account; - } - - private async awsOptions(options: ClientOptions) { - let credentials; - - if (options.assumeRoleArn) { - credentials = await this.assumeRole(options.region, options.assumeRoleArn, options.assumeRoleExternalId); - } - - return { - region: options.region, - customUserAgent: `cdk-assets/${VERSION}`, - credentials, - }; - } - - /** - * Explicit manual AssumeRole call - * - * Necessary since I can't seem to get the built-in support for ChainableTemporaryCredentials to work. - * - * It needs an explicit configuration of `masterCredentials`, we need to put - * a `DefaultCredentialProverChain()` in there but that is not possible. - */ - private async assumeRole(region: string | undefined, roleArn: string, externalId?: string): Promise { - const msg = [ - `Assume ${roleArn}`, - ...externalId ? [`(ExternalId ${externalId})`] : [], - ]; - log('verbose', msg.join(' ')); - - return new this.AWS.ChainableTemporaryCredentials({ - params: { - RoleArn: roleArn, - ExternalId: externalId, - RoleSessionName: `cdk-assets-${safeUsername()}`, - }, - stsConfig: { - region, - customUserAgent: `cdk-assets/${VERSION}`, - }, - }); - } -} - -/** - * Return the username with characters invalid for a RoleSessionName removed - * - * @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html#API_AssumeRole_RequestParameters - */ -function safeUsername() { - return os.userInfo().username.replace(/[^\w+=,.@-]/g, '@'); -} \ No newline at end of file diff --git a/packages/cdk-assets/lib/aws.ts b/packages/cdk-assets/lib/aws.ts index 40609eb155af7..936f0d94954e6 100644 --- a/packages/cdk-assets/lib/aws.ts +++ b/packages/cdk-assets/lib/aws.ts @@ -1,4 +1,4 @@ -import * as AWS from 'aws-sdk'; +import * as os from 'os'; /** * AWS SDK operations required by Asset Publishing @@ -10,6 +10,7 @@ export interface IAws { s3Client(options: ClientOptions): Promise; ecrClient(options: ClientOptions): Promise; + secretsManagerClient(options: ClientOptions): Promise; } export interface ClientOptions { @@ -35,3 +36,107 @@ export interface Account { */ readonly partition: string; } + +/** + * AWS client using the AWS SDK for JS with no special configuration + */ +export class DefaultAwsClient implements IAws { + private readonly AWS: typeof import('aws-sdk'); + private account?: Account; + + constructor(profile?: string) { + // Force AWS SDK to look in ~/.aws/credentials and potentially use the configured profile. + process.env.AWS_SDK_LOAD_CONFIG = '1'; + process.env.AWS_STS_REGIONAL_ENDPOINTS = 'regional'; + process.env.AWS_NODEJS_CONNECTION_REUSE_ENABLED = '1'; + if (profile) { + process.env.AWS_PROFILE = profile; + } + + // We need to set the environment before we load this library for the first time. + // eslint-disable-next-line @typescript-eslint/no-require-imports + this.AWS = require('aws-sdk'); + } + + public async s3Client(options: ClientOptions) { + return new this.AWS.S3(await this.awsOptions(options)); + } + + public async ecrClient(options: ClientOptions) { + return new this.AWS.ECR(await this.awsOptions(options)); + } + + public async secretsManagerClient(options: ClientOptions) { + return new this.AWS.SecretsManager(await this.awsOptions(options)); + } + + public async discoverPartition(): Promise { + return (await this.discoverCurrentAccount()).partition; + } + + public async discoverDefaultRegion(): Promise { + return this.AWS.config.region || 'us-east-1'; + } + + public async discoverCurrentAccount(): Promise { + if (this.account === undefined) { + const sts = new this.AWS.STS(); + const response = await sts.getCallerIdentity().promise(); + if (!response.Account || !response.Arn) { + throw new Error(`Unrecognized reponse from STS: '${JSON.stringify(response)}'`); + } + this.account = { + accountId: response.Account!, + partition: response.Arn!.split(':')[1], + }; + } + + return this.account; + } + + private async awsOptions(options: ClientOptions) { + let credentials; + + if (options.assumeRoleArn) { + credentials = await this.assumeRole(options.region, options.assumeRoleArn, options.assumeRoleExternalId); + } + + return { + region: options.region, + customUserAgent: 'cdk-assets', + credentials, + }; + } + + /** + * Explicit manual AssumeRole call + * + * Necessary since I can't seem to get the built-in support for ChainableTemporaryCredentials to work. + * + * It needs an explicit configuration of `masterCredentials`, we need to put + * a `DefaultCredentialProverChain()` in there but that is not possible. + */ + private async assumeRole(region: string | undefined, roleArn: string, externalId?: string): Promise { + return new this.AWS.ChainableTemporaryCredentials({ + params: { + RoleArn: roleArn, + ExternalId: externalId, + RoleSessionName: `cdk-assets-${safeUsername()}`, + }, + stsConfig: { + region, + customUserAgent: 'cdk-assets', + }, + }); + } +} + +/** + * Return the username with characters invalid for a RoleSessionName removed + * + * @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html#API_AssumeRole_RequestParameters + */ +function safeUsername() { + return os.userInfo().username.replace(/[^\w+=,.@-]/g, '@'); +} + diff --git a/packages/cdk-assets/lib/private/docker-credentials.ts b/packages/cdk-assets/lib/private/docker-credentials.ts new file mode 100644 index 0000000000000..b5c3f42139581 --- /dev/null +++ b/packages/cdk-assets/lib/private/docker-credentials.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { IAws } from '../aws'; +import { Logger } from './shell'; + +export interface DockerCredentials { + readonly Username: string; + readonly Secret: string; +} + +export interface DockerCredentialsConfig { + readonly version: string; + readonly domainCredentials: Record; +} + +export interface DockerDomainCredentialSource { + readonly secretsManagerSecretId?: string; + readonly secretsUsernameField?: string; + readonly secretsPasswordField?: string; + readonly ecrRepository?: boolean; + readonly assumeRoleArn?: string; +} + +/** Returns the presumed location of the CDK Docker credentials config file */ +export function cdkCredentialsConfigFile(): string { + return process.env.CDK_DOCKER_CREDS_FILE ?? path.join((os.userInfo().homedir ?? os.homedir()).trim() || '/', '.cdk', 'cdk-docker-creds.json'); +} + +let _cdkCredentials: DockerCredentialsConfig | undefined; +/** Loads and parses the CDK Docker credentials configuration, if it exists. */ +export function cdkCredentialsConfig(): DockerCredentialsConfig | undefined { + if (!_cdkCredentials) { + try { + _cdkCredentials = JSON.parse(fs.readFileSync(cdkCredentialsConfigFile(), { encoding: 'utf-8' })) as DockerCredentialsConfig; + } catch (err) { } + } + return _cdkCredentials; +} + +/** Fetches login credentials from the configured source (e.g., SecretsManager, ECR) */ +export async function fetchDockerLoginCredentials(aws: IAws, config: DockerCredentialsConfig, domain: string) { + if (!Object.keys(config.domainCredentials).includes(domain)) { + throw new Error(`unknown domain ${domain}`); + } + + const domainConfig = config.domainCredentials[domain]; + + if (domainConfig.secretsManagerSecretId) { + const sm = await aws.secretsManagerClient({ assumeRoleArn: domainConfig.assumeRoleArn }); + const secretValue = await sm.getSecretValue({ SecretId: domainConfig.secretsManagerSecretId }).promise(); + if (!secretValue.SecretString) { throw new Error(`unable to fetch SecretString from secret: ${domainConfig.secretsManagerSecretId}`); }; + + const secret = JSON.parse(secretValue.SecretString); + + const usernameField = domainConfig.secretsUsernameField ?? 'username'; + const secretField = domainConfig.secretsPasswordField ?? 'secret'; + if (!secret[usernameField] || !secret[secretField]) { + throw new Error(`malformed secret string ("${usernameField}" or "${secretField}" field missing)`); + } + + return { Username: secret[usernameField], Secret: secret[secretField] }; + } else if (domainConfig.ecrRepository) { + const ecr = await aws.ecrClient({ assumeRoleArn: domainConfig.assumeRoleArn }); + const ecrAuthData = await obtainEcrCredentials(ecr); + + return { Username: ecrAuthData.username, Secret: ecrAuthData.password }; + } else { + throw new Error('unknown credential type: no secret ID or ECR repo'); + } +} + +export async function obtainEcrCredentials(ecr: AWS.ECR, logger?: Logger) { + if (logger) { logger('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(':'); + if (!username || !password) { throw new Error('unexpected ECR authData format'); } + + return { + username, + password, + endpoint: authData[0].proxyEndpoint!, + }; +} diff --git a/packages/cdk-assets/lib/private/docker.ts b/packages/cdk-assets/lib/private/docker.ts index eef2bd4392933..e1fc54429f18f 100644 --- a/packages/cdk-assets/lib/private/docker.ts +++ b/packages/cdk-assets/lib/private/docker.ts @@ -1,4 +1,7 @@ -// import * as os from 'os'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { cdkCredentialsConfig, obtainEcrCredentials } from './docker-credentials'; import { Logger, shell, ShellOptions } from './shell'; interface BuildOptions { @@ -13,7 +16,20 @@ interface BuildOptions { readonly buildArgs?: Record; } +export interface DockerCredentialsConfig { + readonly version: string; + readonly domainCredentials: Record; +} + +export interface DockerDomainCredentials { + readonly secretsManagerSecretId?: string; + readonly ecrRepository?: string; +} + export class Docker { + + private configDir: string | undefined = undefined; + constructor(private readonly logger?: Logger) { } @@ -70,9 +86,46 @@ export class Docker { await this.execute(['push', tag]); } + /** + * 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, 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: ShellOptions = {}) { + const configArgs = this.configDir ? ['--config', this.configDir] : []; + try { - await shell(['docker', ...args], { logger: this.logger, ...options }); + await shell(['docker', ...configArgs, ...args], { logger: this.logger, ...options }); } catch (e) { if (e.code === 'ENOENT') { throw new Error('Unable to execute \'docker\' in order to build a container asset. Please install \'docker\' and try again.'); @@ -82,22 +135,6 @@ export class Docker { } } -async function obtainEcrCredentials(ecr: AWS.ECR, logger?: Logger) { - if (logger) { logger('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 { - username, - password, - endpoint: authData[0].proxyEndpoint!, - }; -} - function flatten(x: string[][]) { return Array.prototype.concat([], ...x); } diff --git a/packages/cdk-assets/lib/private/handlers/container-images.ts b/packages/cdk-assets/lib/private/handlers/container-images.ts index 1d2b5bedee38e..ec4327775c37c 100644 --- a/packages/cdk-assets/lib/private/handlers/container-images.ts +++ b/packages/cdk-assets/lib/private/handlers/container-images.ts @@ -31,8 +31,13 @@ export class ContainerImageAssetHandler implements IAssetHandler { if (await this.destinationAlreadyExists(ecr, destination, imageUri)) { return; } if (this.host.aborted) { return; } - // Login before build so that the Dockerfile can reference images in the ECR repo - await this.docker.login(ecr); + // 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 = this.docker.configureCdkCredentials(); + if (!cdkDockerCredentialsConfigured) { await this.docker.login(ecr); } const localTagName = this.asset.source.executable ? await this.buildExternalAsset(this.asset.source.executable) @@ -45,6 +50,12 @@ export class ContainerImageAssetHandler implements IAssetHandler { this.host.emitMessage(EventType.UPLOAD, `Push ${imageUri}`); if (this.host.aborted) { return; } await this.docker.tag(localTagName, imageUri); + + if (cdkDockerCredentialsConfigured) { + this.docker.resetAuthPlugins(); + await this.docker.login(ecr); + } + await this.docker.push(imageUri); } diff --git a/packages/cdk-assets/package.json b/packages/cdk-assets/package.json index 36cb5b5d542e4..2ba132a9149f7 100644 --- a/packages/cdk-assets/package.json +++ b/packages/cdk-assets/package.json @@ -5,7 +5,8 @@ "main": "lib/index.js", "types": "lib/index.d.ts", "bin": { - "cdk-assets": "bin/cdk-assets" + "cdk-assets": "bin/cdk-assets", + "docker-credential-cdk-assets": "bin/docker-credential-cdk-assets" }, "scripts": { "build": "cdk-build", diff --git a/packages/cdk-assets/test/docker-images.test.ts b/packages/cdk-assets/test/docker-images.test.ts index 8dc4f70b7726a..1f36b88025725 100644 --- a/packages/cdk-assets/test/docker-images.test.ts +++ b/packages/cdk-assets/test/docker-images.test.ts @@ -1,16 +1,22 @@ jest.mock('child_process'); +import * as fs from 'fs'; import { Manifest } from '@aws-cdk/cloud-assembly-schema'; import * as mockfs from 'mock-fs'; import { AssetManifest, AssetPublishing } from '../lib'; +import * as dockercreds from '../lib/private/docker-credentials'; import { mockAws, mockedApiFailure, mockedApiResult } from './mock-aws'; import { mockSpawn } from './mock-child_process'; + let aws: ReturnType; const absoluteDockerPath = '/simple/cdk.out/dockerdir'; beforeEach(() => { jest.resetAllMocks(); + // By default, assume no externally-configured credentials. + jest.spyOn(dockercreds, 'cdkCredentialsConfig').mockReturnValue(undefined); + mockfs({ '/simple/cdk.out/assets.json': JSON.stringify({ version: Manifest.version(), @@ -216,3 +222,32 @@ test('correctly identify Docker directory if path is absolute', async () => { expect(true).toBeTruthy(); // Expect no exception, satisfy linter expectAllSpawns(); }); + +test('when external credentials are present, explicit Docker config directories are used', async () => { + // Setup -- Mock that we have CDK credentials, and mock fs operations. + jest.spyOn(dockercreds, 'cdkCredentialsConfig').mockReturnValue({ version: '0.1', domainCredentials: {} }); + jest.spyOn(fs, 'mkdtempSync').mockImplementationOnce(() => '/tmp/mockedTempDir'); + jest.spyOn(fs, 'writeFileSync').mockImplementation(jest.fn()); + + let pub = new AssetPublishing(AssetManifest.fromPath('/simple/cdk.out'), { aws }); + aws.mockEcr.describeImages = mockedApiFailure('ImageNotFoundException', 'File does not exist'); + aws.mockEcr.getAuthorizationToken = mockedApiResult({ + authorizationData: [ + { authorizationToken: 'dXNlcjpwYXNz', proxyEndpoint: 'https://proxy.com/' }, + ], + }); + + const expectAllSpawns = mockSpawn( + // Initally use the first created directory with the CDK credentials + { commandLine: ['docker', '--config', '/tmp/mockedTempDir', 'inspect', 'cdkasset-theasset'], exitCode: 1 }, + { commandLine: ['docker', '--config', '/tmp/mockedTempDir', 'build', '--tag', 'cdkasset-theasset', '.'], cwd: absoluteDockerPath }, + { commandLine: ['docker', '--config', '/tmp/mockedTempDir', 'tag', 'cdkasset-theasset', '12345.amazonaws.com/repo:abcdef'] }, + // Prior to push, revert to the default config directory + { commandLine: ['docker', 'login'], prefix: true }, + { commandLine: ['docker', 'push', '12345.amazonaws.com/repo:abcdef'] }, + ); + + await pub.publish(); + + expectAllSpawns(); +}); diff --git a/packages/cdk-assets/test/mock-aws.ts b/packages/cdk-assets/test/mock-aws.ts index 262ab495bd430..2d40f7c756a28 100644 --- a/packages/cdk-assets/test/mock-aws.ts +++ b/packages/cdk-assets/test/mock-aws.ts @@ -4,6 +4,7 @@ import * as AWS from 'aws-sdk'; export function mockAws() { const mockEcr = new AWS.ECR(); const mockS3 = new AWS.S3(); + const mockSecretsManager = new AWS.SecretsManager(); // Sane defaults which can be overridden mockS3.getBucketLocation = mockedApiResult({}); @@ -18,11 +19,13 @@ export function mockAws() { return { mockEcr, mockS3, + mockSecretsManager, discoverPartition: jest.fn(() => Promise.resolve('swa')), discoverCurrentAccount: jest.fn(() => Promise.resolve({ accountId: 'current_account', partition: 'swa' })), discoverDefaultRegion: jest.fn(() => Promise.resolve('current_region')), ecrClient: jest.fn(() => Promise.resolve(mockEcr)), s3Client: jest.fn(() => Promise.resolve(mockS3)), + secretsManagerClient: jest.fn(() => Promise.resolve(mockSecretsManager)), }; } @@ -65,4 +68,4 @@ export function mockUpload(expectContent?: string) { }); }), })); -} \ No newline at end of file +} diff --git a/packages/cdk-assets/test/private/docker-credentials.test.ts b/packages/cdk-assets/test/private/docker-credentials.test.ts new file mode 100644 index 0000000000000..6b521c67457b6 --- /dev/null +++ b/packages/cdk-assets/test/private/docker-credentials.test.ts @@ -0,0 +1,206 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as mockfs from 'mock-fs'; +import { cdkCredentialsConfig, cdkCredentialsConfigFile, DockerCredentialsConfig, fetchDockerLoginCredentials } from '../../lib/private/docker-credentials'; +import { mockAws, mockedApiFailure, mockedApiResult } from '../mock-aws'; + +const _ENV = process.env; + +let aws: ReturnType; +beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + + aws = mockAws(); + + process.env = { ..._ENV }; +}); + +afterEach(() => { + mockfs.restore(); + process.env = _ENV; +}); + +describe('cdkCredentialsConfigFile', () => { + test('Can be overridden by CDK_DOCKER_CREDS_FILE', () => { + const credsFile = '/tmp/insertfilenamehere_cdk_config.json'; + process.env.CDK_DOCKER_CREDS_FILE = credsFile; + + expect(cdkCredentialsConfigFile()).toEqual(credsFile); + }); + + test('Uses homedir if no process env is set', () => { + expect(cdkCredentialsConfigFile()).toEqual(path.join(os.userInfo().homedir, '.cdk', 'cdk-docker-creds.json')); + }); +}); + +describe('cdkCredentialsConfig', () => { + const credsFile = '/tmp/foo/bar/does/not/exist/config.json'; + beforeEach(() => { process.env.CDK_DOCKER_CREDS_FILE = credsFile; }); + + test('returns undefined if no config exists', () => { + expect(cdkCredentialsConfig()).toBeUndefined(); + }); + + test('returns parsed config if it exists', () => { + mockfs({ + [credsFile]: JSON.stringify({ + version: '0.1', + domainCredentials: { + 'test1.example.com': { secretsManagerSecretId: 'mySecret' }, + 'test2.example.com': { ecrRepository: 'arn:aws:ecr:bar' }, + }, + }), + }); + + const config = cdkCredentialsConfig(); + expect(config).toBeDefined(); + expect(config?.version).toEqual('0.1'); + expect(config?.domainCredentials['test1.example.com']?.secretsManagerSecretId).toEqual('mySecret'); + expect(config?.domainCredentials['test2.example.com']?.ecrRepository).toEqual('arn:aws:ecr:bar'); + }); +}); + +describe('fetchDockerLoginCredentials', () => { + let config: DockerCredentialsConfig; + + beforeEach(() => { + config = { + version: '0.1', + domainCredentials: { + 'misconfigured.example.com': {}, + 'secret.example.com': { secretsManagerSecretId: 'mySecret' }, + 'secretwithrole.example.com': { + secretsManagerSecretId: 'mySecret', + assumeRoleArn: 'arn:aws:iam::0123456789012:role/my-role', + }, + 'secretwithcustomfields.example.com': { + secretsManagerSecretId: 'mySecret', + secretsUsernameField: 'name', + secretsPasswordField: 'apiKey', + assumeRoleArn: 'arn:aws:iam::0123456789012:role/my-role', + }, + 'ecr.example.com': { ecrRepository: true }, + 'ecrwithrole.example.com': { + ecrRepository: true, + assumeRoleArn: 'arn:aws:iam::0123456789012:role/my-role', + }, + }, + }; + }); + + test('throws on unknown domain', async () => { + await expect(fetchDockerLoginCredentials(aws, config, 'unknowndomain.example.com')).rejects.toThrow(/unknown domain/); + }); + + test('throws on misconfigured domain (no ECR or SM)', async () => { + await expect(fetchDockerLoginCredentials(aws, config, 'misconfigured.example.com')).rejects.toThrow(/unknown credential type/); + }); + + describe('SecretsManager', () => { + test('returns the credentials sucessfully if configured correctly', async () => { + mockSecretWithSecretString({ username: 'secretUser', secret: 'secretPass' }); + + const creds = await fetchDockerLoginCredentials(aws, config, 'secret.example.com'); + + expect(creds).toEqual({ Username: 'secretUser', Secret: 'secretPass' }); + }); + + test('throws when SecretsManager returns an error', async () => { + const errMessage = "Secrets Manager can't find the specified secret."; + aws.mockSecretsManager.getSecretValue = mockedApiFailure('ResourceNotFoundException', errMessage); + + await expect(fetchDockerLoginCredentials(aws, config, 'secret.example.com')).rejects.toThrow(errMessage); + }); + + test('supports assuming a role', async () => { + mockSecretWithSecretString({ username: 'secretUser', secret: 'secretPass' }); + + const creds = await fetchDockerLoginCredentials(aws, config, 'secretwithrole.example.com'); + + expect(creds).toEqual({ Username: 'secretUser', Secret: 'secretPass' }); + expect(aws.secretsManagerClient).toHaveBeenCalledWith({ assumeRoleArn: 'arn:aws:iam::0123456789012:role/my-role' }); + }); + + test('supports configuring the secret fields', async () => { + mockSecretWithSecretString({ name: 'secretUser', apiKey: '01234567' }); + + const creds = await fetchDockerLoginCredentials(aws, config, 'secretwithcustomfields.example.com'); + + expect(creds).toEqual({ Username: 'secretUser', Secret: '01234567' }); + }); + + test('throws when secret does not have the correct fields - key/value', async () => { + mockSecretWithSecretString({ principal: 'foo', credential: 'bar' }); + + await expect(fetchDockerLoginCredentials(aws, config, 'secret.example.com')).rejects.toThrow(/malformed secret string/); + }); + + test('throws when secret does not have the correct fields - plaintext', async () => { + mockSecretWithSecretString('myAPIKey'); + + await expect(fetchDockerLoginCredentials(aws, config, 'secret.example.com')).rejects.toThrow(/malformed secret string/); + }); + }); + + describe('ECR getAuthorizationToken', () => { + test('returns the credentials successfully', async () => { + mockEcrAuthorizationData(Buffer.from('myFoo:myBar', 'utf-8').toString('base64')); + + const creds = await fetchDockerLoginCredentials(aws, config, 'ecr.example.com'); + + expect(creds).toEqual({ Username: 'myFoo', Secret: 'myBar' }); + }); + + test('throws if ECR errors', async () => { + aws.mockEcr.getAuthorizationToken = mockedApiFailure('ServerException', 'uhoh'); + + await expect(fetchDockerLoginCredentials(aws, config, 'ecr.example.com')).rejects.toThrow(/uhoh/); + }); + + test('supports assuming a role', async () => { + mockEcrAuthorizationData(Buffer.from('myFoo:myBar', 'utf-8').toString('base64')); + + const creds = await fetchDockerLoginCredentials(aws, config, 'ecrwithrole.example.com'); + + expect(creds).toEqual({ Username: 'myFoo', Secret: 'myBar' }); + expect(aws.ecrClient).toHaveBeenCalledWith({ assumeRoleArn: 'arn:aws:iam::0123456789012:role/my-role' }); + }); + + test('throws if ECR returns no authData', async () => { + aws.mockEcr.getAuthorizationToken = mockedApiResult({ authorizationData: [] }); + + await expect(fetchDockerLoginCredentials(aws, config, 'ecr.example.com')).rejects.toThrow(/No authorization data received from ECR/); + }); + + test('throws if ECR authData is in an incorrect format', async () => { + mockEcrAuthorizationData('notabase64encodedstring'); + + await expect(fetchDockerLoginCredentials(aws, config, 'ecr.example.com')).rejects.toThrow(/unexpected ECR authData format/); + }); + }); + +}); + +function mockSecretWithSecretString(secretString: any) { + aws.mockSecretsManager.getSecretValue = mockedApiResult({ + ARN: 'arn:aws:secretsmanager:eu-west-1:0123456789012:secret:mySecret', + Name: 'mySecret', + VersionId: 'fa81fe61-c167-4aca-969e-4d8df74d4814', + SecretString: JSON.stringify(secretString), + VersionStages: [ + 'AWSCURRENT', + ], + }); +} + +function mockEcrAuthorizationData(authorizationToken: string) { + aws.mockEcr.getAuthorizationToken = mockedApiResult({ + authorizationData: [ + { + authorizationToken, + proxyEndpoint: 'https://0123456789012.dkr.ecr.eu-west-1.amazonaws.com', + }, + ], + }); +}