@go-to-k/cdkd 0.262.3 → 0.263.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.
@@ -5209,6 +5209,50 @@ function shouldRetainResource(deletionPolicy) {
5209
5209
  return deletionPolicy === "Retain" || deletionPolicy === "RetainExceptOnCreate";
5210
5210
  }
5211
5211
 
5212
+ //#endregion
5213
+ //#region src/types/rollback-journal.ts
5214
+ /**
5215
+ * Journal format version, INDEPENDENT of the state schema. An unknown value
5216
+ * on read is a hard error telling the user to upgrade cdkd (forward-compat
5217
+ * guard, mirrors state-schema handling).
5218
+ */
5219
+ const ROLLBACK_JOURNAL_VERSION = 1;
5220
+ /** Thrown when a journal's `journalVersion` is newer than this binary knows. */
5221
+ var UnknownRollbackJournalVersionError = class extends Error {
5222
+ foundVersion;
5223
+ stackName;
5224
+ constructor(foundVersion, stackName) {
5225
+ super(`Rollback journal for '${stackName}' has journalVersion ${foundVersion}, but this cdkd only understands up to ${1}. Upgrade cdkd to roll this stack back.`);
5226
+ this.name = "UnknownRollbackJournalVersionError";
5227
+ this.foundVersion = foundVersion;
5228
+ this.stackName = stackName;
5229
+ }
5230
+ };
5231
+ /**
5232
+ * Parse + validate a journal body. Throws
5233
+ * {@link UnknownRollbackJournalVersionError} on a newer version, and a plain
5234
+ * Error on a structurally-invalid body.
5235
+ */
5236
+ function parseRollbackJournal(bodyString, stackName) {
5237
+ let parsed;
5238
+ try {
5239
+ parsed = JSON.parse(bodyString);
5240
+ } catch (err) {
5241
+ throw new Error(`Rollback journal for '${stackName}' is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
5242
+ }
5243
+ if (typeof parsed !== "object" || parsed === null) throw new Error(`Rollback journal for '${stackName}' is malformed (not an object).`);
5244
+ const j = parsed;
5245
+ if (typeof j.journalVersion !== "number" || j.journalVersion < 1) throw new Error(`Rollback journal for '${stackName}' has an invalid 'journalVersion' (${String(j.journalVersion)}).`);
5246
+ if (j.journalVersion > 1) throw new UnknownRollbackJournalVersionError(j.journalVersion, stackName);
5247
+ if (!Array.isArray(j.segments)) throw new Error(`Rollback journal for '${stackName}' is missing a 'segments' array.`);
5248
+ return {
5249
+ journalVersion: j.journalVersion,
5250
+ stackName: j.stackName ?? stackName,
5251
+ region: j.region ?? "",
5252
+ segments: j.segments
5253
+ };
5254
+ }
5255
+
5212
5256
  //#endregion
5213
5257
  //#region src/utils/bucket-region-client.ts
5214
5258
  /**
@@ -5342,6 +5386,16 @@ var S3StateBackend = class {
5342
5386
  return `${this.config.prefix}/${stackName}/state.json`;
5343
5387
  }
5344
5388
  /**
5389
+ * Get the rollback-journal S3 key — a sibling of `state.json` (issue
5390
+ * #1183). Only the region-scoped layout is used: journals are new objects
5391
+ * only ever written by journal-aware binaries, and the deploy failure path
5392
+ * migrates legacy-layout state before the journal write, so a legacy-key
5393
+ * journal can never exist.
5394
+ */
5395
+ getRollbackJournalKey(stackName, region) {
5396
+ return `${this.config.prefix}/${stackName}/${region}/rollback-journal.json`;
5397
+ }
5398
+ /**
5345
5399
  * Resolve the state bucket's actual region and, if it differs from the
5346
5400
  * client's currently-configured region, replace the S3Client with one
5347
5401
  * pointed at the bucket's region.
@@ -5567,6 +5621,7 @@ var S3StateBackend = class {
5567
5621
  }));
5568
5622
  this.logger.debug(`Deleted legacy state for stack: ${stackName}`);
5569
5623
  }
5624
+ await this.deleteRollbackJournal(stackName, region);
5570
5625
  this.logger.debug(`State deleted: ${stackName} (${region})`);
5571
5626
  } catch (error) {
5572
5627
  const normalized = normalizeAwsError(error, {
@@ -5739,6 +5794,70 @@ var S3StateBackend = class {
5739
5794
  if (failures.length > 0) throw new StateError(`Failed to delete ${failures.length} object(s) from bucket '${this.config.bucket}': ${failures.join("; ")}`);
5740
5795
  }
5741
5796
  /**
5797
+ * Load the rollback journal for a stack (issue #1183). Returns `null` when
5798
+ * no journal exists (the common case — a journal only lives between a
5799
+ * failed/interrupted deploy and its `cdkd rollback`). Throws
5800
+ * {@link UnknownRollbackJournalVersionError} on a newer-version journal.
5801
+ */
5802
+ async loadRollbackJournal(stackName, region) {
5803
+ const body = await this.getRawObject(this.getRollbackJournalKey(stackName, region));
5804
+ if (body === null) return null;
5805
+ return parseRollbackJournal(body, stackName);
5806
+ }
5807
+ /**
5808
+ * Append one segment to the stack's rollback journal, creating it if
5809
+ * absent. Existing segments are preserved (never overwritten) so
5810
+ * consecutive failed deploys accumulate one segment each. Every writer
5811
+ * holds the stack lock, so no optimistic locking is needed.
5812
+ */
5813
+ async appendRollbackJournalSegment(stackName, region, segment) {
5814
+ const journal = await this.loadRollbackJournal(stackName, region) ?? {
5815
+ journalVersion: 1,
5816
+ stackName,
5817
+ region,
5818
+ segments: []
5819
+ };
5820
+ journal.segments.push(segment);
5821
+ await this.putRawObject(this.getRollbackJournalKey(stackName, region), JSON.stringify(journal, null, 2));
5822
+ }
5823
+ /**
5824
+ * Pop the newest segment off the stack's rollback journal after it has
5825
+ * been fully replayed. When the last segment is removed, the journal
5826
+ * object is deleted entirely. Returns the number of segments remaining.
5827
+ */
5828
+ async popRollbackJournalSegment(stackName, region) {
5829
+ const journal = await this.loadRollbackJournal(stackName, region);
5830
+ if (!journal || journal.segments.length === 0) {
5831
+ await this.deleteRollbackJournal(stackName, region);
5832
+ return 0;
5833
+ }
5834
+ journal.segments.pop();
5835
+ if (journal.segments.length === 0) {
5836
+ await this.deleteRollbackJournal(stackName, region);
5837
+ return 0;
5838
+ }
5839
+ await this.putRawObject(this.getRollbackJournalKey(stackName, region), JSON.stringify(journal, null, 2));
5840
+ return journal.segments.length;
5841
+ }
5842
+ /**
5843
+ * Delete the stack's rollback journal object (idempotent). Called on the
5844
+ * deploy success path, after a clean rollback, and via {@link deleteState}
5845
+ * so `cdkd destroy` / `cdkd state destroy` sweep it too.
5846
+ */
5847
+ async deleteRollbackJournal(stackName, region) {
5848
+ await this.ensureClientForBucket();
5849
+ try {
5850
+ await this.s3Client.send(new DeleteObjectCommand({
5851
+ Bucket: this.config.bucket,
5852
+ ...await this.ownerParam(),
5853
+ Key: this.getRollbackJournalKey(stackName, region)
5854
+ }));
5855
+ } catch (error) {
5856
+ if (isNoSuchKey(error) || error.name === "NotFound") return;
5857
+ this.logger.warn(`Failed to delete rollback journal for '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`);
5858
+ }
5859
+ }
5860
+ /**
5742
5861
  * HeadObject probe — returns true on 200, false on NotFound. Other errors
5743
5862
  * propagate so we don't accidentally swallow IAM denials.
5744
5863
  */
@@ -11121,7 +11240,7 @@ var CloudControlProvider = class {
11121
11240
  this.logger.debug(`Deleting resource ${logicalId} (${resourceType}), physical ID: ${physicalId}`);
11122
11241
  if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
11123
11242
  this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
11124
- const { ASGProvider } = await import("./asg-provider-CAnHrdEN.js").then((n) => n.n);
11243
+ const { ASGProvider } = await import("./asg-provider-DF1bV_pu.js").then((n) => n.n);
11125
11244
  await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context);
11126
11245
  return;
11127
11246
  }
@@ -16284,6 +16403,763 @@ async function withResourceDeadline(operation, opts) {
16284
16403
  });
16285
16404
  }
16286
16405
 
