@@ -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' ;
2425import { 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 } ;
0 commit comments