Skip to content

Commit 314768d

Browse files
committed
fix(s3): apply the delta-review findings on the lifecycle fix-backs
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.
1 parent a2e71a0 commit 314768d

3 files changed

Lines changed: 127 additions & 43 deletions

File tree

docs/_generated/integ-last-run.tsv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ s3-asset-deploy 2026-07-26T19:45:58Z PASS 58 verify.sh 0727b sweep-b12 staleness
230230
s3-cloudfront 2026-08-08T17:42:05Z PASS 228 verify.sh 1373 OriginCustomHeaders create+update-survive asserts green; destroy 0 errors 0 orphans
231231
s3-directory-bucket 2026-08-02T15:38:47Z PASS 50 verify.sh re-run for #1347 wontdo-comment PR; guard + clean destroy, 0 orphans
232232
s3-event-notification 2026-07-21T05:28:05Z PASS 79 verify.sh rc ok, orph clean
233-
s3-lifecycle 2026-08-09T13:48:40Z PASS 57 verify.sh post-review re-run (#1388/#1424); rule-level keys asserted live; 2 del/0 err, 0 orphans
233+
s3-lifecycle 2026-08-09T13:59:22Z PASS 57 verify.sh post-delta-review re-run (#1388/#1424); rule-level keys asserted live; 2 del/0 err, 0 orphans
234234
s3-object-lock 2026-07-21T14:42:20Z PASS 53 verify.sh rc ok, orph clean
235235
s3-replication-and-filter 2026-07-21T14:43:45Z PASS 63 verify.sh rc ok, orph clean
236236
s3-tables 2026-07-27T16:53:47Z PASS 45 verify.sh issue #1270/#1272 post-review re-run; 5 del 0 err, 0 orphans

src/provisioning/providers/s3-bucket-provider.ts

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,40 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
8484
* branches exist for) can carry `"365"` where the schema says number.
8585
*/
8686
function coerceCfnNumber(value: unknown): number | undefined {
87-
if (value === undefined || value === null || value === '') return undefined;
87+
// Screen the TYPE first: `Number([])` is 0 and `Number([5])` is 5, so an
88+
// unresolved-intrinsic array would coerce to a plausible-looking day count.
89+
// That is the same class `isPlainObject` blocks on the object path.
90+
if (typeof value !== 'number' && typeof value !== 'string') return undefined;
91+
if (value === '') return undefined;
8892
const n = Number(value);
8993
return Number.isFinite(n) ? n : undefined;
9094
}
9195

96+
/**
97+
* Merge a legacy SINGULAR action object into its modern plural array.
98+
*
99+
* S3 rejects two transitions with the same `StorageClass` and fails the whole
100+
* `PutBucketLifecycleConfiguration`, so a blind concatenation is unsafe. But
101+
* dropping the singular wholesale loses a legitimate template: a plural
102+
* `[{GLACIER, 30}]` alongside a singular `{DEEP_ARCHIVE, 90}` is two different
103+
* classes, which S3 accepts. So: keep both, let the PLURAL win on a
104+
* StorageClass collision, and say so rather than dropping in silence.
105+
*/
106+
function mergeLegacySingular(
107+
plural: unknown,
108+
singular: unknown,
109+
onCollision: (storageClass: string) => void
110+
): Array<Record<string, unknown>> {
111+
const out = (Array.isArray(plural) ? plural : []).filter(isPlainObject);
112+
if (!isPlainObject(singular)) return out;
113+
const sc = singular['StorageClass'];
114+
if (typeof sc === 'string' && out.some((e) => e['StorageClass'] === sc)) {
115+
onCollision(sc);
116+
return out;
117+
}
118+
return [...out, singular];
119+
}
120+
92121
/** Coerce a CFn boolean, which may arrive as the string `"true"` / `"false"`. */
93122
function coerceCfnBoolean(value: unknown): boolean | undefined {
94123
if (typeof value === 'boolean') return value;
@@ -432,12 +461,16 @@ export class S3BucketProvider implements ResourceProvider {
432461
// object with every member `undefined` for an empty / unresolved
433462
// `Expiration`, so an existence check would drop the marker AND leave the
434463
// rule action-less — the exact failure this block exists to prevent.
464+
// Only TRUE engages. `false` is a legal synth (CDK's own validation is
465+
// truthy-gated), and treating it as a request would both warn about a
466+
// cleanup nobody asked for and, on a marker-only rule, emit
467+
// `Expiration: { ExpiredObjectDeleteMarker: false }` — action-less again.
435468
const ruleLevelDeleteMarker = coerceCfnBoolean(rule['ExpiredObjectDeleteMarker']);
436-
if (ruleLevelDeleteMarker !== undefined) {
469+
if (ruleLevelDeleteMarker === true) {
437470
const exp = sdkRule.Expiration as Record<string, unknown> | undefined;
438471
const hasDaysOrDate = exp?.['Days'] !== undefined || exp?.['Date'] !== undefined;
439472
if (!hasDaysOrDate) {
440-
sdkRule.Expiration = { ExpiredObjectDeleteMarker: ruleLevelDeleteMarker };
473+
sdkRule.Expiration = { ExpiredObjectDeleteMarker: true };
441474
} else {
442475
// S3 rejects ExpiredObjectDeleteMarker combined with Days / Date, so
443476
// one of the two has to go. Warn instead of dropping in silence.
@@ -452,7 +485,9 @@ export class S3BucketProvider implements ResourceProvider {
452485
// NoncurrentVersionExpiration. The CFn schema ALSO still accepts the
453486
// legacy scalar `NoncurrentVersionExpirationInDays` alongside the modern
454487
// object form; the object wins when both are present (issue #1388).
455-
const nve = rule['NoncurrentVersionExpiration'] as Record<string, unknown> | undefined;
488+
const nve = isPlainObject(rule['NoncurrentVersionExpiration'])
489+
? rule['NoncurrentVersionExpiration']
490+
: undefined;
456491
const legacyNveDays = rule['NoncurrentVersionExpirationInDays'];
457492
if (nve) {
458493
sdkRule.NoncurrentVersionExpiration = {
@@ -468,8 +503,8 @@ export class S3BucketProvider implements ResourceProvider {
468503

469504
// NoncurrentVersionTransitions, plus the legacy singular
470505
// `NoncurrentVersionTransition` object the CFn schema still accepts.
471-
// Both may appear on one rule, so they are concatenated rather than
472-
// treated as alternatives.
506+
// Both may appear on one rule; they are MERGED, with the plural winning
507+
// on a StorageClass collision (see `mergeLegacySingular`).
473508
const toSdkNvt = (nvt: Record<string, unknown>): Record<string, unknown> => ({
474509
// CFn spells the day count `TransitionInDays` on BOTH the singular
475510
// `NoncurrentVersionTransition` and the plural
@@ -489,25 +524,19 @@ export class S3BucketProvider implements ResourceProvider {
489524
const singularNvt = rule['NoncurrentVersionTransition'] as
490525
| Record<string, unknown>
491526
| undefined;
492-
// The PLURAL array wins when both forms are present, matching the
493-
// NoncurrentVersionExpiration policy 30 lines above. Concatenating them
494-
// instead would be a REGRESSION: S3 rejects two transitions with the same
495-
// StorageClass (`InvalidRequest: Found two transitions with the same
496-
// storage class`) and fails the whole PutBucketLifecycleConfiguration,
497-
// whereas pre-fix such a template deployed because the singular form was
498-
// simply ignored.
499-
const allNvts =
500-
Array.isArray(nvts) && nvts.length > 0
501-
? nvts
502-
: isPlainObject(singularNvt)
503-
? [singularNvt]
504-
: [];
527+
const allNvts = mergeLegacySingular(nvts, singularNvt, (sc) =>
528+
this.logger.warn(
529+
`Lifecycle rule '${(rule['Id'] as string) ?? '<unnamed>'}' on ${bucketName} declares ` +
530+
`both NoncurrentVersionTransitions and the legacy NoncurrentVersionTransition for ` +
531+
`storage class ${sc}; S3 rejects duplicates, so the legacy singular was ignored.`
532+
)
533+
);
505534
if (allNvts.length > 0) {
506535
sdkRule.NoncurrentVersionTransitions = allNvts.map(toSdkNvt);
507536
}
508537

509-
// Transitions, plus the legacy singular `Transition` object. Same
510-
// concatenation rule as the noncurrent-version pair above.
538+
// Transitions, plus the legacy singular `Transition` object. Same merge
539+
// rule as the noncurrent-version pair above.
511540
const toSdkTransition = (t: Record<string, unknown>): Record<string, unknown> => ({
512541
Days: (t['TransitionInDays'] ?? t['Days']) as number | undefined,
513542
Date:
@@ -518,20 +547,21 @@ export class S3BucketProvider implements ResourceProvider {
518547
});
519548
const transitions = rule['Transitions'] as Array<Record<string, unknown>> | undefined;
520549
const singularTransition = rule['Transition'] as Record<string, unknown> | undefined;
521-
// Plural wins over the legacy singular — see the NVT note above for why
522-
// concatenating is not safe.
523-
const allTransitions =
524-
Array.isArray(transitions) && transitions.length > 0
525-
? transitions
526-
: isPlainObject(singularTransition)
527-
? [singularTransition]
528-
: [];
550+
const allTransitions = mergeLegacySingular(transitions, singularTransition, (sc) =>
551+
this.logger.warn(
552+
`Lifecycle rule '${(rule['Id'] as string) ?? '<unnamed>'}' on ${bucketName} declares ` +
553+
`both Transitions and the legacy Transition for storage class ${sc}; S3 rejects ` +
554+
`duplicates, so the legacy singular was ignored.`
555+
)
556+
);
529557
if (allTransitions.length > 0) {
530558
sdkRule.Transitions = allTransitions.map(toSdkTransition);
531559
}
532560

533561
// AbortIncompleteMultipartUpload
534-
const abort = rule['AbortIncompleteMultipartUpload'] as Record<string, unknown> | undefined;
562+
const abort = isPlainObject(rule['AbortIncompleteMultipartUpload'])
563+
? rule['AbortIncompleteMultipartUpload']
564+
: undefined;
535565
if (abort) {
536566
sdkRule.AbortIncompleteMultipartUpload = {
537567
DaysAfterInitiation: abort['DaysAfterInitiation'] as number | undefined,

tests/unit/provisioning/s3-bucket-provider-lifecycle-rule-keys.test.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -290,16 +290,14 @@ describe('S3 lifecycle rule-level + legacy singular keys (issue #1388)', () => {
290290
});
291291

292292
describe('review fix-backs (PR #1426)', () => {
293-
it('lets the plural array win over the legacy singular instead of concatenating', async () => {
294-
// Concatenating emits two transitions with the same StorageClass, which
295-
// S3 rejects outright ("Found two transitions with the same storage
296-
// class") and fails the WHOLE PutBucketLifecycleConfiguration — a
297-
// regression, since pre-fix such a template deployed with the singular
298-
// simply ignored. Plural-wins also matches the NoncurrentVersionExpiration
299-
// policy.
293+
it('lets the plural win — and warns — when the singular COLLIDES on StorageClass', async () => {
294+
// S3 rejects two transitions with the same StorageClass and fails the
295+
// WHOLE PutBucketLifecycleConfiguration, so they cannot both be sent.
296+
// Dropping the loser silently would be the very thing this PR argues
297+
// against, so it warns.
300298
const rules = await putRules([
301299
{
302-
Id: 'both',
300+
Id: 'collide',
303301
Status: 'Enabled',
304302
Prefix: 'm/',
305303
Transitions: [{ StorageClass: 'GLACIER', TransitionInDays: 30 }],
@@ -309,20 +307,76 @@ describe('S3 lifecycle rule-level + legacy singular keys (issue #1388)', () => {
309307
expect(rules[0]!.Transitions).toEqual([
310308
{ Days: 30, Date: undefined, StorageClass: 'GLACIER' },
311309
]);
310+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('storage class GLACIER'));
311+
});
312+
313+
it('KEEPS both when the singular is a DIFFERENT StorageClass', async () => {
314+
// S3 accepts them; dropping the singular here would lose a legitimate
315+
// template for no reason.
316+
const rules = await putRules([
317+
{
318+
Id: 'distinct',
319+
Status: 'Enabled',
320+
Prefix: 'm/',
321+
Transitions: [{ StorageClass: 'GLACIER', TransitionInDays: 30 }],
322+
Transition: { StorageClass: 'DEEP_ARCHIVE', TransitionInDays: 90 },
323+
},
324+
]);
325+
expect(rules[0]!.Transitions.map((t: { StorageClass: string }) => t.StorageClass)).toEqual([
326+
'GLACIER',
327+
'DEEP_ARCHIVE',
328+
]);
329+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('storage class'));
312330
});
313331

314-
it('lets the plural NoncurrentVersionTransitions win over the singular too', async () => {
332+
it('merges the noncurrent pair the same way', async () => {
315333
const rules = await putRules([
316334
{
317335
Id: 'both-nvt',
318336
Status: 'Enabled',
319337
Prefix: 'm/',
320338
NoncurrentVersionTransitions: [{ StorageClass: 'GLACIER', TransitionInDays: 10 }],
321-
NoncurrentVersionTransition: { StorageClass: 'GLACIER', TransitionInDays: 20 },
339+
NoncurrentVersionTransition: { StorageClass: 'DEEP_ARCHIVE', TransitionInDays: 20 },
340+
},
341+
]);
342+
expect(rules[0]!.NoncurrentVersionTransitions).toHaveLength(2);
343+
expect(rules[0]!.NoncurrentVersionTransitions[1].NoncurrentDays).toBe(20);
344+
});
345+
346+
it('ignores ExpiredObjectDeleteMarker: false instead of warning or emitting it', async () => {
347+
// `false` is a legal synth (CDK's validation is truthy-gated). Treating
348+
// it as a request warned about a cleanup nobody asked for, and on a
349+
// marker-only rule emitted an action-less Expiration.
350+
const rules = await putRules([
351+
{ Id: 'off', Status: 'Enabled', Prefix: 'o/', ExpiredObjectDeleteMarker: false },
352+
{
353+
Id: 'off-with-days',
354+
Status: 'Enabled',
355+
Prefix: 'od/',
356+
ExpirationInDays: 30,
357+
ExpiredObjectDeleteMarker: false,
358+
},
359+
]);
360+
expect(rules[0]!.Expiration).toBeUndefined();
361+
expect(rules[1]!.Expiration).toEqual({ Days: 30 });
362+
expect(warnSpy).not.toHaveBeenCalledWith(
363+
expect.stringContaining('ExpiredObjectDeleteMarker')
364+
);
365+
});
366+
367+
it('rejects a non-scalar CFn numeric instead of coercing it to 0', async () => {
368+
// `Number([])` is 0 and `Number([5])` is 5, so an unresolved-intrinsic
369+
// array would become a plausible-looking day count.
370+
const rules = await putRules([
371+
{
372+
Id: 'bad-num',
373+
Status: 'Enabled',
374+
Prefix: 'n/',
375+
NoncurrentVersionExpirationInDays: [],
376+
ExpirationInDays: 5,
322377
},
323378
]);
324-
expect(rules[0]!.NoncurrentVersionTransitions).toHaveLength(1);
325-
expect(rules[0]!.NoncurrentVersionTransitions[0].NoncurrentDays).toBe(10);
379+
expect(rules[0]!.NoncurrentVersionExpiration).toBeUndefined();
326380
});
327381

328382
it('ignores a non-object singular Transition (array / unresolved intrinsic)', async () => {

0 commit comments

Comments
 (0)