Skip to content

Commit 48983bf

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 48983bf

5 files changed

Lines changed: 532 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-09T06:01:13Z PASS 55 verify.sh #1392 exclusion-filter create + filters-only update + filterType asserts; 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: 98 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,55 @@ 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+
// Undefined-valued keys are omitted rather than emitted: S3 drops them when
155+
// `observedProperties` is serialized, so a later drift read that DID carry
156+
// them would differ by key count and report phantom drift. Both members are
157+
// required in the SDK model, so this is defense against a shape AWS should
158+
// never return.
159+
return sdk.map((f) => ({
160+
...(f.filterType !== undefined && {
161+
ImageTagMutabilityExclusionFilterType: f.filterType,
162+
}),
163+
...(f.filter !== undefined && { ImageTagMutabilityExclusionFilterValue: f.filter }),
164+
}));
165+
}
166+
117167
/**
118168
* Create an ECR Repository
119169
*/
@@ -138,6 +188,9 @@ export class ECRProvider implements ResourceProvider {
138188
const encryptionConfig = this.toSdkEncryptionConfig(
139189
properties['EncryptionConfiguration'] as Record<string, unknown> | undefined
140190
);
191+
const exclusionFilters = this.toSdkTagMutabilityExclusionFilters(
192+
properties['ImageTagMutabilityExclusionFilters']
193+
);
141194

142195
const response = await this.getClient().send(
143196
new CreateRepositoryCommand({
@@ -148,6 +201,7 @@ export class ECRProvider implements ResourceProvider {
148201
imageTagMutability: properties['ImageTagMutability'] as ImageTagMutability,
149202
}
150203
: {}),
204+
...(exclusionFilters ? { imageTagMutabilityExclusionFilters: exclusionFilters } : {}),
151205
...(encryptionConfig ? { encryptionConfiguration: encryptionConfig } : {}),
152206
...(tags ? { tags } : {}),
153207
})
@@ -216,7 +270,8 @@ export class ECRProvider implements ResourceProvider {
216270
* Update an ECR Repository
217271
*
218272
* Mutable properties: ImageScanningConfiguration, ImageTagMutability,
219-
* LifecyclePolicy, RepositoryPolicyText, Tags.
273+
* ImageTagMutabilityExclusionFilters, LifecyclePolicy, RepositoryPolicyText,
274+
* Tags.
220275
* Immutable: RepositoryName, EncryptionConfiguration (require replacement).
221276
*/
222277
async update(
@@ -251,16 +306,41 @@ export class ECRProvider implements ResourceProvider {
251306
this.logger.debug(`Updated image scanning configuration for ${physicalId}`);
252307
}
253308

254-
// Update ImageTagMutability if changed
309+
// Update ImageTagMutability / ImageTagMutabilityExclusionFilters if
310+
// changed. Both members ride the SAME PutImageTagMutability call, so the
311+
// exclusion filters must be able to fire it on their own — a filters-only
312+
// edit (`IMMUTABLE_WITH_EXCLUSION` throughout, only the filter values
313+
// changing) would otherwise never reach AWS. `imageTagMutability` is a
314+
// REQUIRED member of that request, so the filters-only case re-sends the
315+
// current mutability value alongside the new filters.
255316
const newMutability = properties['ImageTagMutability'] as ImageTagMutability | undefined;
256317
const oldMutability = previousProperties['ImageTagMutability'] as
257318
| ImageTagMutability
258319
| undefined;
259-
if (newMutability !== oldMutability) {
320+
const newExclusionFilters = this.toSdkTagMutabilityExclusionFilters(
321+
properties['ImageTagMutabilityExclusionFilters']
322+
);
323+
const oldExclusionFilters = this.toSdkTagMutabilityExclusionFilters(
324+
previousProperties['ImageTagMutabilityExclusionFilters']
325+
);
326+
if (
327+
newMutability !== oldMutability ||
328+
JSON.stringify(newExclusionFilters) !== JSON.stringify(oldExclusionFilters)
329+
) {
260330
await this.getClient().send(
261331
new PutImageTagMutabilityCommand({
262332
repositoryName: physicalId,
263333
imageTagMutability: newMutability ?? 'MUTABLE',
334+
// Omitted on removal, on the expectation that
335+
// PutImageTagMutability is a full-replace setter rather than a
336+
// patch. NOT probed against real AWS, because the case is not
337+
// reachable: CFn/CDK reject filters without a `*_WITH_EXCLUSION`
338+
// mode and reject an exclusion mode without filters, so a removal
339+
// always rides a mutability change to a non-exclusion mode, where
340+
// any surviving filters are inert.
341+
...(newExclusionFilters
342+
? { imageTagMutabilityExclusionFilters: newExclusionFilters }
343+
: {}),
264344
})
265345
);
266346
this.logger.debug(`Updated image tag mutability for ${physicalId}`);
@@ -488,17 +568,20 @@ export class ECRProvider implements ResourceProvider {
488568
* (which `DescribeRepositories` doesn't return).
489569
*
490570
* Surfaced keys: `RepositoryName`, `ImageTagMutability`,
491-
* `ImageScanningConfiguration`, `EncryptionConfiguration`, `LifecyclePolicy`
492-
* (when configured — `LifecyclePolicyNotFoundException` is caught and the
493-
* key omitted, NOT propagated as repo-gone).
571+
* `ImageTagMutabilityExclusionFilters` (when the repository has any —
572+
* `DescribeRepositories` returns them on the `Repository` shape, mapped back
573+
* to the CFn member names), `ImageScanningConfiguration`,
574+
* `EncryptionConfiguration`, `LifecyclePolicy` (when configured —
575+
* `LifecyclePolicyNotFoundException` is caught and the key omitted, NOT
576+
* propagated as repo-gone).
494577
*
495578
* Intentionally omitted:
496579
* - `RepositoryPolicyText`: requires a separate `GetRepositoryPolicy`
497580
* round-trip; cdkd state holds the policy as either a string or an
498581
* object (depending on user input), and the comparator round-trip
499582
* is not yet handled here.
500-
* - `EmptyOnDelete` / `ImageTagMutabilityExclusionFilters`: not part
501-
* of the persisted AWS state visible via standard Describe.
583+
* - `EmptyOnDelete`: a cdkd/CDK delete-time intent flag, not part of the
584+
* persisted AWS state visible via standard Describe.
502585
*
503586
* `Tags` is surfaced via a follow-up `ListTagsForResource(arn)` call
504587
* (using the repository ARN that `DescribeRepositories` returns). CDK's
@@ -517,6 +600,7 @@ export class ECRProvider implements ResourceProvider {
517600
repositoryName?: string;
518601
repositoryArn?: string;
519602
imageTagMutability?: string;
603+
imageTagMutabilityExclusionFilters?: Array<{ filterType?: string; filter?: string }>;
520604
imageScanningConfiguration?: { scanOnPush?: boolean };
521605
encryptionConfiguration?: { encryptionType?: string; kmsKey?: string };
522606
}>;
@@ -535,6 +619,12 @@ export class ECRProvider implements ResourceProvider {
535619
const result: Record<string, unknown> = {};
536620
if (r.repositoryName !== undefined) result['RepositoryName'] = r.repositoryName;
537621
if (r.imageTagMutability !== undefined) result['ImageTagMutability'] = r.imageTagMutability;
622+
const cfnExclusionFilters = this.toCfnTagMutabilityExclusionFilters(
623+
r.imageTagMutabilityExclusionFilters
624+
);
625+
if (cfnExclusionFilters) {
626+
result['ImageTagMutabilityExclusionFilters'] = cfnExclusionFilters;
627+
}
538628
result['ImageScanningConfiguration'] = {
539629
ScanOnPush: r.imageScanningConfiguration?.scanOnPush ?? false,
540630
};

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: 64 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 filter types, sorted and space-joined (one entry per filter, no dedupe) —
126+
# proves the diverging member name (CFn ImageTagMutabilityExclusionFilterType ->
127+
# SDK filterType) 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,19 @@ 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+
FTYPES2="$(exclusion_filter_types)"
224+
echo " mutability (Phase 2): ${MUT2} filters=[${FILTERS2}] types=[${FTYPES2}]"
225+
[ "${MUT2}" = "IMMUTABLE_WITH_EXCLUSION" ] || { echo "FAIL: expected imageTagMutability to stay IMMUTABLE_WITH_EXCLUSION, got '${MUT2}'" >&2; exit 1; }
226+
[ "${FILTERS2}" = "hotfix-* release-v*" ] || { echo "FAIL: expected the changed exclusion filters 'hotfix-* release-v*' after a filters-only update, got '${FILTERS2}'" >&2; exit 1; }
227+
# One entry per filter (no dedupe), so both mapped entries must carry the type.
228+
[ "${FTYPES2}" = "WILDCARD WILDCARD" ] || { echo "FAIL: expected both filters to carry filterType WILDCARD after update, got '${FTYPES2}'" >&2; exit 1; }
229+
echo " filters-only update reached AWS"
230+
172231
# --- Phase 3: destroy --------------------------------------------------
173232
echo "==> Phase 3: destroy"
174233
node "${LOCAL_DIST}" destroy "${STACK}" --state-bucket "${STATE_BUCKET}" --region "${REGION}" --force
@@ -178,4 +237,4 @@ echo " repo deleted"
178237
assert_gone "state file still exists after destroy" aws s3api head-object --bucket "${STATE_BUCKET}" --key "${STATE_KEY}"
179238
echo " cdkd state removed"
180239

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