-
Notifications
You must be signed in to change notification settings - Fork 4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(aws-ecr): add support for ECR repositories (#697)
- Loading branch information
Showing
10 changed files
with
654 additions
and
11 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
} | ||
}; | ||
} |
Oops, something went wrong.