Skip to content

Commit a2e71a0

Browse files
committed
fix(s3): apply the 3-axis review fix-backs to the lifecycle rule-key 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.
1 parent 81bb69a commit a2e71a0

4 files changed

Lines changed: 227 additions & 29 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:29:41Z PASS 58 verify.sh issue #1388/#1424 rule-level keys asserted live (tags, NoncurrentDays, 4 legacy keys); 2 del/0 err, 0 orphans
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
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: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,35 @@ import type {
6868
ResourceImportResult,
6969
} from '../../types/resource.js';
7070

71+
/**
72+
* A plain (non-array) object. `typeof x === 'object'` alone accepts arrays and
73+
* `null`; a `Transition: []` or an unresolved intrinsic pushed through as a
74+
* single transition entry makes S3 answer `MalformedXML` for the WHOLE
75+
* lifecycle configuration, so the legacy singular readers screen with this.
76+
*/
77+
function isPlainObject(value: unknown): value is Record<string, unknown> {
78+
return typeof value === 'object' && value !== null && !Array.isArray(value);
79+
}
80+
81+
/**
82+
* Coerce a CFn numeric to a finite number. CloudFormation is stringly typed —
83+
* a hand-written or imported template (the audience the legacy lifecycle
84+
* branches exist for) can carry `"365"` where the schema says number.
85+
*/
86+
function coerceCfnNumber(value: unknown): number | undefined {
87+
if (value === undefined || value === null || value === '') return undefined;
88+
const n = Number(value);
89+
return Number.isFinite(n) ? n : undefined;
90+
}
91+
92+
/** Coerce a CFn boolean, which may arrive as the string `"true"` / `"false"`. */
93+
function coerceCfnBoolean(value: unknown): boolean | undefined {
94+
if (typeof value === 'boolean') return value;
95+
if (value === 'true') return true;
96+
if (value === 'false') return false;
97+
return undefined;
98+
}
99+
71100
/**
72101
* SDK Provider for AWS::S3::Bucket
73102
*
@@ -398,9 +427,26 @@ export class S3BucketProvider implements ResourceProvider {
398427
// whose ONLY action is the delete-marker cleanup produced no `Expiration`
399428
// at all and S3 rejects the action-less rule (issue #1388). S3 forbids
400429
// combining it with Days / Date, so it only fills an empty Expiration.
401-
const ruleLevelDeleteMarker = rule['ExpiredObjectDeleteMarker'];
402-
if (typeof ruleLevelDeleteMarker === 'boolean' && sdkRule.Expiration === undefined) {
403-
sdkRule.Expiration = { ExpiredObjectDeleteMarker: ruleLevelDeleteMarker };
430+
// Gate on whether the built `Expiration` actually carries a Days / Date
431+
// rather than on its mere existence: the nested branch above emits an
432+
// object with every member `undefined` for an empty / unresolved
433+
// `Expiration`, so an existence check would drop the marker AND leave the
434+
// rule action-less — the exact failure this block exists to prevent.
435+
const ruleLevelDeleteMarker = coerceCfnBoolean(rule['ExpiredObjectDeleteMarker']);
436+
if (ruleLevelDeleteMarker !== undefined) {
437+
const exp = sdkRule.Expiration as Record<string, unknown> | undefined;
438+
const hasDaysOrDate = exp?.['Days'] !== undefined || exp?.['Date'] !== undefined;
439+
if (!hasDaysOrDate) {
440+
sdkRule.Expiration = { ExpiredObjectDeleteMarker: ruleLevelDeleteMarker };
441+
} else {
442+
// S3 rejects ExpiredObjectDeleteMarker combined with Days / Date, so
443+
// one of the two has to go. Warn instead of dropping in silence.
444+
this.logger.warn(
445+
`Lifecycle rule '${(rule['Id'] as string) ?? '<unnamed>'}' on ${bucketName} sets ` +
446+
`ExpiredObjectDeleteMarker alongside an expiration Days/Date; S3 forbids ` +
447+
`combining them, so the delete-marker cleanup was not applied.`
448+
);
449+
}
404450
}
405451

406452
// NoncurrentVersionExpiration. The CFn schema ALSO still accepts the
@@ -413,8 +459,11 @@ export class S3BucketProvider implements ResourceProvider {
413459
NoncurrentDays: nve['NoncurrentDays'] as number | undefined,
414460
NewerNoncurrentVersions: nve['NewerNoncurrentVersions'] as number | undefined,
415461
};
416-
} else if (typeof legacyNveDays === 'number') {
417-
sdkRule.NoncurrentVersionExpiration = { NoncurrentDays: legacyNveDays };
462+
} else if (coerceCfnNumber(legacyNveDays) !== undefined) {
463+
// Coerced, not `typeof === 'number'`: CFn is stringly typed and this
464+
// branch exists specifically for hand-written / imported templates,
465+
// which is exactly where `"365"` shows up.
466+
sdkRule.NoncurrentVersionExpiration = { NoncurrentDays: coerceCfnNumber(legacyNveDays) };
418467
}
419468

420469
// NoncurrentVersionTransitions, plus the legacy singular
@@ -440,10 +489,19 @@ export class S3BucketProvider implements ResourceProvider {
440489
const singularNvt = rule['NoncurrentVersionTransition'] as
441490
| Record<string, unknown>
442491
| undefined;
443-
const allNvts = [
444-
...(Array.isArray(nvts) ? nvts : []),
445-
...(singularNvt && typeof singularNvt === 'object' ? [singularNvt] : []),
446-
];
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+
: [];
447505
if (allNvts.length > 0) {
448506
sdkRule.NoncurrentVersionTransitions = allNvts.map(toSdkNvt);
449507
}
@@ -460,12 +518,14 @@ export class S3BucketProvider implements ResourceProvider {
460518
});
461519
const transitions = rule['Transitions'] as Array<Record<string, unknown>> | undefined;
462520
const singularTransition = rule['Transition'] as Record<string, unknown> | undefined;
463-
const allTransitions = [
464-
...(Array.isArray(transitions) ? transitions : []),
465-
...(singularTransition && typeof singularTransition === 'object'
466-
? [singularTransition]
467-
: []),
468-
];
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+
: [];
469529
if (allTransitions.length > 0) {
470530
sdkRule.Transitions = allTransitions.map(toSdkTransition);
471531
}
@@ -2073,7 +2133,15 @@ export class S3BucketProvider implements ResourceProvider {
20732133
if (r.NoncurrentVersionTransitions && r.NoncurrentVersionTransitions.length > 0) {
20742134
out['NoncurrentVersionTransitions'] = r.NoncurrentVersionTransitions.map((nvt) => {
20752135
const item: Record<string, unknown> = {};
2076-
if (nvt.NoncurrentDays !== undefined) item['NoncurrentDays'] = nvt.NoncurrentDays;
2136+
// Reverse-map to the CFn spelling `TransitionInDays`, matching
2137+
// the `Transitions` sibling 20 lines above. Emitting the SDK's
2138+
// `NoncurrentDays` here made `cdkd drift` report a permanent
2139+
// phantom diff on every versioned bucket with a noncurrent
2140+
// transition, because the template baseline carries
2141+
// `TransitionInDays` and `Rules` is compared array-wholesale.
2142+
// Latent until this PR: the write side never delivered the day
2143+
// count, so the two sides were both empty and agreed by accident.
2144+
if (nvt.NoncurrentDays !== undefined) item['TransitionInDays'] = nvt.NoncurrentDays;
20772145
if (nvt.StorageClass !== undefined) item['StorageClass'] = nvt.StorageClass;
20782146
if (nvt.NewerNoncurrentVersions !== undefined)
20792147
item['NewerNoncurrentVersions'] = nvt.NewerNoncurrentVersions;

tests/integration/s3-lifecycle/verify.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
1111
# - an in-place UPDATE that shortens a transition + adds a Filter-based rule
1212
#
1313
# Phases:
14-
# 1. Deploy; assert both rules reached AWS, none carries a top-level Prefix
14+
# 1. Deploy; assert all three rules reached AWS, none carries a top-level Prefix
1515
# (all normalized to V2 Filter form), and the archive rule's expiration=730.
1616
# 2. Re-deploy with CDKD_TEST_UPDATE=true (expiration 730 -> 365, GLACIER
1717
# transition 90 -> 60, + a new big-objects Filter rule). Assert the new
18-
# values reached AWS, there are 3 rules, and the bucket was NOT replaced.
18+
# values reached AWS, there are 4 rules, and the bucket was NOT replaced.
1919
# 3. Destroy; assert the bucket is gone and the state file is removed.
2020
#
2121
# Required env vars:

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

Lines changed: 140 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { PutBucketLifecycleConfigurationCommand } from '@aws-sdk/client-s3';
1717
* not just legacy-template trivia.
1818
*/
1919