16406
+ //#endregion
16407
+ //#region src/deployment/rollback-executor.ts
16408
+ /**
16409
+ * True when the op recorded a replacement (old physical id differs from the
16410
+ * new one). The old physical resource is already gone / orphaned, so an
16411
+ * in-place revert is best-effort — the plan labels these explicitly.
16412
+ */
16413
+ function isReplacementOp(op) {
16414
+ return op.changeType === "UPDATE" && op.previousState?.physicalId !== void 0 && op.previousState.physicalId !== op.physicalId;
16415
+ }
16416
+ function deepEqual(a, b) {
16417
+ if (a === b) return true;
16418
+ if (a == null || b == null) return a === b;
16419
+ if (typeof a !== typeof b) return false;
16420
+ if (typeof a !== "object") return false;
16421
+ if (Array.isArray(a) || Array.isArray(b)) {
16422
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
16423
+ return a.every((v, i) => deepEqual(v, b[i]));
16424
+ }
16425
+ const ao = a;
16426
+ const bo = b;
16427
+ const ak = Object.keys(ao);
16428
+ if (ak.length !== Object.keys(bo).length) return false;
16429
+ for (const k of ak) {
16430
+ if (!Object.prototype.hasOwnProperty.call(bo, k)) return false;
16431
+ if (!deepEqual(ao[k], bo[k])) return false;
16432
+ }
16433
+ return true;
16434
+ }
16435
+ /**
16436
+ * Classify what a single op WILL do against the current state, without
16437
+ * touching AWS. Pure — used both by the command's plan preview and by the
16438
+ * replayer (which re-derives the action to stay in lock-step with the
16439
+ * plan). `orphanLogicalIds` mirrors `cdk rollback --orphan`.
16440
+ */
16441
+ function classifyRollbackOp(op, stateResources, orphanLogicalIds) {
16442
+ const replacement = isReplacementOp(op);
16443
+ if (op.changeType === "DELETE") return "unrecoverable-delete";
16444
+ if (orphanLogicalIds.has(op.logicalId)) return "orphan-flag";
16445
+ if (op.changeType === "CREATE") {
16446
+ const current = stateResources[op.logicalId];
16447
+ if (!current) return "skip-already-done";
16448
+ if (op.physicalId !== void 0 && current.physicalId !== op.physicalId) return "skip-mismatch";
16449
+ const policy = current.deletionPolicy;
16450
+ if (policy === "Retain" || policy === "Snapshot") return "orphan-retain";
16451
+ return "delete";
16452
+ }
16453
+ const current = stateResources[op.logicalId];
16454
+ if (!current) return "skip-absent";
16455
+ if (op.previousState && deepEqual(current.properties, op.previousState.properties)) {
16456
+ if (!replacement) return "skip-already-done";
16457
+ }
16458
+ return "revert";
16459
+ }
16460
+ /**
16461
+ * Build the full ordered plan for a list of ops (one segment). Mirrors the
16462
+ * replay order: UPDATE/DELETE first (reverse completion order), then CREATE
16463
+ * deletions in dependency-aware order.
16464
+ */
16465
+ function planRollback(operations, stateResources, orphanLogicalIds = /* @__PURE__ */ new Set()) {
16466
+ const { createOps, otherOps } = partitionOps(operations);
16467
+ return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => ({
16468
+ op,
16469
+ action: classifyRollbackOp(op, stateResources, orphanLogicalIds),
16470
+ replacement: isReplacementOp(op)
16471
+ }));
16472
+ }
16473
+ function partitionOps(operations) {
16474
+ const createOps = [];
16475
+ const otherOps = [];
16476
+ for (const op of operations) if (op.changeType === "CREATE") createOps.push(op);
16477
+ else otherOps.push(op);
16478
+ return {
16479
+ createOps,
16480
+ otherOps
16481
+ };
16482
+ }
16483
+ /**
16484
+ * Replay a list of completed operations against `stateResources` (mutated in
16485
+ * place), reverting each. Best-effort: a provider failure is caught, warned,
16486
+ * and counted; replay continues.
16487
+ *
16488
+ * - UPDATE / DELETE first (reverse completion order), then CREATE deletions
16489
+ * in reverse dependency order (dependents deleted before dependencies).
16490
+ * - `afterOp` is invoked after each op that MUTATED state (so the command can
16491
+ * persist state incrementally, mirroring `saveStateAfterResource`). The
16492
+ * in-process engine passes no `afterOp` and saves state once at the end.
16493
+ * - `isInterrupted` is polled between ops; when it flips true, replay stops
16494
+ * (the pending op is left for a re-run).
16495
+ */
16496
+ async function replayRollback(operations, stateResources, stackName, ctx, options = {}) {
16497
+ const orphanLogicalIds = options.orphanLogicalIds ?? /* @__PURE__ */ new Set();
16498
+ const result = {
16499
+ failures: 0,
16500
+ warnings: 0,
16501
+ interrupted: false
16502
+ };
16503
+ if (operations.length === 0) {
16504
+ ctx.logger.info("No completed operations to roll back.");
16505
+ return result;
16506
+ }
16507
+ ctx.logger.info(`Rolling back ${operations.length} completed operation(s)...`);
16508
+ ctx.recordEvent?.({
16509
+ eventType: "ROLLBACK_STARTED",
16510
+ stackName
16511
+ });
16512
+ const { createOps, otherOps } = partitionOps(operations);
16513
+ for (let i = otherOps.length - 1; i >= 0; i--) {
16514
+ if (options.isInterrupted?.()) {
16515
+ result.interrupted = true;
16516
+ break;
16517
+ }
16518
+ await replaySingle(otherOps[i], stateResources, stackName, ctx, orphanLogicalIds, result, options.afterOp);
16519
+ }
16520
+ if (!result.interrupted && createOps.length > 0) {
16521
+ const sorted = sortRollbackCreates(createOps, stateResources);
16522
+ for (const op of sorted) {
16523
+ if (options.isInterrupted?.()) {
16524
+ result.interrupted = true;
16525
+ break;
16526
+ }
16527
+ await replaySingle(op, stateResources, stackName, ctx, orphanLogicalIds, result, options.afterOp);
16528
+ }
16529
+ }
16530
+ ctx.logger.info("Rollback completed. Some resources may remain if deletion failed.");
16531
+ ctx.recordEvent?.({
16532
+ eventType: "ROLLBACK_FINISHED",
16533
+ stackName
16534
+ });
16535
+ return result;
16536
+ }
16537
+ async function replaySingle(op, stateResources, stackName, ctx, orphanLogicalIds, result, afterOp) {
16538
+ const action = classifyRollbackOp(op, stateResources, orphanLogicalIds);
16539
+ const { logger } = ctx;
16540
+ try {
16541
+ switch (action) {
16542
+ case "unrecoverable-delete":
16543
+ logger.warn(` Rollback: Cannot restore deleted resource ${op.logicalId} (${op.resourceType}) — resource has already been deleted`);
16544
+ result.warnings++;
16545
+ return;
16546
+ case "skip-already-done":
16547
+ logger.debug(` Rollback: ${op.logicalId} already reverted, skipping`);
16548
+ return;
16549
+ case "skip-mismatch":
16550
+ logger.warn(` Rollback: Skipping ${op.logicalId} — its physical id changed since the failed deploy (replaced by a later attempt); manual attention may be required`);
16551
+ result.warnings++;
16552
+ return;
16553
+ case "skip-absent":
16554
+ logger.warn(` Rollback: Cannot restore ${op.logicalId} — resource no longer in state, skipping`);
16555
+ result.warnings++;
16556
+ return;
16557
+ case "orphan-flag":
16558
+ if (op.changeType === "CREATE") {
16559
+ delete stateResources[op.logicalId];
16560
+ logger.info(` Rollback: Orphaning created resource ${op.logicalId} (--orphan)`);
16561
+ await afterOp?.(op.logicalId);
16562
+ } else logger.info(` Rollback: Leaving ${op.logicalId} at its new state (--orphan)`);
16563
+ return;
16564
+ case "orphan-retain":
16565
+ delete stateResources[op.logicalId];
16566
+ logger.info(` Rollback: Leaving ${op.logicalId} (${op.resourceType}) in AWS (DeletionPolicy: ${stateResourcesPolicyLabel(op, stateResources)}) — removed from state`);
16567
+ await afterOp?.(op.logicalId);
16568
+ ctx.recordEvent?.({
16569
+ eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
16570
+ stackName,
16571
+ operation: "CREATE",
16572
+ logicalId: op.logicalId,
16573
+ resourceType: op.resourceType,
16574
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy }
16575
+ });
16576
+ return;
16577
+ case "delete": {
16578
+ if (!op.physicalId) {
16579
+ logger.warn(` Rollback: Cannot delete ${op.logicalId} — no physical ID recorded`);
16580
+ result.warnings++;
16581
+ return;
16582
+ }
16583
+ logger.info(` Rollback: Deleting created resource ${op.logicalId} (${op.resourceType})`);
16584
+ const { provider } = ctx.providerRegistry.getProviderFor({
16585
+ resourceType: op.resourceType,
16586
+ provisionedBy: op.provisionedBy
16587
+ });
16588
+ await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties, { expectedRegion: ctx.region });
16589
+ delete stateResources[op.logicalId];
16590
+ logger.info(` Rollback: ${op.logicalId} deleted successfully`);
16591
+ await afterOp?.(op.logicalId);
16592
+ ctx.recordEvent?.({
16593
+ eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
16594
+ stackName,
16595
+ operation: "CREATE",
16596
+ logicalId: op.logicalId,
16597
+ resourceType: op.resourceType,
16598
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy }
16599
+ });
16600
+ return;
16601
+ }
16602
+ case "revert": {
16603
+ if (!op.previousState) {
16604
+ logger.warn(` Rollback: Cannot restore ${op.logicalId} — no previous state available`);
16605
+ result.warnings++;
16606
+ return;
16607
+ }
16608
+ const current = stateResources[op.logicalId];
16609
+ if (!current) {
16610
+ logger.warn(` Rollback: Cannot restore ${op.logicalId} — resource not found in current state`);
16611
+ result.warnings++;
16612
+ return;
16613
+ }
16614
+ logger.info(` Rollback: Restoring ${op.logicalId} (${op.resourceType}) to previous state`);
16615
+ const { provider } = ctx.providerRegistry.getProviderFor({
16616
+ resourceType: op.resourceType,
16617
+ provisionedBy: op.provisionedBy
16618
+ });
16619
+ await provider.update(op.logicalId, current.physicalId, op.resourceType, op.previousState.properties, current.properties);
16620
+ stateResources[op.logicalId] = op.previousState;
16621
+ logger.info(` Rollback: ${op.logicalId} restored successfully`);
16622
+ await afterOp?.(op.logicalId);
16623
+ ctx.recordEvent?.({
16624
+ eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
16625
+ stackName,
16626
+ operation: "UPDATE",
16627
+ logicalId: op.logicalId,
16628
+ resourceType: op.resourceType,
16629
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy }
16630
+ });
16631
+ return;
16632
+ }
16633
+ }
16634
+ } catch (rollbackError) {
16635
+ logger.warn(` Rollback failed for ${op.logicalId} (${op.changeType}): ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
16636
+ logger.warn(" Continuing with remaining rollback operations...");
16637
+ result.failures++;
16638
+ ctx.recordEvent?.({
16639
+ eventType: "ROLLBACK_RESOURCE_FAILED",
16640
+ stackName,
16641
+ operation: op.changeType,
16642
+ logicalId: op.logicalId,
16643
+ resourceType: op.resourceType,
16644
+ ...op.provisionedBy && { provisionedBy: op.provisionedBy },
16645
+ error: extractDeploymentEventError(rollbackError)
16646
+ });
16647
+ }
16648
+ }
16649
+ function stateResourcesPolicyLabel(op, stateResources) {
16650
+ return stateResources[op.logicalId]?.deletionPolicy ?? "Retain";
16651
+ }
16652
+ /**
16653
+ * Sort CREATE rollback operations so that resources depending on others are
16654
+ * deleted first (reverse dependency order), using state dependencies. Same
16655
+ * algorithm as the pre-extraction `DeployEngine.sortRollbackCreates`.
16656
+ */
16657
+ function sortRollbackCreates(createOps, stateResources, logger) {
16658
+ const opMap = /* @__PURE__ */ new Map();
16659
+ const deleteIds = /* @__PURE__ */ new Set();
16660
+ for (const op of createOps) {
16661
+ opMap.set(op.logicalId, op);
16662
+ deleteIds.add(op.logicalId);
16663
+ }
16664
+ const dependedBy = /* @__PURE__ */ new Map();
16665
+ for (const id of deleteIds) if (!dependedBy.has(id)) dependedBy.set(id, /* @__PURE__ */ new Set());
16666
+ for (const id of deleteIds) {
16667
+ const resource = stateResources[id];
16668
+ if (!resource?.dependencies) continue;
16669
+ for (const dep of resource.dependencies) {
16670
+ if (!deleteIds.has(dep)) continue;
16671
+ if (!dependedBy.has(dep)) dependedBy.set(dep, /* @__PURE__ */ new Set());
16672
+ dependedBy.get(dep).add(id);
16673
+ }
16674
+ }
16675
+ const sorted = [];
16676
+ let remaining = new Set(deleteIds);
16677
+ while (remaining.size > 0) {
16678
+ const level = [];
16679
+ for (const id of remaining) {
16680
+ const dependents = dependedBy.get(id);
16681
+ if (!(dependents ? [...dependents].some((d) => remaining.has(d)) : false)) level.push(id);
16682
+ }
16683
+ if (level.length === 0) {
16684
+ logger?.warn(`Circular dependency detected in rollback order, processing remaining ${remaining.size} resources`);
16685
+ for (const id of remaining) {
16686
+ const op = opMap.get(id);
16687
+ if (op) sorted.push(op);
16688
+ }
16689
+ break;
16690
+ }
16691
+ for (const id of level) {
16692
+ const op = opMap.get(id);
16693
+ if (op) sorted.push(op);
16694
+ }
16695
+ remaining = new Set([...remaining].filter((id) => !level.includes(id)));
16696
+ }
16697
+ logger?.debug(`Rollback CREATE deletion order: ${sorted.map((op) => op.logicalId).join(" → ")}`);
16698
+ return sorted;
16699
+ }
16700
+
16701
+ //#endregion
16702
+ //#region src/state/deployment-events-store.ts
16703
+ /**
16704
+ * Deployment-events store (issue #808) — persists the structured
16705
+ * deployment events emitted by the deploy engine / destroy runner as
16706
+ * JSONL to the state bucket, plus a small per-stack run index:
16707
+ *
16708
+ * - Events: `{prefix}/{stackName}/{region}/deployments/{runId}.jsonl`
16709
+ * - Index: `{prefix}/{stackName}/{region}/deployments/index.json`
16710
+ *
16711
+ * Design constraints (all load-bearing):
16712
+ *
16713
+ * - **Best-effort, never blocking**: `record()` is synchronous and only
16714
+ * buffers in memory; flushes run asynchronously (debounced timer +
16715
+ * size threshold) and are serialized on a write chain. A failed S3
16716
+ * write warns once and degrades to debug-level afterwards — it can
16717
+ * NEVER fail or block the deploy/destroy itself.
16718
+ * - **No locking**: each run writes to its own unique `{runId}.jsonl`
16719
+ * key (no concurrent writer by construction). `index.json` is
16720
+ * last-writer-wins — acceptable for a derived view; the `.jsonl`
16721
+ * files are the source of truth and `cdkd events` can read a run
16722
+ * directly by id even if the index lost the race.
16723
+ * - **No resource properties** in events — errors + metadata only
16724
+ * (properties may contain secrets and already live in state.json).
16725
+ * - **Separate keys from state.json** — no state schema bump; event
16726
+ * files survive `cdkd destroy` (state deletion does not touch
16727
+ * `deployments/`), preserving post-mortem context.
16728
+ * - **Bounded growth** (issue #885): `finalize()` prunes `{runId}.jsonl`
16729
+ * streams that fell out of the index window so the `deployments/`
16730
+ * prefix stays bounded; `cdkd events prune` (via
16731
+ * {@link DeploymentEventsReader.pruneRuns}) is the explicit purge.
16732
+ */
16733
+ /** Max runs retained in `deployments/index.json` (newest first). */
16734
+ const DEPLOYMENT_EVENTS_MAX_INDEX_RUNS = 20;
16735
+ /** Debounce window between buffered events and the async S3 flush. */
16736
+ const FLUSH_INTERVAL_MS = 2e3;
16737
+ /** Flush immediately once this many events are buffered. */
16738
+ const FLUSH_EVENT_THRESHOLD = 50;
16739
+ /** Build-time cdkd version, with a dev fallback for non-built contexts. */
16740
+ function getCdkdVersion() {
16741
+ return "0.263.0";
16742
+ }
16743
+ /**
16744
+ * Generate a time-sortable unique run id, e.g.
16745
+ * `20260613T012345678Z-1a2b3c4d`. The timestamp prefix keeps S3 listings
16746
+ * and `cdkd events` output chronologically meaningful; the random suffix
16747
+ * guarantees uniqueness across concurrent runs.
16748
+ */
16749
+ function newDeploymentRunId(now = /* @__PURE__ */ new Date()) {
16750
+ return `${now.toISOString().replace(/[-:.]/g, "")}-${randomUUID().slice(0, 8)}`;
16751
+ }
16752
+ /** S3 key of a run's JSONL event stream. */
16753
+ function deploymentEventsKey(prefix, stackName, region, runId) {
16754
+ return `${prefix}/${stackName}/${region}/deployments/${runId}.jsonl`;
16755
+ }
16756
+ /** S3 key of a stack's run index. */
16757
+ function deploymentEventsIndexKey(prefix, stackName, region) {
16758
+ return `${prefix}/${stackName}/${region}/deployments/index.json`;
16759
+ }
16760
+ /** S3 key prefix under which a stack's `deployments/` artifacts live. */
16761
+ function deploymentsDirPrefix(prefix, stackName, region) {
16762
+ return `${prefix}/${stackName}/${region}/deployments/`;
16763
+ }
16764
+ /**
16765
+ * Parse the wall-clock time encoded in a run id's leading timestamp
16766
+ * (e.g. `20260613T012345678Z-1a2b3c4d`) back to epoch milliseconds.
16767
+ * Returns `null` for any id that does not start with the canonical
16768
+ * compact-ISO prefix — the age-based pruner treats an unparseable id as
16769
+ * "do not delete on age grounds", which is the safe direction.
16770
+ */
16771
+ function runIdTimestampMs(runId) {
16772
+ const m = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(\d{3})Z/.exec(runId);
16773
+ if (!m) return null;
16774
+ const iso = `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}.${m[7]}Z`;
16775
+ const ms = Date.parse(iso);
16776
+ return Number.isNaN(ms) ? null : ms;
16777
+ }
16778
+ /** Extract the `{runId}` from a `.../{runId}.jsonl` event-stream key. */
16779
+ function runIdFromJsonlKey(key, dirPrefix) {
16780
+ if (!key.startsWith(dirPrefix) || !key.endsWith(".jsonl")) return null;
16781
+ return key.slice(dirPrefix.length, -6);
16782
+ }
16783
+ /**
16784
+ * Buffering JSONL writer for one deployment run. Implements the
16785
+ * {@link DeploymentEventRecorder} seam the deploy engine / destroy
16786
+ * runner emit through.
16787
+ */
16788
+ var DeploymentEventsStore = class {
16789
+ logger = getLogger().child("DeploymentEvents");
16790
+ backend;
16791
+ stackName;
16792
+ region;
16793
+ command;
16794
+ runId;
16795
+ cdkdVersion;
16796
+ startedAt;
16797
+ /** All events recorded so far (the full JSONL body is re-PUT per flush —
16798
+ * S3 has no append, and metadata-only events are small). */
16799
+ events = [];
16800
+ /** Number of events already persisted by the last successful flush. */
16801
+ persistedCount = 0;
16802
+ flushTimer;
16803
+ /** Serializes S3 writes so flushes never interleave. */
16804
+ writeChain = Promise.resolve();
16805
+ warnedOnce = false;
16806
+ finalized = false;
16807
+ constructor(backend, options) {
16808
+ this.backend = backend;
16809
+ this.stackName = options.stackName;
16810
+ this.region = options.region;
16811
+ this.command = options.command;
16812
+ this.runId = options.runId ?? newDeploymentRunId();
16813
+ this.cdkdVersion = options.cdkdVersion ?? getCdkdVersion();
16814
+ this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
16815
+ }
16816
+ /**
16817
+ * Buffer one event (synchronous, never throws). The timestamp is
16818
+ * stamped here so emitters don't need to.
16819
+ */
16820
+ record(event) {
16821
+ try {
16822
+ if (this.finalized) return;
16823
+ this.events.push({
16824
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
16825
+ ...event
16826
+ });
16827
+ if (this.events.length - this.persistedCount >= FLUSH_EVENT_THRESHOLD) this.scheduleFlush(0);
16828
+ else this.scheduleFlush(FLUSH_INTERVAL_MS);
16829
+ } catch {}
16830
+ }
16831
+ /**
16832
+ * Final flush + index update. Called by the owner (deploy CLI per-stack
16833
+ * finally / destroy runner finally) after the run reaches a terminal
16834
+ * state. Best-effort: never throws.
16835
+ */
16836
+ async finalize(result) {
16837
+ if (this.finalized) return;
16838
+ this.finalized = true;
16839
+ if (this.flushTimer) {
16840
+ clearTimeout(this.flushTimer);
16841
+ this.flushTimer = void 0;
16842
+ }
16843
+ if (this.events.length === 0) return;
16844
+ await this.enqueueWrite(async () => {
16845
+ await this.doFlush();
16846
+ const keptRunIds = await this.updateIndex(result);
16847
+ await this.pruneSupersededRunFiles(keptRunIds);
16848
+ });
16849
+ }
16850
+ /** Await any in-flight async flushes (used by tests). */
16851
+ async drain() {
16852
+ await this.writeChain;
16853
+ }
16854
+ scheduleFlush(delayMs) {
16855
+ if (this.flushTimer) {
16856
+ if (delayMs > 0) return;
16857
+ clearTimeout(this.flushTimer);
16858
+ }
16859
+ this.flushTimer = setTimeout(() => {
16860
+ this.flushTimer = void 0;
16861
+ this.enqueueWrite(() => this.doFlush());
16862
+ }, delayMs);
16863
+ this.flushTimer.unref?.();
16864
+ }
16865
+ enqueueWrite(op) {
16866
+ const next = this.writeChain.then(op).catch((err) => {
16867
+ this.warnOnce(`Failed to persist deployment events for run ${this.runId}: ${err instanceof Error ? err.message : String(err)}`);
16868
+ });
16869
+ this.writeChain = next;
16870
+ return next;
16871
+ }
16872
+ async doFlush() {
16873
+ if (this.events.length === 0 || this.events.length === this.persistedCount) return;
16874
+ const snapshotCount = this.events.length;
16875
+ const body = this.events.slice(0, snapshotCount).map((e) => JSON.stringify(e)).join("\n");
16876
+ await this.backend.putRawObject(deploymentEventsKey(this.backend.prefix, this.stackName, this.region, this.runId), body + "\n", "application/x-ndjson");
16877
+ this.persistedCount = snapshotCount;
16878
+ }
16879
+ /**
16880
+ * Prepend this run's summary to `deployments/index.json`, truncated to
16881
+ * the last {@link DEPLOYMENT_EVENTS_MAX_INDEX_RUNS} runs. Read-modify-
16882
+ * write WITHOUT optimistic locking — last-writer-wins (documented
16883
+ * trade-off; the per-run `.jsonl` files are the source of truth).
16884
+ *
16885
+ * Returns the run ids retained in the index (newest-first), which the
16886
+ * caller feeds to {@link pruneSupersededRunFiles} so the `.jsonl` files
16887
+ * stay bounded to the same window as the index.
16888
+ */
16889
+ async updateIndex(result) {
16890
+ const key = deploymentEventsIndexKey(this.backend.prefix, this.stackName, this.region);
16891
+ let existingRuns = [];
16892
+ try {
16893
+ const raw = await this.backend.getRawObject(key);
16894
+ if (raw !== null) {
16895
+ const parsed = JSON.parse(raw);
16896
+ if (Array.isArray(parsed.runs)) existingRuns = parsed.runs;
16897
+ }
16898
+ } catch (err) {
16899
+ this.logger.debug(`Deployment-events index unreadable, rewriting: ${err instanceof Error ? err.message : String(err)}`);
16900
+ }
16901
+ const runs = [{
16902
+ runId: this.runId,
16903
+ command: this.command,
16904
+ cdkdVersion: this.cdkdVersion,
16905
+ startedAt: this.startedAt,
16906
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
16907
+ result,
16908
+ eventCount: this.persistedCount
16909
+ }, ...existingRuns.filter((r) => r.runId !== this.runId)].slice(0, 20);
16910
+ const file = {
16911
+ indexVersion: 1,
16912
+ stackName: this.stackName,
16913
+ region: this.region,
16914
+ runs,
16915
+ lastModified: Date.now()
16916
+ };
16917
+ await this.backend.putRawObject(key, JSON.stringify(file, null, 2));
16918
+ return runs.map((r) => r.runId);
16919
+ }
16920
+ /**
16921
+ * Self-bounding prune (issue #885): delete `{runId}.jsonl` streams that
16922
+ * have fallen out of the index window so the `deployments/` prefix does
16923
+ * not grow without bound. Best-effort — runs inside the same write-chain
16924
+ * link as the flush + index write, so a failure here is caught + warned
16925
+ * once by {@link enqueueWrite} and never blocks the deploy / destroy.
16926
+ *
16927
+ * Concurrency-tolerant by construction: the cutoff is the OLDEST retained
16928
+ * run id, and only `.jsonl` files strictly below it are deleted. Run ids
16929
+ * are time-sortable, so a concurrent NEWER run's id sorts ABOVE every
16930
+ * retained id and is never touched. A concurrent run that STARTED earlier
16931
+ * but is still writing could in theory fall below the cutoff if 20+ newer
16932
+ * runs finalized while it ran — an extreme edge that self-heals anyway,
16933
+ * since the whole JSONL body is re-PUT on that run's next flush / finalize
16934
+ * (S3 has no append; each flush rewrites the full stream).
16935
+ */
16936
+ async pruneSupersededRunFiles(keptRunIds) {
16937
+ if (keptRunIds.length < 20) return;
16938
+ const cutoff = keptRunIds.reduce((min, id) => id < min ? id : min, keptRunIds[0]);
16939
+ const dirPrefix = deploymentsDirPrefix(this.backend.prefix, this.stackName, this.region);
16940
+ const stale = (await this.backend.listRawKeys(dirPrefix)).filter((k) => {
16941
+ const runId = runIdFromJsonlKey(k, dirPrefix);
16942
+ return runId !== null && runId < cutoff;
16943
+ });
16944
+ if (stale.length === 0) return;
16945
+ await this.backend.deleteRawObjects(stale);
16946
+ this.logger.debug(`Pruned ${stale.length} superseded deployment-event stream(s) for ${this.stackName} (${this.region})`);
16947
+ }
16948
+ warnOnce(message) {
16949
+ if (this.warnedOnce) {
16950
+ this.logger.debug(message);
16951
+ return;
16952
+ }
16953
+ this.warnedOnce = true;
16954
+ this.logger.warn(`${message} — continuing; deployment events are best-effort and never block the run.`);
16955
+ }
16956
+ };
16957
+ /**
16958
+ * Read side for `cdkd events`: discovers regions / runs / event streams
16959
+ * under `{prefix}/{stackName}/{region}/deployments/`. Region discovery
16960
+ * deliberately does NOT rely on state.json — event files survive
16961
+ * `cdkd destroy`, so a destroyed stack's runs stay readable.
16962
+ */
16963
+ var DeploymentEventsReader = class {
16964
+ backend;
16965
+ constructor(backend) {
16966
+ this.backend = backend;
16967
+ }
16968
+ /**
16969
+ * Regions that have a `deployments/` index or event stream for the
16970
+ * stack. Derived from the raw key listing under `{prefix}/{stackName}/`.
16971
+ */
16972
+ async listRegions(stackName) {
16973
+ const prefix = `${this.backend.prefix}/${stackName}/`;
16974
+ const keys = await this.backend.listRawKeys(prefix);
16975
+ const regions = /* @__PURE__ */ new Set();
16976
+ for (const key of keys) {
16977
+ const segments = key.slice(prefix.length).split("/");
16978
+ if (segments.length === 3 && segments[1] === "deployments" && segments[0]) regions.add(segments[0]);
16979
+ }
16980
+ return [...regions].sort();
16981
+ }
16982
+ /**
16983
+ * Run summaries for `(stackName, region)`, newest first. Returns the
16984
+ * index file's `runs` (already newest-first); when the index is missing
16985
+ * or unreadable, falls back to enumerating `{runId}.jsonl` keys (sorted
16986
+ * descending — runIds are time-prefixed).
16987
+ *
16988
+ * In the fallback path the run's terminal result / command / version are
16989
+ * NOT known from the key alone, so each run's JSONL is read and its last
16990
+ * `RUN_FINISHED` (+ first `RUN_STARTED`) event mined for the true result,
16991
+ * command, version, and timestamps. A run whose JSONL has no terminal
16992
+ * `RUN_FINISHED` (interrupted run, or one whose index write lost the
16993
+ * race) is reported as `'UNKNOWN'` — it is NEVER fabricated as `'FAILED'`,
16994
+ * which would mislabel a successful run that merely failed to update the
16995
+ * derived index.
16996
+ */
16997
+ async listRuns(stackName, region) {
16998
+ const key = deploymentEventsIndexKey(this.backend.prefix, stackName, region);
16999
+ try {
17000
+ const raw = await this.backend.getRawObject(key);
17001
+ if (raw !== null) {
17002
+ const parsed = JSON.parse(raw);
17003
+ if (Array.isArray(parsed.runs)) return parsed.runs;
17004
+ }
17005
+ } catch {}
17006
+ const dirPrefix = `${this.backend.prefix}/${stackName}/${region}/deployments/`;
17007
+ const runIds = (await this.backend.listRawKeys(dirPrefix)).filter((k) => k.endsWith(".jsonl")).map((k) => k.slice(dirPrefix.length, -6)).sort().reverse();
17008
+ return Promise.all(runIds.map((runId) => this.summarizeRunFromJsonl(stackName, region, runId)));
17009
+ }
17010
+ /**
17011
+ * Reconstruct a {@link DeploymentRunSummary} for the index-fallback path
17012
+ * by reading the run's JSONL. Derives the true result from the last
17013
+ * `RUN_FINISHED` event; absent that, returns `'UNKNOWN'` rather than
17014
+ * fabricating a definitive failure.
17015
+ */
17016
+ async summarizeRunFromJsonl(stackName, region, runId) {
17017
+ const fallback = {
17018
+ runId,
17019
+ command: "deploy",
17020
+ cdkdVersion: "unknown",
17021
+ startedAt: "",
17022
+ finishedAt: "",
17023
+ result: "UNKNOWN",
17024
+ eventCount: 0
17025
+ };
17026
+ let events = null;
17027
+ try {
17028
+ events = await this.readRunEvents(stackName, region, runId);
17029
+ } catch {
17030
+ return fallback;
17031
+ }
17032
+ if (events === null || events.length === 0) return fallback;
17033
+ const started = events.find((e) => e.eventType === "RUN_STARTED");
17034
+ let finished;
17035
+ for (const e of events) if (e.eventType === "RUN_FINISHED") finished = e;
17036
+ return {
17037
+ runId,
17038
+ command: started?.command ?? fallback.command,
17039
+ cdkdVersion: started?.cdkdVersion ?? fallback.cdkdVersion,
17040
+ startedAt: started?.timestamp ?? fallback.startedAt,
17041
+ finishedAt: finished?.timestamp ?? fallback.finishedAt,
17042
+ result: finished?.result ?? "UNKNOWN",
17043
+ eventCount: events.length
17044
+ };
17045
+ }
17046
+ /**
17047
+ * Parse one run's JSONL event stream. Returns `null` when the run file
17048
+ * does not exist. Malformed lines are skipped (a torn final line from
17049
+ * an interrupted flush must not hide the rest of the stream).
17050
+ */
17051
+ async readRunEvents(stackName, region, runId) {
17052
+ const key = deploymentEventsKey(this.backend.prefix, stackName, region, runId);
17053
+ const raw = await this.backend.getRawObject(key);
17054
+ if (raw === null) return null;
17055
+ const events = [];
17056
+ for (const line of raw.split("\n")) {
17057
+ const trimmed = line.trim();
17058
+ if (!trimmed) continue;
17059
+ try {
17060
+ events.push(JSON.parse(trimmed));
17061
+ } catch {}
17062
+ }
17063
+ return events;
17064
+ }
17065
+ /**
17066
+ * Prune a stack's recorded deployment-event runs (issue #885 — `cdkd
17067
+ * events prune`). Deletes `{runId}.jsonl` streams beyond a retention
17068
+ * window and rewrites (or removes) `index.json` to match.
17069
+ *
17070
+ * Retention semantics (see {@link DeploymentEventsPruneOptions}):
17071
+ * - `all` — delete every run + the index (full purge).
17072
+ * - `keep N` — retain the newest N runs, delete the rest.
17073
+ * - `olderThanMs`— delete runs whose run-id timestamp is older than the
17074
+ * cutoff; a run id with no parseable timestamp is kept.
17075
+ * - `keep` + `olderThanMs` — a run is deleted only when it is BOTH
17076
+ * beyond the newest-N window AND older than the cutoff
17077
+ * (the most conservative combination).
17078
+ * - none of the above — defaults to `keep DEPLOYMENT_EVENTS_MAX_INDEX_RUNS`
17079
+ * (matches the index window the writer self-bounds to).
17080
+ *
17081
+ * Unlike the writer's best-effort auto-prune, this is user-initiated and
17082
+ * surfaces errors to the caller (the command reports / exits non-zero).
17083
+ */
17084
+ async pruneRuns(stackName, region, opts = {}) {
17085
+ const dirPrefix = deploymentsDirPrefix(this.backend.prefix, stackName, region);
17086
+ const runIdsDesc = (await this.backend.listRawKeys(dirPrefix)).map((k) => runIdFromJsonlKey(k, dirPrefix)).filter((id) => id !== null).sort().reverse();
17087
+ const indexKey = deploymentEventsIndexKey(this.backend.prefix, stackName, region);
17088
+ if (opts.all === true) {
17089
+ const toDelete = runIdsDesc.map((id) => deploymentEventsKey(this.backend.prefix, stackName, region, id));
17090
+ await this.backend.deleteRawObjects([...toDelete, indexKey]);
17091
+ return {
17092
+ deletedRunIds: runIdsDesc,
17093
+ remainingRunIds: [],
17094
+ indexDeleted: true
17095
+ };
17096
+ }
17097
+ const noGuards = opts.keep === void 0 && opts.olderThanMs === void 0;
17098
+ const keep = opts.keep ?? (noGuards ? 20 : void 0);
17099
+ const protectedByCount = keep === void 0 ? /* @__PURE__ */ new Set() : new Set(runIdsDesc.slice(0, keep));
17100
+ let candidates = runIdsDesc.filter((id) => !protectedByCount.has(id));
17101
+ if (opts.olderThanMs !== void 0) {
17102
+ const cutoff = (opts.now ?? /* @__PURE__ */ new Date()).getTime() - opts.olderThanMs;
17103
+ candidates = candidates.filter((id) => {
17104
+ const t = runIdTimestampMs(id);
17105
+ return t !== null && t < cutoff;
17106
+ });
17107
+ }
17108
+ if (candidates.length === 0) return {
17109
+ deletedRunIds: [],
17110
+ remainingRunIds: runIdsDesc,
17111
+ indexDeleted: false
17112
+ };
17113
+ const deletedSet = new Set(candidates);
17114
+ const deleteKeys = candidates.map((id) => deploymentEventsKey(this.backend.prefix, stackName, region, id));
17115
+ const remainingRunIds = runIdsDesc.filter((id) => !deletedSet.has(id));
17116
+ const indexDeleted = await this.rewriteIndexAfterPrune(indexKey, stackName, region, deletedSet, remainingRunIds.length === 0);
17117
+ await this.backend.deleteRawObjects(deleteKeys);
17118
+ return {
17119
+ deletedRunIds: candidates,
17120
+ remainingRunIds,
17121
+ indexDeleted
17122
+ };
17123
+ }
17124
+ /**
17125
+ * Drop the pruned run ids from `index.json`, or delete the index entirely
17126
+ * when no `.jsonl` streams remain. A corrupt / unreadable index is left
17127
+ * untouched (the `.jsonl` files are the source of truth; `cdkd events`
17128
+ * falls back to key enumeration). Returns whether the index was deleted.
17129
+ */
17130
+ async rewriteIndexAfterPrune(indexKey, stackName, region, deletedRunIds, noRunsRemain) {
17131
+ if (noRunsRemain) {
17132
+ await this.backend.deleteRawObjects([indexKey]);
17133
+ return true;
17134
+ }
17135
+ let raw;
17136
+ try {
17137
+ raw = await this.backend.getRawObject(indexKey);
17138
+ } catch {
17139
+ return false;
17140
+ }
17141
+ if (raw === null) return false;
17142
+ let parsed;
17143
+ try {
17144
+ parsed = JSON.parse(raw);
17145
+ } catch {
17146
+ return false;
17147
+ }
17148
+ if (!Array.isArray(parsed.runs)) return false;
17149
+ const remaining = parsed.runs.filter((r) => !deletedRunIds.has(r.runId));
17150
+ if (remaining.length === parsed.runs.length) return false;
17151
+ const file = {
17152
+ indexVersion: 1,
17153
+ stackName,
17154
+ region,
17155
+ runs: remaining,
17156
+ lastModified: Date.now()
17157
+ };
17158
+ await this.backend.putRawObject(indexKey, JSON.stringify(file, null, 2));
17159
+ return false;
17160
+ }
17161
+ };
17162
+
16287
17163
  //#endregion
16288
17164
  //#region src/deployment/deploy-engine.ts
16289
17165
  /**
@@ -16690,6 +17566,10 @@ var DeployEngine = class {
16690
17566
  const currentEtag = currentStateData?.etag;
16691
17567
  const migrationPending = currentStateData?.migrationPending ?? false;
16692
17568
  this.logger.debug(`Loaded current state: ${Object.keys(currentState.resources).length} resources`);
17569
+ try {
17570
+ const journal = await this.stateBackend.loadRollbackJournal(stackName, this.stackRegion);
17571
+ if (journal && journal.segments.length > 0) this.logger.info(`A previous deploy of '${stackName}' failed or was interrupted. Run 'cdkd rollback ${stackName}' to revert it, or continue deploying to fix forward.`);
17572
+ } catch {}
16693
17573
  this.kickOffAutoRefreshObservedProperties(currentState.resources);
16694
17574
  this.logger.debug(`Template has ${Object.keys(template.Resources || {}).length} resources`);
16695
17575
  const parameterValues = await this.resolver.resolveParameters(template, this.options.parameters);
@@ -16795,6 +17675,7 @@ var DeployEngine = class {
16795
17675
  await this.drainObservedCaptures(newState.resources);
16796
17676
  const newEtag = await this.stateBackend.saveState(stackName, this.stackRegion, this.withParentInfo(newState));
16797
17677
  this.logger.debug(`State saved (ETag: ${newEtag})`);
17678
+ await this.deleteRollbackJournalBestEffort(stackName);
16798
17679
  if (this.exportIndexStore) await this.exportIndexStore.updateForStack(stackName, this.stackRegion, newState.outputs ?? {});
16799
17680
  const durationMs = Date.now() - startTime;
16800
17681
  const unchangedCount = this.diffCalculator.filterByType(changes, "NO_CHANGE").length + actualCounts.skipped;
@@ -16955,6 +17836,7 @@ var DeployEngine = class {
16955
17836
  if (this.interrupted && this.hasPending(deleteExecutor)) throw new InterruptedError(this.interruptCause ?? "user");
16956
17837
  }
16957
17838
  } catch (error) {
17839
+ const initialDeploy = currentEtag === void 0;
16958
17840
  try {
16959
17841
  const preRollbackState = {
16960
17842
  version: 8,
@@ -16977,14 +17859,20 @@ var DeployEngine = class {
16977
17859
  } catch (saveError) {
16978
17860
  this.logger.warn(`Failed to save partial state before rollback: ${saveError instanceof Error ? saveError.message : String(saveError)}`);
16979
17861
  }
17862
+ let autoRollbackClean = false;
16980
17863
  if (error instanceof InterruptedError) {
16981
- this.logger.info(`Partial state saved (${Object.keys(newResources).length} resources). Run deploy again to resume, or destroy to clean up.`);
17864
+ await this.writeRollbackJournalSegment(stackName, completedOperations, "interrupted", initialDeploy);
17865
+ this.logger.info(`Partial state saved (${Object.keys(newResources).length} resources). Run deploy again to resume, 'cdkd rollback' to revert, or destroy to clean up.`);
16982
17866
  throw error;
16983
17867
  }
16984
17868
  if (this.options.noRollback) {
17869
+ await this.writeRollbackJournalSegment(stackName, completedOperations, "no-rollback-failure", initialDeploy);
16985
17870
  this.logger.warn("Deployment failed. --no-rollback is set, skipping rollback.");
16986
- this.logger.warn("Partial state has been saved. Manual cleanup may be required.");
16987
- } else await this.performRollback(completedOperations, newResources, stackName);
17871
+ this.logger.warn("Partial state has been saved. Run 'cdkd deploy' to resume, 'cdkd rollback' to revert, or destroy to clean up.");
17872
+ } else {
17873
+ await this.writeRollbackJournalSegment(stackName, completedOperations, "auto-rollback-started", initialDeploy);
17874
+ autoRollbackClean = (await this.performRollback(completedOperations, newResources, stackName)).failures === 0;
17875
+ }
16988
17876
  try {
16989
17877
  const postRollbackState = {
16990
17878
  version: 8,
@@ -16998,6 +17886,7 @@ var DeployEngine = class {
16998
17886
  };
16999
17887
  await this.stateBackend.saveState(stackName, this.stackRegion, this.withParentInfo(postRollbackState), { ...currentEtag !== void 0 && { expectedEtag: currentEtag } });
17000
17888
  this.logger.debug("State saved after deployment failure");
17889
+ if (autoRollbackClean) await this.deleteRollbackJournalBestEffort(stackName);
17001
17890
  } catch (saveError) {
17002
17891
  this.logger.debug(`Retrying state save after rollback (ETag mismatch): ${saveError instanceof Error ? saveError.message : String(saveError)}`);
17003
17892
  try {
@@ -17014,6 +17903,7 @@ var DeployEngine = class {
17014
17903
  };
17015
17904
  await this.stateBackend.saveState(stackName, this.stackRegion, this.withParentInfo(postRollbackState), { ...freshEtag !== void 0 && { expectedEtag: freshEtag } });
17016
17905
  this.logger.debug("State saved after deployment failure (retry succeeded)");
17906
+ if (autoRollbackClean) await this.deleteRollbackJournalBestEffort(stackName);
17017
17907
  } catch (retryError) {
17018
17908
  this.logger.warn(`Failed to save state after rollback: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
17019
17909
  }
