@go-to-k/cdkd 0.85.0 → 0.87.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
@@ -79270,7 +79270,8 @@ import {
79270
79270
  DescribeTypeCommand,
79271
79271
  DeleteChangeSetCommand,
79272
79272
  waitUntilChangeSetCreateComplete,
79273
- waitUntilStackImportComplete
79273
+ waitUntilStackImportComplete,
79274
+ waitUntilStackUpdateComplete as waitUntilStackUpdateComplete2
79274
79275
  } from "@aws-sdk/client-cloudformation";
79275
79276
  init_aws_clients();
79276
79277
  var NEVER_IMPORTABLE_TYPES = /* @__PURE__ */ new Set([
@@ -79317,6 +79318,38 @@ var PRIMARY_IDENTIFIER_FALLBACK = {
79317
79318
  "AWS::Cognito::UserPool": "UserPoolId",
79318
79319
  "AWS::ECR::Repository": "RepositoryName"
79319
79320
  };
79321
+ var COMPOSITE_ID_SPLITTERS = {
79322
+ // cdkd stores `restApiId|resourceId|httpMethod` (apigateway-provider.ts);
79323
+ // CFn primary identifier is [RestApiId, ResourceId, HttpMethod] — same order.
79324
+ "AWS::ApiGateway::Method": (id) => {
79325
+ const parts = id.split("|");
79326
+ if (parts.length !== 3) {
79327
+ throw new Error(
79328
+ `expected 3 parts (restApiId|resourceId|httpMethod), got ${parts.length}: '${id}'`
79329
+ );
79330
+ }
79331
+ return { RestApiId: parts[0], ResourceId: parts[1], HttpMethod: parts[2] };
79332
+ },
79333
+ // cdkd stores `restApiId|resourceId` (apigateway-provider.ts);
79334
+ // CFn primary identifier is [RestApiId, ResourceId].
79335
+ "AWS::ApiGateway::Resource": (id) => {
79336
+ const parts = id.split("|");
79337
+ if (parts.length !== 2) {
79338
+ throw new Error(`expected 2 parts (restApiId|resourceId), got ${parts.length}: '${id}'`);
79339
+ }
79340
+ return { RestApiId: parts[0], ResourceId: parts[1] };
79341
+ },
79342
+ // cdkd stores `IGW|VpcId` (ec2-provider.ts);
79343
+ // CFn primary identifier is [VpcId, InternetGatewayId] — DIFFERENT order
79344
+ // from cdkd. Splitter reorders explicitly.
79345
+ "AWS::EC2::VPCGatewayAttachment": (id) => {
79346
+ const parts = id.split("|");
79347
+ if (parts.length !== 2) {
79348
+ throw new Error(`expected 2 parts (IGW|VpcId), got ${parts.length}: '${id}'`);
79349
+ }
79350
+ return { VpcId: parts[1], InternetGatewayId: parts[0] };
79351
+ }
79352
+ };
79320
79353
  async function exportCommand(stackArg, options) {
79321
79354
  const logger = getLogger();
79322
79355
  if (options.verbose) {
@@ -79425,39 +79458,90 @@ async function exportCommand(stackArg, options) {
79425
79458
  }
79426
79459
  }
79427
79460
  try {
79428
- const { plan, skipped } = await buildImportPlan(state, template, awsClients.cloudFormation);
79429
- if (skipped.length > 0) {
79461
+ const { phase1Imports, phase2Creates, blocked } = await buildImportPlan(
79462
+ state,
79463
+ template,
79464
+ awsClients.cloudFormation
79465
+ );
79466
+ if (blocked.length > 0) {
79467
+ logger.error("The following resources block migration:");
79468
+ for (const b of blocked) {
79469
+ logger.error(` - ${b.logicalId} (${b.resourceType}): ${b.reason}`);
79470
+ }
79471
+ throw new Error(
79472
+ `${blocked.length} resource(s) block migration. Either destroy them first (cdkd destroy / cdkd state destroy cherry-picked), or remove them from the CDK app and re-synthesize.`
79473
+ );
79474
+ }
79475
+ if (phase2Creates.length > 0 && !options.includeNonImportable) {
79430
79476
  logger.error("The following resources cannot be imported into CloudFormation:");
79431
- for (const s of skipped) {
79432
- logger.error(` - ${s.logicalId} (${s.resourceType}): ${s.reason}`);
79477
+ for (const p of phase2Creates) {
79478
+ logger.error(` - ${p.logicalId} (${p.resourceType}): CFn cannot import this type`);
79433
79479
  }
79434
79480
  throw new Error(
79435
- `${skipped.length} resource(s) cannot be imported. CloudFormation IMPORT requires every template resource to map to an importable AWS resource. Either destroy these resources first (cdkd destroy / cdkd state destroy cherry-picked), or accept abandoning them by removing them from the CDK app and re-synthesizing.`
79481
+ `${phase2Creates.length} non-importable resource(s) detected (Custom::*). Pass --include-non-importable to run a 2-phase migration: phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones (re-invoking each Custom Resource's backing Lambda onCreate handler \u2014 make sure those are idempotent). Or destroy these resources first.`
79436
79482
  );
79437
79483
  }
79438
- if (plan.length === 0) {
79439
- logger.warn("No resources to import \u2014 cdkd state is empty.");
79484
+ if (phase1Imports.length === 0 && phase2Creates.length === 0) {
79485
+ logger.warn("No resources to migrate \u2014 cdkd state is empty.");
79440
79486
  return;
79441
79487
  }
79442
- printPlan(plan, cfnStackName);
79488
+ if (phase1Imports.length === 0) {
79489
+ throw new Error(
79490
+ "No importable resources in the template. CloudFormation IMPORT changeset requires at least one importable resource for phase 1."
79491
+ );
79492
+ }
79493
+ printPlan(phase1Imports, cfnStackName);
79494
+ if (phase2Creates.length > 0) {
79495
+ logger.info(`Phase 2 will CREATE ${phase2Creates.length} non-importable resource(s):`);
79496
+ for (const p of phase2Creates) {
79497
+ logger.info(` ${p.logicalId} (${p.resourceType})`);
79498
+ }
79499
+ logger.info("");
79500
+ }
79443
79501
  if (options.dryRun) {
79444
79502
  logger.info("--dry-run: no CloudFormation changeset will be created.");
79445
79503
  return;
79446
79504
  }
79447
79505
  if (!options.yes) {
79506
+ const phase2Note = phase2Creates.length > 0 ? ` Phase 2 will then CREATE ${phase2Creates.length} non-importable resource(s) (invoking each Custom Resource's onCreate handler).` : "";
79448
79507
  const ok = await confirmPrompt6(
79449
- `Create CloudFormation stack '${cfnStackName}' by importing ${plan.length} resource(s) from cdkd state '${resolvedStackName}' (${targetRegion})? AWS resources are unchanged. cdkd state for '${resolvedStackName}' will be deleted on success.`
79508
+ `Create CloudFormation stack '${cfnStackName}' by importing ${phase1Imports.length} resource(s) from cdkd state '${resolvedStackName}' (${targetRegion})?` + phase2Note + ` AWS resources are unchanged on import. cdkd state for '${resolvedStackName}' will be deleted on success.`
79450
79509
  );
79451
79510
  if (!ok) {
79452
79511
  logger.info("Migration cancelled. cdkd state and CloudFormation are unchanged.");
79453
79512
  return;
79454
79513
  }
79455
79514
  }
79456
- const filteredTemplate = filterTemplateForImport(template, plan);
79457
- await executeImportChangeSet(awsClients.cloudFormation, cfnStackName, filteredTemplate, plan);
79515
+ const phase1Template = filterTemplateForImport(template, phase1Imports);
79516
+ await executeImportChangeSet(
79517
+ awsClients.cloudFormation,
79518
+ cfnStackName,
79519
+ phase1Template,
79520
+ phase1Imports
79521
+ );
79458
79522
  logger.info(
79459
- `\u2713 CloudFormation stack '${cfnStackName}' created via IMPORT. ${plan.length} resource(s) are now managed by CloudFormation.`
79523
+ `\u2713 Phase 1: CloudFormation stack '${cfnStackName}' created via IMPORT. ${phase1Imports.length} resource(s) imported.`
79460
79524
  );
79525
+ if (phase2Creates.length > 0) {
79526
+ try {
79527
+ await executeUpdateChangeSet(awsClients.cloudFormation, cfnStackName, template);
79528
+ logger.info(`\u2713 Phase 2: ${phase2Creates.length} non-importable resource(s) CREATEd.`);
79529
+ } catch (err) {
79530
+ const msg = err instanceof Error ? err.message : String(err);
79531
+ throw new Error(
79532
+ `Phase 1 (IMPORT) succeeded; phase 2 (UPDATE) failed: ${msg}
79533
+
79534
+ The CloudFormation stack '${cfnStackName}' now contains the imported resources but is missing the ${phase2Creates.length} non-importable resource(s). cdkd state is UNCHANGED so you can inspect what's in it, but DO NOT run \`cdkd deploy\` against this stack (the imported resources are now CFn-managed). To recover:
79535
+ 1. Fix the failure cause (typically an onCreate Lambda error).
79536
+ 2. Re-run the phase 2 UPDATE manually with the full synth template:
79537
+ aws cloudformation create-change-set --stack-name ${cfnStackName} \\
79538
+ --change-set-name cdkd-phase2-retry --change-set-type UPDATE \\
79539
+ --template-body file://<full-template.json>
79540
+ 3. Once phase 2 succeeds, run: cdkd state orphan ${resolvedStackName}
79541
+ to clean up cdkd's stale state record.`
79542
+ );
79543
+ }
79544
+ }
79461
79545
  await stateBackend.deleteState(resolvedStackName, targetRegion);
79462
79546
  logger.info(
79463
79547
  `cdkd state for '${resolvedStackName}' (${targetRegion}) removed. Manage the stack with 'cdk deploy' or 'aws cloudformation' from here on.`
@@ -79553,13 +79637,17 @@ async function assertCfnStackAbsent(cfnClient, stackName) {
79553
79637
  throw err;
79554
79638
  }
79555
79639
  }
79640
+ function isPhase2CreatableType(resourceType) {
79641
+ return resourceType.startsWith("Custom::");
79642
+ }
79556
79643
  async function buildImportPlan(state, template, cfnClient) {
79557
79644
  const templateResources = template["Resources"];
79558
79645
  if (!templateResources || typeof templateResources !== "object" || Array.isArray(templateResources)) {
79559
79646
  throw new Error("Template has no Resources section.");
79560
79647
  }
79561
- const plan = [];
79562
- const skipped = [];
79648
+ const phase1Imports = [];
79649
+ const phase2Creates = [];
79650
+ const blocked = [];
79563
79651
  const identifierCache = /* @__PURE__ */ new Map();
79564
79652
  for (const [logicalId, raw] of Object.entries(templateResources)) {
79565
79653
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -79568,49 +79656,88 @@ async function buildImportPlan(state, template, cfnClient) {
79568
79656
  const resourceType = resource.Type ?? "";
79569
79657
  if (!resourceType)
79570
79658
  continue;
79659
+ if (resourceType === "AWS::CDK::Metadata") {
79660
+ continue;
79661
+ }
79571
79662
  if (isNeverImportableType(resourceType)) {
79572
- if (resourceType === "AWS::CDK::Metadata")
79573
- continue;
79574
- skipped.push({
79575
- logicalId,
79576
- resourceType,
79577
- reason: "CloudFormation IMPORT does not support this resource type"
79578
- });
79663
+ if (isPhase2CreatableType(resourceType)) {
79664
+ phase2Creates.push({ logicalId, resourceType });
79665
+ } else {
79666
+ blocked.push({
79667
+ logicalId,
79668
+ resourceType,
79669
+ reason: "CloudFormation cannot import or recreate this resource type"
79670
+ });
79671
+ }
79579
79672
  continue;
79580
79673
  }
79581
79674
  const stateEntry = state.resources[logicalId];
79582
79675
  if (!stateEntry || !stateEntry.physicalId) {
79583
- skipped.push({
79676
+ blocked.push({
79584
79677
  logicalId,
79585
79678
  resourceType,
79586
79679
  reason: "no entry in cdkd state (resource is in template but was not deployed by cdkd)"
79587
79680
  });
79588
79681
  continue;
79589
79682
  }
79590
- let identifierKey;
79683
+ let resourceIdentifier;
79591
79684
  try {
79592
- identifierKey = await resolvePrimaryIdentifier(resourceType, cfnClient, identifierCache);
79685
+ resourceIdentifier = await resolveResourceIdentifier(
79686
+ resourceType,
79687
+ stateEntry.physicalId,
79688
+ cfnClient,
79689
+ identifierCache
79690
+ );
79593
79691
  } catch (err) {
79594
- skipped.push({
79692
+ blocked.push({
79595
79693
  logicalId,
79596
79694
  resourceType,
79597
- reason: "could not resolve primary identifier: " + (err instanceof Error ? err.message : String(err))
79695
+ reason: "could not resolve resource identifier: " + (err instanceof Error ? err.message : String(err))
79598
79696
  });
79599
79697
  continue;
79600
79698
  }
79601
- plan.push({
79699
+ phase1Imports.push({
79602
79700
  logicalId,
79603
79701
  resourceType,
79604
79702
  physicalId: stateEntry.physicalId,
79605
- identifierKey
79703
+ resourceIdentifier
79606
79704
  });
79607
79705
  }
79608
- return { plan, skipped };
79706
+ return { phase1Imports, phase2Creates, blocked };
79609
79707
  }
79610
- async function resolvePrimaryIdentifier(resourceType, cfnClient, cache2) {
79611
- const cached = cache2.get(resourceType);
79612
- if (cached !== void 0)
79613
- return cached;
79708
+ async function resolveResourceIdentifier(resourceType, physicalId, cfnClient, cache2) {
79709
+ let entry = cache2.get(resourceType);
79710
+ if (entry === void 0) {
79711
+ entry = await fetchPrimaryIdentifier(resourceType, cfnClient);
79712
+ cache2.set(resourceType, entry);
79713
+ }
79714
+ if (entry.fields.length === 1) {
79715
+ return { [entry.fields[0]]: physicalId };
79716
+ }
79717
+ const splitter = COMPOSITE_ID_SPLITTERS[resourceType];
79718
+ if (!splitter) {
79719
+ throw new Error(
79720
+ `resource type uses a composite primary identifier (${entry.fields.length} fields: ${entry.fields.join(", ")}); add an entry to COMPOSITE_ID_SPLITTERS in src/cli/commands/export.ts that parses cdkd's physicalId for this type, or destroy the resource first and let CFn create it fresh`
79721
+ );
79722
+ }
79723
+ let split;
79724
+ try {
79725
+ split = splitter(physicalId);
79726
+ } catch (err) {
79727
+ throw new Error(
79728
+ `composite-id splitter for ${resourceType} failed: ` + (err instanceof Error ? err.message : String(err))
79729
+ );
79730
+ }
79731
+ for (const f of entry.fields) {
79732
+ if (!(f in split)) {
79733
+ throw new Error(
79734
+ `composite-id splitter for ${resourceType} did not produce field '${f}' (produced: ${Object.keys(split).join(", ")})`
79735
+ );
79736
+ }
79737
+ }
79738
+ return split;
79739
+ }
79740
+ async function fetchPrimaryIdentifier(resourceType, cfnClient) {
79614
79741
  try {
79615
79742
  const resp = await cfnClient.send(
79616
79743
  new DescribeTypeCommand({ Type: "RESOURCE", TypeName: resourceType })
@@ -79618,15 +79745,9 @@ async function resolvePrimaryIdentifier(resourceType, cfnClient, cache2) {
79618
79745
  if (resp.Schema) {
79619
79746
  const parsed = JSON.parse(resp.Schema);
79620
79747
  const primary = parsed.primaryIdentifier;
79621
- if (Array.isArray(primary) && primary.length === 1 && typeof primary[0] === "string") {
79622
- const propName = primary[0].replace(/^\/properties\//, "");
79623
- cache2.set(resourceType, propName);
79624
- return propName;
79625
- }
79626
- if (Array.isArray(primary) && primary.length > 1) {
79627
- throw new Error(
79628
- `resource type uses a composite primary identifier (${primary.length} fields); cdkd does not yet support composite identifiers for cdkd export`
79629
- );
79748
+ if (Array.isArray(primary) && primary.length > 0 && primary.every((p) => typeof p === "string")) {
79749
+ const fields = primary.map((p) => p.replace(/^\/properties\//, ""));
79750
+ return { fields };
79630
79751
  }
79631
79752
  }
79632
79753
  } catch (err) {
@@ -79635,8 +79756,7 @@ async function resolvePrimaryIdentifier(resourceType, cfnClient, cache2) {
79635
79756
  }
79636
79757
  const fallback = PRIMARY_IDENTIFIER_FALLBACK[resourceType];
79637
79758
  if (fallback) {
79638
- cache2.set(resourceType, fallback);
79639
- return fallback;
79759
+ return { fields: [fallback] };
79640
79760
  }
79641
79761
  throw new Error(
79642
79762
  `primary identifier unknown (DescribeType returned no usable schema and no fallback is registered). Add ${resourceType} to PRIMARY_IDENTIFIER_FALLBACK in export.ts, or open an issue.`
@@ -79696,9 +79816,8 @@ function printPlan(plan, cfnStackName) {
79696
79816
  logger.info("");
79697
79817
  logger.info(`Import plan for CloudFormation stack '${cfnStackName}':`);
79698
79818
  for (const entry of plan) {
79699
- logger.info(
79700
- ` ${entry.logicalId} (${entry.resourceType}) \u2190 ${entry.identifierKey}=${entry.physicalId}`
79701
- );
79819
+ const idStr = Object.entries(entry.resourceIdentifier).map(([k, v]) => `${k}=${v}`).join(", ");
79820
+ logger.info(` ${entry.logicalId} (${entry.resourceType}) \u2190 ${idStr}`);
79702
79821
  }
79703
79822
  logger.info("");
79704
79823
  }
@@ -79709,7 +79828,7 @@ async function executeImportChangeSet(cfnClient, stackName, template, plan) {
79709
79828
  const resourcesToImport = plan.map((entry) => ({
79710
79829
  ResourceType: entry.resourceType,
79711
79830
  LogicalResourceId: entry.logicalId,
79712
- ResourceIdentifier: { [entry.identifierKey]: entry.physicalId }
79831
+ ResourceIdentifier: entry.resourceIdentifier
79713
79832
  }));
79714
79833
  logger.info(
79715
79834
  `Creating IMPORT changeset '${changeSetName}' for stack '${stackName}' (${plan.length} resource(s), ${templateBody.length} bytes)...`
@@ -79773,6 +79892,68 @@ async function executeImportChangeSet(cfnClient, stackName, template, plan) {
79773
79892
  throw err;
79774
79893
  }
79775
79894
  }
79895
+ async function executeUpdateChangeSet(cfnClient, stackName, template) {
79896
+ const logger = getLogger();
79897
+ const changeSetName = `cdkd-phase2-${Date.now()}`;
79898
+ const templateBody = JSON.stringify(template, null, 2);
79899
+ if (templateBody.length > 51200) {
79900
+ throw new Error(
79901
+ `Full template is ${templateBody.length} bytes, over the 51,200-byte inline TemplateBody limit for phase-2 UPDATE. TemplateURL upload is not yet implemented.`
79902
+ );
79903
+ }
79904
+ logger.info(
79905
+ `Creating UPDATE changeset '${changeSetName}' for phase 2 (${templateBody.length} bytes)...`
79906
+ );
79907
+ try {
79908
+ await cfnClient.send(
79909
+ new CreateChangeSetCommand({
79910
+ StackName: stackName,
79911
+ ChangeSetName: changeSetName,
79912
+ ChangeSetType: "UPDATE",
79913
+ TemplateBody: templateBody,
79914
+ Capabilities: ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]
79915
+ })
79916
+ );
79917
+ } catch (err) {
79918
+ const msg = err instanceof Error ? err.message : String(err);
79919
+ throw new Error(`Failed to create UPDATE changeset: ${msg}`);
79920
+ }
79921
+ try {
79922
+ await waitUntilChangeSetCreateComplete(
79923
+ { client: cfnClient, maxWaitTime: 600 },
79924
+ { StackName: stackName, ChangeSetName: changeSetName }
79925
+ );
79926
+ } catch (err) {
79927
+ try {
79928
+ const desc = await cfnClient.send(
79929
+ new DescribeChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName })
79930
+ );
79931
+ const reason = desc.StatusReason ?? "unknown";
79932
+ await cfnClient.send(new DeleteChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName })).catch(() => {
79933
+ });
79934
+ throw new Error(`UPDATE changeset FAILED: ${reason}`);
79935
+ } catch (innerErr) {
79936
+ if (innerErr instanceof Error && innerErr.message.startsWith("UPDATE changeset FAILED")) {
79937
+ throw innerErr;
79938
+ }
79939
+ throw err;
79940
+ }
79941
+ }
79942
+ logger.info(`Executing UPDATE changeset...`);
79943
+ try {
79944
+ await cfnClient.send(
79945
+ new ExecuteChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName })
79946
+ );
79947
+ await waitUntilStackUpdateComplete2(
79948
+ { client: cfnClient, maxWaitTime: 3600 },
79949
+ { StackName: stackName }
79950
+ );
79951
+ } catch (err) {
79952
+ await cfnClient.send(new DeleteChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName })).catch(() => {
79953
+ });
79954
+ throw err;
79955
+ }
79956
+ }
79776
79957
  function refuseTransientContextIfUnsafe(options) {
79777
79958
  const overrides = options.context ?? [];
79778
79959
  if (overrides.length === 0)
@@ -79842,6 +80023,10 @@ function createExportCommand() {
79842
80023
  "--accept-transient-context",
79843
80024
  "Allow CLI -c key=value overrides at export time even though they are not persisted to cdk.json / cdk.context.json (default: refuse). When set, the user is responsible for passing the same -c flags to every future cdk deploy.",
79844
80025
  false
80026
+ ).option(
80027
+ "--include-non-importable",
80028
+ "Run a 2-phase migration when the stack contains non-importable resources (Custom::*). Phase 1 imports the importable resources; phase 2 CFn-CREATEs the non-importable ones, which re-invokes each Custom Resource's onCreate handler. Make sure onCreate is idempotent before enabling.",
80029
+ false
79845
80030
  ).action(withErrorHandling(exportCommand));
79846
80031
  [...commonOptions, ...appOptions, ...stateOptions, ...contextOptions].forEach(
79847
80032
  (opt) => cmd.addOption(opt)
@@ -79880,7 +80065,7 @@ function reorderArgs(argv) {
79880
80065
  }
79881
80066
  async function main() {
79882
80067
  const program = new Command18();
79883
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.85.0");
80068
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.87.0");
79884
80069
  program.addCommand(createBootstrapCommand());
79885
80070
  program.addCommand(createSynthCommand());
79886
80071
  program.addCommand(createListCommand());