@@ -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,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 } ;
0 commit comments