From 62ccdf0ca12afdd4a9e877784b6df3584c8c65a1 Mon Sep 17 00:00:00 2001 From: Lorenzo Rogai Date: Tue, 3 Feb 2026 18:48:20 +0100 Subject: [PATCH] Add maxImageCount config to control ECR lifecycle policy --- lib/plugins/aws/provider.js | 39 ++++++++++++++++++- test/unit/lib/plugins/aws/provider.test.js | 44 ++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index 2bb29e701..640d68293 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -1143,6 +1143,7 @@ class AwsProvider { type: 'object', properties: { scanOnPush: { type: 'boolean' }, + maxImageCount: { type: 'integer', minimum: 1 }, images: { type: 'object', patternProperties: { @@ -2337,7 +2338,7 @@ Object.defineProperties( { promise: true } ), getOrCreateEcrRepository: d( - async function (scanOnPush) { + async function (scanOnPush, maxImageCount) { const registryId = await this.getAccountId(); const repositoryName = this.naming.getEcrRepositoryName(); let repositoryUri; @@ -2357,6 +2358,27 @@ Object.defineProperties( }); repositoryUri = result.repository.repositoryUri; } + + // Set ECR Lifecycle policy. See https://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html + if (maxImageCount > 0) { + await this.request('ECR', 'putLifecyclePolicy', { + repositoryName, + lifecyclePolicyText: JSON.stringify({ + rules: [ + { + rulePriority: 1, + action: { type: 'expire' }, + selection: { + tagStatus: 'any', + countType: 'imageCountMoreThan', + countNumber: maxImageCount, + }, + }, + ], + }), + }); + } + return { repositoryUri, repositoryName, @@ -2375,6 +2397,7 @@ Object.defineProperties( platform, provenance, scanOnPush, + maxImageCount, }) { const imageProgress = progress.get(`containerImage:${imageName}`); await this.ensureDockerIsAvailable(); @@ -2395,7 +2418,10 @@ Object.defineProperties( ); } - const { repositoryUri, repositoryName } = await this.getOrCreateEcrRepository(scanOnPush); + const { repositoryUri, repositoryName } = await this.getOrCreateEcrRepository( + scanOnPush, + maxImageCount + ); const localTag = `${repositoryName}:${imageName}`; const remoteTag = `${repositoryUri}:${imageName}`; @@ -2561,6 +2587,7 @@ Object.defineProperties( const defaultScanOnPush = false; const defaultPlatform = ''; const defaultProvenance = ''; + const defaultMaxImageCount = 0; if (imageUri) { return await this.resolveImageUriAndShaFromUri(imageUri); @@ -2577,6 +2604,12 @@ Object.defineProperties( defaultScanOnPush ); + const maxImageCountProvider = _.get( + this.serverless.service.provider, + 'ecr.maxImageCount', + defaultMaxImageCount + ); + if (!imageDefinedInProvider) { throw new ServerlessError( `Referenced "${imageName}" not defined in "provider.ecr.images"`, @@ -2638,6 +2671,7 @@ Object.defineProperties( platform: imageDefinedInProvider.platform || defaultPlatform, provenance: imageDefinedInProvider.provenance || defaultProvenance, scanOnPush: imageScanDefinedInProvider, + maxImageCount: maxImageCountProvider, }); } return await this.resolveImageUriAndShaFromUri(imageDefinedInProvider.uri); @@ -2655,6 +2689,7 @@ Object.defineProperties( platform: imageDefinedInProvider.platform || defaultPlatform, provenance: imageDefinedInProvider.provenance || defaultProvenance, scanOnPush: imageScanDefinedInProvider, + maxImageCount: maxImageCountProvider, }); }, { promise: true } diff --git a/test/unit/lib/plugins/aws/provider.test.js b/test/unit/lib/plugins/aws/provider.test.js index 1dba40617..0945d2aa9 100644 --- a/test/unit/lib/plugins/aws/provider.test.js +++ b/test/unit/lib/plugins/aws/provider.test.js @@ -1255,6 +1255,7 @@ aws_secret_access_key = CUSTOMSECRET const describeRepositoriesStub = sinon.stub(); const createRepositoryStub = sinon.stub(); const createRepositoryStubScanOnPush = sinon.stub(); + const putLifecyclePolicyStub = sinon.stub(); const baseAwsRequestStubMap = { STS: { getCallerIdentity: { @@ -1420,6 +1421,49 @@ aws_secret_access_key = CUSTOMSECRET expect(versionCfConfig.CodeSha256).to.equal(imageSha); expect(describeRepositoriesStub).to.be.calledOnce; expect(createRepositoryStub).to.be.calledOnce; + expect(putLifecyclePolicyStub).to.not.have.been.called; + }); + + it('should set ECR lifecycle policy correctly', async () => { + const awsRequestStubMap = { + ...baseAwsRequestStubMap, + ECR: { + ...baseAwsRequestStubMap.ECR, + describeRepositories: describeRepositoriesStub.throws({ + providerError: { code: 'RepositoryNotFoundException' }, + }), + createRepository: createRepositoryStub.resolves({ repository: { repositoryUri } }), + putLifecyclePolicy: putLifecyclePolicyStub.resolves(), + }, + }; + + await runServerless({ + fixture: 'ecr', + command: 'package', + awsRequestStubMap, + modulesCacheStub, + configExt: { + provider: { + ecr: { + maxImageCount: 10, + }, + }, + }, + }); + + expect(JSON.parse(putLifecyclePolicyStub.args[0][0].lifecyclePolicyText)).to.deep.equal({ + rules: [ + { + rulePriority: 1, + action: { type: 'expire' }, + selection: { + tagStatus: 'any', + countType: 'imageCountMoreThan', + countNumber: 10, + }, + }, + ], + }); }); it('should login and retry when docker push fails with no basic auth credentials error', async () => {