Skip to content

Commit 9d71e62

Browse files
committed
fix(ecr): wire ImageTagMutabilityExclusionFilters onto the create and mutability calls
ECRProvider declared ImageTagMutabilityExclusionFilters in handledProperties but never sent it: CreateRepository omitted it and the update path's PutImageTagMutability sent only imageTagMutability. A repository using IMMUTABLE_WITH_EXCLUSION therefore lost its exclusions silently, while the property-coverage pre-flight passed on the false handled claim. The member names diverge (not just their casing): CFn {ImageTagMutabilityExclusionFilterType, ImageTagMutabilityExclusionFilterValue} vs SDK {filterType, filter}, so the blob cannot be forwarded verbatim. - Map CFn to SDK on create and on PutImageTagMutability. - Fire the update on a filters-only change (both members ride the same call, and imageTagMutability is a required member, so it is re-sent). - Read the filters back off DescribeRepositories through the inverse mapping so drift compares against a template-shaped baseline, and correct the readCurrentState comment that claimed the property was not readable. - Extend the ecr-scanning integ fixture to deploy IMMUTABLE_WITH_EXCLUSION with a filter, then change ONLY the filters on the UPDATE phase, asserting both reach AWS. Closes #1392
1 parent 98dc56b commit 9d71e62

5 files changed

Lines changed: 430 additions & 16 deletions

File tree

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ ec2-instance 2026-07-30T06:37:46Z PASS 180 verify.sh #1281 NetworkInterfaces ins
8989
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
9090
ec2-vpc 2026-07-26T17:21:15Z PASS 35 standard issue 1241 fix verified: explicit DESTROY FlowLog LogGroup; 20 deleted 0 retained 0 orphans
9191
ecr 2026-07-26T18:36:31Z PASS 85 standard 0727b sweep-b4 staleness re-run (rc=0); account clean
92-
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
92+
ecr-scanning 2026-08-09T05:36:37Z PASS 57 verify.sh #1392 exclusion-filter create + filters-only update asserts added; 2 del/0 err, 0 orphans
9393
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
9494
ecs-schedule-targets 2026-08-08T19:04:46Z PASS 60 verify.sh re-run with drift assert after review fixes; clean
9595
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

src/provisioning/providers/ecr-provider.ts

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
type ImageScanningConfiguration,
2020
type EncryptionConfiguration,
2121
type ImageTagMutability,
22+
type ImageTagMutabilityExclusionFilter,
2223
type Tag,
2324
} from '@aws-sdk/client-ecr';
2425
import { getLogger } from '../../utils/logger.js';
@@ -114,6 +115,48 @@ export class ECRProvider implements ResourceProvider {
114115
return out;
115116
}
116117

