@go-to-k/cdkd 0.145.1 → 0.146.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -31721,6 +31721,18 @@ function getCurrentNestedStackContext() {
31721
31721
  //#endregion
31722
31722
  //#region src/provisioning/providers/nested-stack-provider.ts
31723
31723
  /**
31724
+ * Returns `true` when `p` is absolute on the current platform OR begins
31725
+ * with a forward slash. The `startsWith('/')` arm covers the cross-platform
31726
+ * leak where `path.isAbsolute('/abs/foo')` returns `true` on POSIX but
31727
+ * `false` on Windows (Windows absolute paths require a drive letter or
31728
+ * UNC root) — a POSIX-style absolute path embedded in a synth template
31729
+ * generated on Linux and consumed on a Windows host would slip past
31730
+ * `path.isAbsolute()` alone. Exported for unit testing.
31731
+ */
31732
+ function isAbsoluteCrossPlatform(p) {
31733
+ return path.isAbsolute(p) || p.startsWith("/");
31734
+ }
31735
+ /**
31724
31736
  * Provider for `AWS::CloudFormation::Stack` — cdkd's recursive nested-stack
31725
31737
  * adapter. Issue [#459](https://github.com/go-to-k/cdkd/issues/459); see
31726
31738
  * [docs/design/459-nested-stacks.md](../../../docs/design/459-nested-stacks.md)
@@ -31940,7 +31952,7 @@ var NestedStackProvider = class {
31940
31952
  if (resource?.Type !== "AWS::CloudFormation::Stack") continue;
31941
31953
  const assetPath = resource.Metadata?.["aws:asset:path"];
31942
31954
  if (typeof assetPath !== "string" || assetPath.length === 0) continue;
31943
- if (path.isAbsolute(assetPath)) throw new Error(`NestedStackProvider: nested-stack '${grandLogicalId}' has Metadata['aws:asset:path']='${assetPath}' which is absolute. CDK emits relative asset paths for nested templates; an absolute path indicates the synth output was hand-modified or generated by a non-CDK toolchain. Refusing to load.`);
31955
+ if (isAbsoluteCrossPlatform(assetPath)) throw new Error(`NestedStackProvider: nested-stack '${grandLogicalId}' has Metadata['aws:asset:path']='${assetPath}' which is absolute. CDK emits relative asset paths for nested templates; an absolute path indicates the synth output was hand-modified or generated by a non-CDK toolchain. Refusing to load.`);
31944
31956
  result[grandLogicalId] = path.join(dir, assetPath);
31945
31957
  }
31946
31958
  return result;
@@ -36196,6 +36208,16 @@ function parseCfnTemplateWithFormat(text) {
36196
36208
  //#endregion
36197
36209
  //#region src/cli/commands/retire-cfn-stack.ts
36198
36210
  /**
36211
+ * Resource type for a CloudFormation nested-stack child. Hoisted as a
36212
+ * constant so the recursive walker, the Retain-injection skip rule, and
36213
+ * the import-side short-circuit all reference the same literal — a typo
36214
+ * in any one of them would otherwise silently break nested-stack support
36215
+ * (the offending row would either be missed by the tree walk or have
36216
+ * Retain injected on it, both of which would orphan the child stack
36217
+ * record on AWS at retire time).
36218
+ */
36219
+ const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
36220
+ /**
36199
36221
  * UpdateStack TemplateBody hard limit (51,200 bytes). Templates larger than
36200
36222
  * this are uploaded to cdkd's state S3 bucket and submitted via `TemplateURL`
36201
36223
  * instead — see {@link uploadTemplateForUpdateStack}.
@@ -36242,7 +36264,8 @@ const TEMPLATE_URL_LIMIT = CFN_TEMPLATE_URL_LIMIT;
36242
36264
  */
36243
36265
  async function retireCloudFormationStack(options) {
36244
36266
  const logger = getLogger();
36245
- const { cfnStackName, cfnClient, yes, stateBucket, s3ClientOpts } = options;
36267
+ const { cfnStackName, cfnClient, yes, stateBucket, s3ClientOpts, resourceTree } = options;
36268
+ if (resourceTree && resourceTree.stackName !== cfnStackName) throw new Error(`retireCloudFormationStack: caller-supplied resourceTree.stackName='${resourceTree.stackName}' does not match cfnStackName='${cfnStackName}'. The tree's root must be the stack being retired.`);
36246
36269
  logger.info(`[1/4] Inspecting CloudFormation stack '${cfnStackName}'...`);
36247
36270
  const stack = (await cfnClient.send(new DescribeStacksCommand({ StackName: cfnStackName }))).Stacks?.[0];
36248
36271
  if (!stack) throw new Error(`CloudFormation stack '${cfnStackName}' not found.`);
@@ -36254,15 +36277,54 @@ async function retireCloudFormationStack(options) {
36254
36277
  TemplateStage: "Original"
36255
36278
  }));
36256
36279
  if (!tpl.TemplateBody) throw new Error(`GetTemplate returned no body for '${cfnStackName}'.`);
