@go-to-k/cdkd 0.24.0 → 0.25.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
@@ -508,6 +508,51 @@ var stateOptions = [
508
508
  new Option("--state-prefix <prefix>", "S3 key prefix for state files").default("cdkd")
509
509
  ];
510
510
  var stackOptions = [new Option("--stack <name>", "Stack name to operate on")];
511
+ function parseDuration(value) {
512
+ if (typeof value !== "string" || value.length === 0) {
513
+ throw new Error(
514
+ `Invalid duration "${value}": expected <number>s, <number>m, or <number>h (e.g. 30s, 5m, 1h)`
515
+ );
516
+ }
517
+ const match = /^(\d+(?:\.\d+)?)([smh])$/.exec(value.trim());
518
+ if (!match) {
519
+ throw new Error(
520
+ `Invalid duration "${value}": expected <number>s, <number>m, or <number>h (e.g. 30s, 5m, 1h)`
521
+ );
522
+ }
523
+ const num = Number(match[1]);
524
+ if (!Number.isFinite(num) || num <= 0) {
525
+ throw new Error(`Invalid duration "${value}": must be greater than zero`);
526
+ }
527
+ const unit = match[2];
528
+ const multiplier = unit === "s" ? 1e3 : unit === "m" ? 6e4 : 36e5;
529
+ return Math.round(num * multiplier);
530
+ }
531
+ var resourceTimeoutOptions = [
532
+ // Default values are stored as parsed milliseconds (NOT the source
533
+ // string) because commander's `argParser` only runs on user-supplied
534
+ // values, never on defaults — without this every command handler
535
+ // would see `'5m'` (string) when the user did not pass the flag and
536
+ // `300_000` (number) when they did. The second `defaultValueDescription`
537
+ // argument keeps `--help` showing the human-readable form.
538
+ new Option(
539
+ "--resource-warn-after <duration>",
540
+ "Warn when a single resource operation exceeds this wall-clock duration (e.g. 5m, 90s, 1h)"
541
+ ).default(parseDuration("5m"), "5m").argParser(parseDuration),
542
+ new Option(
543
+ "--resource-timeout <duration>",
544
+ "Abort a single resource operation that exceeds this wall-clock duration. Custom-Resource-heavy stacks may need to raise this above the default 30m (the Custom Resource provider's polling cap is 1h)."
545
+ ).default(parseDuration("30m"), "30m").argParser(parseDuration)
546
+ ];
547
+ function validateResourceTimeouts(opts) {
548
+ const warn = opts.resourceWarnAfter;
549
+ const timeout = opts.resourceTimeout;
550
+ if (typeof warn === "number" && typeof timeout === "number" && warn >= timeout) {
551
+ throw new Error(
552
+ `--resource-warn-after (${warn}ms) must be less than --resource-timeout (${timeout}ms)`
553
+ );
554
+ }
555
+ }
511
556
  var deployOptions = [
512
557
  new Option("--concurrency <number>", "Maximum concurrent resource operations").default(10).argParser((value) => parseInt(value, 10)),
513
558
  new Option("--stack-concurrency <number>", "Maximum concurrent stack deployments").default(4).argParser((value) => parseInt(value, 10)),
@@ -523,7 +568,8 @@ var deployOptions = [
523
568
  new Option(
524
569
  "-e, --exclusively",
525
570
  "Only deploy requested stacks, do not include dependencies"
526
- ).default(false)
571
+ ).default(false),
572
+ ...resourceTimeoutOptions
527
573
  ];
