-
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathtemplate-adapter.ts
More file actions
1004 lines (969 loc) · 55.8 KB
/
Copy pathtemplate-adapter.ts
File metadata and controls
1004 lines (969 loc) · 55.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Builds the "declared desired" view of a deployed stack:
// GetTemplate + ListStackResources (phys-id map, paginated) + DescribeStacks (params)
// → intrinsic-resolve each resource's declared properties.
// Slice scope: JSON templates (CDK app output). YAML support is a follow-up.
import {
type CloudFormationClient,
DescribeStacksCommand,
GetTemplateCommand,
ListExportsCommand,
ListStackResourcesCommand,
} from '@aws-sdk/client-cloudformation';
import { GetParametersCommand, SSMClient, type SSMClientConfig } from '@aws-sdk/client-ssm';
import { classifyStackStatus, StackNotCheckableError } from '../aws-errors.js';
import { evalCondition, resolveProperties } from '../normalize/intrinsic-resolver.js';
import { READ_RETRY } from '../read/client-config.js';
import type { DesiredResource, ResolverContext } from '../types.js';
import { recoverNonAsciiMasks } from './recover-nonascii.js';
import { parseCfnTemplate } from './yaml-cfn.js';
export interface Desired {
stackName: string;
region: string;
accountId: string;
resources: DesiredResource[];
// CloudFormation STACK-level tags (`cdk deploy --tags`, `create-stack --tags`, StackSets,
// Service Catalog) as key->value — from DescribeStacks. CFN propagates these onto every
// taggable resource without them appearing in the template, so classify subtracts them from
// each resource's live `Tags` to avoid a first-run / declared-tier tag FP (#683). Optional +
// post-assigned below to keep loadDesired's return object literal small (a tsgolint budget
// quirk cascades false lint errors when that literal grows — see #683).
stackTags?: Record<string, string>;
rawTemplate: string; // verbatim deployed template body (for baseline templateHash)
ctx: ResolverContext; // exposed so gather can re-resolve GetAtt once live attrs are read
// set when the stack's StackStatus is mid-operation / failed (a comparison still runs
// but results may be unreliable — check prints this). REVIEW_IN_PROGRESS / deleting
// states never reach here: loadDesired throws StackNotCheckableError for those.
stackStatusWarning?: string | undefined;
// #1737: declared parents whose child-enumerator scan COMPLETED this gather run (type
// in CHILD_ENUMERATORS, parent read live, enumerate() returned without throwing).
// Post-assigned by gather (like stackTags — run-state metadata riding Desired so the
// record path can reach it without new plumbing): `record` writes these as the
// baseline `enumeratedParents` marker, which is what lets a LATER check confirm that
// a live child with no recorded entry under such a parent appeared after the record.
childScanComplete?: { logicalId: string; resourceType: string }[];
}
/** Parse a deployed template body (JSON or CFn-flavored YAML). */
export function parseTemplateBody(body: string): Record<string, unknown> {
return parseCfnTemplate(body);
}
// #883: under --pre-deploy the declared source is the LOCAL synth template, so the
// symmetric half of "resource in the template but not yet deployed" is "resource DEPLOYED
// but absent from the template" — a live resource the next deploy will DELETE (a rename
// X->Y, or a construct removed from the app). loadDesired iterates only template Resources,
// so those deployed-only logical ids are otherwise invisible: the report shows the new
// resource as pending creation and says NOTHING about the one being torn down (often a
// stateful resource). Compute it from physIds (the live stack's resources) minus the local
// template's logical ids — zero extra AWS calls. Returns the info line, or null when none.
// Pure + exported for unit tests.
export function deletedResourceInfo(
physIds: Record<string, string>,
template: Record<string, unknown>,
stackName: string
): string | null {
const templateIds = new Set(Object.keys((template.Resources ?? {}) as Record<string, unknown>));
const deleted = Object.keys(physIds)
.filter((id) => !templateIds.has(id))
.sort();
if (deleted.length === 0) return null;
const shown = deleted.slice(0, 10);
const more = deleted.length > shown.length ? `, …(+${deleted.length - shown.length} more)` : '';
return `info: ${stackName}: ${deleted.length} deployed resource(s) absent from the local template — the next deploy will DELETE them: ${shown.join(', ')}${more}`;
}
// #882: under --pre-deploy the declared type at a logical id comes from the LOCAL synth
// template, while physIds + deployedTypeOf come from the deployed stack. Swapping a construct
// at the SAME construct path (`new sqs.Queue(this,'X')` -> `new sns.Topic(this,'X')`) keeps
// the logical id but changes the Type. Attaching the deployed QUEUE's physical id to a
// DesiredResource of the new TOPIC type makes the live read do
// GetResource(TypeName=<new>, Identifier=<old-queue-id>) -> not-found -> a FALSE "resource
// deleted out of band". In reality the next deploy will REPLACE the resource (delete the old,
// create the new). Detect it (template type != deployed type at the same logical id) so the
// caller can (a) withhold the stale physical id and (b) surface a "will REPLACE" note instead
// of a false deletion. Returns the changed logical ids (sorted) — pure + exported for tests.
export function typeChangedResources(
templateResources: Record<string, { Type?: string }>,
deployedTypeOf: Record<string, string>
): string[] {
return Object.entries(templateResources)
.filter(([lid, res]) => {
const declaredType = res?.Type;
const deployedType = deployedTypeOf[lid];
// both types must be known AND differ; an unknown deployed type (id not in the live
// stack — a brand-new resource) is a normal creation, not a type change.
return (
typeof declaredType === 'string' &&
typeof deployedType === 'string' &&
declaredType !== deployedType
);
})
.map(([lid]) => lid)
.sort();
}
// Build the human-facing "this deploy will REPLACE" note for type-changed logical ids under
// --pre-deploy. Returns null when none changed. Pure + exported for unit tests.
export function typeChangeReplaceInfo(
changed: string[],
templateResources: Record<string, { Type?: string }>,
deployedTypeOf: Record<string, string>,
stackName: string
): string | null {
if (changed.length === 0) return null;
const shown = changed
.slice(0, 10)
.map((lid) => `${lid} (${deployedTypeOf[lid]} -> ${templateResources[lid]?.Type})`);
const more = changed.length > shown.length ? `, …(+${changed.length - shown.length} more)` : '';
return `info: ${stackName}: ${changed.length} resource(s) changed Type at the same logical id — the next deploy will REPLACE them (old resource deleted, new created): ${shown.join(', ')}${more}`;
}
// Collect the NAMES referenced by `{ Ref: X }` or an `Fn::Sub` `${X}` anywhere under the given
// (Resources) subtree, so the unpreviewable-param note below can be scoped to params that
// actually feed a declared property. `${!Literal}` is a Sub-escaped literal (not a ref) and is
// skipped; a `${Res.Attr}` GetAtt-style key contributes only its base name.
function collectReferencedNames(node: unknown, out: Set<string> = new Set()): Set<string> {
if (Array.isArray(node)) {
for (const x of node) collectReferencedNames(x, out);
return out;
}
if (node && typeof node === 'object') {
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
if (k === 'Ref' && typeof v === 'string') {
out.add(v);
} else if (k === 'Fn::Sub') {
const tmpl = Array.isArray(v) ? v[0] : v;
if (typeof tmpl === 'string') {
for (const m of tmpl.matchAll(/\$\{([^}]+)\}/g)) {
const name = m[1]?.trim();
if (!name || name.startsWith('!')) continue; // ${!Literal} escape, not a reference
const base = name.split('.')[0]; // ${Res.Attr} -> base name
if (base) out.add(base);
}
}
collectReferencedNames(v, out); // still walk the vars map / nested intrinsics
} else {
collectReferencedNames(v, out);
}
}
}
return out;
}
// #728 case 1 / #1194: under --pre-deploy the declared source is the LOCAL synth template, so
// its Parameters are seeded from local `Default`s and only FILLED by the deployed DescribeStacks
// values (buildResolverContext). A parameter that is NEW/renamed in the local template — no
// local `Default` AND absent from the deployed stack — has NO value anywhere, so every `{ Ref }`
// to it resolves UNRESOLVED and the referencing declared property lands SILENTLY in the generic
// `unresolved` "not compared" info line. The canonical trigger is a legacy-synth
// `AssetParameters<newhash>S3Bucket / S3VersionKey` after an asset change (a new hash → a
// brand-new param with no Default and no deployed value) — EXACTLY the change --pre-deploy
// exists to preview. Surface such params LOUDLY (a distinct coverage warning naming the ROOT
// CAUSE the generic footer cannot) so a reviewer sees the property was not compared. NoEcho and
// SSM `::Parameter::Value<` params are EXCLUDED — they are intentionally left UNRESOLVED under
// their own documented treatment (#744/#882), not the new/renamed-no-Default case here. Only
// counts params actually REFERENCED by a declared resource property (an unused new param causes
// no skipped comparison). The referencing properties are already `unresolved`-tier findings, so
// `--strict` treats them as a coverage gap; this note only makes the cause visible. Returns the
// note, or null when none. Pure + exported for unit tests; the caller gates on templateOverride.
export function unpreviewableParamInfo(
template: Record<string, any>,
stackParams: Record<string, string>,
stackName: string
): string | null {
const paramDefs = (template.Parameters ?? {}) as Record<
string,
{ Default?: unknown; Type?: string; NoEcho?: unknown }
>;
const noValue = Object.entries(paramDefs)
.filter(([k, def]) => {
if (def?.NoEcho === true || def?.NoEcho === 'true') return false; // #744 masked treatment
if ((def?.Type ?? '').includes('::Parameter::Value<')) return false; // #882 SSM treatment
if (def && 'Default' in def) return false; // seeded from the local Default
return !(k in stackParams); // no deployed value to fill it
})
.map(([k]) => k);
if (noValue.length === 0) return null;
// #1296: a no-value param referenced ONLY through a Condition (e.g. `Fn::If`-fed property or a
// resource-level `Condition:` attribute) is still unpreviewable — union in the Conditions bodies
// so its Refs are counted, not just those under Resources.
const referenced = collectReferencedNames(template.Resources ?? {});
collectReferencedNames(template.Conditions ?? {}, referenced);
const unpreviewable = noValue.filter((k) => referenced.has(k)).sort();
if (unpreviewable.length === 0) return null;
const shown = unpreviewable.slice(0, 10);
const more =
unpreviewable.length > shown.length ? `, …(+${unpreviewable.length - shown.length} more)` : '';
return `warning: ${stackName}: ${unpreviewable.length} local parameter(s) have NO value under --pre-deploy (new/renamed, no Default, absent from the deployed stack) — every declared property that Refs them resolves UNRESOLVED and was NOT compared (cannot preview): ${shown.join(', ')}${more}`;
}
// #1292: under --pre-deploy a CHANGED local param Default wins over the deployed DescribeStacks
// value (#1194), so the preview resolves every property fed by the param from the NEW Default —
// but deployment tools do not apply it that way: for an EXISTING parameter a plain `cdk deploy`
// (and `aws cloudformation deploy`) always sends `UsePreviousValue: true` (toolkit-lib's
// ParameterValues), so the gated deploy KEEPS the deployed value unless the user passes
// `--parameters <key>=<value>` / `--no-previous-parameters`. DescribeStacks offers no
// explicit-vs-default signal either, so a param explicitly set at deploy time is
// indistinguishable from a changed Default — either way the previewed declared drift may be
// one a plain deploy will NOT apply. Resolution is deliberately unchanged (the local Default
// still wins — reverting #1194 would re-mask the drift); instead surface the divergence LOUDLY:
// one aggregated stderr note per stack naming each such param, both values, and the
// UsePreviousValue caveat. NoEcho and SSM `::Parameter::Value<` params are EXCLUDED — their
// local Default is a placeholder / SSM key that is never seeded (#744/#882), so no preview is
// derived from it. Only counts params actually REFERENCED by a declared resource property or a
// Condition (an unused param feeds no previewed value). Returns the note, or null when none.
// Pure + exported for unit tests; the caller gates on templateOverride.
export function changedDefaultParamInfo(
template: Record<string, any>,
stackParams: Record<string, string>,
stackName: string
): string | null {
const paramDefs = (template.Parameters ?? {}) as Record<
string,
{ Default?: unknown; Type?: string; NoEcho?: unknown }
>;
// CloudFormation whitespace-trims CommaDelimitedList values ("a, b" == "a,b"), so compare
// list-typed params on the trimmed split — mirroring buildResolverContext's toParam — else
// a cosmetic-whitespace Default would false-note. (SSM list variants are excluded above.)
const normalize = (k: string, v: string): string => {
const t = paramDefs[k]?.Type ?? '';
if (t !== 'CommaDelimitedList' && !t.startsWith('List<')) return v;
return v
.split(',')
.map((s) => s.trim())
.join(',');
};
const changed = Object.entries(paramDefs)
.filter(([k, def]) => {
if (def?.NoEcho === true || def?.NoEcho === 'true') return false; // #744 masked treatment
if ((def?.Type ?? '').includes('::Parameter::Value<')) return false; // #882 SSM treatment
if (!def || !('Default' in def)) return false; // no local Default → the fill step applies
if (!(k in stackParams)) return false; // new param, no deployed value → #1221's warning
return normalize(k, String(def.Default)) !== normalize(k, stackParams[k] ?? '');
})
.map(([k]) => k);
if (changed.length === 0) return null;
const referenced = collectReferencedNames(template.Resources ?? {});
collectReferencedNames(template.Conditions ?? {}, referenced);
const diverged = changed.filter((k) => referenced.has(k)).sort();
if (diverged.length === 0) return null;
const shown = diverged
.slice(0, 10)
.map(
(k) =>
`${k} (local Default ${JSON.stringify(String(paramDefs[k]?.Default))}, deployed ${JSON.stringify(stackParams[k])})`
);
const more = diverged.length > shown.length ? `, …(+${diverged.length - shown.length} more)` : '';
return `warning: ${stackName}: ${diverged.length} local parameter(s) changed their Default vs the deployed value — the preview resolves them from the NEW local Default, but a plain \`cdk deploy\` KEEPS the deployed value (UsePreviousValue) unless \`--parameters <key>=<value>\` / \`--no-previous-parameters\` is passed: ${shown.join(', ')}${more}`;
}
// #904: a local synth template that carries a server-side Transform (SAM
// `AWS::Serverless-2016-10-31`, `AWS::LanguageExtensions`, or a custom macro) is
// systemically un-previewable under `--pre-deploy`. The normal deployed path fetches
// the PROCESSED template (transforms already expanded server-side), so a SAM
// `AWS::Serverless::Function` is an ordinary `AWS::Lambda::Function` and the
// transform-GENERATED Role/RestApi are declared resources that compare cleanly. But
// `--pre-deploy` swaps in the LOCAL, UNPROCESSED template: CDK never runs the
// server-side transform, so every `AWS::Serverless::*` resource has no readable live
// type (CC `TypeNotFoundException` → permanently `skipped`, its Handler/Runtime/Policies
// never compared) and each transform-generated live resource is invisible (the `added`
// tier only enumerates children of DECLARED parents). Rather than emit confusing
// per-type DescribeType spam + silent skips, surface ONE honest per-stack warning naming
// the Transform and the affected Serverless resources. Pure + exported for unit tests;
// the caller gates on templateOverride (--pre-deploy). Returns the note, or null when the
// template carries no server-side transform.
export function transformStackWarning(
template: Record<string, any>,
stackName: string
): string | null {
// A `Transform` directive names one or more macros (string or array). SAM and
// LanguageExtensions are the common named ones; any value is a server-side transform.
const rawTransform = template.Transform as unknown;
const transforms = (Array.isArray(rawTransform) ? rawTransform : [rawTransform])
.filter((t): t is string => typeof t === 'string' && t.length > 0)
.sort();
const resources = (template.Resources ?? {}) as Record<string, { Type?: unknown }>;
const serverless = Object.entries(resources)
.filter(([, def]) => typeof def?.Type === 'string' && def.Type.startsWith('AWS::Serverless::'))
.map(([logicalId]) => logicalId)
.sort();
// Fire only when the local template actually carries a transform (a named Transform
// directive OR a SAM `AWS::Serverless::*` resource — SAM stacks always declare the
// Transform, but guard on the resource set too so a macro that mutates ordinary types
// is still caught). A transform-free stack previews normally.
if (transforms.length === 0 && serverless.length === 0) return null;
const parts: string[] = [];
if (transforms.length > 0) parts.push(`Transform ${transforms.join(', ')}`);
if (serverless.length > 0) {
const shown = serverless.slice(0, 10);
const more =
serverless.length > shown.length ? `, …(+${serverless.length - shown.length} more)` : '';
parts.push(`${serverless.length} AWS::Serverless::* resource(s): ${shown.join(', ')}${more}`);
}
return `warning: ${stackName}: --pre-deploy CANNOT process server-side transforms — CDK's local synth output is UNPROCESSED, so transform-expanded resources are NOT previewed (${parts.join('; ')}). Each AWS::Serverless::* resource is \`skipped\` (its declared props are not compared) and any transform-generated resource is invisible. Run \`check\` WITHOUT --pre-deploy (the deployed, already-processed template) for a full comparison of a transformed stack.`;
}
// The AWS::Partition / AWS::URLSuffix pseudo-parameters are a deterministic function of the
// region, NOT a commercial-partition constant. CDK env-agnostic stacks emit ${AWS::Partition}
// inside nearly every Sub/Join-built ARN, so hard-coding `aws` / `amazonaws.com` mis-resolves
// EVERY such declared ARN in GovCloud (`arn:aws-us-gov:...`) or China (`arn:aws-cn:...`) → a
// declared-tier FP on essentially every resource. Derive both from the region prefix (#730).
// Ordering note: `us-isob-` / `us-isof-` do not start with `us-iso-` (the char after `us-iso`
// is a letter, not `-`), so the `us-iso-` test does not swallow them; still, keep the more
// specific ISO prefixes listed for clarity.
export function partitionForRegion(region: string): { partition: string; urlSuffix: string } {
if (region.startsWith('us-gov-')) return { partition: 'aws-us-gov', urlSuffix: 'amazonaws.com' };
if (region.startsWith('cn-')) return { partition: 'aws-cn', urlSuffix: 'amazonaws.com.cn' };
if (region.startsWith('us-iso-')) return { partition: 'aws-iso', urlSuffix: 'c2s.ic.gov' };
if (region.startsWith('us-isob-')) return { partition: 'aws-iso-b', urlSuffix: 'sc2s.sgov.gov' };
if (region.startsWith('us-isof-')) return { partition: 'aws-iso-f', urlSuffix: 'csp.hci.ic.gov' };
if (region.startsWith('eu-isoe-')) return { partition: 'aws-iso-e', urlSuffix: 'cloud.adc-e.uk' };
return { partition: 'aws', urlSuffix: 'amazonaws.com' };
}
export function buildResolverContext(
template: Record<string, any>,
stackParams: Record<string, string>,
physIds: Record<string, string>,
region: string,
accountId: string,
stackName: string,
stackId: string,
// #728: whether this is a --pre-deploy run (templateOverride set in loadDesired). It
// flips the deployed-value overlay below: under --pre-deploy the declared source is the
// LOCAL template, so a local param Default is authoritative and the deployed DescribeStacks
// value only FILLS params that have no local Default (rather than overriding it). Defaults
// to false — the deployed-path behaviour (deployed wins) — so existing non-pre-deploy
// callers are unchanged.
preDeploy = false
): ResolverContext {
// CommaDelimitedList / List<> params must resolve to ARRAYS so Fn::Join /
// Fn::Select / conditions over them evaluate correctly (a string would break
// Fn::Join and mis-evaluate conditions like HasTrustedAccounts).
const paramDefs = (template.Parameters ?? {}) as Record<
string,
{ Default?: unknown; Type?: string; NoEcho?: unknown }
>;
const isList = (k: string): boolean => {
const t = paramDefs[k]?.Type ?? '';
// Plain list params AND SSM list-typed params. An SSM list param
// (AWS::SSM::Parameter::Value<List<...>> / <CommaDelimitedList>) is returned by
// DescribeStacks' ResolvedValue as a COMMA-JOINED string (AWS-documented), so it must
// split to an array too — else declared "sg-a,sg-b" (string) vs live ["sg-a","sg-b"]
// is a declared FP on every list-typed property fed by it, and an Fn::Select/Join over
// it fails closed to UNRESOLVED (#745).
return (
t === 'CommaDelimitedList' ||
t.startsWith('List<') ||
t.includes('::Parameter::Value<List<') ||
t.includes('::Parameter::Value<CommaDelimitedList')
);
};
// CommaDelimitedList values are whitespace-trimmed by CloudFormation
// ("a, b , c" -> ["a","b","c"]); mirror that so a Fn::Select / membership test
// over the list matches the deployed-resource value (untrimmed " b" would FP).
const toParam = (k: string, raw: string): string | string[] =>
isList(k) ? (raw === '' ? [] : raw.split(',').map((s) => s.trim())) : raw;
const params: Record<string, string | string[]> = {};
for (const [k, def] of Object.entries(paramDefs)) {
// A NoEcho parameter's template Default is a PLACEHOLDER (the raw-CFn/SAM
// `Default: "changeme"` staple), NOT the real deployed secret. Seeding it makes every
// property fed by the param a declared FP ("placeholder" vs the live secret) that
// survives record and whose revert would OVERWRITE the live secret with the placeholder;
// a Condition/Fn::If over it would also bake the wrong branch. The real deployed value
// comes back masked '****' from DescribeStacks and is dropped in loadDesired, so skip the
// Default too and let the param resolve UNRESOLVED (property skipped, conditions
// fail-closed) — the same safe treatment as the masked deployed value (#744).
if (def?.NoEcho === true || def?.NoEcho === 'true') continue;
// An SSM-typed parameter (Type: AWS::SSM::Parameter::Value<...>) carries the SSM
// parameter NAME/KEY in its Default, NOT the dereferenced value AWS will resolve at
// deploy time. The deployed ResolvedValue (set below from DescribeStacks) is the real
// value and overrides — but a parameter that is NEW in the LOCAL --pre-deploy template
// has no deployed value yet, so seeding its Default would make Ref resolve to the KEY
// string (`/golden/ami`) rather than the live value (`ami-0abc…`): a fabricated declared
// FP, and the wrong literal fed into any Condition/Fn::If over it. Skip the Default and
// let it resolve UNRESOLVED (property skipped, conditions fail-closed) — the same safe
// treatment as a masked/unresolvable value; a deployed ResolvedValue still wins (#882).
if ((def?.Type ?? '').includes('::Parameter::Value<')) continue;
if (def && 'Default' in def) params[k] = toParam(k, String(def.Default));
}
if (preDeploy) {
// #728: under --pre-deploy the declared source is the LOCAL template, so its param
// Defaults are the values the next `cdk deploy` will apply. A DescribeStacks value is the
// OLD deployed value (DescribeStacks returns an effective value for ALL params, including
// default-materialized ones) — letting it override a CHANGED local Default masks exactly
// the drift --pre-deploy exists to preview (the run reports CLEAN). So the local Default
// wins; the deployed value only fills params with NO local Default (a required param set
// at deploy time — we still need a value to resolve its Refs). NoEcho / SSM
// `::Parameter::Value<` params were intentionally NOT seeded above, so they are absent
// from `params` and still pick up their deployed value here (the fill step), preserving
// their existing safe treatment.
for (const [k, v] of Object.entries(stackParams)) {
if (!(k in params)) params[k] = toParam(k, v);
}
} else {
for (const [k, v] of Object.entries(stackParams)) params[k] = toParam(k, v); // deployed values win
}
// logicalId -> type and -> raw declared Properties, for resolveGetAtt's
// declared-property-mirroring attributes (GETATT_DECLARED_PROPERTY).
const templateResources = (template.Resources ?? {}) as Record<
string,
{ Type?: string; Properties?: Record<string, unknown> }
>;
const typeOf: Record<string, string> = {};
const declaredRawProps: Record<string, Record<string, unknown>> = {};
for (const [lid, res] of Object.entries(templateResources)) {
if (res?.Type) typeOf[lid] = res.Type;
if (res?.Properties) declaredRawProps[lid] = res.Properties;
}
const { partition, urlSuffix } = partitionForRegion(region);
return {
params,
pseudo: {
'AWS::Region': region,
'AWS::AccountId': accountId,
'AWS::Partition': partition,
'AWS::URLSuffix': urlSuffix,
'AWS::StackName': stackName,
'AWS::StackId': stackId,
},
conditions: template.Conditions ?? {},
physIds,
liveAttrs: {},
typeOf,
declaredRawProps,
mappings: template.Mappings ?? {},
exports: {}, // populated by loadDesired's prefetch only when the template references Fn::ImportValue
condCache: new Map(),
};
}
// Per-account+region cache of CFn exports (Name -> Value). CFn exports are scoped to
// an account AND a region, so the cache key MUST carry BOTH axes: keying on region
// alone would serve account A's exports to account B's same-region stack (a multi-account
// run) — a wrong-value resolution surfacing as a declared false positive / a wrong revert
// value. A single fetch serves every ImportValue in the stack; cache so repeated gather
// runs in the same process don't re-page.
const exportsCache = new Map<string, Record<string, string>>();
export async function listExports(
client: CloudFormationClient,
accountId: string,
region: string
): Promise<Record<string, string>> {
const cacheKey = `${accountId}:${region}`;
const cached = exportsCache.get(cacheKey);
if (cached) return cached;
const exports: Record<string, string> = {};
let next: string | undefined;
do {
const res = await client.send(new ListExportsCommand({ NextToken: next }));
for (const e of res.Exports ?? []) if (e.Name) exports[e.Name] = e.Value ?? '';
next = res.NextToken;
} while (next);
exportsCache.set(cacheKey, exports);
return exports;
}
// Per-account+region cache of resolved `/cdk/exports/*` SSM parameters (name -> value), for
// the CDK `crossRegionReferences: true` pattern. Like exportsCache, these parameters are scoped
// to an account AND a region (they are written into the CONSUMER region by the reader), so the
// cache key MUST carry BOTH axes.
//
// Unlike exportsCache (backed by ListExports, which returns ALL exports so one fetch fully
// determines the account:region content), getCrossRegionExports fetches only the names the
// CURRENT stack's template references. The cached value is therefore a running MERGE across
// every stack in the account:region — NOT a single stack's subset. Caching the first stack's
// subset as the whole-key value starved every later same-region consumer stack of its own
// (different) export names: they resolved UNRESOLVED though their parameters were live and
// readable, re-hiding the out-of-band cert swap #741 was fixed to catch (#1282). The canonical
// `crossRegionReferences: true` shape — several same-region stacks importing a us-east-1 ACM
// cert — is exactly that population.
const crossRegionExportsCache = new Map<string, Record<string, string>>();
// Names already REQUESTED for a key (resolved OR confirmed-missing), so a name that came back
// InvalidParameters is not re-fetched by every later stack. Without this tombstone a
// confirmed-missing name would page ssm:GetParameters once per consumer stack forever.
const crossRegionExportsFetched = new Map<string, Set<string>>();
// The CDK cross-region-reference reader custom-resource type.
const CROSS_REGION_EXPORT_READER_TYPE = 'Custom::CrossRegionExportReader';
/**
* Scan the parsed template for `Fn::GetAtt` references whose logicalId is a
* `Custom::CrossRegionExportReader` resource and whose attribute is an SSM parameter name
* (`/cdk/exports/<name>`). Returns the DISTINCT parameter names to prefetch. Pure + exported
* for unit tests. Both GetAtt forms are handled: the array form `[LogicalId, AttrName]` and
* the long-form string `"LogicalId./cdk/exports/name"` (split on the FIRST dot — the attribute
* itself may contain dots).
*/
export function collectCrossRegionExportNames(template: Record<string, any>): string[] {
const resources = (template.Resources ?? {}) as Record<string, { Type?: string }>;
const readerIds = new Set(
Object.entries(resources)
.filter(([, r]) => r?.Type === CROSS_REGION_EXPORT_READER_TYPE)
.map(([lid]) => lid)
);
if (readerIds.size === 0) return [];
const names = new Set<string>();
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
for (const x of node) walk(x);
return;
}
if (node && typeof node === 'object') {
const obj = node as Record<string, unknown>;
const g = obj['Fn::GetAtt'];
if (g !== undefined) {
let logicalId: string | undefined;
let attr: string | undefined;
if (typeof g === 'string') {
const dot = g.indexOf('.');
if (dot >= 0) {
logicalId = g.slice(0, dot);
attr = g.slice(dot + 1);
}
} else if (Array.isArray(g) && g.length >= 2) {
logicalId = String(g[0]);
attr = String(g[1]);
}
if (
logicalId !== undefined &&
readerIds.has(logicalId) &&
attr !== undefined &&
attr.startsWith('/cdk/exports/')
) {
names.add(attr);
}
}
for (const v of Object.values(obj)) walk(v);
}
};
walk(template.Resources ?? {});
return [...names].sort();
}
// Resolve the given `/cdk/exports/*` SSM parameter names in the stack's region via
// ssm:GetParameters (batched 10 at a time). Returns name -> value; a name that is missing /
// unreadable is simply LEFT OUT (fail closed — resolveGetAtt then yields UNRESOLVED). Cached
// per account:region so repeated gather runs in the same process don't re-fetch. Mirrors the
// listExports prefetch/cache idiom — but MERGES, fetching only the names not yet requested for
// this key and accumulating results, because each stack references a DIFFERENT subset of the
// account:region's exports (#1282). Fully-served requests (every name already attempted) return
// the accumulated map with no SDK call.
export async function getCrossRegionExports(
ssm: SSMClient,
accountId: string,
region: string,
names: string[]
): Promise<Record<string, string>> {
const cacheKey = `${accountId}:${region}`;
const resolved = crossRegionExportsCache.get(cacheKey) ?? {};
const attempted = crossRegionExportsFetched.get(cacheKey) ?? new Set<string>();
const toFetch = names.filter((n) => !attempted.has(n));
if (toFetch.length === 0) return resolved;
// Accumulate into LOCAL copies and commit to the module cache only after every page
// succeeds: a throw mid-fetch must leave the cache untouched (no partial/poisoned map), the
// same all-or-nothing failure contract the previous throw-before-set had. On that throw the
// caller warns and every reader GetAtt in the stack falls back to UNRESOLVED for the run.
const nextResolved: Record<string, string> = { ...resolved };
const nextAttempted = new Set(attempted);
for (let i = 0; i < toFetch.length; i += 10) {
const batch = toFetch.slice(i, i + 10);
const res = await ssm.send(new GetParametersCommand({ Names: batch }));
for (const p of res.Parameters ?? []) {
if (p.Name && typeof p.Value === 'string') nextResolved[p.Name] = p.Value;
}
// InvalidParameters (names that don't exist) are returned separately and intentionally
// dropped from the value map — the reader GetAtt for a missing name stays UNRESOLVED (fail
// closed) — but the whole batch is marked attempted so it is not re-requested (tombstone).
for (const n of batch) nextAttempted.add(n);
}
crossRegionExportsCache.set(cacheKey, nextResolved);
crossRegionExportsFetched.set(cacheKey, nextAttempted);
return nextResolved;
}
// Page ListStackResources (DescribeStackResources caps at 100; CDK stacks reach ~500).
async function listStackResources(
client: CloudFormationClient,
stackName: string
): Promise<{ physIds: Record<string, string>; typeOf: Record<string, string> }> {
const physIds: Record<string, string> = {};
const typeOf: Record<string, string> = {};
let next: string | undefined;
do {
const res = await client.send(
new ListStackResourcesCommand({ StackName: stackName, NextToken: next })
);
for (const r of res.StackResourceSummaries ?? []) {
if (r.LogicalResourceId && r.PhysicalResourceId)
physIds[r.LogicalResourceId] = r.PhysicalResourceId;
if (r.LogicalResourceId && r.ResourceType) typeOf[r.LogicalResourceId] = r.ResourceType;
}
next = res.NextToken;
} while (next);
return { physIds, typeOf };
}
export async function loadDesired(
client: CloudFormationClient,
stackName: string,
region: string,
// when provided (--pre-deploy), this LOCAL synth template is the declared source
// instead of the deployed GetTemplate; physIds + params still come from the live stack
templateOverride?: Record<string, unknown>,
// the LOCAL synth template, used ONLY to recover non-ASCII string literals that
// GetTemplate masked as `?` (see recoverNonAsciiMasks). Unlike templateOverride it does
// NOT replace the declared source — it patches only the mask-matching corrupted leaves.
recoveryTemplate?: Record<string, unknown>
): Promise<Desired> {
// GetTemplate + DescribeStacks are single calls (kept in Promise.all); ListStackResources
// is paginated separately (DescribeStackResources caps at 100, CDK stacks reach ~500).
const [tmplRes, stkRes, { physIds, typeOf: deployedTypeOf }] = await Promise.all([
templateOverride
? Promise.resolve({ TemplateBody: undefined })
: // #904: pin TemplateStage explicitly. The deployed-path desired model MUST be the
// PROCESSED template — server-side Transforms (SAM `AWS::Serverless-2016-10-31`,
// `AWS::LanguageExtensions`, macros) already expanded — so its logical ids and types
// match the live resources CFn actually created (a SAM `AWS::Serverless::Function`
// becomes an ordinary `AWS::Lambda::Function` + generated Role/RestApi). `Processed`
// IS the GetTemplate API default, but nothing pinned it; make it explicit so a future
// default change can never silently swap in the unprocessed `Original` (which would
// regress every transformed stack to the broken `--pre-deploy` shape — see #904).
client.send(new GetTemplateCommand({ StackName: stackName, TemplateStage: 'Processed' })),
client.send(new DescribeStacksCommand({ StackName: stackName })),
listStackResources(client, stackName),
]);
const stack = stkRes.Stacks?.[0];
// Stack-state gate — runs BEFORE the template is parsed: a REVIEW_IN_PROGRESS stack
// (a change set created but never deployed) returns an EMPTY GetTemplate body, which
// would otherwise blow up parseTemplateBody with "Unexpected end of JSON input". Skip
// a stack with no meaningful deployed reality (REVIEW_IN_PROGRESS / deleting) rather
// than silently compare live state against a never-deployed template; carry a warning
// for mid-operation / failed states.
//
// #882: this gate runs under --pre-deploy too. The templateOverride only substitutes the
// DECLARED source (the local synth template replaces GetTemplate); physIds + every live
// read still come from the DEPLOYED stack, so the deployed stack's state is just as
// relevant. Skipping the gate under --pre-deploy meant a DELETE_IN_PROGRESS stack was
// compared against half-deleted reality (red `deleted` findings), and a mid-operation /
// failed stack proceeded with NO stackStatusWarning (always undefined). Keep the
// classification. (REVIEW_IN_PROGRESS still returns an empty GetTemplate, but that body
// is never parsed under --pre-deploy — it is skipped by the `skip` branch just the same.)
const stateClass = classifyStackStatus(stack?.StackStatus);
if (stateClass.kind === 'skip') throw new StackNotCheckableError(stateClass.message);
const stackStatusWarning = stateClass.kind === 'warn' ? stateClass.message : undefined;
const template = (templateOverride ?? parseTemplateBody(tmplRes.TemplateBody ?? '{}')) as Record<
string,
any
>;
// Recover GetTemplate's `?`-masked non-ASCII literals from the local synth template
// (mask-gated, per leaf). Skipped under --pre-deploy: there the declared source already
// IS the intact synth template, so there is nothing to recover.
if (!templateOverride && recoveryTemplate) recoverNonAsciiMasks(template, recoveryTemplate);
const rawTemplate = templateOverride
? JSON.stringify(templateOverride)
: (tmplRes.TemplateBody ?? '{}');
const stackId = stack?.StackId ?? '';
const accountId = stackId.split(':')[4] ?? '';
// #683 — the stack's own tags (from `cdk deploy --tags` etc.). CFN propagates these onto
// every taggable resource without them appearing in the template; classify subtracts them.
const stackTags: Record<string, string> = {};
for (const t of stack?.Tags ?? []) {
if (typeof t.Key === 'string' && typeof t.Value === 'string') stackTags[t.Key] = t.Value;
}
const stackParams: Record<string, string> = {};
for (const p of stack?.Parameters ?? []) {
if (!p.ParameterKey) continue;
// SSM-typed params (Type: AWS::SSM::Parameter::Value<...>) carry the raw SSM
// KEY in ParameterValue and the dereferenced value in ResolvedValue — prefer
// ResolvedValue so Ref resolves to what AWS actually deployed, not the key.
const value = p.ResolvedValue ?? p.ParameterValue ?? '';
// A NoEcho parameter is returned MASKED as '****'. Comparing against the mask
// would be a false positive, so skip it entirely: the param drops out of ctx,
// Ref resolves UNRESOLVED, and the dependent property is skipped (not compared)
// — same treatment as a dynamic reference we cannot resolve.
if (value === '****') continue;
stackParams[p.ParameterKey] = value;
}
// #883: under --pre-deploy (templateOverride set), surface deployed resources that are
// ABSENT from the local template — the next deploy will DELETE them. This is the symmetric
// half of the #727 pending-creation note (which surfaces template resources not yet
// deployed); together they answer "will this deploy fight reality?". stderr keeps --json
// stdout clean, mirroring the #727 note in check.ts. Only meaningful pre-deploy: on the
// deployed path the declared source IS the deployed template, so the two id sets match.
if (templateOverride) {
const deletedInfo = deletedResourceInfo(physIds, template, stackName);
if (deletedInfo) console.error(deletedInfo);
// #728 case 1 / #1194: loud coverage note for new/renamed local params that resolve to no
// value (see unpreviewableParamInfo). stderr keeps --json stdout clean, matching the notes
// above/below. stackParams is the deployed DescribeStacks set built just above.
const noValueInfo = unpreviewableParamInfo(template, stackParams, stackName);
if (noValueInfo) console.error(noValueInfo);
// #1292: loud caveat for existing params whose local Default DIFFERS from the deployed
// value — the preview applies the new Default (#1194) but a plain `cdk deploy` keeps the
// deployed value via UsePreviousValue (see changedDefaultParamInfo). Same stderr channel.
const changedDefaultInfo = changedDefaultParamInfo(template, stackParams, stackName);
if (changedDefaultInfo) console.error(changedDefaultInfo);
// #904: a Transform-bearing (SAM / LanguageExtensions / macro) local template cannot be
// server-side-processed by CDK's local synth, so its AWS::Serverless::* resources are
// `skipped` and its transform-generated resources are invisible under --pre-deploy. One
// honest per-stack note (vs confusing DescribeType spam) pointing to the deployed path.
const transformInfo = transformStackWarning(template, stackName);
if (transformInfo) console.error(transformInfo);
}
// #882: detect logical ids whose Type changed between the deployed stack and the declared
// template (a same-path construct swap under --pre-deploy). On the deployed path the declared
// type IS the deployed type, so this set is always empty there — but compute unconditionally
// and use it in the resource loop below to WITHHOLD the stale physical id (a phys id from the
// OLD type can't be read as the NEW type → false "deleted out of band"). Under --pre-deploy,
// also surface a "will REPLACE" note so the type change is not silently swallowed.
const templateResourcesForTypeCheck = (template.Resources ?? {}) as Record<
string,
{ Type?: string }
>;
const typeChanged = new Set(typeChangedResources(templateResourcesForTypeCheck, deployedTypeOf));
if (templateOverride && typeChanged.size > 0) {
const replaceInfo = typeChangeReplaceInfo(
[...typeChanged].sort(),
templateResourcesForTypeCheck,
deployedTypeOf,
stackName
);
if (replaceInfo) console.error(replaceInfo);
}
const ctx = buildResolverContext(
template,
stackParams,
physIds,
region,
accountId,
stackName,
stackId,
// #728: templateOverride set == --pre-deploy run. Pass it so a changed LOCAL param Default
// wins over the OLD deployed value instead of being masked by it.
!!templateOverride
);
// AWS::NotificationARNs is a LIST-valued pseudo-parameter that DescribeStacks already
// returned above — plumb it so a `{ Ref: "AWS::NotificationARNs" }` (nested-stack /
// alarm-action pattern) resolves instead of surfacing as a permanent `unresolved` footer
// that also blocks `--strict`. It goes in `params` (which carries arrays) rather than the
// scalar-typed `pseudo` map; resolveRef checks pseudo then params, and CFn reserves the
// `AWS::` prefix so no user parameter can collide. Absent/empty → leave it unresolved (#746).
const notificationArns = stack?.NotificationARNs ?? [];
if (notificationArns.length > 0) ctx.params['AWS::NotificationARNs'] = notificationArns;
// Fn::ImportValue is synchronous in the resolver, so prefetch exports here — but
// ONLY when the template actually references it, so normal stacks pay nothing for the
// extra ListExports call(s). Gate on the PARSED template, NOT the raw body: a YAML
// deployed template carries the short-form `!ImportValue` tag, so a substring check on
// the raw body would miss it and leave exports unfetched — the import then resolves
// UNRESOLVED and its declared property is silently skipped (missed drift).
// parseTemplateBody has already normalized short-form tags to long-form `Fn::ImportValue`.
if (JSON.stringify(template).includes('Fn::ImportValue')) {
// DEGRADE, don't die: a principal with cloudformation:GetTemplate + DescribeStacks but
// NO cloudformation:ListExports would otherwise hard-fail the ENTIRE stack check (exit 2)
// over a permission gap that affects only one resolution axis. A read-only check should
// leave ctx.exports empty on failure — the ImportValue-consuming properties then resolve
// to `unresolved` (visible, and blocks --strict) instead of taking down the whole stack.
try {
ctx.exports = await listExports(client, accountId, region);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`warning: ${stackName}: cloudformation:ListExports failed — Fn::ImportValue references ` +
`will resolve UNRESOLVED (their declared properties are skipped, not compared). ` +
`Grant cloudformation:ListExports for full coverage. (${msg})`
);
}
}
// CDK `crossRegionReferences: true` pattern: a `Custom::CrossRegionExportReader` custom
// resource materializes each cross-region import as an SSM parameter `/cdk/exports/<name>`
// in THIS (consumer) region, and the consumer property is a GetAtt to that parameter name.
// The reader has no live model, so those GetAtts would resolve UNRESOLVED forever — leaving
// an out-of-band cert swap invisible. Prefetch the referenced parameters (one ssm:GetParameters
// batch) so resolveGetAtt can resolve them. Only fetch when the template actually has such a
// reader + reference, so normal stacks pay nothing. Fail closed: on any read failure the map
// stays empty and those GetAtts remain UNRESOLVED (the pre-fix behavior).
const crossRegionExportNames = collectCrossRegionExportNames(template);
if (crossRegionExportNames.length > 0) {
try {
// Build the SSM client for the SAME region as the stack (the reader writes its SSM
// parameters into the consumer region). Reuse the CFn client's resolved credentials so a
// `--profile` run hits the same account; fall back to the default chain when the client
// exposes none (e.g. a bare mock in tests). `config`/`credentials` are typed as always
// defined, but a bare mock leaves them undefined at runtime — read defensively.
const credentials = (client as { config?: { credentials?: SSMClientConfig['credentials'] } })
.config?.credentials;
// Spread READ_RETRY so this prefetch client inherits the same connection/request timeouts
// (#1066 — a stalled/silent SSM endpoint would otherwise hang check/record FOREVER here)
// AND the adaptive retry budget (maxAttempts 10 vs the SDK default 3 — a transient
// ThrottlingException here aborts the prefetch and downgrades EVERY reader GetAtt in the
// stack to UNRESOLVED for the run). Keep the CFn-inherited `credentials` LAST so it wins
// over READ_RETRY's default CLIENT_CREDENTIALS chain in the `--profile` case (#1286).
const ssm = new SSMClient(
credentials ? { region, ...READ_RETRY, credentials } : { region, ...READ_RETRY }
);
ctx.crossRegionExports = await getCrossRegionExports(
ssm,
accountId,
region,
crossRegionExportNames
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`warning: ${stackName}: ssm:GetParameters failed — crossRegionReferences reader GetAtt ` +
`references (/cdk/exports/*) will resolve UNRESOLVED (their declared properties are ` +
`skipped, not compared). Grant ssm:GetParameters for full coverage. (${msg})`
);
}
}
const resources: DesiredResource[] = [];
// IAM principals (Role / User / Group) whose inline Policies are managed by SIBLING
// AWS::IAM::Policy resources (the CDK pattern). classify uses the per-principal POLICY
// NAMES to drop only the sibling-owned live entries — an out-of-band inline policy still
// surfaces.
const principalsWithSiblingPolicy = collectPrincipalsWithSiblingPolicies(
template.Resources ?? {},
ctx
);
// ECS Cluster logicalIds whose CapacityProviders / DefaultCapacityProviderStrategy are
// declared by a sibling AWS::ECS::ClusterCapacityProviderAssociations resource (the only
// CFn way to set them). classify drops those reflected live props on the flagged cluster.
const clustersWithSiblingCapacityProviders = collectClustersWithSiblingCapacityProviders(
template.Resources ?? {}
);
for (const [logicalId, res] of Object.entries(
(template.Resources ?? {}) as Record<string, any>
)) {
if (res.Type === 'AWS::CDK::Metadata') continue;
// A resource guarded by a template `Condition:` that evaluates definitively FALSE is
// never created by CloudFormation (the raw-CFn multi-env staple — one template serving
// dev/prod). It has no physical id and no live counterpart to compare, so pushing it
// would make classifyRead tag it a permanent `skipped: no physical id` — false
// "coverage incomplete" noise that also keeps `check --strict` red forever, with
// nothing the user can do in-tool. Drop it (matching CloudFormation's own semantics:
// the resource is not part of the stack). Gate on "condition FALSE **and** no physical
// id" so the fold is strictly noise-only: an UNRESOLVED or TRUE condition keeps today's
// conservative behavior, and a false condition that somehow has a physical id (a CFn
// anomaly) still surfaces.
if (typeof res.Condition === 'string' && !physIds[logicalId]) {
if (evalCondition(res.Condition, ctx) === false) continue;
}
const cdkPath = res.Metadata?.['aws:cdk:path'];
const declaredRaw = (res.Properties ?? {}) as Record<string, unknown>;
// #882: withhold the physical id when the Type changed at this logical id — the deployed
// phys id belongs to the OLD type and can't be GetResource'd as the NEW type. Leaving it
// undefined makes the live read find nothing to fetch (the resource does not yet exist
// under the new type), so classify tags it a normal pending create rather than emitting a
// false "resource deleted out of band" (the note above already told the user it'll REPLACE).
const physicalId = typeChanged.has(logicalId) ? undefined : physIds[logicalId];
resources.push({
logicalId,
resourceType: res.Type as string,
physicalId,
constructPath: typeof cdkPath === 'string' ? prettyConstructPath(cdkPath) : undefined,
// first-pass resolution (no live attrs yet → GetAtt is UNRESOLVED). gather
// re-resolves declaredRaw once liveAttrs is populated, reducing UNRESOLVED.
declared: resolveProperties(declaredRaw, ctx),
declaredRaw,
siblingPolicyNames: IAM_PRINCIPAL_TYPES.has(res.Type)
? principalsWithSiblingPolicy.get(logicalId)
: undefined,
hasSiblingCapacityProviders:
res.Type === 'AWS::ECS::Cluster'
? clustersWithSiblingCapacityProviders.has(logicalId)
: undefined,
});
}
// POST-ASSIGN stackTags rather than adding it to the return literal: growing this 8-property
// literal trips tsgolint's whole-program type-aware pass into cascading false lint errors on
// UNRELATED files (tsgo typecheck is unaffected). Keeping the literal at 7 fields + a separate
// assignment sidesteps it. (#683.)
const desired: Desired = {
stackName,
region,
accountId,
resources,
rawTemplate,
ctx,
stackStatusWarning,
};
desired.stackTags = stackTags;
return desired;
}
// The IAM principal types an AWS::IAM::Policy can attach an inline policy to, via its
// Roles / Users / Groups reference lists (the CDK `<Principal>DefaultPolicy` pattern).
const IAM_PRINCIPAL_TYPES: ReadonlySet<string> = new Set([
'AWS::IAM::Role',
'AWS::IAM::User',
'AWS::IAM::Group',
]);
// CDK construct paths end in "/Resource" for the L1 node; drop it for readability
// (e.g. "MyStack/Bucket/Resource" -> "MyStack/Bucket").
function prettyConstructPath(p: string): string {
return p.endsWith('/Resource') ? p.slice(0, -'/Resource'.length) : p;
}
/**
* Map each IAM principal logicalId (Role / User / Group) to the PolicyNames of the
* sibling AWS::IAM::Policy resources attached to it via its Roles / Users / Groups
* reference lists. A sibling whose PolicyName cannot be resolved to a string (an
* intrinsic the resolver can't evaluate) marks the principal 'unresolved' — classify
* then falls back to suppressing the whole live Policies property rather than risk a
* false positive on the unidentifiable sibling entry.
*/
export function collectPrincipalsWithSiblingPolicies(
resources: Record<string, any>,
ctx?: ResolverContext
): Map<string, string[] | 'unresolved'> {
const principals = new Map<string, string[] | 'unresolved'>();
// Register one inline-policy sibling: attach `name` (resolved PolicyName) to each
// principal logicalId in `refs`. An unresolvable PolicyName marks the principal
// 'unresolved' (classify then suppresses the whole live Policies property).
const attach = (name: string | undefined, refs: unknown[]) => {
for (const r of refs) {
const ref = r && typeof r === 'object' ? (r as Record<string, unknown>).Ref : undefined;
if (typeof ref !== 'string') continue;
const prev = principals.get(ref);
if (prev === 'unresolved') continue;
if (name === undefined) principals.set(ref, 'unresolved');
else principals.set(ref, [...(prev ?? []), name]);
}
};
for (const res of Object.values(resources)) {
const name = resolvePolicyName(res?.Properties?.PolicyName, ctx);
if (res?.Type === 'AWS::IAM::Policy') {
// Array reference props: attach the same inline policy to every referenced principal.
attach(name, [
...((res.Properties?.Roles ?? []) as unknown[]),
...((res.Properties?.Users ?? []) as unknown[]),
...((res.Properties?.Groups ?? []) as unknown[]),
]);
} else {
// Standalone inline-policy types attach to a SINGLE principal via a singular
// RoleName / UserName / GroupName (a Ref/GetAtt to the principal, or a literal
// name). Only a Ref maps to a principal logicalId — resolve exactly like the
// Policy array entries (a literal name has no logicalId, so it is ignored).
const singular = IAM_STANDALONE_INLINE_POLICY_TYPES.get(res?.Type);
if (singular !== undefined) attach(name, [res?.Properties?.[singular]]);
}
}
return principals;
}
// The standalone inline-policy resource types, mapped to the singular property that
// references their one attached principal. Each is the CDK `CfnRolePolicy` /
// `CfnUserPolicy` / `CfnGroupPolicy` equivalent of an inline policy on the principal.
const IAM_STANDALONE_INLINE_POLICY_TYPES: ReadonlyMap<string, string> = new Map([
['AWS::IAM::RolePolicy', 'RoleName'],
['AWS::IAM::UserPolicy', 'UserName'],
['AWS::IAM::GroupPolicy', 'GroupName'],
]);
/**
* The set of ECS Cluster logicalIds that a sibling AWS::ECS::ClusterCapacityProviderAssociations
* resource references (via its `Cluster: { Ref }`). CapacityProviders and
* DefaultCapacityProviderStrategy can only be set through that separate resource — the Cluster's
* own schema has no such property — so the association reflects them into the cluster's live model
* where they read as false undeclared drift. The association is tracked + compared as its own
* resource, so classify drops the reflected props on a flagged cluster.
*/
export function collectClustersWithSiblingCapacityProviders(
resources: Record<string, any>
): Set<string> {
const clusters = new Set<string>();
for (const res of Object.values(resources)) {
if (res?.Type !== 'AWS::ECS::ClusterCapacityProviderAssociations') continue;
const ref = res.Properties?.Cluster?.Ref;
if (typeof ref === 'string') clusters.add(ref);
}
return clusters;
}
// CDK emits literal PolicyNames (e.g. "RoleDefaultPolicyABC123"); hand-written
// templates may use intrinsics, which we resolve when a ctx is available.
function resolvePolicyName(raw: unknown, ctx?: ResolverContext): string | undefined {
if (typeof raw === 'string') return raw;