@go-to-k/cdkd 0.145.1 → 0.146.0

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
@@ -36196,6 +36196,16 @@ function parseCfnTemplateWithFormat(text) {
36196
36196
  //#endregion
36197
36197
  //#region src/cli/commands/retire-cfn-stack.ts
36198
36198
  /**
36199
+ * Resource type for a CloudFormation nested-stack child. Hoisted as a
36200
+ * constant so the recursive walker, the Retain-injection skip rule, and
36201
+ * the import-side short-circuit all reference the same literal — a typo
36202
+ * in any one of them would otherwise silently break nested-stack support
36203
+ * (the offending row would either be missed by the tree walk or have
36204
+ * Retain injected on it, both of which would orphan the child stack
36205
+ * record on AWS at retire time).
36206
+ */
36207
+ const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
36208
+ /**
36199
36209
  * UpdateStack TemplateBody hard limit (51,200 bytes). Templates larger than
36200
36210
  * this are uploaded to cdkd's state S3 bucket and submitted via `TemplateURL`
36201
36211
  * instead — see {@link uploadTemplateForUpdateStack}.
@@ -36242,7 +36252,8 @@ const TEMPLATE_URL_LIMIT = CFN_TEMPLATE_URL_LIMIT;
36242
36252
  */
36243
36253
  async function retireCloudFormationStack(options) {
36244
36254
  const logger = getLogger();
36245
- const { cfnStackName, cfnClient, yes, stateBucket, s3ClientOpts } = options;
36255
+ const { cfnStackName, cfnClient, yes, stateBucket, s3ClientOpts, resourceTree } = options;
36256
+ 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
36257
  logger.info(`[1/4] Inspecting CloudFormation stack '${cfnStackName}'...`);
36247
36258
  const stack = (await cfnClient.send(new DescribeStacksCommand({ StackName: cfnStackName }))).Stacks?.[0];
36248
36259
  if (!stack) throw new Error(`CloudFormation stack '${cfnStackName}' not found.`);
@@ -36254,15 +36265,54 @@ async function retireCloudFormationStack(options) {
36254
36265
  TemplateStage: "Original"
36255
36266
  }));
36256
36267
  if (!tpl.TemplateBody) throw new Error(`GetTemplate returned no body for '${cfnStackName}'.`);
