@go-to-k/cdkd 0.284.30 → 0.284.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{asg-provider-qMgJEhCM.js → asg-provider-BVzQ7mQX.js} +2 -2
- package/dist/{asg-provider-qMgJEhCM.js.map → asg-provider-BVzQ7mQX.js.map} +1 -1
- package/dist/cli.js +787 -141
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-BFgi0RJr.js → deploy-engine-DsUQ4GxO.js} +145 -37
- package/dist/deploy-engine-DsUQ4GxO.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/deploy-engine-BFgi0RJr.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { $ as endCommandInterruptScope, $t as
|
|
2
|
+
import { $ as endCommandInterruptScope, $t as buildAssetRedirectMap, A as MULTI_REGION_RECREATE_BLOCKED_TYPES, An as resolveStateBucketWithDefaultAndSource, At as maskSecretsInError, B as collectDeclaredOutputNames, Bn as PARTITION_TABLE, Bt as DiffCalculator, C as ccRoutedFinalSnapshotError, Cn as getDefaultStateBucketName, Cr as isThrottlingError, Ct as STATE_SOURCED_CROSS_GENERATION_RULES, D as unsupportedFinalSnapshotError, Dn as resolveCaptureObservedState, Dt as dynamicReferenceTokens, E as refusesFinalSnapshot, En as resolveAutoAssetStorage, Et as createSecretMasker, F as cyan, Fn as CFN_TEMPLATE_URL_LIMIT, Ft as s3BucketDomainName, G as stateKeySecretExposure, Gt as TemplateParser, H as exportAliasCollisionScrubWarning, Hn as derivePartitionAndUrlSuffix, Ht as describeTypeWithThrottleRetry, I as gray, In as MIGRATE_TMP_PREFIX, It as s3BucketDualStackDomainName, J as clearOnUpdateRemoval, Jn as getAwsClients, Jt as rebuildClientForBucketRegion, K as IAMRoleProvider, Kn as resolveBucketRegion, Kt as LockManager, L as green, Ln as findLargeInlineResources, Lt as s3BucketRegionalDomainName, M as renderStatefulReason, Mn as stateBucketExistenceConfirmed, Mt as redactSecretsForState, N as formatResourceLine, Nn as warnDeprecatedNoPrefixCliFlag, Nt as scrubResourceRecord, O as makeCanonicalizePropertiesFn, On as resolveSkipPrefix, Ot as errorCauseChain, P as bold, Pn as CFN_TEMPLATE_BODY_LIMIT, Pt as s3BucketArn, Q as beginCommandInterruptScope, Qn as CdkdError, Qt as WorkGraph, R as red, Rn as uploadCfnTemplate, Rt as s3BucketWebsiteUrl, S as buildFinalSnapshotIdentifier, Sn as synthesisStatusMessage, Sr as isRetryableTransientError, St as requireConfigString, T as isFinalSnapshotError, Tn as resolveApp, Tt as TEMPLATE_SOURCED_RULES, U as isExportAliasCollision, Un as AssemblyReader, Ut as withRetry, V as collectPublishedOutputNames, Vn as canonicalizeRegion, Vt as INTRINSIC_KEYS, W as secretBearingStateKeyWarning, Wn as processStackMessages, Wt as DagBuilder, X as findActionableSilentDrops, Xn as setAwsClients, Xt as AssetPublisher, Y as ProviderRegistry, Yn as resetAwsClients, Yt as shouldRetainResource, Z as findSilentDropProperties, Zt as stringifyValue, _ as maskingRetryLogger, _n as runDockerForeground, _t as configStringRefusal, a as DeploymentEventsStore, an as AssetModeResolver, ar as LocalStartServiceError, at as isTerminationProtectionPropagationError, b as ATOMIC_FINAL_SNAPSHOT_TYPES, bn as getDockerImageBySourceHash, br as withErrorHandling, bt as requireConfigArray, c as planRollback, cn as getBootstrapMarkerKey, cr as NestedStackChildDirectDestroyError, ct as cfnRefValueFromPhysicalId, d as replayRollback, dn as validateAssetBucketName, dr as ResourceTimeoutError, dt as WAFv2WebACLProvider, en as createAssetRedirectResolver, er as CrossAccountSecretRefusalError, et as isInterruptedWaitError, f as updatePartialMessage, fn as validateContainerRepoName, fr as ResourceUpdateNotSupportedError, ft as normalizeAwsTagsToCfn, g as withResourceDeadline, gn as getDockerCmd, gt as configBooleanRefusal, h as deleteSkipReason, hn as formatDockerLoginError, ht as coerceCfnBoolean, i as DeploymentEventsReader, in as stripControlChars, ir as LocalMigrateError, it as disableInstanceApiTermination, j as isStatefulRecreateTargetSync, jn as resolveUseCdkBootstrapAssets, jt as maskSecretsInText, k as extractDeploymentEventError, kn as resolveStateBucketWithDefault, kt as isSingleDynamicReferenceToken, l as producerRegionsFromState, ln as parseBootstrapMarker, lr as PartialFailureError, lt as getAccountInfo, m as UNSPECIFIED_SKIP_REASON, mn as buildDockerImage, mr as StackTerminationProtectionError, mt as assertRegionMatch, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nn as rewriteTemplateAssetReferences, nr as DeployCancelledError, nt as CloudControlProvider, o as classifyReplaySecretRegion, on as BOOTSTRAP_MARKER_PREFIX, ot as IntrinsicFunctionResolver, p as updatePartialReason, pn as buildDenyExternalAccessPolicy, pr as StackHasActiveImportsError, pt as resolveExplicitPhysicalId, q as collectInlinePolicyNamesManagedBySiblings, qn as AwsClients, qt as S3StateBackend, r as DeployEngine, rn as escapeRegExp, rr as LocalInvokeBuildError$1, rt as slowCcOperationTimeoutMs, s as planFailedOps, sn as ensureAssetStorage, sr as MissingCdkCliError, st as carriesDynamicReference, t as DEFAULT_RESOURCE_TIMEOUT_MS, tn as loadPublishableAssetManifest, tt as startInterruptWatch, u as replayFailedOperations, un as readBootstrapMarkerBody, ur as ProvisioningError, ut as refStateLookupFromResource, v as IMPLICIT_DELETE_DEPENDENCIES, vn as runDockerStreaming, vt as readConfigString, w as createPreDeleteFinalSnapshot, wn as getLegacyStateBucketName, wr as markNonRetryable, wt as STATE_SOURCED_READBACK_RULES, x as PRE_DELETE_SNAPSHOT_TYPES, xn as Synthesizer, xr as isMarkedNonRetryable, xt as requireConfigObject, y as computeImplicitDeleteEdges, yn as AssetManifestLoader, yr as normalizeAwsError, yt as replayWarn, z as yellow, zn as expectedOwnerParam, zt as applyRoleArnIfSet } from "./deploy-engine-DsUQ4GxO.js";
|
|
3
3
|
import { a as getLiveRenderer, c as PATTERN_B_RESOURCE_TYPES, d as generateResourceNameWithFallback, f as getCurrentStackName, h as withStackName, i as runStackBuffered, m as withSkipPrefix, n as getLogger, o as PATTERN_B_NAME_OPTIONS, s as PATTERN_B_NAME_PROPERTIES, u as generateResourceName } from "./logger-zRrlbaQt.js";
|
|
4
|
-
import { t as ASGProvider } from "./asg-provider-
|
|
4
|
+
import { t as ASGProvider } from "./asg-provider-BVzQ7mQX.js";
|
|
5
5
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
6
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketEncryptionCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketOwnershipControlsCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketOwnershipControlsCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -77,6 +77,162 @@ import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
|
77
77
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
78
78
|
import { WebSocketServer } from "ws";
|
|
79
79
|
|
|
80
|
+
//#region src/cli/commands/state-file-keys.ts
|
|
81
|
+
/**
|
|
82
|
+
* Shared helpers for enumerating and describing cdkd state-bucket keys.
|
|
83
|
+
*
|
|
84
|
+
* Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
|
|
85
|
+
* (issue #1012) reuses the exact same whole-bucket state-file discovery
|
|
86
|
+
* instead of duplicating it — the "scan the WHOLE bucket, not just the
|
|
87
|
+
* default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
|
|
88
|
+
* and must not drift between the two commands.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Every cdkd state file ends with this suffix, regardless of the
|
|
92
|
+
* `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
|
|
93
|
+
* or legacy `{prefix}/{stack}/state.json`).
|
|
94
|
+
*/
|
|
95
|
+
const STATE_FILE_SUFFIX = "/state.json";
|
|
96
|
+
/**
|
|
97
|
+
* The prefix `cdkd bootstrap` writes under, and the one `gc` /
|
|
98
|
+
* `bootstrap --destroy` configure their own state backend with.
|
|
99
|
+
*
|
|
100
|
+
* Shared here rather than spelled per command for the same reason this module
|
|
101
|
+
* exists at all: `bootstrap-destroy.ts` and `gc.ts` each held their own copy,
|
|
102
|
+
* and it is now also the `knownPrefix` argument to {@link describeStateKey},
|
|
103
|
+
* where a copy that drifted from the backend's actual prefix would silently
|
|
104
|
+
* send every key back to the shape heuristic instead of the exact depth rule.
|
|
105
|
+
*
|
|
106
|
+
* Note this is only the DEFAULT. Other commands accept `--state-prefix`, so
|
|
107
|
+
* the whole-bucket listings deliberately do not scope to it.
|
|
108
|
+
*/
|
|
109
|
+
const DEFAULT_STATE_PREFIX = "cdkd";
|
|
110
|
+
/**
|
|
111
|
+
* Segments left under a known prefix for the legacy `{prefix}/{stack}` layout.
|
|
112
|
+
*
|
|
113
|
+
* The twin of `S3StateBackend`'s `LEGACY_KEY_DEPTH`, which counts the
|
|
114
|
+
* `state.json` segment as well; this one counts what remains after the prefix
|
|
115
|
+
* and the suffix are stripped, so it is that value minus one. Fenced against it
|
|
116
|
+
* by a unit test rather than imported, because a value import from
|
|
117
|
+
* `s3-state-backend.js` breaks the suites that mock that module.
|
|
118
|
+
*/
|
|
119
|
+
const LEGACY_SEGMENTS_UNDER_PREFIX = 1;
|
|
120
|
+
/**
|
|
121
|
+
* Every cdkd stack lock ends with this suffix — the lock lives next to the
|
|
122
|
+
* state file (`{prefix}/{stack}/{region}/lock.json`, or the legacy region-less
|
|
123
|
+
* `{prefix}/{stack}/lock.json` that `lock-manager.ts` still writes for a
|
|
124
|
+
* pre-v2 stack).
|
|
125
|
+
*/
|
|
126
|
+
const LOCK_FILE_SUFFIX = "/lock.json";
|
|
127
|
+
/**
|
|
128
|
+
* `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment.
|
|
129
|
+
*
|
|
130
|
+
* The first token is `[a-z]{2}` OR the literal `eusc` (issue #2001). Every
|
|
131
|
+
* region AWS ships has a two-letter first token except the European Sovereign
|
|
132
|
+
* Cloud partition's, which is four — `eusc-de-east-1` — and the old
|
|
133
|
+
* `^[a-z]{2}` rejected that whole partition. Enumerated from
|
|
134
|
+
* `aws-cdk-lib/region-info` and `@aws-sdk/util-endpoints`: 46 REAL regions,
|
|
135
|
+
* first tokens all two letters or `eusc`, none three. (A count of 53 includes
|
|
136
|
+
* the `aws-global` / `aws-iso-*-global` PSEUDO-regions, whose first token is
|
|
137
|
+
* `aws`; they carry no trailing `-<digits>`, so this pattern rejects them and
|
|
138
|
+
* so did the old one — no behavioural difference, but at 53 the "two letters
|
|
139
|
+
* or eusc" claim is false as stated.)
|
|
140
|
+
*
|
|
141
|
+
* **The exception is enumerated rather than widened, and that is the whole
|
|
142
|
+
* design.** The obvious fix — relaxing the bound to `{2,4}` — was written
|
|
143
|
+
* first and review measured what it cost: `api-prod-1`, `demo-app-1`,
|
|
144
|
+
* `dev-api-1`, `core-api-1`, `test-stack-2` all become region-shaped under it.
|
|
145
|
+
* Those are idiomatic STACK names, and in the legacy region-less layout a
|
|
146
|
+
* stack name sits exactly where a region would, so the pattern would report
|
|
147
|
+
* the key's PREFIX as the stack for each of them — the same wrong answer this
|
|
148
|
+
* issue is about, arriving from the other side and at a far higher rate. A
|
|
149
|
+
* length class cannot separate `eusc` from `demo`; only naming the partition
|
|
150
|
+
* can.
|
|
151
|
+
*
|
|
152
|
+
* The cost of enumerating is that a future partition with a new non-two-letter
|
|
153
|
+
* token needs this line updated. That is the same failure this issue fixed, it
|
|
154
|
+
* is a one-token change, and it fails in the recoverable direction (a region
|
|
155
|
+
* read as a stack name) rather than corrupting ordinary stack names.
|
|
156
|
+
*
|
|
157
|
+
* It stays SHAPE-based and deliberately does not reuse `isClientSafeRegion`
|
|
158
|
+
* from `intrinsic-function-resolver.ts`: that predicate is charset-based (its
|
|
159
|
+
* job is to keep a value inside a hostname label, so it accepts anything
|
|
160
|
+
* lowercase-alphanumeric) and would classify almost every stack name as a
|
|
161
|
+
* region. This one has the opposite job — telling a region segment apart from
|
|
162
|
+
* a stack name in the same position — so it must constrain the shape.
|
|
163
|
+
*
|
|
164
|
+
* A two-letter STACK name like `db-prod-1` is still misread in the legacy
|
|
165
|
+
* layout; that is pre-existing (`main` does the same) and is what the
|
|
166
|
+
* `knownPrefix` depth rule below repairs for callers that can supply one.
|
|
167
|
+
*/
|
|
168
|
+
const REGION_SEGMENT = /^(?:[a-z]{2}|eusc)(-[a-z]+)+-\d+$/;
|
|
169
|
+
/**
|
|
170
|
+
* List every state file in the bucket — the WHOLE bucket, not just the
|
|
171
|
+
* default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
|
|
172
|
+
* stack state may exist under ANY prefix in this bucket; scoping this
|
|
173
|
+
* listing to the default prefix would let reference scans and teardown
|
|
174
|
+
* guards silently miss those stacks and delete live data.
|
|
175
|
+
*/
|
|
176
|
+
async function listAllStateKeys(stateBackend) {
|
|
177
|
+
return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* List every stack lock file in the bucket — same whole-bucket rule as
|
|
181
|
+
* {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
|
|
182
|
+
* destroy may be in flight for that stack.
|
|
183
|
+
*/
|
|
184
|
+
async function listAllLockKeys(stateBackend) {
|
|
185
|
+
return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
|
|
189
|
+
* `{prefix}/{stack}/state.json` → `stack`. Pass {@link LOCK_FILE_SUFFIX} to
|
|
190
|
+
* describe lock keys.
|
|
191
|
+
*
|
|
192
|
+
* The shape heuristic below cannot be made exact, and it errs in BOTH
|
|
193
|
+
* directions -- a region it fails to recognise is reported AS the stack name,
|
|
194
|
+
* while a region-shaped STACK name in the legacy layout makes it report the
|
|
195
|
+
* key's PREFIX as the stack. Neither is safe: `cdkd gc`'s corrupt-state abort
|
|
196
|
+
* turns whichever answer it gets into a `cdkd state show ...` recovery
|
|
197
|
+
* command, so a wrong one is handed to the user as a command to run.
|
|
198
|
+
*
|
|
199
|
+
* `knownPrefix` narrows it where a caller can: a key with exactly one segment
|
|
200
|
+
* under a prefix we know is the legacy `{stack}` layout by construction,
|
|
201
|
+
* whatever the stack is named, so no shape test is needed there at all. That
|
|
202
|
+
* repairs the pre-existing two-letter case (`cdkd/db-prod-1/state.json`, which
|
|
203
|
+
* `main` describes as `cdkd (db-prod-1)`).
|
|
204
|
+
*
|
|
205
|
+
* **It is a narrowing, not a safety net for the pattern above.** A first cut
|
|
206
|
+
* treated it as one — widening `REGION_SEGMENT` to `{2,4}` and relying on this
|
|
207
|
+
* rule to undo the damage — and review measured that the damage is repo-wide
|
|
208
|
+
* while this rule reaches only keys under the ONE prefix a caller names. The
|
|
209
|
+
* listing deliberately spans the whole bucket, so foreign and nested prefixes
|
|
210
|
+
* are in scope and unreachable from here; six shapes regressed against `main`
|
|
211
|
+
* that way. The pattern must be right on its own.
|
|
212
|
+
*
|
|
213
|
+
* **It deliberately does NOT claim the two-segment case.** A first cut did, on
|
|
214
|
+
* the reasoning that at that depth the last segment must be the region -- and
|
|
215
|
+
* review found `--state-prefix cdkd/team-a`, which nests inside the default
|
|
216
|
+
* prefix, so `cdkd/team-a/MyStack/state.json` is a LEGACY key two segments
|
|
217
|
+
* deep and would have been mis-split as `team-a (MyStack)` where `main` said
|
|
218
|
+
* `MyStack`. Depth cannot tell that apart from a real `{stack}/{region}`; only
|
|
219
|
+
* the shape of the tail can, so the heuristic keeps that case.
|
|
220
|
+
*/
|
|
221
|
+
function describeStateKey(key, suffix = STATE_FILE_SUFFIX, knownPrefix) {
|
|
222
|
+
const body = key.slice(0, -suffix.length);
|
|
223
|
+
const prefix = knownPrefix?.replace(/\/+$/, "");
|
|
224
|
+
if (prefix !== void 0 && prefix !== "" && key.startsWith(`${prefix}/`)) {
|
|
225
|
+
const rest = body.slice(prefix.length + 1).split("/");
|
|
226
|
+
if (rest.length === LEGACY_SEGMENTS_UNDER_PREFIX && rest[0]) return rest[0];
|
|
227
|
+
}
|
|
228
|
+
const segments = body.split("/");
|
|
229
|
+
const last = segments[segments.length - 1] ?? key;
|
|
230
|
+
const secondLast = segments[segments.length - 2];
|
|
231
|
+
if (secondLast !== void 0 && secondLast !== "" && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
|
|
232
|
+
return last;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
//#endregion
|
|
80
236
|
//#region src/cli/options.ts
|
|
81
237
|
/**
|
|
82
238
|
* Parse context key=value pairs from CLI arguments into a Record
|
|
@@ -152,7 +308,7 @@ const appOptions = [new Option("-a, --app <command>", "CDK app command (e.g., \"
|
|
|
152
308
|
* --state-bucket is optional: falls back to CDKD_STATE_BUCKET env var,
|
|
153
309
|
* then cdk.json context.cdkd.stateBucket
|
|
154
310
|
*/
|
|
155
|
-
const stateOptions = [new Option("--state-bucket <bucket>", "S3 bucket for state storage. Falls back to CDKD_STATE_BUCKET env or cdk.json"), new Option("--state-prefix <prefix>", "S3 key prefix for state files").default(
|
|
311
|
+
const stateOptions = [new Option("--state-bucket <bucket>", "S3 bucket for state storage. Falls back to CDKD_STATE_BUCKET env or cdk.json"), new Option("--state-prefix <prefix>", "S3 key prefix for state files").default(DEFAULT_STATE_PREFIX)];
|
|
156
312
|
/**
|
|
157
313
|
* Stack options
|
|
158
314
|
*/
|
|
@@ -1025,91 +1181,9 @@ function regionNeedsReconciliation(effective) {
|
|
|
1025
1181
|
return effective.region !== LEGACY_DEFAULT_REGION;
|
|
1026
1182
|
}
|
|
1027
1183
|
|
|
1028
|
-
//#endregion
|
|
1029
|
-
//#region src/cli/commands/state-file-keys.ts
|
|
1030
|
-
/**
|
|
1031
|
-
* Shared helpers for enumerating and describing cdkd state-bucket keys.
|
|
1032
|
-
*
|
|
1033
|
-
* Extracted from `bootstrap-destroy.ts` (issue #1010) so that `cdkd gc`
|
|
1034
|
-
* (issue #1012) reuses the exact same whole-bucket state-file discovery
|
|
1035
|
-
* instead of duplicating it — the "scan the WHOLE bucket, not just the
|
|
1036
|
-
* default `cdkd/` prefix" rule was a review blocker once already (PR #1018)
|
|
1037
|
-
* and must not drift between the two commands.
|
|
1038
|
-
*/
|
|
1039
|
-
/**
|
|
1040
|
-
* Every cdkd state file ends with this suffix, regardless of the
|
|
1041
|
-
* `--state-prefix` it was deployed under (`{prefix}/{stack}/{region}/state.json`,
|
|
1042
|
-
* or legacy `{prefix}/{stack}/state.json`).
|
|
1043
|
-
*/
|
|
1044
|
-
const STATE_FILE_SUFFIX = "/state.json";
|
|
1045
|
-
/**
|
|
1046
|
-
* Every cdkd stack lock ends with this suffix — the lock lives next to the
|
|
1047
|
-
* state file (`{prefix}/{stack}/{region}/lock.json`).
|
|
1048
|
-
*/
|
|
1049
|
-
const LOCK_FILE_SUFFIX = "/lock.json";
|
|
1050
|
-
/** `us-east-1` / `ap-northeast-1` / `us-gov-west-1` — a region-shaped segment. */
|
|
1051
|
-
const REGION_SEGMENT = /^[a-z]{2}(-[a-z]+)+-\d+$/;
|
|
1052
|
-
/**
|
|
1053
|
-
* List every state file in the bucket — the WHOLE bucket, not just the
|
|
1054
|
-
* default `cdkd/` prefix. Other commands accept `--state-prefix`, so live
|
|
1055
|
-
* stack state may exist under ANY prefix in this bucket; scoping this
|
|
1056
|
-
* listing to the default prefix would let reference scans and teardown
|
|
1057
|
-
* guards silently miss those stacks and delete live data.
|
|
1058
|
-
*/
|
|
1059
|
-
async function listAllStateKeys(stateBackend) {
|
|
1060
|
-
return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(STATE_FILE_SUFFIX));
|
|
1061
|
-
}
|
|
1062
|
-
/**
|
|
1063
|
-
* List every stack lock file in the bucket — same whole-bucket rule as
|
|
1064
|
-
* {@link listAllStateKeys}. A lock under ANY prefix means a deploy /
|
|
1065
|
-
* destroy may be in flight for that stack.
|
|
1066
|
-
*/
|
|
1067
|
-
async function listAllLockKeys(stateBackend) {
|
|
1068
|
-
return (await stateBackend.listRawKeys("")).filter((k) => k.endsWith(LOCK_FILE_SUFFIX));
|
|
1069
|
-
}
|
|
1070
|
-
/**
|
|
1071
|
-
* `{prefix}/{stack}/{region}/state.json` → `stack (region)`; legacy
|
|
1072
|
-
* `{prefix}/{stack}/state.json` → `stack`. The prefix is arbitrary (custom
|
|
1073
|
-
* `--state-prefix` values included), so the stack/region pair is derived
|
|
1074
|
-
* from the key's TAIL: the last segment is treated as a region only when
|
|
1075
|
-
* it is region-shaped. Pass {@link LOCK_FILE_SUFFIX} to describe lock keys.
|
|
1076
|
-
*/
|
|
1077
|
-
function describeStateKey(key, suffix = STATE_FILE_SUFFIX) {
|
|
1078
|
-
const segments = key.slice(0, -suffix.length).split("/");
|
|
1079
|
-
const last = segments[segments.length - 1] ?? key;
|
|
1080
|
-
const secondLast = segments[segments.length - 2];
|
|
1081
|
-
if (secondLast !== void 0 && REGION_SEGMENT.test(last)) return `${secondLast} (${last})`;
|
|
1082
|
-
return last;
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
1184
|
//#endregion
|
|
1086
1185
|
//#region src/cli/commands/bootstrap-destroy.ts
|
|
1087
1186
|
/**
|
|
1088
|
-
* `cdkd bootstrap --destroy` — teardown of cdkd-created account resources
|
|
1089
|
-
* (issue #1010, the reverse of `cdkd bootstrap` / `ensureAssetStorage`).
|
|
1090
|
-
*
|
|
1091
|
-
* Scope per invocation is ONE region's asset storage (`--region`, same
|
|
1092
|
-
* option the create side uses): empty + delete the asset bucket, force-
|
|
1093
|
-
* delete the container-asset ECR repo, then delete the region's bootstrap
|
|
1094
|
-
* marker LAST — the mirror image of the create side's marker-written-last
|
|
1095
|
-
* ordering, so a crash mid-teardown leaves the region still consistently
|
|
1096
|
-
* opted in (marker present → deploys keep hard-erroring at the missing
|
|
1097
|
-
* resources with a re-bootstrap hint, never a silent legacy fallback).
|
|
1098
|
-
*
|
|
1099
|
-
* The asset bucket / repo NAMES are read from the region's bootstrap
|
|
1100
|
-
* marker, NOT recomputed from the naming convention — the marker is the
|
|
1101
|
-
* source of truth for names (design §4.1), which keeps this teardown
|
|
1102
|
-
* compatible with custom asset-storage names (issue #1011).
|
|
1103
|
-
*
|
|
1104
|
-
* The state bucket is NOT deleted by default: it is the account's source
|
|
1105
|
-
* of truth. `--include-state-bucket` opts in, and even then the deletion
|
|
1106
|
-
* is refused while ANY stack state exists in the bucket or any OTHER
|
|
1107
|
-
* region is still opted in to asset storage (deleting their markers would
|
|
1108
|
-
* silently flip those regions back to legacy mode).
|
|
1109
|
-
*/
|
|
1110
|
-
/** S3 key prefix for state files — same fixed value the create side uses. */
|
|
1111
|
-
const STATE_PREFIX = "cdkd";
|
|
1112
|
-
/**
|
|
1113
1187
|
* Interactive confirmation for the teardown. Follows the repo's
|
|
1114
1188
|
* destructive-prompt convention (see `recreate-confirm-prompt.ts`, the
|
|
1115
1189
|
* same pattern family as `confirm-prompt.ts`): print the deletion plan as
|
|
@@ -1157,7 +1231,7 @@ async function scanStateReferences(stateBackend, names) {
|
|
|
1157
1231
|
for (const key of stateKeys) {
|
|
1158
1232
|
const body = await stateBackend.getRawObject(key);
|
|
1159
1233
|
if (body === null) continue;
|
|
1160
|
-
if (names.some((name) => body.includes(name))) referencing.push(describeStateKey(key));
|
|
1234
|
+
if (names.some((name) => body.includes(name))) referencing.push(describeStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX));
|
|
1161
1235
|
}
|
|
1162
1236
|
return referencing;
|
|
1163
1237
|
}
|
|
@@ -1332,7 +1406,7 @@ async function bootstrapDestroyCommand(options) {
|
|
|
1332
1406
|
...options.profile && { profile: options.profile }
|
|
1333
1407
|
}), {
|
|
1334
1408
|
bucket: bucketName,
|
|
1335
|
-
prefix:
|
|
1409
|
+
prefix: DEFAULT_STATE_PREFIX
|
|
1336
1410
|
}, {
|
|
1337
1411
|
region: effective.region,
|
|
1338
1412
|
...options.profile && { profile: options.profile }
|
|
@@ -1361,7 +1435,7 @@ async function bootstrapDestroyCommand(options) {
|
|
|
1361
1435
|
});
|
|
1362
1436
|
const stateBackend = new S3StateBackend(markerS3Client, {
|
|
1363
1437
|
bucket: bucketName,
|
|
1364
|
-
prefix:
|
|
1438
|
+
prefix: DEFAULT_STATE_PREFIX
|
|
1365
1439
|
}, {
|
|
1366
1440
|
region,
|
|
1367
1441
|
...options.profile && { profile: options.profile }
|
|
@@ -1413,7 +1487,7 @@ async function bootstrapDestroyCommand(options) {
|
|
|
1413
1487
|
if (options.includeStateBucket) {
|
|
1414
1488
|
const stateKeys = await listAllStateKeys(stateBackend);
|
|
1415
1489
|
if (stateKeys.length > 0) {
|
|
1416
|
-
const listing = stateKeys.map((k) => ` - ${describeStateKey(k)} [${k}]`).join("\n");
|
|
1490
|
+
const listing = stateKeys.map((k) => ` - ${describeStateKey(k, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
|
|
1417
1491
|
throw new CdkdError(`Refusing to delete state bucket '${bucketName}': ${stateKeys.length} stack(s) still have state in it:\n${listing}\nDestroy every stack ('cdkd destroy' / 'cdkd state destroy') before deleting the state bucket.`, "STATE_BUCKET_NOT_EMPTY");
|
|
1418
1492
|
}
|
|
1419
1493
|
if (markerSiblings.otherRegions.length > 0) throw new CdkdError(`Refusing to delete state bucket '${bucketName}': region(s) ${markerSiblings.otherRegions.join(", ")} are still opted in to cdkd asset storage (their bootstrap markers live in this bucket). Run 'cdkd bootstrap --destroy --region <r>' for each first.`, "STATE_BUCKET_HOLDS_MARKERS");
|
|
@@ -1509,7 +1583,7 @@ async function bootstrapCommand(options) {
|
|
|
1509
1583
|
...options.profile && { profile: options.profile }
|
|
1510
1584
|
}), {
|
|
1511
1585
|
bucket: bucketName,
|
|
1512
|
-
prefix:
|
|
1586
|
+
prefix: DEFAULT_STATE_PREFIX
|
|
1513
1587
|
}, {
|
|
1514
1588
|
region: effective.region,
|
|
1515
1589
|
...options.profile && { profile: options.profile }
|
|
@@ -1610,7 +1684,7 @@ async function bootstrapCommand(options) {
|
|
|
1610
1684
|
});
|
|
1611
1685
|
const stateBackend = new S3StateBackend(markerS3Client, {
|
|
1612
1686
|
bucket: bucketName,
|
|
1613
|
-
prefix:
|
|
1687
|
+
prefix: DEFAULT_STATE_PREFIX
|
|
1614
1688
|
}, {
|
|
1615
1689
|
region,
|
|
1616
1690
|
...options.profile && { profile: options.profile }
|
|
@@ -62373,6 +62447,7 @@ async function scrubCommand(stacks, options) {
|
|
|
62373
62447
|
else throw new Error(`Multiple stacks found: ${allStacks.map(describeStack).join(", ")}. Specify stack name(s) or use --all`);
|
|
62374
62448
|
if (targetStacks.length === 0) throw new Error("No stacks matched.");
|
|
62375
62449
|
await synthesizer.expandMacrosForStacks(targetStacks, synthOptions);
|
|
62450
|
+
targetStacks = orderScrubTargets(targetStacks);
|
|
62376
62451
|
const stateConfig = {
|
|
62377
62452
|
bucket: stateBucket,
|
|
62378
62453
|
prefix: options.statePrefix
|
|
@@ -62388,6 +62463,7 @@ async function scrubCommand(stacks, options) {
|
|
|
62388
62463
|
const lockManager = new LockManager(stateS3.s3, stateConfig);
|
|
62389
62464
|
let totalStacksScrubbed = 0;
|
|
62390
62465
|
let totalStacksWithUnscrubbableKeys = 0;
|
|
62466
|
+
let totalStacksWithUnverifiableReads = 0;
|
|
62391
62467
|
const failures = [];
|
|
62392
62468
|
for (const stack of targetStacks) {
|
|
62393
62469
|
const stackRegion = stack.region || region;
|
|
@@ -62396,7 +62472,8 @@ async function scrubCommand(stacks, options) {
|
|
|
62396
62472
|
scrubbed = await scrubStack(stack, stackRegion, stateBackend, lockManager, {
|
|
62397
62473
|
dryRun: options.dryRun ?? false,
|
|
62398
62474
|
roleArn: options.roleArn,
|
|
62399
|
-
logger
|
|
62475
|
+
logger,
|
|
62476
|
+
appStacks: allStacks
|
|
62400
62477
|
});
|
|
62401
62478
|
} catch (err) {
|
|
62402
62479
|
failures.push({ stackName: stack.stackName });
|
|
@@ -62407,13 +62484,17 @@ async function scrubCommand(stacks, options) {
|
|
|
62407
62484
|
if (scrubbed.recordsChanged > 0) {
|
|
62408
62485
|
totalStacksScrubbed++;
|
|
62409
62486
|
logger.info(`${options.dryRun ? "Would scrub" : "Scrubbed"} ${scrubbed.recordsChanged} resource record(s) in ${stack.stackName}`);
|
|
62410
|
-
} else if (scrubbed.secretBearingKeys === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
|
|
62487
|
+
} else if (scrubbed.secretBearingKeys === 0 && scrubbed.unverifiableReads === 0) logger.info(`No plaintext secrets found in ${stack.stackName}`);
|
|
62488
|
+
if (scrubbed.unverifiableReads > 0) {
|
|
62489
|
+
totalStacksWithUnverifiableReads++;
|
|
62490
|
+
logger.warn(`${scrubbed.unverifiableReads} cross-stack read(s) in ${stack.stackName} could NOT be verified — cdkd declines them by design (see the warnings above), so this stack is not reported clean.`);
|
|
62491
|
+
}
|
|
62411
62492
|
if (scrubbed.secretBearingKeys > 0) {
|
|
62412
62493
|
totalStacksWithUnscrubbableKeys++;
|
|
62413
62494
|
logger.warn(`${scrubbed.secretBearingKeys} output KEY(s) in ${stack.stackName} hold plaintext and CANNOT be scrubbed — rename the Export.Name and redeploy (see the warning above).`);
|
|
62414
62495
|
}
|
|
62415
62496
|
}
|
|
62416
|
-
if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0) {
|
|
62497
|
+
if (totalStacksScrubbed === 0 && totalStacksWithUnscrubbableKeys === 0 && totalStacksWithUnverifiableReads === 0) {
|
|
62417
62498
|
if (failures.length === 0) {
|
|
62418
62499
|
logger.info("\nNo plaintext secrets found in any target stack state. Nothing to scrub.");
|
|
62419
62500
|
return;
|
|
@@ -62423,17 +62504,18 @@ async function scrubCommand(stacks, options) {
|
|
|
62423
62504
|
}
|
|
62424
62505
|
const failureNote = failures.length > 0 ? ` ${failures.length} stack(s) could NOT be scrubbed at all and are NOT covered by this summary — see the errors above.` : "";
|
|
62425
62506
|
const keyNote = totalStacksWithUnscrubbableKeys > 0 ? ` ${totalStacksWithUnscrubbableKeys} stack(s) hold plaintext in an output KEY, which cdkd scrub cannot rewrite — rename that output's Export.Name and redeploy.` : "";
|
|
62507
|
+
const unverifiableNote = totalStacksWithUnverifiableReads > 0 ? ` ${totalStacksWithUnverifiableReads} stack(s) carry a cross-stack read cdkd declines to perform, so their imported values could NOT be checked — export a non-secret value (e.g. the secret's ARN) from the producer, or reference it from within its own account.` : "";
|
|
62426
62508
|
if (options.dryRun) {
|
|
62427
|
-
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
62428
|
-
else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${failureNote} ROTATE any exposed secret.`);
|
|
62509
|
+
if (totalStacksScrubbed > 0) logger.info(`\nPlan: ${totalStacksScrubbed} stack(s) hold plaintext secrets and would be scrubbed (--dry-run, no state written).${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret in Secrets Manager.`);
|
|
62510
|
+
else logger.info(`\nPlan: nothing can be scrubbed.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
|
|
62429
62511
|
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
62430
62512
|
if (options.fail) throw new ScrubNeededError();
|
|
62431
62513
|
return;
|
|
62432
62514
|
}
|
|
62433
|
-
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${failureNote}`);
|
|
62434
|
-
else logger.info(`\nNothing could be rewritten.${keyNote}${failureNote} ROTATE any exposed secret.`);
|
|
62515
|
+
if (totalStacksScrubbed > 0) logger.info(`\nDone: scrubbed ${totalStacksScrubbed} stack(s). The plaintext is no longer stored there, but a value that was ever persisted should be treated as compromised — ROTATE it in Secrets Manager (scrub matches the current value, so scrub BEFORE rotating).${keyNote}${unverifiableNote}${failureNote}`);
|
|
62516
|
+
else logger.info(`\nNothing could be rewritten.${keyNote}${unverifiableNote}${failureNote} ROTATE any exposed secret.`);
|
|
62435
62517
|
if (failures.length > 0) throw scrubStacksFailedError(failures);
|
|
62436
|
-
if (options.fail && totalStacksWithUnscrubbableKeys > 0) throw new ScrubNeededError();
|
|
62518
|
+
if (options.fail && (totalStacksWithUnscrubbableKeys > 0 || totalStacksWithUnverifiableReads > 0)) throw new ScrubNeededError();
|
|
62437
62519
|
}
|
|
62438
62520
|
/**
|
|
62439
62521
|
* Every secret plaintext this run recorded, from ANY position in the template
|
|
@@ -62972,6 +63054,571 @@ async function pinCrossRegionSecrets(bag, stackName, ctx) {
|
|
|
62972
63054
|
return await walk(bag, "");
|
|
62973
63055
|
}
|
|
62974
63056
|
/**
|
|
63057
|
+
* Order scrub targets PRODUCER-BEFORE-CONSUMER (issue
|
|
63058
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133) review).
|
|
63059
|
+
*
|
|
63060
|
+
* `cdkd scrub --all` used to walk the assembly's artifact order, which is not
|
|
63061
|
+
* topological — `getAllStacks` iterates the manifest, and only `cdkd deploy`
|
|
63062
|
+
* sorts. That ordering decides whether a legacy multi-stack app can be scrubbed
|
|
63063
|
+
* in ONE run: a consumer importing a secret learns its expression only by
|
|
63064
|
+
* reading the producer's state, and that state holds the expression only once
|
|
63065
|
+
* the PRODUCER has been scrubbed. Reached in the other order the consumer gets
|
|
63066
|
+
* a plaintext, has no needle, and is refused
|
|
63067
|
+
* ({@link plaintextProducerCrossStackReadError}) until the user re-runs. Both
|
|
63068
|
+
* orders are SAFE — the refusal is what makes them safe — but only one of them
|
|
63069
|
+
* finishes the job in a single command.
|
|
63070
|
+
*
|
|
63071
|
+
* Edges come from BOTH sources `cdkd deploy` uses: CDK's own manifest
|
|
63072
|
+
* dependencies (`dependencyNames`, emitted for a strong reference through
|
|
63073
|
+
* `exportValue`) and {@link inferCrossStackStackDeps}, which scans the
|
|
63074
|
+
* synthesized templates for a RAW `cdk.Fn.importValue` / `Fn::GetStackOutput`
|
|
63075
|
+
* that CDK records no dependency for. Edges to stacks outside the target set
|
|
63076
|
+
* are ignored: a producer that is not being scrubbed cannot be ordered.
|
|
63077
|
+
*
|
|
63078
|
+
* Cycles cannot happen through `Fn::ImportValue` on a deployable app, but a
|
|
63079
|
+
* cycle in the INFERRED graph is possible (two stacks reading each other's
|
|
63080
|
+
* outputs weakly), so the sort degrades to input order for whatever it cannot
|
|
63081
|
+
* place rather than dropping or duplicating a stack. Ties keep input order, so
|
|
63082
|
+
* a single-stack or dependency-free `--all` run is byte-identical to before.
|
|
63083
|
+
*/
|
|
63084
|
+
function orderScrubTargets(targets) {
|
|
63085
|
+
if (targets.length < 2) return [...targets];
|
|
63086
|
+
const inSet = new Set(targets.map((t) => t.stackName));
|
|
63087
|
+
const inferred = inferCrossStackStackDeps(targets);
|
|
63088
|
+
const producersOf = /* @__PURE__ */ new Map();
|
|
63089
|
+
for (const target of targets) {
|
|
63090
|
+
const producers = /* @__PURE__ */ new Set();
|
|
63091
|
+
for (const dep of target.dependencyNames ?? []) if (inSet.has(dep) && dep !== target.stackName) producers.add(dep);
|
|
63092
|
+
for (const dep of inferred.get(target.stackName) ?? []) if (inSet.has(dep) && dep !== target.stackName) producers.add(dep);
|
|
63093
|
+
producersOf.set(target.stackName, producers);
|
|
63094
|
+
}
|
|
63095
|
+
const ordered = [];
|
|
63096
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
63097
|
+
const pending = [...targets];
|
|
63098
|
+
while (pending.length > 0) {
|
|
63099
|
+
let index = pending.findIndex((t) => [...producersOf.get(t.stackName) ?? []].every((p) => emitted.has(p)));
|
|
63100
|
+
if (index === -1) index = 0;
|
|
63101
|
+
const [next] = pending.splice(index, 1);
|
|
63102
|
+
if (!next) break;
|
|
63103
|
+
ordered.push(next);
|
|
63104
|
+
emitted.add(next.stackName);
|
|
63105
|
+
}
|
|
63106
|
+
return ordered;
|
|
63107
|
+
}
|
|
63108
|
+
/**
|
|
63109
|
+
* A memoizing READ view over the state backend, for the resolutions ONE
|
|
63110
|
+
* `scrubStack` call performs (issue #2133 review).
|
|
63111
|
+
*
|
|
63112
|
+
* `resolveImportValue` has no lookup cache of its own: each reference costs a
|
|
63113
|
+
* `listStacks()` plus up to N `getState()` calls, and scrub resolves every
|
|
63114
|
+
* cross-stack reference TWICE — once in the pre-pass (which must run before the
|
|
63115
|
+
* best-effort catch can swallow a failure) and once in the main resolution
|
|
63116
|
+
* (which must run because the pre-pass deliberately does not rewrite the bag).
|
|
63117
|
+
* The view collapses both into one scan and one `getState` per (stack, region).
|
|
63118
|
+
*
|
|
63119
|
+
* `Object.create` rather than a hand-written wrapper object: the resolver also
|
|
63120
|
+
* reads `stateBackend.prefix` and could reach for more, and a literal cast to
|
|
63121
|
+
* `S3StateBackend` would turn the next such call into `undefined is not a
|
|
63122
|
+
* function`. Prototype delegation keeps every other member — and
|
|
63123
|
+
* `instanceof S3StateBackend` — intact.
|
|
63124
|
+
*
|
|
63125
|
+
* Deliberately NOT used for this stack's own `getState` / `saveState`: those go
|
|
63126
|
+
* through the real backend, so the read-modify-write still sees S3 rather than
|
|
63127
|
+
* a cache, and its ETag precondition still means what it says.
|
|
63128
|
+
*/
|
|
63129
|
+
function memoizeCrossStackStateReads(backend) {
|
|
63130
|
+
const view = Object.create(backend);
|
|
63131
|
+
let listed;
|
|
63132
|
+
const states = /* @__PURE__ */ new Map();
|
|
63133
|
+
view.listStacks = () => {
|
|
63134
|
+
listed ??= backend.listStacks();
|
|
63135
|
+
return listed;
|
|
63136
|
+
};
|
|
63137
|
+
view.getState = (stackName, stateRegion) => {
|
|
63138
|
+
const key = `${stackName}\u0000${stateRegion}`;
|
|
63139
|
+
let pending = states.get(key);
|
|
63140
|
+
if (!pending) {
|
|
63141
|
+
pending = backend.getState(stackName, stateRegion);
|
|
63142
|
+
states.set(key, pending);
|
|
63143
|
+
}
|
|
63144
|
+
return pending;
|
|
63145
|
+
};
|
|
63146
|
+
return view;
|
|
63147
|
+
}
|
|
63148
|
+
/**
|
|
63149
|
+
* Is this template output one the deploy would NOT have written (issue #2133
|
|
63150
|
+
* review)?
|
|
63151
|
+
*
|
|
63152
|
+
* The outputs passes deliberately run over EVERY declared output regardless of
|
|
63153
|
+
* its `Condition`, so a secret carried by a (possibly spuriously) suppressed
|
|
63154
|
+
* output still becomes a needle for the resource records that share it. That
|
|
63155
|
+
* argument justifies RESOLVING a suppressed output, and nothing more — it does
|
|
63156
|
+
* not justify REFUSING the whole stack when such a resolution fails, because a
|
|
63157
|
+
* suppressed output produced no `state.outputs` key and so has nothing at risk.
|
|
63158
|
+
* A dev-environment scrub would otherwise be refused outright by a prod-only
|
|
63159
|
+
* `Value: {Fn::ImportValue: ProdOnlyExport}` whose producer does not exist in
|
|
63160
|
+
* dev. So: resolve it best-effort, but disarm the refusal.
|
|
63161
|
+
*
|
|
63162
|
+
* An UNKNOWN condition counts as suppressed, matching `resolveIf`'s
|
|
63163
|
+
* unknown-selects-FALSE rule and erring toward not refusing.
|
|
63164
|
+
*
|
|
63165
|
+
* STATE OVERRULES THE CONDITION (issue #2133 review). `conditions` degrades to
|
|
63166
|
+
* `{}` whenever `evaluateConditions` throws, and even when it succeeds it is
|
|
63167
|
+
* evaluated from TEMPLATE PARAMETER DEFAULTS — scrub takes no `--parameters` —
|
|
63168
|
+
* so an output the deploy really did write reads as suppressed on any stack
|
|
63169
|
+
* deployed with non-default parameters, which silently disarms the refusal for
|
|
63170
|
+
* that position. `state.outputs` is the record of what the deploy ACTUALLY
|
|
63171
|
+
* wrote (keyed by output NAME, plus the export-name alias), so a key present
|
|
63172
|
+
* there is proof the output was not suppressed, whatever this run's
|
|
63173
|
+
* best-effort condition evaluation concluded.
|
|
63174
|
+
*/
|
|
63175
|
+
function isOutputSuppressed(name, output, conditions, stateOutputs) {
|
|
63176
|
+
const condition = output.Condition;
|
|
63177
|
+
if (typeof condition !== "string") return false;
|
|
63178
|
+
if (conditions[condition] === true) return false;
|
|
63179
|
+
return !(name in stateOutputs);
|
|
63180
|
+
}
|
|
63181
|
+
/**
|
|
63182
|
+
* The intrinsics that read ANOTHER stack's state (issue
|
|
63183
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
|
|
63184
|
+
*
|
|
63185
|
+
* Both require `context.stateBackend` — `resolveImportValue` throws without it,
|
|
63186
|
+
* and `Fn::GetStackOutput` reaches the same requirement through
|
|
63187
|
+
* `getSameAccountStackState` — which is exactly what scrub's three resolve
|
|
63188
|
+
* contexts did not supply, so neither could ever resolve here.
|
|
63189
|
+
*/
|
|
63190
|
+
const CROSS_STACK_INTRINSIC_KEYS = ["Fn::ImportValue", "Fn::GetStackOutput"];
|
|
63191
|
+
/**
|
|
63192
|
+
* The intrinsic keys {@link IntrinsicFunctionResolver}'s `resolveValue`
|
|
63193
|
+
* dispatches on, IN ITS ORDER (issue #2133 review).
|
|
63194
|
+
*
|
|
63195
|
+
* The pre-pass below must decide which key of a multi-key node the RESOLVER
|
|
63196
|
+
* would act on, because a node carrying a higher-precedence sibling never
|
|
63197
|
+
* reaches its `Fn::ImportValue` at all. Testing the cross-stack keys first — as
|
|
63198
|
+
* the first cut did — makes the pre-pass perform a READ the main resolution
|
|
63199
|
+
* never performs, and a read that fails is a refusal over a reference the
|
|
63200
|
+
* deploy never made. Isolation (`{ [key]: node[key] }`) does not answer this:
|
|
63201
|
+
* it stops the pre-pass resolving something ELSE, it does not stop it resolving
|
|
63202
|
+
* something EXTRA.
|
|
63203
|
+
*
|
|
63204
|
+
* `Condition` is deliberately absent. The resolver's `{Condition: <name>}` arm
|
|
63205
|
+
* is gated on `context.conditionResolver`, which only `evaluateConditions`
|
|
63206
|
+
* threads; in every context this pre-pass runs under, such a node is an
|
|
63207
|
+
* ordinary object property literally named `Condition`.
|
|
63208
|
+
*
|
|
63209
|
+
* Fenced against the resolver's own dispatch order by
|
|
63210
|
+
* `tests/unit/cli/commands/scrub-import-value-secret.test.ts`, so a new
|
|
63211
|
+
* intrinsic added there cannot silently leave this copy behind.
|
|
63212
|
+
*/
|
|
63213
|
+
const RESOLVER_INTRINSIC_PRECEDENCE = [
|
|
63214
|
+
"Ref",
|
|
63215
|
+
"Fn::GetAtt",
|
|
63216
|
+
"Fn::Join",
|
|
63217
|
+
"Fn::Sub",
|
|
63218
|
+
"Fn::Select",
|
|
63219
|
+
"Fn::Split",
|
|
63220
|
+
"Fn::If",
|
|
63221
|
+
"Fn::Equals",
|
|
63222
|
+
"Fn::And",
|
|
63223
|
+
"Fn::Or",
|
|
63224
|
+
"Fn::Not",
|
|
63225
|
+
"Fn::ImportValue",
|
|
63226
|
+
"Fn::GetStackOutput",
|
|
63227
|
+
"Fn::FindInMap",
|
|
63228
|
+
"Fn::Base64",
|
|
63229
|
+
"Fn::GetAZs",
|
|
63230
|
+
"Fn::Cidr"
|
|
63231
|
+
];
|
|
63232
|
+
/** The key `resolveValue` would dispatch on for `node`, or `undefined`. */
|
|
63233
|
+
function effectiveIntrinsicKey(node) {
|
|
63234
|
+
return RESOLVER_INTRINSIC_PRECEDENCE.find((key) => key in node);
|
|
63235
|
+
}
|
|
63236
|
+
/**
|
|
63237
|
+
* The refusal a cross-stack read raises when scrub cannot PERFORM it (issue
|
|
63238
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
|
|
63239
|
+
*
|
|
63240
|
+
* The value behind such a reference MAY be a secret — a producer stack exporting
|
|
63241
|
+
* a `{{resolve:...}}` expression is exactly the shape issue #1934 persists — and
|
|
63242
|
+
* scrub learns its plaintext ONLY by performing the read. A read that fails
|
|
63243
|
+
* therefore leaves the command with no needle for that value, so a stored
|
|
63244
|
+
* plaintext survives and the stack is reported clean. Refusing is the same bar
|
|
63245
|
+
* issue #2109 sets for the cross-region case: reporting success over a document
|
|
63246
|
+
* scrub could not examine is the one outcome that must not survive.
|
|
63247
|
+
*
|
|
63248
|
+
* MASKED for the reason `unresolvableForeignScrubSecretError` states — the bag
|
|
63249
|
+
* this resolved through is one `pinCrossRegionSecrets` may already have
|
|
63250
|
+
* substituted a foreign plaintext into, so a resolver error echoing its input
|
|
63251
|
+
* can carry one, and the map is the same one this pass records into.
|
|
63252
|
+
*/
|
|
63253
|
+
function unresolvableCrossStackReadError(origin, stackName, intrinsic, path, cause, secrets) {
|
|
63254
|
+
return new ScrubRefusalError(`Scrub of ${stackName} could not resolve the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""}: ${maskSecretsInText(cause instanceof Error ? cause.message : String(cause), secrets)}. Refusing rather than continuing: the value that reference carries may be a secret, and scrub would then have no plaintext to look for — reporting the stack clean over state that still holds it. Deploy the producer stack (or correct the reference) and re-run 'cdkd scrub'.`, "SCRUB_CROSS_STACK_READ_UNRESOLVED");
|
|
63255
|
+
}
|
|
63256
|
+
/**
|
|
63257
|
+
* The refusal a cross-stack read raises when it SUCCEEDED but the PRODUCER's
|
|
63258
|
+
* own state record still stores the PLAINTEXT (issue
|
|
63259
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133) review).
|
|
63260
|
+
*
|
|
63261
|
+
* That producer state is precisely the population `cdkd scrub` exists for:
|
|
63262
|
+
* state an older binary wrote. Reading it hands the consumer a bare string,
|
|
63263
|
+
* nothing lands in `recordedSecretValues`, `totalSecrets` stays 0 and the
|
|
63264
|
+
* consumer is reported "No plaintext secrets found" while its record still
|
|
63265
|
+
* holds that plaintext. That is the same silent success the issue exists to
|
|
63266
|
+
* kill, reached one step later.
|
|
63267
|
+
*
|
|
63268
|
+
* There is no correct value to write here instead. What scrub must persist for
|
|
63269
|
+
* an imported secret is the PRODUCER's `{{resolve:...}}` expression, and that
|
|
63270
|
+
* expression exists only in the producer's state — the consumer's template
|
|
63271
|
+
* carries `{"Fn::ImportValue": ...}`, which is a REFERENCE, not an expression.
|
|
63272
|
+
* Writing the reference into a state leaf would fabricate an expression that
|
|
63273
|
+
* `Fn::ImportValue` and the exports index re-apply verbatim to further
|
|
63274
|
+
* consumers, the #1934 BREAK class this file already refuses one class over.
|
|
63275
|
+
* So: refuse, and name the fix that actually works — scrub the producer first,
|
|
63276
|
+
* which turns its stored plaintext into the expression this read then resolves.
|
|
63277
|
+
*/
|
|
63278
|
+
function plaintextProducerCrossStackReadError(origin, stackName, intrinsic, path, producerStack, exportKey, verdict, secrets) {
|
|
63279
|
+
const loggedExportKey = maskSecretsInText(exportKey, secrets);
|
|
63280
|
+
const loggedProducerStack = maskSecretsInText(producerStack, secrets);
|
|
63281
|
+
const templateClaim = verdict === "declared" ? `declares '${loggedExportKey}' from a {{resolve:...}} expression` : `publishes at least one output from a {{resolve:...}} expression (this run could not match '${loggedExportKey}' to a declared output, so it cannot say which)`;
|
|
63282
|
+
return new ScrubRefusalError(`Scrub of ${stackName} resolved the ${intrinsic} in ${origin}${path ? ` at ${path}` : ""} to a PLAINTEXT value: the producer stack '${loggedProducerStack}' ${templateClaim}, but its own state still stores the resolved plaintext rather than that expression. scrub has no expression to write in this stack's place, so it cannot redact the imported secret and must not report this stack clean. Scrub the producer first ('cdkd scrub ${loggedProducerStack}'), then re-run 'cdkd scrub ${stackName}' — the read will then return the expression and this stack is scrubbed normally.`, "SCRUB_CROSS_STACK_PRODUCER_PLAINTEXT");
|
|
63283
|
+
}
|
|
63284
|
+
/**
|
|
63285
|
+
* Can the producer's STORED value be a legacy plaintext secret at all (issue
|
|
63286
|
+
* #2133 review)?
|
|
63287
|
+
*
|
|
63288
|
+
* `false` for a value that carries no text: `null` / `undefined`, the empty
|
|
63289
|
+
* string, and every non-string scalar. A resolved `{{resolve:...}}` reference
|
|
63290
|
+
* is always a STRING — `resolveDynamicReferences` substitutes text — so a
|
|
63291
|
+
* number or a boolean cannot be one, and an empty string holds nothing to
|
|
63292
|
+
* redact. Without this, `carriesDynamicReference` being false for those shapes
|
|
63293
|
+
* made a `widened` verdict refuse the consumer over a value with nothing behind
|
|
63294
|
+
* it, in a refusal that no re-run could clear (scrubbing the producer cannot
|
|
63295
|
+
* turn an empty value into an expression).
|
|
63296
|
+
*
|
|
63297
|
+
* Containers are NOT included: an array or object can nest a plaintext string,
|
|
63298
|
+
* and `carriesDynamicReference` already walks them, so the classification for
|
|
63299
|
+
* those is left exactly as it was.
|
|
63300
|
+
*/
|
|
63301
|
+
function storedValueCarriesNoPlaintext(stored) {
|
|
63302
|
+
if (stored === null || stored === void 0) return true;
|
|
63303
|
+
if (typeof stored === "string") return stored === "";
|
|
63304
|
+
return typeof stored !== "object";
|
|
63305
|
+
}
|
|
63306
|
+
/**
|
|
63307
|
+
* Is `key` an export the producer's TEMPLATE declares from a `{{resolve:...}}`
|
|
63308
|
+
* expression (issue #2133 review)?
|
|
63309
|
+
*
|
|
63310
|
+
* This is HALF the discriminator, and the half the direct state read below
|
|
63311
|
+
* cannot supply. From the consumer's side, "the producer stored a plaintext
|
|
63312
|
+
* SECRET" and "the producer stored an ordinary non-secret value" are
|
|
63313
|
+
* indistinguishable — a bucket name and a leaked password are both bare
|
|
63314
|
+
* strings — so a refusal keyed on the producer's STORED value alone would fire
|
|
63315
|
+
* on every multi-stack app that imports anything at all. An export whose
|
|
63316
|
+
* declared `Value` contains no `{{resolve:` anywhere cannot be secret-bearing,
|
|
63317
|
+
* so its plaintext is nothing to redact.
|
|
63318
|
+
*
|
|
63319
|
+
* What it deliberately does NOT answer is whether the producer's own STATE has
|
|
63320
|
+
* been scrubbed down to that expression yet, which is the other half and the
|
|
63321
|
+
* one the three earlier cuts of this file each got wrong. See the discriminator
|
|
63322
|
+
* in {@link makeCrossStackPrePass}.
|
|
63323
|
+
*
|
|
63324
|
+
* `key` is the state.outputs KEY the read matched, which the deploy engine
|
|
63325
|
+
* writes under BOTH the output name and the literal `Export.Name` — hence both
|
|
63326
|
+
* are matched. When no output matches (an INTRINSIC `Export.Name`, which scrub
|
|
63327
|
+
* cannot reproduce from here) the test widens to every output of the producer,
|
|
63328
|
+
* which over-approximates in the SAFE direction: it can refuse a stack that
|
|
63329
|
+
* imports a non-secret export from a producer that has some other secret
|
|
63330
|
+
* output, and it never lets a secret-bearing one through. The widening is
|
|
63331
|
+
* REPORTED rather than folded into a boolean, because the refusal message
|
|
63332
|
+
* asserts what the producer declares and only the `declared` verdict has
|
|
63333
|
+
* checked that for THIS key.
|
|
63334
|
+
*
|
|
63335
|
+
* Residual, stated rather than hidden: a producer that publishes a secret
|
|
63336
|
+
* WITHOUT a literal `{{resolve:` in its template — one re-exporting a value it
|
|
63337
|
+
* imported from a third stack, or reading one through `Ref` to a parameter —
|
|
63338
|
+
* is not detected, and neither is a producer outside this app (whose template
|
|
63339
|
+
* scrub never sees). Those keep the pre-#2133 outcome for that one reference.
|
|
63340
|
+
*/
|
|
63341
|
+
function producerPublishesSecretExpression(template, key) {
|
|
63342
|
+
const outputs = template.Outputs ?? {};
|
|
63343
|
+
const matched = [];
|
|
63344
|
+
for (const [name, output] of Object.entries(outputs)) {
|
|
63345
|
+
const exportName = output?.Export?.Name;
|
|
63346
|
+
if (name === key || typeof exportName === "string" && exportName === key) matched.push(output?.Value);
|
|
63347
|
+
}
|
|
63348
|
+
const widened = matched.length === 0;
|
|
63349
|
+
if (!(widened ? Object.values(outputs).map((o) => o?.Value) : matched).some((value) => JSON.stringify(value ?? null).includes("{{resolve:"))) return "no";
|
|
63350
|
+
return widened ? "widened" : "declared";
|
|
63351
|
+
}
|
|
63352
|
+
/**
|
|
63353
|
+
* Build the CROSS-STACK pre-pass for ONE stack's scrub (issue
|
|
63354
|
+
* [#2133](https://github.com/go-to-k/cdkd/issues/2133)).
|
|
63355
|
+
*
|
|
63356
|
+
* WHY THIS RUNS AT ALL, given the wiring in `scrubStack` already lets the main
|
|
63357
|
+
* resolution perform these reads. The wiring is what makes an
|
|
63358
|
+
* `Fn::ImportValue`-sourced secret RESOLVE; this pass is what makes a FAILED
|
|
63359
|
+
* one LOUD, and what catches a SUCCEEDING one that produced no needle. A
|
|
63360
|
+
* cross-stack read that throws inside `resolver.resolve` lands in the caller's
|
|
63361
|
+
* best-effort `catch { logger.debug(...) }`, and that catch cannot tell "a
|
|
63362
|
+
* `Ref` to a resource that is not in state" — which it exists for — from "the
|
|
63363
|
+
* producer's state could not be read", which silently costs the command its
|
|
63364
|
+
* needle. Lifting the second class OUT of the catch is the same placement issue
|
|
63365
|
+
* #2109 uses for its own refusals, and it keeps the catch doing its job:
|
|
63366
|
+
* everything it still swallows is now provably NOT a cross-stack read.
|
|
63367
|
+
*
|
|
63368
|
+
* THREE OUTCOMES per reference, and the split is the whole design:
|
|
63369
|
+
* - the read FAILS for a reason a user can fix (producer not deployed, wrong
|
|
63370
|
+
* reference, an `AccessDenied` on the producer's state or on
|
|
63371
|
+
* `cloudformation:ListExports`, an unassumable `RoleArn`) — REFUSE the stack.
|
|
63372
|
+
* A re-run after the fix scrubs it, so refusing costs reachability only until
|
|
63373
|
+
* the cause is addressed.
|
|
63374
|
+
* - the read is DECLINED BY DESIGN (`CrossAccountSecretRefusalError`) — record
|
|
63375
|
+
* an UNVERIFIABLE finding, scrub the rest of the stack, and let the run exit
|
|
63376
|
+
* non-zero. No user action can make that read succeed, so refusing the whole
|
|
63377
|
+
* stack would strand every OTHER secret in it forever — a reachability
|
|
63378
|
+
* regression against the pre-#2133 best-effort behaviour, in the command that
|
|
63379
|
+
* exists to remediate. This is the `secretBearingKeys` treatment (issue
|
|
63380
|
+
* #1919), applied to the same shape of finding: real, reported, unremediable.
|
|
63381
|
+
* The match is on that SUBCLASS, never on `IntrinsicResolutionRefusalError`:
|
|
63382
|
+
* five of its six throw sites are USER-FIXABLE and are reachable from here
|
|
63383
|
+
* whenever a cross-stack node's ARGUMENT is an `Fn::Sub` / `Fn::GetAtt` /
|
|
63384
|
+
* `Fn::Split` (`resolveSub` re-raises the class), so matching the base class
|
|
63385
|
+
* downgraded a fixable refusal to a finding and let scrub print
|
|
63386
|
+
* `No plaintext secrets found` and exit 0 over surviving plaintext.
|
|
63387
|
+
* - the read SUCCEEDS but the PRODUCER's own state record still holds the
|
|
63388
|
+
* plaintext, for an export the producer's template declares from a
|
|
63389
|
+
* `{{resolve:...}}` expression — REFUSE, see
|
|
63390
|
+
* {@link plaintextProducerCrossStackReadError}.
|
|
63391
|
+
*
|
|
63392
|
+
* Each node is resolved on its OWN — `{ [key]: node[key] }` rather than the
|
|
63393
|
+
* enclosing object — so a bag carrying an unrelated sibling key cannot make
|
|
63394
|
+
* this pass resolve something else, and so a failure names the cross-stack read
|
|
63395
|
+
* rather than whatever the whole node resolves to. Which key of a multi-key
|
|
63396
|
+
* node is even eligible is decided by {@link RESOLVER_INTRINSIC_PRECEDENCE},
|
|
63397
|
+
* mirroring the resolver — so for the CHOICE OF KEY the pass never reads
|
|
63398
|
+
* through an intrinsic the main resolution would ignore.
|
|
63399
|
+
*
|
|
63400
|
+
* That is the whole of the invariant, and it is deliberately narrower than
|
|
63401
|
+
* "this pass performs no read the main resolution would not" (issue #2133
|
|
63402
|
+
* review — the earlier, overstated wording). `resolveValue` walks a plain
|
|
63403
|
+
* object's properties SEQUENTIALLY and aborts the whole bag at the first one
|
|
63404
|
+
* that throws, and it THROWS on an unknown intrinsic (`Fn::ToJsonString`,
|
|
63405
|
+
* `Fn::ForEach`) before descending into its argument at all; this pass resolves
|
|
63406
|
+
* each node on its own and keeps walking in both cases. So it can read a
|
|
63407
|
+
* reference the main resolution never reaches — after a sibling `Ref` to a
|
|
63408
|
+
* resource absent from state, which is the routine case, or beneath an
|
|
63409
|
+
* intrinsic cdkd cannot resolve. The DIRECTION is safe (the deploy made those
|
|
63410
|
+
* reads, so refusing over one names a real unscrubbed producer rather than a
|
|
63411
|
+
* fabricated one) but the sentence is load-bearing for the refusal, so it is
|
|
63412
|
+
* stated as what it is rather than as the stronger claim.
|
|
63413
|
+
*
|
|
63414
|
+
* The bag is NOT rewritten. The main resolution re-resolves these references
|
|
63415
|
+
* itself, and the S3 reads behind them are shared with this pass by the
|
|
63416
|
+
* memoizing backend view `scrubStack` builds (issue #2133 review — before that
|
|
63417
|
+
* every reference cost `listStacks()` plus up to N `getState()` TWICE). What
|
|
63418
|
+
* not rewriting buys back is the whole class of hazard a substituted value
|
|
63419
|
+
* carries: a resolved cross-stack value that is itself intrinsic- or
|
|
63420
|
+
* reference-shaped would be re-interpreted by the stack's own resolver, which
|
|
63421
|
+
* is the shape `SCRUB_SECRET_RESOLUTION_REINTRODUCED_TOKEN` guards against one
|
|
63422
|
+
* class over. The needles this records survive the main resolution failing for
|
|
63423
|
+
* an unrelated reason, since `recordedSecretValues` is filled in place.
|
|
63424
|
+
*
|
|
63425
|
+
* `Fn::If` is walked the way {@link IntrinsicFunctionResolver} walks it — the
|
|
63426
|
+
* SELECTED branch only, with an unknown condition selecting the FALSE branch,
|
|
63427
|
+
* mirroring `resolveIf`. Reading a reference the main resolution would never
|
|
63428
|
+
* touch would refuse a stack over a conditional import the deploy never made.
|
|
63429
|
+
* A MALFORMED `Fn::If` (args not a 3-tuple) is walked with refusals DISARMED
|
|
63430
|
+
* rather than skipped: which branch is live is unknowable, so a read inside it
|
|
63431
|
+
* may be one the deploy never made — the exact hazard the selected-branch
|
|
63432
|
+
* mirror exists to prevent — but any needle it does yield is still worth having.
|
|
63433
|
+
*/
|
|
63434
|
+
function makeCrossStackPrePass(deps) {
|
|
63435
|
+
const { stackName, resolver, producerTemplates, findings, logger } = deps;
|
|
63436
|
+
/**
|
|
63437
|
+
* Is this a refusal NO RE-RUN can clear (issue #2133 review)?
|
|
63438
|
+
*
|
|
63439
|
+
* The predicate is the SUBCLASS, not `IntrinsicResolutionRefusalError`. That
|
|
63440
|
+
* class has six throw sites and only ONE — `resolveGetStackOutput`'s
|
|
63441
|
+
* cross-account arm — is permanent; the other five (a stale placeholder ARN,
|
|
63442
|
+
* a fabricated account id, an unenriched `Fn::GetAtt`, `--strict-getatt`, a
|
|
63443
|
+
* malformed `Fn::Split`) are all fixed by editing the template or deploying
|
|
63444
|
+
* the producer. They are REACHABLE here, because `resolveSub` re-raises the
|
|
63445
|
+
* class, so any cross-stack node whose ARGUMENT is an `Fn::Sub` /
|
|
63446
|
+
* `Fn::GetAtt` / `Fn::Split` can carry one up to this catch. Matching the
|
|
63447
|
+
* base class downgraded every one of them to a finding logged as "cdkd
|
|
63448
|
+
* declines this read by design, so no re-run can change it" — false — and
|
|
63449
|
+
* scrub then printed `No plaintext secrets found` and exited 0 without
|
|
63450
|
+
* `--fail` while the consumer's state.json still held the imported plaintext.
|
|
63451
|
+
*
|
|
63452
|
+
* The CAUSE CHAIN rather than a bare `instanceof`: the refusal reaches this
|
|
63453
|
+
* catch through `resolver.resolve`, which wraps in several places, and a
|
|
63454
|
+
* top-link-only test silently reclassifies a wrapped permanent refusal as
|
|
63455
|
+
* user-fixable — i.e. refuses the whole stack over something no user can fix.
|
|
63456
|
+
*/
|
|
63457
|
+
const isByDesignRefusal = (err) => err instanceof Error && errorCauseChain(err).some((link) => link instanceof CrossAccountSecretRefusalError);
|
|
63458
|
+
return async function resolveCrossStackReads(bag, context, origin, opts) {
|
|
63459
|
+
const canRefuse = opts?.canRefuse ?? true;
|
|
63460
|
+
const secrets = context.recordedSecretValues ?? /* @__PURE__ */ new Map();
|
|
63461
|
+
/**
|
|
63462
|
+
* The producer this reference actually resolved through, and the KEY it
|
|
63463
|
+
* matched — read back out of the resolver's own recording bags.
|
|
63464
|
+
*
|
|
63465
|
+
* Keyed on the intrinsic UNDER TEST rather than "imports first, then output
|
|
63466
|
+
* reads": `resolveImportValue` / `resolveGetStackOutput` each resolve their
|
|
63467
|
+
* ARGUMENTS before recording their own read, so a nested cross-stack read
|
|
63468
|
+
* inside the argument lands in the OTHER family's bag (or earlier in the
|
|
63469
|
+
* same one). Preferring imports unconditionally made an
|
|
63470
|
+
* `Fn::GetStackOutput` whose `StackName` contains an `Fn::ImportValue`
|
|
63471
|
+
* report the INNER producer.
|
|
63472
|
+
*
|
|
63473
|
+
* `.at(-1)` for the same reason within one family: the outer read is
|
|
63474
|
+
* recorded LAST, after its argument has resolved.
|
|
63475
|
+
*
|
|
63476
|
+
* An empty bag means the value did NOT come from a cdkd state record, and
|
|
63477
|
+
* the two arms that produce one are NOT unreadable for the same reason
|
|
63478
|
+
* (issue #2133 review — one rationale used to cover both, and it was false
|
|
63479
|
+
* of the second):
|
|
63480
|
+
*
|
|
63481
|
+
* - the CloudFormation `ListExports` / `DescribeStacks` FALLBACKS record
|
|
63482
|
+
* nothing deliberately, a CFn-managed producer being a weak reference.
|
|
63483
|
+
* Here "no expression to restore" is literally true: the value came from
|
|
63484
|
+
* CloudFormation, which cdkd never redacted, so no `{{resolve:...}}`
|
|
63485
|
+
* expression exists anywhere for scrub to write in its place.
|
|
63486
|
+
* - the CROSS-ACCOUNT `Fn::GetStackOutput` arm records nothing for a
|
|
63487
|
+
* different reason, and a post-#1934 producer in the other account DOES
|
|
63488
|
+
* store the expression — which is precisely why
|
|
63489
|
+
* `CrossAccountSecretRefusalError` exists, and why it fires BEFORE this
|
|
63490
|
+
* point whenever the stored value carries one. What can reach here is a
|
|
63491
|
+
* value that carried none, and scrub cannot classify it any further: the
|
|
63492
|
+
* producer's `state.json` lives in another account and the only
|
|
63493
|
+
* credentials in hand are the consumer's, so reading it is the
|
|
63494
|
+
* wrong-account lookup that refusal exists to prevent.
|
|
63495
|
+
*
|
|
63496
|
+
* The OUTCOME is the same today either way — a cross-account producer is
|
|
63497
|
+
* never in `appStacks`, so the `!producerTemplate` return below fires first
|
|
63498
|
+
* regardless — but a future change that widened `producerTemplates` would
|
|
63499
|
+
* be relying on this sentence, so it must not claim the second arm has no
|
|
63500
|
+
* expression behind it.
|
|
63501
|
+
*
|
|
63502
|
+
* Residual: an OUTER read that fell through to a CloudFormation fallback
|
|
63503
|
+
* while its ARGUMENT performed a same-family cdkd read leaves the inner
|
|
63504
|
+
* entry last, so the check below consults the wrong producer. Bounded by
|
|
63505
|
+
* the key test — the inner key must also exist in the inner producer's
|
|
63506
|
+
* outputs, which it does — and it can only produce a false REFUSAL naming a
|
|
63507
|
+
* real unscrubbed producer, never a false clean.
|
|
63508
|
+
*/
|
|
63509
|
+
const recordedProducer = (key, probe) => {
|
|
63510
|
+
if (key === "Fn::GetStackOutput") {
|
|
63511
|
+
const read = probe.recordedOutputReads?.at(-1);
|
|
63512
|
+
return read ? {
|
|
63513
|
+
stack: read.sourceStack,
|
|
63514
|
+
region: read.sourceRegion,
|
|
63515
|
+
key: read.outputName
|
|
63516
|
+
} : void 0;
|
|
63517
|
+
}
|
|
63518
|
+
const imported = probe.recordedImports?.at(-1);
|
|
63519
|
+
return imported ? {
|
|
63520
|
+
stack: imported.sourceStack,
|
|
63521
|
+
region: imported.sourceRegion,
|
|
63522
|
+
key: imported.exportName
|
|
63523
|
+
} : void 0;
|
|
63524
|
+
};
|
|
63525
|
+
/**
|
|
63526
|
+
* The producer's STORED value for `key` — what its `state.json` actually
|
|
63527
|
+
* holds, as opposed to what the read RETURNED.
|
|
63528
|
+
*
|
|
63529
|
+
* `undefined` when the producer's record cannot be read or does not carry
|
|
63530
|
+
* the key, which is treated as "cannot classify" by the caller rather than
|
|
63531
|
+
* as evidence of anything. Both are near-unreachable on the arms that get
|
|
63532
|
+
* here (the read just succeeded against that record, through the SAME
|
|
63533
|
+
* memoized `getState` promise this call resolves), so the read costs no
|
|
63534
|
+
* extra AWS call.
|
|
63535
|
+
*/
|
|
63536
|
+
const storedProducerValue = async (producer, backend) => {
|
|
63537
|
+
if (!backend) return void 0;
|
|
63538
|
+
let loaded;
|
|
63539
|
+
try {
|
|
63540
|
+
loaded = await backend.getState(producer.stack, producer.region);
|
|
63541
|
+
} catch (err) {
|
|
63542
|
+
logger.debug(`Scrub of ${stackName}: could not re-read producer '${producer.stack}' (${producer.region}) to classify its stored value: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
|
|
63543
|
+
return;
|
|
63544
|
+
}
|
|
63545
|
+
const outputs = loaded?.state?.outputs;
|
|
63546
|
+
if (!outputs || !(producer.key in outputs)) return void 0;
|
|
63547
|
+
return { stored: outputs[producer.key] };
|
|
63548
|
+
};
|
|
63549
|
+
const readOne = async (key, node, path, nodeCanRefuse) => {
|
|
63550
|
+
const probe = {
|
|
63551
|
+
...context,
|
|
63552
|
+
recordedImports: [],
|
|
63553
|
+
recordedOutputReads: []
|
|
63554
|
+
};
|
|
63555
|
+
try {
|
|
63556
|
+
await resolver.resolve({ [key]: node[key] }, probe);
|
|
63557
|
+
} catch (err) {
|
|
63558
|
+
if (!nodeCanRefuse) {
|
|
63559
|
+
logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} could not be resolved, and this position cannot refuse: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`);
|
|
63560
|
+
return;
|
|
63561
|
+
}
|
|
63562
|
+
if (isByDesignRefusal(err)) {
|
|
63563
|
+
const detail = `${key} in ${origin}${path ? ` at ${path}` : ""}: ${maskSecretsInText(err instanceof Error ? err.message : String(err), secrets)}`;
|
|
63564
|
+
findings.unverifiable.push(detail);
|
|
63565
|
+
logger.warn(`Scrub of ${stackName} cannot verify ${detail} — cdkd declines this read by design, so no re-run can change it. The rest of the stack is still scrubbed; this stack is NOT reported clean.`);
|
|
63566
|
+
return;
|
|
63567
|
+
}
|
|
63568
|
+
throw unresolvableCrossStackReadError(origin, stackName, key, path, err, secrets);
|
|
63569
|
+
}
|
|
63570
|
+
const producer = recordedProducer(key, probe);
|
|
63571
|
+
if (!producer) return;
|
|
63572
|
+
const producerTemplate = producerTemplates.get(producer.stack);
|
|
63573
|
+
if (!producerTemplate) return;
|
|
63574
|
+
const verdict = producerPublishesSecretExpression(producerTemplate, producer.key);
|
|
63575
|
+
if (verdict === "no") return;
|
|
63576
|
+
const stored = await storedProducerValue(producer, context.stateBackend);
|
|
63577
|
+
if (!stored) return;
|
|
63578
|
+
if (carriesDynamicReference(stored.stored)) return;
|
|
63579
|
+
if (storedValueCarriesNoPlaintext(stored.stored)) {
|
|
63580
|
+
logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} resolved through producer '${producer.stack}', whose stored value for that key carries no text — nothing to redact, so this is not classified as a plaintext producer.`);
|
|
63581
|
+
return;
|
|
63582
|
+
}
|
|
63583
|
+
if (!nodeCanRefuse) {
|
|
63584
|
+
logger.debug(`Scrub of ${stackName}: ${key} in ${origin}${path ? ` at ${path}` : ""} resolved to a plaintext from unscrubbed producer '${producer.stack}', and this position cannot refuse.`);
|
|
63585
|
+
return;
|
|
63586
|
+
}
|
|
63587
|
+
throw plaintextProducerCrossStackReadError(origin, stackName, key, path, producer.stack, producer.key, verdict, secrets);
|
|
63588
|
+
};
|
|
63589
|
+
const walk = async (v, path, nodeCanRefuse) => {
|
|
63590
|
+
if (Array.isArray(v)) {
|
|
63591
|
+
for (let i = 0; i < v.length; i++) await walk(v[i], `${path}[${i}]`, nodeCanRefuse);
|
|
63592
|
+
return;
|
|
63593
|
+
}
|
|
63594
|
+
if (v === null || typeof v !== "object") return;
|
|
63595
|
+
const node = v;
|
|
63596
|
+
const key = effectiveIntrinsicKey(node);
|
|
63597
|
+
if (key === void 0) {
|
|
63598
|
+
for (const [k, val] of Object.entries(node)) await walk(val, path ? `${path}.${k}` : k, nodeCanRefuse);
|
|
63599
|
+
return;
|
|
63600
|
+
}
|
|
63601
|
+
if (key === "Fn::If") {
|
|
63602
|
+
const args = node["Fn::If"];
|
|
63603
|
+
if (Array.isArray(args) && args.length === 3) {
|
|
63604
|
+
const [conditionName, ifTrue, ifFalse] = args;
|
|
63605
|
+
const selected = typeof conditionName === "string" && context.conditions?.[conditionName] === true ? ifTrue : ifFalse;
|
|
63606
|
+
await walk(selected, `${path}['Fn::If']`, nodeCanRefuse);
|
|
63607
|
+
return;
|
|
63608
|
+
}
|
|
63609
|
+
await walk(args, `${path}['Fn::If']`, false);
|
|
63610
|
+
return;
|
|
63611
|
+
}
|
|
63612
|
+
if (CROSS_STACK_INTRINSIC_KEYS.includes(key)) {
|
|
63613
|
+
await readOne(key, node, path, nodeCanRefuse);
|
|
63614
|
+
return;
|
|
63615
|
+
}
|
|
63616
|
+
await walk(node[key], `${path}['${key}']`, nodeCanRefuse);
|
|
63617
|
+
};
|
|
63618
|
+
await walk(bag, "", canRefuse);
|
|
63619
|
+
};
|
|
63620
|
+
}
|
|
63621
|
+
/**
|
|
62975
63622
|
* Scrub one stack's state. Re-resolves the template's per-resource properties to
|
|
62976
63623
|
* learn the resolved secret VALUES, then replaces those values in the state
|
|
62977
63624
|
* record with their `{{resolve:...}}` expressions. Returns counts; performs no
|
|
@@ -62990,6 +63637,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62990
63637
|
if (acquired) await lockManager.acquireLockWithRetry(stack.stackName, region, void 0, "scrub");
|
|
62991
63638
|
const perResourceSecrets = /* @__PURE__ */ new Map();
|
|
62992
63639
|
const outputSecrets = /* @__PURE__ */ new Map();
|
|
63640
|
+
const prePassFindings = { unverifiable: [] };
|
|
62993
63641
|
try {
|
|
62994
63642
|
const loaded = await stateBackend.getState(stack.stackName, region);
|
|
62995
63643
|
if (!loaded) {
|
|
@@ -62997,7 +63645,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
62997
63645
|
return {
|
|
62998
63646
|
recordsChanged: 0,
|
|
62999
63647
|
secretsFound: 0,
|
|
63000
|
-
secretBearingKeys: 0
|
|
63648
|
+
secretBearingKeys: 0,
|
|
63649
|
+
unverifiableReads: 0
|
|
63001
63650
|
};
|
|
63002
63651
|
}
|
|
63003
63652
|
const state = loaded.state;
|
|
@@ -63014,13 +63663,26 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63014
63663
|
} catch (err) {
|
|
63015
63664
|
logger.debug(`Parameter resolution skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
63016
63665
|
}
|
|
63666
|
+
const resolverStateBackend = memoizeCrossStackStateReads(stateBackend);
|
|
63667
|
+
const resolverContext = (recordedSecretValues) => ({
|
|
63668
|
+
template: stack.template,
|
|
63669
|
+
resources: state.resources,
|
|
63670
|
+
...Object.keys(parameters).length > 0 && { parameters },
|
|
63671
|
+
...Object.keys(conditions).length > 0 && { conditions },
|
|
63672
|
+
stackName: stack.stackName,
|
|
63673
|
+
stateBackend: resolverStateBackend,
|
|
63674
|
+
...recordedSecretValues && { recordedSecretValues },
|
|
63675
|
+
bestEffort: true
|
|
63676
|
+
});
|
|
63677
|
+
const resolveCrossStackReads = makeCrossStackPrePass({
|
|
63678
|
+
stackName: stack.stackName,
|
|
63679
|
+
resolver,
|
|
63680
|
+
producerTemplates: new Map((opts.appStacks ?? []).map((s) => [s.stackName, s.template])),
|
|
63681
|
+
findings: prePassFindings,
|
|
63682
|
+
logger
|
|
63683
|
+
});
|
|
63017
63684
|
try {
|
|
63018
|
-
conditions = await resolver.evaluateConditions(
|
|
63019
|
-
template: stack.template,
|
|
63020
|
-
resources: state.resources,
|
|
63021
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63022
|
-
bestEffort: true
|
|
63023
|
-
});
|
|
63685
|
+
conditions = await resolver.evaluateConditions(resolverContext(outputSecrets));
|
|
63024
63686
|
} catch (err) {
|
|
63025
63687
|
logger.debug(`Condition evaluation skipped for ${stack.stackName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
63026
63688
|
}
|
|
@@ -63029,6 +63691,7 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63029
63691
|
const templateResource = templateResources[logicalId];
|
|
63030
63692
|
if (!templateResource?.Properties) continue;
|
|
63031
63693
|
const recordedSecretValues = /* @__PURE__ */ new Map();
|
|
63694
|
+
perResourceSecrets.set(logicalId, recordedSecretValues);
|
|
63032
63695
|
const resolveInput = await pinCrossRegionSecrets(templateResource.Properties, stack.stackName, {
|
|
63033
63696
|
stackRegion: region,
|
|
63034
63697
|
producerRegions,
|
|
@@ -63037,20 +63700,13 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63037
63700
|
recordedSecretValues,
|
|
63038
63701
|
origin: `resource '${logicalId}'`
|
|
63039
63702
|
});
|
|
63703
|
+
const resourceContext = resolverContext(recordedSecretValues);
|
|
63704
|
+
await resolveCrossStackReads(resolveInput, resourceContext, `resource '${logicalId}'`);
|
|
63040
63705
|
try {
|
|
63041
|
-
await resolver.resolve(resolveInput,
|
|
63042
|
-
template: stack.template,
|
|
63043
|
-
resources: state.resources,
|
|
63044
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63045
|
-
...Object.keys(conditions).length > 0 && { conditions },
|
|
63046
|
-
stackName: stack.stackName,
|
|
63047
|
-
recordedSecretValues,
|
|
63048
|
-
bestEffort: true
|
|
63049
|
-
});
|
|
63706
|
+
await resolver.resolve(resolveInput, resourceContext);
|
|
63050
63707
|
} catch (err) {
|
|
63051
63708
|
logger.debug(`Resolution of ${logicalId} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), recordedSecretValues)}`);
|
|
63052
63709
|
}
|
|
63053
|
-
if (recordedSecretValues.size > 0) perResourceSecrets.set(logicalId, recordedSecretValues);
|
|
63054
63710
|
perResourceTemplateProps.set(logicalId, templateResource.Properties);
|
|
63055
63711
|
}
|
|
63056
63712
|
const templateOutputs = stack.template.Outputs ?? {};
|
|
@@ -63071,16 +63727,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63071
63727
|
recordedSecretValues: outputSecrets,
|
|
63072
63728
|
origin: `Export.Name of output '${name}'`
|
|
63073
63729
|
});
|
|
63730
|
+
const nameContext = resolverContext(outputSecrets);
|
|
63731
|
+
await resolveCrossStackReads(nameSource, nameContext, `Export.Name of output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
|
|
63074
63732
|
try {
|
|
63075
|
-
exportName = await resolver.resolve(nameSource,
|
|
63076
|
-
template: stack.template,
|
|
63077
|
-
resources: state.resources,
|
|
63078
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63079
|
-
...Object.keys(conditions).length > 0 && { conditions },
|
|
63080
|
-
stackName: stack.stackName,
|
|
63081
|
-
recordedSecretValues: outputSecrets,
|
|
63082
|
-
bestEffort: true
|
|
63083
|
-
});
|
|
63733
|
+
exportName = await resolver.resolve(nameSource, nameContext);
|
|
63084
63734
|
} catch (err) {
|
|
63085
63735
|
outputsSourceUntrusted = true;
|
|
63086
63736
|
logger.warn(`Export.Name of output ${name} could not be resolved during scrub (${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}) — redacting this stack's outputs by value match instead of by template position, since state may be keyed under a name this run cannot reproduce.`);
|
|
@@ -63111,16 +63761,10 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63111
63761
|
recordedSecretValues: outputSecrets,
|
|
63112
63762
|
origin: `output '${name}'`
|
|
63113
63763
|
});
|
|
63764
|
+
const valueContext = resolverContext(outputSecrets);
|
|
63765
|
+
await resolveCrossStackReads(valueSource, valueContext, `output '${name}'`, { canRefuse: !isOutputSuppressed(name, output, conditions, state.outputs ?? {}) });
|
|
63114
63766
|
try {
|
|
63115
|
-
await resolver.resolve(valueSource,
|
|
63116
|
-
template: stack.template,
|
|
63117
|
-
resources: state.resources,
|
|
63118
|
-
...Object.keys(parameters).length > 0 && { parameters },
|
|
63119
|
-
...Object.keys(conditions).length > 0 && { conditions },
|
|
63120
|
-
stackName: stack.stackName,
|
|
63121
|
-
recordedSecretValues: outputSecrets,
|
|
63122
|
-
bestEffort: true
|
|
63123
|
-
});
|
|
63767
|
+
await resolver.resolve(valueSource, valueContext);
|
|
63124
63768
|
} catch (err) {
|
|
63125
63769
|
logger.debug(`Resolution of output ${name} during scrub was partial: ${maskSecretsInText(err instanceof Error ? err.message : String(err), outputSecrets)}`);
|
|
63126
63770
|
}
|
|
@@ -63137,7 +63781,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63137
63781
|
if (totalSecrets === 0) return {
|
|
63138
63782
|
recordsChanged: 0,
|
|
63139
63783
|
secretsFound: 0,
|
|
63140
|
-
secretBearingKeys:
|
|
63784
|
+
secretBearingKeys: secretBearingKeys.length,
|
|
63785
|
+
unverifiableReads: prePassFindings.unverifiable.length
|
|
63141
63786
|
};
|
|
63142
63787
|
let recordsChanged = 0;
|
|
63143
63788
|
const newResources = {};
|
|
@@ -63167,7 +63812,8 @@ async function scrubStack(stack, region, stateBackend, lockManager, opts) {
|
|
|
63167
63812
|
return {
|
|
63168
63813
|
recordsChanged,
|
|
63169
63814
|
secretsFound: totalSecrets,
|
|
63170
|
-
secretBearingKeys: secretBearingKeys.length
|
|
63815
|
+
secretBearingKeys: secretBearingKeys.length,
|
|
63816
|
+
unverifiableReads: prePassFindings.unverifiable.length
|
|
63171
63817
|
};
|
|
63172
63818
|
} catch (err) {
|
|
63173
63819
|
throw maskSecretsInError(err, allRecordedSecrets(outputSecrets, perResourceSecrets));
|
|
@@ -73031,7 +73677,7 @@ async function scanReferencedAssets(stateBackend, marker, logger) {
|
|
|
73031
73677
|
try {
|
|
73032
73678
|
parsed = JSON.parse(body);
|
|
73033
73679
|
} catch (error) {
|
|
73034
|
-
const described = describeStateKey(key);
|
|
73680
|
+
const described = describeStateKey(key, STATE_FILE_SUFFIX, DEFAULT_STATE_PREFIX);
|
|
73035
73681
|
const regionMatch = /^(\S+) \((\S+)\)$/.exec(described);
|
|
73036
73682
|
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);
|
|
73037
73683
|
}
|
|
@@ -73236,7 +73882,7 @@ async function gcCommand(options) {
|
|
|
73236
73882
|
});
|
|
73237
73883
|
const stateBackend = new S3StateBackend(markerS3Client, {
|
|
73238
73884
|
bucket: bucketName,
|
|
73239
|
-
prefix:
|
|
73885
|
+
prefix: DEFAULT_STATE_PREFIX
|
|
73240
73886
|
}, {
|
|
73241
73887
|
region: effective.region,
|
|
73242
73888
|
...options.profile && { profile: options.profile }
|
|
@@ -73282,7 +73928,7 @@ async function gcCommand(options) {
|
|
|
73282
73928
|
const marker = parseBootstrapMarker(markerBody, resolvedMarkerKey);
|
|
73283
73929
|
const lockKeys = await listAllLockKeys(stateBackend);
|
|
73284
73930
|
if (lockKeys.length > 0) {
|
|
73285
|
-
const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX)} [${k}]`).join("\n");
|
|
73931
|
+
const listing = lockKeys.map((k) => ` - ${describeStateKey(k, LOCK_FILE_SUFFIX, DEFAULT_STATE_PREFIX)} [${k}]`).join("\n");
|
|
73286
73932
|
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");
|
|
73287
73933
|
}
|
|
73288
73934
|
const refs = await scanReferencedAssets(stateBackend, marker, logger);
|
|
@@ -85084,7 +85730,7 @@ async function promptConfirm(message) {
|
|
|
85084
85730
|
* callers get the standard exit-code-2 routing.
|
|
85085
85731
|
*/
|
|
85086
85732
|
function createMigrateCommand() {
|
|
85087
|
-
return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default(
|
|
85733
|
+
return new Command("migrate").description("Adopt a plain (non-CDK) CloudFormation stack into a cdkd-managed CDK app. Generates new CDK code via upstream `cdk migrate`, builds a logical-ID mapping between the source CFn template and the synth template, writes cdkd state, and (optionally) retires the source CFn stack. AWS resources are never modified.").argument("[stack]", "Source CFn stack name. Alias for --from-cfn-stack.").addOption(new Option("--from-cfn-stack <name>", "Source CloudFormation stack name to adopt. Required (or pass positionally).")).addOption(new Option("--output-dir <dir>", "Directory to write the generated CDK app to. Defaults to <cwd>/<CfnStackName>.")).addOption(new Option("--language <choice>", "Generated code language. v1: typescript only.").choices(["typescript"]).default("typescript")).addOption(new Option("--region <region>", "AWS region. Defaults to AWS_REGION env / profile.")).addOption(new Option("--account <id>", "AWS account ID. Auto-detected via STS when omitted.")).addOption(new Option("--retire-cfn-stack", "After cdkd state is written, inject DeletionPolicy=Retain on every resource in the source CFn stack and DeleteStack. AWS resources stay; the CFn stack record is gone. Off by default.").default(false)).addOption(new Option("--filter <key=value>", "Pass-through to `cdk migrate --filter` for resource subsetting. Repeatable.").argParser((value, previous) => [...previous ?? [], value]).default([])).addOption(new Option("--skip-install", "Skip `npm install` after codegen.").default(false)).addOption(new Option("--skip-synth", "Skip `cdk synth` (does NOT write cdkd state). Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("--dry-run", "Print the import plan without writing state or retiring the CFn stack. Mutually exclusive with --retire-cfn-stack.").default(false)).addOption(new Option("-y, --yes", "Auto-confirm the import + retirement prompts.").default(false)).addOption(new Option("--cdk-bin <path>", "Override the `cdk` binary path.")).addOption(new Option("--resource-mapping <file>", `Path to a JSON file of {sourceLogicalId: synthLogicalId} overrides. Same shape as the auto-written ${RESOURCE_MAPPING_FILENAME}.`)).addOption(new Option("--state-bucket <name>", "cdkd state bucket. Defaults to cdkd-state-<accountId>.")).addOption(new Option("--state-prefix <prefix>", "cdkd state prefix inside the bucket.").default(DEFAULT_STATE_PREFIX)).addOption(new Option("--profile <name>", "AWS profile name.")).addOption(new Option("--role-arn <arn>", "IAM role to assume before any AWS call.")).addOption(new Option("--verbose", "Enable debug-level logging.").default(false)).action(withErrorHandling(migrateCommandAction));
|
|
85088
85734
|
}
|
|
85089
85735
|
|
|
85090
85736
|
//#endregion
|
|
@@ -85107,7 +85753,7 @@ function createMigrateCommand() {
|
|
|
85107
85753
|
*/
|
|
85108
85754
|
function buildProgram() {
|
|
85109
85755
|
const program = new Command();
|
|
85110
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.
|
|
85756
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.284.32");
|
|
85111
85757
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
85112
85758
|
const { profile } = actionCommand.optsWithGlobals();
|
|
85113
85759
|
if (profile !== void 0) process.env["AWS_PROFILE"] = profile;
|