528
574
  var contextOptions = [
529
575
  new Option(
@@ -718,6 +764,22 @@ var LiveRenderer = class {
718
764
  if (this.active)
719
765
  this.draw();
720
766
  }
767
+ /**
768
+ * Replace the label of a previously-added task in place. No-op if the
769
+ * task is not currently tracked (e.g. it already finished). Used by the
770
+ * per-resource deadline wrapper to surface a "[taking longer than
771
+ * expected, Nm+]" suffix without disturbing the elapsed-time counter
772
+ * the renderer tracks via `startedAt`.
773
+ */
774
+ updateTaskLabel(id, label) {
775
+ const stackName = getCurrentStackName();
776
+ const task = this.tasks.get(scopedKey(id, stackName));
777
+ if (!task)
778
+ return;
779
+ task.label = label;
780
+ if (this.active)
781
+ this.draw();
782
+ }
721
783
  /**
722
784
  * Print content above the live area. Clears the live area, runs the writer,
723
785
  * then redraws the live area. When the renderer is inactive, the writer
@@ -1011,6 +1073,38 @@ var ProvisioningError = class _ProvisioningError extends CdkdError {
1011
1073
  Object.setPrototypeOf(this, _ProvisioningError.prototype);
1012
1074
  }
1013
1075
  };
1076
+ var ResourceTimeoutError = class _ResourceTimeoutError extends CdkdError {
1077
+ constructor(logicalId, resourceType, region, elapsedMs, operation, timeoutMs) {
1078
+ const elapsedLabel = formatDuration(elapsedMs);
1079
+ const timeoutLabel = formatDuration(timeoutMs);
1080
+ super(
1081
+ `Resource ${logicalId} (${resourceType}) in ${region} timed out after ${timeoutLabel} during ${operation} (elapsed ${elapsedLabel}).
1082
+ This may indicate a stuck Cloud Control polling loop, hung Custom Resource, or
1083
+ slow ENI provisioning. Re-run with --resource-timeout 1h if the resource genuinely
1084
+ needs more time, or --verbose to see the underlying provider activity.`,
1085
+ "RESOURCE_TIMEOUT"
1086
+ );
1087
+ this.logicalId = logicalId;
1088
+ this.resourceType = resourceType;
1089
+ this.region = region;
1090
+ this.elapsedMs = elapsedMs;
1091
+ this.operation = operation;
1092
+ this.timeoutMs = timeoutMs;
1093
+ this.name = "ResourceTimeoutError";
1094
+ Object.setPrototypeOf(this, _ResourceTimeoutError.prototype);
1095
+ }
1096
+ };
1097
+ function formatDuration(ms) {
1098
+ if (ms < 6e4) {
1099
+ return `${Math.round(ms / 1e3)}s`;
1100
+ }
1101
+ const totalMinutes = Math.round(ms / 6e4);
1102
+ if (totalMinutes < 60)
1103
+ return `${totalMinutes}m`;
1104
+ const hours = Math.floor(totalMinutes / 60);
1105
+ const minutes = totalMinutes % 60;
1106
+ return minutes === 0 ? `${hours}h` : `${hours}h${minutes}m`;
1107
+ }
1014
1108
  var DependencyError = class _DependencyError extends CdkdError {
1015
1109
  constructor(message, cause) {
1016
1110
  super(message, "DEPENDENCY_ERROR", cause);
@@ -30781,7 +30875,85 @@ async function withRetry(operation, logicalId, opts = {}) {
30781
30875
  throw lastError;
30782
30876
  }
30783
30877
 
30878
+ // src/deployment/resource-deadline.ts
30879
+ var InvalidResourceDeadlineError = class extends Error {
30880
+ constructor(message) {
30881
+ super(message);
30882
+ this.name = "InvalidResourceDeadlineError";
30883
+ }
30884
+ };
30885
+ function validateOptions(opts) {
30886
+ const { warnAfterMs, timeoutMs } = opts;
30887
+ if (!Number.isFinite(warnAfterMs) || !Number.isFinite(timeoutMs) || warnAfterMs <= 0 || timeoutMs <= 0) {
30888
+ throw new InvalidResourceDeadlineError(
30889
+ `withResourceDeadline: warnAfterMs and timeoutMs must be positive finite numbers (got warnAfterMs=${warnAfterMs}, timeoutMs=${timeoutMs})`
30890
+ );
30891
+ }
30892
+ if (warnAfterMs >= timeoutMs) {
30893
+ throw new InvalidResourceDeadlineError(
30894
+ `withResourceDeadline: warnAfterMs (${warnAfterMs}ms) must be less than timeoutMs (${timeoutMs}ms)`
30895
+ );
30896
+ }
30897
+ }
30898
+ async function withResourceDeadline(operation, opts) {
30899
+ validateOptions(opts);
30900
+ const startedAt = Date.now();
30901
+ return new Promise((resolve4, reject) => {
30902
+ let settled = false;
30903
+ let warnTimer;
30904
+ let timeoutTimer;
30905
+ const cleanup = () => {
30906
+ if (warnTimer !== void 0)
30907
+ clearTimeout(warnTimer);
30908
+ if (timeoutTimer !== void 0)
30909
+ clearTimeout(timeoutTimer);
30910
+ warnTimer = void 0;
30911
+ timeoutTimer = void 0;
30912
+ };
30913
+ if (opts.onWarn) {
30914
+ warnTimer = setTimeout(() => {
30915
+ if (settled)
30916
+ return;
30917
+ try {
30918
+ opts.onWarn(Date.now() - startedAt);
30919
+ } catch {
30920
+ }
30921
+ }, opts.warnAfterMs);
30922
+ if (typeof warnTimer.unref === "function")
30923
+ warnTimer.unref();
30924
+ }
30925
+ timeoutTimer = setTimeout(() => {
30926
+ if (settled)
30927
+ return;
30928
+ settled = true;
30929
+ cleanup();
30930
+ const elapsed = Date.now() - startedAt;
30931
+ reject(opts.onTimeout(elapsed));
30932
+ }, opts.timeoutMs);
30933
+ if (typeof timeoutTimer.unref === "function")
30934
+ timeoutTimer.unref();
30935
+ Promise.resolve().then(() => operation()).then(
30936
+ (value) => {
30937
+ if (settled)
30938
+ return;
30939
+ settled = true;
30940
+ cleanup();
30941
+ resolve4(value);
30942
+ },
30943
+ (err) => {
30944
+ if (settled)
30945
+ return;
30946
+ settled = true;
30947
+ cleanup();
30948
+ reject(err);
30949
+ }
30950
+ );
30951
+ });
30952
+ }
30953
+
30784
30954
  // src/deployment/deploy-engine.ts
30955
+ var DEFAULT_RESOURCE_WARN_AFTER_MS = 5 * 60 * 1e3;
30956
+ var DEFAULT_RESOURCE_TIMEOUT_MS = 30 * 60 * 1e3;
30785
30957
  var InterruptedError = class extends Error {
30786
30958
  constructor() {
30787
30959
  super("Deployment interrupted by user (Ctrl+C)");
@@ -30802,6 +30974,8 @@ var DeployEngine = class {
30802
30974
  this.options.dryRun = options.dryRun ?? false;
30803
30975
  this.options.lockTimeout = options.lockTimeout ?? 5 * 60 * 1e3;
30804
30976
  this.options.noRollback = options.noRollback ?? false;
30977
+ this.options.resourceWarnAfterMs = options.resourceWarnAfterMs ?? DEFAULT_RESOURCE_WARN_AFTER_MS;
30978
+ this.options.resourceTimeoutMs = options.resourceTimeoutMs ?? DEFAULT_RESOURCE_TIMEOUT_MS;
30805
30979
  }
30806
30980
  logger = getLogger().child("DeployEngine");
30807
30981
  resolver;
@@ -31400,259 +31574,305 @@ var DeployEngine = class {
31400
31574
  */
31401
31575
  async provisionResource(logicalId, change, stateResources, stackName, template, parameterValues, conditions, counts, progress) {
31402
31576
  const resourceType = change.resourceType;
31403
- const provider = this.providerRegistry.getProvider(resourceType);
31404
31577
  const renderer = getLiveRenderer();
31405
31578
  const needsReplacement = change.changeType === "UPDATE" && (change.propertyChanges?.some((pc) => pc.requiresReplacement) ?? false);
31406
31579
  const verb = change.changeType === "CREATE" ? "Creating" : change.changeType === "DELETE" ? "Deleting" : needsReplacement ? "Replacing" : "Updating";
31407
- renderer.addTask(logicalId, `${verb} ${logicalId} (${resourceType})`);
31408
- try {
31409
- switch (change.changeType) {
31410
- case "CREATE": {
31411
- const desiredProps = change.desiredProperties || {};
31412
- const context = {
31580
+ const baseLabel = `${verb} ${logicalId} (${resourceType})`;
31581
+ renderer.addTask(logicalId, baseLabel);
31582
+ const operationKind = change.changeType === "CREATE" ? "CREATE" : change.changeType === "DELETE" ? "DELETE" : "UPDATE";
31583
+ const warnAfterMs = this.options.resourceWarnAfterMs ?? DEFAULT_RESOURCE_WARN_AFTER_MS;
31584
+ const timeoutMs = this.options.resourceTimeoutMs ?? DEFAULT_RESOURCE_TIMEOUT_MS;
31585
+ try {
31586
+ await withResourceDeadline(
31587
+ async () => {
31588
+ await this.provisionResourceBody(
31589
+ logicalId,
31590
+ change,
31591
+ stateResources,
31592
+ stackName,
31413
31593
  template,
31414
- resources: stateResources,
31415
- ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31416
- ...conditions && Object.keys(conditions).length > 0 && { conditions },
31417
- stateBackend: this.stateBackend,
31418
- stackName
31419
- };
31420
- const resolvedProps = await this.resolver.resolve(desiredProps, context);
31421
- const { provider: createProvider, properties: createProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31422
- const result = await this.withRetry(
31423
- () => createProvider.create(logicalId, resourceType, createProps),
31594
+ parameterValues,
31595
+ conditions,
31596
+ counts,
31597
+ progress
31598
+ );
31599
+ },
31600
+ {
31601
+ warnAfterMs,
31602
+ timeoutMs,
31603
+ onWarn: (elapsedMs) => {
31604
+ const minutes = Math.max(1, Math.round(elapsedMs / 6e4));
31605
+ const warnSuffix = ` [taking longer than expected, ${minutes}m+]`;
31606
+ renderer.updateTaskLabel(logicalId, `${baseLabel}${warnSuffix}`);
31607
+ renderer.printAbove(() => {
31608
+ this.logger.warn(
31609
+ `${logicalId} (${resourceType}) has been ${operationKind === "CREATE" ? "creating" : operationKind === "DELETE" ? "deleting" : "updating"} for ${minutes}m \u2014 still waiting`
31610
+ );
31611
+ });
31612
+ },
31613
+ onTimeout: (elapsedMs) => new ResourceTimeoutError(
31614
+ logicalId,
31615
+ resourceType,
31616
+ this.stackRegion,
31617
+ elapsedMs,
31618
+ operationKind,
31619
+ timeoutMs
31620
+ )
31621
+ }
31622
+ );
31623
+ } catch (error) {
31624
+ renderer.removeTask(logicalId);
31625
+ const message = error instanceof Error ? error.message : String(error);
31626
+ this.logger.error(`Failed to ${change.changeType.toLowerCase()} ${logicalId}: ${message}`);
31627
+ throw new ProvisioningError(
31628
+ `Failed to ${change.changeType.toLowerCase()} resource ${logicalId}`,
31629
+ resourceType,
31630
+ logicalId,
31631
+ stateResources[logicalId]?.physicalId,
31632
+ error instanceof Error ? error : void 0
31633
+ );
31634
+ } finally {
31635
+ renderer.removeTask(logicalId);
31636
+ }
31637
+ }
31638
+ /**
31639
+ * Inner body of provisionResource, extracted so the outer wrapper can
31640
+ * apply the per-resource deadline (`withResourceDeadline`) without
31641
+ * having the timeout / warn timer code dwarf the real provisioning
31642
+ * logic. Behaviour is unchanged from the pre-deadline implementation.
31643
+ */
31644
+ async provisionResourceBody(logicalId, change, stateResources, stackName, template, parameterValues, conditions, counts, progress) {
31645
+ const resourceType = change.resourceType;
31646
+ const provider = this.providerRegistry.getProvider(resourceType);
31647
+ const renderer = getLiveRenderer();
31648
+ switch (change.changeType) {
31649
+ case "CREATE": {
31650
+ const desiredProps = change.desiredProperties || {};
31651
+ const context = {
31652
+ template,
31653
+ resources: stateResources,
31654
+ ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31655
+ ...conditions && Object.keys(conditions).length > 0 && { conditions },
31656
+ stateBackend: this.stateBackend,
31657
+ stackName
31658
+ };
31659
+ const resolvedProps = await this.resolver.resolve(desiredProps, context);
31660
+ const { provider: createProvider, properties: createProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31661
+ const result = await this.withRetry(
31662
+ () => createProvider.create(logicalId, resourceType, createProps),
31663
+ logicalId
31664
+ );
31665
+ const dependencies = this.extractAllDependencies(template, logicalId);
31666
+ stateResources[logicalId] = {
31667
+ physicalId: result.physicalId,
31668
+ resourceType,
31669
+ properties: resolvedProps,
31670
+ ...result.attributes && { attributes: result.attributes },
31671
+ ...dependencies && dependencies.length > 0 && { dependencies }
31672
+ };
31673
+ if (counts)
31674
+ counts.created++;
31675
+ if (progress)
31676
+ progress.current++;
31677
+ const createPrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31678
+ renderer.removeTask(logicalId);
31679
+ this.logger.info(`${createPrefix}\u2705 ${logicalId} (${resourceType}) created`);
31680
+ break;
31681
+ }
31682
+ case "UPDATE": {
31683
+ const currentResource = stateResources[logicalId];
31684
+ if (!currentResource) {
31685
+ throw new Error(`Cannot update ${logicalId}: resource not found in state`);
31686
+ }
31687
+ const desiredProps = change.desiredProperties || {};
31688
+ const currentProps = change.currentProperties || {};
31689
+ const context = {
31690
+ template,
31691
+ resources: stateResources,
31692
+ ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31693
+ ...conditions && Object.keys(conditions).length > 0 && { conditions },
31694
+ stateBackend: this.stateBackend,
31695
+ stackName
31696
+ };
31697
+ const resolvedProps = await this.resolver.resolve(desiredProps, context);
31698
+ if (JSON.stringify(resolvedProps) === JSON.stringify(currentProps)) {
31699
+ this.logger.debug(
31700
+ `Skipping ${logicalId}: no actual changes after intrinsic function resolution`
31701
+ );
31702
+ if (counts)
31703
+ counts.skipped++;
31704
+ break;
31705
+ }
31706
+ const needsReplacement = change.propertyChanges?.some((pc) => pc.requiresReplacement);
31707
+ const dependencies = this.extractAllDependencies(template, logicalId);
31708
+ if (needsReplacement) {
31709
+ const replacedProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
31710
+ this.logger.info(
31711
+ `Replacing ${logicalId} (${resourceType}) - immutable properties changed: ${replacedProps}`
31712
+ );
31713
+ this.logger.info(` Creating new ${logicalId}...`);
31714
+ const { provider: replaceProvider, properties: replaceProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31715
+ const createResult = await this.withRetry(
31716
+ () => replaceProvider.create(logicalId, resourceType, replaceProps),
31424
31717
  logicalId
31425
31718
  );
31426
- const dependencies = this.extractAllDependencies(template, logicalId);
31719
+ const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
31720
+ if (updateReplacePolicy === "Retain") {
31721
+ this.logger.info(
31722
+ ` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`
31723
+ );
31724
+ } else {
31725
+ this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
31726
+ try {
31727
+ await provider.delete(
31728
+ logicalId,
31729
+ currentResource.physicalId,
31730
+ resourceType,
31731
+ currentResource.properties,
31732
+ { expectedRegion: this.stackRegion }
31733
+ );
31734
+ this.logger.info(` \u2713 Old resource deleted`);
31735
+ } catch (deleteError) {
31736
+ this.logger.warn(
31737
+ ` \u26A0 Failed to delete old resource ${logicalId} (${currentResource.physicalId}): ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
31738
+ );
31739
+ }
31740
+ }
31427
31741
  stateResources[logicalId] = {
31428
- physicalId: result.physicalId,
31742
+ physicalId: createResult.physicalId,
31429
31743
  resourceType,
31430
31744
  properties: resolvedProps,
31431
- ...result.attributes && { attributes: result.attributes },
31745
+ ...createResult.attributes && { attributes: createResult.attributes },
31432
31746
  ...dependencies && dependencies.length > 0 && { dependencies }
31433
31747
  };
31434
31748
  if (counts)
31435
- counts.created++;
31749
+ counts.updated++;
31436
31750
  if (progress)
31437
31751
  progress.current++;
31438
- const createPrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31752
+ const replacePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31439
31753
  renderer.removeTask(logicalId);
31440
- this.logger.info(`${createPrefix}\u2705 ${logicalId} (${resourceType}) created`);
31441
- break;
31442
- }
31443
- case "UPDATE": {
31444
- const currentResource = stateResources[logicalId];
31445
- if (!currentResource) {
31446
- throw new Error(`Cannot update ${logicalId}: resource not found in state`);
31447
- }
31448
- const desiredProps = change.desiredProperties || {};
31449
- const currentProps = change.currentProperties || {};
31450
- const context = {
31451
- template,
31452
- resources: stateResources,
31453
- ...parameterValues && Object.keys(parameterValues).length > 0 && { parameters: parameterValues },
31454
- ...conditions && Object.keys(conditions).length > 0 && { conditions },
31455
- stateBackend: this.stateBackend,
31456
- stackName
31457
- };
31458
- const resolvedProps = await this.resolver.resolve(desiredProps, context);
31459
- if (JSON.stringify(resolvedProps) === JSON.stringify(currentProps)) {
31460
- this.logger.debug(
31461
- `Skipping ${logicalId}: no actual changes after intrinsic function resolution`
31462
- );
31463
- if (counts)
31464
- counts.skipped++;
31465
- break;
31466
- }
31467
- const needsReplacement2 = change.propertyChanges?.some((pc) => pc.requiresReplacement);
31468
- const dependencies = this.extractAllDependencies(template, logicalId);
31469
- if (needsReplacement2) {
31470
- const replacedProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
31471
- this.logger.info(
31472
- `Replacing ${logicalId} (${resourceType}) - immutable properties changed: ${replacedProps}`
31473
- );
31474
- this.logger.info(` Creating new ${logicalId}...`);
31475
- const { provider: replaceProvider, properties: replaceProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31476
- const createResult = await this.withRetry(
31477
- () => replaceProvider.create(logicalId, resourceType, replaceProps),
31754
+ this.logger.info(`${replacePrefix}\u2705 ${logicalId} (${resourceType}) replaced`);
31755
+ } else {
31756
+ this.logger.debug(`Updating ${logicalId} (${resourceType})`);
31757
+ const { provider: updateProvider, properties: updateProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31758
+ let result;
31759
+ try {
31760
+ result = await this.withRetry(
31761
+ () => updateProvider.update(
31762
+ logicalId,
31763
+ currentResource.physicalId,
31764
+ resourceType,
31765
+ updateProps,
31766
+ currentProps
31767
+ ),
31478
31768
  logicalId
31479
31769
  );
31480
- const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
31481
- if (updateReplacePolicy === "Retain") {
31770
+ } catch (updateError) {
31771
+ const msg = updateError instanceof Error ? updateError.message : String(updateError);
31772
+ if (msg.includes("UnsupportedActionException") || msg.includes("does not support UPDATE")) {
31482
31773
  this.logger.info(
31483
- ` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`
31774
+ `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE \u2192 CREATE)`
31484
31775
  );
31485
- } else {
31486
- this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
31487
31776
  try {
31488
31777
  await provider.delete(
31489
31778
  logicalId,
31490
31779
  currentResource.physicalId,
31491
31780
  resourceType,
31492
- currentResource.properties,
31781
+ currentProps,
31493
31782
  { expectedRegion: this.stackRegion }
31494
31783
  );
31495
- this.logger.info(` \u2713 Old resource deleted`);
31496
31784
  } catch (deleteError) {
31497
- this.logger.warn(
31498
- ` \u26A0 Failed to delete old resource ${logicalId} (${currentResource.physicalId}): ${deleteError instanceof Error ? deleteError.message : String(deleteError)}`
31499
- );
31500
- }
31501
- }
31502
- stateResources[logicalId] = {
31503
- physicalId: createResult.physicalId,
31504
- resourceType,
31505
- properties: resolvedProps,
31506
- ...createResult.attributes && { attributes: createResult.attributes },
31507
- ...dependencies && dependencies.length > 0 && { dependencies }
31508
- };
31509
- if (counts)
31510
- counts.updated++;
31511
- if (progress)
31512
- progress.current++;
31513
- const replacePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31514
- renderer.removeTask(logicalId);
31515
- this.logger.info(`${replacePrefix}\u2705 ${logicalId} (${resourceType}) replaced`);
31516
- } else {
31517
- this.logger.debug(`Updating ${logicalId} (${resourceType})`);
31518
- const { provider: updateProvider, properties: updateProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31519
- let result;
31520
- try {
31521
- result = await this.withRetry(
31522
- () => updateProvider.update(
31523
- logicalId,
31524
- currentResource.physicalId,
31525
- resourceType,
31526
- updateProps,
31527
- currentProps
31528
- ),
31529
- logicalId
31530
- );
31531
- } catch (updateError) {
31532
- const msg = updateError instanceof Error ? updateError.message : String(updateError);
31533
- if (msg.includes("UnsupportedActionException") || msg.includes("does not support UPDATE")) {
31534
- this.logger.info(
31535
- `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE \u2192 CREATE)`
31536
- );
31537
- try {
31538
- await provider.delete(
31539
- logicalId,
31540
- currentResource.physicalId,
31541
- resourceType,
31542
- currentProps,
31543
- { expectedRegion: this.stackRegion }
31785
+ const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31786
+ if (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound")) {
31787
+ this.logger.debug(
31788
+ `Old resource ${logicalId} already gone, proceeding with CREATE`
31544
31789
  );
31545
- } catch (deleteError) {
31546
- const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31547
- if (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound")) {
31548
- this.logger.debug(
31549
- `Old resource ${logicalId} already gone, proceeding with CREATE`
31550
- );
31551
- } else {
31552
- throw deleteError;
31553
- }
31790
+ } else {
31791
+ throw deleteError;
31554
31792
  }
31555
- const { provider: replProvider, properties: replProps } = this.selectProviderWithSafetyNet(
31556
- provider,
31557
- resourceType,
31558
- resolvedProps,
31559
- logicalId
31560
- );
31561
- const createResult = await this.withRetry(
31562
- () => replProvider.create(logicalId, resourceType, replProps),
31563
- logicalId
31564
- );
31565
- result = {
31566
- physicalId: createResult.physicalId,
31567
- attributes: createResult.attributes,
31568
- wasReplaced: true
31569
- };
31570
- } else {
31571
- throw updateError;
31572
31793
  }
31573
- }
31574
- if (result.wasReplaced) {
31575
- this.logger.info(
31576
- `Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`
31794
+ const { provider: replProvider, properties: replProps } = this.selectProviderWithSafetyNet(provider, resourceType, resolvedProps, logicalId);
31795
+ const createResult = await this.withRetry(
31796
+ () => replProvider.create(logicalId, resourceType, replProps),
31797
+ logicalId
31577
31798
  );
31799
+ result = {
31800
+ physicalId: createResult.physicalId,
31801
+ attributes: createResult.attributes,
31802
+ wasReplaced: true
31803
+ };
31804
+ } else {
31805
+ throw updateError;
31578
31806
  }
31579
- stateResources[logicalId] = {
31580
- physicalId: result.physicalId,
31581
- resourceType,
31582
- properties: resolvedProps,
31583
- ...result.attributes && { attributes: result.attributes },
31584
- ...dependencies && dependencies.length > 0 && { dependencies }
31585
- };
31586
- if (counts)
31587
- counts.updated++;
31588
- if (progress)
31589
- progress.current++;
31590
- const updatePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31591
- renderer.removeTask(logicalId);
31592
- this.logger.info(`${updatePrefix}\u2705 ${logicalId} (${resourceType}) updated`);
31593
- }
31594
- break;
31595
- }
31596
- case "DELETE": {
31597
- const currentResource = stateResources[logicalId];
31598
- if (!currentResource) {
31599
- throw new Error(`Cannot delete ${logicalId}: resource not found in state`);
31600
31807
  }
31601
- const deletionPolicy = template?.Resources?.[logicalId]?.DeletionPolicy;
31602
- if (deletionPolicy === "Retain") {
31603
- this.logger.info(`Retaining ${logicalId} (${resourceType}) - DeletionPolicy: Retain`);
31604
- delete stateResources[logicalId];
31605
- break;
31606
- }
31607
- this.logger.debug(`Deleting ${logicalId} (${resourceType})`);
31608
- try {
31609
- await this.withRetry(
31610
- () => provider.delete(
31611
- logicalId,
31612
- currentResource.physicalId,
31613
- resourceType,
31614
- currentResource.properties,
31615
- { expectedRegion: this.stackRegion }
31616
- ),
31617
- logicalId,
31618
- 3,
31619
- // fewer retries for DELETE
31620
- 5e3
31808
+ if (result.wasReplaced) {
31809
+ this.logger.info(
31810
+ `Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`
31621
31811
  );
31622
- } catch (deleteError) {
31623
- const msg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31624
- if (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException")) {
31625
- this.logger.debug(
31626
- `Resource ${logicalId} already deleted (${msg}), removing from state`
31627
- );
31628
- } else {
31629
- throw deleteError;
31630
- }
31631
31812
  }
31632
- delete stateResources[logicalId];
31813
+ stateResources[logicalId] = {
31814
+ physicalId: result.physicalId,
31815
+ resourceType,
31816
+ properties: resolvedProps,
31817
+ ...result.attributes && { attributes: result.attributes },
31818
+ ...dependencies && dependencies.length > 0 && { dependencies }
31819
+ };
31633
31820
  if (counts)
31634
- counts.deleted++;
31821
+ counts.updated++;
31635
31822
  if (progress)
31636
31823
  progress.current++;
31637
- const deletePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31824
+ const updatePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31638
31825
  renderer.removeTask(logicalId);
31639
- this.logger.info(`${deletePrefix}\u2705 ${logicalId} (${resourceType}) deleted`);
31826
+ this.logger.info(`${updatePrefix}\u2705 ${logicalId} (${resourceType}) updated`);
31827
+ }
31828
+ break;
31829
+ }
31830
+ case "DELETE": {
31831
+ const currentResource = stateResources[logicalId];
31832
+ if (!currentResource) {
31833
+ throw new Error(`Cannot delete ${logicalId}: resource not found in state`);
31834
+ }
31835
+ const deletionPolicy = template?.Resources?.[logicalId]?.DeletionPolicy;
31836
+ if (deletionPolicy === "Retain") {
31837
+ this.logger.info(`Retaining ${logicalId} (${resourceType}) - DeletionPolicy: Retain`);
31838
+ delete stateResources[logicalId];
31640
31839
  break;
31641
31840
  }
31841
+ this.logger.debug(`Deleting ${logicalId} (${resourceType})`);
31842
+ try {
31843
+ await this.withRetry(
31844
+ () => provider.delete(
31845
+ logicalId,
31846
+ currentResource.physicalId,
31847
+ resourceType,
31848
+ currentResource.properties,
31849
+ { expectedRegion: this.stackRegion }
31850
+ ),
31851
+ logicalId,
31852
+ 3,
31853
+ // fewer retries for DELETE
31854
+ 5e3
31855
+ );
31856
+ } catch (deleteError) {
31857
+ const msg = deleteError instanceof Error ? deleteError.message : String(deleteError);
31858
+ if (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException")) {
31859
+ this.logger.debug(
31860
+ `Resource ${logicalId} already deleted (${msg}), removing from state`
31861
+ );
31862
+ } else {
31863
+ throw deleteError;
31864
+ }
31865
+ }
31866
+ delete stateResources[logicalId];
31867
+ if (counts)
31868
+ counts.deleted++;
31869
+ if (progress)
31870
+ progress.current++;
31871
+ const deletePrefix = progress ? `[${progress.current}/${progress.total}] ` : " ";
31872
+ renderer.removeTask(logicalId);
31873
+ this.logger.info(`${deletePrefix}\u2705 ${logicalId} (${resourceType}) deleted`);
31874
+ break;
31642
31875
  }
31643
- } catch (error) {
31644
- renderer.removeTask(logicalId);
31645
- const message = error instanceof Error ? error.message : String(error);
31646
- this.logger.error(`Failed to ${change.changeType.toLowerCase()} ${logicalId}: ${message}`);
31647
- throw new ProvisioningError(
31648
- `Failed to ${change.changeType.toLowerCase()} resource ${logicalId}`,
31649
- resourceType,
31650
- logicalId,
31651
- stateResources[logicalId]?.physicalId,
31652
- error instanceof Error ? error : void 0
31653
- );
31654
- } finally {
31655
- renderer.removeTask(logicalId);
31656
31876
  }
31657
31877
  }
31658
31878
  /**
@@ -31864,6 +32084,10 @@ async function deployCommand(stacks, options) {
31864
32084
  process.env["CDKD_NO_LIVE"] = "1";
31865
32085
  }
31866
32086
  warnIfDeprecatedRegion(options);
32087
+ validateResourceTimeouts({
32088
+ resourceWarnAfter: options.resourceWarnAfter,
32089
+ resourceTimeout: options.resourceTimeout
32090
+ });
31867
32091
  if (!options.wait) {
31868
32092
  process.env["CDKD_NO_WAIT"] = "true";
31869
32093
  }
@@ -32056,7 +32280,9 @@ Deploying stack: ${stackInfo.stackName}${stackRegion !== baseRegion ? ` (region:
32056
32280
  {
32057
32281
  concurrency: options.concurrency,
32058
32282
  dryRun: options.dryRun,
32059
- noRollback: !options.rollback
32283
+ noRollback: !options.rollback,
32284
+ resourceWarnAfterMs: options.resourceWarnAfter,
32285
+ resourceTimeoutMs: options.resourceTimeout
32060
32286
  },
32061
32287
  stackRegion
32062
32288
  );
@@ -32466,41 +32692,73 @@ Acquiring lock for stack ${stackName}...`);
32466
32692
  logger.debug(
32467
32693
  `Deletion level ${executionLevels.length - levelIndex}/${executionLevels.length} (${level.length} resources)`
32468
32694
  );
32695
+ const warnAfterMs = ctx.resourceWarnAfterMs ?? DEFAULT_RESOURCE_WARN_AFTER_MS;
32696
+ const timeoutMs = ctx.resourceTimeoutMs ?? DEFAULT_RESOURCE_TIMEOUT_MS;
32697
+ const stackRegion2 = state.region ?? ctx.baseRegion;
32469
32698
  const deletePromises = level.map(async (logicalId) => {
32470
32699
  const resource = state.resources[logicalId];
32471
32700
  if (!resource) {
32472
32701
  logger.warn(`Resource ${logicalId} not found in state, skipping`);
32473
32702
  return;
32474
32703
  }
32475
- renderer.addTask(logicalId, `Deleting ${logicalId} (${resource.resourceType})`);
32704
+ const baseLabel = `Deleting ${logicalId} (${resource.resourceType})`;
32705
+ renderer.addTask(logicalId, baseLabel);
32476
32706
  try {
32477
32707
  const provider = destroyProviderRegistry.getProvider(resource.resourceType);
32478
- let lastDeleteError;
32479
- for (let attempt = 0; attempt <= 3; attempt++) {
32480
- try {
32481
- await provider.delete(
32708
+ await withResourceDeadline(
32709
+ async () => {
32710
+ let lastDeleteError;
32711
+ for (let attempt = 0; attempt <= 3; attempt++) {
32712
+ try {
32713
+ await provider.delete(
32714
+ logicalId,
32715
+ resource.physicalId,
32716
+ resource.resourceType,
32717
+ resource.properties
32718
+ );
32719
+ lastDeleteError = null;
32720
+ break;
32721
+ } catch (retryError) {
32722
+ lastDeleteError = retryError;
32723
+ const msg = retryError instanceof Error ? retryError.message : String(retryError);
32724
+ const isRetryable = msg.includes("Too Many Requests") || msg.includes("has dependencies") || msg.includes("can't be deleted since") || msg.includes("DependencyViolation");
32725
+ if (!isRetryable || attempt >= 3)
32726
+ break;
32727
+ const delay = 5e3 * Math.pow(2, attempt);
32728
+ logger.debug(
32729
+ ` \u23F3 Retrying delete ${logicalId} in ${delay / 1e3}s (attempt ${attempt + 1}/3)`
32730
+ );
32731
+ await new Promise((resolve4) => setTimeout(resolve4, delay));
32732
+ }
32733
+ }
32734
+ if (lastDeleteError)
32735
+ throw lastDeleteError;
32736
+ },
32737
+ {
32738
+ warnAfterMs,
32739
+ timeoutMs,
32740
+ onWarn: (elapsedMs) => {
32741
+ const minutes = Math.max(1, Math.round(elapsedMs / 6e4));
32742
+ renderer.updateTaskLabel(
32743
+ logicalId,
32744
+ `${baseLabel} [taking longer than expected, ${minutes}m+]`
32745
+ );
32746
+ renderer.printAbove(() => {
32747
+ logger.warn(
32748
+ `${logicalId} (${resource.resourceType}) has been deleting for ${minutes}m \u2014 still waiting`
32749
+ );
32750
+ });
32751
+ },
32752
+ onTimeout: (elapsedMs) => new ResourceTimeoutError(
32482
32753
  logicalId,
32483
- resource.physicalId,
32484
32754
  resource.resourceType,
32485
- resource.properties
32486
- );
32487
- lastDeleteError = null;
32488
- break;
32489
- } catch (retryError) {
32490
- lastDeleteError = retryError;
32491
- const msg = retryError instanceof Error ? retryError.message : String(retryError);
32492
- const isRetryable = msg.includes("Too Many Requests") || msg.includes("has dependencies") || msg.includes("can't be deleted since") || msg.includes("DependencyViolation");
32493
- if (!isRetryable || attempt >= 3)
32494
- break;
32495
- const delay = 5e3 * Math.pow(2, attempt);
32496
- logger.debug(
32497
- ` \u23F3 Retrying delete ${logicalId} in ${delay / 1e3}s (attempt ${attempt + 1}/3)`
32498
- );
32499
- await new Promise((resolve4) => setTimeout(resolve4, delay));
32755
+ stackRegion2,
32756
+ elapsedMs,
32757
+ "DELETE",
32758
+ timeoutMs
32759
+ )
32500
32760
  }
32501
- }
32502
- if (lastDeleteError)
32503
- throw lastDeleteError;
32761
+ );
32504
32762
  renderer.removeTask(logicalId);
32505
32763
  logger.info(` \u2705 ${logicalId} (${resource.resourceType}) deleted`);
32506
32764
  result.deletedCount++;
@@ -32510,6 +32768,16 @@ Acquiring lock for stack ${stackName}...`);
32510
32768
  if (msg.includes("does not exist") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException")) {
32511
32769
  logger.debug(` ${logicalId} already deleted, removing from state`);
32512
32770
  result.deletedCount++;
32771
+ } else if (error instanceof ResourceTimeoutError) {
32772
+ const wrapped = new ProvisioningError(
32773
+ error.message,
32774
+ resource.resourceType,
32775
+ logicalId,
32776
+ resource.physicalId,
32777
+ error
32778
+ );
32779
+ logger.error(` \u2717 Failed to delete ${logicalId}:`, wrapped.message);
32780
+ result.errorCount++;
32513
32781
  } else {
32514
32782
  logger.error(` \u2717 Failed to delete ${logicalId}:`, String(error));
32515
32783
  result.errorCount++;
@@ -32552,6 +32820,10 @@ async function destroyCommand(stackArgs, options) {
32552
32820
  process.env["CDKD_NO_LIVE"] = "1";
32553
32821
  }
32554
32822
  warnIfDeprecatedRegion(options);
32823
+ validateResourceTimeouts({
32824
+ resourceWarnAfter: options.resourceWarnAfter,
32825
+ resourceTimeout: options.resourceTimeout
32826
+ });
32555
32827
  const region = options.region || process.env["AWS_REGION"] || "us-east-1";
32556
32828
  const stateBucket = await resolveStateBucketWithDefault(options.stateBucket, region);
32557
32829
  logger.info("Starting stack destruction...");
@@ -32683,7 +32955,9 @@ Preparing to destroy stack: ${stackName}`);
32683
32955
  baseRegion: region,
32684
32956
  ...options.profile && { profile: options.profile },
32685
32957
  stateBucket,
32686
- skipConfirmation: options.yes || options.force
32958
+ skipConfirmation: options.yes || options.force,
32959
+ resourceWarnAfterMs: options.resourceWarnAfter,
32960
+ resourceTimeoutMs: options.resourceTimeout
32687
32961
  });
32688
32962
  }
32689
32963
  } finally {
@@ -32701,6 +32975,7 @@ function createDestroyCommand() {
32701
32975
  ...stateOptions,
32702
32976
  ...stackOptions,
32703
32977
  ...destroyOptions,
32978
+ ...resourceTimeoutOptions,
32704
32979
  ...contextOptions
32705
32980
  ].forEach((opt) => cmd.addOption(opt));
32706
32981
  cmd.addOption(deprecatedRegionOption);
@@ -33536,7 +33811,7 @@ function formatAttributeValue(value) {
33536
33811
  }
33537
33812
  return JSON.stringify(value);
33538
33813
  }
33539
- function formatDuration(ms) {
33814
+ function formatDuration2(ms) {
33540
33815
  const seconds = Math.floor(ms / 1e3);
33541
33816
  if (seconds < 60)
33542
33817
  return `${seconds}s`;
@@ -33549,7 +33824,7 @@ function formatLockSummary(lockInfo) {
33549
33824
  return "unlocked";
33550
33825
  const opStr = lockInfo.operation ? ` (operation: ${lockInfo.operation})` : "";
33551
33826
  const expiresInMs = lockInfo.expiresAt - Date.now();
33552
- const expiresStr = expiresInMs > 0 ? `expires in ${formatDuration(expiresInMs)}` : `expired ${formatDuration(-expiresInMs)} ago`;
33827
+ const expiresStr = expiresInMs > 0 ? `expires in ${formatDuration2(expiresInMs)}` : `expired ${formatDuration2(-expiresInMs)} ago`;
33553
33828
  return `locked by ${lockInfo.owner}${opStr}, ${expiresStr}`;
33554
33829
  }
33555
33830
  function createStateResourcesCommand() {
@@ -33737,6 +34012,10 @@ async function stateDestroyCommand(stackArgs, options) {
33737
34012
  logger.setLevel("debug");
33738
34013
  process.env["CDKD_NO_LIVE"] = "1";
33739
34014
  }
34015
+ validateResourceTimeouts({
34016
+ resourceWarnAfter: options.resourceWarnAfter,
34017
+ resourceTimeout: options.resourceTimeout
34018
+ });
33740
34019
  if (!options.all && stackArgs.length === 0) {
33741
34020
  throw new Error(
33742
34021
  "Stack name is required. Usage: cdkd state destroy <stack> [<stack>...] | --all"
@@ -33836,7 +34115,9 @@ Preparing to destroy stack: ${stackName}${ref.region ? ` (${ref.region})` : ""}`
33836
34115
  // and the per-stack prompt inside the runner. Per-stack prompts are
33837
34116
  // skipped when `options.yes` is set OR `--all` was set (the user
33838
34117
  // already accepted the batch prompt).
33839
- skipConfirmation: options.yes || options.all === true
34118
+ skipConfirmation: options.yes || options.all === true,
34119
+ resourceWarnAfterMs: options.resourceWarnAfter,
34120
+ resourceTimeoutMs: options.resourceTimeout
33840
34121
  });
33841
34122
  totalErrors += result.errorCount;
33842
34123
  }
@@ -33867,7 +34148,9 @@ function createStateDestroyCommand() {
33867
34148
  "For removing only the state record (keeping AWS resources intact), use 'cdkd state orphan'."
33868
34149
  ].join("\n")
33869
34150
  ).action(withErrorHandling(stateDestroyCommand));
33870
- [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
34151
+ [...commonOptions, ...stateOptions, ...resourceTimeoutOptions].forEach(
34152
+ (opt) => cmd.addOption(opt)
34153
+ );
33871
34154
  cmd.addOption(deprecatedRegionOption);
33872
34155
  return cmd;
33873
34156
  }
@@ -34423,7 +34706,7 @@ function reorderArgs(argv) {
34423
34706
  }
34424
34707
  async function main() {
34425
34708
  const program = new Command13();
34426
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.24.0");
34709
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.25.0");
34427
34710
  program.addCommand(createBootstrapCommand());
34428
34711
  program.addCommand(createSynthCommand());
34429
34712
  program.addCommand(createListCommand());