diff --git a/packages/@aws-cdk/aws-ecr/README.md b/packages/@aws-cdk/aws-ecr/README.md index 7629882651d98..93a7e841a25bf 100644 --- a/packages/@aws-cdk/aws-ecr/README.md +++ b/packages/@aws-cdk/aws-ecr/README.md @@ -1,2 +1,25 @@ -## The CDK Construct Library for AWS Elastic Container Registry (ECR) -This module is part of the [AWS Cloud Development Kit](https://github.com/awslabs/aws-cdk) project. +## Amazon Elastic Container Registry Construct Library + +This package contains constructs for working with Amazon Elastic Container Registry. + +### Repositories + +Define a repository by creating a new instance of `Repository`. A repository +holds multiple verions of a single container image. + +```ts +const repository = new ecr.Repository(this, 'Repository'); +``` + +### Automatically clean up repositories + +You can set life cycle rules to automatically clean up old images from your +repository. The first life cycle rule that matches an image will be applied +against that image. For example, the following deletes images older than +30 days, while keeping all images tagged with prod (note that the order +is important here): + +```ts +repository.addLifecycleRule({ tagPrefixList: ['prod'], maxImageCount: 9999 }); +repository.addLifecycleRule({ maxImageAgeDays: 30 }); +``` diff --git a/packages/@aws-cdk/aws-ecr/lib/index.ts b/packages/@aws-cdk/aws-ecr/lib/index.ts index 14adb95e85a9f..21b453e140916 100644 --- a/packages/@aws-cdk/aws-ecr/lib/index.ts +++ b/packages/@aws-cdk/aws-ecr/lib/index.ts @@ -1,2 +1,6 @@ // AWS::ECR CloudFormation Resources: export * from './ecr.generated'; + +export * from './repository'; +export * from './repository-ref'; +export * from './lifecycle'; diff --git a/packages/@aws-cdk/aws-ecr/lib/lifecycle.ts b/packages/@aws-cdk/aws-ecr/lib/lifecycle.ts new file mode 100644 index 0000000000000..804438da03997 --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/lib/lifecycle.ts @@ -0,0 +1,93 @@ +/** + * An ECR life cycle rule + */ +export interface LifecycleRule { + /** + * Controls the order in which rules are evaluated (low to high) + * + * All rules must have a unique priority, where lower numbers have + * higher precedence. The first rule that matches is applied to an image. + * + * There can only be one rule with a tagStatus of Any, and it must have + * the highest rulePriority. + * + * All rules without a specified priority will have incrementing priorities + * automatically assigned to them, higher than any rules that DO have priorities. + * + * @default Automatically assigned + */ + rulePriority?: number; + + /** + * Describes the purpose of the rule + * + * @default No description + */ + description?: string; + + /** + * Select images based on tags + * + * Only one rule is allowed to select untagged images, and it must + * have the highest rulePriority. + * + * @default TagStatus.Tagged if tagPrefixList is given, TagStatus.Any otherwise + */ + tagStatus?: TagStatus; + + /** + * Select images that have ALL the given prefixes in their tag. + * + * Only if tagStatus == TagStatus.Tagged + */ + tagPrefixList?: string[]; + + /** + * The maximum number of images to retain + * + * Specify exactly one of maxImageCount and maxImageAgeDays. + */ + maxImageCount?: number; + + /** + * The maximum age of images to retain + * + * Specify exactly one of maxImageCount and maxImageAgeDays. + */ + maxImageAgeDays?: number; +} + +/** + * Select images based on tags + */ +export enum TagStatus { + /** + * Rule applies to all images + */ + Any = 'any', + + /** + * Rule applies to tagged images + */ + Tagged = 'tagged', + + /** + * Rule applies to untagged images + */ + Untagged = 'untagged', +} + +/** + * Select images based on counts + */ +export enum CountType { + /** + * Set a limit on the number of images in your repository + */ + ImageCountMoreThan = 'imageCountMoreThan', + + /** + * Set an age limit on the images in your repository + */ + SinceImagePushed = 'sinceImagePushed', +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts b/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts new file mode 100644 index 0000000000000..a63fb0911aceb --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts @@ -0,0 +1,75 @@ +import cdk = require('@aws-cdk/cdk'); +import { RepositoryArn, RepositoryName } from './ecr.generated'; + +/** + * An ECR repository + */ +export abstract class RepositoryRef extends cdk.Construct { + /** + * Import a repository + */ + public static import(parent: cdk.Construct, id: string, props: RepositoryRefProps): RepositoryRef { + return new ImportedRepository(parent, id, props); + } + + /** + * The name of the repository + */ + public abstract readonly repositoryName: RepositoryName; + + /** + * The ARN of the repository + */ + public abstract readonly repositoryArn: RepositoryArn; + + /** + * Add a policy statement to the repository's resource policy + */ + public abstract addToResourcePolicy(statement: cdk.PolicyStatement): void; + + /** + * Export this repository from the stack + */ + public export(): RepositoryRefProps { + return { + repositoryArn: new RepositoryArn(new cdk.Output(this, 'RepositoryArn', { value: this.repositoryArn }).makeImportValue()), + }; + } + + /** + * The URI of the repository, for use in Docker/image references + */ + public get repositoryUri(): RepositoryUri { + // Calculate this from the ARN + const parts = cdk.Arn.parseToken(this.repositoryArn); + return new RepositoryUri(`${parts.account}.dkr.ecr.${parts.region}.amazonaws.com/${parts.resourceName}`); + } +} + +/** + * URI of a repository + */ +export class RepositoryUri extends cdk.CloudFormationToken { +} + +export interface RepositoryRefProps { + repositoryArn: RepositoryArn; +} + +/** + * An already existing repository + */ +class ImportedRepository extends RepositoryRef { + public readonly repositoryName: RepositoryName; + public readonly repositoryArn: RepositoryArn; + + constructor(parent: cdk.Construct, id: string, props: RepositoryRefProps) { + super(parent, id); + this.repositoryArn = props.repositoryArn; + this.repositoryName = new RepositoryName(cdk.Arn.parseToken(props.repositoryArn).resourceName); + } + + public addToResourcePolicy(_statement: cdk.PolicyStatement) { + // FIXME: Add annotation about policy we dropped on the floor + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts new file mode 100644 index 0000000000000..1043970e0f052 --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -0,0 +1,190 @@ +import cdk = require('@aws-cdk/cdk'); +import { cloudformation, RepositoryArn, RepositoryName } from './ecr.generated'; +import { CountType, LifecycleRule, TagStatus } from './lifecycle'; +import { RepositoryRef } from "./repository-ref"; + +export interface RepositoryProps { + /** + * Name for this repository + * + * @default Automatically generated name. + */ + repositoryName?: string; + + /** + * Life cycle rules to apply to this registry + * + * @default No life cycle rules + */ + lifecycleRules?: LifecycleRule[]; + + /** + * The AWS account ID associated with the registry that contains the repository. + * + * @see https://docs.aws.amazon.com/AmazonECR/latest/APIReference/API_PutLifecyclePolicy.html + * @default The default registry is assumed. + */ + lifecycleRegistryId?: string; + + /** + * Retain the repository on stack deletion + * + * If you don't set this to true, the registry must be empty, otherwise + * your stack deletion will fail. + * + * @default false + */ + retain?: boolean; +} + +/** + * Define an ECR repository + */ +export class Repository extends RepositoryRef { + public readonly repositoryName: RepositoryName; + public readonly repositoryArn: RepositoryArn; + private readonly lifecycleRules = new Array(); + private readonly registryId?: string; + private policyDocument?: cdk.PolicyDocument; + + constructor(parent: cdk.Construct, id: string, props: RepositoryProps = {}) { + super(parent, id); + + const resource = new cloudformation.RepositoryResource(this, 'Resource', { + repositoryName: props.repositoryName, + // It says "Text", but they actually mean "Object". + repositoryPolicyText: this.policyDocument, + lifecyclePolicy: new cdk.Token(() => this.renderLifecyclePolicy()), + }); + + if (props.retain) { + resource.options.deletionPolicy = cdk.DeletionPolicy.Retain; + } + + this.registryId = props.lifecycleRegistryId; + if (props.lifecycleRules) { + props.lifecycleRules.forEach(this.addLifecycleRule.bind(this)); + } + + this.repositoryName = resource.ref; + this.repositoryArn = resource.repositoryArn; + } + + public addToResourcePolicy(statement: cdk.PolicyStatement) { + if (this.policyDocument === undefined) { + this.policyDocument = new cdk.PolicyDocument(); + } + this.policyDocument.addStatement(statement); + } + + /** + * Add a life cycle rule to the repository + * + * Life cycle rules automatically expire images from the repository that match + * certain conditions. + */ + public addLifecycleRule(rule: LifecycleRule) { + // Validate rule here so users get errors at the expected location + if (rule.tagStatus === undefined) { + rule.tagStatus = rule.tagPrefixList === undefined ? TagStatus.Any : TagStatus.Tagged; + } + + if (rule.tagStatus === TagStatus.Tagged && (rule.tagPrefixList === undefined || rule.tagPrefixList.length === 0)) { + throw new Error('TagStatus.Tagged requires the specification of a tagPrefixList'); + } + if (rule.tagStatus !== TagStatus.Tagged && rule.tagPrefixList !== undefined) { + throw new Error('tagPrefixList can only be specified when tagStatus is set to Tagged'); + } + if ((rule.maxImageAgeDays !== undefined) === (rule.maxImageCount !== undefined)) { + throw new Error(`Life cycle rule must contain exactly one of 'maxImageAgeDays' and 'maxImageCount', got: ${JSON.stringify(rule)}`); + } + + if (rule.tagStatus === TagStatus.Any && this.lifecycleRules.filter(r => r.tagStatus === TagStatus.Any).length > 0) { + throw new Error('Life cycle can only have one TagStatus.Any rule'); + } + + this.lifecycleRules.push({ ...rule }); + } + + /** + * Render the life cycle policy object + */ + private renderLifecyclePolicy(): cloudformation.RepositoryResource.LifecyclePolicyProperty | undefined { + let lifecyclePolicyText: any; + + if (this.lifecycleRules.length === 0 && !this.registryId) { return undefined; } + + if (this.lifecycleRules.length > 0) { + lifecyclePolicyText = JSON.stringify(cdk.resolve({ + rules: this.orderedLifecycleRules().map(renderLifecycleRule), + })); + } + + return { + lifecyclePolicyText, + registryId: this.registryId, + }; + } + + /** + * Return life cycle rules with automatic ordering applied. + * + * Also applies validation of the 'any' rule. + */ + private orderedLifecycleRules(): LifecycleRule[] { + if (this.lifecycleRules.length === 0) { return []; } + + const prioritizedRules = this.lifecycleRules.filter(r => r.rulePriority !== undefined && r.tagStatus !== TagStatus.Any); + const autoPrioritizedRules = this.lifecycleRules.filter(r => r.rulePriority === undefined && r.tagStatus !== TagStatus.Any); + const anyRules = this.lifecycleRules.filter(r => r.tagStatus === TagStatus.Any); + if (anyRules.length > 0 && anyRules[0].rulePriority !== undefined && autoPrioritizedRules.length > 0) { + // Supporting this is too complex for very little value. We just prohibit it. + throw new Error("Cannot combine prioritized TagStatus.Any rule with unprioritized rules. Remove rulePriority from the 'Any' rule."); + } + + const prios = prioritizedRules.map(r => r.rulePriority!); + let autoPrio = (prios.length > 0 ? Math.max(...prios) : 0) + 1; + + const ret = new Array(); + for (const rule of prioritizedRules.concat(autoPrioritizedRules).concat(anyRules)) { + ret.push({ + ...rule, + rulePriority: rule.rulePriority !== undefined ? rule.rulePriority : autoPrio++ + }); + } + + // Do validation on the final array--might still be wrong because the user supplied all prios, but incorrectly. + validateAnyRuleLast(ret); + return ret; + } +} + +function validateAnyRuleLast(rules: LifecycleRule[]) { + const anyRules = rules.filter(r => r.tagStatus === TagStatus.Any); + if (anyRules.length === 1) { + const maxPrio = Math.max(...rules.map(r => r.rulePriority!)); + if (anyRules[0].rulePriority !== maxPrio) { + throw new Error(`TagStatus.Any rule must have highest priority, has ${anyRules[0].rulePriority} which is smaller than ${maxPrio}`); + } + } +} + +/** + * Render the lifecycle rule to JSON + */ +function renderLifecycleRule(rule: LifecycleRule) { + return { + rulePriority: rule.rulePriority, + description: rule.description, + selection: { + tagStatus: rule.tagStatus || TagStatus.Any, + tagPrefixList: rule.tagPrefixList, + countType: rule.maxImageAgeDays !== undefined ? CountType.SinceImagePushed : CountType.ImageCountMoreThan, + countNumber: rule.maxImageAgeDays !== undefined ? rule.maxImageAgeDays : rule.maxImageCount, + countUnit: rule.maxImageAgeDays !== undefined ? 'days' : undefined, + }, + action: { + type: 'expire' + } + }; +} diff --git a/packages/@aws-cdk/aws-ecr/package.json b/packages/@aws-cdk/aws-ecr/package.json index e252b068f1440..79071ed185c36 100644 --- a/packages/@aws-cdk/aws-ecr/package.json +++ b/packages/@aws-cdk/aws-ecr/package.json @@ -55,7 +55,8 @@ "@aws-cdk/assert": "^0.9.0", "cdk-build-tools": "^0.9.0", "cfn2ts": "^0.9.0", - "pkglint": "^0.9.0" + "pkglint": "^0.9.0", + "cdk-integ-tools": "^0.9.0" }, "dependencies": { "@aws-cdk/cdk": "^0.9.0" diff --git a/packages/@aws-cdk/aws-ecr/test/integ.basic.expected.json b/packages/@aws-cdk/aws-ecr/test/integ.basic.expected.json new file mode 100644 index 0000000000000..3fd615ddb218c --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/test/integ.basic.expected.json @@ -0,0 +1,86 @@ +{ + "Resources": { + "Repo02AC86CF": { + "Type": "AWS::ECR::Repository", + "Properties": { + "LifecyclePolicy": { + "LifecyclePolicyText": "{\"rules\":[{\"rulePriority\":1,\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":5},\"action\":{\"type\":\"expire\"}}]}" + } + } + } + }, + "Outputs": { + "RepositoryURI": { + "Value": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 4, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "Repo02AC86CF", + "Arn" + ] + } + ] + } + ] + }, + ".dkr.ecr.", + { + "Fn::Select": [ + 3, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "Repo02AC86CF", + "Arn" + ] + } + ] + } + ] + }, + ".amazonaws.com/", + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "/", + { + "Fn::Select": [ + 5, + { + "Fn::Split": [ + ":", + { + "Fn::GetAtt": [ + "Repo02AC86CF", + "Arn" + ] + } + ] + } + ] + } + ] + } + ] + } + ] + ] + }, + "Export": { + "Name": "aws-ecr-integ-stack:RepositoryURI" + } + } + } +} diff --git a/packages/@aws-cdk/aws-ecr/test/integ.basic.ts b/packages/@aws-cdk/aws-ecr/test/integ.basic.ts new file mode 100644 index 0000000000000..f1a2915428ab0 --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/test/integ.basic.ts @@ -0,0 +1,14 @@ +import cdk = require('@aws-cdk/cdk'); +import ecr = require('../lib'); + +const app = new cdk.App(process.argv); +const stack = new cdk.Stack(app, 'aws-ecr-integ-stack'); + +const repo = new ecr.Repository(stack, 'Repo'); +repo.addLifecycleRule({ maxImageCount: 5 }); + +new cdk.Output(stack, 'RepositoryURI', { + value: repo.repositoryUri +}); + +process.stdout.write(app.run()); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecr/test/test.ecr.ts b/packages/@aws-cdk/aws-ecr/test/test.ecr.ts deleted file mode 100644 index db4c843199541..0000000000000 --- a/packages/@aws-cdk/aws-ecr/test/test.ecr.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Test, testCase } from 'nodeunit'; - -exports = testCase({ - notTested(test: Test) { - test.ok(true, 'No tests are specified for this package.'); - test.done(); - } -}); diff --git a/packages/@aws-cdk/aws-ecr/test/test.repository.ts b/packages/@aws-cdk/aws-ecr/test/test.repository.ts new file mode 100644 index 0000000000000..3b4f6c4abf211 --- /dev/null +++ b/packages/@aws-cdk/aws-ecr/test/test.repository.ts @@ -0,0 +1,165 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecr = require('../lib'); + +export = { + 'construct repository'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new ecr.Repository(stack, 'Repo'); + + // THEN + expect(stack).toMatch({ + Resources: { + Repo02AC86CF: { + Type: "AWS::ECR::Repository" + } + } + }); + + test.done(); + }, + + 'tag-based lifecycle policy'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // WHEN + repo.addLifecycleRule({ tagPrefixList: ['abc'], maxImageCount: 1 }); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + LifecyclePolicy: { + // tslint:disable-next-line:max-line-length + LifecyclePolicyText: "{\"rules\":[{\"rulePriority\":1,\"selection\":{\"tagStatus\":\"tagged\",\"tagPrefixList\":[\"abc\"],\"countType\":\"imageCountMoreThan\",\"countNumber\":1},\"action\":{\"type\":\"expire\"}}]}" + } + })); + + test.done(); + }, + + 'add day-based lifecycle policy'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + const repo = new ecr.Repository(stack, 'Repo'); + repo.addLifecycleRule({ + maxImageAgeDays: 5, + }); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + LifecyclePolicy: { + // tslint:disable-next-line:max-line-length + LifecyclePolicyText: "{\"rules\":[{\"rulePriority\":1,\"selection\":{\"tagStatus\":\"any\",\"countType\":\"sinceImagePushed\",\"countNumber\":5,\"countUnit\":\"days\"},\"action\":{\"type\":\"expire\"}}]}", + } + })); + + test.done(); + }, + + 'add count-based lifecycle policy'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // WHEN + repo.addLifecycleRule({ + maxImageCount: 5, + }); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + LifecyclePolicy: { + // tslint:disable-next-line:max-line-length + LifecyclePolicyText: "{\"rules\":[{\"rulePriority\":1,\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":5},\"action\":{\"type\":\"expire\"}}]}", + } + })); + + test.done(); + }, + + 'mixing numbered and unnumbered rules'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // WHEN + repo.addLifecycleRule({ tagStatus: ecr.TagStatus.Tagged, tagPrefixList: ['a'], maxImageCount: 5 }); + repo.addLifecycleRule({ rulePriority: 10, tagStatus: ecr.TagStatus.Tagged, tagPrefixList: ['b'], maxImageCount: 5 }); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + LifecyclePolicy: { + // tslint:disable-next-line:max-line-length + LifecyclePolicyText: "{\"rules\":[{\"rulePriority\":10,\"selection\":{\"tagStatus\":\"tagged\",\"tagPrefixList\":[\"b\"],\"countType\":\"imageCountMoreThan\",\"countNumber\":5},\"action\":{\"type\":\"expire\"}},{\"rulePriority\":11,\"selection\":{\"tagStatus\":\"tagged\",\"tagPrefixList\":[\"a\"],\"countType\":\"imageCountMoreThan\",\"countNumber\":5},\"action\":{\"type\":\"expire\"}}]}" + } + })); + + test.done(); + }, + + 'tagstatus Any is automatically sorted to the back'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // WHEN + repo.addLifecycleRule({ maxImageCount: 5 }); + repo.addLifecycleRule({ tagStatus: ecr.TagStatus.Tagged, tagPrefixList: ['important'], maxImageCount: 999 }); + + // THEN + expect(stack).to(haveResource('AWS::ECR::Repository', { + LifecyclePolicy: { + // tslint:disable-next-line:max-line-length + LifecyclePolicyText: "{\"rules\":[{\"rulePriority\":1,\"selection\":{\"tagStatus\":\"tagged\",\"tagPrefixList\":[\"important\"],\"countType\":\"imageCountMoreThan\",\"countNumber\":999},\"action\":{\"type\":\"expire\"}},{\"rulePriority\":2,\"selection\":{\"tagStatus\":\"any\",\"countType\":\"imageCountMoreThan\",\"countNumber\":5},\"action\":{\"type\":\"expire\"}}]}" + } + })); + + test.done(); + }, + + 'calculate repository URI'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const repo = new ecr.Repository(stack, 'Repo'); + + // WHEN + const uri = repo.repositoryUri; + + // THEN + const arnSplit = { 'Fn::Split': [ ':', { 'Fn::GetAtt': [ 'Repo02AC86CF', 'Arn' ] } ] }; + test.deepEqual(cdk.resolve(uri), { 'Fn::Join': [ '', [ + { 'Fn::Select': [ 4, arnSplit ] }, + '.dkr.ecr.', + { 'Fn::Select': [ 3, arnSplit ] }, + '.amazonaws.com/', + { 'Fn::Select': [ 1, { 'Fn::Split': [ '/', { 'Fn::Select': [ 5, arnSplit ] } ] } ] } + ]]}); + + test.done(); + }, + + 'export/import'(test: Test) { + // GIVEN + const stack1 = new cdk.Stack(); + const repo1 = new ecr.Repository(stack1, 'Repo'); + + const stack2 = new cdk.Stack(); + + // WHEN + const repo2 = ecr.RepositoryRef.import(stack2, 'Repo', repo1.export()); + + // THEN + test.deepEqual(cdk.resolve(repo2.repositoryArn), { + 'Fn::ImportValue': 'RepoRepositoryArn7F2901C9' + }); + + test.done(); + } +};