20-
const mockSend = vi.fn();
20+
const { mockSend, warnSpy } = vi.hoisted(() => ({ mockSend: vi.fn(), warnSpy: vi.fn() }));
2121

2222
vi.mock('../../../src/utils/aws-clients.js', () => ({
2323
getAwsClients: () => ({
@@ -29,7 +29,7 @@ vi.mock('../../../src/utils/logger.js', () => {
2929
const childLogger = {
3030
debug: vi.fn(),
3131
info: vi.fn(),
32-
warn: vi.fn(),
32+
warn: warnSpy,
3333
error: vi.fn(),
3434
child: vi.fn().mockReturnThis(),
3535
};
@@ -38,7 +38,7 @@ vi.mock('../../../src/utils/logger.js', () => {
3838
child: () => childLogger,
3939
debug: vi.fn(),
4040
info: vi.fn(),
41-
warn: vi.fn(),
41+
warn: warnSpy,
4242
error: vi.fn(),
4343
}),
4444
};
@@ -255,20 +255,20 @@ describe('S3 lifecycle rule-level + legacy singular keys (issue #1388)', () => {
255255
expect(n.NoncurrentVersionExpiration).toEqual({ NoncurrentDays: 365 });
256256
});
257257

258-
it('concatenates the plural and singular forms when a rule carries both', async () => {
258+
it('uses the singular form only when the plural array is absent', async () => {
259+
// The both-present case is covered in the fix-back block below: the
260+
// plural wins, because concatenating can emit two transitions with the
261+
// same StorageClass and S3 rejects the whole configuration.
259262
const rules = await putRules([
260263
{
261-
Id: 'mixed',
264+
Id: 'singular-only',
262265
Status: 'Enabled',
263266
Prefix: 'm/',
264-
Transitions: [{ StorageClass: 'STANDARD_IA', TransitionInDays: 30 }],
265267
Transition: { StorageClass: 'GLACIER', TransitionInDays: 90 },
266268
},
267269
]);
268-
expect(rules[0]!.Transitions).toHaveLength(2);
269-
expect(rules[0]!.Transitions.map((t: { StorageClass: string }) => t.StorageClass)).toEqual([
270-
'STANDARD_IA',
271-
'GLACIER',
270+
expect(rules[0]!.Transitions).toEqual([
271+
{ Days: 90, Date: undefined, StorageClass: 'GLACIER' },
272272
]);
273273
});
274274

@@ -289,6 +289,136 @@ describe('S3 lifecycle rule-level + legacy singular keys (issue #1388)', () => {
289289
});
290290
});
291291

292+
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.
300+
const rules = await putRules([
301+
{
302+
Id: 'both',
303+
Status: 'Enabled',
304+
Prefix: 'm/',
305+
Transitions: [{ StorageClass: 'GLACIER', TransitionInDays: 30 }],
306+
Transition: { StorageClass: 'GLACIER', TransitionInDays: 90 },
307+
},
308+
]);
309+
expect(rules[0]!.Transitions).toEqual([
310+
{ Days: 30, Date: undefined, StorageClass: 'GLACIER' },
311+
]);
312+
});
313+
314+
it('lets the plural NoncurrentVersionTransitions win over the singular too', async () => {
315+
const rules = await putRules([
316+
{
317+
Id: 'both-nvt',
318+
Status: 'Enabled',
319+
Prefix: 'm/',
320+
NoncurrentVersionTransitions: [{ StorageClass: 'GLACIER', TransitionInDays: 10 }],
321+
NoncurrentVersionTransition: { StorageClass: 'GLACIER', TransitionInDays: 20 },
322+
},
323+
]);
324+
expect(rules[0]!.NoncurrentVersionTransitions).toHaveLength(1);
325+
expect(rules[0]!.NoncurrentVersionTransitions[0].NoncurrentDays).toBe(10);
326+
});
327+
328+
it('ignores a non-object singular Transition (array / unresolved intrinsic)', async () => {
329+
// `typeof [] === 'object'`, so an unguarded check pushed it through as a
330+
// transition with no StorageClass and S3 answered MalformedXML for the
331+
// whole config.
332+
const rules = await putRules([
333+
{ Id: 'bad', Status: 'Enabled', Prefix: 'b/', Transition: [], ExpirationInDays: 5 },
334+
]);
335+
expect(rules[0]!.Transitions).toBeUndefined();
336+
});
337+
338+
it('still applies the delete marker when Expiration is present but empty', async () => {
339+
// The nested branch emits an all-undefined object for an empty
340+
// `Expiration`, so gating on mere existence dropped the marker AND left
341+
// the rule action-less — the very failure the block exists to prevent.
342+
const rules = await putRules([
343+
{
344+
Id: 'degenerate',
345+
Status: 'Enabled',
346+
Prefix: 'd/',
347+
Expiration: {},
348+
ExpiredObjectDeleteMarker: true,
349+
},
350+
]);
351+
expect(rules[0]!.Expiration).toEqual({ ExpiredObjectDeleteMarker: true });
352+
});
353+
354+
it('warns instead of silently dropping a delete marker that conflicts with Days', async () => {
355+
await putRules([
356+
{
357+
Id: 'conflict',
358+
Status: 'Enabled',
359+
Prefix: 'c/',
360+
ExpirationInDays: 30,
361+
ExpiredObjectDeleteMarker: true,
362+
},
363+
]);
364+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ExpiredObjectDeleteMarker'));
365+
});
366+
367+
it('coerces stringly-typed CFn scalars from hand-written templates', async () => {
368+
// CFn is stringly typed and these legacy branches exist precisely for
369+
// hand-written / imported templates, where `"365"` / `"true"` show up.
370+
const rules = await putRules([
371+
{
372+
Id: 'stringly',
373+
Status: 'Enabled',
374+
Prefix: 's/',
375+
NoncurrentVersionExpirationInDays: '365',
376+
},
377+
{
378+
Id: 'stringly-marker',
379+
Status: 'Enabled',
380+
Prefix: 'sm/',
381+
ExpiredObjectDeleteMarker: 'true',
382+
},
383+
]);
384+
expect(rules[0]!.NoncurrentVersionExpiration).toEqual({ NoncurrentDays: 365 });
385+
expect(rules[1]!.Expiration).toEqual({ ExpiredObjectDeleteMarker: true });
386+
});
387+
388+
it('combines rule-level tags with a size constraint under And', async () => {
389+
// A real synth emits tags AND ObjectSizeGreaterThan at the rule level on
390+
// one rule; that hits the multi-component `And` branch.
391+
const rules = await putRules([
392+
{
393+
Id: 'tags-and-size',
394+
Status: 'Enabled',
395+
TagFilters: [{ Key: 'env', Value: 'prod' }],
396+
ObjectSizeGreaterThan: 1024,
397+
ExpirationInDays: 30,
398+
},
399+
]);
400+
expect(rules[0]!.Filter.And).toMatchObject({
401+
Tags: [{ Key: 'env', Value: 'prod' }],
402+
ObjectSizeGreaterThan: 1024,
403+
});
404+
});
405+
406+
it('applies the delete marker alongside an ExpirationDate-free rule', async () => {
407+
const rules = await putRules([
408+
{
409+
Id: 'date-marker',
410+
Status: 'Enabled',
411+
Prefix: 'dm/',
412+
ExpirationDate: '2030-01-01T00:00:00Z',
413+
ExpiredObjectDeleteMarker: true,
414+
},
415+
]);
416+
// Date is set, so the marker must NOT be smuggled in beside it.
417+
expect(rules[0]!.Expiration.Date).toBeInstanceOf(Date);
418+
expect(rules[0]!.Expiration.ExpiredObjectDeleteMarker).toBeUndefined();
419+
});
420+
});
421+
292422
describe('no regression on the shapes that already worked', () => {
293423
it('keeps a prefix-only config in V1 form (bare top-level Prefix, no Filter)', async () => {
294424
const rules = await putRules([

0 commit comments

Comments
 (0)