diff --git a/docs/_generated/integ-last-run.tsv b/docs/_generated/integ-last-run.tsv index 632f6b333..cd9dc4304 100644 --- a/docs/_generated/integ-last-run.tsv +++ b/docs/_generated/integ-last-run.tsv @@ -89,7 +89,7 @@ ec2-instance 2026-07-30T06:37:46Z PASS 180 verify.sh #1281 NetworkInterfaces ins ec2-instance-fanout 2026-07-30T06:24:02Z PASS 150 verify.sh new fixture (#1292): 10-inst cold fan-out converged, 13 propagation retries, 0 throttles, 32s deploy ec2-vpc 2026-07-26T17:21:15Z PASS 35 standard issue 1241 fix verified: explicit DESTROY FlowLog LogGroup; 20 deleted 0 retained 0 orphans ecr 2026-07-26T18:36:31Z PASS 85 standard 0727b sweep-b4 staleness re-run (rc=0); account clean -ecr-scanning 2026-08-08T16:24:26Z PASS 52 verify.sh P0 post-#1345 ECR force-delete opt-in; 3 phases ok, 2 del/0 err, 0 orphans +ecr-scanning 2026-08-09T06:01:13Z PASS 55 verify.sh #1392 exclusion-filter create + filters-only update + filterType asserts; 2 del/0 err, 0 orphans ecs-fargate 2026-07-30T11:54:49Z PASS 420 verify.sh #1280 live-test: --full-wait waiter w/ new max(600,resolved) cap ran on create+update; 23 del/0 err, orph clean ecs-schedule-targets 2026-08-08T19:04:46Z PASS 60 verify.sh re-run with drift assert after review fixes; clean ecs-service-update-props 2026-07-23T08:20:10Z PASS verify.sh #1173 re-run after VersionConsistency norm; Phase1f + drift clean; destroy 15 del 0 err 0 orph diff --git a/src/provisioning/providers/ecr-provider.ts b/src/provisioning/providers/ecr-provider.ts index c8dd7bcba..b4e3f016d 100644 --- a/src/provisioning/providers/ecr-provider.ts +++ b/src/provisioning/providers/ecr-provider.ts @@ -19,6 +19,7 @@ import { type ImageScanningConfiguration, type EncryptionConfiguration, type ImageTagMutability, + type ImageTagMutabilityExclusionFilter, type Tag, } from '@aws-sdk/client-ecr'; import { getLogger } from '../../utils/logger.js'; @@ -114,6 +115,55 @@ export class ECRProvider implements ResourceProvider { return out; } + /** + * Map CFn `ImageTagMutabilityExclusionFilters` + * (`[{ ImageTagMutabilityExclusionFilterType, ImageTagMutabilityExclusionFilterValue }]`) + * to the SDK shape (`[{ filterType, filter }]`). The member NAMES diverge — + * not just their casing — so forwarding the CFn-shaped array verbatim makes + * the SDK drop every member and send `[{}]`, and AWS rejects the call (or, + * worse, silently loses the exclusions). Returns `undefined` for an absent / + * empty list so the caller can omit the field entirely. + */ + private toSdkTagMutabilityExclusionFilters( + cfn: unknown + ): ImageTagMutabilityExclusionFilter[] | undefined { + if (!Array.isArray(cfn) || cfn.length === 0) return undefined; + return cfn.map((entry) => { + const e = (entry ?? {}) as Record; + return { + filterType: e['ImageTagMutabilityExclusionFilterType'] as + | ImageTagMutabilityExclusionFilter['filterType'] + | undefined, + filter: e['ImageTagMutabilityExclusionFilterValue'] as string | undefined, + }; + }); + } + + /** + * Inverse of {@link toSdkTagMutabilityExclusionFilters}: SDK + * `[{ filterType, filter }]` back to the CFn property shape, so + * `cdkd drift` compares the AWS-current exclusions against the + * template-shaped baseline instead of a guaranteed false positive. + * Returns `undefined` for an absent / empty list so `readCurrentState` + * omits the key (a repository with no exclusions). + */ + private toCfnTagMutabilityExclusionFilters( + sdk: Array<{ filterType?: string; filter?: string }> | undefined + ): Array> | undefined { + if (!sdk || sdk.length === 0) return undefined; + // Undefined-valued keys are omitted rather than emitted: S3 drops them when + // `observedProperties` is serialized, so a later drift read that DID carry + // them would differ by key count and report phantom drift. Both members are + // required in the SDK model, so this is defense against a shape AWS should + // never return. + return sdk.map((f) => ({ + ...(f.filterType !== undefined && { + ImageTagMutabilityExclusionFilterType: f.filterType, + }), + ...(f.filter !== undefined && { ImageTagMutabilityExclusionFilterValue: f.filter }), + })); + } + /** * Create an ECR Repository */ @@ -138,6 +188,9 @@ export class ECRProvider implements ResourceProvider { const encryptionConfig = this.toSdkEncryptionConfig( properties['EncryptionConfiguration'] as Record | undefined ); + const exclusionFilters = this.toSdkTagMutabilityExclusionFilters( + properties['ImageTagMutabilityExclusionFilters'] + ); const response = await this.getClient().send( new CreateRepositoryCommand({ @@ -148,6 +201,7 @@ export class ECRProvider implements ResourceProvider { imageTagMutability: properties['ImageTagMutability'] as ImageTagMutability, } : {}), + ...(exclusionFilters ? { imageTagMutabilityExclusionFilters: exclusionFilters } : {}), ...(encryptionConfig ? { encryptionConfiguration: encryptionConfig } : {}), ...(tags ? { tags } : {}), }) @@ -216,7 +270,8 @@ export class ECRProvider implements ResourceProvider { * Update an ECR Repository * * Mutable properties: ImageScanningConfiguration, ImageTagMutability, - * LifecyclePolicy, RepositoryPolicyText, Tags. + * ImageTagMutabilityExclusionFilters, LifecyclePolicy, RepositoryPolicyText, + * Tags. * Immutable: RepositoryName, EncryptionConfiguration (require replacement). */ async update( @@ -251,16 +306,41 @@ export class ECRProvider implements ResourceProvider { this.logger.debug(`Updated image scanning configuration for ${physicalId}`); } - // Update ImageTagMutability if changed + // Update ImageTagMutability / ImageTagMutabilityExclusionFilters if + // changed. Both members ride the SAME PutImageTagMutability call, so the + // exclusion filters must be able to fire it on their own — a filters-only + // edit (`IMMUTABLE_WITH_EXCLUSION` throughout, only the filter values + // changing) would otherwise never reach AWS. `imageTagMutability` is a + // REQUIRED member of that request, so the filters-only case re-sends the + // current mutability value alongside the new filters. const newMutability = properties['ImageTagMutability'] as ImageTagMutability | undefined; const oldMutability = previousProperties['ImageTagMutability'] as | ImageTagMutability | undefined; - if (newMutability !== oldMutability) { + const newExclusionFilters = this.toSdkTagMutabilityExclusionFilters( + properties['ImageTagMutabilityExclusionFilters'] + ); + const oldExclusionFilters = this.toSdkTagMutabilityExclusionFilters( + previousProperties['ImageTagMutabilityExclusionFilters'] + ); + if ( + newMutability !== oldMutability || + JSON.stringify(newExclusionFilters) !== JSON.stringify(oldExclusionFilters) + ) { await this.getClient().send( new PutImageTagMutabilityCommand({ repositoryName: physicalId, imageTagMutability: newMutability ?? 'MUTABLE', + // Omitted on removal, on the expectation that + // PutImageTagMutability is a full-replace setter rather than a + // patch. NOT probed against real AWS, because the case is not + // reachable: CFn/CDK reject filters without a `*_WITH_EXCLUSION` + // mode and reject an exclusion mode without filters, so a removal + // always rides a mutability change to a non-exclusion mode, where + // any surviving filters are inert. + ...(newExclusionFilters + ? { imageTagMutabilityExclusionFilters: newExclusionFilters } + : {}), }) ); this.logger.debug(`Updated image tag mutability for ${physicalId}`); @@ -488,17 +568,20 @@ export class ECRProvider implements ResourceProvider { * (which `DescribeRepositories` doesn't return). * * Surfaced keys: `RepositoryName`, `ImageTagMutability`, - * `ImageScanningConfiguration`, `EncryptionConfiguration`, `LifecyclePolicy` - * (when configured — `LifecyclePolicyNotFoundException` is caught and the - * key omitted, NOT propagated as repo-gone). + * `ImageTagMutabilityExclusionFilters` (when the repository has any — + * `DescribeRepositories` returns them on the `Repository` shape, mapped back + * to the CFn member names), `ImageScanningConfiguration`, + * `EncryptionConfiguration`, `LifecyclePolicy` (when configured — + * `LifecyclePolicyNotFoundException` is caught and the key omitted, NOT + * propagated as repo-gone). * * Intentionally omitted: * - `RepositoryPolicyText`: requires a separate `GetRepositoryPolicy` * round-trip; cdkd state holds the policy as either a string or an * object (depending on user input), and the comparator round-trip * is not yet handled here. - * - `EmptyOnDelete` / `ImageTagMutabilityExclusionFilters`: not part - * of the persisted AWS state visible via standard Describe. + * - `EmptyOnDelete`: a cdkd/CDK delete-time intent flag, not part of the + * persisted AWS state visible via standard Describe. * * `Tags` is surfaced via a follow-up `ListTagsForResource(arn)` call * (using the repository ARN that `DescribeRepositories` returns). CDK's @@ -517,6 +600,7 @@ export class ECRProvider implements ResourceProvider { repositoryName?: string; repositoryArn?: string; imageTagMutability?: string; + imageTagMutabilityExclusionFilters?: Array<{ filterType?: string; filter?: string }>; imageScanningConfiguration?: { scanOnPush?: boolean }; encryptionConfiguration?: { encryptionType?: string; kmsKey?: string }; }>; @@ -535,6 +619,12 @@ export class ECRProvider implements ResourceProvider { const result: Record = {}; if (r.repositoryName !== undefined) result['RepositoryName'] = r.repositoryName; if (r.imageTagMutability !== undefined) result['ImageTagMutability'] = r.imageTagMutability; + const cfnExclusionFilters = this.toCfnTagMutabilityExclusionFilters( + r.imageTagMutabilityExclusionFilters + ); + if (cfnExclusionFilters) { + result['ImageTagMutabilityExclusionFilters'] = cfnExclusionFilters; + } result['ImageScanningConfiguration'] = { ScanOnPush: r.imageScanningConfiguration?.scanOnPush ?? false, }; diff --git a/tests/integration/ecr-scanning/lib/ecr-scanning-stack.ts b/tests/integration/ecr-scanning/lib/ecr-scanning-stack.ts index e6bf01ac7..4fe6da9fe 100644 --- a/tests/integration/ecr-scanning/lib/ecr-scanning-stack.ts +++ b/tests/integration/ecr-scanning/lib/ecr-scanning-stack.ts @@ -15,13 +15,25 @@ import * as kms from 'aws-cdk-lib/aws-kms'; * true` never reached AWS. This fixture asserts scanOnPush actually reaches AWS * on create AND that toggling it off via UPDATE reaches AWS. * + * ALSO covers issue #1392: `ImageTagMutabilityExclusionFilters` was declared in + * `handledProperties` but never sent — absent from `CreateRepository` and from + * update's `PutImageTagMutability` — so a repository's exclusions silently + * vanished, and a filters-only edit never reached AWS at all (the update fired + * on `ImageTagMutability` alone). The member names diverge too (CFn + * `ImageTagMutabilityExclusionFilterType` / `...Value` vs SDK `filterType` / + * `filter`), so the blob cannot be forwarded verbatim. + * * Phase 1 (no env): scanOnPush true + a lifecycle rule + two Tags - * (`env=dev`, `team=platform`). + * (`env=dev`, `team=platform`) + IMMUTABLE_WITH_EXCLUSION with one + * exclusion filter (`dev-*`). * Phase 2 (CDKD_TEST_UPDATE=true): scanOnPush false; the `env` tag value is * CHANGED to `prod` and the `team` tag is REMOVED. This exercises the * update() tag-diff: `ECRProvider.update()` used to call `TagResourceCommand` * only (additive), so a removed tag survived on AWS (issue #981). The fix * untags the removed key(s) via `UntagResourceCommand` before re-tagging. + * The exclusion filters change to two DIFFERENT patterns while + * `imageTagMutability` stays IMMUTABLE_WITH_EXCLUSION — a filters-ONLY edit, + * which is precisely the case the pre-#1392 update() gate skipped. */ export class EcrScanningStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { @@ -38,10 +50,22 @@ export class EcrScanningStack extends cdk.Stack { pendingWindow: cdk.Duration.days(7), }); + // Phase 1: one exclusion filter. Phase 2: two DIFFERENT patterns, with + // imageTagMutability unchanged — the filters-only update that pre-#1392 + // never reached AWS, because both members ride the same + // PutImageTagMutability call and only a mutability change could fire it. + const exclusionFilters = isUpdate + ? [ + ecr.ImageTagMutabilityExclusionFilter.wildcard('release-v*'), + ecr.ImageTagMutabilityExclusionFilter.wildcard('hotfix-*'), + ] + : [ecr.ImageTagMutabilityExclusionFilter.wildcard('dev-*')]; + const repo = new ecr.Repository(this, 'Repo', { repositoryName: `${this.stackName.toLowerCase()}-repo`, imageScanOnPush: !isUpdate, - imageTagMutability: ecr.TagMutability.IMMUTABLE, + imageTagMutability: ecr.TagMutability.IMMUTABLE_WITH_EXCLUSION, + imageTagMutabilityExclusionFilters: exclusionFilters, encryption: ecr.RepositoryEncryption.KMS, encryptionKey: key, lifecycleRules: [{ description: 'keep last 5', maxImageCount: 5 }], diff --git a/tests/integration/ecr-scanning/verify.sh b/tests/integration/ecr-scanning/verify.sh index a610a16ce..e067b92f4 100755 --- a/tests/integration/ecr-scanning/verify.sh +++ b/tests/integration/ecr-scanning/verify.sh @@ -12,12 +12,23 @@ # TagResourceCommand only (additive), so a tag removed from the template # survived on AWS. The fix untags removed keys via UntagResourceCommand. # +# ALSO covers issue #1392: ImageTagMutabilityExclusionFilters was declared in +# handledProperties but sent on NO API call — omitted from CreateRepository and +# from update's PutImageTagMutability — so the exclusions silently vanished +# while the property-coverage pre-flight passed on the false handled claim. The +# member names diverge as well (CFn ImageTagMutabilityExclusionFilterType / +# ...Value vs SDK filterType / filter). The update gate also fired on +# ImageTagMutability alone, so a filters-ONLY edit never reached AWS. +# # Phases: -# 1. Deploy with imageScanOnPush=true + Tags env=dev, team=platform. Assert -# AWS reports scanOnPush=true and both tags are present. +# 1. Deploy with imageScanOnPush=true + Tags env=dev, team=platform + +# IMMUTABLE_WITH_EXCLUSION with one exclusion filter (dev-*). Assert AWS +# reports scanOnPush=true, both tags, and the filter. # 2. Re-deploy (CDKD_TEST_UPDATE=true) with imageScanOnPush=false, env changed -# to prod, team REMOVED. Assert scanOnPush=false, env=prod, team untagged, -# and exactly one user tag remains. +# to prod, team REMOVED, and the exclusion filters changed to two DIFFERENT +# patterns with imageTagMutability UNCHANGED. Assert scanOnPush=false, +# env=prod, team untagged, exactly one user tag remains, and the new +# filters reached AWS (the filters-only update path). # 3. Destroy + assert the repo is gone and the cdkd state file is removed. # # Required env vars: STATE_BUCKET; AWS_REGION (defaults us-east-1). @@ -96,6 +107,30 @@ scan_on_push() { --query 'repositories[0].imageScanningConfiguration.scanOnPush' --output text } +tag_mutability() { + aws ecr describe-repositories --repository-names "${REPO}" --region "${REGION}" \ + --query 'repositories[0].imageTagMutability' --output text +} + +# The exclusion filter PATTERNS, sorted and space-joined. AWS does not preserve +# the submitted order of list-valued members on readback, so both sides of the +# comparison are sorted (the null-list coalesce keeps an empty list from +# aborting the script under set -e). +exclusion_filter_patterns() { + aws ecr describe-repositories --repository-names "${REPO}" --region "${REGION}" \ + --query "join(' ', sort(repositories[0].imageTagMutabilityExclusionFilters[].filter || \`[]\`))" \ + --output text +} + +# The filter types, sorted and space-joined (one entry per filter, no dedupe) — +# proves the diverging member name (CFn ImageTagMutabilityExclusionFilterType -> +# SDK filterType) reached AWS rather than arriving as an empty object. +exclusion_filter_types() { + aws ecr describe-repositories --repository-names "${REPO}" --region "${REGION}" \ + --query "join(' ', sort(repositories[0].imageTagMutabilityExclusionFilters[].filterType || \`[]\`))" \ + --output text +} + # Read a single tag's value via list-tags-for-resource. Emits the value or # 'NONE' when the key is absent (JMESPath `[?Key==...] | [0].Value` -> null -> # printed as literal "None" by --output text; we normalize to NONE for the @@ -148,6 +183,17 @@ echo " tags (Phase 1): env=${ENVTAG1} team=${TEAMTAG1}" [ "${TEAMTAG1}" = "platform" ] || { echo "FAIL: expected team=platform on create, got '${TEAMTAG1}'" >&2; exit 1; } echo " both tags reached AWS on create" +# ImageTagMutabilityExclusionFilters on create (issue #1392). Pre-fix the +# property never reached CreateRepository at all. +MUT1="$(tag_mutability)" +FILTERS1="$(exclusion_filter_patterns)" +FTYPES1="$(exclusion_filter_types)" +echo " mutability (Phase 1): ${MUT1} filters=[${FILTERS1}] types=[${FTYPES1}]" +[ "${MUT1}" = "IMMUTABLE_WITH_EXCLUSION" ] || { echo "FAIL: expected imageTagMutability=IMMUTABLE_WITH_EXCLUSION, got '${MUT1}'" >&2; exit 1; } +[ "${FILTERS1}" = "dev-*" ] || { echo "FAIL: expected exclusion filter 'dev-*' on create, got '${FILTERS1}'" >&2; exit 1; } +[ "${FTYPES1}" = "WILDCARD" ] || { echo "FAIL: expected filterType WILDCARD on create, got '${FTYPES1}'" >&2; exit 1; } +echo " exclusion filters reached AWS on create" + # --- Phase 2: UPDATE scanOnPush=false --------------------------------- echo "==> Phase 2: re-deploy with imageScanOnPush=false (UPDATE)" CDKD_TEST_UPDATE=true node "${LOCAL_DIST}" deploy "${STACK}" \ @@ -169,6 +215,19 @@ echo " tags (Phase 2): env=${ENVTAG2} team=${TEAMTAG2} user_tag_count=${UTC2} [ "${UTC2}" = "1" ] || { echo "FAIL: expected exactly 1 user tag after update (env only), got '${UTC2}'" >&2; exit 1; } echo " removed tag untagged + changed tag updated on AWS" +# Filters-ONLY update (issue #1392): imageTagMutability is identical across +# both phases, so pre-fix the PutImageTagMutability call never fired and the +# Phase 1 filter survived on AWS. +MUT2="$(tag_mutability)" +FILTERS2="$(exclusion_filter_patterns)" +FTYPES2="$(exclusion_filter_types)" +echo " mutability (Phase 2): ${MUT2} filters=[${FILTERS2}] types=[${FTYPES2}]" +[ "${MUT2}" = "IMMUTABLE_WITH_EXCLUSION" ] || { echo "FAIL: expected imageTagMutability to stay IMMUTABLE_WITH_EXCLUSION, got '${MUT2}'" >&2; exit 1; } +[ "${FILTERS2}" = "hotfix-* release-v*" ] || { echo "FAIL: expected the changed exclusion filters 'hotfix-* release-v*' after a filters-only update, got '${FILTERS2}'" >&2; exit 1; } +# One entry per filter (no dedupe), so both mapped entries must carry the type. +[ "${FTYPES2}" = "WILDCARD WILDCARD" ] || { echo "FAIL: expected both filters to carry filterType WILDCARD after update, got '${FTYPES2}'" >&2; exit 1; } +echo " filters-only update reached AWS" + # --- Phase 3: destroy -------------------------------------------------- echo "==> Phase 3: destroy" node "${LOCAL_DIST}" destroy "${STACK}" --state-bucket "${STATE_BUCKET}" --region "${REGION}" --force @@ -178,4 +237,4 @@ echo " repo deleted" assert_gone "state file still exists after destroy" aws s3api head-object --bucket "${STATE_BUCKET}" --key "${STATE_KEY}" echo " cdkd state removed" -echo "[verify] PASS — ECR scanOnPush (CFn->SDK casing) + tag add/change/untag on update reach AWS, 3 phases passed" +echo "[verify] PASS — ECR scanOnPush (CFn->SDK casing) + tag add/change/untag on update + ImageTagMutabilityExclusionFilters on create and on a filters-only update reach AWS, 3 phases passed" diff --git a/tests/unit/provisioning/providers/ecr-provider.test.ts b/tests/unit/provisioning/providers/ecr-provider.test.ts index 45a568024..7fe86eb47 100644 --- a/tests/unit/provisioning/providers/ecr-provider.test.ts +++ b/tests/unit/provisioning/providers/ecr-provider.test.ts @@ -34,7 +34,10 @@ vi.mock('../../../../src/utils/logger.js', () => { import { ECRProvider } from '../../../../src/provisioning/providers/ecr-provider.js'; import { + CreateRepositoryCommand, DescribeRepositoriesCommand, + LifecyclePolicyNotFoundException, + PutImageTagMutabilityCommand, RepositoryNotFoundException, TagResourceCommand, UntagResourceCommand, @@ -203,3 +206,343 @@ describe('ECRProvider update Tags', () => { expect(tagCall).toBeUndefined(); }); }); + +// Issue #1392: `ImageTagMutabilityExclusionFilters` was declared in +// `handledProperties` but never sent on any API call, so an +// `IMMUTABLE_WITH_EXCLUSION` repository silently lost its exclusions while the +// property-coverage pre-flight passed. The CFn member names +// (`ImageTagMutabilityExclusionFilterType` / `...Value`) diverge from the SDK's +// (`filterType` / `filter`), so the mapping has to be explicit. +describe('ECRProvider ImageTagMutabilityExclusionFilters', () => { + let provider: ECRProvider; + const repoArn = 'arn:aws:ecr:us-east-1:123456789012:repository/my-repo'; + + beforeEach(() => { + vi.clearAllMocks(); + provider = new ECRProvider(); + }); + + // CreateRepository answers with `repository` (singular); DescribeRepositories + // with `repositories`. Both shapes are returned so one default mock serves + // the create and the update paths. + function mockRepoResponses() { + const repo = { + repositoryName: 'my-repo', + repositoryArn: repoArn, + repositoryUri: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-repo', + }; + mockSend.mockResolvedValue({ repository: repo, repositories: [repo] }); + } + + it('create maps the CFn filters to the SDK member names', async () => { + mockRepoResponses(); + + await provider.create('MyRepo', 'AWS::ECR::Repository', { + RepositoryName: 'my-repo', + ImageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ], + }); + + const createCall = mockSend.mock.calls.find( + (c) => c[0] instanceof CreateRepositoryCommand + ); + expect(createCall).toBeDefined(); + expect(createCall![0].input.imageTagMutability).toBe('IMMUTABLE_WITH_EXCLUSION'); + expect(createCall![0].input.imageTagMutabilityExclusionFilters).toEqual([ + { filterType: 'WILDCARD', filter: 'dev-*' }, + ]); + }); + + it('create omits the key entirely when the property is absent', async () => { + mockRepoResponses(); + + await provider.create('MyRepo', 'AWS::ECR::Repository', { + RepositoryName: 'my-repo', + ImageTagMutability: 'IMMUTABLE', + }); + + const createCall = mockSend.mock.calls.find( + (c) => c[0] instanceof CreateRepositoryCommand + ); + expect(createCall).toBeDefined(); + expect(createCall![0].input).not.toHaveProperty('imageTagMutabilityExclusionFilters'); + }); + + it('create omits the key when the property is an empty array', async () => { + mockRepoResponses(); + + await provider.create('MyRepo', 'AWS::ECR::Repository', { + RepositoryName: 'my-repo', + ImageTagMutabilityExclusionFilters: [], + }); + + const createCall = mockSend.mock.calls.find( + (c) => c[0] instanceof CreateRepositoryCommand + ); + expect(createCall).toBeDefined(); + expect(createCall![0].input).not.toHaveProperty('imageTagMutabilityExclusionFilters'); + }); + + it('update sends the mapped filters alongside a changed mutability value', async () => { + mockRepoResponses(); + + await provider.update( + 'MyRepo', + 'my-repo', + 'AWS::ECR::Repository', + { + ImageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ], + }, + { ImageTagMutability: 'IMMUTABLE' } + ); + + const putCall = mockSend.mock.calls.find( + (c) => c[0] instanceof PutImageTagMutabilityCommand + ); + expect(putCall).toBeDefined(); + expect(putCall![0].input).toEqual({ + repositoryName: 'my-repo', + imageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + imageTagMutabilityExclusionFilters: [{ filterType: 'WILDCARD', filter: 'dev-*' }], + }); + }); + + // The filters ride the same PutImageTagMutability call as the mutability + // value, so a filters-only edit must still fire it — and re-send the + // unchanged (required) mutability value. + it('update fires on a filters-only change with the mutability value unchanged', async () => { + mockRepoResponses(); + + await provider.update( + 'MyRepo', + 'my-repo', + 'AWS::ECR::Repository', + { + ImageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'staging-*', + }, + ], + }, + { + ImageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ], + } + ); + + const putCall = mockSend.mock.calls.find( + (c) => c[0] instanceof PutImageTagMutabilityCommand + ); + expect(putCall).toBeDefined(); + expect(putCall![0].input).toEqual({ + repositoryName: 'my-repo', + imageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + imageTagMutabilityExclusionFilters: [{ filterType: 'WILDCARD', filter: 'staging-*' }], + }); + }); + + // A removal drops the key: PutImageTagMutability is a full-replace setter, + // so an omitted list clears the repository's exclusions. + it('update omits the key when the filters are removed from the template', async () => { + mockRepoResponses(); + + await provider.update( + 'MyRepo', + 'my-repo', + 'AWS::ECR::Repository', + { ImageTagMutability: 'IMMUTABLE' }, + { + ImageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ], + } + ); + + const putCall = mockSend.mock.calls.find( + (c) => c[0] instanceof PutImageTagMutabilityCommand + ); + expect(putCall).toBeDefined(); + expect(putCall![0].input).not.toHaveProperty('imageTagMutabilityExclusionFilters'); + }); + + // The two cases above always co-change ImageTagMutability, so the mutability + // half of the gate would fire the call on its own. These two isolate the + // filters half: the mode is identical across both sides, so ONLY the filter + // diff can trigger PutImageTagMutability. + it('update fires and omits the key when the filters are removed with the mode unchanged', async () => { + mockRepoResponses(); + + await provider.update( + 'MyRepo', + 'my-repo', + 'AWS::ECR::Repository', + { ImageTagMutability: 'MUTABLE_WITH_EXCLUSION' }, + { + ImageTagMutability: 'MUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ], + } + ); + + const putCall = mockSend.mock.calls.find( + (c) => c[0] instanceof PutImageTagMutabilityCommand + ); + expect(putCall).toBeDefined(); + expect(putCall![0].input.imageTagMutability).toBe('MUTABLE_WITH_EXCLUSION'); + expect(putCall![0].input).not.toHaveProperty('imageTagMutabilityExclusionFilters'); + }); + + it('update fires when filters are added with the mode unchanged', async () => { + mockRepoResponses(); + + await provider.update( + 'MyRepo', + 'my-repo', + 'AWS::ECR::Repository', + { + ImageTagMutability: 'MUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ], + }, + { ImageTagMutability: 'MUTABLE_WITH_EXCLUSION' } + ); + + const putCall = mockSend.mock.calls.find( + (c) => c[0] instanceof PutImageTagMutabilityCommand + ); + expect(putCall).toBeDefined(); + expect(putCall![0].input.imageTagMutabilityExclusionFilters).toEqual([ + { filterType: 'WILDCARD', filter: 'dev-*' }, + ]); + }); + + it('update does NOT fire PutImageTagMutability when neither member changed', async () => { + mockRepoResponses(); + + const unchanged = { + ImageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + ImageTagMutabilityExclusionFilters: [ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ], + }; + + // structuredClone, NOT a shallow spread: a spread shares the filters array + // reference, so a broken reference-equality implementation would pass. In + // production previousProperties comes from deserialized state JSON and is + // never identity-equal to the template's array. + await provider.update( + 'MyRepo', + 'my-repo', + 'AWS::ECR::Repository', + unchanged, + structuredClone(unchanged) + ); + + expect( + mockSend.mock.calls.find((c) => c[0] instanceof PutImageTagMutabilityCommand) + ).toBeUndefined(); + }); + + it('readCurrentState maps the AWS-current filters back to the CFn shape', async () => { + // DescribeRepositories -> GetLifecyclePolicy -> ListTagsForResource + mockSend.mockResolvedValueOnce({ + repositories: [ + { + repositoryName: 'my-repo', + repositoryArn: repoArn, + imageTagMutability: 'IMMUTABLE_WITH_EXCLUSION', + imageTagMutabilityExclusionFilters: [{ filterType: 'WILDCARD', filter: 'dev-*' }], + }, + ], + }); + mockSend.mockRejectedValueOnce( + new LifecyclePolicyNotFoundException({ $metadata: {}, message: 'none' }) + ); + mockSend.mockResolvedValueOnce({ tags: [] }); + + const state = await provider.readCurrentState('my-repo', 'MyRepo', 'AWS::ECR::Repository'); + + expect(state?.['ImageTagMutabilityExclusionFilters']).toEqual([ + { + ImageTagMutabilityExclusionFilterType: 'WILDCARD', + ImageTagMutabilityExclusionFilterValue: 'dev-*', + }, + ]); + }); + + it('readCurrentState omits the key when the repository has no exclusions', async () => { + mockSend.mockResolvedValueOnce({ + repositories: [ + { + repositoryName: 'my-repo', + repositoryArn: repoArn, + imageTagMutability: 'IMMUTABLE', + imageTagMutabilityExclusionFilters: [], + }, + ], + }); + mockSend.mockRejectedValueOnce( + new LifecyclePolicyNotFoundException({ $metadata: {}, message: 'none' }) + ); + mockSend.mockResolvedValueOnce({ tags: [] }); + + const state = await provider.readCurrentState('my-repo', 'MyRepo', 'AWS::ECR::Repository'); + + expect(state).not.toHaveProperty('ImageTagMutabilityExclusionFilters'); + }); + + // The likelier real-world shape: DescribeRepositories OMITS the member for a + // repository with no exclusions rather than returning an empty array. + it('readCurrentState omits the key when AWS does not return the member at all', async () => { + mockSend.mockResolvedValueOnce({ + repositories: [ + { + repositoryName: 'my-repo', + repositoryArn: repoArn, + imageTagMutability: 'IMMUTABLE', + }, + ], + }); + mockSend.mockRejectedValueOnce( + new LifecyclePolicyNotFoundException({ $metadata: {}, message: 'none' }) + ); + mockSend.mockResolvedValueOnce({ tags: [] }); + + const state = await provider.readCurrentState('my-repo', 'MyRepo', 'AWS::ECR::Repository'); + + expect(state).not.toHaveProperty('ImageTagMutabilityExclusionFilters'); + }); +});