36257
- const { body: newBody, modified, format } = injectRetainPolicies(tpl.TemplateBody, cfnStackName);
36280
+ const nestedCleanups = [];
36281
+ const hasNestedChildren = templateContainsNestedStackRows(tpl.TemplateBody);
36282
+ let newBody;
36283
+ let modified;
36284
+ let format;
36285
+ if (hasNestedChildren) {
36286
+ const tree = resourceTree ?? await getCloudFormationResourceTree(cfnStackName, cfnClient);
36287
+ try {
36288
+ const recursive = await injectRetainPoliciesRecursive(tpl.TemplateBody, cfnStackName, tree, {
36289
+ cfnClient,
36290
+ stateBucket,
36291
+ ...s3ClientOpts && { s3ClientOpts }
36292
+ });
36293
+ newBody = recursive.body;
36294
+ modified = recursive.modified;
36295
+ format = recursive.format;
36296
+ nestedCleanups.push(...recursive.cleanups);
36297
+ } catch (err) {
36298
+ if (err instanceof RecursiveRetainInjectionError) for (const cleanup of err.cleanups) try {
36299
+ await cleanup();
36300
+ } catch (cleanupErr) {
36301
+ const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36302
+ logger.warn(`Failed to delete partial nested-template upload from '${stateBucket}' during error recovery. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
36303
+ }
36304
+ throw err;
36305
+ }
36306
+ } else ({body: newBody, modified, format} = injectRetainPolicies(tpl.TemplateBody, cfnStackName));
36258
36307
  if (!yes) {
36259
36308
  if (!await confirmPrompt$2(`Set DeletionPolicy=Retain and UpdateReplacePolicy=Retain on every resource in CloudFormation stack '${cfnStackName}', then delete the stack? AWS resources will NOT be deleted (cdkd state has been written).`)) {
36260
36309
  logger.info("CloudFormation stack retirement cancelled. cdkd state is unaffected.");
36310
+ for (const cleanup of nestedCleanups) try {
36311
+ await cleanup();
36312
+ } catch (cleanupErr) {
36313
+ const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36314
+ logger.warn(`Failed to delete temporary nested-template upload from '${stateBucket}' during cancel cleanup. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
36315
+ }
36261
36316
  return { outcome: "cancelled" };
36262
36317
  }
36263
36318
  }
36264
- if (!modified) logger.info(`[2/4] Template already has Retain on every resource — skipping UpdateStack.`);
36265
- else {
36319
+ if (!modified) {
36320
+ logger.info(`[2/4] Template already has Retain on every resource — skipping UpdateStack.`);
36321
+ if (nestedCleanups.length > 0) for (const cleanup of nestedCleanups) try {
36322
+ await cleanup();
36323
+ } catch (cleanupErr) {
36324
+ const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36325
+ logger.warn(`Failed to delete temporary nested-template upload from '${stateBucket}'. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
36326
+ }
36327
+ } else {
36266
36328
  logger.info(`[2/4] Injected DeletionPolicy=Retain and UpdateReplacePolicy=Retain.`);
36267
36329
  if (newBody.length > TEMPLATE_URL_LIMIT) throw new Error(`Modified template is ${newBody.length} bytes, exceeds the CloudFormation UpdateStack TemplateURL limit (${TEMPLATE_URL_LIMIT}). cdkd state has already been written; retire the stack manually with (1) shrink the template, then (2) UpdateStack with Retain policies, (3) DeleteStack — or split the stack and retry.`);
36268
36330
  let updateInput;
@@ -36305,8 +36367,9 @@ async function retireCloudFormationStack(options) {
36305
36367
  maxWaitTime: 1800
36306
36368
  }, { StackName: cfnStackName });
36307
36369
  } finally {
36308
- if (s3Cleanup) try {
36309
- await s3Cleanup();
36370
+ const allCleanups = [...s3Cleanup ? [s3Cleanup] : [], ...nestedCleanups];
36371
+ for (const cleanup of allCleanups) try {
36372
+ await cleanup();
36310
36373
  } catch (cleanupErr) {
36311
36374
  const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36312
36375
  logger.warn(`Failed to delete temporary template upload from '${stateBucket}'. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
@@ -36374,11 +36437,66 @@ function injectRetainPolicies(templateBody, cfnStackName) {
36374
36437
  throw new Error(`Template for '${cfnStackName}' is not a valid CloudFormation template. cdkd's --migrate-from-cloudformation flow supports both JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Cause: ${err instanceof Error ? err.message : String(err)}`);
36375
36438
  }
36376
36439
  if (!("Resources" in parsed) || typeof parsed["Resources"] !== "object" || parsed["Resources"] === null || Array.isArray(parsed["Resources"])) throw new Error(`Template for '${cfnStackName}' has no Resources section — refusing to retire.`);
36377
- let modified = false;
36440
+ const modified = injectRetainPoliciesOnParsedResources(parsed["Resources"]);
36441
+ return {
36442
+ body: stringifyCfnTemplate(parsed, format),
36443
+ modified,
36444
+ format
36445
+ };
36446
+ }
36447
+ /**
36448
+ * Cheap sniff: does the template body contain any `AWS::CloudFormation::Stack`
36449
+ * resource rows? Parses + walks `Resources` once (no recursive descent),
36450
+ * tolerating parse failures by returning `false` (the caller's downstream
36451
+ * parse step will surface the same error with a richer message). Used by
36452
+ * {@link retireCloudFormationStack} to decide whether to lazily build the
36453
+ * recursive {@link CfnStackResourceTree} — non-nested templates skip the
36454
+ * extra `DescribeStackResources` round-trip entirely.
36455
+ */
36456
+ function templateContainsNestedStackRows(templateBody) {
36457
+ let parsed;
36458
+ try {
36459
+ parsed = parseCfnTemplate(templateBody);
36460
+ } catch {
36461
+ return false;
36462
+ }
36378
36463
  const resources = parsed["Resources"];
36464
+ if (!resources || typeof resources !== "object" || Array.isArray(resources)) return false;
36465
+ for (const resource of Object.values(resources)) {
36466
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
36467
+ if (resource["Type"] === "AWS::CloudFormation::Stack") return true;
36468
+ }
36469
+ return false;
36470
+ }
36471
+ /**
36472
+ * Mutate an already-parsed `Resources` map in place: set `DeletionPolicy: Retain`
36473
+ * and `UpdateReplacePolicy: Retain` on every leaf resource that doesn't
36474
+ * already have them. Returns the `modified` flag so the caller can decide
36475
+ * whether the round-trip is worth a `stringifyCfnTemplate` re-serialization
36476
+ * (and whether `UpdateStack` is needed at all).
36477
+ *
36478
+ * `AWS::CloudFormation::Stack` resources are **intentionally skipped** — see
36479
+ * issue [#464](https://github.com/go-to-k/cdkd/issues/464) and the design at
36480
+ * [docs/design/464-nested-stacks-export-import.md](../../../docs/design/464-nested-stacks-export-import.md)
36481
+ * §3.4. Retain on a nested-stack row tells AWS CFn's parent-side `DeleteStack`
36482
+ * to NOT cascade-delete the child stack record at all, which would leave a
36483
+ * stranded child stack record on AWS that the user has to clean up manually.
36484
+ * We want the cascade to descend (so child stack records get deleted as the
36485
+ * tree unwinds) while every child's own leaf resources stay because THEIR
36486
+ * Retain was injected on the previous recursion level — the recursive
36487
+ * Retain injection in {@link injectRetainPoliciesRecursive} relies on this
36488
+ * skip rule to behave correctly.
36489
+ *
36490
+ * Shared between the single-template path ({@link injectRetainPolicies}) and
36491
+ * the per-level walk inside {@link injectRetainPoliciesRecursive} so both
36492
+ * sites apply the exact same skip rule with no risk of drift.
36493
+ */
36494
+ function injectRetainPoliciesOnParsedResources(resources) {
36495
+ let modified = false;
36379
36496
  for (const [, resource] of Object.entries(resources)) {
36380
36497
  if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
36381
36498
  const r = resource;
36499
+ if (r["Type"] === "AWS::CloudFormation::Stack") continue;
36382
36500
  if (r["DeletionPolicy"] !== "Retain") {
36383
36501
  r["DeletionPolicy"] = "Retain";
36384
36502
  modified = true;
@@ -36388,6 +36506,119 @@ function injectRetainPolicies(templateBody, cfnStackName) {
36388
36506
  modified = true;
36389
36507
  }
36390
36508
  }
36509
+ return modified;
36510
+ }
36511
+ /**
36512
+ * Recursive variant of {@link injectRetainPolicies} that handles a parent
36513
+ * template containing one or more `AWS::CloudFormation::Stack` rows. Issue
36514
+ * [#464](https://github.com/go-to-k/cdkd/issues/464); see
36515
+ * [docs/design/464-nested-stacks-export-import.md](../../../docs/design/464-nested-stacks-export-import.md)
36516
+ * §3.4 for the design rationale.
36517
+ *
36518
+ * For each nested-stack row in the parent template:
36519
+ * 1. `GetTemplate` Original-stage on the corresponding child stack ARN (read
36520
+ * from the supplied {@link CfnStackResourceTree}, populated up front by
36521
+ * {@link getCloudFormationResourceTree}).
36522
+ * 2. Recursively process the child template — Retain injection on its
36523
+ * leaves AND further recursion into any grandchildren.
36524
+ * 3. If the recursion produced a modified child body, upload the modified
36525
+ * body to the cdkd state bucket via the shared {@link uploadCfnTemplate}
36526
+ * helper and rewrite the parent's `Properties.TemplateURL` for that
36527
+ * row to the uploaded URL — so the parent's eventual `UpdateStack`
36528
+ * cascades into the child with the Retain-bearing template.
36529
+ *
36530
+ * After all nested-stack rows are processed, the parent's own leaf
36531
+ * resources get Retain injected via {@link injectRetainPoliciesOnParsedResources}
36532
+ * — the SAME helper the single-template path uses, applying the same
36533
+ * "skip `AWS::CloudFormation::Stack` rows" rule (those rows must NOT have
36534
+ * Retain or cascade-delete won't descend — see the helper's doc-comment).
36535
+ *
36536
+ * The returned `cleanups` array carries one S3 delete callback per
36537
+ * transient child-template upload made anywhere in the recursive walk.
36538
+ * The caller drains it in a `finally` block around the parent
36539
+ * `UpdateStack` call — CFn fetches each child template synchronously
36540
+ * during `UpdateStack`, so the transient objects can be reaped as soon
36541
+ * as that call returns (success OR failure). When the helper itself
36542
+ * throws mid-walk, the partial uploads accumulated so far are returned
36543
+ * via a thrown {@link RecursiveRetainInjectionError} so the outer
36544
+ * `finally` can still reap them — losing the throw stack vs. the
36545
+ * cleanups would have leaked transient S3 objects.
36546
+ *
36547
+ * Exported so the recursive call can be unit-tested end-to-end (the AWS
36548
+ * round-trips are mocked at the SDK boundary, but the recursion shape
36549
+ * itself is the load-bearing piece).
36550
+ */
36551
+ async function injectRetainPoliciesRecursive(rootTemplateBody, rootCfnStackName, rootTree, deps) {
36552
+ const cleanups = [];
36553
+ try {
36554
+ return {
36555
+ ...await injectRetainPoliciesRecursiveInternal(rootTemplateBody, rootCfnStackName, rootTree, deps, cleanups),
36556
+ cleanups
36557
+ };
36558
+ } catch (err) {
36559
+ throw new RecursiveRetainInjectionError(err instanceof Error ? err.message : String(err), cleanups, err instanceof Error ? err : void 0);
36560
+ }
36561
+ }
36562
+ /**
36563
+ * Thrown by {@link injectRetainPoliciesRecursive} when the recursive walk
36564
+ * fails partway through. Carries the array of cleanup callbacks for every
36565
+ * successful transient S3 upload made BEFORE the failure so the outer
36566
+ * `finally` in `retireCloudFormationStack` can still reap them.
36567
+ *
36568
+ * Without this, a mid-walk error would lose every accumulated cleanup —
36569
+ * the error path would skip the parent's `finally` block that drains
36570
+ * `nestedCleanups`, leaking N transient S3 objects per failed retire.
36571
+ */
36572
+ var RecursiveRetainInjectionError = class extends Error {
36573
+ cleanups;
36574
+ cause;
36575
+ constructor(message, cleanups, cause) {
36576
+ super(message);
36577
+ this.name = "RecursiveRetainInjectionError";
36578
+ this.cleanups = cleanups;
36579
+ if (cause) this.cause = cause;
36580
+ }
36581
+ };
36582
+ async function injectRetainPoliciesRecursiveInternal(templateBody, cfnStackName, tree, deps, cleanups) {
36583
+ const format = detectTemplateFormat(templateBody);
36584
+ let parsed;
36585
+ try {
36586
+ parsed = parseCfnTemplate(templateBody);
36587
+ } catch (err) {
36588
+ throw new Error(`Template for '${cfnStackName}' is not a valid CloudFormation template. cdkd's --migrate-from-cloudformation flow supports both JSON and YAML templates (YAML via a CFn-aware codec that preserves !Ref / !GetAtt / !Sub shorthand). Cause: ${err instanceof Error ? err.message : String(err)}`);
36589
+ }
36590
+ if (!("Resources" in parsed) || typeof parsed["Resources"] !== "object" || parsed["Resources"] === null || Array.isArray(parsed["Resources"])) throw new Error(`Template for '${cfnStackName}' has no Resources section — refusing to retire.`);
36591
+ const resources = parsed["Resources"];
36592
+ let modified = false;
36593
+ for (const [logicalId, resource] of Object.entries(resources)) {
36594
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
36595
+ const r = resource;
36596
+ if (r["Type"] !== "AWS::CloudFormation::Stack") continue;
36597
+ const childNode = tree.nested.get(logicalId);
36598
+ if (!childNode) throw new Error(`Template for '${cfnStackName}' has nested-stack '${logicalId}' (Type: ${NESTED_STACK_RESOURCE_TYPE}) but the CloudFormation resource tree has no matching child entry. AWS may have removed the child since DescribeStackResources ran, or the template was hand-edited mid-flight — re-run \`cdkd import --migrate-from-cloudformation\` to refresh.`);
36599
+ const childTpl = await deps.cfnClient.send(new GetTemplateCommand({
36600
+ StackName: childNode.physicalId,
36601
+ TemplateStage: "Original"
36602
+ }));
36603
+ if (!childTpl.TemplateBody) throw new Error(`GetTemplate returned no body for nested stack '${logicalId}' (physicalId='${childNode.physicalId}', parent='${cfnStackName}').`);
36604
+ const childResult = await injectRetainPoliciesRecursiveInternal(childTpl.TemplateBody, childNode.stackName, childNode, deps, cleanups);
36605
+ if (childResult.modified) {
36606
+ if (childResult.body.length > TEMPLATE_URL_LIMIT) throw new Error(`Modified nested-stack template for '${logicalId}' is ${childResult.body.length} bytes, exceeds the CloudFormation TemplateURL limit (${TEMPLATE_URL_LIMIT}). cdkd state has already been written for the root parent; retire the stack manually with (1) shrink the child template, then (2) UpdateStack with Retain policies on the parent and each child, (3) DeleteStack on the parent.`);
36607
+ const uploaded = await uploadCfnTemplate({
36608
+ bucket: deps.stateBucket,
36609
+ body: childResult.body,
36610
+ stackName: `${cfnStackName}__nested__${logicalId}`,
36611
+ format: childResult.format,
36612
+ ...deps.s3ClientOpts && { s3ClientOpts: deps.s3ClientOpts }
36613
+ });
36614
+ cleanups.push(uploaded.cleanup);
36615
+ const propsRaw = r["Properties"];
36616
+ const props = propsRaw && typeof propsRaw === "object" && !Array.isArray(propsRaw) ? propsRaw : r["Properties"] = {};
36617
+ props["TemplateURL"] = uploaded.url;
36618
+ modified = true;
36619
+ }
36620
+ }
36621
+ if (injectRetainPoliciesOnParsedResources(resources)) modified = true;
36391
36622
  return {
36392
36623
  body: stringifyCfnTemplate(parsed, format),
36393
36624
  modified,
@@ -36395,28 +36626,56 @@ function injectRetainPolicies(templateBody, cfnStackName) {
36395
36626
  };
36396
36627
  }
36397
36628
  /**
36398
- * Ask CloudFormation directly which physical id corresponds to each logical
36399
- * id in the named stack. Used by `cdkd import --migrate-from-cloudformation`
36400
- * to side-step cdkd's tag-based auto-lookup (which can't find resources
36401
- * deployed by upstream `cdk deploy` that flow doesn't propagate
36402
- * `aws:cdk:path` as an AWS tag, and AWS reserves the `aws:` tag prefix so
36403
- * we can't add it ourselves either).
36629
+ * Recursive variant of {@link getCloudFormationResourceMapping} that returns
36630
+ * the full nested-stack tree rooted at `rootStackName`. For each
36631
+ * `AWS::CloudFormation::Stack` row in the root's resources, recursively
36632
+ * calls `DescribeStackResources(<child ARN>)` (AWS accepts the ARN as
36633
+ * `StackName`) to populate the child node, and so on to arbitrary depth.
36404
36634
  *
36405
- * Pulls the entire stack with `DescribeStackResources` (one round-trip,
36406
- * unbounded result by spec CFn caps stacks at 500 resources). Resources
36407
- * whose `PhysicalResourceId` is missing (rare; typically an import-failed
36408
- * resource or `AWS::CDK::Metadata`) are skipped silently — the caller
36409
- * already iterates the synthesized template separately and will surface
36410
- * those as `skipped-no-impl` / `skipped-not-found`.
36635
+ * Issue [#464](https://github.com/go-to-k/cdkd/issues/464): the recursive
36636
+ * `cdkd import --migrate-from-cloudformation` flow uses this once at the
36637
+ * top of the import command to drive BOTH (a) per-child state writes
36638
+ * under the v6 state-key shape `cdkd/<parent>~<child>/<region>/state.json`
36639
+ * AND (b) the recursive `injectRetainPoliciesRecursive` walk that retires
36640
+ * the whole tree on AWS without orphaning any resources.
36641
+ *
36642
+ * Children at every level are fetched in parallel via `Promise.all` so an
36643
+ * N-stack-wide tree only takes log(depth) round-trips of wall-clock time
36644
+ * instead of N — load-bearing when a CDK app spreads micro-services across
36645
+ * many nested children.
36646
+ *
36647
+ * Tree node `physicalId` is the AWS-side identifier accepted by every
36648
+ * CFn API call (`DescribeStackResources` / `GetTemplate`). For the root,
36649
+ * that's the user-supplied stack name; for nested children, the child
36650
+ * stack ARN.
36411
36651
  */
36412
- async function getCloudFormationResourceMapping(cfnStackName, cfnClient) {
36413
- const resp = await cfnClient.send(new DescribeStackResourcesCommand({ StackName: cfnStackName }));
36414
- const map = /* @__PURE__ */ new Map();
36652
+ async function getCloudFormationResourceTree(rootStackName, cfnClient) {
36653
+ return walkCfnStackTree(rootStackName, rootStackName, cfnClient);
36654
+ }
36655
+ async function walkCfnStackTree(stackName, physicalId, cfnClient) {
36656
+ const resp = await cfnClient.send(new DescribeStackResourcesCommand({ StackName: physicalId }));
36657
+ const resources = /* @__PURE__ */ new Map();
36658
+ const nestedChildren = [];
36415
36659
  for (const r of resp.StackResources ?? []) {
36416
36660
  if (!r.LogicalResourceId || !r.PhysicalResourceId) continue;
36417
- map.set(r.LogicalResourceId, r.PhysicalResourceId);
36661
+ resources.set(r.LogicalResourceId, r.PhysicalResourceId);
36662
+ if (r.ResourceType === "AWS::CloudFormation::Stack") nestedChildren.push({
36663
+ logicalId: r.LogicalResourceId,
36664
+ childArn: r.PhysicalResourceId
36665
+ });
36418
36666
  }
36419
- return map;
36667
+ const childPairs = await Promise.all(nestedChildren.map(async ({ logicalId, childArn }) => ({
36668
+ logicalId,
36669
+ node: await walkCfnStackTree(childArn, childArn, cfnClient)
36670
+ })));
36671
+ const nested = /* @__PURE__ */ new Map();
36672
+ for (const { logicalId, node } of childPairs) nested.set(logicalId, node);
36673
+ return {
36674
+ stackName,
36675
+ physicalId,
36676
+ resources,
36677
+ nested
36678
+ };
36420
36679
  }
36421
36680
  async function confirmPrompt$2(prompt) {
36422
36681
  const rl = readline.createInterface({
@@ -36499,26 +36758,42 @@ async function importCommand(stackArg, options) {
36499
36758
  const resources = collectImportableResources(template);
36500
36759
  const templateLogicalIds = new Set(resources.map((r) => r.logicalId));
36501
36760
  logger.info(`Found ${resources.length} resource(s) in template`);
36761
+ let migrationTree;
36502
36762
  if (migrationCfnStackName) {
36503
- logger.info(`Resolving physical IDs from CloudFormation stack '${migrationCfnStackName}'...`);
36504
- const cfnMapping = await getCloudFormationResourceMapping(migrationCfnStackName, awsClients.cloudFormation);
36763
+ logger.info(`Resolving physical IDs from CloudFormation stack '${migrationCfnStackName}' (recursive)...`);
36764
+ migrationTree = await getCloudFormationResourceTree(migrationCfnStackName, awsClients.cloudFormation);
36765
+ const cfnMapping = migrationTree.resources;
36505
36766
  let derived = 0;
36506
36767
  let skippedNonImportable = 0;
36768
+ let skippedNestedStackRow = 0;
36507
36769
  for (const [logicalId, physicalId] of cfnMapping) {
36508
36770
  if (!templateLogicalIds.has(logicalId)) {
36509
36771
  skippedNonImportable++;
36510
36772
  continue;
36511
36773
  }
36774
+ if (template.Resources[logicalId]?.Type === "AWS::CloudFormation::Stack") {
36775
+ skippedNestedStackRow++;
36776
+ continue;
36777
+ }
36512
36778
  if (!overrides.has(logicalId)) {
36513
36779
  overrides.set(logicalId, physicalId);
36514
36780
  derived++;
36515
36781
  }
36516
36782
  }
36517
- const overriddenByUser = cfnMapping.size - derived - skippedNonImportable;
36783
+ const overriddenByUser = cfnMapping.size - derived - skippedNonImportable - skippedNestedStackRow;
36518
36784
  const detail = [];
36519
36785
  if (overriddenByUser > 0) detail.push(`${overriddenByUser} already overridden by --resource`);
36520
36786
  if (skippedNonImportable > 0) detail.push(`${skippedNonImportable} non-importable (e.g. CDKMetadata)`);
36787
+ if (skippedNestedStackRow > 0) detail.push(`${skippedNestedStackRow} nested-stack row(s) handled separately`);
36521
36788
  logger.info(`Resolved ${derived} physical ID(s) from CloudFormation` + (detail.length > 0 ? ` (${detail.join(", ")})` : ""));
36789
+ validateNestedStackShape(template, migrationTree, stackInfo.stackName, stackInfo.nestedTemplates ?? {});
36790
+ }
36791
+ let accountIdForNestedSynth;
36792
+ if (migrationTree && migrationTree.nested.size > 0) {
36793
+ const { GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
36794
+ const identity = await awsClients.sts.send(new GetCallerIdentityCommand({}));
36795
+ if (!identity.Account) throw new Error("STS GetCallerIdentity returned no Account — cdkd needs the account ID to synthesize cdkd-local ARNs for nested-stack rows. Verify the active AWS credentials are valid (e.g. `aws sts get-caller-identity`).");
36796
+ accountIdForNestedSynth = identity.Account;
36522
36797
  }
36523
36798
  const selectiveMode = overrides.size > 0 && !options.auto && !options.migrateFromCloudformation;
36524
36799
  if (selectiveMode) logger.info(`Selective mode: only importing the ${overrides.size} resource(s) you listed (${[...overrides.keys()].join(", ")}). Pass --auto to also tag-import the rest.`);
@@ -36549,6 +36824,15 @@ async function importCommand(stackArg, options) {
36549
36824
  });
36550
36825
  continue;
36551
36826
  }
36827
+ if (resource.Type === "AWS::CloudFormation::Stack" && migrationTree && migrationTree.nested.has(logicalId) && accountIdForNestedSynth) {
36828
+ rows.push({
36829
+ logicalId,
36830
+ resourceType: resource.Type,
36831
+ outcome: "imported",
36832
+ physicalId: synthesizeNestedStackArn(targetRegion, accountIdForNestedSynth, stackInfo.stackName, logicalId)
36833
+ });
36834
+ continue;
36835
+ }
36552
36836
  const outcome = await importOne({
36553
36837
  logicalId,
36554
36838
  resource,
@@ -36590,6 +36874,19 @@ async function importCommand(stackArg, options) {
36590
36874
  await stateBackend.saveState(stackInfo.stackName, targetRegion, stackState, saveOptions);
36591
36875
  logger.info(`✓ State written: ${stackInfo.stackName} (${targetRegion})`);
36592
36876
  logger.info(` ${importedRows.length} resource(s) imported. Run 'cdkd diff' to see how the imported state lines up with the template.`);
36877
+ if (migrationCfnStackName && migrationTree && accountIdForNestedSynth) await importNestedStackChildrenRecursive({
36878
+ parentStackName: stackInfo.stackName,
36879
+ parentRegion: targetRegion,
36880
+ parentNestedTemplates: stackInfo.nestedTemplates ?? {},
36881
+ parentTree: migrationTree,
36882
+ stateBackend,
36883
+ lockManager,
36884
+ providerRegistry,
36885
+ templateParser,
36886
+ lockOwner: owner,
36887
+ accountId: accountIdForNestedSynth,
36888
+ logger
36889
+ });
36593
36890
  if (migrationCfnStackName) {
36594
36891
  const orphaned = resources.length - importedRows.length;
36595
36892
  if (orphaned > 0) logger.warn(`--migrate-from-cloudformation: ${orphaned} of ${resources.length} template resource(s) were NOT imported into cdkd. After the CloudFormation stack is retired, those resources remain in AWS but are unmanaged by both CloudFormation and cdkd.`);
@@ -36598,7 +36895,8 @@ async function importCommand(stackArg, options) {
36598
36895
  cfnClient: awsClients.cloudFormation,
36599
36896
  yes: options.yes,
36600
36897
  stateBucket,
36601
- ...options.profile && { s3ClientOpts: { profile: options.profile } }
36898
+ ...options.profile && { s3ClientOpts: { profile: options.profile } },
36899
+ ...migrationTree && { resourceTree: migrationTree }
36602
36900
  });
36603
36901
  }
36604
36902
  } finally {
@@ -37047,6 +37345,205 @@ async function captureObservedForImportedResources(stackState, providerRegistry,
37047
37345
  }
37048
37346
  }));
37049
37347
  }
37348
+ /**
37349
+ * Synthesize the cdkd-local ARN that `NestedStackProvider.create` would write
37350
+ * for a nested-stack resource (design [docs/design/459-nested-stacks.md](../../../docs/design/459-nested-stacks.md)
37351
+ * §3, issue [#464](https://github.com/go-to-k/cdkd/issues/464) §6). Partition
37352
+ * `cdkd-local` is load-bearing — any consumer that misuses this value as a
37353
+ * real AWS ARN fails loudly with "Invalid ARN partition: cdkd-local" rather
37354
+ * than silently using a non-ARN string. The format MUST match
37355
+ * `NestedStackProvider.synthesizeArn` so an import-then-deploy cycle does
37356
+ * not surface phantom property changes on the nested-stack row.
37357
+ */
37358
+ function synthesizeNestedStackArn(region, accountId, parentStackName, logicalId) {
37359
+ return `arn:cdkd-local:${region}:${accountId}:nested-stack/${parentStackName}/${logicalId}`;
37360
+ }
37361
+ /**
37362
+ * Validate the parent template ↔ AWS CFn tree shape consistency before any
37363
+ * destructive walk begins. Three failure modes are surfaced up front so
37364
+ * the user gets one clear error instead of a partial-success state file
37365
+ * graveyard:
37366
+ *
37367
+ * 1. Synth template has `AWS::CloudFormation::Stack` row at logical id X
37368
+ * but AWS tree has no child at X — likely the user added a new
37369
+ * nested child to the CDK code without running `cdk deploy` first.
37370
+ * 2. AWS tree has child at logical id Y but synth template has no
37371
+ * matching row — the user removed a nested child from the CDK code
37372
+ * but the live CFn stack still has it (or AWS removed it
37373
+ * mid-flight).
37374
+ * 3. Synth's `nestedTemplates` index is missing a path for some nested
37375
+ * row — usually means CDK 2.x didn't emit `Metadata['aws:asset:path']`
37376
+ * on the row (older CDK versions, or a hand-edited template). Without
37377
+ * the local template file we can't enumerate the child's resources
37378
+ * for the per-child state write.
37379
+ *
37380
+ * Mirrors the upstream `cdk import` mismatch UX — "import refuses on a
37381
+ * shape mismatch, fix the shape and re-run."
37382
+ */
37383
+ function validateNestedStackShape(template, tree, parentStackName, nestedTemplates) {
37384
+ const templateNestedIds = /* @__PURE__ */ new Set();
37385
+ for (const [logicalId, resource] of Object.entries(template.Resources)) if (resource.Type === "AWS::CloudFormation::Stack") templateNestedIds.add(logicalId);
37386
+ const treeNestedIds = new Set(tree.nested.keys());
37387
+ const inTemplateMissingFromAws = [];
37388
+ for (const id of templateNestedIds) if (!treeNestedIds.has(id)) inTemplateMissingFromAws.push(id);
37389
+ const inAwsMissingFromTemplate = [];
37390
+ for (const id of treeNestedIds) if (!templateNestedIds.has(id)) inAwsMissingFromTemplate.push(id);
37391
+ const inTemplateMissingNestedTemplatePath = [];
37392
+ for (const id of templateNestedIds) if (!nestedTemplates[id]) inTemplateMissingNestedTemplatePath.push(id);
37393
+ const problems = [];
37394
+ if (inTemplateMissingFromAws.length > 0) problems.push(`template has nested-stack row(s) not present in CloudFormation: [${inTemplateMissingFromAws.join(", ")}] — run \`cdk deploy\` first so the AWS-side stack matches the synth template`);
37395
+ if (inAwsMissingFromTemplate.length > 0) problems.push(`CloudFormation has nested-child stack(s) not present in the synth template: [${inAwsMissingFromTemplate.join(", ")}] — the CDK code was edited to remove these children, but the live CFn stack still has them. Run \`cdk deploy\` to apply the removal, or revert the CDK edit`);
37396
+ if (inTemplateMissingNestedTemplatePath.length > 0) problems.push(`synth cloud assembly is missing nested-template asset paths for row(s) [${inTemplateMissingNestedTemplatePath.join(", ")}] — verify CDK 2.x \`cdk.NestedStack\` emits Metadata['aws:asset:path'] (default behavior)`);
37397
+ if (problems.length > 0) throw new Error(`cdkd import --migrate-from-cloudformation: parent stack '${parentStackName}' template ↔ CloudFormation shape mismatch:\n - ${problems.join("\n - ")}`);
37398
+ }
37399
+ /**
37400
+ * After the root parent stack's cdkd state is written, walk the
37401
+ * `migrationTree.nested` map and adopt every nested child into its own
37402
+ * v6-keyed state file (`cdkd/<parent>~<childLogicalId>/<region>/state.json`).
37403
+ * Recurses into grandchildren via the same walker.
37404
+ *
37405
+ * Per child:
37406
+ * 1. Acquire the child's lock. Order across the full tree is
37407
+ * **parent-first acquire, leaves-first release** (each level's
37408
+ * `finally` releases its child lock before sibling iteration
37409
+ * continues, and the root lock is the outermost — held by
37410
+ * `runImport`'s `try` / `finally`). This is the conventional
37411
+ * hierarchical lock pattern: parent-first acquire prevents a
37412
+ * second cdkd import from racing past the root lock; leaves-first
37413
+ * release on success/failure means a mid-walk error never strands
37414
+ * a child lock past its scope. Design §3.3 wording ("leaves first,
37415
+ * parent last") is preserved as the RELEASE order; the acquire
37416
+ * order is parent-first to keep the lock graph deadlock-free.
37417
+ * 2. Read the child template body from the synth cloud assembly via
37418
+ * `parentNestedTemplates[<childLogicalId>]` (populated by
37419
+ * AssemblyReader at synth time — see {@link AssemblyReader.parseStack}).
37420
+ * 3. Enumerate the child's importable resources (same filter as the
37421
+ * root's `collectImportableResources` — drop `AWS::CDK::Metadata`,
37422
+ * short-circuit `AWS::CloudFormation::Stack` rows to synth ARNs).
37423
+ * 4. For each child resource: dispatch through `importOne` with the
37424
+ * child's `(logicalId → physicalId)` overrides from `childTree.resources`.
37425
+ * 5. Build the child's `StackState` with `parentStack` / `parentLogicalId`
37426
+ * / `parentRegion` populated per state schema v6.
37427
+ * 6. Save state via `stateBackend.saveState('<parent>~<childLogicalId>', ...)`.
37428
+ * 7. Recurse into grandchildren.
37429
+ *
37430
+ * Lock release happens in REVERSE acquisition order in the outer
37431
+ * `finally` of each child — leaves-first acquire / parent-last release on
37432
+ * success, AND parent-last release on failure. Per memory rule
37433
+ * `feedback_destructive_state_test_coverage.md`, the lock-release order
37434
+ * is verified by unit test.
37435
+ */
37436
+ async function importNestedStackChildrenRecursive(args) {
37437
+ const { parentStackName, parentRegion, parentNestedTemplates, parentTree, stateBackend, lockManager, providerRegistry, templateParser, lockOwner, accountId, logger } = args;
37438
+ for (const [childLogicalId, childTreeNode] of parentTree.nested) {
37439
+ const childStackName = `${parentStackName}~${childLogicalId}`;
37440
+ const childRegion = parentRegion;
37441
+ const childTemplatePath = parentNestedTemplates[childLogicalId];
37442
+ if (!childTemplatePath) throw new Error(`cdkd import --migrate-from-cloudformation: missing nested-template path for '${childLogicalId}' under parent '${parentStackName}' — validateNestedStackShape should have rejected this; please file a bug.`);
37443
+ logger.info(`Adopting nested stack '${childLogicalId}' as cdkd stack '${childStackName}' (${childRegion})...`);
37444
+ const childTemplate = readNestedChildTemplate(childTemplatePath, childLogicalId);
37445
+ await lockManager.acquireLock(childStackName, childRegion, lockOwner, "import");
37446
+ try {
37447
+ const childResources = collectImportableResources(childTemplate);
37448
+ const childTemplateLogicalIds = new Set(childResources.map((r) => r.logicalId));
37449
+ const childOverrides = /* @__PURE__ */ new Map();
37450
+ for (const [logicalId, physicalId] of childTreeNode.resources) {
37451
+ if (!childTemplateLogicalIds.has(logicalId)) continue;
37452
+ if (childTemplate.Resources[logicalId]?.Type === "AWS::CloudFormation::Stack") continue;
37453
+ childOverrides.set(logicalId, physicalId);
37454
+ }
37455
+ const rows = [];
37456
+ for (const { logicalId, resource } of childResources) {
37457
+ if (resource.Type === "AWS::CloudFormation::Stack" && childTreeNode.nested.has(logicalId)) {
37458
+ rows.push({
37459
+ logicalId,
37460
+ resourceType: resource.Type,
37461
+ outcome: "imported",
37462
+ physicalId: synthesizeNestedStackArn(childRegion, accountId, childStackName, logicalId)
37463
+ });
37464
+ continue;
37465
+ }
37466
+ const outcome = await importOne({
37467
+ logicalId,
37468
+ resource,
37469
+ stackName: childStackName,
37470
+ region: childRegion,
37471
+ providerRegistry,
37472
+ override: childOverrides.get(logicalId),
37473
+ overrides: childOverrides
37474
+ });
37475
+ rows.push(outcome);
37476
+ }
37477
+ const childStackState = buildStackState(childStackName, childRegion, rows, templateParser, childTemplate, null, false);
37478
+ childStackState.parentStack = parentStackName;
37479
+ childStackState.parentLogicalId = childLogicalId;
37480
+ childStackState.parentRegion = parentRegion;
37481
+ await resolveImportedProperties(childStackState, childTemplate, childRegion, stateBackend, logger);
37482
+ await captureObservedForImportedResources(childStackState, providerRegistry, logger);
37483
+ await stateBackend.saveState(childStackName, childRegion, childStackState);
37484
+ logger.info(`✓ Nested stack state written: ${childStackName} (${childRegion}) — ${rows.filter((r) => r.outcome === "imported").length} resource(s) imported.`);
37485
+ if (childTreeNode.nested.size > 0) await importNestedStackChildrenRecursive({
37486
+ parentStackName: childStackName,
37487
+ parentRegion: childRegion,
37488
+ parentNestedTemplates: indexGrandchildTemplatePaths(childTemplate, childTemplatePath),
37489
+ parentTree: childTreeNode,
37490
+ stateBackend,
37491
+ lockManager,
37492
+ providerRegistry,
37493
+ templateParser,
37494
+ lockOwner,
37495
+ accountId,
37496
+ logger
37497
+ });
37498
+ } finally {
37499
+ await lockManager.releaseLock(childStackName, childRegion).catch((err) => {
37500
+ logger.warn(`Failed to release lock for nested stack '${childStackName}' (${childRegion}): ${err instanceof Error ? err.message : String(err)}`);
37501
+ });
37502
+ }
37503
+ }
37504
+ }
37505
+ /**
37506
+ * Read a nested child's template body from the synth cloud assembly's
37507
+ * sibling file (the path AssemblyReader populates from
37508
+ * `Metadata['aws:asset:path']`). Wraps the I/O + JSON parse failures with
37509
+ * an actionable error that names the offending child logical id.
37510
+ */
37511
+ function readNestedChildTemplate(templatePath, childLogicalId) {
37512
+ let raw;
37513
+ try {
37514
+ raw = readFileSync(templatePath, "utf-8");
37515
+ } catch (err) {
37516
+ throw new Error(`Failed to read nested-stack template for '${childLogicalId}' at ${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
37517
+ }
37518
+ try {
37519
+ return JSON.parse(raw);
37520
+ } catch (err) {
37521
+ throw new Error(`Failed to parse nested-stack template for '${childLogicalId}' at ${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
37522
+ }
37523
+ }
37524
+ /**
37525
+ * Index a child template's `AWS::CloudFormation::Stack` rows by their
37526
+ * `aws:asset:path` Metadata, mirroring what {@link AssemblyReader.parseStack}
37527
+ * does for the root parent. Grandchild template files are sibling .nested
37528
+ * files of the child template file in the same cdk.out subdirectory.
37529
+ *
37530
+ * Refuses absolute paths for the same reason `NestedStackProvider.indexGrandchildTemplates`
37531
+ * does: an absolute path indicates the synth output was hand-modified or
37532
+ * generated by a non-CDK toolchain — `path.join(dir, '/abs/foo')` would
37533
+ * silently bypass our `dir` resolution and point outside cdk.out.
37534
+ */
37535
+ function indexGrandchildTemplatePaths(childTemplate, childTemplatePath) {
37536
+ const dir = path.dirname(childTemplatePath);
37537
+ const result = {};
37538
+ for (const [grandLogicalId, resource] of Object.entries(childTemplate.Resources)) {
37539
+ if (resource.Type !== "AWS::CloudFormation::Stack") continue;
37540
+ const assetPath = resource.Metadata?.["aws:asset:path"];
37541
+ if (typeof assetPath !== "string" || assetPath.length === 0) continue;
37542
+ if (path.isAbsolute(assetPath)) throw new Error(`cdkd import --migrate-from-cloudformation: grandchild nested-stack '${grandLogicalId}' has Metadata['aws:asset:path']='${assetPath}' which is absolute. CDK emits relative asset paths for nested templates.`);
37543
+ result[grandLogicalId] = path.join(dir, assetPath);
37544
+ }
37545
+ return result;
37546
+ }
37050
37547
 
37051
37548
  //#endregion
37052
37549
  //#region src/cli/commands/local-state-loader.ts
@@ -55975,7 +56472,7 @@ function reorderArgs(argv) {
55975
56472
  */
55976
56473
  async function main() {
55977
56474
  const program = new Command();
55978
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.145.1");
56475
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.146.1");
55979
56476
  program.addCommand(createBootstrapCommand());
55980
56477
  program.addCommand(createSynthCommand());
55981
56478
  program.addCommand(createListCommand());