@go-to-k/cdkd 0.237.0 → 0.238.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/README.md CHANGED
@@ -243,6 +243,7 @@ cdkd publish-assets # synth + upload only (typical CI split)
243
243
  cdkd destroy MyStack
244
244
  cdkd orphan MyStack/MyBucket # drop one resource from state (AWS resource stays)
245
245
  cdkd force-unlock MyStack # clear stale lock from an interrupted deploy
246
+ cdkd gc --dry-run # reclaim unreferenced cdkd-owned assets (S3 + ECR)
246
247
 
247
248
  # Migrate between cdkd and CloudFormation
248
249
  cdkd import MyStack --yes # adopt existing AWS resources into cdkd state
package/dist/cli.js CHANGED
@@ -35,7 +35,7 @@ import { AssociateVPCWithHostedZoneCommand, ChangeResourceRecordSetsCommand, Cha
35
35
  import { AddTagsCommand, CreateListenerCommand, CreateLoadBalancerCommand, CreateTargetGroupCommand, DeleteListenerCommand, DeleteLoadBalancerCommand, DeleteTargetGroupCommand, DescribeListenerAttributesCommand, DescribeListenersCommand, DescribeLoadBalancerAttributesCommand, DescribeLoadBalancersCommand, DescribeTagsCommand, DescribeTargetGroupsCommand, ElasticLoadBalancingV2Client, ModifyListenerAttributesCommand, ModifyListenerCommand, ModifyLoadBalancerAttributesCommand, ModifyTargetGroupCommand, RemoveTagsCommand, SetIpAddressTypeCommand, SetSecurityGroupsCommand, SetSubnetsCommand } from "@aws-sdk/client-elastic-load-balancing-v2";
36
36
  import { CreateAliasCommand, CreateKeyCommand, DeleteAliasCommand, DescribeKeyCommand, DisableKeyCommand, DisableKeyRotationCommand, EnableKeyCommand, EnableKeyRotationCommand, GetKeyPolicyCommand, GetKeyRotationStatusCommand, KMSClient, ListAliasesCommand, ListKeysCommand, ListResourceTagsCommand, NotFoundException as NotFoundException$2, PutKeyPolicyCommand, ScheduleKeyDeletionCommand, TagResourceCommand as TagResourceCommand$9, UntagResourceCommand as UntagResourceCommand$9, UpdateAliasCommand, UpdateKeyDescriptionCommand } from "@aws-sdk/client-kms";
37
37
  import { join as join$1 } from "path";
38
- import { CreateRepositoryCommand, DeleteLifecyclePolicyCommand, DeleteRepositoryCommand, DeleteRepositoryPolicyCommand, DescribeRepositoriesCommand, ECRClient, GetAuthorizationTokenCommand, GetLifecyclePolicyCommand, LifecyclePolicyNotFoundException, ListTagsForResourceCommand as ListTagsForResourceCommand$7, PutImageScanningConfigurationCommand, PutImageTagMutabilityCommand, PutLifecyclePolicyCommand, RepositoryNotFoundException, SetRepositoryPolicyCommand, TagResourceCommand as TagResourceCommand$10, UntagResourceCommand as UntagResourceCommand$10 } from "@aws-sdk/client-ecr";
38
+ import { BatchDeleteImageCommand, CreateRepositoryCommand, DeleteLifecyclePolicyCommand, DeleteRepositoryCommand, DeleteRepositoryPolicyCommand, DescribeImagesCommand as DescribeImagesCommand$1, DescribeRepositoriesCommand, ECRClient, GetAuthorizationTokenCommand, GetLifecyclePolicyCommand, LifecyclePolicyNotFoundException, ListTagsForResourceCommand as ListTagsForResourceCommand$7, PutImageScanningConfigurationCommand, PutImageTagMutabilityCommand, PutLifecyclePolicyCommand, RepositoryNotFoundException, SetRepositoryPolicyCommand, TagResourceCommand as TagResourceCommand$10, UntagResourceCommand as UntagResourceCommand$10 } from "@aws-sdk/client-ecr";
39
39
  import graphlib from "graphlib";
40
40
  import { AddTagsToResourceCommand as AddTagsToResourceCommand$1, CreateDBClusterCommand, CreateDBInstanceCommand, CreateDBProxyCommand, CreateDBProxyEndpointCommand, CreateDBSubnetGroupCommand, DBProxyEndpointNotFoundFault, DBProxyNotFoundFault, DBProxyTargetGroupNotFoundFault, DBProxyTargetNotFoundFault, DeleteDBClusterCommand, DeleteDBInstanceCommand, DeleteDBProxyCommand, DeleteDBProxyEndpointCommand, DeleteDBSubnetGroupCommand, DeregisterDBProxyTargetsCommand, DescribeDBClustersCommand, DescribeDBInstancesCommand, DescribeDBProxiesCommand, DescribeDBProxyEndpointsCommand, DescribeDBProxyTargetGroupsCommand, DescribeDBProxyTargetsCommand, DescribeDBSubnetGroupsCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$8, ModifyDBClusterCommand, ModifyDBInstanceCommand, ModifyDBProxyCommand, ModifyDBProxyEndpointCommand, ModifyDBProxyTargetGroupCommand, ModifyDBSubnetGroupCommand, RDSClient, RegisterDBProxyTargetsCommand, RemoveTagsFromResourceCommand as RemoveTagsFromResourceCommand$1 } from "@aws-sdk/client-rds";
41
41
  import { AddTagsToResourceCommand as AddTagsToResourceCommand$2, CreateCacheClusterCommand, CreateCacheSubnetGroupCommand, DeleteCacheClusterCommand, DeleteCacheSubnetGroupCommand, DescribeCacheClustersCommand, DescribeCacheSubnetGroupsCommand, ElastiCacheClient, ListTagsForResourceCommand as ListTagsForResourceCommand$9, ModifyCacheClusterCommand, ModifyCacheSubnetGroupCommand, RemoveTagsFromResourceCommand as RemoveTagsFromResourceCommand$2 } from "@aws-sdk/client-elasticache";
@@ -610,6 +610,63 @@ const destroyOptions = [
610
610
  allowUnsupportedTypesOption
611
611
  ];
612
612
 
613
+ //#endregion
614
+ //#region src/cli/commands/state-file-keys.ts
615
+ /**
616
+ * Shared helpers for enumerating and describing cdkd state-bucket keys.
617
+ *
618
+ * Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
619
+ * (issue #1012) reuses the exact same whole-bucket state-file discovery
620
+ * instead of duplicating it — the "scan the WHOLE bucket, not just the
621
+ * default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
622
+ * and must not drift between the two commands.
623
+ */
624
+ /**
625
+ * Every cdkd state file ends with this suffix, regardless of the
626
+ * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
627
+ * or legacy `{prefix}/{stack}/state.json`).
628
+ */
629
+ const STATE_FILE_SUFFIX = "/state.json";
630
+ /**
631
+ * Every cdkd stack lock ends with this suffix — the lock lives next to the
632
+ * state file (`{prefix}/{stack}/{region}/lock.json`).
633
+ */
634
+ const LOCK_FILE_SUFFIX = "/lock.json";
635
+ /** `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment. */
636
+ const REGION_SEGMENT = /^[a-z]{2}(-[a-z]+)+-\d+$/;
637
+ /**
638
+ * List every state file in the bucket — the WHOLE bucket, not just the
639
+ * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
640
+ * stack state may exist under ANY prefix in this bucket; scoping this
641
+ * listing to the default prefix would let reference scans and teardown
642
+ * guards silently miss those stacks and delete live data.
643
+ */
644
+ async function listAllStateKeys(stateBackend) {
645
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
646
+ }
647
+ /**
648
+ * List every stack lock file in the bucket — same whole-bucket rule as
649
+ * {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
650
+ * destroy may be in flight for that stack.
651
+ */
652
+ async function listAllLockKeys(stateBackend) {
653
+ return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
654
+ }
655
+ /**
656
+ * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
657
+ * `{prefix}/{stack}/state.json` → `stack`. The prefix is arbitrary (custom
658
+ * `--state-prefix` values included), so the stack/region pair is derived
659
+ * from the key's TAIL: the last segment is treated as a region only when
660
+ * it is region-shaped. Pass {@link LOCK_FILE_SUFFIX} to describe lock keys.
661
+ */
662
+ function describeStateKey(key, suffix = STATE_FILE_SUFFIX) {
663
+ const segments = key.slice(0, -suffix.length).split("/");
664
+ const last = segments[segments.length - 1] ?? key;
665
+ const secondLast = segments[segments.length - 2];
666
+ if (secondLast !== void 0 && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
667
+ return last;
668
+ }
669
+
613
670
  //#endregion
614
671
  //#region src/cli/commands/bootstrap-destroy.ts
615
672
  /**
@@ -638,25 +695,6 @@ const destroyOptions = [
638
695
  /** S3 key prefix for state files — same fixed value the create side uses. */
639
696
  const STATE_PREFIX = "cdkd";
640
697
  /**
641
- * Every cdkd state file ends with this suffix, regardless of the
642
- * `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
643
- * or legacy `{prefix}/{stack}/state.json`).
644
- */
645
- const STATE_FILE_SUFFIX = "/state.json";
646
- /** `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment. */
647
- const REGION_SEGMENT = /^[a-z]{2}(-[a-z]+)+-\d+$/;
648
- /**
649
- * List every state file in the bucket — the WHOLE bucket, not just the
650
- * default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
651
- * stack state may exist under ANY prefix in this bucket; scoping this
652
- * listing to the default prefix would let the reference scan and the
653
- * `--include-state-bucket` guard silently miss those stacks and delete
654
- * live state.
655
- */
656
- async function listAllStateKeys(stateBackend) {
657
- return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
658
- }
659
- /**
660
698
  * Interactive confirmation for the teardown. Follows the repo's
661
699
  * destructive-prompt convention (see `recreate-confirm-prompt.ts`, the
662
700
  * same pattern family as `confirm-prompt.ts`): print the deletion plan as
@@ -709,20 +747,6 @@ async function scanStateReferences(stateBackend, names) {
709
747
  return referencing;
710
748
  }
711
749
  /**
712
- * `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
713
- * `{prefix}/{stack}/state.json` → `stack`. The prefix is arbitrary (custom
714
- * `--state-prefix` values included), so the stack/region pair is derived
715
- * from the key's TAIL: the last segment is treated as a region only when
716
- * it is region-shaped.
717
- */
718
- function describeStateKey(key) {
719
- const segments = key.slice(0, -11).split("/");
720
- const last = segments[segments.length - 1] ?? key;
721
- const secondLast = segments[segments.length - 2];
722
- if (secondLast !== void 0 && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
723
- return last;
724
- }
725
- /**
726
750
  * Empty (all versions + delete markers) and delete an S3 bucket, with
727
751
  * `ExpectedBucketOwner` pinned on every call — deleting a foreign bucket
728
752
  * that squatted the predictable name would be catastrophic, so the same
@@ -1861,7 +1885,7 @@ const FLUSH_INTERVAL_MS = 2e3;
1861
1885
  const FLUSH_EVENT_THRESHOLD = 50;
1862
1886
  /** Build-time cdkd version, with a dev fallback for non-built contexts. */
1863
1887
  function getCdkdVersion() {
1864
- return "0.237.0";
1888
+ return "0.238.0";
1865
1889
  }
1866
1890
  /**
1867
1891
  * Generate a time-sortable unique run id, e.g.
@@ -41215,6 +41239,495 @@ function createEventsPruneCommand() {
41215
41239
  })).option("--older-than <duration>", "Delete runs older than this duration (e.g. 24h, 90m)").option("--all", "Delete every recorded run and the index (full purge)", false).action(withErrorHandling((stack, _options, command) => eventsPruneCommand(stack, command.optsWithGlobals())));
41216
41240
  }
41217
41241
 
41242
+ //#endregion
41243
+ //#region src/cli/commands/gc.ts
41244
+ /**
41245
+ * `cdkd gc` — garbage-collect unreferenced objects / images from the
41246
+ * cdkd-owned asset storage of ONE region (issue #1012).
41247
+ *
41248
+ * cdkd-owned asset storage (issue #1002) is content-addressed and never
41249
+ * deleted on `cdkd destroy` (another stack or a future rollback may
41250
+ * reference the same hash), so the asset bucket / container-asset ECR repo
41251
+ * grow without bound — and `cdk gc` cannot reach them by design. cdkd can
41252
+ * gc them PRECISELY because its state files record exactly which assets
41253
+ * are in use.
41254
+ *
41255
+ * Safety posture (this command DELETES user data — every ambiguity is
41256
+ * biased toward NOT deleting):
41257
+ *
41258
+ * - Names come from the region's bootstrap marker, never recomputed from
41259
+ * the naming convention (custom-name compatibility, issue #1011). No
41260
+ * marker → the region is not opted in; friendly no-op. CDK bootstrap
41261
+ * storage (`cdk-hnb659fds-*`) is never touched — that stays `cdk gc`'s
41262
+ * job.
41263
+ * - The reference scan lists EVERY state file in the WHOLE state bucket
41264
+ * (any `--state-prefix`), and a state file that fails to JSON-parse
41265
+ * aborts the whole run — deleting on partial knowledge is how a live
41266
+ * asset gets deleted.
41267
+ * - Any stack lock in the bucket aborts the run: a deploy in flight may
41268
+ * have published assets whose state write has not landed yet.
41269
+ * - `--older-than` (default 30d) age-guards every deletion: an object /
41270
+ * image newer than the cutoff is kept even when unreferenced (protects
41271
+ * in-flight publishes and recent rollback targets). Missing timestamps
41272
+ * are treated as "new" (kept).
41273
+ * - Every S3 call pins `ExpectedBucketOwner`; a 403 on the asset bucket is
41274
+ * a foreign-bucket refusal like the create / teardown sides.
41275
+ */
41276
+ /** Default `--older-than` when the flag is not passed. */
41277
+ const DEFAULT_OLDER_THAN_MS = 720 * 60 * 60 * 1e3;
41278
+ /** S3 `DeleteObjects` accepts at most 1,000 keys per call. */
41279
+ const S3_DELETE_BATCH_SIZE = 1e3;
41280
+ /** ECR `BatchDeleteImage` accepts at most 100 image ids per call. */
41281
+ const ECR_DELETE_BATCH_SIZE = 100;
41282
+ /**
41283
+ * Parse the `--older-than` age guard: `<n>d` (days) or `<n>h` (hours),
41284
+ * decimals allowed (`1.5d`). Zero, negative, missing-unit, and
41285
+ * unknown-unit values are rejected at parse time — a zero / negative age
41286
+ * guard would disable the in-flight-publish protection entirely.
41287
+ *
41288
+ * Kept local (rather than extending `parseDuration` in `options.ts`):
41289
+ * the deploy-side duration grammar is seconds/minutes/hours for
41290
+ * per-resource deadlines, while gc ages are naturally days — mixing `30s`
41291
+ * into an age guard invites typos that all but disable it.
41292
+ */
41293
+ function parseOlderThan(value) {
41294
+ const match = /^(\d+(?:\.\d+)?)([dh])$/.exec(value.trim());
41295
+ if (!match) throw new Error(`Invalid --older-than "${value}": expected <number>d or <number>h (e.g. 30d, 12h)`);
41296
+ const num = Number(match[1]);
41297
+ if (!Number.isFinite(num) || num <= 0) throw new Error(`Invalid --older-than "${value}": must be greater than zero`);
41298
+ const multiplier = match[2] === "d" ? 1440 * 60 * 1e3 : 3600 * 1e3;
41299
+ return Math.round(num * multiplier);
41300
+ }
41301
+ /** Escape a literal string for embedding in a RegExp. */
41302
+ function escapeRegExp(value) {
41303
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41304
+ }
41305
+ /**
41306
+ * Characters that terminate an S3 key extracted from a URL-shaped string.
41307
+ * Whitespace / quotes never appear in cdkd's content-addressed asset keys
41308
+ * (`<sha256>.zip` etc.); `?` strips query strings (pre-signed URLs).
41309
+ */
41310
+ const KEY_TERMINATORS = "[^\\s\"'?]";
41311
+ /**
41312
+ * cdkd's publishers write content-addressed keys (`<sha256>.<ext>`). A
41313
+ * second, name-independent pass collects every such token from every
41314
+ * scanned string — belt-and-braces against URL captures that ran through
41315
+ * an embedded separator (e.g. two `s3://` URIs joined by a comma yield ONE
41316
+ * over-long capture, leaving both real keys unprotected). Over-collection
41317
+ * only ever KEEPS more.
41318
+ */
41319
+ const CONTENT_HASH_KEY_RE = /\b[0-9a-f]{64}\.[A-Za-z0-9]+\b/g;
41320
+ /**
41321
+ * Try to decode a base64-looking string (e.g. `Fn::Base64`-resolved EC2 /
41322
+ * ASG UserData stored in state) so references INSIDE it are collected too —
41323
+ * an `aws s3 cp s3://<assetBucket>/<key>` in UserData is a long-lived
41324
+ * reference (every future scale-out fetches it at boot). Returns null for
41325
+ * strings that are not plausibly base64-encoded text; a binary decode is
41326
+ * rejected by the control-character check. Hex strings (valid base64
41327
+ * alphabet) decode to binary and are rejected the same way.
41328
+ */
41329
+ function tryDecodeBase64Text(value) {
41330
+ if (value.length < 24 || value.length % 4 !== 0) return null;
41331
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
41332
+ try {
41333
+ const decoded = Buffer.from(value, "base64").toString("utf8");
41334
+ if (/[\x00-\x08\x0e-\x1f\ufffd]/.test(decoded)) return null;
41335
+ return decoded;
41336
+ } catch {
41337
+ return null;
41338
+ }
41339
+ }
41340
+ /**
41341
+ * Per-(bucket, repo) reference extractors. Built once per run.
41342
+ *
41343
+ * Matched shapes (all carry the bucket / repo name verbatim):
41344
+ * - (bucket, key) location OBJECTS — any object carrying the asset bucket
41345
+ * name as a value (`{S3Bucket, S3Key}` Lambda Code, `{Bucket, Key}`
41346
+ * ApiGateway BodyS3Location / SFN DefinitionS3Location, ...) — handled
41347
+ * in the walk itself; every sibling string value is collected.
41348
+ * - `s3://<assetBucket>/<key>` URIs.
41349
+ * - `https://<assetBucket>.s3[.<region>].amazonaws.com/<key>`
41350
+ * (virtual-hosted style, region / dualstack variants included).
41351
+ * - `https://s3[.<region>].amazonaws.com/<assetBucket>/<key>` (path style).
41352
+ * - `<acct>.dkr.ecr.<region>.amazonaws.com/<containerRepo>:<tag>` and/or
41353
+ * `...@sha256:<digest>` image URIs. Account / region are matched
41354
+ * loosely on purpose: collecting a reference from another account's or
41355
+ * region's URI can only over-protect (keep more), never delete more.
41356
+ */
41357
+ function buildReferenceExtractors(marker) {
41358
+ const bucket = escapeRegExp(marker.assetBucket);
41359
+ const repo = escapeRegExp(marker.containerRepo);
41360
+ const s3UriRe = new RegExp(`s3://${bucket}/(${KEY_TERMINATORS}+)`, "g");
41361
+ const virtualHostedRe = new RegExp(`https://${bucket}\\.s3[^/\\s]*\\.amazonaws\\.com/(${KEY_TERMINATORS}+)`, "g");
41362
+ const pathStyleRe = new RegExp(`https://s3[^/\\s]*\\.amazonaws\\.com/${bucket}/(${KEY_TERMINATORS}+)`, "g");
41363
+ const ecrRe = new RegExp(`\\d{12}\\.dkr\\.ecr\\.[a-z0-9-]+\\.amazonaws\\.com/${repo}(?::([A-Za-z0-9_][A-Za-z0-9._-]{0,127}))?(?:@(sha256:[0-9a-f]{64}))?`, "g");
41364
+ const extractOnce = (value, refs) => {
41365
+ for (const re of [
41366
+ s3UriRe,
41367
+ virtualHostedRe,
41368
+ pathStyleRe
41369
+ ]) for (const match of value.matchAll(re)) if (match[1]) refs.s3Keys.add(match[1]);
41370
+ for (const match of value.matchAll(ecrRe)) {
41371
+ if (match[1]) refs.imageTags.add(match[1]);
41372
+ if (match[2]) refs.imageDigests.add(match[2]);
41373
+ }
41374
+ for (const match of value.matchAll(CONTENT_HASH_KEY_RE)) refs.s3Keys.add(match[0]);
41375
+ };
41376
+ return { extractFromString(value, refs) {
41377
+ extractOnce(value, refs);
41378
+ const decoded = tryDecodeBase64Text(value);
41379
+ if (decoded !== null) extractOnce(decoded, refs);
41380
+ } };
41381
+ }
41382
+ /**
41383
+ * Deep-walk a parsed state file and collect every reference to the target
41384
+ * region's asset bucket / container repo into `refs`.
41385
+ *
41386
+ * The walk covers the ENTIRE state document — a superset of the spec'd
41387
+ * `properties` / `observedProperties` / `attributes` / `outputs` fields —
41388
+ * because over-collection can only KEEP more (safe direction) and a future
41389
+ * state field carrying an asset reference is then protected automatically.
41390
+ * Unexpected value types are walked defensively (arrays / objects
41391
+ * recursed, non-strings ignored).
41392
+ */
41393
+ function collectAssetReferences(stateDocument, marker, refs) {
41394
+ const { extractFromString } = buildReferenceExtractors(marker);
41395
+ const walk = (value) => {
41396
+ if (typeof value === "string") {
41397
+ extractFromString(value, refs);
41398
+ return;
41399
+ }
41400
+ if (Array.isArray(value)) {
41401
+ for (const item of value) walk(item);
41402
+ return;
41403
+ }
41404
+ if (value !== null && typeof value === "object") {
41405
+ const values = Object.values(value);
41406
+ if (values.some((v) => v === marker.assetBucket)) {
41407
+ for (const v of values) if (typeof v === "string" && v !== marker.assetBucket) refs.s3Keys.add(v);
41408
+ }
41409
+ for (const item of values) walk(item);
41410
+ }
41411
+ };
41412
+ walk(stateDocument);
41413
+ }
41414
+ /**
41415
+ * Scan every state file in the state bucket and collect the referenced
41416
+ * asset keys / image tags / digests for the marker's bucket + repo.
41417
+ *
41418
+ * Fail safe: a state file that fails to JSON-parse aborts the whole run —
41419
+ * a reference we could not read is a reference we would otherwise delete.
41420
+ * A state key that disappeared between the listing and the read (destroy
41421
+ * completed concurrently) is skipped: its references are legitimately gone.
41422
+ */
41423
+ async function scanReferencedAssets(stateBackend, marker, logger) {
41424
+ const refs = {
41425
+ s3Keys: /* @__PURE__ */ new Set(),
41426
+ imageTags: /* @__PURE__ */ new Set(),
41427
+ imageDigests: /* @__PURE__ */ new Set()
41428
+ };
41429
+ const stateKeys = await listAllStateKeys(stateBackend);
41430
+ logger.info(`Scanning ${stateKeys.length} state file(s) for asset references...`);
41431
+ for (const key of stateKeys) {
41432
+ const body = await stateBackend.getRawObject(key);
41433
+ if (body === null) {
41434
+ logger.debug(`State file ${key} disappeared during the scan — skipping`);
41435
+ continue;
41436
+ }
41437
+ let parsed;
41438
+ try {
41439
+ parsed = JSON.parse(body);
41440
+ } catch (error) {
41441
+ const described = describeStateKey(key);
41442
+ const regionMatch = /^(\S+) \((\S+)\)$/.exec(described);
41443
+ throw new CdkdError(`State file '${key}' is not valid JSON — aborting: gc must know every referenced asset before deleting anything, and this file's references are unreadable. Repair or remove the corrupt state file ('${regionMatch ? `cdkd state show ${regionMatch[1]} --stack-region ${regionMatch[2]}` : `cdkd state show ${described}`}' to inspect), then re-run.`, "GC_STATE_UNREADABLE", error);
41444
+ }
41445
+ collectAssetReferences(parsed, marker, refs);
41446
+ }
41447
+ logger.debug(`Referenced: ${refs.s3Keys.size} S3 key(s), ${refs.imageTags.size} image tag(s), ${refs.imageDigests.size} image digest(s)`);
41448
+ return refs;
41449
+ }
41450
+ /**
41451
+ * List the asset bucket (paginated, `ExpectedBucketOwner`) and pick the
41452
+ * deletion candidates: keys NOT referenced AND strictly older than the
41453
+ * cutoff. Objects with no `LastModified` are kept (treated as new).
41454
+ *
41455
+ * A missing bucket is an idempotent skip (nothing to gc there); a 403 is
41456
+ * a foreign-bucket refusal, mirroring the create / teardown sides.
41457
+ */
41458
+ async function listS3Candidates(s3Client, bucket, accountId, refs, cutoffMs, logger) {
41459
+ const candidates = [];
41460
+ let continuationToken;
41461
+ try {
41462
+ do {
41463
+ const response = await s3Client.send(new ListObjectsV2Command({
41464
+ Bucket: bucket,
41465
+ ExpectedBucketOwner: accountId,
41466
+ ...continuationToken && { ContinuationToken: continuationToken }
41467
+ }));
41468
+ for (const obj of response.Contents ?? []) {
41469
+ if (!obj.Key) continue;
41470
+ if (refs.s3Keys.has(obj.Key)) continue;
41471
+ if (!obj.LastModified || obj.LastModified.getTime() >= cutoffMs) continue;
41472
+ candidates.push({
41473
+ key: obj.Key,
41474
+ size: obj.Size ?? 0,
41475
+ lastModified: obj.LastModified
41476
+ });
41477
+ }
41478
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : void 0;
41479
+ } while (continuationToken);
41480
+ } catch (error) {
41481
+ const err = error;
41482
+ if (err.name === "NoSuchBucket" || err.name === "NotFound") {
41483
+ logger.info(`Asset bucket ${bucket} does not exist — skipping`);
41484
+ return [];
41485
+ }
41486
+ if (err.$metadata?.httpStatusCode === 403) throw new CdkdError(`Asset bucket '${bucket}' exists but is not owned by account ${accountId} (or access is denied). Refusing to touch it.`, "ASSET_STORAGE_FOREIGN_BUCKET", error);
41487
+ throw normalizeAwsError(error, {
41488
+ bucket,
41489
+ operation: "ListObjectsV2"
41490
+ });
41491
+ }
41492
+ return candidates;
41493
+ }
41494
+ /**
41495
+ * Describe the container repo's images (paginated) and pick the deletion
41496
+ * candidates: an image is REFERENCED when any of its tags OR its digest is
41497
+ * in the referenced set; candidates are unreferenced AND strictly older
41498
+ * than the cutoff. Images with no `imagePushedAt` are kept (treated as
41499
+ * new). A missing repo is an idempotent skip.
41500
+ */
41501
+ async function listEcrCandidates(ecrClient, repositoryName, refs, cutoffMs, logger) {
41502
+ const candidates = [];
41503
+ let nextToken;
41504
+ try {
41505
+ do {
41506
+ const response = await ecrClient.send(new DescribeImagesCommand$1({
41507
+ repositoryName,
41508
+ ...nextToken && { nextToken }
41509
+ }));
41510
+ for (const image of response.imageDetails ?? []) {
41511
+ if (!image.imageDigest) continue;
41512
+ const tags = image.imageTags ?? [];
41513
+ if (refs.imageDigests.has(image.imageDigest) || tags.some((t) => refs.imageTags.has(t))) continue;
41514
+ if (!image.imagePushedAt || image.imagePushedAt.getTime() >= cutoffMs) continue;
41515
+ candidates.push({
41516
+ digest: image.imageDigest,
41517
+ tags,
41518
+ size: image.imageSizeInBytes ?? 0,
41519
+ pushedAt: image.imagePushedAt
41520
+ });
41521
+ }
41522
+ nextToken = response.nextToken;
41523
+ } while (nextToken);
41524
+ } catch (error) {
41525
+ if (error.name === "RepositoryNotFoundException") {
41526
+ logger.info(`Container-asset repository ${repositoryName} does not exist — skipping`);
41527
+ return [];
41528
+ }
41529
+ throw normalizeAwsError(error, { operation: "DescribeImages" });
41530
+ }
41531
+ return candidates;
41532
+ }
41533
+ /** `12345678` → `11.8 MiB` — human-readable byte count for the plan. */
41534
+ function formatBytes(bytes) {
41535
+ if (bytes < 1024) return `${bytes} B`;
41536
+ const units = [
41537
+ "KiB",
41538
+ "MiB",
41539
+ "GiB",
41540
+ "TiB"
41541
+ ];
41542
+ let value = bytes;
41543
+ let unit = "B";
41544
+ for (const u of units) {
41545
+ if (value < 1024) break;
41546
+ value /= 1024;
41547
+ unit = u;
41548
+ }
41549
+ return `${value.toFixed(1)} ${unit}`;
41550
+ }
41551
+ /** Age of a timestamp relative to now, in whole days (or hours under 1d). */
41552
+ function formatAge(date) {
41553
+ const ageMs = Date.now() - date.getTime();
41554
+ const days = Math.floor(ageMs / (1440 * 60 * 1e3));
41555
+ if (days >= 1) return `${days}d old`;
41556
+ return `${Math.max(0, Math.floor(ageMs / (3600 * 1e3)))}h old`;
41557
+ }
41558
+ /**
41559
+ * Interactive confirmation for the deletion. Follows the repo's
41560
+ * destructive-prompt convention (same pattern family as
41561
+ * `promptBootstrapDestroyConfirm`): print the plan as a WARN block,
41562
+ * `--yes` skips, a non-TTY stdin without `--yes` is a hard error (never
41563
+ * hang / never silently decline in CI), and the prompt defaults to NO.
41564
+ */
41565
+ async function promptGcConfirm(input) {
41566
+ const logger = getLogger();
41567
+ logger.warn("");
41568
+ logger.warn("cdkd gc will delete the following unreferenced assets:");
41569
+ for (const line of input.planLines) logger.warn(` - ${line}`);
41570
+ if (input.yes) return true;
41571
+ if (process.stdin.isTTY !== true) throw new CdkdError("The gc confirmation prompt cannot run in a non-interactive environment. Pass --yes / -y to confirm the deletion, or run the command from a real terminal.", "NON_INTERACTIVE_CONFIRM");
41572
+ const rl = readline.createInterface({
41573
+ input: process.stdin,
41574
+ output: process.stdout
41575
+ });
41576
+ try {
41577
+ const trimmed = (await rl.question("\nContinue? (y/N): ")).trim().toLowerCase();
41578
+ return trimmed === "y" || trimmed === "yes";
41579
+ } finally {
41580
+ rl.close();
41581
+ }
41582
+ }
41583
+ /**
41584
+ * Delete the S3 candidates via chunked `DeleteObjects` (1,000 keys per
41585
+ * call, `ExpectedBucketOwner`). Per-key `Errors` are surfaced as a hard
41586
+ * error so gc never reports success while objects remain.
41587
+ */
41588
+ async function deleteS3Candidates(s3Client, bucket, accountId, candidates) {
41589
+ const failures = [];
41590
+ for (let i = 0; i < candidates.length; i += S3_DELETE_BATCH_SIZE) {
41591
+ const chunk = candidates.slice(i, i + S3_DELETE_BATCH_SIZE);
41592
+ const response = await s3Client.send(new DeleteObjectsCommand({
41593
+ Bucket: bucket,
41594
+ ExpectedBucketOwner: accountId,
41595
+ Delete: {
41596
+ Objects: chunk.map((c) => ({ Key: c.key })),
41597
+ Quiet: true
41598
+ }
41599
+ }));
41600
+ for (const err of response.Errors ?? []) failures.push(`${err.Key ?? "<unknown>"} (${err.Code ?? "Error"}: ${err.Message ?? ""})`);
41601
+ }
41602
+ if (failures.length > 0) throw new CdkdError(`Failed to delete ${failures.length} object(s) from asset bucket '${bucket}': ` + failures.join("; "), "GC_DELETE_FAILED");
41603
+ }
41604
+ /**
41605
+ * Delete the ECR candidates via chunked `BatchDeleteImage` (100 image ids
41606
+ * per call), addressed by digest so every tag of the image goes with it.
41607
+ * Per-image `failures` are surfaced as a hard error.
41608
+ */
41609
+ async function deleteEcrCandidates(ecrClient, repositoryName, candidates) {
41610
+ const failures = [];
41611
+ for (let i = 0; i < candidates.length; i += ECR_DELETE_BATCH_SIZE) {
41612
+ const chunk = candidates.slice(i, i + ECR_DELETE_BATCH_SIZE);
41613
+ const response = await ecrClient.send(new BatchDeleteImageCommand({
41614
+ repositoryName,
41615
+ imageIds: chunk.map((c) => ({ imageDigest: c.digest }))
41616
+ }));
41617
+ for (const failure of response.failures ?? []) failures.push(`${failure.imageId?.imageDigest ?? "<unknown>"} (${failure.failureCode ?? "Error"}: ${failure.failureReason ?? ""})`);
41618
+ }
41619
+ if (failures.length > 0) throw new CdkdError(`Failed to delete ${failures.length} image(s) from repository '${repositoryName}': ` + failures.join("; "), "GC_DELETE_FAILED");
41620
+ }
41621
+ /**
41622
+ * `cdkd gc` implementation. See the module JSDoc for the safety posture.
41623
+ */
41624
+ async function gcCommand(options) {
41625
+ const logger = getLogger();
41626
+ if (options.verbose) logger.setLevel("debug");
41627
+ logger.info("Starting cdkd gc...");
41628
+ logger.debug("Options:", options);
41629
+ await applyRoleArnIfSet({
41630
+ roleArn: options.roleArn,
41631
+ region: options.region
41632
+ });
41633
+ const region = options.region || process.env["AWS_REGION"] || "us-east-1";
41634
+ const awsClients = new AwsClients({
41635
+ region,
41636
+ ...options.profile && { profile: options.profile }
41637
+ });
41638
+ setAwsClients(awsClients);
41639
+ const accountId = (await awsClients.sts.send(new GetCallerIdentityCommand({}))).Account;
41640
+ const bucketName = options.stateBucket ?? getDefaultStateBucketName(accountId);
41641
+ const markerS3Client = new S3Client({
41642
+ region,
41643
+ ...options.profile && { profile: options.profile }
41644
+ });
41645
+ const stateBackend = new S3StateBackend(markerS3Client, {
41646
+ bucket: bucketName,
41647
+ prefix: "cdkd"
41648
+ }, {
41649
+ region,
41650
+ ...options.profile && { profile: options.profile }
41651
+ });
41652
+ const ecrClient = new ECRClient({
41653
+ region,
41654
+ ...options.profile && { profile: options.profile }
41655
+ });
41656
+ try {
41657
+ const markerKey = getBootstrapMarkerKey(region);
41658
+ let markerBody;
41659
+ try {
41660
+ markerBody = await stateBackend.getRawObject(markerKey);
41661
+ } catch (error) {
41662
+ if (error.name === "NoSuchBucket") {
41663
+ logger.info(`State bucket '${bucketName}' does not exist — this account was never bootstrapped; nothing to garbage-collect.`);
41664
+ return;
41665
+ }
41666
+ throw error;
41667
+ }
41668
+ if (markerBody === null) {
41669
+ logger.info(`No bootstrap marker for region '${region}' (${markerKey}) — the region is not opted in to cdkd asset storage; nothing to garbage-collect. (CDK bootstrap storage is 'cdk gc' territory.)`);
41670
+ return;
41671
+ }
41672
+ const marker = parseBootstrapMarker(markerBody, markerKey);
41673
+ const lockKeys = await listAllLockKeys(stateBackend);
41674
+ if (lockKeys.length > 0) {
41675
+ const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX)} [${k}]`).join("\n");
41676
+ throw new CdkdError(`Refusing to gc while ${lockKeys.length} stack(s) hold an active lock (a deploy in flight may have published assets whose state write has not landed yet):\n${listing}\nWait for the operation(s) to finish — or clear a stale lock with 'cdkd force-unlock <stack>' — then re-run 'cdkd gc'.`, "GC_LOCKED");
41677
+ }
41678
+ const refs = await scanReferencedAssets(stateBackend, marker, logger);
41679
+ const cutoffMs = Date.now() - options.olderThan;
41680
+ const s3Candidates = await listS3Candidates(awsClients.s3, marker.assetBucket, accountId, refs, cutoffMs, logger);
41681
+ const ecrCandidates = await listEcrCandidates(ecrClient, marker.containerRepo, refs, cutoffMs, logger);
41682
+ if (s3Candidates.length === 0 && ecrCandidates.length === 0) {
41683
+ logger.info(`Nothing to garbage-collect in region '${region}': every object in ${marker.assetBucket} / image in ${marker.containerRepo} is either referenced by a deployed stack or newer than the --older-than cutoff.`);
41684
+ return;
41685
+ }
41686
+ const s3Bytes = s3Candidates.reduce((sum, c) => sum + c.size, 0);
41687
+ const ecrBytes = ecrCandidates.reduce((sum, c) => sum + c.size, 0);
41688
+ const planLines = [...s3Candidates.map((c) => `s3://${marker.assetBucket}/${c.key} (${formatBytes(c.size)}, ${formatAge(c.lastModified)})`), ...ecrCandidates.map((c) => `${marker.containerRepo}${c.tags.length > 0 ? `:${c.tags.join(",")}` : ""}@${c.digest} (${formatBytes(c.size)}, ${formatAge(c.pushedAt)})`)];
41689
+ const totals = `Total: ${s3Candidates.length} S3 object(s) (${formatBytes(s3Bytes)}) + ${ecrCandidates.length} ECR image(s) (${formatBytes(ecrBytes)}) = ${formatBytes(s3Bytes + ecrBytes)} reclaimable`;
41690
+ if (options.dryRun) {
41691
+ logger.info("");
41692
+ logger.info("Dry run — the following unreferenced assets would be deleted:");
41693
+ for (const line of planLines) logger.info(` - ${line}`);
41694
+ logger.info(totals);
41695
+ logger.info("Dry run: nothing deleted. Re-run without --dry-run to delete.");
41696
+ return;
41697
+ }
41698
+ if (!await promptGcConfirm({
41699
+ planLines: [...planLines, totals],
41700
+ yes: options.yes
41701
+ })) {
41702
+ logger.info("gc cancelled — nothing deleted.");
41703
+ return;
41704
+ }
41705
+ if (s3Candidates.length > 0) {
41706
+ await deleteS3Candidates(awsClients.s3, marker.assetBucket, accountId, s3Candidates);
41707
+ logger.info(`✓ Deleted ${s3Candidates.length} object(s) (${formatBytes(s3Bytes)}) from ${marker.assetBucket}`);
41708
+ }
41709
+ if (ecrCandidates.length > 0) {
41710
+ await deleteEcrCandidates(ecrClient, marker.containerRepo, ecrCandidates);
41711
+ logger.info(`✓ Deleted ${ecrCandidates.length} image(s) (${formatBytes(ecrBytes)}) from ${marker.containerRepo}`);
41712
+ }
41713
+ logger.info(`\n✓ gc completed: ${formatBytes(s3Bytes + ecrBytes)} reclaimed`);
41714
+ } finally {
41715
+ ecrClient.destroy();
41716
+ markerS3Client.destroy();
41717
+ awsClients.destroy();
41718
+ }
41719
+ }
41720
+ /**
41721
+ * Create the `cdkd gc` command (upstream `cdk gc` parity naming).
41722
+ */
41723
+ function createGcCommand() {
41724
+ const cmd = new Command("gc").description("Garbage-collect unreferenced objects / images from ONE region's cdkd-owned asset storage (asset bucket + container-asset ECR repo). References are collected from every cdkd state file; CDK bootstrap storage is never touched (use cdk gc for that).").option("--state-bucket <bucket>", "S3 bucket holding cdkd state (default: cdkd-state-{accountId})").addOption(new Option("--older-than <duration>", "Never delete an object / image newer than this age, even when unreferenced (protects in-flight publishes and recent rollback targets). Accepts <n>d / <n>h.").default(DEFAULT_OLDER_THAN_MS, "30d").argParser(parseOlderThan)).option("--dry-run", "Print the reclaim plan (per-item list + totals) without deleting", false).addOption(new Option("--region <region>", "Region whose cdkd asset storage to garbage-collect (defaults to AWS_REGION env or us-east-1)")).action(withErrorHandling(async (options) => {
41725
+ await gcCommand(options);
41726
+ }));
41727
+ commonOptions.forEach((opt) => cmd.addOption(opt));
41728
+ return cmd;
41729
+ }
41730
+
41218
41731
  //#endregion
41219
41732
  //#region src/cli/cdk-path.ts
41220
41733
  /**
@@ -56378,6 +56891,7 @@ const SUBCOMMANDS = new Set([
56378
56891
  "diff",
56379
56892
  "drift",
56380
56893
  "destroy",
56894
+ "gc",
56381
56895
  "orphan",
56382
56896
  "import",
56383
56897
  "export",
@@ -56410,7 +56924,7 @@ function reorderArgs(argv) {
56410
56924
  async function main() {
56411
56925
  installPipeCloseHandler();
56412
56926
  const program = new Command();
56413
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.237.0");
56927
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.238.0");
56414
56928
  program.addCommand(createBootstrapCommand());
56415
56929
  program.addCommand(createSynthCommand());
56416
56930
  program.addCommand(createListCommand());
@@ -56419,6 +56933,7 @@ async function main() {
56419
56933
  program.addCommand(createDriftCommand());
56420
56934
  program.addCommand(createDestroyCommand());
56421
56935
  program.addCommand(createEventsCommand());
56936
+ program.addCommand(createGcCommand());
56422
56937
  program.addCommand(createOrphanCommand());
56423
56938
  program.addCommand(createImportCommand());
56424
56939
  program.addCommand(createPublishAssetsCommand());