packages/aws-cdk-lib/aws-ecr/lib/repository.ts (575 lines of code) (raw):

import { EOL } from 'os'; import { IConstruct, Construct } from 'constructs'; import { CfnRepository } from './ecr.generated'; import { LifecycleRule, TagStatus } from './lifecycle'; import * as events from '../../aws-events'; import * as iam from '../../aws-iam'; import * as kms from '../../aws-kms'; import * as cxschema from '../../cloud-assembly-schema'; import { Annotations, ArnFormat, IResource, Lazy, RemovalPolicy, Resource, Stack, Tags, Token, TokenComparison, CustomResource, Aws, ContextProvider, Arn, ValidationError, UnscopedValidationError, } from '../../core'; import { addConstructMetadata, MethodMetadata } from '../../core/lib/metadata-resource'; import { AutoDeleteImagesProvider } from '../../custom-resource-handlers/dist/aws-ecr/auto-delete-images-provider.generated'; const AUTO_DELETE_IMAGES_RESOURCE_TYPE = 'Custom::ECRAutoDeleteImages'; const AUTO_DELETE_IMAGES_TAG = 'aws-cdk:auto-delete-images'; /** * Represents an ECR repository. */ export interface IRepository extends IResource { /** * The name of the repository * @attribute */ readonly repositoryName: string; /** * The ARN of the repository * @attribute */ readonly repositoryArn: string; /** * The URI of this repository (represents the latest image): * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY * * @attribute */ readonly repositoryUri: string; /** * The URI of this repository's registry: * * ACCOUNT.dkr.ecr.REGION.amazonaws.com * * @attribute */ readonly registryUri: string; /** * Returns the URI of the repository for a certain tag. Can be used in `docker push/pull`. * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[:TAG] * * @param tag Image tag to use (tools usually default to "latest" if omitted) */ repositoryUriForTag(tag?: string): string; /** * Returns the URI of the repository for a certain digest. Can be used in `docker push/pull`. * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[@DIGEST] * * @param digest Image digest to use (tools usually default to the image with the "latest" tag if omitted) */ repositoryUriForDigest(digest?: string): string; /** * Returns the URI of the repository for a certain tag or digest, inferring based on the syntax of the tag. Can be used in `docker push/pull`. * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[:TAG] * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[@DIGEST] * * @param tagOrDigest Image tag or digest to use (tools usually default to the image with the "latest" tag if omitted) */ repositoryUriForTagOrDigest(tagOrDigest?: string): string; /** * Add a policy statement to the repository's resource policy */ addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult; /** * Grant the given principal identity permissions to perform the actions on this repository */ grant(grantee: iam.IGrantable, ...actions: string[]): iam.Grant; /** * Grant the given identity permissions to read images in this repository. */ grantRead(grantee: iam.IGrantable): iam.Grant; /** * Grant the given identity permissions to pull images in this repository. */ grantPull(grantee: iam.IGrantable): iam.Grant; /** * Grant the given identity permissions to push images in this repository. */ grantPush(grantee: iam.IGrantable): iam.Grant; /** * Grant the given identity permissions to pull and push images to this repository. */ grantPullPush(grantee: iam.IGrantable): iam.Grant; /** * Define a CloudWatch event that triggers when something happens to this repository * * Requires that there exists at least one CloudTrail Trail in your account * that captures the event. This method will not create the Trail. * * @param id The id of the rule * @param options Options for adding the rule */ onCloudTrailEvent(id: string, options?: events.OnEventOptions): events.Rule; /** * Defines an AWS CloudWatch event rule that can trigger a target when an image is pushed to this * repository. * * Requires that there exists at least one CloudTrail Trail in your account * that captures the event. This method will not create the Trail. * * @param id The id of the rule * @param options Options for adding the rule */ onCloudTrailImagePushed(id: string, options?: OnCloudTrailImagePushedOptions): events.Rule; /** * Defines an AWS CloudWatch event rule that can trigger a target when the image scan is completed * * * @param id The id of the rule * @param options Options for adding the rule */ onImageScanCompleted(id: string, options?: OnImageScanCompletedOptions): events.Rule; /** * Defines a CloudWatch event rule which triggers for repository events. Use * `rule.addEventPattern(pattern)` to specify a filter. */ onEvent(id: string, options?: events.OnEventOptions): events.Rule; } /** * Base class for ECR repository. Reused between imported repositories and owned repositories. */ export abstract class RepositoryBase extends Resource implements IRepository { private readonly REPO_PULL_ACTIONS: string[] = [ 'ecr:BatchCheckLayerAvailability', 'ecr:GetDownloadUrlForLayer', 'ecr:BatchGetImage', ]; // https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-push.html#image-push-iam private readonly REPO_PUSH_ACTIONS: string[] = [ 'ecr:CompleteLayerUpload', 'ecr:UploadLayerPart', 'ecr:InitiateLayerUpload', 'ecr:BatchCheckLayerAvailability', 'ecr:PutImage', ]; /** * The name of the repository */ public abstract readonly repositoryName: string; /** * The ARN of the repository */ public abstract readonly repositoryArn: string; /** * Add a policy statement to the repository's resource policy */ public abstract addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult; /** * The URI of this repository (represents the latest image): * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY * */ public get repositoryUri() { return this.repositoryUriForTag(); } /** * The URI of this repository's registry: * * ACCOUNT.dkr.ecr.REGION.amazonaws.com * */ public get registryUri(): string { const parts = this.stack.splitArn(this.repositoryArn, ArnFormat.SLASH_RESOURCE_NAME); return `${parts.account}.dkr.ecr.${parts.region}.${this.stack.urlSuffix}`; } /** * Returns the URL of the repository. Can be used in `docker push/pull`. * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[:TAG] * * @param tag Optional image tag */ public repositoryUriForTag(tag?: string): string { const tagSuffix = tag ? `:${tag}` : ''; return this.repositoryUriWithSuffix(tagSuffix); } /** * Returns the URL of the repository. Can be used in `docker push/pull`. * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[@DIGEST] * * @param digest Optional image digest */ public repositoryUriForDigest(digest?: string): string { const digestSuffix = digest ? `@${digest}` : ''; return this.repositoryUriWithSuffix(digestSuffix); } /** * Returns the URL of the repository. Can be used in `docker push/pull`. * * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[:TAG] * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[@DIGEST] * * @param tagOrDigest Optional image tag or digest (digests must start with `sha256:`) */ public repositoryUriForTagOrDigest(tagOrDigest?: string): string { if (tagOrDigest?.startsWith('sha256:')) { return this.repositoryUriForDigest(tagOrDigest); } else { return this.repositoryUriForTag(tagOrDigest); } } /** * Returns the repository URI, with an appended suffix, if provided. * @param suffix An image tag or an image digest. * @private */ private repositoryUriWithSuffix(suffix?: string): string { const parts = this.stack.splitArn(this.repositoryArn, ArnFormat.SLASH_RESOURCE_NAME); return `${parts.account}.dkr.ecr.${parts.region}.${this.stack.urlSuffix}/${this.repositoryName}${suffix}`; } /** * Define a CloudWatch event that triggers when something happens to this repository * * Requires that there exists at least one CloudTrail Trail in your account * that captures the event. This method will not create the Trail. * * @param id The id of the rule * @param options Options for adding the rule */ public onCloudTrailEvent(id: string, options: events.OnEventOptions = {}): events.Rule { const rule = new events.Rule(this, id, options); rule.addTarget(options.target); rule.addEventPattern({ source: ['aws.ecr'], detailType: ['AWS API Call via CloudTrail'], detail: { requestParameters: { repositoryName: [this.repositoryName], }, }, }); return rule; } /** * Defines an AWS CloudWatch event rule that can trigger a target when an image is pushed to this * repository. * * Requires that there exists at least one CloudTrail Trail in your account * that captures the event. This method will not create the Trail. * * @param id The id of the rule * @param options Options for adding the rule */ public onCloudTrailImagePushed(id: string, options: OnCloudTrailImagePushedOptions = {}): events.Rule { const rule = this.onCloudTrailEvent(id, options); rule.addEventPattern({ detail: { eventName: ['PutImage'], requestParameters: { imageTag: options.imageTag ? [options.imageTag] : undefined, }, }, }); return rule; } /** * Defines an AWS CloudWatch event rule that can trigger a target when an image scan is completed * * * @param id The id of the rule * @param options Options for adding the rule */ public onImageScanCompleted(id: string, options: OnImageScanCompletedOptions = {}): events.Rule { const rule = new events.Rule(this, id, options); rule.addTarget(options.target); rule.addEventPattern({ source: ['aws.ecr'], detailType: ['ECR Image Scan'], detail: { 'repository-name': [this.repositoryName], 'scan-status': ['COMPLETE'], 'image-tags': options.imageTags ?? undefined, }, }); return rule; } /** * Defines a CloudWatch event rule which triggers for repository events. Use * `rule.addEventPattern(pattern)` to specify a filter. */ public onEvent(id: string, options: events.OnEventOptions = {}) { const rule = new events.Rule(this, id, options); rule.addEventPattern({ source: ['aws.ecr'], detail: { 'repository-name': [this.repositoryName], }, }); rule.addTarget(options.target); return rule; } /** * Grant the given principal identity permissions to perform the actions on this repository */ public grant(grantee: iam.IGrantable, ...actions: string[]) { const crossAccountPrincipal = this.unsafeCrossAccountResourcePolicyPrincipal(grantee); if (crossAccountPrincipal) { // If the principal is from a different account, // that means addToPrincipalOrResource() will update the Resource Policy of this repo to trust that principal. // However, ECR verifies that the principal used in the Policy exists, // and will error out if it doesn't. // Because of that, if the principal is a newly created resource, // and there is not a dependency relationship between the Stacks of this repo and the principal, // trust the entire account of the principal instead // (otherwise, deploying this repo will fail). // To scope down the permissions as much as possible, // only trust principals from that account with a specific tag const crossAccountPrincipalStack = Stack.of(crossAccountPrincipal); const roleTag = `${crossAccountPrincipalStack.stackName}_${crossAccountPrincipal.node.addr}`; Tags.of(crossAccountPrincipal).add('aws-cdk:id', roleTag); this.addToResourcePolicy(new iam.PolicyStatement({ actions, principals: [new iam.AccountPrincipal(crossAccountPrincipalStack.account)], conditions: { StringEquals: { 'aws:PrincipalTag/aws-cdk:id': roleTag }, }, })); return iam.Grant.addToPrincipal({ grantee, actions, resourceArns: [this.repositoryArn], scope: this, }); } else { return iam.Grant.addToPrincipalOrResource({ grantee, actions, resourceArns: [this.repositoryArn], resourceSelfArns: [], resource: this, }); } } /** * Grant the given identity permissions to read the images in this repository */ public grantRead(grantee: iam.IGrantable): iam.Grant { return this.grant(grantee, 'ecr:DescribeRepositories', 'ecr:DescribeImages', ); } /** * Grant the given identity permissions to use the images in this repository */ public grantPull(grantee: iam.IGrantable) { const ret = this.grant(grantee, ...this.REPO_PULL_ACTIONS); iam.Grant.addToPrincipal({ grantee, actions: ['ecr:GetAuthorizationToken'], resourceArns: ['*'], scope: this, }); return ret; } /** * Grant the given identity permissions to use the images in this repository */ public grantPush(grantee: iam.IGrantable) { const ret = this.grant(grantee, ...this.REPO_PUSH_ACTIONS); iam.Grant.addToPrincipal({ grantee, actions: ['ecr:GetAuthorizationToken'], resourceArns: ['*'], scope: this, }); return ret; } /** * Grant the given identity permissions to pull and push images to this repository. */ public grantPullPush(grantee: iam.IGrantable) { const ret = this.grant(grantee, ...this.REPO_PULL_ACTIONS, ...this.REPO_PUSH_ACTIONS, ); iam.Grant.addToPrincipal({ grantee, actions: ['ecr:GetAuthorizationToken'], resourceArns: ['*'], scope: this, }); return ret; } /** * Returns the resource that backs the given IAM grantee if we cannot put a direct reference * to the grantee in the resource policy of this ECR repository, * and 'undefined' in case we can. */ private unsafeCrossAccountResourcePolicyPrincipal(grantee: iam.IGrantable): IConstruct | undefined { // A principal cannot be safely added to the Resource Policy of this ECR repository, if: // 1. The principal is from a different account, and // 2. The principal is a new resource (meaning, not just referenced), and // 3. The Stack this repo belongs to doesn't depend on the Stack the principal belongs to. // condition #1 const principal = grantee.grantPrincipal; const principalAccount = principal.principalAccount; if (!principalAccount) { return undefined; } const repoAndPrincipalAccountCompare = Token.compareStrings(this.env.account, principalAccount); if (repoAndPrincipalAccountCompare === TokenComparison.BOTH_UNRESOLVED || repoAndPrincipalAccountCompare === TokenComparison.SAME) { return undefined; } // condition #2 if (!iam.principalIsOwnedResource(principal)) { return undefined; } // condition #3 const principalStack = Stack.of(principal); if (this.stack.dependencies.includes(principalStack)) { return undefined; } return principal; } } /** * Options for the onCloudTrailImagePushed method */ export interface OnCloudTrailImagePushedOptions extends events.OnEventOptions { /** * Only watch changes to this image tag * * @default - Watch changes to all tags */ readonly imageTag?: string; } /** * Options for the OnImageScanCompleted method */ export interface OnImageScanCompletedOptions extends events.OnEventOptions { /** * Only watch changes to the image tags specified. * Leave it undefined to watch the full repository. * * @default - Watch the changes to the repository with all image tags */ readonly imageTags?: string[]; } export interface RepositoryProps { /** * Name for this repository. * * The repository name must start with a letter and can only contain lowercase letters, numbers, hyphens, underscores, and forward slashes. * * > If you specify a name, you cannot perform updates that require replacement of this resource. You can perform updates that require no or some interruption. If you must replace the resource, specify a new name. * * @default Automatically generated name. */ readonly repositoryName?: string; /** * The kind of server-side encryption to apply to this repository. * * If you choose KMS, you can specify a KMS key via `encryptionKey`. If * encryptionKey is not specified, an AWS managed KMS key is used. * * @default - `KMS` if `encryptionKey` is specified, or `AES256` otherwise. */ readonly encryption?: RepositoryEncryption; /** * External KMS key to use for repository encryption. * * The 'encryption' property must be either not specified or set to "KMS". * An error will be emitted if encryption is set to "AES256". * * @default - If encryption is set to `KMS` and this property is undefined, * an AWS managed KMS key is used. */ readonly encryptionKey?: kms.IKey; /** * Life cycle rules to apply to this registry * * @default No life cycle rules */ readonly 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. */ readonly lifecycleRegistryId?: string; /** * Determine what happens to the repository when the resource/stack is deleted. * * @default RemovalPolicy.Retain */ readonly removalPolicy?: RemovalPolicy; /** * Enable the scan on push when creating the repository * * @default false */ readonly imageScanOnPush?: boolean; /** * The tag mutability setting for the repository. If this parameter is omitted, the default setting of MUTABLE will be used which will allow image tags to be overwritten. * * @default TagMutability.MUTABLE */ readonly imageTagMutability?: TagMutability; /** * Whether all images should be automatically deleted when the repository is * removed from the stack or when the stack is deleted. * * Requires the `removalPolicy` to be set to `RemovalPolicy.DESTROY`. * * @default false * @deprecated Use `emptyOnDelete` instead. */ readonly autoDeleteImages?: boolean; /** * If true, deleting the repository force deletes the contents of the repository. If false, the repository must be empty before attempting to delete it. * * @default false */ readonly emptyOnDelete?: boolean; } /** * Properties for looking up an existing Repository. */ export interface RepositoryLookupOptions { /** * The name of the repository. * * @default - Do not filter on repository name */ readonly repositoryName?: string; /** * The ARN of the repository. * * @default - Do not filter on repository ARN */ readonly repositoryArn?: string; } export interface RepositoryAttributes { readonly repositoryName: string; readonly repositoryArn: string; } /** * Define an ECR repository */ export class Repository extends RepositoryBase { /** * Lookup an existing repository */ public static fromLookup(scope: Construct, id: string, options: RepositoryLookupOptions): IRepository { if (Token.isUnresolved(options.repositoryName) || Token.isUnresolved(options.repositoryArn)) { throw new UnscopedValidationError('Cannot look up a repository with a tokenized name or ARN.'); } if (!options.repositoryArn && !options.repositoryName) { throw new UnscopedValidationError('At least one of `repositoryName` or `repositoryArn` must be provided.'); } const identifier = options.repositoryName ?? (options.repositoryArn ? Arn.split(options.repositoryArn, ArnFormat.SLASH_RESOURCE_NAME).resourceName : undefined); if (!identifier) { throw new UnscopedValidationError('Could not determine repository identifier from provided options.'); } const response: {[key: string]: any}[] = ContextProvider.getValue(scope, { provider: cxschema.ContextProvider.CC_API_PROVIDER, props: { typeName: 'AWS::ECR::Repository', exactIdentifier: identifier, propertiesToReturn: ['Arn'], } as cxschema.CcApiContextQuery, dummyValue: [ { Arn: Stack.of(scope).formatArn({ service: 'ecr', region: 'us-east-1', account: '123456789012', resource: 'repository', resourceName: 'DUMMY_ARN', }), }, ], }).value; const repository = response[0]; const repositoryName = Arn.extractResourceName(repository.Arn, 'repository'); return this.fromRepositoryAttributes(scope, id, { repositoryName: repositoryName, repositoryArn: repository.Arn, }); } /** * Import a repository */ public static fromRepositoryAttributes(scope: Construct, id: string, attrs: RepositoryAttributes): IRepository { class Import extends RepositoryBase { public readonly repositoryName = attrs.repositoryName; public readonly repositoryArn = attrs.repositoryArn; public addToResourcePolicy(_statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { // dropped return { statementAdded: false }; } } return new Import(scope, id); } public static fromRepositoryArn(scope: Construct, id: string, repositoryArn: string): IRepository { // if repositoryArn is a token, the repository name is also required. this is because // repository names can include "/" (e.g. foo/bar/myrepo) and it is impossible to // parse the name from an ARN using CloudFormation's split/select. if (Token.isUnresolved(repositoryArn)) { throw new UnscopedValidationError('"repositoryArn" is a late-bound value, and therefore "repositoryName" is required. Use `fromRepositoryAttributes` instead'); } validateRepositoryArn(); const repositoryName = repositoryArn.split('/').slice(1).join('/'); class Import extends RepositoryBase { public repositoryName = repositoryName; public repositoryArn = repositoryArn; public addToResourcePolicy(_statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { // dropped return { statementAdded: false }; } } return new Import(scope, id, { environmentFromArn: repositoryArn, }); function validateRepositoryArn() { const splitArn = repositoryArn.split(':'); if (!splitArn[splitArn.length - 1].startsWith('repository/')) { throw new UnscopedValidationError(`Repository arn should be in the format 'arn:<PARTITION>:ecr:<REGION>:<ACCOUNT>:repository/<NAME>', got ${repositoryArn}.`); } } } public static fromRepositoryName(scope: Construct, id: string, repositoryName: string): IRepository { class Import extends RepositoryBase { public repositoryName = repositoryName; public repositoryArn = Repository.arnForLocalRepository(repositoryName, scope); public addToResourcePolicy(_statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { // dropped return { statementAdded: false }; } } return new Import(scope, id); } /** * Returns an ECR ARN for a repository that resides in the same account/region * as the current stack. */ public static arnForLocalRepository(repositoryName: string, scope: IConstruct, account?: string): string { return Stack.of(scope).formatArn({ account, service: 'ecr', resource: 'repository', resourceName: repositoryName, }); } private static validateRepositoryName(physicalName: string) { const repositoryName = physicalName; if (!repositoryName || Token.isUnresolved(repositoryName)) { // the name is a late-bound value, not a defined string, // so skip validation return; } const errors: string[] = []; // Rules codified from https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ecr-repository.html if (repositoryName.length < 2 || repositoryName.length > 256) { errors.push('Repository name must be at least 2 and no more than 256 characters'); } const isPatternMatch = /^(?:[a-z0-9]+(?:[._-][a-z0-9]+)*\/)*[a-z0-9]+(?:[._-][a-z0-9]+)*$/.test(repositoryName); if (!isPatternMatch) { errors.push('Repository name must start with a letter and can only contain lowercase letters, numbers, hyphens, underscores, periods and forward slashes'); } if (errors.length > 0) { throw new UnscopedValidationError(`Invalid ECR repository name (value: ${repositoryName})${EOL}${errors.join(EOL)}`); } } public readonly repositoryName: string; public readonly repositoryArn: string; private readonly lifecycleRules = new Array<LifecycleRule>(); private readonly registryId?: string; private policyDocument?: iam.PolicyDocument; private readonly _resource: CfnRepository; constructor(scope: Construct, id: string, props: RepositoryProps = {}) { super(scope, id, { physicalName: props.repositoryName, }); // Enhanced CDK Analytics Telemetry addConstructMetadata(this, props); Repository.validateRepositoryName(this.physicalName); const resource = new CfnRepository(this, 'Resource', { repositoryName: this.physicalName, // It says "Text", but they actually mean "Object". repositoryPolicyText: Lazy.any({ produce: () => this.policyDocument }), lifecyclePolicy: Lazy.any({ produce: () => this.renderLifecyclePolicy() }), imageScanningConfiguration: props.imageScanOnPush !== undefined ? { scanOnPush: props.imageScanOnPush } : undefined, imageTagMutability: props.imageTagMutability || undefined, encryptionConfiguration: this.parseEncryption(props), emptyOnDelete: props.emptyOnDelete, }); this._resource = resource; resource.applyRemovalPolicy(props.removalPolicy); this.registryId = props.lifecycleRegistryId; if (props.lifecycleRules) { props.lifecycleRules.forEach(this.addLifecycleRule.bind(this)); } this.repositoryName = this.getResourceNameAttribute(resource.ref); this.repositoryArn = this.getResourceArnAttribute(resource.attrArn, { service: 'ecr', resource: 'repository', resourceName: this.physicalName, }); if (props.emptyOnDelete && props.removalPolicy !== RemovalPolicy.DESTROY) { throw new ValidationError('Cannot use \'emptyOnDelete\' property on a repository without setting removal policy to \'DESTROY\'.', this); } else if (props.emptyOnDelete == undefined && props.autoDeleteImages) { if (props.removalPolicy !== RemovalPolicy.DESTROY) { throw new ValidationError('Cannot use \'autoDeleteImages\' property on a repository without setting removal policy to \'DESTROY\'.', this); } this.enableAutoDeleteImages(); } this.node.addValidation({ validate: () => this.policyDocument?.validateForResourcePolicy() ?? [] }); } /** * Add a policy statement to the repository's resource policy. * * While other resources policies in AWS either require or accept a resource section, * Cfn for ECR does not allow us to specify a resource policy. * It will fail if a resource section is present at all. */ @MethodMetadata() public addToResourcePolicy(statement: iam.PolicyStatement): iam.AddToResourcePolicyResult { if (statement.resources.length) { Annotations.of(this).addWarningV2('@aws-cdk/aws-ecr:noResourceStatements', 'ECR resource policy does not allow resource statements.'); } if (this.policyDocument === undefined) { this.policyDocument = new iam.PolicyDocument(); } this.policyDocument.addStatements(statement); return { statementAdded: true, policyDependable: this.policyDocument }; } /** * Add a life cycle rule to the repository * * Life cycle rules automatically expire images from the repository that match * certain conditions. */ @MethodMetadata() public addLifecycleRule(rule: LifecycleRule) { // Validate rule here so users get errors at the expected location if (rule.tagStatus === undefined) { rule = { ...rule, tagStatus: rule.tagPrefixList === undefined && rule.tagPatternList === undefined ? TagStatus.ANY : TagStatus.TAGGED }; } if (rule.tagStatus === TagStatus.TAGGED && (rule.tagPrefixList === undefined || rule.tagPrefixList.length === 0) && (rule.tagPatternList === undefined || rule.tagPatternList.length === 0) ) { throw new ValidationError('TagStatus.Tagged requires the specification of a tagPrefixList or a tagPatternList', this); } if (rule.tagStatus !== TagStatus.TAGGED && (rule.tagPrefixList !== undefined || rule.tagPatternList !== undefined)) { throw new ValidationError('tagPrefixList and tagPatternList can only be specified when tagStatus is set to Tagged', this); } if (rule.tagPrefixList !== undefined && rule.tagPatternList !== undefined) { throw new ValidationError('Both tagPrefixList and tagPatternList cannot be specified together in a rule', this); } if (rule.tagPatternList !== undefined) { rule.tagPatternList.forEach((pattern) => { const splitPatternLength = pattern.split('*').length; if (splitPatternLength > 5) { throw new ValidationError(`A tag pattern cannot contain more than four wildcard characters (*), pattern: ${pattern}, counts: ${splitPatternLength - 1}`, this); } }); } if ((rule.maxImageAge !== undefined) === (rule.maxImageCount !== undefined)) { throw new ValidationError(`Life cycle rule must contain exactly one of 'maxImageAge' and 'maxImageCount', got: ${JSON.stringify(rule)}`, this); } if (rule.tagStatus === TagStatus.ANY && this.lifecycleRules.filter(r => r.tagStatus === TagStatus.ANY).length > 0) { throw new ValidationError('Life cycle can only have one TagStatus.Any rule', this); } this.lifecycleRules.push({ ...rule }); } /** * Render the life cycle policy object */ private renderLifecyclePolicy(): CfnRepository.LifecyclePolicyProperty | undefined { const stack = Stack.of(this); let lifecyclePolicyText: any; if (this.lifecycleRules.length === 0 && !this.registryId) { return undefined; } if (this.lifecycleRules.length > 0) { lifecyclePolicyText = JSON.stringify(stack.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 ValidationError("Cannot combine prioritized TagStatus.Any rule with unprioritized rules. Remove rulePriority from the 'Any' rule.", this); } 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 ?? autoPrio++, }); } // Do validation on the final array--might still be wrong because the user supplied all prios, but incorrectly. validateAnyRuleLast(ret); return ret; } /** * Set up key properties and return the Repository encryption property from the * user's configuration. */ private parseEncryption(props: RepositoryProps): CfnRepository.EncryptionConfigurationProperty | undefined { // default based on whether encryptionKey is specified const encryptionType = props.encryption ?? (props.encryptionKey ? RepositoryEncryption.KMS : RepositoryEncryption.AES_256); // if encryption key is set, encryption must be set to KMS. if (encryptionType !== RepositoryEncryption.KMS && props.encryptionKey) { throw new ValidationError(`encryptionKey is specified, so 'encryption' must be set to KMS (value: ${encryptionType.value})`, this); } if (encryptionType === RepositoryEncryption.AES_256) { return undefined; } if (encryptionType === RepositoryEncryption.KMS) { return { encryptionType: 'KMS', kmsKey: props.encryptionKey?.keyArn, }; } throw new ValidationError(`Unexpected 'encryptionType': ${encryptionType}`, this); } private enableAutoDeleteImages() { const firstTime = Stack.of(this).node.tryFindChild(`${AUTO_DELETE_IMAGES_RESOURCE_TYPE}CustomResourceProvider`) === undefined; const provider = AutoDeleteImagesProvider.getOrCreateProvider(this, AUTO_DELETE_IMAGES_RESOURCE_TYPE, { useCfnResponseWrapper: false, description: `Lambda function for auto-deleting images in ${this.repositoryName} repository.`, }); if (firstTime) { // Use a iam policy to allow the custom resource to list & delete // images in the repository and the ability to get all repositories to find the arn needed on delete. provider.addToRolePolicy({ Effect: 'Allow', Action: [ 'ecr:BatchDeleteImage', 'ecr:DescribeRepositories', 'ecr:ListImages', 'ecr:ListTagsForResource', ], Resource: [`arn:${Aws.PARTITION}:ecr:${Stack.of(this).region}:${Stack.of(this).account}:repository/*`], Condition: { StringEquals: { ['ecr:ResourceTag/' + AUTO_DELETE_IMAGES_TAG]: 'true', }, }, }); } const customResource = new CustomResource(this, 'AutoDeleteImagesCustomResource', { resourceType: AUTO_DELETE_IMAGES_RESOURCE_TYPE, serviceToken: provider.serviceToken, properties: { RepositoryName: this.repositoryName, }, }); customResource.node.addDependency(this); // We also tag the repository to record the fact that we want it autodeleted. // The custom resource will check this tag before actually doing the delete. // Because tagging and untagging will ALWAYS happen before the CR is deleted, // we can set `autoDeleteImages: false` without the removal of the CR emptying // the repository as a side effect. Tags.of(this._resource).add(AUTO_DELETE_IMAGES_TAG, 'true'); } } 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 UnscopedValidationError(`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, tagPatternList: rule.tagPatternList, countType: rule.maxImageAge !== undefined ? CountType.SINCE_IMAGE_PUSHED : CountType.IMAGE_COUNT_MORE_THAN, countNumber: rule.maxImageAge?.toDays() ?? rule.maxImageCount, countUnit: rule.maxImageAge !== undefined ? 'days' : undefined, }, action: { type: 'expire', }, }; } /** * Select images based on counts */ enum CountType { /** * Set a limit on the number of images in your repository */ IMAGE_COUNT_MORE_THAN = 'imageCountMoreThan', /** * Set an age limit on the images in your repository */ SINCE_IMAGE_PUSHED = 'sinceImagePushed', } /** * The tag mutability setting for your repository. */ export enum TagMutability { /** * allow image tags to be overwritten. */ MUTABLE = 'MUTABLE', /** * all image tags within the repository will be immutable which will prevent them from being overwritten. */ IMMUTABLE = 'IMMUTABLE', } /** * Indicates whether server-side encryption is enabled for the object, and whether that encryption is * from the AWS Key Management Service (AWS KMS) or from Amazon S3 managed encryption (SSE-S3). * @see https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMetadata.html#SysMetadata */ export class RepositoryEncryption { /** * 'AES256' */ public static readonly AES_256 = new RepositoryEncryption('AES256'); /** * 'KMS' */ public static readonly KMS = new RepositoryEncryption('KMS'); /** * 'KMS_DSSE' */ public static readonly KMS_DSSE = new RepositoryEncryption('KMS_DSSE'); /** * @param value the string value of the encryption */ protected constructor(public readonly value: string) { } }