@@ -17025,6 +17915,7 @@ var DeployEngine = class {
17025
17915
  outputs = await this.resolveOutputs(template, newResources, stackName, parameterValues, conditions);
17026
17916
  } catch (outputError) {
17027
17917
  await this.persistStateAfterOutputFailure(stackName, currentState, newResources, currentEtag, pendingMigration);
17918
+ await this.writeRollbackJournalSegment(stackName, completedOperations, "no-rollback-failure", currentEtag === void 0);
17028
17919
  throw outputError;
17029
17920
  }
17030
17921
  return {
@@ -17094,169 +17985,62 @@ var DeployEngine = class {
17094
17985
  }
17095
17986
  }
17096
17987
  /**
17097
- * Perform best-effort rollback of completed operations respecting dependencies
17098
- *
17099
- * - CREATE delete the newly created resource (in reverse dependency order)
17100
- * - UPDATE update back to previous properties
17101
- * - DELETE → cannot rollback (resource already deleted), log warning
17102
- *
17103
- * Resources completed concurrently in the dispatcher may have dependencies
17104
- * between them (e.g., IAM Policy depends on IAM Role). When rolling back
17105
- * CREATEs (deleting), dependent resources must be deleted before their
17106
- * dependencies. This method sorts CREATE rollback operations using dependency
17107
- * information from state, then processes UPDATE/DELETE rollbacks, and finally
17108
- * processes sorted CREATE rollback deletions.
17988
+ * Perform best-effort rollback of completed operations (issue #1183:
17989
+ * extracted into `rollback-executor.ts` so the standalone `cdkd rollback`
17990
+ * command drives identical semantics). Thin wrapper that builds the
17991
+ * executor context from the engine's collaborators and delegates.
17109
17992
  */
