fix(s3): read every lifecycle rule-level key and translate the noncurrent transition day field - #1426
Merged
Merged
Conversation
… legacy singular actions Closes #1388 Closes #1424 The CFn AWS::S3::Bucket lifecycle Rule has NO Filter member: every scope component and several actions live at the rule level. gatherScope read rule-level fallbacks for Prefix / ObjectSizeGreaterThan / ObjectSizeLessThan but not TagFilters, so a tag-scoped rule gathered no scope at all and fell through to the catch-all Filter { Prefix: '' } - sending a tag-scoped expiration against EVERY object in the bucket. Verified against a real cdk synth (aws-cdk-lib 2.244.0); this is the current CDK L2 path, not a legacy one. Fixing the gather alone would have traded one silent drop for another: the single-component branch was Filter { Tag: tagFilters[0] } and the SDK Tag member holds exactly one tag, so multi-tag rules now take And { Tags }. Also reads rule-level ExpiredObjectDeleteMarker (a rule whose only action is the delete-marker cleanup produced no Expiration at all and S3 rejects the action-less rule) and the legacy singular Transition / NoncurrentVersionTransition / NoncurrentVersionExpirationInDays the schema still accepts, concatenating singular with plural rather than treating them as alternatives. Found by enumerating all 16 CFn Rule members against what the provider reads, per the diff-the-whole-blob rule in .claude/rules/providers.md - the issue reported 3 of the 5 gaps and described the impact as legacy-templates-only.
… live-verify every rule-level key Sixth gap in the same function, found while extending the integ fixture. CFn spells the noncurrent transition day count TransitionInDays on BOTH the singular and the plural form; the SDK member is NoncurrentDays. The provider read only the SDK spelling, so the value was undefined for every real template and a CDK noncurrentVersionTransitions reached AWS with no schedule. Its sibling Transitions already did TransitionInDays ?? Days; this makes the pair consistent. Fixture: the tag-scoped rule, a plural noncurrent transition, and an L1 bucket carrying the legacy singular Transition / NoncurrentVersionTransition / NoncurrentVersionExpirationInDays plus a rule-level ExpiredObjectDeleteMarker (shapes the L2 cannot emit). verify.sh now asserts each against a real DescribeBucketLifecycleConfiguration readback, tags sorted since AWS does not preserve list order. The first real-AWS run FAILED with 'NoncurrentDays in the NoncurrentVersionExpiration action must be greater than NoncurrentDays in the NoncurrentVersionTransition action', which only AWS could have said once the day count actually reached it - the pre-fix binary sent nothing there to compare. Fixture values corrected. Also hardens the post-destroy bucket probe with a bounded retry: S3 propagates DeleteBucket to HeadBucket asynchronously and a single probe raced it. The retry still FAILs on a bucket that never disappears, so leak detection is unchanged.
…work Plural transitions now WIN over the legacy singular instead of being concatenated. Concatenating can emit two transitions with the same StorageClass, which S3 rejects outright and fails the whole PutBucketLifecycleConfiguration - a regression, since pre-fix such a template deployed with the singular ignored. Plural-wins also matches the NoncurrentVersionExpiration policy already chosen 30 lines above. Rule-level ExpiredObjectDeleteMarker now gates on whether the built Expiration actually carries Days/Date rather than on its existence: the nested branch emits an all-undefined object for an empty Expiration, so the existence check dropped the marker AND left the rule action-less, the exact failure the block exists to prevent. A genuine Days+marker conflict now warns instead of dropping in silence. readLifecycle reverse-maps the noncurrent transition day count to the CFn spelling TransitionInDays, matching its Transitions sibling. Emitting the SDK NoncurrentDays made cdkd drift report a permanent phantom diff on every versioned bucket with a noncurrent transition - latent until this PR, since the write side never delivered the value and both sides were empty. Adds isPlainObject (typeof x === 'object' accepts arrays and null; a Transition: [] became an entry with no StorageClass and S3 answered MalformedXML for the whole config) and coerceCfnNumber / coerceCfnBoolean (CFn is stringly typed and these legacy branches exist for hand-written templates, where '365' / 'true' is exactly what shows up). 8 new unit tests (22 total) plus a re-run of the s3-lifecycle integ: PASS, 2 deleted / 0 errors / 0 orphans.
mergeLegacySingular replaces plural-wins: keeping only the plural dropped a legitimate template, since a plural [{GLACIER,30}] alongside a singular {DEEP_ARCHIVE,90} is two different storage classes that S3 accepts. Now both are kept, the plural wins on a StorageClass COLLISION (which S3 rejects outright), and the collision warns rather than dropping silently - the same standard this PR applies to the delete marker.
ExpiredObjectDeleteMarker now engages only on TRUE. coerceCfnBoolean returns false for an explicit false, which is a legal synth (CDK's own validation is truthy-gated), so the previous check warned about a cleanup nobody requested and, on a marker-only rule, emitted Expiration { ExpiredObjectDeleteMarker: false } - action-less again, the exact failure the block exists to prevent.
coerceCfnNumber screens the type first: Number([]) is 0 and Number([5]) is 5, so an unresolved-intrinsic array coerced to a plausible day count - the same class isPlainObject blocks on the object path. NoncurrentVersionExpiration and AbortIncompleteMultipartUpload are screened with isPlainObject too, and the plural arrays now filter their elements.
3 further unit tests (25 total) plus a third clean s3-lifecycle integ run.
github-actions Bot
pushed a commit
that referenced
this pull request
Aug 9, 2026
## [0.278.13](v0.278.12...v0.278.13) (2026-08-09) ### Bug Fixes * **s3:** read every lifecycle rule-level key and translate the noncurrent transition day field ([#1426](#1426)) ([768cd44](768cd44))
|
🎉 This PR is included in version 0.278.13 🎉 The release is available on: Your semantic-release bot 📦🚀 |
go-to-k
added a commit
that referenced
this pull request
Aug 9, 2026
…, and guard a null EventBridge block Review fix-back for #1437. The headline correction: this target's central claim was FALSE, and the tests that were supposed to prove it were passing for the wrong reason. WHAT THE CRITIC WOULD ACTUALLY HAVE CAUGHT. Measured by running it against the REAL pre-#1426 provider (`git show 768cd44^`) rather than predicted: it flags `Transition`, `NoncurrentVersionTransition` and `NoncurrentVersionExpirationInDays` -- the legacy-singular defect. Issue #1430 predicted `TagFilters`, `NoncurrentVersionTransitions[].TransitionInDays` and rule-level `ExpiredObjectDeleteMarker`, and was wrong on all three: - `TagFilters` (16 literal sites pre-#1426) and `TransitionInDays` (2) are also named by `readCurrentState`'s reverse map, so the file-global literal heuristic reports `provider-handled` however broken the write path is. That is item 2 of #1393. - `ExpiredObjectDeleteMarker` is not shape-audited at all: the shape pass matches CFn definitions to same-named SDK interfaces, and `@aws-sdk/client-s3` spells it `LifecycleRule`, so CFn's `Rule` sits in `unmatchedDefinitions` and the whole lifecycle-rule blob is unaudited. The strip-probes inherited the wrong list, and `replaceAll` hid it by deleting EVERY occurrence -- a regression shape that cannot occur. They now probe only keys a realistic single-site regression can actually silence, and each probe ASSERTS the key left `collectStringLiterals`'s evidence set before asserting the bucket. That self-validation immediately caught a second miss: the read-side fix makes `EventBridgeEnabled` an object-literal property name as well as a quoted literal, so stripping only `'EventBridgeEnabled'` left it in evidence. A new test pins the honest limit -- removing ONLY TagFilters' write-side conversion must still classify `provider-handled` -- so nobody re-adds the false claim. Provider fixes: - `EventBridgeConfiguration: null` threw. The branch now reads a member off the block, so an explicit null (hand-written JSON, or an intrinsic resolving to null) hit `Cannot read properties of null` where the pre-change `eb !== undefined` test merely emitted. Guarded with the file's existing `isPlainObject`; non-objects stay on the enable-on-presence side. - `coerceCfnBoolean` is now case-insensitive. This is the one call site where "not false" means "turn it on", so `'False'` falling through to `undefined` would silently ENABLE delivery -- the exact inversion this PR fixes. - The `readCurrentState` comment claimed the always-emit is invisible to a state record with no `EventBridgeConfiguration` key. Not true: `cdkd drift` uses `unionWalkObjects: true`, so upgrading reports ONE cosmetic diff per bucket until the next `state refresh-observed` / `drift --accept` / real UPDATE. The comment now says so, matching the convention in ec2 / ssm / s3-tables. Tests: +6 units (case variants, null block, EventBridge-false alongside a Topic config, and the true -> false UPDATE flip, which no existing test covered since they all start from a bare previous state). The round-trip test was vacuous -- with the read fix reverted the reader returns `{}`, which the write path also emits as `{}` -- so it is split, and the DISCRIMINATING direction is now covered: the disabled shape fed back through the write path must NOT re-enable delivery, which is what a `drift --revert` would do. The `#1378` definitionShapes fence sorts before picking its stand-in fixture (`readdirSync` order differs between macOS and CI) and requires a string `resourceType`, so it cannot pick the bookkeeping file and throw a TypeError instead of the assertion it means to make. Integ: the EventBridge + drift assertions are factored into functions and run after BOTH phases, so the `diffSubConfig` -> `applyNotificationConfiguration` UPDATE call site is covered against real AWS too. Re-run: PASS, destroy 3 deleted / 0 errors / 0 orphans. Also corrects "the largest target" (CloudFront Distribution is 121 keys, S3 is 115) in `.claude/rules/code-layout.md`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1388
Closes #1424
Summary
The CFn
AWS::S3::BucketlifecycleRulehas noFiltermember at all —every scope component and several actions sit at the rule level, and the schema
still accepts legacy singular action forms next to the modern plural ones.
applyLifecycleConfigurationread a subset. Enumerating all 16Rulemembers (from
aws-cdk-lib's generatedCfnBucket.RuleProperty) against whatthe provider actually reads found 6 defects, not the 3 #1388 reported — and
the two worst are on the CURRENT CDK L2 path, not the legacy one the issue
describes.
The severe one: rule-level
TagFilters(#1424)gatherScopehad rule-level fallbacks forPrefix,ObjectSizeGreaterThanand
ObjectSizeLessThan— but for tags it read onlyFilter.TagFilters:An omission in an otherwise-consistent pattern (
Filteris cdkd's ownaccommodation for SDK-shaped / imported input).
A real
cdk synth(aws-cdk-lib 2.244.0) oflifecycleRules: [{ tagFilters: { env: 'prod', team: 'core' }, expiration: Duration.days(30) }]emits
TagFiltersat the rule level. cdkd gathered no scope for that rule, soit fell through to the catch-all
Filter: { Prefix: '' }— a 30-dayexpiration sent against every object in the bucket instead of the tagged
subset. Lifecycle expiration deletes objects, and cdkd reported success. This
is the CURRENT CDK L2 path, so the impact is not limited to the legacy /
hand-written templates #1388 describes.
Fixing the gather alone would have introduced a second silent drop. The
single-component branch was
Filter: { Tag: tagFilters[0] }, and the SDK'sTagmember holds exactly one tag; multi-tag rules now takeAnd: { Tags: [...] }. That branch was unreachable before this PR preciselybecause tags were never gathered, so it had to land in the same change.
Rule-level
ExpiredObjectDeleteMarker(#1424)expiredObjectDeleteMarker: truesynthesizes to the rule level, but theprovider only read it from a nested
Expirationobject. A rule whose onlyaction is the delete-marker cleanup produced no
Expirationat all, and S3rejects the action-less rule. Now fills an otherwise-empty
Expiration, anddeliberately does not override an explicit
Days/Date(S3 forbidscombining them).
The plural path was broken too:
TransitionInDaysFound while extending the integ fixture. CFn spells the noncurrent transition
day count
TransitionInDayson both the singular and the pluralNoncurrentVersionTransitions[]; the SDK member isNoncurrentDays. Theprovider read only the SDK spelling:
So every CDK
noncurrentVersionTransitionsreached AWS with no schedule.Its sibling
Transitionsalready didTransitionInDays ?? Days; this makes thepair consistent. Confirmed in
aws-cdk-lib'sNoncurrentVersionTransitionProperty, in the SDK model, and in a real synth.The legacy singular forms (#1388, as filed)
Transition,NoncurrentVersionTransition, and the scalarNoncurrentVersionExpirationInDays. Singular and plural are concatenatedrather than treated as alternatives, since the schema permits both on one rule;
for the expiration pair the modern object wins over the legacy scalar.
Full 16-member comparison
RulememberAbortIncompleteMultipartUploadExpirationDate/ExpirationInDaysExpiredObjectDeleteMarkerId/Status/PrefixNoncurrentVersionExpirationNoncurrentVersionExpirationInDaysNoncurrentVersionTransitionNoncurrentVersionTransitionsTransitionInDays)ObjectSizeGreaterThan/ObjectSizeLessThanTagFiltersTransitionTransitionsTest plan
tests/unit/provisioning/s3-bucket-provider-lifecycle-rule-keys.test.ts— 14cases. The fixtures are copied verbatim from the real
cdk synthoutput aboverather than hand-authored, so they pin the shape CDK actually emits (no
Filterwrapper anywhere).Coverage: multi-tag
And.Tagsscoping and the explicit assertion that thecatch-all
Filter: { Prefix: '' }is NOT what we send; every tag preserved;single-tag
Tagform; tags combined with a prefix; delete-marker rule notaction-less; delete-marker not overriding explicit
Days; each legacy singularform; singular+plural concatenation; modern object beating the legacy scalar;
plus two no-regression cases (prefix-only stays V1 with a bare top-level
Prefix; an explicitFilter.TagFiltersstill works for imported input).Plus the two
NoncurrentVersionTransitionscases: the CFnTransitionInDaysspelling a real synth emits, and the SDK
NoncurrentDaysspelling that mustkeep working for imported input.
Revert-proof: restoring
s3-bucket-provider.tsfromorigin/mainfailsexactly 9 of the 14. The 5 that still pass are the two no-regression cases,
the two "already worked" cases, and the SDK-spelling fallback — the expected
split. The restore was verified byte-identical to the commit afterwards.
Full local gate: typecheck, lint, build, 522 files / 8919 tests,
vp run gen:all-matricesclean (the fixture change regeneratedinteg-coverage, included here).Real-AWS verification
s3-lifecycleextended and run end to end (PASS, 58s, 2 deleted / 0 errors /0 orphans). The fixture gained the tag-scoped rule, a plural noncurrent
transition, and an L1
CfnBucketcarrying the legacy singular forms plus arule-level
ExpiredObjectDeleteMarker— shapes the L2 construct cannot emit.verify.shasserts each against a realget-bucket-lifecycle-configurationreadback (tags sorted, since AWS does notpreserve list order):
The first run failed, and the failure is the strongest evidence in this PR:
AWS could only say that once the day count actually reached it — the pre-fix
binary sent nothing there to compare. The fixture's values were genuinely
invalid and were corrected.
Also hardens the post-destroy bucket probe with a bounded retry: S3 propagates
DeleteBuckettoHeadBucketasynchronously and a single probe raced it(both buckets were in fact gone). A bucket that never disappears still FAILs,
so leak detection is unchanged.
Review fix-backs
A 3-axis review ran on the first version. No blockers; every finding is
addressed here, and the integ was re-run afterwards (PASS, 2 deleted / 0 errors
/ 0 orphans). Independently confirmed by the reviewers: the 16-member
enumeration is complete (no 7th gap), the unit fixtures match a real synth
byte-for-byte, and the 9-of-14 revert-proof split holds.
concatenated. Concatenating can emit two transitions with the same
StorageClass, which S3 rejects (Found two transitions with the same storage class), failing the wholePutBucketLifecycleConfiguration— a regression,since pre-fix such a template deployed with the singular simply ignored. It
also now matches the
NoncurrentVersionExpirationpolicy chosen 30 linesabove.
ExpiredObjectDeleteMarkergates on Days/Date, not onExpirationexisting.The nested branch emits an all-undefined object for an empty
Expiration, sothe existence check dropped the marker AND left the rule action-less — the
exact failure the block exists to prevent. A genuine
Days+ marker conflictnow warns instead of dropping silently.
readLifecyclereverse-maps to CFn'sTransitionInDays, matching itsTransitionssibling. Emitting the SDK'sNoncurrentDaysmadecdkd driftreport a permanent phantom diff on every versioned bucket with a noncurrent
transition. Latent until this PR: the write side never delivered the value, so
both sides were empty and agreed by accident.
isPlainObject/coerceCfnNumber/coerceCfnBoolean.typeof x === 'object'accepts arrays andnull, so aTransition: []became an entrywith no
StorageClassand S3 answeredMalformedXMLfor the whole config;and CFn is stringly typed, so
"365"/"true"is exactly what thehand-written templates these legacy branches serve actually carry.
8 further unit tests (22 total).
Deliberately out of scope — filed as #1430 and #1423
#1430 —
AWS::S3::Bucketis not inNESTED_KEY_TARGETS, so the nested-keycritic does not guard this provider; it would have caught 3 of the 6 defects
mechanically. Deferred because the schema fixture must be re-captured first
(it predates the
nestedPropertiescapture) and because the first run auditsevery nested blob in a ~2,500-line provider — its own work, with its own review
surface. Same call as the EMR deferral in #1393.
#1423 —
removing a per-GSI on-demand limit from a template silently no-ops (the old
value stays in AWS; CFn would reset it). Same absent-field-reset class as
#1160. The blocking unknown there — whether
-1is the reset sentinel for theper-GSI
Updateaction — has since been settled by a live AWS probe andrecorded on the issue, so it is ready to implement. Unrelated file
(
dynamodb-globaltable-provider.ts).