Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/_generated/integ-last-run.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 98 additions & 8 deletions src/provisioning/providers/ecr-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>;
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<Record<string, unknown>> | 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
*/
Expand All @@ -138,6 +188,9 @@ export class ECRProvider implements ResourceProvider {
const encryptionConfig = this.toSdkEncryptionConfig(
properties['EncryptionConfiguration'] as Record<string, unknown> | undefined
);
const exclusionFilters = this.toSdkTagMutabilityExclusionFilters(
properties['ImageTagMutabilityExclusionFilters']
);

const response = await this.getClient().send(
new CreateRepositoryCommand({
Expand All @@ -148,6 +201,7 @@ export class ECRProvider implements ResourceProvider {
imageTagMutability: properties['ImageTagMutability'] as ImageTagMutability,
}
: {}),
...(exclusionFilters ? { imageTagMutabilityExclusionFilters: exclusionFilters } : {}),
...(encryptionConfig ? { encryptionConfiguration: encryptionConfig } : {}),
...(tags ? { tags } : {}),
})
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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
Expand All @@ -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 };
}>;
Expand All @@ -535,6 +619,12 @@ export class ECRProvider implements ResourceProvider {
const result: Record<string, unknown> = {};
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,
};
Expand Down
28 changes: 26 additions & 2 deletions tests/integration/ecr-scanning/lib/ecr-scanning-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 }],
Expand Down
69 changes: 64 additions & 5 deletions tests/integration/ecr-scanning/verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}" \
Expand All @@ -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
Expand All @@ -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"
Loading