Skip to content

Commit

Permalink
feat(aws-ecr): add support for ECR repositories (#697)
Browse files Browse the repository at this point in the history
  • Loading branch information
rix0rrr committed Sep 13, 2018
1 parent 4bd1cf2 commit c6c09bf
Show file tree
Hide file tree
Showing 10 changed files with 654 additions and 11 deletions.
27 changes: 25 additions & 2 deletions packages/@aws-cdk/aws-ecr/README.md
Original file line number Diff line number Diff line change
@@ -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 });
```
4 changes: 4 additions & 0 deletions packages/@aws-cdk/aws-ecr/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
// AWS::ECR CloudFormation Resources:
export * from './ecr.generated';

export * from './repository';
export * from './repository-ref';
export * from './lifecycle';
93 changes: 93 additions & 0 deletions packages/@aws-cdk/aws-ecr/lib/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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',
}
75 changes: 75 additions & 0 deletions packages/@aws-cdk/aws-ecr/lib/repository-ref.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
190 changes: 190 additions & 0 deletions packages/@aws-cdk/aws-ecr/lib/repository.ts
Original file line number Diff line number Diff line change
@@ -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<LifecycleRule>();
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<LifecycleRule>();
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'
}
};
}
Loading

0 comments on commit c6c09bf

Please sign in to comment.