From 62ccdf0ca12afdd4a9e877784b6df3584c8c65a1 Mon Sep 17 00:00:00 2001 From: Lorenzo Rogai Date: Tue, 3 Feb 2026 18:48:20 +0100 Subject: [PATCH 1/2] 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 () => { From 238be817adcfbe8938fe57a4631e1bd85430eb94 Mon Sep 17 00:00:00 2001 From: Lorenzo Rogai Date: Thu, 26 Feb 2026 21:34:16 +0100 Subject: [PATCH 2/2] Update documentation --- docs/guides/functions.md | 5 ++++- docs/guides/serverless.yml.md | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/guides/functions.md b/docs/guides/functions.md index e02a3923f..74bb81ebf 100644 --- a/docs/guides/functions.md +++ b/docs/guides/functions.md @@ -293,6 +293,8 @@ Alternatively lambda environment can be configured through docker images. Image Serverless will create an ECR repository for your image, but it currently does not manage updates to it. An ECR repository is created only for new services or the first time that a function configured with an `image` is deployed. In service configuration, you can configure the ECR repository to scan for CVEs via the `provider.ecr.scanOnPush` property, which is `false` by default. (See [documentation](https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning.html)) +You can also configure an ECR lifecycle policy to automatically clean up old images by setting `provider.ecr.maxImageCount` to a positive integer. When set, images exceeding this count will be expired. (See [documentation](https://docs.aws.amazon.com/AmazonECR/latest/userguide/LifecyclePolicies.html)) + In service configuration, images can be configured via `provider.ecr.images`. To define an image that will be built locally, you need to specify `path` property, which should point to valid docker context directory. Optionally, you can also set `file` to specify Dockerfile that should be used when building an image. It is also possible to define images that already exist in AWS ECR repository. In order to do that, you need to define `uri` property, which should follow `.dkr.ecr..amazonaws.com/@` or `.dkr.ecr..amazonaws.com/:` format. Additionally, you can define arguments that will be passed to the `docker build` command via the following properties: @@ -313,6 +315,7 @@ provider: name: aws ecr: scanOnPush: true + maxImageCount: 10 images: baseimage: path: ./path/to/context @@ -382,7 +385,7 @@ functions: - flag ``` -During the first deployment when locally built images are used, Framework will automatically create a dedicated ECR repository to store these images, with name `serverless--`. Currently, the Framework will not remove older versions of images uploaded to ECR as they still might be in use by versioned functions. During `sls remove`, the created ECR repository will be removed. During deployment, Framework will attempt to `docker login` to ECR if needed. Depending on your local configuration, docker authorization token might be stored unencrypted. Please refer to documentation for more details: https://docs.docker.com/engine/reference/commandline/login/#credentials-store +During the first deployment when locally built images are used, Framework will automatically create a dedicated ECR repository to store these images, with name `serverless--`. By default, older versions of images uploaded to ECR are not removed as they still might be in use by versioned functions. To automatically expire old images, set `provider.ecr.maxImageCount` to limit the number of images retained in the repository. During `sls remove`, the created ECR repository will be removed. During deployment, Framework will attempt to `docker login` to ECR if needed. Depending on your local configuration, docker authorization token might be stored unencrypted. Please refer to documentation for more details: https://docs.docker.com/engine/reference/commandline/login/#credentials-store ## Instruction set architecture diff --git a/docs/guides/serverless.yml.md b/docs/guides/serverless.yml.md index a4b9f01b3..e10118041 100644 --- a/docs/guides/serverless.yml.md +++ b/docs/guides/serverless.yml.md @@ -378,6 +378,7 @@ Configure [deployment via Docker images](./functions.md#referencing-container-im provider: ecr: scanOnPush: true + maxImageCount: 10 # Max number of images to retain in ECR (enables lifecycle policy) # Definitions of images that later can be referenced by key in `function.image` images: baseimage: