Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(aws-ecr): add support for ECR repositories #697

Merged
merged 3 commits into from
Sep 13, 2018
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to support isToken(string) and then cdk.Arn.parse(s) will decide if it wants to parse this as a token or as a string

Copy link
Contributor Author

@rix0rrr rix0rrr Sep 13, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure that makes sense. The outputs of this function are definitely not interpretable from a CDK app (even though you can't tell from the types today, but you should be able to tell that). If it makes a decision at runtime to either return readable types or not, you're bound to make errors against that.

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comments

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the ARN can be derived from the name (and/or vice versa), we should enable users to just specify one of the them.

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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"the lifecycle registry"

Can you add a @see link?

*
* @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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does it means to specify a registry ID without rules?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know, but I don't want to preclude it.

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