36257
- const { body: newBody, modified, format } = injectRetainPolicies(tpl.TemplateBody, cfnStackName);
36268
+ const nestedCleanups = [];
36269
+ const hasNestedChildren = templateContainsNestedStackRows(tpl.TemplateBody);
36270
+ let newBody;
36271
+ let modified;
36272
+ let format;
36273
+ if (hasNestedChildren) {
36274
+ const tree = resourceTree ?? await getCloudFormationResourceTree(cfnStackName, cfnClient);
36275
+ try {
36276
+ const recursive = await injectRetainPoliciesRecursive(tpl.TemplateBody, cfnStackName, tree, {
36277
+ cfnClient,
36278
+ stateBucket,
36279
+ ...s3ClientOpts && { s3ClientOpts }
36280
+ });
36281
+ newBody = recursive.body;
36282
+ modified = recursive.modified;
36283
+ format = recursive.format;
36284
+ nestedCleanups.push(...recursive.cleanups);
36285
+ } catch (err) {
36286
+ if (err instanceof RecursiveRetainInjectionError) for (const cleanup of err.cleanups) try {
36287
+ await cleanup();
36288
+ } catch (cleanupErr) {
36289
+ const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36290
+ logger.warn(`Failed to delete partial nested-template upload from '${stateBucket}' during error recovery. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
36291
+ }
36292
+ throw err;
36293
+ }
36294
+ } else ({body: newBody, modified, format} = injectRetainPolicies(tpl.TemplateBody, cfnStackName));
36258
36295
  if (!yes) {
36259
36296
  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
36297
  logger.info("CloudFormation stack retirement cancelled. cdkd state is unaffected.");
36298
+ for (const cleanup of nestedCleanups) try {
36299
+ await cleanup();
36300
+ } catch (cleanupErr) {
36301
+ const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36302
+ logger.warn(`Failed to delete temporary nested-template upload from '${stateBucket}' during cancel cleanup. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
36303
+ }
36261
36304
  return { outcome: "cancelled" };
36262
36305
  }
36263
36306
  }
36264
- if (!modified) logger.info(`[2/4] Template already has Retain on every resource — skipping UpdateStack.`);
36265
- else {
36307
+ if (!modified) {
36308
+ logger.info(`[2/4] Template already has Retain on every resource — skipping UpdateStack.`);
36309
+ if (nestedCleanups.length > 0) for (const cleanup of nestedCleanups) try {
36310
+ await cleanup();
36311
+ } catch (cleanupErr) {
36312
+ const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36313
+ logger.warn(`Failed to delete temporary nested-template upload from '${stateBucket}'. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
36314
+ }
36315
+ } else {
36266
36316
  logger.info(`[2/4] Injected DeletionPolicy=Retain and UpdateReplacePolicy=Retain.`);
36267
36317
  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
36318
  let updateInput;
@@ -36305,8 +36355,9 @@ async function retireCloudFormationStack(options) {
36305
36355
  maxWaitTime: 1800
36306
36356
  }, { StackName: cfnStackName });
36307
36357
  } finally {
36308
- if (s3Cleanup) try {
36309
- await s3Cleanup();
36358
+ const allCleanups = [...s3Cleanup ? [s3Cleanup] : [], ...nestedCleanups];
36359
+ for (const cleanup of allCleanups) try {
36360
+ await cleanup();
36310
36361
  } catch (cleanupErr) {
36311
36362
  const msg = cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr);
36312
36363
  logger.warn(`Failed to delete temporary template upload from '${stateBucket}'. Clean up manually under prefix '${MIGRATE_TMP_PREFIX}/'. Cause: ${msg}`);
@@ -36374,11 +36425,66 @@ function injectRetainPolicies(templateBody, cfnStackName) {
36374
36425
  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
36426
  }
36376
36427
  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;
36428
+ const modified = injectRetainPoliciesOnParsedResources(parsed["Resources"]);
36429
+ return {
36430
+ body: stringifyCfnTemplate(parsed, format),
36431
+ modified,
36432
+ format
36433
+ };
36434
+ }
36435
+ /**
36436
+ * Cheap sniff: does the template body contain any `AWS::CloudFormation::Stack`
36437
+ * resource rows? Parses + walks `Resources` once (no recursive descent),
36438
+ * tolerating parse failures by returning `false` (the caller's downstream
36439
+ * parse step will surface the same error with a richer message). Used by
36440
+ * {@link retireCloudFormationStack} to decide whether to lazily build the
36441
+ * recursive {@link CfnStackResourceTree} — non-nested templates skip the
36442
+ * extra `DescribeStackResources` round-trip entirely.
36443
+ */
36444
+ function templateContainsNestedStackRows(templateBody) {
36445
+ let parsed;
36446
+ try {
36447
+ parsed = parseCfnTemplate(templateBody);
36448
+ } catch {
36449
+ return false;
36450
+ }
36378
36451
  const resources = parsed["Resources"];
36452
+ if (!resources || typeof resources !== "object" || Array.isArray(resources)) return false;
36453
+ for (const resource of Object.values(resources)) {
36454
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
36455
+ if (resource["Type"] === "AWS::CloudFormation::Stack") return true;
36456
+ }
36457
+ return false;
36458
+ }
36459
+ /**
36460
+ * Mutate an already-parsed `Resources` map in place: set `DeletionPolicy: Retain`
36461
+ * and `UpdateReplacePolicy: Retain` on every leaf resource that doesn't
36462
+ * already have them. Returns the `modified` flag so the caller can decide
36463
+ * whether the round-trip is worth a `stringifyCfnTemplate` re-serialization
36464
+ * (and whether `UpdateStack` is needed at all).
36465
+ *
36466
+ * `AWS::CloudFormation::Stack` resources are **intentionally skipped** — see
36467
+ * issue [#464](https://github.com/go-to-k/cdkd/issues/464) and the design at
36468
+ * [docs/design/464-nested-stacks-export-import.md](../../../docs/design/464-nested-stacks-export-import.md)
36469
+ * §3.4. Retain on a nested-stack row tells AWS CFn's parent-side `DeleteStack`
36470
+ * to NOT cascade-delete the child stack record at all, which would leave a
36471
+ * stranded child stack record on AWS that the user has to clean up manually.
36472
+ * We want the cascade to descend (so child stack records get deleted as the
36473
+ * tree unwinds) while every child's own leaf resources stay because THEIR
36474
+ * Retain was injected on the previous recursion level — the recursive
36475
+ * Retain injection in {@link injectRetainPoliciesRecursive} relies on this
36476
+ * skip rule to behave correctly.
36477
+ *
36478
+ * Shared between the single-template path ({@link injectRetainPolicies}) and
36479
+ * the per-level walk inside {@link injectRetainPoliciesRecursive} so both
36480
+ * sites apply the exact same skip rule with no risk of drift.
36481
+ */
36482
+ function injectRetainPoliciesOnParsedResources(resources) {
36483
+ let modified = false;
36379
36484
  for (const [, resource] of Object.entries(resources)) {
36380
36485
  if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
36381
36486
  const r = resource;
36487
+ if (r["Type"] === "AWS::CloudFormation::Stack") continue;
36382
36488
  if (r["DeletionPolicy"] !== "Retain") {
36383
36489
  r["DeletionPolicy"] = "Retain";
36384
36490
  modified = true;
@@ -36388,6 +36494,119 @@ function injectRetainPolicies(templateBody, cfnStackName) {
36388
36494
  modified = true;
36389
36495
  }
36390
36496
  }
36497
+ return modified;
36498
+ }
36499
+ /**
36500
+ * Recursive variant of {@link injectRetainPolicies} that handles a parent
36501
+ * template containing one or more `AWS::CloudFormation::Stack` rows. Issue
36502
+ * [#464](https://github.com/go-to-k/cdkd/issues/464); see
36503
+ * [docs/design/464-nested-stacks-export-import.md](../../../docs/design/464-nested-stacks-export-import.md)
36504
+ * §3.4 for the design rationale.
36505
+ *
36506
+ * For each nested-stack row in the parent template:
36507
+ * 1. `GetTemplate` Original-stage on the corresponding child stack ARN (read
36508
+ * from the supplied {@link CfnStackResourceTree}, populated up front by
36509
+ * {@link getCloudFormationResourceTree}).
36510
+ * 2. Recursively process the child template — Retain injection on its
36511
+ * leaves AND further recursion into any grandchildren.
36512
+ * 3. If the recursion produced a modified child body, upload the modified
36513
+ * body to the cdkd state bucket via the shared {@link uploadCfnTemplate}
36514
+ * helper and rewrite the parent's `Properties.TemplateURL` for that
36515
+ * row to the uploaded URL — so the parent's eventual `UpdateStack`
36516
+ * cascades into the child with the Retain-bearing template.
36517
+ *
36518
+ * After all nested-stack rows are processed, the parent's own leaf
36519
+ * resources get Retain injected via {@link injectRetainPoliciesOnParsedResources}
36520
+ * — the SAME helper the single-template path uses, applying the same
36521
+ * "skip `AWS::CloudFormation::Stack` rows" rule (those rows must NOT have
36522
+ * Retain or cascade-delete won't descend — see the helper's doc-comment).
36523
+ *
36524
+ * The returned `cleanups` array carries one S3 delete callback per
36525
+ * transient child-template upload made anywhere in the recursive walk.
36526
+ * The caller drains it in a `finally` block around the parent
36527
+ * `UpdateStack` call — CFn fetches each child template synchronously
36528
+ * during `UpdateStack`, so the transient objects can be reaped as soon
36529
+ * as that call returns (success OR failure). When the helper itself
36530
+ * throws mid-walk, the partial uploads accumulated so far are returned
36531
+ * via a thrown {@link RecursiveRetainInjectionError} so the outer
36532
+ * `finally` can still reap them — losing the throw stack vs. the
36533
+ * cleanups would have leaked transient S3 objects.
36534
+ *
36535
+ * Exported so the recursive call can be unit-tested end-to-end (the AWS
36536
+ * round-trips are mocked at the SDK boundary, but the recursion shape
36537
+ * itself is the load-bearing piece).
36538
+ */
36539
+ async function injectRetainPoliciesRecursive(rootTemplateBody, rootCfnStackName, rootTree, deps) {
36540
+ const cleanups = [];
36541
+ try {
36542
+ return {
36543
+ ...await injectRetainPoliciesRecursiveInternal(rootTemplateBody, rootCfnStackName, rootTree, deps, cleanups),
36544
+ cleanups
36545
+ };
36546
+ } catch (err) {
36547
+ throw new RecursiveRetainInjectionError(err instanceof Error ? err.message : String(err), cleanups, err instanceof Error ? err : void 0);
36548
+ }
36549
+ }
36550
+ /**
36551
+ * Thrown by {@link injectRetainPoliciesRecursive} when the recursive walk
36552
+ * fails partway through. Carries the array of cleanup callbacks for every
36553
+ * successful transient S3 upload made BEFORE the failure so the outer
36554
+ * `finally` in `retireCloudFormationStack` can still reap them.
36555
+ *
36556
+ * Without this, a mid-walk error would lose every accumulated cleanup —
36557
+ * the error path would skip the parent's `finally` block that drains
36558
+ * `nestedCleanups`, leaking N transient S3 objects per failed retire.
36559
+ */
36560
+ var RecursiveRetainInjectionError = class extends Error {
36561
+ cleanups;
36562
+ cause;
36563
+ constructor(message, cleanups, cause) {
36564
+ super(message);
36565
+ this.name = "RecursiveRetainInjectionError";
36566
+ this.cleanups = cleanups;
36567
+ if (cause) this.cause = cause;
36568
+ }
36569
+ };
36570
+ async function injectRetainPoliciesRecursiveInternal(templateBody, cfnStackName, tree, deps, cleanups) {
36571
+ const format = detectTemplateFormat(templateBody);
36572
+ let parsed;
36573
+ try {
36574
+ parsed = parseCfnTemplate(templateBody);
36575
+ } catch (err) {
36576
+ 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)}`);
36577
+ }
36578
+ 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.`);
36579
+ const resources = parsed["Resources"];
36580
+ let modified = false;
36581
+ for (const [logicalId, resource] of Object.entries(resources)) {
36582
+ if (!resource || typeof resource !== "object" || Array.isArray(resource)) continue;
36583
+ const r = resource;
36584
+ if (r["Type"] !== "AWS::CloudFormation::Stack") continue;
36585
+ const childNode = tree.nested.get(logicalId);
36586
+ 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.`);
36587
+ const childTpl = await deps.cfnClient.send(new GetTemplateCommand({
36588
+ StackName: childNode.physicalId,
36589
+ TemplateStage: "Original"
36590
+ }));
36591
+ if (!childTpl.TemplateBody) throw new Error(`GetTemplate returned no body for nested stack '${logicalId}' (physicalId='${childNode.physicalId}', parent='${cfnStackName}').`);
36592
+ const childResult = await injectRetainPoliciesRecursiveInternal(childTpl.TemplateBody, childNode.stackName, childNode, deps, cleanups);
36593
+ if (childResult.modified) {
36594
+ 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.`);
36595
+ const uploaded = await uploadCfnTemplate({
36596
+ bucket: deps.stateBucket,
36597
+ body: childResult.body,
36598
+ stackName: `${cfnStackName}__nested__${logicalId}`,
36599
+ format: childResult.format,
36600
+ ...deps.s3ClientOpts && { s3ClientOpts: deps.s3ClientOpts }
36601
+ });
36602
+ cleanups.push(uploaded.cleanup);
36603
+ const propsRaw = r["Properties"];
36604
+ const props = propsRaw && typeof propsRaw === "object" && !Array.isArray(propsRaw) ? propsRaw : r["Properties"] = {};
36605
+ props["TemplateURL"] = uploaded.url;
36606
+ modified = true;
36607
+ }
36608
+ }
36609
+ if (injectRetainPoliciesOnParsedResources(resources)) modified = true;
36391
36610
  return {
36392
36611
  body: stringifyCfnTemplate(parsed, format),
36393
36612
  modified,
@@ -36395,28 +36614,56 @@ function injectRetainPolicies(templateBody, cfnStackName) {
36395
36614
  };
36396
36615
  }
36397
36616
  /**
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).
36617
+ * Recursive variant of {@link getCloudFormationResourceMapping} that returns
36618
+ * the full nested-stack tree rooted at `rootStackName`. For each
36619
+ * `AWS::CloudFormation::Stack` row in the root's resources, recursively
36620
+ * calls `DescribeStackResources(<child ARN>)` (AWS accepts the ARN as
36621
+ * `StackName`) to populate the child node, and so on to arbitrary depth.
36622
+ *
36623
+ * Issue [#464](https://github.com/go-to-k/cdkd/issues/464): the recursive
36624
+ * `cdkd import --migrate-from-cloudformation` flow uses this once at the
36625
+ * top of the import command to drive BOTH (a) per-child state writes
36626
+ * under the v6 state-key shape `cdkd/<parent>~<child>/<region>/state.json`
36627
+ * AND (b) the recursive `injectRetainPoliciesRecursive` walk that retires
36628
+ * the whole tree on AWS without orphaning any resources.
36404
36629
  *
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`.
36630
+ * Children at every level are fetched in parallel via `Promise.all` so an
36631
+ * N-stack-wide tree only takes log(depth) round-trips of wall-clock time
36632
+ * instead of N load-bearing when a CDK app spreads micro-services across
36633
+ * many nested children.
36634
+ *
36635
+ * Tree node `physicalId` is the AWS-side identifier accepted by every
36636
+ * CFn API call (`DescribeStackResources` / `GetTemplate`). For the root,
36637
+ * that's the user-supplied stack name; for nested children, the child
36638
+ * stack ARN.
36411
36639
  */
36412
- async function getCloudFormationResourceMapping(cfnStackName, cfnClient) {
36413
- const resp = await cfnClient.send(new DescribeStackResourcesCommand({ StackName: cfnStackName }));
36414
- const map = /* @__PURE__ */ new Map();
36640
+ async function getCloudFormationResourceTree(rootStackName, cfnClient) {
36641
+ return walkCfnStackTree(rootStackName, rootStackName, cfnClient);
36642
+ }
36643
+ async function walkCfnStackTree(stackName, physicalId, cfnClient) {
36644
+ const resp = await cfnClient.send(new DescribeStackResourcesCommand({ StackName: physicalId }));
36645
+ const resources = /* @__PURE__ */ new Map();
36646
+ const nestedChildren = [];
36415
36647
  for (const r of resp.StackResources ?? []) {
36416
36648
  if (!r.LogicalResourceId || !r.PhysicalResourceId) continue;
36417
- map.set(r.LogicalResourceId, r.PhysicalResourceId);
36649
+ resources.set(r.LogicalResourceId, r.PhysicalResourceId);
36650
+ if (r.ResourceType === "AWS::CloudFormation::Stack") nestedChildren.push({
36651
+ logicalId: r.LogicalResourceId,
36652
+ childArn: r.PhysicalResourceId
36653
+ });
36418
36654
  }
36419
- return map;
36655
+ const childPairs = await Promise.all(nestedChildren.map(async ({ logicalId, childArn }) => ({
36656
+ logicalId,
36657
+ node: await walkCfnStackTree(childArn, childArn, cfnClient)
36658
+ })));
36659
+ const nested = /* @__PURE__ */ new Map();
36660
+ for (const { logicalId, node } of childPairs) nested.set(logicalId, node);
36661
+ return {
36662
+ stackName,
36663
+ physicalId,
36664
+ resources,
36665
+ nested
36666
+ };
36420
36667
  }
36421
36668
  async function confirmPrompt$2(prompt) {
36422
36669
  const rl = readline.createInterface({
@@ -36499,26 +36746,42 @@ async function importCommand(stackArg, options) {
36499
36746
  const resources = collectImportableResources(template);
36500
36747
  const templateLogicalIds = new Set(resources.map((r) => r.logicalId));
36501
36748
  logger.info(`Found ${resources.length} resource(s) in template`);
36749
+ let migrationTree;
36502
36750
  if (migrationCfnStackName) {
36503
- logger.info(`Resolving physical IDs from CloudFormation stack '${migrationCfnStackName}'...`);
36504
- const cfnMapping = await getCloudFormationResourceMapping(migrationCfnStackName, awsClients.cloudFormation);
36751
+ logger.info(`Resolving physical IDs from CloudFormation stack '${migrationCfnStackName}' (recursive)...`);
36752
+ migrationTree = await getCloudFormationResourceTree(migrationCfnStackName, awsClients.cloudFormation);
36753
+ const cfnMapping = migrationTree.resources;
36505
36754
  let derived = 0;
36506
36755
  let skippedNonImportable = 0;
36756
+ let skippedNestedStackRow = 0;
36507
36757
  for (const [logicalId, physicalId] of cfnMapping) {
36508
36758
  if (!templateLogicalIds.has(logicalId)) {
36509
36759
  skippedNonImportable++;
36510
36760
  continue;
36511
36761
  }
36762
+ if (template.Resources[logicalId]?.Type === "AWS::CloudFormation::Stack") {
36763
+ skippedNestedStackRow++;
36764
+ continue;
36765
+ }
36512
36766
  if (!overrides.has(logicalId)) {
36513
36767
  overrides.set(logicalId, physicalId);
36514
36768
  derived++;
36515
36769
  }
36516
36770
  }
36517
- const overriddenByUser = cfnMapping.size - derived - skippedNonImportable;
36771
+ const overriddenByUser = cfnMapping.size - derived - skippedNonImportable - skippedNestedStackRow;
36518
36772
  const detail = [];
36519
36773
  if (overriddenByUser > 0) detail.push(`${overriddenByUser} already overridden by --resource`);
36520
36774
  if (skippedNonImportable > 0) detail.push(`${skippedNonImportable} non-importable (e.g. CDKMetadata)`);
36775
+ if (skippedNestedStackRow > 0) detail.push(`${skippedNestedStackRow} nested-stack row(s) handled separately`);
36521
36776
  logger.info(`Resolved ${derived} physical ID(s) from CloudFormation` + (detail.length > 0 ? ` (${detail.join(", ")})` : ""));
36777
+ validateNestedStackShape(template, migrationTree, stackInfo.stackName, stackInfo.nestedTemplates ?? {});
36778
+ }
36779
+ let accountIdForNestedSynth;
36780
+ if (migrationTree && migrationTree.nested.size > 0) {
36781
+ const { GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
36782
+ const identity = await awsClients.sts.send(new GetCallerIdentityCommand({}));
36783
+ 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`).");
36784
+ accountIdForNestedSynth = identity.Account;
36522
36785
  }
36523
36786
  const selectiveMode = overrides.size > 0 && !options.auto && !options.migrateFromCloudformation;
36524
36787
  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 +36812,15 @@ async function importCommand(stackArg, options) {
36549
36812
  });
36550
36813
  continue;
36551
36814
  }
36815
+ if (resource.Type === "AWS::CloudFormation::Stack" && migrationTree && migrationTree.nested.has(logicalId) && accountIdForNestedSynth) {
36816
+ rows.push({
36817
+ logicalId,
36818
+ resourceType: resource.Type,
36819
+ outcome: "imported",
36820
+ physicalId: synthesizeNestedStackArn(targetRegion, accountIdForNestedSynth, stackInfo.stackName, logicalId)
36821
+ });
36822
+ continue;
36823
+ }
36552
36824
  const outcome = await importOne({
36553
36825
  logicalId,
36554
36826
  resource,
@@ -36590,6 +36862,19 @@ async function importCommand(stackArg, options) {
36590
36862
  await stateBackend.saveState(stackInfo.stackName, targetRegion, stackState, saveOptions);
36591
36863
  logger.info(`✓ State written: ${stackInfo.stackName} (${targetRegion})`);
36592
36864
  logger.info(` ${importedRows.length} resource(s) imported. Run 'cdkd diff' to see how the imported state lines up with the template.`);
36865
+ if (migrationCfnStackName && migrationTree && accountIdForNestedSynth) await importNestedStackChildrenRecursive({
36866
+ parentStackName: stackInfo.stackName,
36867
+ parentRegion: targetRegion,
36868
+ parentNestedTemplates: stackInfo.nestedTemplates ?? {},
36869
+ parentTree: migrationTree,
36870
+ stateBackend,
36871
+ lockManager,
36872
+ providerRegistry,
36873
+ templateParser,
36874
+ lockOwner: owner,
36875
+ accountId: accountIdForNestedSynth,
36876
+ logger
36877
+ });
36593
36878
  if (migrationCfnStackName) {
36594
36879
  const orphaned = resources.length - importedRows.length;
36595
36880
  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 +36883,8 @@ async function importCommand(stackArg, options) {
36598
36883
  cfnClient: awsClients.cloudFormation,
36599
36884
  yes: options.yes,
36600
36885
  stateBucket,
36601
- ...options.profile && { s3ClientOpts: { profile: options.profile } }
36886
+ ...options.profile && { s3ClientOpts: { profile: options.profile } },
36887
+ ...migrationTree && { resourceTree: migrationTree }
36602
36888
  });
36603
36889
  }
36604
36890
  } finally {
@@ -37047,6 +37333,205 @@ async function captureObservedForImportedResources(stackState, providerRegistry,
37047
37333
  }
37048
37334
  }));
37049
37335
  }
37336
+ /**
37337
+ * Synthesize the cdkd-local ARN that `NestedStackProvider.create` would write
37338
+ * for a nested-stack resource (design [docs/design/459-nested-stacks.md](../../../docs/design/459-nested-stacks.md)
37339
+ * §3, issue [#464](https://github.com/go-to-k/cdkd/issues/464) §6). Partition
37340
+ * `cdkd-local` is load-bearing — any consumer that misuses this value as a
37341
+ * real AWS ARN fails loudly with "Invalid ARN partition: cdkd-local" rather
37342
+ * than silently using a non-ARN string. The format MUST match
37343
+ * `NestedStackProvider.synthesizeArn` so an import-then-deploy cycle does
37344
+ * not surface phantom property changes on the nested-stack row.
37345
+ */
37346
+ function synthesizeNestedStackArn(region, accountId, parentStackName, logicalId) {
37347
+ return `arn:cdkd-local:${region}:${accountId}:nested-stack/${parentStackName}/${logicalId}`;
37348
+ }
37349
+ /**
37350
+ * Validate the parent template ↔ AWS CFn tree shape consistency before any
37351
+ * destructive walk begins. Three failure modes are surfaced up front so
37352
+ * the user gets one clear error instead of a partial-success state file
37353
+ * graveyard:
37354
+ *
37355
+ * 1. Synth template has `AWS::CloudFormation::Stack` row at logical id X
37356
+ * but AWS tree has no child at X — likely the user added a new
37357
+ * nested child to the CDK code without running `cdk deploy` first.
37358
+ * 2. AWS tree has child at logical id Y but synth template has no
37359
+ * matching row — the user removed a nested child from the CDK code
37360
+ * but the live CFn stack still has it (or AWS removed it
37361
+ * mid-flight).
37362
+ * 3. Synth's `nestedTemplates` index is missing a path for some nested
37363
+ * row — usually means CDK 2.x didn't emit `Metadata['aws:asset:path']`
37364
+ * on the row (older CDK versions, or a hand-edited template). Without
37365
+ * the local template file we can't enumerate the child's resources
37366
+ * for the per-child state write.
37367
+ *
37368
+ * Mirrors the upstream `cdk import` mismatch UX — "import refuses on a
37369
+ * shape mismatch, fix the shape and re-run."
37370
+ */
37371
+ function validateNestedStackShape(template, tree, parentStackName, nestedTemplates) {
37372
+ const templateNestedIds = /* @__PURE__ */ new Set();
37373
+ for (const [logicalId, resource] of Object.entries(template.Resources)) if (resource.Type === "AWS::CloudFormation::Stack") templateNestedIds.add(logicalId);
37374
+ const treeNestedIds = new Set(tree.nested.keys());
37375
+ const inTemplateMissingFromAws = [];
37376
+ for (const id of templateNestedIds) if (!treeNestedIds.has(id)) inTemplateMissingFromAws.push(id);
37377
+ const inAwsMissingFromTemplate = [];
37378
+ for (const id of treeNestedIds) if (!templateNestedIds.has(id)) inAwsMissingFromTemplate.push(id);
37379
+ const inTemplateMissingNestedTemplatePath = [];
37380
+ for (const id of templateNestedIds) if (!nestedTemplates[id]) inTemplateMissingNestedTemplatePath.push(id);
37381
+ const problems = [];
37382
+ 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`);
37383
+ 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`);
37384
+ 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)`);
37385
+ if (problems.length > 0) throw new Error(`cdkd import --migrate-from-cloudformation: parent stack '${parentStackName}' template ↔ CloudFormation shape mismatch:\n - ${problems.join("\n - ")}`);
37386
+ }
37387
+ /**
37388
+ * After the root parent stack's cdkd state is written, walk the
37389
+ * `migrationTree.nested` map and adopt every nested child into its own
37390
+ * v6-keyed state file (`cdkd/<parent>~<childLogicalId>/<region>/state.json`).
37391
+ * Recurses into grandchildren via the same walker.
37392
+ *
37393
+ * Per child:
37394
+ * 1. Acquire the child's lock. Order across the full tree is
37395
+ * **parent-first acquire, leaves-first release** (each level's
37396
+ * `finally` releases its child lock before sibling iteration
37397
+ * continues, and the root lock is the outermost — held by
37398
+ * `runImport`'s `try` / `finally`). This is the conventional
37399
+ * hierarchical lock pattern: parent-first acquire prevents a
37400
+ * second cdkd import from racing past the root lock; leaves-first
37401
+ * release on success/failure means a mid-walk error never strands
37402
+ * a child lock past its scope. Design §3.3 wording ("leaves first,
37403
+ * parent last") is preserved as the RELEASE order; the acquire
37404
+ * order is parent-first to keep the lock graph deadlock-free.
37405
+ * 2. Read the child template body from the synth cloud assembly via
37406
+ * `parentNestedTemplates[<childLogicalId>]` (populated by
37407
+ * AssemblyReader at synth time — see {@link AssemblyReader.parseStack}).
37408
+ * 3. Enumerate the child's importable resources (same filter as the
37409
+ * root's `collectImportableResources` — drop `AWS::CDK::Metadata`,
37410
+ * short-circuit `AWS::CloudFormation::Stack` rows to synth ARNs).
37411
+ * 4. For each child resource: dispatch through `importOne` with the
37412
+ * child's `(logicalId → physicalId)` overrides from `childTree.resources`.
37413
+ * 5. Build the child's `StackState` with `parentStack` / `parentLogicalId`
37414
+ * / `parentRegion` populated per state schema v6.
37415
+ * 6. Save state via `stateBackend.saveState('<parent>~<childLogicalId>', ...)`.
37416
+ * 7. Recurse into grandchildren.
37417
+ *
37418
+ * Lock release happens in REVERSE acquisition order in the outer
37419
+ * `finally` of each child — leaves-first acquire / parent-last release on
37420
+ * success, AND parent-last release on failure. Per memory rule
37421
+ * `feedback_destructive_state_test_coverage.md`, the lock-release order
37422
+ * is verified by unit test.
37423
+ */
37424
+ async function importNestedStackChildrenRecursive(args) {
37425
+ const { parentStackName, parentRegion, parentNestedTemplates, parentTree, stateBackend, lockManager, providerRegistry, templateParser, lockOwner, accountId, logger } = args;
37426
+ for (const [childLogicalId, childTreeNode] of parentTree.nested) {
37427
+ const childStackName = `${parentStackName}~${childLogicalId}`;
37428
+ const childRegion = parentRegion;
37429
+ const childTemplatePath = parentNestedTemplates[childLogicalId];
37430
+ 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.`);
37431
+ logger.info(`Adopting nested stack '${childLogicalId}' as cdkd stack '${childStackName}' (${childRegion})...`);
37432
+ const childTemplate = readNestedChildTemplate(childTemplatePath, childLogicalId);
37433
+ await lockManager.acquireLock(childStackName, childRegion, lockOwner, "import");
37434
+ try {
37435
+ const childResources = collectImportableResources(childTemplate);
37436
+ const childTemplateLogicalIds = new Set(childResources.map((r) => r.logicalId));
37437
+ const childOverrides = /* @__PURE__ */ new Map();
37438
+ for (const [logicalId, physicalId] of childTreeNode.resources) {
37439
+ if (!childTemplateLogicalIds.has(logicalId)) continue;
37440
+ if (childTemplate.Resources[logicalId]?.Type === "AWS::CloudFormation::Stack") continue;
37441
+ childOverrides.set(logicalId, physicalId);
37442
+ }
37443
+ const rows = [];
37444
+ for (const { logicalId, resource } of childResources) {
37445
+ if (resource.Type === "AWS::CloudFormation::Stack" && childTreeNode.nested.has(logicalId)) {
37446
+ rows.push({
37447
+ logicalId,
37448
+ resourceType: resource.Type,
37449
+ outcome: "imported",
37450
+ physicalId: synthesizeNestedStackArn(childRegion, accountId, childStackName, logicalId)
37451
+ });
37452
+ continue;
37453
+ }
37454
+ const outcome = await importOne({
37455
+ logicalId,
37456
+ resource,
37457
+ stackName: childStackName,
37458
+ region: childRegion,
37459
+ providerRegistry,
37460
+ override: childOverrides.get(logicalId),
37461
+ overrides: childOverrides
37462
+ });
37463
+ rows.push(outcome);
37464
+ }
37465
+ const childStackState = buildStackState(childStackName, childRegion, rows, templateParser, childTemplate, null, false);
37466
+ childStackState.parentStack = parentStackName;
37467
+ childStackState.parentLogicalId = childLogicalId;
37468
+ childStackState.parentRegion = parentRegion;
37469
+ await resolveImportedProperties(childStackState, childTemplate, childRegion, stateBackend, logger);
37470
+ await captureObservedForImportedResources(childStackState, providerRegistry, logger);
37471
+ await stateBackend.saveState(childStackName, childRegion, childStackState);
37472
+ logger.info(`✓ Nested stack state written: ${childStackName} (${childRegion}) — ${rows.filter((r) => r.outcome === "imported").length} resource(s) imported.`);
37473
+ if (childTreeNode.nested.size > 0) await importNestedStackChildrenRecursive({
37474
+ parentStackName: childStackName,
37475
+ parentRegion: childRegion,
37476
+ parentNestedTemplates: indexGrandchildTemplatePaths(childTemplate, childTemplatePath),
37477
+ parentTree: childTreeNode,
37478
+ stateBackend,
37479
+ lockManager,
37480
+ providerRegistry,
37481
+ templateParser,
37482
+ lockOwner,
37483
+ accountId,
37484
+ logger
37485
+ });
37486
+ } finally {
37487
+ await lockManager.releaseLock(childStackName, childRegion).catch((err) => {
37488
+ logger.warn(`Failed to release lock for nested stack '${childStackName}' (${childRegion}): ${err instanceof Error ? err.message : String(err)}`);
37489
+ });
37490
+ }
37491
+ }
37492
+ }
37493
+ /**
37494
+ * Read a nested child's template body from the synth cloud assembly's
37495
+ * sibling file (the path AssemblyReader populates from
37496
+ * `Metadata['aws:asset:path']`). Wraps the I/O + JSON parse failures with
37497
+ * an actionable error that names the offending child logical id.
37498
+ */
37499
+ function readNestedChildTemplate(templatePath, childLogicalId) {
37500
+ let raw;
37501
+ try {
37502
+ raw = readFileSync(templatePath, "utf-8");
37503
+ } catch (err) {
37504
+ throw new Error(`Failed to read nested-stack template for '${childLogicalId}' at ${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
37505
+ }
37506
+ try {
37507
+ return JSON.parse(raw);
37508
+ } catch (err) {
37509
+ throw new Error(`Failed to parse nested-stack template for '${childLogicalId}' at ${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
37510
+ }
37511
+ }
37512
+ /**
37513
+ * Index a child template's `AWS::CloudFormation::Stack` rows by their
37514
+ * `aws:asset:path` Metadata, mirroring what {@link AssemblyReader.parseStack}
37515
+ * does for the root parent. Grandchild template files are sibling .nested
37516
+ * files of the child template file in the same cdk.out subdirectory.
37517
+ *
37518
+ * Refuses absolute paths for the same reason `NestedStackProvider.indexGrandchildTemplates`
37519
+ * does: an absolute path indicates the synth output was hand-modified or
37520
+ * generated by a non-CDK toolchain — `path.join(dir, '/abs/foo')` would
37521
+ * silently bypass our `dir` resolution and point outside cdk.out.
37522
+ */
37523
+ function indexGrandchildTemplatePaths(childTemplate, childTemplatePath) {
37524
+ const dir = path.dirname(childTemplatePath);
37525
+ const result = {};
37526
+ for (const [grandLogicalId, resource] of Object.entries(childTemplate.Resources)) {
37527
+ if (resource.Type !== "AWS::CloudFormation::Stack") continue;
37528
+ const assetPath = resource.Metadata?.["aws:asset:path"];
37529
+ if (typeof assetPath !== "string" || assetPath.length === 0) continue;
37530
+ 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.`);
37531
+ result[grandLogicalId] = path.join(dir, assetPath);
37532
+ }
37533
+ return result;
37534
+ }
37050
37535
 
37051
37536
  //#endregion
37052
37537
  //#region src/cli/commands/local-state-loader.ts
@@ -55975,7 +56460,7 @@ function reorderArgs(argv) {
55975
56460
  */
55976
56461
  async function main() {
55977
56462
  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");
56463
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.146.0");
55979
56464
  program.addCommand(createBootstrapCommand());
55980
56465
  program.addCommand(createSynthCommand());
55981
56466
  program.addCommand(createListCommand());