17110
17993
  async performRollback(completedOperations, stateResources, stackName) {
17111
- if (completedOperations.length === 0) {
17112
- this.logger.info("No completed operations to roll back.");
17113
- return;
17114
- }
17115
- this.logger.info(`Rolling back ${completedOperations.length} completed operation(s)...`);
17116
- this.recordEvent({
17117
- eventType: "ROLLBACK_STARTED",
17118
- stackName
17119
- });
17120
- const createOps = [];
17121
- const otherOps = [];
17122
- for (const op of completedOperations) if (op.changeType === "CREATE") createOps.push(op);
17123
- else otherOps.push(op);
17124
- for (let i = otherOps.length - 1; i >= 0; i--) {
17125
- const op = otherOps[i];
17126
- await this.performSingleRollback(op, stateResources, stackName);
17127
- }
17128
- if (createOps.length > 0) {
17129
- const sortedCreateOps = this.sortRollbackCreates(createOps, stateResources);
17130
- for (const op of sortedCreateOps) await this.performSingleRollback(op, stateResources, stackName);
17131
- }
17132
- this.logger.info("Rollback completed. Some resources may remain if deletion failed.");
17133
- this.recordEvent({
17134
- eventType: "ROLLBACK_FINISHED",
17135
- stackName
17136
- });
17994
+ const result = await replayRollback(completedOperations, stateResources, stackName, this.rollbackExecutorContext());
17995
+ return {
17996
+ failures: result.failures,
17997
+ warnings: result.warnings
17998
+ };
17137
17999
  }