118+
/**
119+
* Map CFn `ImageTagMutabilityExclusionFilters`
120+
* (`[{ ImageTagMutabilityExclusionFilterType, ImageTagMutabilityExclusionFilterValue }]`)
121+
* to the SDK shape (`[{ filterType, filter }]`). The member NAMES diverge —
122+
* not just their casing — so forwarding the CFn-shaped array verbatim makes
123+
* the SDK drop every member and send `[{}]`, and AWS rejects the call (or,
124+
* worse, silently loses the exclusions). Returns `undefined` for an absent /
125+
* empty list so the caller can omit the field entirely.
126+
*/
127+
private toSdkTagMutabilityExclusionFilters(
128+
cfn: unknown
129+
): ImageTagMutabilityExclusionFilter[] | undefined {
130+
if (!Array.isArray(cfn) || cfn.length === 0) return undefined;
131+
return cfn.map((entry) => {
132+
const e = (entry ?? {}) as Record<string, unknown>;
133+
return {
134+
filterType: e['ImageTagMutabilityExclusionFilterType'] as
135+
| ImageTagMutabilityExclusionFilter['filterType']
136+
| undefined,
137+
filter: e['ImageTagMutabilityExclusionFilterValue'] as string | undefined,
138+
};
139+
});
140+
}
141+
142+
/**
143+
* Inverse of {@link toSdkTagMutabilityExclusionFilters}: SDK
144+
* `[{ filterType, filter }]` back to the CFn property shape, so
145+
* `cdkd drift` compares the AWS-current exclusions against the
146+
* template-shaped baseline instead of a guaranteed false positive.
147+
* Returns `undefined` for an absent / empty list so `readCurrentState`
148+
* omits the key (a repository with no exclusions).
149+
*/
150+
private toCfnTagMutabilityExclusionFilters(
151+
sdk: Array<{ filterType?: string; filter?: string }> | undefined
152+
): Array<Record<string, unknown>> | undefined {
153+
if (!sdk || sdk.length === 0) return undefined;
154+
return sdk.map((f) => ({
155+
ImageTagMutabilityExclusionFilterType: f.filterType,
156+
ImageTagMutabilityExclusionFilterValue: f.filter,
157+
}));
158+
}
159+
117160
/**
118161
* Create an ECR Repository
119162
*/
@@ -138,6 +181,9 @@ export class ECRProvider implements ResourceProvider {
138181
const encryptionConfig = this.toSdkEncryptionConfig(
139182
properties['EncryptionConfiguration'] as Record<string, unknown> | undefined
140183
);
184+
const exclusionFilters = this.toSdkTagMutabilityExclusionFilters(
185+
properties['ImageTagMutabilityExclusionFilters']
186+
);
141187

142188
const response = await this.getClient().send(
143189
new CreateRepositoryCommand({
@@ -148,6 +194,7 @@ export class ECRProvider implements ResourceProvider {
148194
imageTagMutability: properties['ImageTagMutability'] as ImageTagMutability,
149195
}
150196
: {}),
197+
...(exclusionFilters ? { imageTagMutabilityExclusionFilters: exclusionFilters } : {}),
151198
...(encryptionConfig ? { encryptionConfiguration: encryptionConfig } : {}),
152199
...(tags ? { tags } : {}),
153200
})
@@ -216,7 +263,8 @@ export class ECRProvider implements ResourceProvider {
216263
* Update an ECR Repository
217264
*
218265
* Mutable properties: ImageScanningConfiguration, ImageTagMutability,
219-
* LifecyclePolicy, RepositoryPolicyText, Tags.
266+
* ImageTagMutabilityExclusionFilters, LifecyclePolicy, RepositoryPolicyText,
267+
* Tags.
220268
* Immutable: RepositoryName, EncryptionConfiguration (require replacement).
221269
*/
222270
async update(
@@ -251,16 +299,38 @@ export class ECRProvider implements ResourceProvider {
251299
this.logger.debug(`Updated image scanning configuration for ${physicalId}`);
252300
}
253301

254-
// Update ImageTagMutability if changed
302+
// Update ImageTagMutability / ImageTagMutabilityExclusionFilters if
303+
// changed. Both members ride the SAME PutImageTagMutability call, so the
304+
// exclusion filters must be able to fire it on their own — a filters-only
305+
// edit (`IMMUTABLE_WITH_EXCLUSION` throughout, only the filter values
306+
// changing) would otherwise never reach AWS. `imageTagMutability` is a
307+
// REQUIRED member of that request, so the filters-only case re-sends the
308+
// current mutability value alongside the new filters.
255309
const newMutability = properties['ImageTagMutability'] as ImageTagMutability | undefined;
256310
const oldMutability = previousProperties['ImageTagMutability'] as
257311
| ImageTagMutability
258312
| undefined;
259-
if (newMutability !== oldMutability) {
313+
const newExclusionFilters = this.toSdkTagMutabilityExclusionFilters(
314+
properties['ImageTagMutabilityExclusionFilters']
315+
);
316+
const oldExclusionFilters = this.toSdkTagMutabilityExclusionFilters(
317+
previousProperties['ImageTagMutabilityExclusionFilters']
318+
);
319+
if (
320+
newMutability !== oldMutability ||
321+
JSON.stringify(newExclusionFilters) !== JSON.stringify(oldExclusionFilters)
322+
) {
260323
await this.getClient().send(
261324
new PutImageTagMutabilityCommand({
262325
repositoryName: physicalId,
263326
imageTagMutability: newMutability ?? 'MUTABLE',
327+
// PutImageTagMutability is a full-replace setter, not a patch, so
328+
// an omitted list clears the repository's exclusions — which is
329+
// exactly the removal semantic we want when the template drops the
330+
// property.
331+
...(newExclusionFilters
332+
? { imageTagMutabilityExclusionFilters: newExclusionFilters }
333+
: {}),
264334
})
265335
);
266336
this.logger.debug(`Updated image tag mutability for ${physicalId}`);
@@ -488,17 +558,20 @@ export class ECRProvider implements ResourceProvider {
488558
* (which `DescribeRepositories` doesn't return).
489559
*
490560
* Surfaced keys: `RepositoryName`, `ImageTagMutability`,
491-
* `ImageScanningConfiguration`, `EncryptionConfiguration`, `LifecyclePolicy`
492-
* (when configured — `LifecyclePolicyNotFoundException` is caught and the
493-
* key omitted, NOT propagated as repo-gone).
561+
* `ImageTagMutabilityExclusionFilters` (when the repository has any —
562+
* `DescribeRepositories` returns them on the `Repository` shape, mapped back
563+
* to the CFn member names), `ImageScanningConfiguration`,
564+
* `EncryptionConfiguration`, `LifecyclePolicy` (when configured —
565+
* `LifecyclePolicyNotFoundException` is caught and the key omitted, NOT
566+
* propagated as repo-gone).
494567
*
495568
* Intentionally omitted:
496569
* - `RepositoryPolicyText`: requires a separate `GetRepositoryPolicy`
497570
* round-trip; cdkd state holds the policy as either a string or an
498571
* object (depending on user input), and the comparator round-trip
499572
* is not yet handled here.
500-
* - `EmptyOnDelete` / `ImageTagMutabilityExclusionFilters`: not part
501-
* of the persisted AWS state visible via standard Describe.
573+
* - `EmptyOnDelete`: a cdkd/CDK delete-time intent flag, not part of the
574+
* persisted AWS state visible via standard Describe.
502575
*
503576
* `Tags` is surfaced via a follow-up `ListTagsForResource(arn)` call
504577
* (using the repository ARN that `DescribeRepositories` returns). CDK's
@@ -517,6 +590,7 @@ export class ECRProvider implements ResourceProvider {
517590
repositoryName?: string;
518591
repositoryArn?: string;
519592
imageTagMutability?: string;
593+
imageTagMutabilityExclusionFilters?: Array<{ filterType?: string; filter?: string }>;
520594
imageScanningConfiguration?: { scanOnPush?: boolean };
521595
encryptionConfiguration?: { encryptionType?: string; kmsKey?: string };
522596
}>;
@@ -535,6 +609,12 @@ export class ECRProvider implements ResourceProvider {
535609
const result: Record<string, unknown> = {};
536610
if (r.repositoryName !== undefined) result['RepositoryName'] = r.repositoryName;
537611
if (r.imageTagMutability !== undefined) result['ImageTagMutability'] = r.imageTagMutability;
612+
const cfnExclusionFilters = this.toCfnTagMutabilityExclusionFilters(
613+
r.imageTagMutabilityExclusionFilters
614+
);
615+
if (cfnExclusionFilters) {
616+
result['ImageTagMutabilityExclusionFilters'] = cfnExclusionFilters;
617+
}
538618
result['ImageScanningConfiguration'] = {
539619
ScanOnPush: r.imageScanningConfiguration?.scanOnPush ?? false,
540620
};

tests/integration/ecr-scanning/lib/ecr-scanning-stack.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,25 @@ import * as kms from 'aws-cdk-lib/aws-kms';
1515
* true` never reached AWS. This fixture asserts scanOnPush actually reaches AWS
1616
* on create AND that toggling it off via UPDATE reaches AWS.
1717
*
18+
* ALSO covers issue #1392: `ImageTagMutabilityExclusionFilters` was declared in
19+
* `handledProperties` but never sent — absent from `CreateRepository` and from
20+
* update's `PutImageTagMutability` — so a repository's exclusions silently
21+
* vanished, and a filters-only edit never reached AWS at all (the update fired
22+
* on `ImageTagMutability` alone). The member names diverge too (CFn
23+
* `ImageTagMutabilityExclusionFilterType` / `...Value` vs SDK `filterType` /
24+
* `filter`), so the blob cannot be forwarded verbatim.
25+
*
1826
* Phase 1 (no env): scanOnPush true + a lifecycle rule + two Tags
19-
* (`env=dev`, `team=platform`).
27+
* (`env=dev`, `team=platform`) + IMMUTABLE_WITH_EXCLUSION with one
28+
* exclusion filter (`dev-*`).
2029
* Phase 2 (CDKD_TEST_UPDATE=true): scanOnPush false; the `env` tag value is
2130
* CHANGED to `prod` and the `team` tag is REMOVED. This exercises the
2231
* update() tag-diff: `ECRProvider.update()` used to call `TagResourceCommand`
2332
* only (additive), so a removed tag survived on AWS (issue #981). The fix
2433
* untags the removed key(s) via `UntagResourceCommand` before re-tagging.
34+
* The exclusion filters change to two DIFFERENT patterns while
35+
* `imageTagMutability` stays IMMUTABLE_WITH_EXCLUSION — a filters-ONLY edit,
36+
* which is precisely the case the pre-#1392 update() gate skipped.
2537
*/
2638
export class EcrScanningStack extends cdk.Stack {
2739
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
@@ -38,10 +50,22 @@ export class EcrScanningStack extends cdk.Stack {
3850
pendingWindow: cdk.Duration.days(7),
3951
});
4052

53+
// Phase 1: one exclusion filter. Phase 2: two DIFFERENT patterns, with
54+
// imageTagMutability unchanged — the filters-only update that pre-#1392
55+
// never reached AWS, because both members ride the same
56+
// PutImageTagMutability call and only a mutability change could fire it.
57+
const exclusionFilters = isUpdate
58+
? [
59+
ecr.ImageTagMutabilityExclusionFilter.wildcard('release-v*'),
60+
ecr.ImageTagMutabilityExclusionFilter.wildcard('hotfix-*'),
61+
]
62+
: [ecr.ImageTagMutabilityExclusionFilter.wildcard('dev-*')];
63+
4164
const repo = new ecr.Repository(this, 'Repo', {
4265
repositoryName: `${this.stackName.toLowerCase()}-repo`,
4366
imageScanOnPush: !isUpdate,
44-
imageTagMutability: ecr.TagMutability.IMMUTABLE,
67+
imageTagMutability: ecr.TagMutability.IMMUTABLE_WITH_EXCLUSION,
68+
imageTagMutabilityExclusionFilters: exclusionFilters,
4569
encryption: ecr.RepositoryEncryption.KMS,
4670
encryptionKey: key,
4771
lifecycleRules: [{ description: 'keep last 5', maxImageCount: 5 }],

tests/integration/ecr-scanning/verify.sh

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,23 @@
1212
# TagResourceCommand only (additive), so a tag removed from the template
1313
# survived on AWS. The fix untags removed keys via UntagResourceCommand.
1414
#
15+
# ALSO covers issue #1392: ImageTagMutabilityExclusionFilters was declared in
16+
# handledProperties but sent on NO API call — omitted from CreateRepository and
17+
# from update's PutImageTagMutability — so the exclusions silently vanished
18+
# while the property-coverage pre-flight passed on the false handled claim. The
19+
# member names diverge as well (CFn ImageTagMutabilityExclusionFilterType /
20+
# ...Value vs SDK filterType / filter). The update gate also fired on
21+
# ImageTagMutability alone, so a filters-ONLY edit never reached AWS.
22+
#
1523
# Phases:
16-
# 1. Deploy with imageScanOnPush=true + Tags env=dev, team=platform. Assert
17-
# AWS reports scanOnPush=true and both tags are present.
24+
# 1. Deploy with imageScanOnPush=true + Tags env=dev, team=platform +
25+
# IMMUTABLE_WITH_EXCLUSION with one exclusion filter (dev-*). Assert AWS
26+
# reports scanOnPush=true, both tags, and the filter.
1827
# 2. Re-deploy (CDKD_TEST_UPDATE=true) with imageScanOnPush=false, env changed
19-
# to prod, team REMOVED. Assert scanOnPush=false, env=prod, team untagged,
20-
# and exactly one user tag remains.
28+
# to prod, team REMOVED, and the exclusion filters changed to two DIFFERENT
29+
# patterns with imageTagMutability UNCHANGED. Assert scanOnPush=false,
30+
# env=prod, team untagged, exactly one user tag remains, and the new
31+
# filters reached AWS (the filters-only update path).
2132
# 3. Destroy + assert the repo is gone and the cdkd state file is removed.
2233
#
2334
# Required env vars: STATE_BUCKET; AWS_REGION (defaults us-east-1).
@@ -96,6 +107,30 @@ scan_on_push() {
96107
--query 'repositories[0].imageScanningConfiguration.scanOnPush' --output text
97108
}
98109

110+
tag_mutability() {
111+
aws ecr describe-repositories --repository-names "${REPO}" --region "${REGION}" \
112+
--query 'repositories[0].imageTagMutability' --output text
113+
}
114+
115+
# The exclusion filter PATTERNS, sorted and space-joined. AWS does not preserve
116+
# the submitted order of list-valued members on readback, so both sides of the
117+
# comparison are sorted (the null-list coalesce keeps an empty list from
118+
# aborting the script under set -e).
119+
exclusion_filter_patterns() {
120+
aws ecr describe-repositories --repository-names "${REPO}" --region "${REGION}" \
121+
--query "join(' ', sort(repositories[0].imageTagMutabilityExclusionFilters[].filter || \`[]\`))" \
122+
--output text
123+
}
124+
125+
# The DISTINCT filter types, sorted and space-joined — proves the diverging
126+
# member name (CFn ImageTagMutabilityExclusionFilterType -> SDK filterType)
127+
# reached AWS rather than arriving as an empty object.
128+
exclusion_filter_types() {
129+
aws ecr describe-repositories --repository-names "${REPO}" --region "${REGION}" \
130+
--query "join(' ', sort(repositories[0].imageTagMutabilityExclusionFilters[].filterType || \`[]\`))" \
131+
--output text
132+
}
133+
99134
# Read a single tag's value via list-tags-for-resource. Emits the value or
100135
# 'NONE' when the key is absent (JMESPath `[?Key==...] | [0].Value` -> null ->
101136
# 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}"
148183
[ "${TEAMTAG1}" = "platform" ] || { echo "FAIL: expected team=platform on create, got '${TEAMTAG1}'" >&2; exit 1; }
149184
echo " both tags reached AWS on create"
150185

186+
# ImageTagMutabilityExclusionFilters on create (issue #1392). Pre-fix the
187+
# property never reached CreateRepository at all.
188+
MUT1="$(tag_mutability)"
189+
FILTERS1="$(exclusion_filter_patterns)"
190+
FTYPES1="$(exclusion_filter_types)"
191+
echo " mutability (Phase 1): ${MUT1} filters=[${FILTERS1}] types=[${FTYPES1}]"
192+
[ "${MUT1}" = "IMMUTABLE_WITH_EXCLUSION" ] || { echo "FAIL: expected imageTagMutability=IMMUTABLE_WITH_EXCLUSION, got '${MUT1}'" >&2; exit 1; }
193+
[ "${FILTERS1}" = "dev-*" ] || { echo "FAIL: expected exclusion filter 'dev-*' on create, got '${FILTERS1}'" >&2; exit 1; }
194+
[ "${FTYPES1}" = "WILDCARD" ] || { echo "FAIL: expected filterType WILDCARD on create, got '${FTYPES1}'" >&2; exit 1; }
195+
echo " exclusion filters reached AWS on create"
196+
151197
# --- Phase 2: UPDATE scanOnPush=false ---------------------------------
152198
echo "==> Phase 2: re-deploy with imageScanOnPush=false (UPDATE)"
153199
CDKD_TEST_UPDATE=true node "${LOCAL_DIST}" deploy "${STACK}" \
@@ -169,6 +215,16 @@ echo " tags (Phase 2): env=${ENVTAG2} team=${TEAMTAG2} user_tag_count=${UTC2}
169215
[ "${UTC2}" = "1" ] || { echo "FAIL: expected exactly 1 user tag after update (env only), got '${UTC2}'" >&2; exit 1; }
170216
echo " removed tag untagged + changed tag updated on AWS"
171217

218+
# Filters-ONLY update (issue #1392): imageTagMutability is identical across
219+
# both phases, so pre-fix the PutImageTagMutability call never fired and the
220+
# Phase 1 filter survived on AWS.
221+
MUT2="$(tag_mutability)"
222+
FILTERS2="$(exclusion_filter_patterns)"
223+
echo " mutability (Phase 2): ${MUT2} filters=[${FILTERS2}]"
224+
[ "${MUT2}" = "IMMUTABLE_WITH_EXCLUSION" ] || { echo "FAIL: expected imageTagMutability to stay IMMUTABLE_WITH_EXCLUSION, got '${MUT2}'" >&2; exit 1; }
225+
[ "${FILTERS2}" = "hotfix-* release-v*" ] || { echo "FAIL: expected the changed exclusion filters 'hotfix-* release-v*' after a filters-only update, got '${FILTERS2}'" >&2; exit 1; }
226+
echo " filters-only update reached AWS"
227+
172228
# --- Phase 3: destroy --------------------------------------------------
173229
echo "==> Phase 3: destroy"
174230
node "${LOCAL_DIST}" destroy "${STACK}" --state-bucket "${STATE_BUCKET}" --region "${REGION}" --force
@@ -178,4 +234,4 @@ echo " repo deleted"
178234
assert_gone "state file still exists after destroy" aws s3api head-object --bucket "${STATE_BUCKET}" --key "${STATE_KEY}"
179235
echo " cdkd state removed"
180236

181-
echo "[verify] PASS — ECR scanOnPush (CFn->SDK casing) + tag add/change/untag on update reach AWS, 3 phases passed"
237+
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"

0 commit comments

Comments
 (0)