@go-to-k/cdkd 0.236.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 +5 -3
- package/dist/cli.js +560 -39
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-DqAIXGA_.js → deploy-engine-CX6s1TcX.js} +60 -7
- package/dist/deploy-engine-CX6s1TcX.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine-DqAIXGA_.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, f as withSkipPrefix, i as runStackBuffered, n as getLogger, o as PATTERN_B_NAME_OPTIONS, p as withStackName, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-BzO-joNR.js";
|
|
3
3
|
import { A as withErrorHandling, C as StackHasActiveImportsError, S as ResourceUpdateNotSupportedError, _ as MissingCdkCliError, a as normalizeAwsTagsToCfn, b as ProvisioningError, f as LocalInvokeBuildError$1, i as matchesCdkPath, k as normalizeAwsError, l as CdkdError, m as LocalStartServiceError, n as isTerminationProtectionPropagationError, o as resolveExplicitPhysicalId, p as LocalMigrateError, r as CDK_PATH_TAG, s as assertRegionMatch, t as disableInstanceApiTermination, v as NestedStackChildDirectDestroyError, w as StackTerminationProtectionError, x as ResourceTimeoutError, y as PartialFailureError } from "./ec2-termination-protection-q-WGWgxa.js";
|
|
4
4
|
import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-B15NAPbL.js";
|
|
5
|
-
import { $ as
|
|
5
|
+
import { $ as getDockerCmd, A as DiffCalculator, B as buildAssetRedirectMap, C as findActionableSilentDrops, Ct as resolveBucketRegion, D as refStateLookupFromResource, E as cfnRefValueFromPhysicalId, F as rebuildClientForBucketRegion, G as BOOTSTRAP_MARKER_PREFIX, H as loadPublishableAssetManifest, I as shouldRetainResource, J as parseBootstrapMarker, K as ensureAssetStorage, L as AssetPublisher, M as TemplateParser, N as LockManager, O as WAFv2WebACLProvider, P as S3StateBackend, Q as formatDockerLoginError, R as stringifyValue, S as ProviderRegistry, T as IntrinsicFunctionResolver, U as rewriteTemplateAssetReferences, V as createAssetRedirectResolver, W as AssetModeResolver, X as validateContainerRepoName, Y as validateAssetBucketName, Z as buildDockerImage, _ as green, _t as CFN_TEMPLATE_URL_LIMIT, a as withRetry, at as synthesisStatusMessage, b as IAMRoleProvider, bt as uploadCfnTemplate, c as computeImplicitDeleteEdges, ct as resolveApp, d as isStatefulRecreateTargetSync, dt as resolveSkipPrefix, et as runDockerForeground, f as renderStatefulReason, ft as resolveStateBucketWithDefault, g as gray, gt as CFN_TEMPLATE_BODY_LIMIT, h as cyan, ht as warnDeprecatedNoPrefixCliFlag, i as withResourceDeadline, it as Synthesizer, j as DagBuilder, k as applyRoleArnIfSet, l as extractDeploymentEventError, lt as resolveAutoAssetStorage, m as bold, mt as resolveUseCdkBootstrapAssets, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as AssetManifestLoader, o as isRetryableTransientError, ot as getDefaultStateBucketName, p as formatResourceLine, pt as resolveStateBucketWithDefaultAndSource, q as getBootstrapMarkerKey, r as DeployEngine, rt as getDockerImageBySourceHash, s as IMPLICIT_DELETE_DEPENDENCIES, st as getLegacyStateBucketName, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as runDockerStreaming, u as MULTI_REGION_RECREATE_BLOCKED_TYPES, ut as resolveCaptureObservedState, v as red, vt as MIGRATE_TMP_PREFIX, w as CloudControlProvider, x as collectInlinePolicyNamesManagedBySiblings, xt as AssemblyReader, y as yellow, yt as findLargeInlineResources, z as WorkGraph } from "./deploy-engine-CX6s1TcX.js";
|
|
6
6
|
import { t as expectedOwnerParam } from "./expected-bucket-owner-BjEfUnkA.js";
|
|
7
7
|
import { t as ASGProvider } from "./asg-provider-Dbl1_mwT.js";
|
|
8
8
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -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
|
|
@@ -1049,7 +1073,9 @@ async function bootstrapCommand(options) {
|
|
|
1049
1073
|
stateBackend,
|
|
1050
1074
|
accountId,
|
|
1051
1075
|
region,
|
|
1052
|
-
force: options.force
|
|
1076
|
+
force: options.force,
|
|
1077
|
+
...options.assetBucket && { assetBucketName: options.assetBucket },
|
|
1078
|
+
...options.containerRepo && { containerRepoName: options.containerRepo }
|
|
1053
1079
|
});
|
|
1054
1080
|
} finally {
|
|
1055
1081
|
ecrClient.destroy();
|
|
@@ -1076,8 +1102,9 @@ async function bootstrapCommand(options) {
|
|
|
1076
1102
|
* Create bootstrap command
|
|
1077
1103
|
*/
|
|
1078
1104
|
function createBootstrapCommand() {
|
|
1079
|
-
const cmd = new Command("bootstrap").description("Bootstrap cdkd by creating the S3 state bucket plus cdkd-owned asset storage (asset bucket + container-asset ECR repo) for the region. With --destroy, tear down the region's asset storage instead (issue #1010).").option("--state-bucket <bucket>", "Name of S3 bucket to create for state storage (default: cdkd-state-{accountId})").option("--force", "Create: force reconfiguration of an existing bucket. Destroy: delete the asset storage even when deployed stacks still reference it", false).option("--no-assets", "Skip cdkd asset storage (asset bucket / ECR repo / marker) — deploys keep publishing assets to the CDK bootstrap bucket/repo").option("--destroy", "Tear down the region's cdkd asset storage (empty + delete the asset bucket, force-delete the container-asset ECR repo, then delete the bootstrap marker last). The state bucket is kept unless --include-state-bucket is also passed", false).option("--include-state-bucket", "With --destroy: also delete the S3 state bucket. Refused while any stack state exists or any other region is still opted in to asset storage", false).addOption(new Option("--region <region>", "AWS region in which to create the state bucket (defaults to AWS_REGION env or us-east-1)")).action(withErrorHandling(async (options) => {
|
|
1105
|
+
const cmd = new Command("bootstrap").description("Bootstrap cdkd by creating the S3 state bucket plus cdkd-owned asset storage (asset bucket + container-asset ECR repo) for the region. With --destroy, tear down the region's asset storage instead (issue #1010).").option("--state-bucket <bucket>", "Name of S3 bucket to create for state storage (default: cdkd-state-{accountId})").option("--force", "Create: force reconfiguration of an existing bucket. Destroy: delete the asset storage even when deployed stacks still reference it", false).option("--no-assets", "Skip cdkd asset storage (asset bucket / ECR repo / marker) — deploys keep publishing assets to the CDK bootstrap bucket/repo").option("--asset-bucket <name>", "Custom name for the cdkd asset bucket (default: cdkd-assets-{accountId}-{region}). Written into the bootstrap marker; re-bootstrapping a region with a different name is refused").option("--container-repo <name>", "Custom name for the container-asset ECR repository (default: cdkd-container-assets-{accountId}-{region}). Written into the bootstrap marker; re-bootstrapping a region with a different name is refused").option("--destroy", "Tear down the region's cdkd asset storage (empty + delete the asset bucket, force-delete the container-asset ECR repo, then delete the bootstrap marker last). The state bucket is kept unless --include-state-bucket is also passed", false).option("--include-state-bucket", "With --destroy: also delete the S3 state bucket. Refused while any stack state exists or any other region is still opted in to asset storage", false).addOption(new Option("--region <region>", "AWS region in which to create the state bucket (defaults to AWS_REGION env or us-east-1)")).action(withErrorHandling(async (options) => {
|
|
1080
1106
|
if (options.destroy) {
|
|
1107
|
+
if (options.assetBucket !== void 0 || options.containerRepo !== void 0) throw new CdkdError("--asset-bucket / --container-repo cannot be combined with --destroy. The teardown reads the region's asset storage names from the bootstrap marker.", "INVALID_OPTIONS");
|
|
1081
1108
|
if (!options.assets) throw new CdkdError("--no-assets cannot be combined with --destroy. The teardown scope is the asset storage itself; use --include-state-bucket to also delete the state bucket.", "INVALID_OPTIONS");
|
|
1082
1109
|
await bootstrapDestroyCommand({
|
|
1083
1110
|
...options.stateBucket && { stateBucket: options.stateBucket },
|
|
@@ -1092,6 +1119,9 @@ function createBootstrapCommand() {
|
|
|
1092
1119
|
return;
|
|
1093
1120
|
}
|
|
1094
1121
|
if (options.includeStateBucket) throw new CdkdError("--include-state-bucket requires --destroy (it opts the state bucket into the teardown).", "INVALID_OPTIONS");
|
|
1122
|
+
if (!options.assets && (options.assetBucket !== void 0 || options.containerRepo !== void 0)) throw new CdkdError("--asset-bucket / --container-repo cannot be combined with --no-assets (the flags name the asset storage that --no-assets skips).", "INVALID_OPTIONS");
|
|
1123
|
+
if (options.assetBucket !== void 0) validateAssetBucketName(options.assetBucket);
|
|
1124
|
+
if (options.containerRepo !== void 0) validateContainerRepoName(options.containerRepo);
|
|
1095
1125
|
await bootstrapCommand(options);
|
|
1096
1126
|
}));
|
|
1097
1127
|
commonOptions.forEach((opt) => cmd.addOption(opt));
|
|
@@ -1855,7 +1885,7 @@ const FLUSH_INTERVAL_MS = 2e3;
|
|
|
1855
1885
|
const FLUSH_EVENT_THRESHOLD = 50;
|
|
1856
1886
|
/** Build-time cdkd version, with a dev fallback for non-built contexts. */
|
|
1857
1887
|
function getCdkdVersion() {
|
|
1858
|
-
return "0.
|
|
1888
|
+
return "0.238.0";
|
|
1859
1889
|
}
|
|
1860
1890
|
/**
|
|
1861
1891
|
* Generate a time-sortable unique run id, e.g.
|
|
@@ -41209,6 +41239,495 @@ function createEventsPruneCommand() {
|
|
|
41209
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())));
|
|
41210
41240
|
}
|
|
41211
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
|
+
|
|
41212
41731
|
//#endregion
|
|
41213
41732
|
//#region src/cli/cdk-path.ts
|
|
41214
41733
|
/**
|
|
@@ -56372,6 +56891,7 @@ const SUBCOMMANDS = new Set([
|
|
|
56372
56891
|
"diff",
|
|
56373
56892
|
"drift",
|
|
56374
56893
|
"destroy",
|
|
56894
|
+
"gc",
|
|
56375
56895
|
"orphan",
|
|
56376
56896
|
"import",
|
|
56377
56897
|
"export",
|
|
@@ -56404,7 +56924,7 @@ function reorderArgs(argv) {
|
|
|
56404
56924
|
async function main() {
|
|
56405
56925
|
installPipeCloseHandler();
|
|
56406
56926
|
const program = new Command();
|
|
56407
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
56927
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.238.0");
|
|
56408
56928
|
program.addCommand(createBootstrapCommand());
|
|
56409
56929
|
program.addCommand(createSynthCommand());
|
|
56410
56930
|
program.addCommand(createListCommand());
|
|
@@ -56413,6 +56933,7 @@ async function main() {
|
|
|
56413
56933
|
program.addCommand(createDriftCommand());
|
|
56414
56934
|
program.addCommand(createDestroyCommand());
|
|
56415
56935
|
program.addCommand(createEventsCommand());
|
|
56936
|
+
program.addCommand(createGcCommand());
|
|
56416
56937
|
program.addCommand(createOrphanCommand());
|
|
56417
56938
|
program.addCommand(createImportCommand());
|
|
56418
56939
|
program.addCommand(createPublishAssetsCommand());
|