17138
18000
  /**
17139
- * Sort CREATE rollback operations so that resources depending on others
17140
- * are deleted first (reverse dependency order).
17141
- *
17142
- * Uses state dependencies to determine reverse-dependency order, similar to buildDeletionDependencies.
18001
+ * Best-effort rollback-journal deletion (issue #1183) used on the deploy
18002
+ * success path and after a clean automatic rollback. Never throws — a
18003
+ * failed delete only warns (the journal is advisory; the worst case is a
18004
+ * spurious "previous deploy failed" note on the next deploy).
17143
18005
  */
17144
- sortRollbackCreates(createOps, stateResources) {
17145
- const opMap = /* @__PURE__ */ new Map();
17146
- const deleteIds = /* @__PURE__ */ new Set();
17147
- for (const op of createOps) {
17148
- opMap.set(op.logicalId, op);
17149
- deleteIds.add(op.logicalId);
17150
- }
17151
- const dependedBy = /* @__PURE__ */ new Map();
17152
- for (const id of deleteIds) if (!dependedBy.has(id)) dependedBy.set(id, /* @__PURE__ */ new Set());
17153
- for (const id of deleteIds) {
17154
- const resource = stateResources[id];
17155
- if (!resource?.dependencies) continue;
17156
- for (const dep of resource.dependencies) {
17157
- if (!deleteIds.has(dep)) continue;
17158
- if (!dependedBy.has(dep)) dependedBy.set(dep, /* @__PURE__ */ new Set());
17159
- dependedBy.get(dep).add(id);
17160
- }
17161
- }
17162
- const sorted = [];
17163
- let remaining = new Set(deleteIds);
17164
- while (remaining.size > 0) {
17165
- const level = [];
17166
- for (const id of remaining) {
17167
- const dependents = dependedBy.get(id);
17168
- if (!(dependents ? [...dependents].some((d) => remaining.has(d)) : false)) level.push(id);
17169
- }
17170
- if (level.length === 0) {
17171
- this.logger.warn(`Circular dependency detected in rollback order, processing remaining ${remaining.size} resources`);
17172
- for (const id of remaining) {
17173
- const op = opMap.get(id);
17174
- if (op) sorted.push(op);
17175
- }
17176
- break;
17177
- }
17178
- for (const id of level) {
17179
- const op = opMap.get(id);
17180
- if (op) sorted.push(op);
17181
- }
17182
- remaining = new Set([...remaining].filter((id) => !level.includes(id)));
18006
+ async deleteRollbackJournalBestEffort(stackName) {
18007
+ try {
18008
+ await this.stateBackend.deleteRollbackJournal(stackName, this.stackRegion);
18009
+ } catch (err) {
18010
+ this.logger.debug(`Failed to delete rollback journal for ${stackName}: ${err instanceof Error ? err.message : String(err)}`);
17183
18011
  }
17184
- this.logger.debug(`Rollback CREATE deletion order: ${sorted.map((op) => op.logicalId).join(" → ")}`);
17185
- return sorted;
18012
+ }
18013
+ /** Build the {@link RollbackExecutorContext} from the engine's fields. */
18014
+ rollbackExecutorContext() {
18015
+ return {
18016
+ providerRegistry: this.providerRegistry,
18017
+ region: this.stackRegion,
18018
+ logger: this.logger,
18019
+ recordEvent: (event) => this.recordEvent(event)
18020
+ };
17186
18021
  }
17187
18022
  /**
17188
- * Perform a single rollback operation (extracted for reuse)
18023
+ * Record one rollback-journal segment (issue #1183) so the failed /
18024
+ * interrupted / about-to-auto-rollback deploy can be reverted later by
18025
+ * `cdkd rollback`. Best-effort like the partial-state save, but warns
18026
+ * LOUDLY on failure — the user just lost the ability to `cdkd rollback`.
17189
18027
  */
17190
- async performSingleRollback(op, stateResources, stackName) {
18028
+ async writeRollbackJournalSegment(stackName, completedOperations, reason, initialDeploy) {
18029
+ if (completedOperations.length === 0) return;
17191
18030
  try {
17192
- switch (op.changeType) {
17193
- case "CREATE": {
17194
- if (!op.physicalId) {
17195
- this.logger.warn(` Rollback: Cannot delete ${op.logicalId} — no physical ID recorded`);
17196
- break;
17197
- }
17198
- this.logger.info(` Rollback: Deleting created resource ${op.logicalId} (${op.resourceType})`);
17199
- const { provider } = this.providerRegistry.getProviderFor({
17200
- resourceType: op.resourceType,
17201
- provisionedBy: op.provisionedBy
17202
- });
17203
- await provider.delete(op.logicalId, op.physicalId, op.resourceType, op.properties, { expectedRegion: this.stackRegion });
17204
- delete stateResources[op.logicalId];
17205
- this.logger.info(` Rollback: ${op.logicalId} deleted successfully`);
17206
- this.recordEvent({
17207
- eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
17208
- stackName,
17209
- operation: "CREATE",
17210
- logicalId: op.logicalId,
17211
- resourceType: op.resourceType,
17212
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
17213
- });
17214
- break;
17215
- }
17216
- case "UPDATE": {
17217
- if (!op.previousState) {
17218
- this.logger.warn(` Rollback: Cannot restore ${op.logicalId} — no previous state available`);
17219
- break;
17220
- }
17221
- this.logger.info(` Rollback: Restoring ${op.logicalId} (${op.resourceType}) to previous state`);
17222
- const { provider } = this.providerRegistry.getProviderFor({
17223
- resourceType: op.resourceType,
17224
- provisionedBy: op.provisionedBy
17225
- });
17226
- const currentResource = stateResources[op.logicalId];
17227
- if (!currentResource) {
17228
- this.logger.warn(` Rollback: Cannot restore ${op.logicalId} — resource not found in current state`);
17229
- break;
17230
- }
17231
- await provider.update(op.logicalId, currentResource.physicalId, op.resourceType, op.previousState.properties, currentResource.properties);
17232
- stateResources[op.logicalId] = op.previousState;
17233
- this.logger.info(` Rollback: ${op.logicalId} restored successfully`);
17234
- this.recordEvent({
17235
- eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
17236
- stackName,
17237
- operation: "UPDATE",
17238
- logicalId: op.logicalId,
17239
- resourceType: op.resourceType,
17240
- ...op.provisionedBy && { provisionedBy: op.provisionedBy }
17241
- });
17242
- break;
17243
- }
17244
- case "DELETE":
17245
- this.logger.warn(` Rollback: Cannot restore deleted resource ${op.logicalId} (${op.resourceType}) — resource has already been deleted`);
17246
- break;
17247
- }
17248
- } catch (rollbackError) {
17249
- this.logger.warn(` Rollback failed for ${op.logicalId} (${op.changeType}): ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`);
17250
- this.logger.warn(" Continuing with remaining rollback operations...");
17251
- this.recordEvent({
17252
- eventType: "ROLLBACK_RESOURCE_FAILED",
17253
- stackName,
17254
- operation: op.changeType,
17255
- logicalId: op.logicalId,
17256
- resourceType: op.resourceType,
17257
- ...op.provisionedBy && { provisionedBy: op.provisionedBy },
17258
- error: extractDeploymentEventError(rollbackError)
17259
- });
18031
+ const segment = {
18032
+ ...this.options.eventRecorder?.runId !== void 0 && { runId: this.options.eventRecorder.runId },
18033
+ timestamp: Date.now(),
18034
+ reason,
18035
+ initialDeploy,
18036
+ ...this.options.roleArn && { roleArn: this.options.roleArn },
18037
+ cdkdVersion: getCdkdVersion(),
18038
+ operations: completedOperations
18039
+ };
18040
+ await this.stateBackend.appendRollbackJournalSegment(stackName, this.stackRegion, segment);
18041
+ this.logger.debug(`Rollback journal segment written (${reason})`);
18042
+ } catch (journalError) {
18043
+ this.logger.warn(`Failed to write rollback journal: ${journalError instanceof Error ? journalError.message : String(journalError)}. 'cdkd rollback' will NOT be able to revert this deploy — use 'cdkd deploy' to resume or 'cdkd destroy' to clean up.`);
17260
18044
  }
17261
18045
  }
17262
18046
  /**
@@ -17861,5 +18645,5 @@ var DeployEngine = class {
17861
18645
  };
17862
18646
 
17863
18647
  //#endregion
17864
- export { getBootstrapMarkerKey as $, formatError as $t, refStateLookupFromResource as A, resolveBucketRegion as At, S3StateBackend as B, LocalMigrateError as Bt, findActionableSilentDrops as C, CFN_TEMPLATE_URL_LIMIT as Ct, isTerminationProtectionPropagationError as D, expectedOwnerParam as Dt, disableInstanceApiTermination as E, uploadCfnTemplate as Et, applyRoleArnIfSet as F, AssetError as Ft, WorkGraph as G, PartialFailureError as Gt, shouldRetainResource as H, LockError as Ht, DiffCalculator as I, CdkdError as It, loadPublishableAssetManifest as J, ResourceUpdateNotSupportedError as Jt, buildAssetRedirectMap as K, ProvisioningError as Kt, DagBuilder as L, ConfigError as Lt, normalizeAwsTagsToCfn as M, getAwsClients as Mt, resolveExplicitPhysicalId as N, resetAwsClients as Nt, IntrinsicFunctionResolver as O, AssemblyReader as Ot, assertRegionMatch as P, setAwsClients as Pt, ensureAssetStorage as Q, SynthesisError as Qt, TemplateParser as R, DependencyError as Rt, ProviderRegistry as S, CFN_TEMPLATE_BODY_LIMIT as St, slowCcOperationTimeoutMs as T, findLargeInlineResources as Tt, AssetPublisher as U, MissingCdkCliError as Ut, rebuildClientForBucketRegion as V, LocalStartServiceError as Vt, stringifyValue as W, NestedStackChildDirectDestroyError as Wt, AssetModeResolver as X, StackTerminationProtectionError as Xt, rewriteTemplateAssetReferences as Y, StackHasActiveImportsError as Yt, BOOTSTRAP_MARKER_PREFIX as Z, StateError as Zt, green as _, resolveSkipPrefix as _t, withRetry as a, getDockerCmd as at, IAMRoleProvider as b, resolveUseCdkBootstrapAssets as bt, computeImplicitDeleteEdges as c, AssetManifestLoader as ct, isStatefulRecreateTargetSync as d, synthesisStatusMessage as dt, isCdkdError as en, parseBootstrapMarker as et, renderStatefulReason as f, getDefaultStateBucketName as ft, gray as g, resolveCaptureObservedState as gt, cyan as h, resolveAutoAssetStorage as ht, withResourceDeadline as i, formatDockerLoginError as it, WAFv2WebACLProvider as j, AwsClients as jt, cfnRefValueFromPhysicalId as k, clearBucketRegionCache as kt, extractDeploymentEventError as l, getDockerImageBySourceHash as lt, bold as m, resolveApp as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, withErrorHandling as nn, validateContainerRepoName as nt, isRetryableTransientError as o, runDockerForeground as ot, formatResourceLine as p, getLegacyStateBucketName as pt, createAssetRedirectResolver as q, ResourceTimeoutError as qt, DeployEngine as r, __exportAll as rn, buildDockerImage as rt, IMPLICIT_DELETE_DEPENDENCIES as s, runDockerStreaming as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, normalizeAwsError as tn, validateAssetBucketName as tt, MULTI_REGION_RECREATE_BLOCKED_TYPES as u, Synthesizer as ut, red as v, resolveStateBucketWithDefault as vt, CloudControlProvider as w, MIGRATE_TMP_PREFIX as wt, collectInlinePolicyNamesManagedBySiblings as x, warnDeprecatedNoPrefixCliFlag as xt, yellow as y, resolveStateBucketWithDefaultAndSource as yt, LockManager as z, LocalInvokeBuildError as zt };
17865
- //# sourceMappingURL=deploy-engine-CtpGfN3E.js.map
18648
+ export { rewriteTemplateAssetReferences as $, StackHasActiveImportsError as $t, disableInstanceApiTermination as A, uploadCfnTemplate as At, DiffCalculator as B, CdkdError as Bt, yellow as C, resolveStateBucketWithDefaultAndSource as Ct, findActionableSilentDrops as D, CFN_TEMPLATE_URL_LIMIT as Dt, ProviderRegistry as E, CFN_TEMPLATE_BODY_LIMIT as Et, WAFv2WebACLProvider as F, AwsClients as Ft, rebuildClientForBucketRegion as G, LocalStartServiceError as Gt, TemplateParser as H, DependencyError as Ht, normalizeAwsTagsToCfn as I, getAwsClients as It, stringifyValue as J, NestedStackChildDirectDestroyError as Jt, shouldRetainResource as K, LockError as Kt, resolveExplicitPhysicalId as L, resetAwsClients as Lt, IntrinsicFunctionResolver as M, AssemblyReader as Mt, cfnRefValueFromPhysicalId as N, clearBucketRegionCache as Nt, CloudControlProvider as O, MIGRATE_TMP_PREFIX as Ot, refStateLookupFromResource as P, resolveBucketRegion as Pt, loadPublishableAssetManifest as Q, ResourceUpdateNotSupportedError as Qt, assertRegionMatch as R, setAwsClients as Rt, red as S, resolveStateBucketWithDefault as St, collectInlinePolicyNamesManagedBySiblings as T, warnDeprecatedNoPrefixCliFlag as Tt, LockManager as U, LocalInvokeBuildError as Ut, DagBuilder as V, ConfigError as Vt, S3StateBackend as W, LocalMigrateError as Wt, buildAssetRedirectMap as X, ProvisioningError as Xt, WorkGraph as Y, PartialFailureError as Yt, createAssetRedirectResolver as Z, ResourceTimeoutError as Zt, formatResourceLine as _, getLegacyStateBucketName as _t, DeploymentEventsStore as a, normalizeAwsError as an, validateAssetBucketName as at, gray as b, resolveCaptureObservedState as bt, withResourceDeadline as c, formatDockerLoginError as ct, IMPLICIT_DELETE_DEPENDENCIES as d, runDockerStreaming as dt, StackTerminationProtectionError as en, AssetModeResolver as et, computeImplicitDeleteEdges as f, AssetManifestLoader as ft, renderStatefulReason as g, getDefaultStateBucketName as gt, isStatefulRecreateTargetSync as h, synthesisStatusMessage as ht, DeploymentEventsReader as i, isCdkdError as in, parseBootstrapMarker as it, isTerminationProtectionPropagationError as j, expectedOwnerParam as jt, slowCcOperationTimeoutMs as k, findLargeInlineResources as kt, withRetry as l, getDockerCmd as lt, MULTI_REGION_RECREATE_BLOCKED_TYPES as m, Synthesizer as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, SynthesisError as nn, ensureAssetStorage as nt, planRollback as o, withErrorHandling as on, validateContainerRepoName as ot, extractDeploymentEventError as p, getDockerImageBySourceHash as pt, AssetPublisher as q, MissingCdkCliError as qt, DeployEngine as r, formatError as rn, getBootstrapMarkerKey as rt, replayRollback as s, __exportAll as sn, buildDockerImage as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, StateError as tn, BOOTSTRAP_MARKER_PREFIX as tt, isRetryableTransientError as u, runDockerForeground as ut, bold as v, resolveApp as vt, IAMRoleProvider as w, resolveUseCdkBootstrapAssets as wt, green as x, resolveSkipPrefix as xt, cyan as y, resolveAutoAssetStorage as yt, applyRoleArnIfSet as z, AssetError as zt };
18649
+ //# sourceMappingURL=deploy-engine-BbNhlr7X.js.map