-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 }); | ||
``` |
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'; |
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', | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. comments There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} | ||
} |
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "the lifecycle registry" Can you add a |
||
* | ||
* @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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does it means to specify a registry ID without rules? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' | ||
} | ||
}; | ||
} |
There was a problem hiding this comment.
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 thencdk.Arn.parse(s)
will decide if it wants to parse this as a token or as a stringThere was a problem hiding this comment.
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.