@go-to-k/cdkd 0.286.3 → 0.286.4
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-uaOnRUMe.js → asg-provider-dBSdt3nB.js} +146 -4
- package/dist/asg-provider-dBSdt3nB.js.map +1 -0
- package/dist/cli.js +2 -2
- package/dist/{deploy-engine--rkIGhow.js → deploy-engine-DylsbAdu.js} +1218 -161
- package/dist/deploy-engine-DylsbAdu.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-CG34-sQd.js → program-dEYAo80R.js} +543 -319
- package/dist/{program-CG34-sQd.js.map → program-dEYAo80R.js.map} +1 -1
- package/dist/{version-D3md0Lde.js → version-CKOnsgg1.js} +2 -2
- package/dist/{version-D3md0Lde.js.map → version-CKOnsgg1.js.map} +1 -1
- package/package.json +2 -1
- package/dist/asg-provider-uaOnRUMe.js.map +0 -1
- package/dist/deploy-engine--rkIGhow.js.map +0 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { _ as withStackName, d as applyDefaultNameForFallback, f as generateResourceName, h as looksLikeCdkdGeneratedName, m as getCurrentStackName, n as getLogger, p as generateResourceNameWithFallback, r as isStdoutReservedForPayload, s as getLiveRenderer } from "./logger-Dw-3y48G.js";
|
|
2
2
|
import { t as awsClientDefaults } from "./aws-client-defaults-D-iYiIJ6.js";
|
|
3
|
-
import { t as getCdkdVersion } from "./version-
|
|
3
|
+
import { t as getCdkdVersion } from "./version-CKOnsgg1.js";
|
|
4
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
5
|
import { 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";
|
|
@@ -6085,6 +6085,49 @@ var FileAssetPublisher = class {
|
|
|
6085
6085
|
}
|
|
6086
6086
|
};
|
|
6087
6087
|
|
|
6088
|
+
//#endregion
|
|
6089
|
+
//#region src/utils/regexp.ts
|
|
6090
|
+
/**
|
|
6091
|
+
* Escape a literal string for embedding in a RegExp.
|
|
6092
|
+
*
|
|
6093
|
+
* Lives here because the same four-line helper was spelled THREE times — in
|
|
6094
|
+
* `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
|
|
6095
|
+
* `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
|
|
6096
|
+
* (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
|
|
6097
|
+
* label) into a pattern. Copies of a security-shaped helper are exactly the
|
|
6098
|
+
* shape that drifts: the escaped set is what stops a `.` in an interpolated
|
|
6099
|
+
* literal matching ANY character, and in `gc.ts` a widened match decides which
|
|
6100
|
+
* live assets are treated as referenced.
|
|
6101
|
+
*
|
|
6102
|
+
* The character set is the union every JS engine treats as special in a
|
|
6103
|
+
* non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
|
|
6104
|
+
* character class, and no caller embeds into one.
|
|
6105
|
+
*/
|
|
6106
|
+
function escapeRegExp$1(value) {
|
|
6107
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6108
|
+
}
|
|
6109
|
+
/**
|
|
6110
|
+
* Remove control characters from a value about to be PRINTED.
|
|
6111
|
+
*
|
|
6112
|
+
* Lives beside {@link escapeRegExp} for the reason that helper's own note
|
|
6113
|
+
* gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
|
|
6114
|
+
* in `src/deployment/outputs-export-alias.ts` — and copies of a
|
|
6115
|
+
* security-shaped helper drift. Both print the same class of string: a
|
|
6116
|
+
* resolved CloudFormation Output / `Export.Name`, which passed no CFn
|
|
6117
|
+
* validator, so it can carry ANSI escapes or bidi overrides straight into a
|
|
6118
|
+
* terminal or a CI log.
|
|
6119
|
+
*
|
|
6120
|
+
* C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
|
|
6121
|
+
* only: a `--json` payload is a machine interface where an export name is data
|
|
6122
|
+
* a consumer may match on, and mutating it there trades a correctness
|
|
6123
|
+
* regression for a display concern (see `stripDisplayOnlyChars`, which is the
|
|
6124
|
+
* narrower guard for an already-serialized payload and stays local to its
|
|
6125
|
+
* caller because its C0 exclusion is specific to that path).
|
|
6126
|
+
*/
|
|
6127
|
+
function stripControlChars(value) {
|
|
6128
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
6129
|
+
}
|
|
6130
|
+
|
|
6088
6131
|
//#endregion
|
|
6089
6132
|
//#region src/utils/docker-cmd.ts
|
|
6090
6133
|
/**
|
|
@@ -6503,6 +6546,21 @@ const REDACTED_ARGV_VALUE = "***";
|
|
|
6503
6546
|
* - `--opt` — `DockerVolumeConfiguration.DriverOpts`, which for the `local`
|
|
6504
6547
|
* driver carries mount options (`o=addr=…,username=…,password=…`).
|
|
6505
6548
|
* - `--label` — `DockerVolumeConfiguration.Labels`, user-authored metadata.
|
|
6549
|
+
* - `--build-arg` — a `DockerImageAsset`'s `buildArgs`, forwarded by
|
|
6550
|
+
* `src/assets/docker-build.ts` on BOTH the deploy-time ECR publish path and
|
|
6551
|
+
* `cdkd local run-task`'s image build
|
|
6552
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)). The value is
|
|
6553
|
+
* frequently NOT a secret and IS diagnostic (a version pin, a base-image
|
|
6554
|
+
* tag), which is why this one needed arguing rather than assuming — the
|
|
6555
|
+
* argument that settles it is RECOVERABILITY, not likelihood. The build-arg
|
|
6556
|
+
* values sit unredacted in `cdk.out/*.assets.json` on the operator's own
|
|
6557
|
+
* disk, so a masked `--verbose` line costs one `jq` against a file they
|
|
6558
|
+
* already have; the log line is the copy that travels into a CI archive and
|
|
6559
|
+
* a pasted issue, where a build-time registry / package token
|
|
6560
|
+
* (`NPM_TOKEN`, `GITHUB_TOKEN` — a common, if discouraged, use of
|
|
6561
|
+
* `buildArgs`) is disclosed irreversibly. The KEY survives, so "which build
|
|
6562
|
+
* arg" — the half of the diagnostic that identifies the failure — is
|
|
6563
|
+
* unaffected.
|
|
6506
6564
|
*
|
|
6507
6565
|
* A flag NOT in this list keeps its value. For most of the argv that is
|
|
6508
6566
|
* because the value is cdkd-authored or infrastructure-shaped — a container
|
|
@@ -6518,33 +6576,348 @@ const REDACTED_ARGV_VALUE = "***";
|
|
|
6518
6576
|
* `Environment` / `Secrets` / `DriverOpts`. Revisit per flag if a real leak is
|
|
6519
6577
|
* found through one; do not widen the set on suspicion, since every addition
|
|
6520
6578
|
* trades away diagnostic text.
|
|
6579
|
+
*
|
|
6580
|
+
* Two `docker build` flags were considered WITH `--build-arg` and deliberately
|
|
6581
|
+
* left out, because both carry a LOCATOR rather than a value
|
|
6582
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)):
|
|
6583
|
+
*
|
|
6584
|
+
* - `--secret id=<id>,src=<path>` (or `,env=<NAME>`) — BuildKit resolves the
|
|
6585
|
+
* material itself at build time and docker has NO inline-value syntax, so
|
|
6586
|
+
* cdkd never holds the secret to leak. Masking would also blank the `id`,
|
|
6587
|
+
* since the mask keys on the first `=` and `id` is what sits there — trading
|
|
6588
|
+
* the whole diagnostic ("which secret, sourced from where") for a path.
|
|
6589
|
+
* - `--build-context <name>=<path|image-ref|git-url>` — a path or a ref, like
|
|
6590
|
+
* the positional image. A URL form CAN embed a credential, but so can the
|
|
6591
|
+
* image ref; masking one of the two would be a guarantee this set does not
|
|
6592
|
+
* make, and neither is a documented place to put one.
|
|
6593
|
+
*
|
|
6594
|
+
* `--cache-from` / `--cache-to` were in that second bullet for one review
|
|
6595
|
+
* round and the security reviewer was right to refuse it: their value is a
|
|
6596
|
+
* comma-separated PARAM LIST, and BuildKit's cache backends take real
|
|
6597
|
+
* credentials inline there — s3's `access_key_id` / `secret_access_key` /
|
|
6598
|
+
* `session_token`, azblob's `secret_access_key`, gha's `token`. Those are not
|
|
6599
|
+
* locators, so they get their own structural mask keyed on the PARAM name:
|
|
6600
|
+
* {@link ARGV_PARAM_LIST_FLAGS} / {@link ARGV_PARAM_LIST_LOCATOR_PARAMS}.
|
|
6521
6601
|
*/
|
|
6522
6602
|
const ARGV_VALUE_BEARING_FLAGS = /* @__PURE__ */ new Set([
|
|
6523
6603
|
"-e",
|
|
6524
6604
|
"--env",
|
|
6525
6605
|
"--opt",
|
|
6526
|
-
"--label"
|
|
6606
|
+
"--label",
|
|
6607
|
+
"--build-arg"
|
|
6608
|
+
]);
|
|
6609
|
+
/**
|
|
6610
|
+
* `docker` argv flags whose NEXT token is a COMMA-SEPARATED `key=value` param
|
|
6611
|
+
* list rather than one `KEY=VALUE` pair ([#2623](https://github.com/go-to-k/cdkd/issues/2623)
|
|
6612
|
+
* security review).
|
|
6613
|
+
*
|
|
6614
|
+
* Only the params in {@link ARGV_PARAM_LIST_LOCATOR_PARAMS} survive, and the
|
|
6615
|
+
* rest of the list is masked — `type=s3,region=us-east-1,bucket=b` IS the
|
|
6616
|
+
* diagnostic ("which backend, where"), and blanking the whole value would
|
|
6617
|
+
* destroy it to hide one field.
|
|
6618
|
+
*/
|
|
6619
|
+
const ARGV_PARAM_LIST_FLAGS = /* @__PURE__ */ new Set(["--cache-from", "--cache-to"]);
|
|
6620
|
+
/**
|
|
6621
|
+
* Param names inside an {@link ARGV_PARAM_LIST_FLAGS} value that are LOCATORS
|
|
6622
|
+
* and therefore survive. **Everything else in the list is masked** — this is
|
|
6623
|
+
* an ALLOWLIST, and it is one deliberately.
|
|
6624
|
+
*
|
|
6625
|
+
* The first cut was a denylist of the credential params BuildKit's backends
|
|
6626
|
+
* spell (`secret_access_key`, `access_key_id`, `session_token`, `token`), and
|
|
6627
|
+
* both round-2 reviewers broke it the same way:
|
|
6628
|
+
* `DockerCacheOption.params` is an arbitrary user map that
|
|
6629
|
+
* `cacheOptionToFlag` joins with a bare `,` and no quoting, so a value
|
|
6630
|
+
* CONTAINING a comma (`token=aa,bb`, or BuildKit's own legal
|
|
6631
|
+
* `secret_access_key="ab,cd"`) split into a masked head and a bare tail that
|
|
6632
|
+
* printed verbatim — output that LOOKS redacted. Patching that one shape is
|
|
6633
|
+
* the enumerate-bad-shapes treadmill; inverting the test ends it, because a
|
|
6634
|
+
* CSV continuation fragment has no recognised param name and so masks by
|
|
6635
|
+
* construction.
|
|
6636
|
+
*
|
|
6637
|
+
* The trade is the right way round for a leak fix: a param missing from this
|
|
6638
|
+
* list costs one degraded diagnostic, a param missing from a denylist costs a
|
|
6639
|
+
* printed credential. The list covers every backend BuildKit ships —
|
|
6640
|
+
* `registry` (`ref`), `local` (`dest` / `src` / `digest` / `tag`), `s3`
|
|
6641
|
+
* (`region` / `bucket` / `name` / the prefixes / `use_path_style`), `azblob`
|
|
6642
|
+
* (`account_name` / `name` / `prefix`), `gha` (`scope` / `timeout`),
|
|
6643
|
+
* `inline` (none) — plus the shared export options, so the common case masks
|
|
6644
|
+
* nothing.
|
|
6645
|
+
*
|
|
6646
|
+
* The four URL-valued locators live in {@link ARGV_PARAM_LIST_URL_PARAMS}
|
|
6647
|
+
* instead: they survive only after their userinfo and query are stripped. An
|
|
6648
|
+
* earlier revision kept them HERE, whole, arguing the repo's locator policy;
|
|
6649
|
+
* that paragraph is gone rather than softened, because azblob makes it false.
|
|
6650
|
+
*
|
|
6651
|
+
* Matched case-INSENSITIVELY on the trimmed param name — the comparison is
|
|
6652
|
+
* over a user-supplied key and BuildKit's own option parsing is
|
|
6653
|
+
* case-insensitive, so a `Secret_Access_Key` spelling must not walk past.
|
|
6654
|
+
*/
|
|
6655
|
+
const ARGV_PARAM_LIST_LOCATOR_PARAMS = /* @__PURE__ */ new Set([
|
|
6656
|
+
"type",
|
|
6657
|
+
"mode",
|
|
6658
|
+
"compression",
|
|
6659
|
+
"compression-level",
|
|
6660
|
+
"force-compression",
|
|
6661
|
+
"ignore-error",
|
|
6662
|
+
"image-manifest",
|
|
6663
|
+
"oci-mediatypes",
|
|
6664
|
+
"timeout",
|
|
6665
|
+
"ref",
|
|
6666
|
+
"dest",
|
|
6667
|
+
"src",
|
|
6668
|
+
"digest",
|
|
6669
|
+
"tag",
|
|
6670
|
+
"region",
|
|
6671
|
+
"bucket",
|
|
6672
|
+
"name",
|
|
6673
|
+
"prefix",
|
|
6674
|
+
"manifests_prefix",
|
|
6675
|
+
"blobs_prefix",
|
|
6676
|
+
"use_path_style",
|
|
6677
|
+
"upload_parallelism",
|
|
6678
|
+
"touch_refresh",
|
|
6679
|
+
"account_name",
|
|
6680
|
+
"scope"
|
|
6681
|
+
]);
|
|
6682
|
+
/**
|
|
6683
|
+
* Locator params whose value is a URL, and which therefore survive only after
|
|
6684
|
+
* their USERINFO, QUERY and FRAGMENT are stripped — and only when the value
|
|
6685
|
+
* parses at all
|
|
6686
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623) round-3 security
|
|
6687
|
+
* review).
|
|
6688
|
+
*
|
|
6689
|
+
* These were plain allowlist entries for one round, on the reasoning that a
|
|
6690
|
+
* credential-in-URL is an incidental hazard shared with `--build-context` and
|
|
6691
|
+
* the positional image ref. That reasoning does not survive contact with
|
|
6692
|
+
* azblob: BuildKit builds an UNAUTHENTICATED client from `account_url` when
|
|
6693
|
+
* `secret_access_key` is absent, so a SAS token in its query string is that
|
|
6694
|
+
* backend's SUPPORTED auth path, not an accident. With every other param now
|
|
6695
|
+
* fail-closed, leaving the whole URL was the one entry carrying credential
|
|
6696
|
+
* material by design.
|
|
6697
|
+
*
|
|
6698
|
+
* Scheme and host survive, which is what "which endpoint" needs — on the
|
|
6699
|
+
* ordinary path. FOUR things mask more than that, and every one of them also
|
|
6700
|
+
* masks each param after it in the same value, because they all set `masked`.
|
|
6701
|
+
* Three take the value WHOLE: no recognisable `//` authority; a host that is
|
|
6702
|
+
* really `user:password` with its `@` cut away; and a later param carrying
|
|
6703
|
+
* this URL's severed `@` (the caller's rule, since only it still holds the
|
|
6704
|
+
* parts). The fourth is milder in what it keeps, not in what it cascades — a
|
|
6705
|
+
* `@` in the PATH costs the host but keeps scheme and tail (`https://h/x@y`
|
|
6706
|
+
* -> `https://***@y`). Over-masking is the deliberate direction: see
|
|
6707
|
+
* {@link redactUrlLocator} for what each boundary rule leaked before this one.
|
|
6708
|
+
*/
|
|
6709
|
+
const ARGV_PARAM_LIST_URL_PARAMS = /* @__PURE__ */ new Set([
|
|
6710
|
+
"account_url",
|
|
6711
|
+
"endpoint_url",
|
|
6712
|
+
"url",
|
|
6713
|
+
"url_v2"
|
|
6527
6714
|
]);
|
|
6528
6715
|
/**
|
|
6716
|
+
* A URL locator with its credential-bearing parts removed: `userinfo@`, and
|
|
6717
|
+
* everything from `?` or `#` on.
|
|
6718
|
+
*
|
|
6719
|
+
* Returns the input UNCHANGED when there was nothing to strip, which is the
|
|
6720
|
+
* signal the caller keys "was this masked?" on — so every other branch must
|
|
6721
|
+
* differ from its input. An unparseable or implausible value returns `***`
|
|
6722
|
+
* rather than itself: these four params are the only ones exempt from the
|
|
6723
|
+
* allowlist's mask-by-default rule, so this function is their whole
|
|
6724
|
+
* protection and it fails CLOSED.
|
|
6725
|
+
*/
|
|
6726
|
+
function redactUrlLocator(value) {
|
|
6727
|
+
if (value === "") return value;
|
|
6728
|
+
const authority = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:)?\/\//.exec(value);
|
|
6729
|
+
if (authority === null) return REDACTED_ARGV_VALUE;
|
|
6730
|
+
const prefix = authority[0];
|
|
6731
|
+
let rest = value.slice(prefix.length);
|
|
6732
|
+
const lastAt = rest.lastIndexOf("@");
|
|
6733
|
+
if (lastAt >= 0) rest = `${REDACTED_ARGV_VALUE}${rest.slice(lastAt)}`;
|
|
6734
|
+
else {
|
|
6735
|
+
const host = rest.split(/[/?#]/)[0];
|
|
6736
|
+
const ipv6 = /^\[[0-9A-Fa-f:.]+\]/.exec(host);
|
|
6737
|
+
const bare = ipv6 ? host.slice(ipv6[0].length) : host;
|
|
6738
|
+
if (/:(?!\d{1,5}$)/.test(bare)) return REDACTED_ARGV_VALUE;
|
|
6739
|
+
}
|
|
6740
|
+
for (const delimiter of ["?", "#"]) {
|
|
6741
|
+
const at = rest.indexOf(delimiter);
|
|
6742
|
+
if (at >= 0) rest = `${rest.slice(0, at)}${delimiter}${REDACTED_ARGV_VALUE}`;
|
|
6743
|
+
}
|
|
6744
|
+
return `${prefix}${rest}`;
|
|
6745
|
+
}
|
|
6746
|
+
/**
|
|
6747
|
+
* The masked rendering of `value` for `flag`, or `undefined` when this flag
|
|
6748
|
+
* carries nothing to mask.
|
|
6749
|
+
*
|
|
6750
|
+
* The single place every argv spelling converges — `--flag VALUE` (two
|
|
6751
|
+
* tokens), `--flag=VALUE` (one), and pflag's shorthand cluster in both forms,
|
|
6752
|
+
* which reach it through {@link maskAttachedShortFlag} and through
|
|
6753
|
+
* {@link trailingShortFlagOfCluster} in {@link redactDockerArgvValues}.
|
|
6754
|
+
*
|
|
6755
|
+
* The joined form is valid docker syntax for every long flag here, and it was
|
|
6756
|
+
* UNMASKED until the [#2623](https://github.com/go-to-k/cdkd/issues/2623)
|
|
6757
|
+
* review: harmless while every argv this repo builds emits two tokens, but
|
|
6758
|
+
* `src/assets/docker-build.ts`'s `executable` source mode renders a
|
|
6759
|
+
* USER-AUTHORED command line, and a wrapper script spelling
|
|
6760
|
+
* `--build-arg=NPM_TOKEN=…` leaked it verbatim into both the `--verbose` log
|
|
6761
|
+
* and a thrown error.
|
|
6762
|
+
*/
|
|
6763
|
+
function maskArgvFlagValue(flag, value) {
|
|
6764
|
+
if (ARGV_VALUE_BEARING_FLAGS.has(flag)) {
|
|
6765
|
+
const eqIdx = value.indexOf("=");
|
|
6766
|
+
return eqIdx >= 0 ? `${value.substring(0, eqIdx)}=${REDACTED_ARGV_VALUE}` : void 0;
|
|
6767
|
+
}
|
|
6768
|
+
if (ARGV_PARAM_LIST_FLAGS.has(flag)) {
|
|
6769
|
+
let masked = false;
|
|
6770
|
+
const rawParts = value.split(",");
|
|
6771
|
+
const parts = rawParts.map((part, index) => {
|
|
6772
|
+
if (part === "") return part;
|
|
6773
|
+
if (masked) return REDACTED_ARGV_VALUE;
|
|
6774
|
+
const eqIdx = part.indexOf("=");
|
|
6775
|
+
if (eqIdx < 0) {
|
|
6776
|
+
if (index === 0) return part;
|
|
6777
|
+
masked = true;
|
|
6778
|
+
return REDACTED_ARGV_VALUE;
|
|
6779
|
+
}
|
|
6780
|
+
const name = part.substring(0, eqIdx);
|
|
6781
|
+
const normalized = name.trim().toLowerCase();
|
|
6782
|
+
if (ARGV_PARAM_LIST_URL_PARAMS.has(normalized)) {
|
|
6783
|
+
if (rawParts.slice(index + 1).some((later) => later.includes("@"))) {
|
|
6784
|
+
masked = true;
|
|
6785
|
+
return `${name}=${REDACTED_ARGV_VALUE}`;
|
|
6786
|
+
}
|
|
6787
|
+
const rawUrl = part.substring(eqIdx + 1);
|
|
6788
|
+
const safeUrl = redactUrlLocator(rawUrl);
|
|
6789
|
+
if (safeUrl === rawUrl) return part;
|
|
6790
|
+
masked = true;
|
|
6791
|
+
return `${name}=${safeUrl}`;
|
|
6792
|
+
}
|
|
6793
|
+
if (ARGV_PARAM_LIST_LOCATOR_PARAMS.has(normalized)) return part;
|
|
6794
|
+
masked = true;
|
|
6795
|
+
return `${name}=${REDACTED_ARGV_VALUE}`;
|
|
6796
|
+
});
|
|
6797
|
+
return masked ? parts.join(",") : void 0;
|
|
6798
|
+
}
|
|
6799
|
+
}
|
|
6800
|
+
/**
|
|
6801
|
+
* The masked rendering of an ATTACHED short-flag element (`-eKEY=VALUE`), or
|
|
6802
|
+
* `undefined` when this element is not one.
|
|
6803
|
+
*
|
|
6804
|
+
* pflag — which docker uses — accepts a short flag's value glued to it, so
|
|
6805
|
+
* `-eNPM_TOKEN=secret` is `-e NPM_TOKEN=secret`, and neither the two-token nor
|
|
6806
|
+
* the `--flag=VALUE` branch can see it ([#2623](https://github.com/go-to-k/cdkd/issues/2623)
|
|
6807
|
+
* round-2 security review). No argv this repo BUILDS uses the form, but
|
|
6808
|
+
* `src/assets/docker-build.ts`'s `executable` source mode hands a user's own
|
|
6809
|
+
* command line straight to `spawn`, which is the whole reason the joined form
|
|
6810
|
+
* is masked too.
|
|
6811
|
+
*
|
|
6812
|
+
* This OVER-MASKS relative to pflag, and the earlier claim that it "follows
|
|
6813
|
+
* docker's own parse" was wrong: pflag stops at the first cluster letter that
|
|
6814
|
+
* consumes a value, so it reads `-file=a=b` as `-f` taking `ile=a=b` while
|
|
6815
|
+
* this masks at the `e`. Every such divergence costs a diagnostic and hides
|
|
6816
|
+
* nothing that was not already a `KEY=VALUE` pair, so the direction is the
|
|
6817
|
+
* safe one — but it is a divergence, not parity.
|
|
6818
|
+
*/
|
|
6819
|
+
function maskAttachedShortFlag(arg) {
|
|
6820
|
+
if (arg.startsWith("--") || !arg.startsWith("-")) return void 0;
|
|
6821
|
+
let chosen;
|
|
6822
|
+
for (const flag of ARGV_VALUE_BEARING_FLAGS) {
|
|
6823
|
+
if (flag.length !== 2 || !flag.startsWith("-") || flag[1] === "-") continue;
|
|
6824
|
+
const at = arg.indexOf(flag[1], 1);
|
|
6825
|
+
if (at < 0 || at === arg.length - 1) continue;
|
|
6826
|
+
if (chosen === void 0 || at < chosen.at) chosen = {
|
|
6827
|
+
at,
|
|
6828
|
+
flag
|
|
6829
|
+
};
|
|
6830
|
+
}
|
|
6831
|
+
if (chosen === void 0) return void 0;
|
|
6832
|
+
const masked = maskArgvFlagValue(chosen.flag, arg.slice(chosen.at + 1));
|
|
6833
|
+
return masked === void 0 ? void 0 : `${arg.slice(0, chosen.at + 1)}${masked}`;
|
|
6834
|
+
}
|
|
6835
|
+
/**
|
|
6836
|
+
* The value-bearing short flag a shorthand CLUSTER ENDS in, if any — the form
|
|
6837
|
+
* that takes its value from the NEXT argv element.
|
|
6838
|
+
*
|
|
6839
|
+
* pflag's `parseSingleShortArg` falls through to `value = args[0]` when the
|
|
6840
|
+
* cluster runs out, so `-itde K=v` is `-i -t -d -e K=v` with the value in a
|
|
6841
|
+
* separate token. {@link maskAttachedShortFlag} bails on a flag letter in last
|
|
6842
|
+
* position (there is nothing attached to mask) and the two-token branch looked
|
|
6843
|
+
* `-itde` up as a whole flag and missed, so this spelling printed verbatim
|
|
6844
|
+
* until [#2623](https://github.com/go-to-k/cdkd/issues/2623)'s round-4 review
|
|
6845
|
+
* measured it. Pass 2 cannot rescue it either: its alternation needs a
|
|
6846
|
+
* whitespace-preceded `-e`.
|
|
6847
|
+
*/
|
|
6848
|
+
function trailingShortFlagOfCluster(arg) {
|
|
6849
|
+
if (arg.startsWith("--") || !arg.startsWith("-")) return void 0;
|
|
6850
|
+
const last = arg[arg.length - 1];
|
|
6851
|
+
for (const flag of ARGV_VALUE_BEARING_FLAGS) if (flag.length === 2 && flag.startsWith("-") && flag[1] !== "-" && flag[1] === last) return flag;
|
|
6852
|
+
}
|
|
6853
|
+
/**
|
|
6529
6854
|
* Structural, whitespace-delimited form of {@link ARGV_VALUE_BEARING_FLAGS}
|
|
6530
6855
|
* for scanning a STRING that embeds a space-joined argv. Keyed on the FLAG's
|
|
6531
6856
|
* position — never on the secret's value, so an unrelated literal that merely
|
|
6532
6857
|
* coincides with a value is left alone.
|
|
6533
6858
|
*
|
|
6859
|
+
* Covers the SEPARATED and JOINED spellings of
|
|
6860
|
+
* {@link ARGV_VALUE_BEARING_FLAGS} only. Two gaps, recorded rather than
|
|
6861
|
+
* implied. pflag's shorthand cluster is not modelled here in EITHER form —
|
|
6862
|
+
* attached (`-eKEY=VALUE` / `-itdeKEY=VALUE`) or separated (`-itde KEY=VALUE`,
|
|
6863
|
+
* which this pass's alternation cannot see because it needs a
|
|
6864
|
+
* whitespace-preceded `-e`) — this pass has no argv to resolve
|
|
6865
|
+
* the cluster against, and a bare `-e` prefix scan over free text would fire
|
|
6866
|
+
* on any word starting with `-e`; {@link redactDockerArgvValues} and passes 1
|
|
6867
|
+
* / 1b handle it wherever an argv IS available, which is every composer. And
|
|
6868
|
+
* {@link ARGV_PARAM_LIST_FLAGS}
|
|
6869
|
+
* is deliberately absent: this pass runs with NO argv to key on, and picking a
|
|
6870
|
+
* param name out of a free-text comma list is where a structural match stops
|
|
6871
|
+
* being structural. The param mask therefore rides passes 1 and 1b, both of
|
|
6872
|
+
* which derive from {@link redactDockerArgvValues} and so have the real argv —
|
|
6873
|
+
* i.e. a cache credential is masked in every text composed through a composer,
|
|
6874
|
+
* and not in a bare text handed to {@link redactDockerArgvInText} with no
|
|
6875
|
+
* `args`. Recorded rather than implied.
|
|
6876
|
+
*
|
|
6877
|
+
* DERIVED from the Set rather than re-spelled beside it
|
|
6878
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)). Until then this was
|
|
6879
|
+
* a hand-written literal listing the same four flags, i.e. the shape where
|
|
6880
|
+
* adding a flag to one spelling and not the other masks it on the argv pass
|
|
6881
|
+
* and leaks it on the text pass — a divergence no behavioural test of either
|
|
6882
|
+
* one can see. Deriving is stronger than fencing the pair: there is nothing
|
|
6883
|
+
* left to diverge.
|
|
6884
|
+
*
|
|
6534
6885
|
* What each piece actually buys, since one of them was mis-credited in review:
|
|
6535
6886
|
* the `(^|\s)` lead is what stops `-e` matching inside `--env` (there is no
|
|
6536
6887
|
* `m` flag, so `^` is start-of-INPUT), NOT the alternation order — reordering
|
|
6537
6888
|
* the branches leaves every test green. The order is kept as belt and braces
|
|
6538
|
-
* only
|
|
6539
|
-
* `-easy` / `--environment` matching
|
|
6540
|
-
*
|
|
6541
|
-
|
|
6542
|
-
|
|
6889
|
+
* only, which is why the derivation sorts longest-first. The separator group
|
|
6890
|
+
* IS load-bearing: `\s+` is what stops `-easy` / `--environment` matching, and
|
|
6891
|
+
* the `=` alternative is the joined `--build-arg=KEY=VALUE` spelling
|
|
6892
|
+
* {@link maskArgvFlagValue} handles on the argv side (the group can never be
|
|
6893
|
+
* empty, so `--build-argument=X=y` still cannot match). The KEY is `[^\s=]*`
|
|
6894
|
+
* rather than `+` so an EMPTY key (`-e =value`) is masked too.
|
|
6895
|
+
*
|
|
6896
|
+
* The alternation is asserted NON-EMPTY at construction. An empty
|
|
6897
|
+
* {@link ARGV_VALUE_BEARING_FLAGS} would collapse it to `(^|\s)()(\s+|=)…`,
|
|
6898
|
+
* which masks EVERY whitespace-preceded `k=v` in any docker text — a
|
|
6899
|
+
* fail-OPEN that destroys the diagnostic wholesale, and the one failure mode
|
|
6900
|
+
* deriving from the Set introduced.
|
|
6901
|
+
*/
|
|
6902
|
+
const ARGV_VALUE_TOKEN_RE = (() => {
|
|
6903
|
+
const alternation = [...ARGV_VALUE_BEARING_FLAGS].sort((a, b) => b.length - a.length || a.localeCompare(b)).map(escapeRegExp$1).join("|");
|
|
6904
|
+
if (alternation.length === 0 || [...ARGV_VALUE_BEARING_FLAGS].some((f) => f.trim() === "")) throw new Error("ARGV_VALUE_BEARING_FLAGS is empty or holds an empty flag: the token scan would match every k=v");
|
|
6905
|
+
return new RegExp(`(^|\\s)(${alternation})(\\s+|=)([^\\s=]*)=\\S*`, "g");
|
|
6906
|
+
})();
|
|
6543
6907
|
/**
|
|
6544
6908
|
* Return a copy of `args` with the VALUE of every
|
|
6545
|
-
* {@link ARGV_VALUE_BEARING_FLAGS} pair replaced by
|
|
6546
|
-
*
|
|
6547
|
-
*
|
|
6909
|
+
* {@link ARGV_VALUE_BEARING_FLAGS} pair replaced by `***`, and the credential
|
|
6910
|
+
* PARAMS of every {@link ARGV_PARAM_LIST_FLAGS} value replaced likewise. The
|
|
6911
|
+
* KEY survives — "which variable" is the diagnostic, "what it was set to" is
|
|
6912
|
+
* the disclosure.
|
|
6913
|
+
*
|
|
6914
|
+
* All FOUR argv spellings are handled: `--flag VALUE` (two tokens, what every
|
|
6915
|
+
* argv this repo builds emits), `--flag=VALUE`, and pflag's shorthand cluster
|
|
6916
|
+
* in both its forms — attached (`-itdeKEY=VALUE`) and separated
|
|
6917
|
+
* (`-itde KEY=VALUE`, where the cluster ends in the flag letter and pflag
|
|
6918
|
+
* takes the NEXT token). The last three are handled because a user's own
|
|
6919
|
+
* build script may spell them
|
|
6920
|
+
* ([#2623](https://github.com/go-to-k/cdkd/issues/2623)).
|
|
6548
6921
|
* A value-less `-e KEY` (the form {@link partitionSensitiveEnv} emits for a
|
|
6549
6922
|
* sensitive key) has nothing to mask and is returned unchanged. `args` is
|
|
6550
6923
|
* never mutated: the redacted copy is for DISPLAY only, never for `spawn`.
|
|
@@ -6553,11 +6926,24 @@ function redactDockerArgvValues(args) {
|
|
|
6553
6926
|
const out = [];
|
|
6554
6927
|
for (let i = 0; i < args.length; i++) {
|
|
6555
6928
|
const cur = args[i];
|
|
6929
|
+
const joinedEq = cur.indexOf("=");
|
|
6930
|
+
if (joinedEq > 0) {
|
|
6931
|
+
const maskedJoined = maskArgvFlagValue(cur.substring(0, joinedEq), cur.substring(joinedEq + 1));
|
|
6932
|
+
if (maskedJoined !== void 0) {
|
|
6933
|
+
out.push(`${cur.substring(0, joinedEq)}=${maskedJoined}`);
|
|
6934
|
+
continue;
|
|
6935
|
+
}
|
|
6936
|
+
}
|
|
6937
|
+
const maskedAttached = maskAttachedShortFlag(cur);
|
|
6938
|
+
if (maskedAttached !== void 0) {
|
|
6939
|
+
out.push(maskedAttached);
|
|
6940
|
+
continue;
|
|
6941
|
+
}
|
|
6556
6942
|
const next = args[i + 1];
|
|
6557
|
-
if (
|
|
6558
|
-
const
|
|
6559
|
-
if (
|
|
6560
|
-
out.push(cur,
|
|
6943
|
+
if (typeof next === "string") {
|
|
6944
|
+
const maskedNext = maskArgvFlagValue(ARGV_VALUE_BEARING_FLAGS.has(cur) ? cur : trailingShortFlagOfCluster(cur) ?? cur, next);
|
|
6945
|
+
if (maskedNext !== void 0) {
|
|
6946
|
+
out.push(cur, maskedNext);
|
|
6561
6947
|
i++;
|
|
6562
6948
|
continue;
|
|
6563
6949
|
}
|
|
@@ -6881,32 +7267,39 @@ async function buildDockerImage(asset, cdkOutDir, options) {
|
|
|
6881
7267
|
const [cmd, ...args] = source.executable;
|
|
6882
7268
|
if (!cmd) throw options.wrapError("asset source.executable[] is empty");
|
|
6883
7269
|
const cwd = source.directory ? `${cdkOutDir}/${source.directory}` : cdkOutDir;
|
|
6884
|
-
|
|
7270
|
+
const shownExecutable = redactDockerArgvValues(source.executable).join(" ");
|
|
7271
|
+
logger.debug(`Building Docker image via executable: ${shownExecutable} (cwd=${cwd})`);
|
|
6885
7272
|
let result;
|
|
6886
7273
|
try {
|
|
6887
7274
|
result = await spawnStreaming(cmd, args, { cwd });
|
|
6888
7275
|
} catch (err) {
|
|
6889
|
-
|
|
6890
|
-
throw options.wrapError(e.stderr || e.message || String(err));
|
|
7276
|
+
throw options.wrapError(describeDockerFailure(err, args));
|
|
6891
7277
|
}
|
|
6892
7278
|
const tag = result.stdout.trim();
|
|
6893
|
-
if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${
|
|
7279
|
+
if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${shownExecutable}`);
|
|
6894
7280
|
return tag;
|
|
6895
7281
|
}
|
|
6896
|
-
if (!source.directory)
|
|
7282
|
+
if (!source.directory) {
|
|
7283
|
+
const carriesAValue = (v) => {
|
|
7284
|
+
if (v === void 0 || v === null || v === "") return false;
|
|
7285
|
+
if (typeof v !== "object") return true;
|
|
7286
|
+
return (Array.isArray(v) ? v.length : Object.keys(v).length) > 0;
|
|
7287
|
+
};
|
|
7288
|
+
const present = Object.keys(source).filter((k) => carriesAValue(source[k])).map((k) => displaySafe(k, { asciiOnly: true })).filter((k) => k !== "").sort().join(", ") || "<no fields set>";
|
|
7289
|
+
throw options.wrapError(`DockerImageAssetSource must set either 'directory' or 'executable' (fields present: ${present})`);
|
|
7290
|
+
}
|
|
6897
7291
|
if (!options.tag) throw options.wrapError("buildDockerImage(directory mode) requires options.tag");
|
|
6898
7292
|
const buildArgs = buildDockerBuildCommand(source, options.tag, options.platform);
|
|
6899
7293
|
const contextDir = `${cdkOutDir}/${source.directory}`;
|
|
6900
7294
|
buildArgs.push(".");
|
|
6901
|
-
logger.debug(`${getDockerCmd()} ${buildArgs.join(" ")} (cwd=${contextDir})`);
|
|
7295
|
+
logger.debug(`${getDockerCmd()} ${redactDockerArgvValues(buildArgs).join(" ")} (cwd=${contextDir})`);
|
|
6902
7296
|
try {
|
|
6903
7297
|
await runDockerStreaming(buildArgs, {
|
|
6904
7298
|
cwd: contextDir,
|
|
6905
7299
|
env: { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" }
|
|
6906
7300
|
});
|
|
6907
7301
|
} catch (err) {
|
|
6908
|
-
|
|
6909
|
-
throw options.wrapError(e.stderr || e.message || String(err));
|
|
7302
|
+
throw options.wrapError(describeDockerFailure(err, buildArgs));
|
|
6910
7303
|
}
|
|
6911
7304
|
return options.tag;
|
|
6912
7305
|
}
|
|
@@ -7074,8 +7467,9 @@ var DockerAssetPublisher = class {
|
|
|
7074
7467
|
}
|
|
7075
7468
|
/**
|
|
7076
7469
|
* Build Docker image — delegates to the shared `buildDockerImage`
|
|
7077
|
-
* helper so this code path stays in sync with `cdkd local
|
|
7078
|
-
*
|
|
7470
|
+
* helper so this code path stays in sync with `cdkd local run-task`'s
|
|
7471
|
+
* `ContainerImage.fromAsset` build path (the other caller; `cdkd local
|
|
7472
|
+
* invoke` moved to `cdk-local`'s own builder). `--platform` is read from the asset
|
|
7079
7473
|
* manifest's `source.platform` (when set); cdkd does not currently
|
|
7080
7474
|
* inject a publish-side override.
|
|
7081
7475
|
*
|
|
@@ -7159,33 +7553,33 @@ var DockerAssetPublisher = class {
|
|
|
7159
7553
|
const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
|
|
7160
7554
|
if (!username || password === void 0) throw new AssetError("ECR authorization token has unexpected shape (missing username/password)");
|
|
7161
7555
|
const endpoint = authData.proxyEndpoint || `https://${accountId}.dkr.ecr.${region}.${ecrUrlSuffix(region)}`;
|
|
7556
|
+
const loginArgs = [
|
|
7557
|
+
"login",
|
|
7558
|
+
"--username",
|
|
7559
|
+
username,
|
|
7560
|
+
"--password-stdin",
|
|
7561
|
+
endpoint
|
|
7562
|
+
];
|
|
7162
7563
|
try {
|
|
7163
|
-
await runDockerStreaming(
|
|
7164
|
-
"login",
|
|
7165
|
-
"--username",
|
|
7166
|
-
username,
|
|
7167
|
-
"--password-stdin",
|
|
7168
|
-
endpoint
|
|
7169
|
-
], { input: password });
|
|
7564
|
+
await runDockerStreaming(loginArgs, { input: password });
|
|
7170
7565
|
loggedInRegistries.add(registryKey);
|
|
7171
7566
|
} catch (err) {
|
|
7172
|
-
|
|
7173
|
-
throw new AssetError(`ECR login failed: ${formatDockerLoginError(e.stderr || e.message || String(err), endpoint)}`);
|
|
7567
|
+
throw new AssetError(`ECR login failed: ${formatDockerLoginError(describeDockerFailure(err, loginArgs), endpoint)}`);
|
|
7174
7568
|
}
|
|
7175
7569
|
}
|
|
7176
7570
|
/**
|
|
7177
7571
|
* Tag Docker image
|
|
7178
7572
|
*/
|
|
7179
7573
|
async tagImage(source, target) {
|
|
7574
|
+
const tagArgs = [
|
|
7575
|
+
"tag",
|
|
7576
|
+
source,
|
|
7577
|
+
target
|
|
7578
|
+
];
|
|
7180
7579
|
try {
|
|
7181
|
-
await runDockerStreaming(
|
|
7182
|
-
"tag",
|
|
7183
|
-
source,
|
|
7184
|
-
target
|
|
7185
|
-
]);
|
|
7580
|
+
await runDockerStreaming(tagArgs);
|
|
7186
7581
|
} catch (err) {
|
|
7187
|
-
|
|
7188
|
-
throw new AssetError(`Docker tag failed: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
7582
|
+
throw new AssetError(`Docker tag failed: ${describeDockerFailure(err, tagArgs)}`);
|
|
7189
7583
|
}
|
|
7190
7584
|
}
|
|
7191
7585
|
/**
|
|
@@ -7195,11 +7589,11 @@ var DockerAssetPublisher = class {
|
|
|
7195
7589
|
*/
|
|
7196
7590
|
async pushImage(uri) {
|
|
7197
7591
|
this.logger.debug(`Pushing: ${uri}`);
|
|
7592
|
+
const pushArgs = ["push", uri];
|
|
7198
7593
|
try {
|
|
7199
|
-
await runDockerStreaming(
|
|
7594
|
+
await runDockerStreaming(pushArgs);
|
|
7200
7595
|
} catch (err) {
|
|
7201
|
-
|
|
7202
|
-
throw new AssetError(`Docker push failed: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
7596
|
+
throw new AssetError(`Docker push failed: ${describeDockerFailure(err, pushArgs)}`);
|
|
7203
7597
|
}
|
|
7204
7598
|
}
|
|
7205
7599
|
/**
|
|
@@ -7937,49 +8331,6 @@ var AssetModeResolver = class {
|
|
|
7937
8331
|
}
|
|
7938
8332
|
};
|
|
7939
8333
|
|
|
7940
|
-
//#endregion
|
|
7941
|
-
//#region src/utils/regexp.ts
|
|
7942
|
-
/**
|
|
7943
|
-
* Escape a literal string for embedding in a RegExp.
|
|
7944
|
-
*
|
|
7945
|
-
* Lives here because the same four-line helper was spelled THREE times — in
|
|
7946
|
-
* `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
|
|
7947
|
-
* `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
|
|
7948
|
-
* (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
|
|
7949
|
-
* label) into a pattern. Copies of a security-shaped helper are exactly the
|
|
7950
|
-
* shape that drifts: the escaped set is what stops a `.` in an interpolated
|
|
7951
|
-
* literal matching ANY character, and in `gc.ts` a widened match decides which
|
|
7952
|
-
* live assets are treated as referenced.
|
|
7953
|
-
*
|
|
7954
|
-
* The character set is the union every JS engine treats as special in a
|
|
7955
|
-
* non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
|
|
7956
|
-
* character class, and no caller embeds into one.
|
|
7957
|
-
*/
|
|
7958
|
-
function escapeRegExp$1(value) {
|
|
7959
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7960
|
-
}
|
|
7961
|
-
/**
|
|
7962
|
-
* Remove control characters from a value about to be PRINTED.
|
|
7963
|
-
*
|
|
7964
|
-
* Lives beside {@link escapeRegExp} for the reason that helper's own note
|
|
7965
|
-
* gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
|
|
7966
|
-
* in `src/deployment/outputs-export-alias.ts` — and copies of a
|
|
7967
|
-
* security-shaped helper drift. Both print the same class of string: a
|
|
7968
|
-
* resolved CloudFormation Output / `Export.Name`, which passed no CFn
|
|
7969
|
-
* validator, so it can carry ANSI escapes or bidi overrides straight into a
|
|
7970
|
-
* terminal or a CI log.
|
|
7971
|
-
*
|
|
7972
|
-
* C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
|
|
7973
|
-
* only: a `--json` payload is a machine interface where an export name is data
|
|
7974
|
-
* a consumer may match on, and mutating it there trades a correctness
|
|
7975
|
-
* regression for a display concern (see `stripDisplayOnlyChars`, which is the
|
|
7976
|
-
* narrower guard for an already-serialized payload and stays local to its
|
|
7977
|
-
* caller because its C0 exclusion is specific to that path).
|
|
7978
|
-
*/
|
|
7979
|
-
function stripControlChars(value) {
|
|
7980
|
-
return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
7981
|
-
}
|
|
7982
|
-
|
|
7983
8334
|
//#endregion
|
|
7984
8335
|
//#region src/assets/asset-redirect.ts
|
|
7985
8336
|
/**
|
|
@@ -8861,6 +9212,16 @@ function formatRemaining(ms) {
|
|
|
8861
9212
|
if (minutes < 1) return "in under a minute";
|
|
8862
9213
|
return `in ~${minutes}m`;
|
|
8863
9214
|
}
|
|
9215
|
+
/**
|
|
9216
|
+
* Quote a value for a pasteable shell command.
|
|
9217
|
+
*
|
|
9218
|
+
* EXPORTED since issue [#2610]: `src/provisioning/replacement-protection-advice.ts`
|
|
9219
|
+
* prints `aws <service> ...` recovery commands naming a resource's physical id,
|
|
9220
|
+
* which is the same hazard one directory over. A second spelling of this
|
|
9221
|
+
* predicate is how the two would come to disagree about which values need
|
|
9222
|
+
* quoting -- the reason `display-safe.ts`'s header gives for not widening a
|
|
9223
|
+
* rule by hand. It is a pure function of its argument and imports nothing.
|
|
9224
|
+
*/
|
|
8864
9225
|
function shellQuote(value) {
|
|
8865
9226
|
return /^[A-Za-z0-9._/@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
8866
9227
|
}
|
|
@@ -8880,6 +9241,18 @@ function buildForceUnlockCommand(stackName, region, recovery) {
|
|
|
8880
9241
|
return parts.join(" ");
|
|
8881
9242
|
}
|
|
8882
9243
|
/**
|
|
9244
|
+
* What to say INSTEAD of a `cdkd force-unlock ...` line when
|
|
9245
|
+
* {@link buildForceUnlockCommand} suppresses.
|
|
9246
|
+
*
|
|
9247
|
+
* Exported since issue [#2610]: `lock-manager.ts`'s exhausted-retry arm needed
|
|
9248
|
+
* the same branch, and it was the THIRD place to spell it. The module header's
|
|
9249
|
+
* point applies to the suppression sentence as much as to the command -- a
|
|
9250
|
+
* banner ending in a bare `run: ` is the shape the review found, and copies are
|
|
9251
|
+
* how the next one drifts. Byte-identical to what `forceQuitRecoveryClause`
|
|
9252
|
+
* emitted before the extraction; its callers see no change.
|
|
9253
|
+
*/
|
|
9254
|
+
const UNREPRODUCIBLE_LOCK_CLAUSE = "Inspect the lock object directly: the name or region recorded for this stack cannot be reproduced safely on a command line, so any command shown here would address a different lock.";
|
|
9255
|
+
/**
|
|
8883
9256
|
* The force-quit banner's recovery sentence.
|
|
8884
9257
|
*
|
|
8885
9258
|
* Exported so the two `destroy-runner.ts` banners do not each decide what to
|
|
@@ -8890,7 +9263,7 @@ function buildForceUnlockCommand(stackName, region, recovery) {
|
|
|
8890
9263
|
*/
|
|
8891
9264
|
function forceQuitRecoveryClause(stackName, region, recovery) {
|
|
8892
9265
|
const command = buildForceUnlockCommand(stackName, region, recovery);
|
|
8893
|
-
return command ? ` If the next run reports a lock, run: ${command}` :
|
|
9266
|
+
return command ? ` If the next run reports a lock, run: ${command}` : ` ${UNREPRODUCIBLE_LOCK_CLAUSE}`;
|
|
8894
9267
|
}
|
|
8895
9268
|
/**
|
|
8896
9269
|
* Build the contention message, reading the holder's identity best-effort.
|
|
@@ -10138,7 +10511,7 @@ var LockManager = class {
|
|
|
10138
10511
|
}
|
|
10139
10512
|
return false;
|
|
10140
10513
|
}
|
|
10141
|
-
throw new LockError(`Failed to acquire lock for stack '${stackName}' (${region}): ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : void 0);
|
|
10514
|
+
throw new LockError(`Failed to acquire lock for stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}): ${displaySafe(error instanceof Error ? error.message : String(error))}`, error instanceof Error ? error : void 0);
|
|
10142
10515
|
}
|
|
10143
10516
|
}
|
|
10144
10517
|
/**
|
|
@@ -10362,8 +10735,11 @@ var LockManager = class {
|
|
|
10362
10735
|
/**
|
|
10363
10736
|
* Force release a lock regardless of owner or expiry status
|
|
10364
10737
|
*
|
|
10365
|
-
* This is intended for CLI usage
|
|
10366
|
-
* is stuck and needs manual
|
|
10738
|
+
* This is intended for CLI usage -- it is what the `cdkd force-unlock
|
|
10739
|
+
* <stack>` SUBCOMMAND calls -- when a lock is stuck and needs manual
|
|
10740
|
+
* intervention. There is no `--force-unlock` FLAG anywhere in `src/`; this
|
|
10741
|
+
* comment said there was, and issue [#2610] site 14 records that the message
|
|
10742
|
+
* `acquireLockWithRetry` used to raise had inherited the same mistake.
|
|
10367
10743
|
*
|
|
10368
10744
|
* Pass `region: undefined` to operate on a legacy
|
|
10369
10745
|
* `{prefix}/{stackName}/lock.json` file.
|
|
@@ -10706,7 +11082,7 @@ var LockManager = class {
|
|
|
10706
11082
|
if (lockInfo) {
|
|
10707
11083
|
const remainingMs = lockInfo.expiresAt - Date.now();
|
|
10708
11084
|
if (attempt < maxRetries) {
|
|
10709
|
-
this.logger.info(`Stack '${stackName}' (${region}) is locked by ${lockInfo.owner}${lockInfo.operation ? ` (operation: ${lockInfo.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`);
|
|
11085
|
+
this.logger.info(`Stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}) is locked by ${lockInfo.owner}${lockInfo.operation ? ` (operation: ${lockInfo.operation})` : ""}. Lock expires in ${this.formatDuration(remainingMs)}. Retrying in ${this.formatDuration(retryDelay)}... (attempt ${attempt + 1}/${maxRetries})`);
|
|
10710
11086
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
10711
11087
|
continue;
|
|
10712
11088
|
}
|
|
@@ -10714,7 +11090,9 @@ var LockManager = class {
|
|
|
10714
11090
|
}
|
|
10715
11091
|
const lockInfo = await this.getLockInfo(stackName, region);
|
|
10716
11092
|
const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
|
|
10717
|
-
|
|
11093
|
+
const forceUnlockCommand = buildForceUnlockCommand(stackName, region);
|
|
11094
|
+
const recovery = forceUnlockCommand ? `If you are certain no other process is active, run: ${forceUnlockCommand}` : `If you are certain no other process is active, ${UNREPRODUCIBLE_LOCK_CLAUSE.charAt(0).toLowerCase()}${UNREPRODUCIBLE_LOCK_CLAUSE.slice(1)}`;
|
|
11095
|
+
throw new LockError(`Failed to acquire lock for stack '${displaySafe(stackName, { asciiOnly: true }) || "<unrenderable>"}' (${displaySafe(region, { asciiOnly: true }) || "<unrenderable>"}) after ${maxRetries + 1} attempts. ` + (lockInfo ? `Locked by: ${lockInfo.owner}${lockInfo.operation ? `, operation: ${lockInfo.operation}` : ""}, expires in: ${expiresIn}. ` + recovery : `Lock exists but could not read lock info. ${recovery}`));
|
|
10718
11096
|
}
|
|
10719
11097
|
};
|
|
10720
11098
|
|
|
@@ -11468,7 +11846,7 @@ function storeAssociation(associations, key, expression, plaintext) {
|
|
|
11468
11846
|
* matters: an AWS resource type string is fixed by AWS, and a typo in either
|
|
11469
11847
|
* copy makes that copy's gate simply never fire (no-op), never fire wrongly.
|
|
11470
11848
|
*/
|
|
11471
|
-
const NESTED_STACK_RESOURCE_TYPE$
|
|
11849
|
+
const NESTED_STACK_RESOURCE_TYPE$2 = "AWS::CloudFormation::Stack";
|
|
11472
11850
|
/**
|
|
11473
11851
|
* What a PARENT stack's resolution proved about each `Parameters` entry of an
|
|
11474
11852
|
* `AWS::CloudFormation::Stack` row it is about to provision, keyed by the
|
|
@@ -11575,7 +11953,7 @@ const nestedStackParameterExpressions = /* @__PURE__ */ new WeakMap();
|
|
|
11575
11953
|
* `secret-redaction-nested-parameter-source.test.ts`.
|
|
11576
11954
|
*/
|
|
11577
11955
|
function recordNestedStackParameterExpressions(secrets, resourceType, resolvedProperties, sourceProperties, rules = TEMPLATE_DERIVED_RULES) {
|
|
11578
|
-
if (resourceType !== NESTED_STACK_RESOURCE_TYPE$
|
|
11956
|
+
if (resourceType !== NESTED_STACK_RESOURCE_TYPE$2) return;
|
|
11579
11957
|
if (secrets.size === 0) return;
|
|
11580
11958
|
if (!isPlainObject$2(resolvedProperties) || !isPlainObject$2(sourceProperties)) return;
|
|
11581
11959
|
if (!Object.hasOwn(resolvedProperties, "Parameters")) return;
|
|
@@ -16753,9 +17131,28 @@ function secretsManagerSecretId(inner) {
|
|
|
16753
17131
|
* - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
|
|
16754
17132
|
* (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
|
|
16755
17133
|
* a different thing in the refusal message than the one that would be read.
|
|
17134
|
+
*
|
|
17135
|
+
* A COLON-LESS body (`{{resolve:ssm}}` / `{{resolve:ssm-secure}}`) names no
|
|
17136
|
+
* parameter at all, and the empty string is what says so: the caller reads a
|
|
17137
|
+
* falsy name as `local`, which hands the reference on to the resolver's own
|
|
17138
|
+
* `PARAMETER_NAME is required` — thrown BEFORE any client is built, so no
|
|
17139
|
+
* `GetParameter` is ever issued for such a token. Without the guard
|
|
17140
|
+
* `indexOf(':')` is `-1` and `substring(0)` returns the SERVICE STRING as the
|
|
17141
|
+
* parameter name, so with a foreign producer region on record the same
|
|
17142
|
+
* malformed input drew the ambiguous-region refusal naming `'ssm-secure'` as
|
|
17143
|
+
* the secret. {@link secretsManagerSecretId} already answers `''` for its own
|
|
17144
|
+
* nameless body (a fixed-length `substring` past the end of the string), and a
|
|
17145
|
+
* sibling that answers something else is the kind of split a later reader has
|
|
17146
|
+
* to rediscover.
|
|
17147
|
+
*
|
|
17148
|
+
* The colon-less relaxation has a CONSUMER-VISIBLE consequence on a mixed
|
|
17149
|
+
* leaf, and it is recorded on {@link classifyReplaySecretRegion} rather than
|
|
17150
|
+
* here: this function produces a NAME, and the delta belongs beside the
|
|
17151
|
+
* VERDICT a reader arrives at it through.
|
|
16756
17152
|
*/
|
|
16757
17153
|
function ssmParameterName(inner) {
|
|
16758
|
-
|
|
17154
|
+
const serviceEnd = inner.indexOf(":");
|
|
17155
|
+
return serviceEnd < 0 ? "" : inner.substring(serviceEnd + 1);
|
|
16759
17156
|
}
|
|
16760
17157
|
/**
|
|
16761
17158
|
* The region an ARN names, or `undefined` for anything that is not an ARN with
|
|
@@ -16893,6 +17290,101 @@ function producerRegionsFromState(state) {
|
|
|
16893
17290
|
* A same-region ARN answers `local` even when a foreign producer region IS on
|
|
16894
17291
|
* record: the expression settles the question itself, so the weaker evidence
|
|
16895
17292
|
* never gets consulted.
|
|
17293
|
+
*
|
|
17294
|
+
* ---
|
|
17295
|
+
*
|
|
17296
|
+
* WHO CONSUMES THIS VERDICT, and what the issue #2501 colon-less fix changed
|
|
17297
|
+
* for them. Recorded here because a reader arrives at the blast radius through
|
|
17298
|
+
* the verdict, not through {@link ssmParameterName}, which merely produces a
|
|
17299
|
+
* name.
|
|
17300
|
+
*
|
|
17301
|
+
* Three LEAF PRE-PASSES consume it — `rollback-executor.ts`'s
|
|
17302
|
+
* `resolveLeafByRegion`, `cdkd drift`'s `resolveDriftLeafByRegion` and `cdkd
|
|
17303
|
+
* scrub`'s — and each refuses the WHOLE leaf as soon as ONE token classifies
|
|
17304
|
+
* `ambiguous`, before any token is fetched. There is a FOURTH consumer, and it
|
|
17305
|
+
* is not a pre-pass: `resolveDynamicReferences` classifies token-by-token
|
|
17306
|
+
* INSIDE its substitution loop, so it already fetches earlier tokens before
|
|
17307
|
+
* refusing a later one. That is why the pre-fetch refusal was never a designed
|
|
17308
|
+
* property of this verdict.
|
|
17309
|
+
*
|
|
17310
|
+
* Before the fix, a colon-less `{{resolve:ssm-secure}}` produced the SERVICE
|
|
17311
|
+
* STRING as its name, so with a foreign producer region on record it drew an
|
|
17312
|
+
* `ambiguous` verdict and the pre-pass refused the whole leaf: for
|
|
17313
|
+
* `local=<same-region ARN>;secure={{resolve:ssm-secure}}`, nothing was
|
|
17314
|
+
* resolved. It now answers `local`, so the leaf IS resolved — by the primary
|
|
17315
|
+
* resolver when every other token is `local`, or through the segment-rebuild
|
|
17316
|
+
* path when one is `named-region` — and the ARN sibling's plaintext is fetched
|
|
17317
|
+
* and cached before the degenerate token throws `PARAMETER_NAME is required`.
|
|
17318
|
+
* Pinned by `rollback-executor-cross-region-secret.test.ts`'s "the MIXED-leaf
|
|
17319
|
+
* delta the colon-less guard accepts", so the trade is visible rather than
|
|
17320
|
+
* argued.
|
|
17321
|
+
*
|
|
17322
|
+
* Accepted on three grounds, the third being a LIMIT rather than a
|
|
17323
|
+
* reassurance:
|
|
17324
|
+
*
|
|
17325
|
+
* 1. The lost refusal was an ACCIDENT of the mis-parse, not a designed
|
|
17326
|
+
* protection: with no foreign producer region on record — the
|
|
17327
|
+
* overwhelmingly common case — the colon-less token classified `local`
|
|
17328
|
+
* before the fix too, so the same sibling was already fetched.
|
|
17329
|
+
* 2. The sibling is fetched from the region its OWN verdict names, never a
|
|
17330
|
+
* guessed one (a name-form sibling would itself be `ambiguous` and the
|
|
17331
|
+
* pre-pass would still refuse), so nothing here weakens the issue #1957
|
|
17332
|
+
* rule this module exists to enforce. Nothing fetched reaches a DURABLE
|
|
17333
|
+
* sink — `state.json`, the rollback journal, the `deployments/` event
|
|
17334
|
+
* store, a `--json` payload — and the reason is NOT "the op throws before
|
|
17335
|
+
* any write", which is false for scrub (see 3: scrub swallows and keeps
|
|
17336
|
+
* writing). It is that both places a fetched plaintext is retained are
|
|
17337
|
+
* IN-PROCESS and neither is copied out: the resolver's own
|
|
17338
|
+
* `cachedDynamicReferences` (instance-scoped since issue #1933, so it dies
|
|
17339
|
+
* with the resolver) and `recordedSecretValues`, which every persist path
|
|
17340
|
+
* consults as a redaction NEEDLE set — an entry there causes a value to be
|
|
17341
|
+
* REPLACED BY its expression on the way out, never inserted.
|
|
17342
|
+
*
|
|
17343
|
+
* ON THIS PATH one more needle can only redact more, which is why the
|
|
17344
|
+
* direction is safe even though it fetches more — and the qualifier is
|
|
17345
|
+
* load-bearing rather than hedging. It is NOT a general property of the
|
|
17346
|
+
* needle machinery: `redactSecretsForState`'s own doc (see the
|
|
17347
|
+
* `preferPositionDecisions` ordering note) records that a needle rewriting
|
|
17348
|
+
* a FRAME ANCHOR can un-certify `unkeyedArrayPairsByAnchors`, refuse the
|
|
17349
|
+
* array, and leave a sibling MIXED leaf in plaintext — "a regression of
|
|
17350
|
+
* shipped redaction, in the GHSA disclosure direction". That is reachable
|
|
17351
|
+
* with a NON-EMPTY map on a `STATE_SOURCED_READBACK_RULES` caller, e.g.
|
|
17352
|
+
* `rollback-executor.ts`'s `redactRollbackRecord` ->
|
|
17353
|
+
* `scrubResourceRecord`. It is not what fires here: the case this
|
|
17354
|
+
* paragraph is about records FEWER needles, not more, so nothing new can
|
|
17355
|
+
* rewrite an anchor.
|
|
17356
|
+
* 3. WHAT THE OP DOES NEXT IS NOT UNIFORM. The replay fails the op and `cdkd
|
|
17357
|
+
* drift` reports the resource NOT compared — both loud. `cdkd scrub` is
|
|
17358
|
+
* NOT: `isRegionAmbiguousRefusal` re-raises only a
|
|
17359
|
+
* `DynamicReferenceRegionAmbiguousError`, and the resolver's replacement is
|
|
17360
|
+
* a plain `Error`, so scrub swallows it to `debug` and the run reports
|
|
17361
|
+
* clean. A LOUDNESS regression for that one command on this one input,
|
|
17362
|
+
* bounded by the identical silent miss that already happens for the same
|
|
17363
|
+
* leaf whenever no foreign producer region is on record.
|
|
17364
|
+
*
|
|
17365
|
+
* Issue [#2692](https://github.com/go-to-k/cdkd/issues/2692) tracks it, and
|
|
17366
|
+
* the remedy is NOT resolver-local. Two candidate fixes are wrong, each in
|
|
17367
|
+
* its own way, and scrub has TWO predicates that must not be confused:
|
|
17368
|
+
* `isRegionAmbiguousRefusal` decides RE-RAISE vs SWALLOW (its three call
|
|
17369
|
+
* sites), while `isByDesignRefusal` — matching
|
|
17370
|
+
* `CrossAccountSecretRefusalError` — decides FINDING vs REFUSE. They point
|
|
17371
|
+
* in opposite directions, so a fix aimed at one must not be argued from
|
|
17372
|
+
* the other's record.
|
|
17373
|
+
*
|
|
17374
|
+
* - Throwing `IntrinsicResolutionRefusalError` and widening
|
|
17375
|
+
* `isRegionAmbiguousRefusal` to match the BASE class would make every
|
|
17376
|
+
* user-fixable refusal that class carries RE-RAISE and fail the whole
|
|
17377
|
+
* stack — not the silent-downgrade hazard `isByDesignRefusal`'s doc
|
|
17378
|
+
* records, but the opposite over-refusal, on a class with throw sites
|
|
17379
|
+
* spread across the resolver.
|
|
17380
|
+
* - Throwing the region-ambiguous SUBCLASS is wrong differently: scrub
|
|
17381
|
+
* already re-raises it, but its message tells the user to spell the
|
|
17382
|
+
* reference as a full ARN, which cannot fix a reference naming no
|
|
17383
|
+
* parameter.
|
|
17384
|
+
*
|
|
17385
|
+
* So #2692 needs a NEW sibling subclass AND a scrub-side predicate change,
|
|
17386
|
+
* which makes it blocked on `src/cli/commands/scrub.ts` (held by PR
|
|
17387
|
+
* #2562), not merely on the resolver's `integ-broad` cost.
|
|
16896
17388
|
*/
|
|
16897
17389
|
function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
|
|
16898
17390
|
const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
|
|
@@ -18586,7 +19078,7 @@ function carriesDynamicReference(value) {
|
|
|
18586
19078
|
return false;
|
|
18587
19079
|
}
|
|
18588
19080
|
/** The nested-stack resource type, whose `Outputs.<Name>` attributes are re-resolved (issue #2055). */
|
|
18589
|
-
const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
|
|
19081
|
+
const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
|
|
18590
19082
|
/** Prefix `NestedStackProvider` records a child stack output under. */
|
|
18591
19083
|
const NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX = "Outputs.";
|
|
18592
19084
|
/**
|
|
@@ -20077,7 +20569,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
20077
20569
|
return this.noteAttributeSecrecy(logicalId, attributeName, nameServers, context);
|
|
20078
20570
|
}
|
|
20079
20571
|
this.logger.debug(`Resolved Fn::GetAtt from attributes: ${logicalId}.${attributeName} -> ${stringifyAttributeForLog(attributeName, flatValue)}`);
|
|
20080
|
-
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
|
|
20572
|
+
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX) && carriesDynamicReference(flatValue)) return await this.reresolveCrossStackValue(flatValue, nestedStackChildRegionFromLocalArn(resource.physicalId), context, `nested stack ${logicalId} ${attributeName}`, crossStackSourceKey({ "Fn::GetAtt": getAtt }));
|
|
20081
20573
|
return this.noteAttributeSecrecy(logicalId, attributeName, flatValue, context);
|
|
20082
20574
|
}
|
|
20083
20575
|
if (attributeName.includes(".")) {
|
|
@@ -20094,7 +20586,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
20094
20586
|
}
|
|
20095
20587
|
}
|
|
20096
20588
|
}
|
|
20097
|
-
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
|
|
20589
|
+
if (resource.resourceType === NESTED_STACK_RESOURCE_TYPE$1 && attributeName.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)) {
|
|
20098
20590
|
const declared = Object.keys(resource.attributes ?? {}).filter((k) => k.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)).map((k) => k.slice(8)).sort();
|
|
20099
20591
|
throw markNonRetryable(new IntrinsicResolutionRefusalError(`Cannot resolve Fn::GetAtt [${logicalId}, ${attributeName}]: the nested stack '${logicalId}' declares no output named '${attributeName.slice(8)}'. Its outputs are ${declared.length > 0 ? declared.join(", ") : "(none)"}. Check the output name in the nested stack's template, and deploy the child stack again if you have just added it.`));
|
|
20100
20592
|
}
|
|
@@ -23308,7 +23800,7 @@ var CloudControlProvider = class {
|
|
|
23308
23800
|
const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
|
|
23309
23801
|
if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
|
|
23310
23802
|
this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
|
|
23311
|
-
const { ASGProvider } = await import("./asg-provider-
|
|
23803
|
+
const { ASGProvider } = await import("./asg-provider-dBSdt3nB.js").then((n) => n.n);
|
|
23312
23804
|
return withIndeterminateGuard(await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
|
|
23313
23805
|
}
|
|
23314
23806
|
const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
|
|
@@ -25493,7 +25985,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
25493
25985
|
reuseClientCredentials: true,
|
|
25494
25986
|
tolerateNonStandardClient: true,
|
|
25495
25987
|
onRebuild: ({ bucketRegion, currentRegion }) => {
|
|
25496
|
-
this.logger.debug(`Custom resource response bucket '${bucket}' is in '${bucketRegion}' (client was '${
|
|
25988
|
+
this.logger.debug(`Custom resource response bucket '${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}' is in '${displaySafe(bucketRegion, { asciiOnly: true }) || "<unrenderable>"}' (client was '${displaySafe(currentRegion, { asciiOnly: true }) || "<unrenderable>"}'); building a region-corrected S3 client for response operations.`);
|
|
25497
25989
|
}
|
|
25498
25990
|
});
|
|
25499
25991
|
if (generation !== this.responseClientGeneration) {
|
|
@@ -26170,7 +26662,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
26170
26662
|
Key: responseKey
|
|
26171
26663
|
});
|
|
26172
26664
|
const presignedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 7200 });
|
|
26173
|
-
this.logger.debug(`Generated pre-signed URL for response: s3://${this.responseBucket}/${responseKey}`);
|
|
26665
|
+
this.logger.debug(`Generated pre-signed URL for response: s3://${displaySafe(this.responseBucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}`);
|
|
26174
26666
|
return presignedUrl;
|
|
26175
26667
|
}
|
|
26176
26668
|
/**
|
|
@@ -26274,7 +26766,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
26274
26766
|
Key: responseKey
|
|
26275
26767
|
}));
|
|
26276
26768
|
} catch (error) {
|
|
26277
|
-
this.logger.debug(`Failed to delete custom-resource response object s3://${bucket}/${responseKey}; it remains as a current object. Underlying error: ${error instanceof Error ? error.message : String(error)}`);
|
|
26769
|
+
this.logger.debug(`Failed to delete custom-resource response object s3://${displaySafe(bucket, { asciiOnly: true }) || "<unrenderable>"}/${displaySafe(responseKey)}; it remains as a current object. Underlying error: ${displaySafe(error instanceof Error ? error.message : String(error)) || "<unrenderable>"}`);
|
|
26278
26770
|
}
|
|
26279
26771
|
await purgeNoncurrentKeyVersions(this.s3Client, bucket, [responseKey], {
|
|
26280
26772
|
logger: this.logger,
|
|
@@ -29771,6 +30263,70 @@ function getCurrentResourceSecrets() {
|
|
|
29771
30263
|
return currentResourceSecretsStore.getStore();
|
|
29772
30264
|
}
|
|
29773
30265
|
|
|
30266
|
+
//#endregion
|
|
30267
|
+
//#region src/deployment/type-change-guard.ts
|
|
30268
|
+
/**
|
|
30269
|
+
* The CFn type of a nested stack's row in its PARENT's template.
|
|
30270
|
+
*
|
|
30271
|
+
* Spelled locally rather than imported, matching
|
|
30272
|
+
* `src/deployment/recreate-targets.ts`: the only exported copy lives in
|
|
30273
|
+
* `src/cli/commands/retire-cfn-stack.ts`, and importing a CLI command module
|
|
30274
|
+
* from the deployment layer would invert the dependency direction.
|
|
30275
|
+
*/
|
|
30276
|
+
const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
|
|
30277
|
+
/**
|
|
30278
|
+
* Find every planned change whose recorded type and template type differ with
|
|
30279
|
+
* `AWS::CloudFormation::Stack` on one side.
|
|
30280
|
+
*
|
|
30281
|
+
* Reads exactly the two values the defect is made of: `change.resourceType`
|
|
30282
|
+
* (what `provisionResource` binds and routes BOTH replacement halves on) and
|
|
30283
|
+
* the state record's `resourceType` (the resource that actually exists). That
|
|
30284
|
+
* is deliberate — deriving the "desired" type from the template again would be
|
|
30285
|
+
* a second implementation of the diff's own Type-change rule (metadata skip,
|
|
30286
|
+
* condition pruning) which could drift away from the routing decision this
|
|
30287
|
+
* guards.
|
|
30288
|
+
*
|
|
30289
|
+
* `changeType` is not filtered on. A Type change surfaces as an `UPDATE`, but a
|
|
30290
|
+
* DELETE / NO_CHANGE row cannot diverge in the first place (the diff builds
|
|
30291
|
+
* both from the state record's own type), so filtering would only add a way for
|
|
30292
|
+
* a future change-shape to slip past.
|
|
30293
|
+
*/
|
|
30294
|
+
function findNestedStackTypeChanges(input) {
|
|
30295
|
+
const found = [];
|
|
30296
|
+
for (const [logicalId, change] of input.changes) {
|
|
30297
|
+
if (!Object.hasOwn(input.stateResources, logicalId)) continue;
|
|
30298
|
+
const currentResource = input.stateResources[logicalId];
|
|
30299
|
+
if (!currentResource) continue;
|
|
30300
|
+
const currentType = currentResource.resourceType;
|
|
30301
|
+
const desiredType = change.resourceType;
|
|
30302
|
+
if (currentType === desiredType) continue;
|
|
30303
|
+
const intoNested = desiredType === NESTED_STACK_RESOURCE_TYPE;
|
|
30304
|
+
if (!intoNested && !(currentType === NESTED_STACK_RESOURCE_TYPE)) continue;
|
|
30305
|
+
found.push({
|
|
30306
|
+
logicalId,
|
|
30307
|
+
currentType,
|
|
30308
|
+
desiredType,
|
|
30309
|
+
physicalId: currentResource.physicalId,
|
|
30310
|
+
direction: intoNested ? "into-nested-stack" : "out-of-nested-stack"
|
|
30311
|
+
});
|
|
30312
|
+
}
|
|
30313
|
+
return found;
|
|
30314
|
+
}
|
|
30315
|
+
/**
|
|
30316
|
+
* Render the refusal. Names the logical id, BOTH types, the resource the
|
|
30317
|
+
* mis-routed delete would be aimed at, and what to do instead.
|
|
30318
|
+
*
|
|
30319
|
+
* `stackName` is the stack being deployed, so the into-nested arm can print the
|
|
30320
|
+
* child stack name `NestedStackProvider.delete` would derive and destroy — the
|
|
30321
|
+
* one piece of the damage the user cannot read off their own template.
|
|
30322
|
+
*/
|
|
30323
|
+
function renderNestedStackTypeChangeRefusal(typeChanges, stackName) {
|
|
30324
|
+
const rows = typeChanges.map((tc) => {
|
|
30325
|
+
return `${` - ${tc.logicalId}: Type changes from ${tc.currentType} to ${tc.desiredType} (the existing ${tc.currentType} is ${tc.physicalId}).`}\n${tc.direction === "into-nested-stack" ? ` Both halves of the replacement would route on the TEMPLATE's type, so the existing resource's delete would be dispatched at the ${NESTED_STACK_RESOURCE_TYPE} provider — which ignores the physical id it is handed and instead destroys the nested child stack "${stackName}~${tc.logicalId}" and every resource that child owns. Where no such child exists the delete is a no-op and ${tc.physicalId} is silently leaked instead.` : ` Both halves of the replacement would route on the TEMPLATE's type, so the existing nested stack's delete would be dispatched at the ${tc.desiredType} provider, which cannot delete a nested stack — the child stack "${stackName}~${tc.logicalId}" and every resource it owns would be left behind, untracked.`}`;
|
|
30326
|
+
});
|
|
30327
|
+
return `Refusing to deploy ${stackName}: ` + (typeChanges.length === 1 ? `a resource changes its Type ` : `${typeChanges.length} resources change their Type `) + `into or out of ${NESTED_STACK_RESOURCE_TYPE}, which cdkd cannot replace safely (issue #2668).\n${rows.join("\n")}\n Deploy this as two changes instead: give the new resource a DIFFERENT logical id (in CDK, rename the construct) so the existing row is deleted through its own type's provider and the new one is created under its own — or remove the resource in one deploy and add its replacement in the next. There is no flag that overrides this refusal: the delete's TARGET would be wrong, not merely its consequences.`;
|
|
30328
|
+
}
|
|
30329
|
+
|
|
29774
30330
|
//#endregion
|
|
29775
30331
|
//#region src/deployment/outputs-export-alias.ts
|
|
29776
30332
|
/**
|
|
@@ -30094,6 +30650,218 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
|
|
|
30094
30650
|
}
|
|
30095
30651
|
}
|
|
30096
30652
|
|
|
30653
|
+
//#endregion
|
|
30654
|
+
//#region src/provisioning/dynamodb-warm-throughput.ts
|
|
30655
|
+
/**
|
|
30656
|
+
* The two DynamoDB `WarmThroughput` rules that `AWS::DynamoDB::Table` and
|
|
30657
|
+
* `AWS::DynamoDB::GlobalTable` both need, in ONE spelling.
|
|
30658
|
+
*
|
|
30659
|
+
* `WarmThroughput` is the same CloudFormation block on both types, and both
|
|
30660
|
+
* providers have to answer the same two questions about it before an
|
|
30661
|
+
* `UpdateTable` / `CreateTable` goes out:
|
|
30662
|
+
*
|
|
30663
|
+
* 1. **Is this block SENDABLE, and as what numbers?** CloudFormation is
|
|
30664
|
+
* stringly typed, so `{ReadUnitsPerSecond: '12000'}` — or anything that
|
|
30665
|
+
* came back from an `Fn::Sub` — arrives as a STRING and must not be
|
|
30666
|
+
* forwarded verbatim into a numeric `Long` field.
|
|
30667
|
+
* 2. **Would sending it LOWER what AWS already reports?** Warm throughput
|
|
30668
|
+
* only ever rises with a table's traffic and AWS REJECTS a call that
|
|
30669
|
+
* lowers it (`decreasing WarmThroughput is not supported`, measured live
|
|
30670
|
+
* us-east-1 2026-08-13 for issue #1768).
|
|
30671
|
+
*
|
|
30672
|
+
* **Provenance, stated precisely because it is easy to over-claim.** Exactly
|
|
30673
|
+
* ONE of these rules ever shipped: `AWS::DynamoDB::Table` got them in PR #1808
|
|
30674
|
+
* (issues #1760 / #1768), and the `AWS::DynamoDB::GlobalTable` side never
|
|
30675
|
+
* existed outside the change that created this file (issue #1857). So this is
|
|
30676
|
+
* the Table rule LIFTED — not two shipped rules reconciled, and no deployed
|
|
30677
|
+
* behaviour changed for either type when it moved here.
|
|
30678
|
+
*
|
|
30679
|
+
* What WAS compared is the lifted rule against the GlobalTable spelling
|
|
30680
|
+
* drafted alongside it, over the shapes both had to answer (a quoted numeric
|
|
30681
|
+
* string, a partially usable block, a mixed decrease/increase, an absent live
|
|
30682
|
+
* value, a zero, a negative, a non-numeric string, a whitespace string, an
|
|
30683
|
+
* explicit `undefined` / `null`, a boolean, an empty block, a scalar, an
|
|
30684
|
+
* array). {@link isWarmThroughputDecrease} agreed on every one; the coercion
|
|
30685
|
+
* agreed on all but a whitespace-only string, where a bare `Number(' ')` is
|
|
30686
|
+
* `0` rather than `NaN`. This module keeps the REFUSING answer for that shape,
|
|
30687
|
+
* since `' '` is not a capacity anyone declared and a warm throughput of 0
|
|
30688
|
+
* is not a request AWS can honour. That is a DRAFT reconciled against a
|
|
30689
|
+
* shipped rule, which is worth less than two shipped rules agreeing — the
|
|
30690
|
+
* reason to read the probe list as a design note rather than as field
|
|
30691
|
+
* evidence.
|
|
30692
|
+
*
|
|
30693
|
+
* Living here rather than in either provider is the point. A decrease guard is
|
|
30694
|
+
* three clauses that all FAIL OPEN for different reasons, and two files
|
|
30695
|
+
* spelling it independently is two chances for a later "fix" to change one of
|
|
30696
|
+
* them — at which point the sibling type silently keeps the old answer, and
|
|
30697
|
+
* nothing in the tree says which is intended. Same class as
|
|
30698
|
+
* `emr-configuration.ts`, and the same reason.
|
|
30699
|
+
*
|
|
30700
|
+
* Deliberately NOT here: everything only ONE provider has. The `Table` side's
|
|
30701
|
+
* `isSendableWarmThroughput` / `isRefusedWarmThroughput` /
|
|
30702
|
+
* `declaresWarmThroughput` / `warmThroughputAlreadyMatches` are its drift-side
|
|
30703
|
+
* and already-matches gates, which `GlobalTable` has no counterpart to (issue
|
|
30704
|
+
* #1742 strips the per-index `WarmThroughput` from BOTH of its drift
|
|
30705
|
+
* comparison sides unconditionally, so drift never asks the question there);
|
|
30706
|
+
* the `GlobalTable` side's `warmThroughputDiagnostic` builds a
|
|
30707
|
+
* `ThroughputDiagnostic` that only that provider's collector understands.
|
|
30708
|
+
* Moving a helper with one caller here would buy nothing and cost a hop.
|
|
30709
|
+
*
|
|
30710
|
+
* Issues: #1760 / #1768 (Table, PR #1808), #1857 (GlobalTable).
|
|
30711
|
+
*/
|
|
30712
|
+
/**
|
|
30713
|
+
* The two `WarmThroughput` members, in the ONE order every message, every
|
|
30714
|
+
* comparison and every emitted block uses. A shared order is what makes two
|
|
30715
|
+
* blocks carrying the same numbers compare equal by `deepEqual` and serialize
|
|
30716
|
+
* to the same wire bytes regardless of the order the template wrote them in.
|
|
30717
|
+
*/
|
|
30718
|
+
const WARM_THROUGHPUT_MEMBERS = ["ReadUnitsPerSecond", "WriteUnitsPerSecond"];
|
|
30719
|
+
/**
|
|
30720
|
+
* A CloudFormation-borne numeric property, or `undefined` when the value is
|
|
30721
|
+
* not a usable number.
|
|
30722
|
+
*
|
|
30723
|
+
* Plain `Number()` coercion is NOT good enough and the tree learned it three
|
|
30724
|
+
* separate times: `Number(null)`, `Number('')`, `Number([])`, `Number(false)`
|
|
30725
|
+
* and `Number(' ')` are all **0**, not `NaN` — so a live `0` would compare
|
|
30726
|
+
* EQUAL to a desired `null` / `''` / `[]` / `false`, and a whitespace-only
|
|
30727
|
+
* string would be forwarded as a request for zero units.
|
|
30728
|
+
*
|
|
30729
|
+
* A YAML-borne numeric STRING is still accepted, because that is a real
|
|
30730
|
+
* template shape and `'12000'` genuinely means 12000.
|
|
30731
|
+
*
|
|
30732
|
+
* The accepted STRING set is `Number()`'s, which is WIDER than a decimal
|
|
30733
|
+
* integer: `'0x1e'`, `'0o36'`, `'1e3'`, `'30.5'` and `' 30 '` all coerce.
|
|
30734
|
+
* Whether CloudFormation accepts those for an `Integer`-typed property is
|
|
30735
|
+
* unmeasured, and issue [#2698](https://github.com/go-to-k/cdkd/issues/2698)
|
|
30736
|
+
* holds the live A/B that would settle it. It matters at the CALLERS that
|
|
30737
|
+
* FORWARD the result to AWS rather than merely compare it — narrowing this
|
|
30738
|
+
* shared helper would change the DynamoDB capacity readers too, so read that
|
|
30739
|
+
* issue before tightening anything here.
|
|
30740
|
+
*
|
|
30741
|
+
* EXPORTED, and not because warm throughput needs it exported. This exact rule
|
|
30742
|
+
* was hand-written three times — here, as `dynamodb-table-provider.ts`'s
|
|
30743
|
+
* `capacityNumber` (byte-identical), and as `dynamodb-globaltable-provider.ts`'s
|
|
30744
|
+
* `toFiniteNumber` (a different spelling of the same total function) — which is
|
|
30745
|
+
* precisely the divergence this module exists to stop: the next "fix" to one of
|
|
30746
|
+
* them would have left the other two answering differently, with nothing in the
|
|
30747
|
+
* tree saying which was intended. It reads `ReadCapacityUnits` /
|
|
30748
|
+
* `MaxReadRequestUnits` / `MinCapacity` as readily as it reads
|
|
30749
|
+
* `ReadUnitsPerSecond`; there is only ever one right answer for "is this
|
|
30750
|
+
* stringly-typed CFn value a number I can send?".
|
|
30751
|
+
*/
|
|
30752
|
+
function toFiniteNumber(value) {
|
|
30753
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : void 0;
|
|
30754
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
30755
|
+
const n = Number(value);
|
|
30756
|
+
return Number.isFinite(n) ? n : void 0;
|
|
30757
|
+
}
|
|
30758
|
+
}
|
|
30759
|
+
/**
|
|
30760
|
+
* Coerce a CFn `WarmThroughput` block to the numeric shape the SDK's `Long`
|
|
30761
|
+
* fields accept, PER MEMBER.
|
|
30762
|
+
*
|
|
30763
|
+
* PER MEMBER, not whole-block, and that distinction is the point: a block
|
|
30764
|
+
* whose write half is an unresolved intrinsic still has a perfectly good read
|
|
30765
|
+
* half, and dropping both would silently discard a value the template really
|
|
30766
|
+
* did ask for. The dropped member is NAMED (`droppedMembers`) so the caller's
|
|
30767
|
+
* warning can say which half went missing instead of reporting the whole
|
|
30768
|
+
* property.
|
|
30769
|
+
*
|
|
30770
|
+
* A block with NO usable member yields `spec: undefined` — refused rather than
|
|
30771
|
+
* forwarded, because forwarding a malformed block surfaces as an opaque AWS
|
|
30772
|
+
* validation error naming neither cdkd nor the property. `droppedMembers` is
|
|
30773
|
+
* still populated in that case, so a REFUSAL message can name what it refused;
|
|
30774
|
+
* a block that is not an object at all (an unresolved `Fn::If`, a scalar, an
|
|
30775
|
+
* array) has no member to name and reports none, which is what lets a caller
|
|
30776
|
+
* word "one half went missing" differently from "the block is unusable".
|
|
30777
|
+
*
|
|
30778
|
+
* Pure: takes the raw bag, returns numbers, logs nothing. Each caller owns its
|
|
30779
|
+
* own message, so the wording stays consistent across that provider's send
|
|
30780
|
+
* sites without forcing one wording across both providers.
|
|
30781
|
+
*/
|
|
30782
|
+
function coerceWarmThroughput(raw) {
|
|
30783
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { droppedMembers: [] };
|
|
30784
|
+
const bag = raw;
|
|
30785
|
+
const spec = {};
|
|
30786
|
+
const droppedMembers = [];
|
|
30787
|
+
for (const member of WARM_THROUGHPUT_MEMBERS) {
|
|
30788
|
+
if (bag[member] === void 0) continue;
|
|
30789
|
+
const coerced = toFiniteNumber(bag[member]);
|
|
30790
|
+
if (coerced === void 0) {
|
|
30791
|
+
droppedMembers.push(member);
|
|
30792
|
+
continue;
|
|
30793
|
+
}
|
|
30794
|
+
spec[member] = coerced;
|
|
30795
|
+
}
|
|
30796
|
+
if (Object.keys(spec).length === 0) return { droppedMembers };
|
|
30797
|
+
return {
|
|
30798
|
+
spec,
|
|
30799
|
+
droppedMembers
|
|
30800
|
+
};
|
|
30801
|
+
}
|
|
30802
|
+
/**
|
|
30803
|
+
* Whether the COERCED desired `WarmThroughput` would LOWER what AWS already
|
|
30804
|
+
* reports.
|
|
30805
|
+
*
|
|
30806
|
+
* Measured live (us-east-1, 2026-08-13, issue #1768) against a table AWS
|
|
30807
|
+
* reports `{ReadUnitsPerSecond: 12000, WriteUnitsPerSecond: 4000}` for:
|
|
30808
|
+
*
|
|
30809
|
+
* ```
|
|
30810
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000, WriteUnitsPerSecond: 2000}
|
|
30811
|
+
* ValidationException: One or more parameter values were invalid: Requested
|
|
30812
|
+
* ReadUnitsPerSecond for WarmThroughput for table is lower than current
|
|
30813
|
+
* WarmThroughput, decreasing WarmThroughput is not supported
|
|
30814
|
+
* UpdateTable WarmThroughput={ReadUnitsPerSecond: 6000} -> same rejection
|
|
30815
|
+
* UpdateTable WarmThroughput={WriteUnitsPerSecond: 2000} -> same rejection, naming WriteUnitsPerSecond
|
|
30816
|
+
* UpdateTable WarmThroughput={12000, 4000} (re-assert) -> ACCEPTED
|
|
30817
|
+
* ```
|
|
30818
|
+
*
|
|
30819
|
+
* So the value is one AWS raises with the table's traffic and never lowers,
|
|
30820
|
+
* and a decrease is REJECTED rather than accepted-and-ignored — which is what
|
|
30821
|
+
* had to be measured before choosing, because the two produce different
|
|
30822
|
+
* correct answers. The caller SKIPS the call on a true here.
|
|
30823
|
+
*
|
|
30824
|
+
* Evaluated on the COERCED spec, never on the raw bag: analysing the raw bag
|
|
30825
|
+
* makes the verdict describe a request that is not the one being sent (a
|
|
30826
|
+
* dropped member is not part of the call and must not be part of the
|
|
30827
|
+
* comparison).
|
|
30828
|
+
*
|
|
30829
|
+
* Semantics, all three chosen to FAIL OPEN — i.e. to let the call through and
|
|
30830
|
+
* leave AWS as the authority — because a false positive here silently drops a
|
|
30831
|
+
* legitimate INCREASE, which is a real capacity change the user asked for,
|
|
30832
|
+
* while a false negative merely reproduces the pre-fix behaviour of an
|
|
30833
|
+
* AWS-side rejection that names the property:
|
|
30834
|
+
* - DECLARED members only. An absent member is not a request to lower
|
|
30835
|
+
* anything, so it takes no part in the verdict.
|
|
30836
|
+
* - MIXED is not a decrease. One member below live and the other above means
|
|
30837
|
+
* the call carries a genuine increase; AWS decides.
|
|
30838
|
+
* - An absent or unusable LIVE counterpart is not a decrease. Without a
|
|
30839
|
+
* number to compare against there is no evidence of one. Only the LIVE side
|
|
30840
|
+
* can reach that arm: the desired side is a COERCED spec, so a malformed
|
|
30841
|
+
* template value has already been dropped by {@link coerceWarmThroughput}.
|
|
30842
|
+
*
|
|
30843
|
+
* A decrease therefore requires every declared member to be at-or-below live
|
|
30844
|
+
* AND at least one to be strictly below.
|
|
30845
|
+
*
|
|
30846
|
+
* The skip this drives is right for EVERY `update()` caller — the deploy
|
|
30847
|
+
* engine, `cdkd drift --revert`, and the rollback executor's two revert arms —
|
|
30848
|
+
* because none of them can make AWS lower the value, so none loses anything a
|
|
30849
|
+
* doomed call would have achieved.
|
|
30850
|
+
*/
|
|
30851
|
+
function isWarmThroughputDecrease(desired, live) {
|
|
30852
|
+
if (desired === void 0 || live === void 0) return false;
|
|
30853
|
+
let sawDecrease = false;
|
|
30854
|
+
for (const member of WARM_THROUGHPUT_MEMBERS) {
|
|
30855
|
+
if (desired[member] === void 0) continue;
|
|
30856
|
+
const wanted = toFiniteNumber(desired[member]);
|
|
30857
|
+
const current = toFiniteNumber(live[member]);
|
|
30858
|
+
if (wanted === void 0 || current === void 0) return false;
|
|
30859
|
+
if (wanted > current) return false;
|
|
30860
|
+
if (wanted < current) sawDecrease = true;
|
|
30861
|
+
}
|
|
30862
|
+
return sawDecrease;
|
|
30863
|
+
}
|
|
30864
|
+
|
|
30097
30865
|
//#endregion
|
|
30098
30866
|
//#region src/provisioning/stateful-types.ts
|
|
30099
30867
|
/**
|
|
@@ -30140,9 +30908,12 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
|
|
|
30140
30908
|
* "never expire" — the most data-bearing configuration the type
|
|
30141
30909
|
* has, and what `LogsLogGroupProvider` records `0` for — so reading
|
|
30142
30910
|
* it as "holds nothing" destroyed years of events on a plain
|
|
30143
|
-
* `cdkd deploy`. A
|
|
30144
|
-
* `has-retention` from
|
|
30145
|
-
*
|
|
30911
|
+
* `cdkd deploy`. A `RetentionInDays > 0` recorded in EITHER of the
|
|
30912
|
+
* state record's property bags still answers `has-retention` from
|
|
30913
|
+
* the bags alone (a cheap positive, never probed away); every other
|
|
30914
|
+
* bag DEFERS, exactly as the bucket does. Which bags, and why the
|
|
30915
|
+
* value is coerced rather than type-tested, is issue [#2521] —
|
|
30916
|
+
* see {@link logGroupHasPositiveRetention}.
|
|
30146
30917
|
* The pre-flight resolves the deferral with a live
|
|
30147
30918
|
* `logs:DescribeLogStreams` probe (a log group with no stream can
|
|
30148
30919
|
* hold no event, since every event belongs to a stream); mid-deploy,
|
|
@@ -30327,10 +31098,66 @@ const STATEFUL_TYPES = /* @__PURE__ */ new Set([
|
|
|
30327
31098
|
*/
|
|
30328
31099
|
const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::DynamoDB::GlobalTable"]);
|
|
30329
31100
|
/**
|
|
30330
|
-
*
|
|
31101
|
+
* Does either recorded bag prove this log group carries a POSITIVE
|
|
31102
|
+
* `RetentionInDays`? (issue [#2521])
|
|
31103
|
+
*
|
|
31104
|
+
* Two decisions, and both were defects before this function existed.
|
|
31105
|
+
*
|
|
31106
|
+
* **The value is COERCED, not type-tested.** The predicate used to gate on
|
|
31107
|
+
* `typeof retention === 'number' && retention > 0`, and CloudFormation is
|
|
31108
|
+
* stringly typed: a hand-written L1, an `Fn::Sub`-produced value, a
|
|
31109
|
+
* `Type: String` parameter's default, or a record imported by
|
|
31110
|
+
* `cdkd import --migrate-from-cloudformation` all put `'30'` into the bag,
|
|
31111
|
+
* where the type test answers "not a number". {@link toFiniteNumber} is the
|
|
31112
|
+
* repo's single answer to "is this stringly-typed CFn value a number?" —
|
|
31113
|
+
* imported rather than re-spelled here for the reason its own doc gives, that
|
|
31114
|
+
* the same rule hand-written twice diverges on the next fix to one copy. It
|
|
31115
|
+
* rejects `''`, `' '`, `null`, `[]` and `false`, each of which a bare
|
|
31116
|
+
* `Number()` turns into a `0` that would read as never-expire.
|
|
31117
|
+
*
|
|
31118
|
+
* **BOTH bags are consulted, and a positive in EITHER wins.** The old read
|
|
31119
|
+
* saw `properties` alone, so a retention set OUT OF BAND (the console,
|
|
31120
|
+
* `aws logs put-retention-policy`, another tool) or one recorded only in
|
|
31121
|
+
* `observedProperties` — an imported record whose template never declared
|
|
31122
|
+
* the property — never produced `has-retention`.
|
|
31123
|
+
*
|
|
31124
|
+
* The rule is a plain OR, and the loop order below is INERT — a review round
|
|
31125
|
+
* caught an earlier version of this paragraph claiming `observedProperties`
|
|
31126
|
+
* is "read FIRST", a precedence the code does not implement and must not.
|
|
31127
|
+
* A strict "observed when present, else properties" rule — which is what
|
|
31128
|
+
* issue [#2521] literally prescribed — was tried and REJECTED:
|
|
31129
|
+
* `LogsLogGroupProvider.readCurrentState` writes `RetentionInDays: 0` for a
|
|
31130
|
+
* group with no retention policy, so the observed bag almost always CARRIES
|
|
31131
|
+
* the key, and precedence would make the recorded bag dead for every record
|
|
31132
|
+
* that has ever been captured and would DROP the `has-retention` verdict this
|
|
31133
|
+
* guard already produced. A test pins that rejection
|
|
31134
|
+
* (`tests/unit/provisioning/stateful-types.test.ts`, "reads a retention that
|
|
31135
|
+
* lives ONLY in properties, even against a ZERO observed one"). The OR is
|
|
31136
|
+
* also the strictly safer direction: it can only ADD refusals, never remove
|
|
31137
|
+
* one.
|
|
31138
|
+
*
|
|
31139
|
+
* What a `false` here means is DEFER, not "holds nothing" — see the callers.
|
|
31140
|
+
*/
|
|
31141
|
+
function logGroupHasPositiveRetention(recordedProperties, observedProperties) {
|
|
31142
|
+
for (const bag of [observedProperties, recordedProperties]) {
|
|
31143
|
+
const retention = toFiniteNumber(bag?.["RetentionInDays"]);
|
|
31144
|
+
if (retention !== void 0 && retention > 0) return true;
|
|
31145
|
+
}
|
|
31146
|
+
return false;
|
|
31147
|
+
}
|
|
31148
|
+
/**
|
|
31149
|
+
* Cheap, synchronous read of the state record's own property bags only —
|
|
31150
|
+
* no AWS call. Both bags are parameters rather than one: `properties` is
|
|
31151
|
+
* what the last deploy applied, `observedProperties` what it read back,
|
|
31152
|
+
* and the log group's arm consults BOTH (issue [#2521]). Passing the
|
|
31153
|
+
* observed bag is REQUIRED, not optional, so a new call site has to
|
|
31154
|
+
* decide what it holds instead of silently repeating the omission that
|
|
31155
|
+
* issue records; `undefined` is the right answer where no record is in
|
|
31156
|
+
* hand (`recreate-confirm-prompt.ts`).
|
|
31157
|
+
*
|
|
30331
31158
|
* TWO types return `null` meaning DEFER rather than "not stateful":
|
|
30332
|
-
* `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever
|
|
30333
|
-
*
|
|
31159
|
+
* `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever neither
|
|
31160
|
+
* bag already proves `has-retention`. The live probes
|
|
30334
31161
|
* that resolve both deferrals (`ListObjectVersions` for the bucket,
|
|
30335
31162
|
* `DescribeLogStreams` for the log group) live in
|
|
30336
31163
|
* `src/deployment/recreate-targets.ts#probeStatefulRecreateTargetsAsync`
|
|
@@ -30343,11 +31170,10 @@ const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::Dynam
|
|
|
30343
31170
|
* Returns the {@link StatefulReason} when the type is stateful (or
|
|
30344
31171
|
* `null` for non-stateful types).
|
|
30345
31172
|
*/
|
|
30346
|
-
function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
|
|
31173
|
+
function isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties) {
|
|
30347
31174
|
if (!STATEFUL_TYPES.has(resourceType)) return null;
|
|
30348
31175
|
if (resourceType === "AWS::Logs::LogGroup") {
|
|
30349
|
-
|
|
30350
|
-
if (typeof retention === "number" && retention > 0) return "has-retention";
|
|
31176
|
+
if (logGroupHasPositiveRetention(recordedProperties, observedProperties)) return "has-retention";
|
|
30351
31177
|
return null;
|
|
30352
31178
|
}
|
|
30353
31179
|
if (resourceType === "AWS::S3::Bucket") return null;
|
|
@@ -30374,16 +31200,38 @@ function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
|
|
|
30374
31200
|
* — be DELETE + CREATEd (data loss) without `--force-stateful-recreation`. To
|
|
30375
31201
|
* stay fail-safe, both deferrals resolve to stateful here: the user must pass
|
|
30376
31202
|
* `--force-stateful-recreation` to replace ANY S3 bucket, and any log group
|
|
30377
|
-
* whose recorded
|
|
30378
|
-
* paths — empty or not.
|
|
31203
|
+
* neither of whose recorded property bags already proves `has-retention`, on
|
|
31204
|
+
* any of those paths — empty or not.
|
|
31205
|
+
*
|
|
31206
|
+
* `UpdateReplacePolicy: Retain` is the standing EXEMPTION, and it covers all
|
|
31207
|
+
* three (issue [#2604]): the engine never consults this predicate under it —
|
|
31208
|
+
* the property-driven guard tests `updateReplacePolicy !== 'Retain'`
|
|
31209
|
+
* directly, and the fallback's two triggers short-circuit through
|
|
31210
|
+
* `retainOldOnReplace`, which issue [#2518] added. The old resource survives
|
|
31211
|
+
* the replacement, so there is no data loss to confirm, and the refusal's own
|
|
31212
|
+
* remedy would have destroyed exactly what the user asked to keep.
|
|
31213
|
+
* `Snapshot` is NOT exempt on any of them — a snapshot is a copy, not a
|
|
31214
|
+
* surviving resource. So read the paragraph above as scoped to a template
|
|
31215
|
+
* that is not retaining.
|
|
31216
|
+
*
|
|
31217
|
+
* The two engine sites are not the only callers: a THIRD sits outside those
|
|
31218
|
+
* paths and outside that exemption — `recreate-confirm-prompt.ts` re-derives
|
|
31219
|
+
* a `null` verdict for the `--recreate-via-*` pre-flight, where
|
|
31220
|
+
* `--force-stateful-recreation` is what skipped the probe.
|
|
31221
|
+
* `stateful-replace-message-doc-sync` pins the whole guard's reader list by
|
|
31222
|
+
* file, but the residuals comment above its own walk enumerates what that
|
|
31223
|
+
* cannot see — among them an ALIASED import, a `.mts` / `.cts` reader, and,
|
|
31224
|
+
* the one that bit, a NEW PATH routed through an existing call site, which
|
|
31225
|
+
* adds no file and reds nothing (issue [#2514]'s shape). So this enumeration
|
|
31226
|
+
* is maintained by hand.
|
|
30379
31227
|
*
|
|
30380
31228
|
* The log group's arm is the one issue [#2558] added, and the reason it is
|
|
30381
31229
|
* needed is that the old predicate treated "no retention recorded" as "holds
|
|
30382
31230
|
* nothing" when it is CloudWatch Logs' never-expire. Every other type matches
|
|
30383
31231
|
* {@link isStatefulRecreateTargetSync} exactly.
|
|
30384
31232
|
*/
|
|
30385
|
-
function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
|
|
30386
|
-
const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties);
|
|
31233
|
+
function isStatefulRecreateTargetForReplace(resourceType, recordedProperties, observedProperties) {
|
|
31234
|
+
const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties);
|
|
30387
31235
|
if (sync) return sync;
|
|
30388
31236
|
if (resourceType === "AWS::S3::Bucket") return "has-objects";
|
|
30389
31237
|
if (resourceType === "AWS::Logs::LogGroup") return "has-log-events";
|
|
@@ -31545,6 +32393,84 @@ function rollbackFinalSnapshotId(resourceType, record, fallbackProvisionedBy) {
|
|
|
31545
32393
|
return buildFinalSnapshotIdentifier(record.physicalId, resourceType);
|
|
31546
32394
|
}
|
|
31547
32395
|
/**
|
|
32396
|
+
* `UpdateReplacePolicy: Retain` on the resource a replacement CREATED — the
|
|
32397
|
+
* copy a rollback would otherwise destroy (issue
|
|
32398
|
+
* [#2598](https://github.com/go-to-k/cdkd/issues/2598)).
|
|
32399
|
+
*
|
|
32400
|
+
* Reads the CURRENT record, i.e. the one the replacing deploy wrote from the
|
|
32401
|
+
* template it was applying (`extractTemplateAttributes`), so the attribute
|
|
32402
|
+
* consulted is the one that was in force when the new copy was created. Its
|
|
32403
|
+
* `Snapshot` sibling, {@link rollbackFinalSnapshotId}, reads the same field of
|
|
32404
|
+
* the same record — `Retain` and `Snapshot` are alternative values of ONE
|
|
32405
|
+
* attribute, so the two can never both apply.
|
|
32406
|
+
*
|
|
32407
|
+
* **`UpdateReplacePolicy`, NOT `DeletionPolicy`, and that is measured, not
|
|
32408
|
+
* reasoned.** The repo refuses a CloudFormation-parity claim taken on
|
|
32409
|
+
* folklore, and the AWS documentation answers nothing here: every sentence on
|
|
32410
|
+
* both attribute pages, in the API reference and in the release notes
|
|
32411
|
+
* describes the OLD resource, never the new copy's fate during a rollback. A
|
|
32412
|
+
* live four-variant A/B (2026-09-05, us-east-1: a forced `AWS::SSM::Parameter`
|
|
32413
|
+
* replacement plus a deterministically failing sibling, rolled back) settled
|
|
32414
|
+
* it:
|
|
32415
|
+
*
|
|
32416
|
+
* | DeletionPolicy | UpdateReplacePolicy | new copy | decisive event |
|
|
32417
|
+
* | -------------- | ------------------- | --------- | ---------------- |
|
|
32418
|
+
* | (none) | (none) | DELETED | `DELETE_COMPLETE` |
|
|
32419
|
+
* | Retain | (none) | DELETED | `DELETE_COMPLETE` |
|
|
32420
|
+
* | (none) | Retain | SURVIVED | `DELETE_SKIPPED` |
|
|
32421
|
+
* | Retain | Retain | SURVIVED | `DELETE_SKIPPED` |
|
|
32422
|
+
*
|
|
32423
|
+
* Row 2 alone refutes "`DeletionPolicy` governs it"; row 3 alone refutes
|
|
32424
|
+
* "neither — always deleted". The old copy was restored intact in all four,
|
|
32425
|
+
* and both outcomes land in `UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS`.
|
|
32426
|
+
*
|
|
32427
|
+
* A retained new copy is ORPHANED OUT of the stack, not kept as a managed
|
|
32428
|
+
* resource — the A/B proved it by deleting the whole stack afterwards and
|
|
32429
|
+
* finding the retained parameter still alive. So every caller below leaves NO
|
|
32430
|
+
* state record naming the survivor: the two arms that can complete point state
|
|
32431
|
+
* at the old resource exactly as they already did, and the survivor becomes
|
|
32432
|
+
* untracked. That is the same disposition the deploy engine gives a
|
|
32433
|
+
* `Retain`-orphaned OLD resource, so the two directions agree.
|
|
32434
|
+
*
|
|
32435
|
+
* LIMIT OF THAT EVIDENCE, stated so a later reader does not over-read it: all
|
|
32436
|
+
* four variants carried the SAME policy in both template versions, so the A/B
|
|
32437
|
+
* pinned WHICH ATTRIBUTE wins and did NOT discriminate which template's copy
|
|
32438
|
+
* of it is read. This function reads the current record for the reasons above
|
|
32439
|
+
* (it is the one the new copy was created under, and it matches the
|
|
32440
|
+
* `Snapshot` sibling and both CREATE-rollback arms), not because the A/B
|
|
32441
|
+
* settled that question.
|
|
32442
|
+
*/
|
|
32443
|
+
function rollbackRetainsNewResource(record) {
|
|
32444
|
+
return record?.updateReplacePolicy === "Retain";
|
|
32445
|
+
}
|
|
32446
|
+
/**
|
|
32447
|
+
* The two sentences a `UpdateReplacePolicy: Retain` survivor needs: the `⚠`
|
|
32448
|
+
* terminal warning and the compact `reason` that rides on the durable
|
|
32449
|
+
* `ROLLBACK_RESOURCE_SUCCEEDED` event.
|
|
32450
|
+
*
|
|
32451
|
+
* ONE function because the two must not drift apart. Both replacement-rollback
|
|
32452
|
+
* retain arms produced these by hand, four near-identical copies, and the
|
|
32453
|
+
* failure mode a reviewer named is precise: the warn and the DURABLE record
|
|
32454
|
+
* disagreeing about which id survived. Deriving both from one set of inputs
|
|
32455
|
+
* makes that unrepresentable. The shapes stay deliberately different -- the
|
|
32456
|
+
* warn carries the cost/`cdkd destroy` guidance a human reads once, the reason
|
|
32457
|
+
* stays compact for a `--json` consumer -- so this is one input set, not one
|
|
32458
|
+
* string.
|
|
32459
|
+
*
|
|
32460
|
+
* `stateClause` is the only thing that differs between the two arms (the
|
|
32461
|
+
* readopt arm restores the old id; the create-first arm records a re-created
|
|
32462
|
+
* one), so it is a parameter rather than a branch in here.
|
|
32463
|
+
*
|
|
32464
|
+
* NOT used by the delete-failed survivor a few lines down: that one is an
|
|
32465
|
+
* orphan by OUTCOME rather than by policy, and says so.
|
|
32466
|
+
*/
|
|
32467
|
+
function retainedSurvivorMessages(logicalId, resourceType, survivorPhysicalId, stateClause) {
|
|
32468
|
+
return {
|
|
32469
|
+
warn: ` ⚠ ${logicalId} (${resourceType}) has UpdateReplacePolicy: Retain — the replacement's new physical resource (${survivorPhysicalId}) is RETAINED by this rollback and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need it. ${stateClause}`,
|
|
32470
|
+
reason: `UpdateReplacePolicy: Retain kept the replacement's new ${resourceType} (${survivorPhysicalId}); it is live, still billing, and no longer tracked by cdkd. ${stateClause}`
|
|
32471
|
+
};
|
|
32472
|
+
}
|
|
32473
|
+
/**
|
|
31548
32474
|
* `DeletionPolicy: Snapshot` on a rolled-back CREATE (issue #1358) — the
|
|
31549
32475
|
* executor's copy of the deploy engine's `prepareFinalSnapshotForDelete`
|
|
31550
32476
|
* mechanism matrix, run BEFORE the delete. Shared with the FAILED in-flight
|
|
@@ -31643,7 +32569,7 @@ function classifyRollbackOp(op, stateResources, orphanLogicalIds) {
|
|
|
31643
32569
|
if (deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
|
|
31644
32570
|
return "skip-mismatch";
|
|
31645
32571
|
}
|
|
31646
|
-
return op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
|
|
32572
|
+
return op.oldResourceRetained ?? op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
|
|
31647
32573
|
}
|
|
31648
32574
|
if (op.previousState && deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
|
|
31649
32575
|
return "revert";
|
|
@@ -31683,12 +32609,16 @@ function planFailedOps(failedOps, stateResources) {
|
|
|
31683
32609
|
*/
|
|
31684
32610
|
function planRollback(operations, stateResources, orphanLogicalIds = /* @__PURE__ */ new Set()) {
|
|
31685
32611
|
const { createOps, otherOps } = partitionOps(operations);
|
|
31686
|
-
return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) =>
|
|
31687
|
-
op,
|
|
31688
|
-
|
|
31689
|
-
|
|
31690
|
-
|
|
31691
|
-
|
|
32612
|
+
return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) => {
|
|
32613
|
+
const action = classifyRollbackOp(op, stateResources, orphanLogicalIds);
|
|
32614
|
+
return {
|
|
32615
|
+
op,
|
|
32616
|
+
action,
|
|
32617
|
+
replacement: isReplacementOp(op),
|
|
32618
|
+
effectiveProvisionedBy: effectiveProvisionedBy(stateResources[op.logicalId], op.provisionedBy),
|
|
32619
|
+
retainsNewResource: (action === "reverse-replacement" || action === "reverse-replacement-readopt") && rollbackRetainsNewResource(stateResources[op.logicalId])
|
|
32620
|
+
};
|
|
32621
|
+
});
|
|
31692
32622
|
}
|
|
31693
32623
|
function partitionOps(operations) {
|
|
31694
32624
|
const createOps = [];
|
|
@@ -32046,11 +32976,32 @@ async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resol
|
|
|
32046
32976
|
* writer is one of only two that can honestly claim it.
|
|
32047
32977
|
*
|
|
32048
32978
|
* Using `STATE_SOURCED_READBACK_RULES` here reads plausible and is wrong in the
|
|
32049
|
-
* quiet direction: it turns positional array descent off.
|
|
32050
|
-
*
|
|
32051
|
-
*
|
|
32052
|
-
*
|
|
32053
|
-
*
|
|
32979
|
+
* quiet direction: it turns BLIND positional array descent off. BLIND is
|
|
32980
|
+
* load-bearing, and an earlier revision of this paragraph omitted it — the
|
|
32981
|
+
* concrete loss is narrower than "positional descent is off" makes it sound,
|
|
32982
|
+
* by TWO mechanisms rather than one:
|
|
32983
|
+
*
|
|
32984
|
+
* - Since issue #1915 a `Tags[]` / ECS `Environment[]` element is reached by
|
|
32985
|
+
* the order-independent KEYED descent either way.
|
|
32986
|
+
* - Since issue #2012 an UNKEYED list is reached too, under corroboration.
|
|
32987
|
+
* That is not a general relaxation: swapping this constant in would satisfy
|
|
32988
|
+
* all three conjuncts of `isReadbackProjectedFromState`
|
|
32989
|
+
* (`trustAnyExpression && !descendArrays && sourceIsSameGeneration`), which
|
|
32990
|
+
* ARMS `refuseUncertifiedReadbackPositions`, and its unkeyed arm walks
|
|
32991
|
+
* element i against element i whenever `unkeyedArrayPairsByAnchors`
|
|
32992
|
+
* corroborates the alignment (index counts match; every position whose
|
|
32993
|
+
* SOURCE subtree carries no dynamic reference is deep-equal on both sides;
|
|
32994
|
+
* every reference-bearing element carries a distinguishing anchor of its own
|
|
32995
|
+
* or, being a bare reference leaf, leans on the array's literal frame; and
|
|
32996
|
+
* no two reference-bearing elements share an order-insensitive anchor
|
|
32997
|
+
* signature).
|
|
32998
|
+
*
|
|
32999
|
+
* So the residual loss is narrower again: an unkeyed list whose positions ALSO
|
|
33000
|
+
* fail to corroborate. The CONCLUSION is unchanged — `STATE_DERIVED_RULES` is
|
|
33001
|
+
* still right here, for the reason one paragraph up (the bag was produced by
|
|
33002
|
+
* resolving the source, so the two correspond positionally by construction and
|
|
33003
|
+
* need no corroboration to say so). What changes is only how much a reader
|
|
33004
|
+
* should think the alternative costs (issue #2691).
|
|
32054
33005
|
*
|
|
32055
33006
|
* No-op when the op resolved no secret.
|
|
32056
33007
|
*/
|
|
@@ -32260,7 +33211,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32260
33211
|
return;
|
|
32261
33212
|
case "orphan-flag":
|
|
32262
33213
|
if (op.changeType === "CREATE") {
|
|
32263
|
-
const
|
|
33214
|
+
const record = stateResources[op.logicalId];
|
|
33215
|
+
const orphanFlagProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
|
|
32264
33216
|
createRollbackRoute = orphanFlagProvisionedBy;
|
|
32265
33217
|
delete stateResources[op.logicalId];
|
|
32266
33218
|
logger.info(` Rollback: Orphaning created resource ${op.logicalId} (--orphan)`);
|
|
@@ -32271,12 +33223,17 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32271
33223
|
operation: "CREATE",
|
|
32272
33224
|
logicalId: op.logicalId,
|
|
32273
33225
|
resourceType: op.resourceType,
|
|
32274
|
-
...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy }
|
|
33226
|
+
...orphanFlagProvisionedBy && { provisionedBy: orphanFlagProvisionedBy },
|
|
33227
|
+
...record?.physicalId && {
|
|
33228
|
+
physicalId: record.physicalId,
|
|
33229
|
+
reason: `--orphan left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
|
|
33230
|
+
}
|
|
32275
33231
|
});
|
|
32276
33232
|
} else logger.info(` Rollback: Leaving ${op.logicalId} at its new state (--orphan)`);
|
|
32277
33233
|
return;
|
|
32278
33234
|
case "orphan-retain": {
|
|
32279
|
-
const
|
|
33235
|
+
const record = stateResources[op.logicalId];
|
|
33236
|
+
const orphanProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
|
|
32280
33237
|
createRollbackRoute = orphanProvisionedBy;
|
|
32281
33238
|
delete stateResources[op.logicalId];
|
|
32282
33239
|
logger.info(` Rollback: Leaving ${op.logicalId} (${op.resourceType}) in AWS (DeletionPolicy: Retain) — removed from state`);
|
|
@@ -32287,7 +33244,11 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32287
33244
|
operation: "CREATE",
|
|
32288
33245
|
logicalId: op.logicalId,
|
|
32289
33246
|
resourceType: op.resourceType,
|
|
32290
|
-
...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy }
|
|
33247
|
+
...orphanProvisionedBy && { provisionedBy: orphanProvisionedBy },
|
|
33248
|
+
...record?.physicalId && {
|
|
33249
|
+
physicalId: record.physicalId,
|
|
33250
|
+
reason: `DeletionPolicy: Retain left ${op.logicalId} (${op.resourceType}) in AWS as ${record.physicalId} and dropped it from state; it is live, still billing, and no longer tracked by cdkd.`
|
|
33251
|
+
}
|
|
32291
33252
|
});
|
|
32292
33253
|
return;
|
|
32293
33254
|
}
|
|
@@ -32330,11 +33291,32 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32330
33291
|
const current = stateResources[op.logicalId];
|
|
32331
33292
|
const prev = op.previousState;
|
|
32332
33293
|
logger.info(` Rollback: Reversing replacement of ${op.logicalId} (${op.resourceType}) — deleting the new resource and re-adopting the retained old one (${prev.physicalId})`);
|
|
32333
|
-
|
|
32334
|
-
|
|
32335
|
-
|
|
32336
|
-
|
|
32337
|
-
|
|
33294
|
+
/**
|
|
33295
|
+
* Set when this arm ORPHANS the replacement's new copy. Read at the
|
|
33296
|
+
* `ROLLBACK_RESOURCE_SUCCEEDED` event below, which is the only channel
|
|
33297
|
+
* that OUTLIVES the terminal (security review of issue #2598): a
|
|
33298
|
+
* rollback runs during an already-failing deploy, often non-TTY with
|
|
33299
|
+
* the log truncated or discarded, so a `logger.warn` is the least
|
|
33300
|
+
* likely thing the user still has. Without this the survivor's id dies
|
|
33301
|
+
* with the terminal -- `cdkd events` shows a clean success and state
|
|
33302
|
+
* names only the OLD resource, while a live, billing, untracked copy
|
|
33303
|
+
* remains. `Retain` is precisely the marker users put on data-bearing
|
|
33304
|
+
* resources, so that is the worst population to lose the id for.
|
|
33305
|
+
*
|
|
33306
|
+
* Same shape as the `rollbackPartial` survivor record ~700 lines down
|
|
33307
|
+
* and as the deploy engine's `RESOURCE_SKIPPED` twin.
|
|
33308
|
+
*/
|
|
33309
|
+
let survivorReason;
|
|
33310
|
+
if (rollbackRetainsNewResource(current)) {
|
|
33311
|
+
const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State is restored to the old resource (${prev.physicalId}).`);
|
|
33312
|
+
logger.warn(survivorMessages.warn);
|
|
33313
|
+
survivorReason = survivorMessages.reason;
|
|
33314
|
+
result.warnings++;
|
|
33315
|
+
} else {
|
|
33316
|
+
const { provider: newDeleteProvider } = ctx.providerRegistry.getProviderFor({
|
|
33317
|
+
resourceType: op.resourceType,
|
|
33318
|
+
provisionedBy: current.provisionedBy ?? op.provisionedBy
|
|
33319
|
+
});
|
|
32338
33320
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
32339
33321
|
throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
32340
33322
|
expectedRegion: ctx.region,
|
|
@@ -32344,13 +33326,19 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32344
33326
|
stateResources[op.logicalId] = prev;
|
|
32345
33327
|
logger.info(` Rollback: ${op.logicalId} restored to the retained old resource`);
|
|
32346
33328
|
await afterOp?.(op.logicalId);
|
|
33329
|
+
const survivorProvisionedBy = current.provisionedBy;
|
|
32347
33330
|
ctx.recordEvent?.({
|
|
32348
33331
|
eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
|
|
32349
33332
|
stackName,
|
|
32350
33333
|
operation: "UPDATE",
|
|
32351
33334
|
logicalId: op.logicalId,
|
|
32352
33335
|
resourceType: op.resourceType,
|
|
32353
|
-
...op.provisionedBy && { provisionedBy: op.provisionedBy }
|
|
33336
|
+
...op.provisionedBy && { provisionedBy: op.provisionedBy },
|
|
33337
|
+
...survivorReason !== void 0 && {
|
|
33338
|
+
physicalId: current.physicalId,
|
|
33339
|
+
reason: maskSecretsInText(survivorReason, secrets),
|
|
33340
|
+
...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
|
|
33341
|
+
}
|
|
32354
33342
|
});
|
|
32355
33343
|
return;
|
|
32356
33344
|
}
|
|
@@ -32366,10 +33354,10 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32366
33354
|
resourceType: op.resourceType,
|
|
32367
33355
|
provisionedBy: prev.provisionedBy
|
|
32368
33356
|
});
|
|
32369
|
-
const
|
|
33357
|
+
const resolveNewDeleteProvider = () => ctx.providerRegistry.getProviderFor({
|
|
32370
33358
|
resourceType: op.resourceType,
|
|
32371
33359
|
provisionedBy: current.provisionedBy ?? op.provisionedBy
|
|
32372
|
-
});
|
|
33360
|
+
}).provider;
|
|
32373
33361
|
let deletedNewFirst = false;
|
|
32374
33362
|
let createResult;
|
|
32375
33363
|
try {
|
|
@@ -32378,11 +33366,13 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32378
33366
|
interruptedMessage: "Rollback interrupted while waiting out the name cooldown"
|
|
32379
33367
|
});
|
|
32380
33368
|
} catch (createError) {
|
|
32381
|
-
|
|
33369
|
+
const msg = createError instanceof Error ? createError.message : String(createError);
|
|
33370
|
+
if (!isNameCollisionError(msg)) throw createError;
|
|
33371
|
+
if (rollbackRetainsNewResource(current)) throw markNonRetryable(new CdkdError(maskSecretsInText(`Cannot reverse the replacement of ${op.logicalId} (${op.resourceType}): the re-create of the old resource (${prev.physicalId}) collided with the name still held by the new one (${current.physicalId}), and UpdateReplacePolicy: Retain pins that new resource in place, so cdkd will not delete it to free the name. Delete the new resource yourself, or remove UpdateReplacePolicy: Retain, then re-run \`cdkd rollback\` — the journal is kept, so the revert resumes from here. To leave THIS resource alone and let the rest of the rollback proceed, re-run with \`cdkd rollback --orphan ${op.logicalId}\`: one op failure stops the segment loop, so a single pinned resource otherwise halts every OLDER segment too. Underlying collision: ${msg}`, secrets), "NAMED_REPLACEMENT_COLLISION", maskSecretsInError(createError instanceof Error ? createError : void 0, secrets)));
|
|
32382
33372
|
logger.info(` Rollback: re-create collided with the new resource's name — deleting the new resource (${current.physicalId}) first...`);
|
|
32383
33373
|
{
|
|
32384
33374
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
32385
|
-
throwIfDeleteSkipped(await
|
|
33375
|
+
throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
32386
33376
|
expectedRegion: ctx.region,
|
|
32387
33377
|
...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
|
|
32388
33378
|
}), op.logicalId, current.physicalId, "while clearing the new resource so the old one could be re-created");
|
|
@@ -32396,7 +33386,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32396
33386
|
interruptedMessage: "Rollback interrupted while waiting for the old name to release"
|
|
32397
33387
|
});
|
|
32398
33388
|
} catch (recreateError) {
|
|
32399
|
-
throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets));
|
|
33389
|
+
throw new Error(maskSecretsInText(`Failed to re-create the old ${op.logicalId} after the new resource (${current.physicalId}) was already deleted: ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. The resource is now absent — fix forward with 'cdkd deploy'.`, secrets), { cause: maskSecretsInError(recreateError instanceof Error ? recreateError : void 0, secrets) });
|
|
32400
33390
|
}
|
|
32401
33391
|
}
|
|
32402
33392
|
const adoptedLiveNewResource = !deletedNewFirst && createResult.physicalId === current.physicalId;
|
|
@@ -32412,24 +33402,37 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
32412
33402
|
properties: recordedPropertiesAfterReplayCreate(prevRecord, createResult)
|
|
32413
33403
|
}, secrets, prevRecord.properties);
|
|
32414
33404
|
await afterOp?.(op.logicalId);
|
|
32415
|
-
|
|
33405
|
+
let survivorReason;
|
|
33406
|
+
if (!deletedNewFirst && !adoptedLiveNewResource && rollbackRetainsNewResource(current)) {
|
|
33407
|
+
const survivorMessages = retainedSurvivorMessages(op.logicalId, op.resourceType, current.physicalId, `State records the re-created old resource (${stateResources[op.logicalId]?.physicalId ?? prev.physicalId}).`);
|
|
33408
|
+
logger.warn(survivorMessages.warn);
|
|
33409
|
+
survivorReason = survivorMessages.reason;
|
|
33410
|
+
result.warnings++;
|
|
33411
|
+
} else if (!deletedNewFirst && !adoptedLiveNewResource) try {
|
|
32416
33412
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
32417
|
-
throwIfDeleteSkipped(await
|
|
33413
|
+
throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
32418
33414
|
expectedRegion: ctx.region,
|
|
32419
33415
|
...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
|
|
32420
33416
|
}), op.logicalId, current.physicalId, "while deleting the new resource after re-creating the old one");
|
|
32421
33417
|
} catch (deleteError) {
|
|
32422
33418
|
logger.warn(maskSecretsInText(` Rollback: old ${op.logicalId} re-created, but deleting the new resource (${current.physicalId}) failed: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. Delete it manually — it is no longer tracked in state.`, secrets));
|
|
33419
|
+
survivorReason = `The replacement's new ${op.resourceType} (${current.physicalId}) could not be deleted after the old resource was re-created: ${deleteError instanceof Error ? deleteError.message : String(deleteError)}. It is live, still billing, and no longer tracked by cdkd — delete it yourself.`;
|
|
32423
33420
|
result.warnings++;
|
|
32424
33421
|
}
|
|
32425
33422
|
logger.info(adoptedLiveNewResource ? ` Rollback: ${op.logicalId} adopted the live resource (${createResult.physicalId}) — replacement NOT fully reversed (name-idempotent Create API)` : ` Rollback: ${op.logicalId} replacement reversed (old resource re-created as ${createResult.physicalId})`);
|
|
33423
|
+
const survivorProvisionedBy = current.provisionedBy;
|
|
32426
33424
|
ctx.recordEvent?.({
|
|
32427
33425
|
eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
|
|
32428
33426
|
stackName,
|
|
32429
33427
|
operation: "UPDATE",
|
|
32430
33428
|
logicalId: op.logicalId,
|
|
32431
33429
|
resourceType: op.resourceType,
|
|
32432
|
-
...op.provisionedBy && { provisionedBy: op.provisionedBy }
|
|
33430
|
+
...op.provisionedBy && { provisionedBy: op.provisionedBy },
|
|
33431
|
+
...survivorReason !== void 0 && {
|
|
33432
|
+
physicalId: current.physicalId,
|
|
33433
|
+
reason: maskSecretsInText(survivorReason, secrets),
|
|
33434
|
+
...survivorProvisionedBy && { provisionedBy: survivorProvisionedBy }
|
|
33435
|
+
}
|
|
32433
33436
|
});
|
|
32434
33437
|
return;
|
|
32435
33438
|
}
|
|
@@ -33118,7 +34121,10 @@ var DeploymentEventsReader = class {
|
|
|
33118
34121
|
* window and rewrites (or removes) `index.json` to match.
|
|
33119
34122
|
*
|
|
33120
34123
|
* Retention semantics (see {@link DeploymentEventsPruneOptions}):
|
|
33121
|
-
* - `all` — delete every run + the index
|
|
34124
|
+
* - `all` — delete every run + the index. Clears the LISTING only:
|
|
34125
|
+
* this deletes by key with no `VersionId`, so on a
|
|
34126
|
+
* versioned bucket every earlier version survives
|
|
34127
|
+
* (issue #2624).
|
|
33122
34128
|
* - `keep N` — retain the newest N runs, delete the rest.
|
|
33123
34129
|
* - `olderThanMs`— delete runs whose run-id timestamp is older than the
|
|
33124
34130
|
* cutoff; a run id with no parseable timestamp is kept.
|
|
@@ -33533,6 +34539,39 @@ var DeployEngine = class {
|
|
|
33533
34539
|
*/
|
|
33534
34540
|
attemptedResolvedProps = /* @__PURE__ */ new Map();
|
|
33535
34541
|
/**
|
|
34542
|
+
* Logical ids whose replacement this deploy DELIBERATELY left the old
|
|
34543
|
+
* physical resource alive for — `UpdateReplacePolicy: Retain` (issue
|
|
34544
|
+
* [#2603](https://github.com/go-to-k/cdkd/issues/2603)).
|
|
34545
|
+
*
|
|
34546
|
+
* Written by every engine path that skips the post-replacement delete, read
|
|
34547
|
+
* once at the `completedOperations.push` site to stamp
|
|
34548
|
+
* {@link CompletedOperation.oldResourceRetained}. It exists because the
|
|
34549
|
+
* rollback classifier used to re-derive the verdict from
|
|
34550
|
+
* `previousState.updateReplacePolicy` — a DIFFERENT source than the
|
|
34551
|
+
* template read the engine decides from — so the two disagreed on exactly
|
|
34552
|
+
* the deploy that changes the attribute, in both directions:
|
|
34553
|
+
*
|
|
34554
|
+
* - ADDING `Retain`: the deploy orphans the old resource while the
|
|
34555
|
+
* previous state record carries no policy, so the rollback re-CREATED a
|
|
34556
|
+
* resource that is still alive (a duplicate for an auto-named type, an
|
|
34557
|
+
* `AlreadyExists` failure for a user-named one).
|
|
34558
|
+
* - DROPPING `Retain`: state still carries the stale `Retain`, the
|
|
34559
|
+
* template omits it so the deploy correctly DELETES the old resource,
|
|
34560
|
+
* and the rollback then re-adopted a physical id that no longer exists,
|
|
34561
|
+
* leaving state naming a deleted resource.
|
|
34562
|
+
*
|
|
34563
|
+
* Records only the DELIBERATE retention. A best-effort cleanup delete that
|
|
34564
|
+
* FAILED, was SKIPPED, or was blocked by a failed final snapshot also leaves
|
|
34565
|
+
* the old resource alive, but the deploy does not KNOW it survived — those
|
|
34566
|
+
* stay `false` and keep today's `reverse-replacement` behaviour rather than
|
|
34567
|
+
* having the rollback re-adopt an id it cannot vouch for (issue
|
|
34568
|
+
* [#2631](https://github.com/go-to-k/cdkd/issues/2631)).
|
|
34569
|
+
*
|
|
34570
|
+
* Cleared per `deploy()` alongside the other per-run maps: a `false` here
|
|
34571
|
+
* must mean "this deploy deleted it", never "a previous run said so".
|
|
34572
|
+
*/
|
|
34573
|
+
retainedOldOnReplacement = /* @__PURE__ */ new Set();
|
|
34574
|
+
/**
|
|
33536
34575
|
* Target region for this stack. Required — load-bearing for the
|
|
33537
34576
|
* region-prefixed S3 state key and recorded in state.json for
|
|
33538
34577
|
* cross-region destroy.
|
|
@@ -33571,6 +34610,7 @@ var DeployEngine = class {
|
|
|
33571
34610
|
this.outputSecrets = /* @__PURE__ */ new Map();
|
|
33572
34611
|
this.outputsTemplateSource = {};
|
|
33573
34612
|
this.outputsSourceUsable = true;
|
|
34613
|
+
this.retainedOldOnReplacement = /* @__PURE__ */ new Set();
|
|
33574
34614
|
this.resolver.resetPhysicalIdFallbackCount();
|
|
33575
34615
|
return withStackName(stackName, () => this.doDeploy(stackName, template));
|
|
33576
34616
|
}
|
|
@@ -33992,7 +35032,10 @@ var DeployEngine = class {
|
|
|
33992
35032
|
for (const { logicalId, resource } of candidates) {
|
|
33993
35033
|
let provider;
|
|
33994
35034
|
try {
|
|
33995
|
-
provider = this.providerRegistry.
|
|
35035
|
+
provider = this.providerRegistry.getProviderFor({
|
|
35036
|
+
resourceType: resource.resourceType,
|
|
35037
|
+
provisionedBy: resource.provisionedBy
|
|
35038
|
+
}).provider;
|
|
33996
35039
|
} catch {
|
|
33997
35040
|
continue;
|
|
33998
35041
|
}
|
|
@@ -34084,6 +35127,11 @@ var DeployEngine = class {
|
|
|
34084
35127
|
diffResolverContext.skipDynamicReferences = true;
|
|
34085
35128
|
const diffResolveFn = (value) => this.resolver.resolve(value, diffResolverContext);
|
|
34086
35129
|
const changes = await this.diffCalculator.calculateDiff(currentState, effectiveTemplate, diffResolveFn, makeCanonicalizePropertiesFn(this.providerRegistry));
|
|
35130
|
+
const nestedStackTypeChanges = findNestedStackTypeChanges({
|
|
35131
|
+
changes,
|
|
35132
|
+
stateResources: currentState.resources
|
|
35133
|
+
});
|
|
35134
|
+
if (nestedStackTypeChanges.length > 0) throw markNonRetryable(new CdkdError(renderNestedStackTypeChangeRefusal(nestedStackTypeChanges, stackName), "TYPE_CHANGE_NESTED_STACK"));
|
|
34087
35135
|
if (!this.diffCalculator.hasChanges(changes)) {
|
|
34088
35136
|
this.logger.info("No changes detected. Stack is up to date.");
|
|
34089
35137
|
let persistedOutputs = currentState.outputs ?? {};
|
|
@@ -34293,7 +35341,8 @@ var DeployEngine = class {
|
|
|
34293
35341
|
provisionedBy: newResources[logicalId]?.provisionedBy ?? previousState?.provisionedBy,
|
|
34294
35342
|
previousState,
|
|
34295
35343
|
physicalId: newResources[logicalId]?.physicalId,
|
|
34296
|
-
properties: newResources[logicalId]?.properties
|
|
35344
|
+
properties: newResources[logicalId]?.properties,
|
|
35345
|
+
...change.changeType === "UPDATE" && { oldResourceRetained: this.retainedOldOnReplacement.has(logicalId) }
|
|
34297
35346
|
});
|
|
34298
35347
|
saveStateAfterResource(logicalId);
|
|
34299
35348
|
}, () => this.interrupted);
|
|
@@ -34820,7 +35869,7 @@ var DeployEngine = class {
|
|
|
34820
35869
|
isRetryable: isRecreateRetryableError
|
|
34821
35870
|
});
|
|
34822
35871
|
} catch (recreateError) {
|
|
34823
|
-
throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets));
|
|
35872
|
+
throw new Error(maskSecretsInText(`Failed to re-create ${logicalId} after the --replace delete-first fallback already deleted the old resource (${currentResource.physicalId}): ${recreateError instanceof Error ? recreateError.message : String(recreateError)}. Re-run the deploy to create it fresh.`, secrets), { cause: recreateError instanceof Error ? recreateError : void 0 });
|
|
34824
35873
|
}
|
|
34825
35874
|
}
|
|
34826
35875
|
/**
|
|
@@ -34917,15 +35966,16 @@ var DeployEngine = class {
|
|
|
34917
35966
|
break;
|
|
34918
35967
|
}
|
|
34919
35968
|
const propertyDrivenReplacement = change.propertyChanges?.some((pc) => pc.requiresReplacement);
|
|
34920
|
-
const
|
|
34921
|
-
const
|
|
35969
|
+
const recreateTargets = this.options.recreateTargets?.stackName === stackName ? this.options.recreateTargets : void 0;
|
|
35970
|
+
const recreateViaCcApi = recreateTargets?.viaCcApi.has(logicalId) ?? false;
|
|
35971
|
+
const recreateViaSdkProvider = recreateTargets?.viaSdkProvider.has(logicalId) ?? false;
|
|
34922
35972
|
const recreateFlagged = recreateViaCcApi || recreateViaSdkProvider;
|
|
34923
35973
|
const needsReplacement = propertyDrivenReplacement || recreateFlagged;
|
|
34924
35974
|
const dependencies = this.extractAllDependencies(template, logicalId);
|
|
34925
35975
|
const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
|
|
34926
35976
|
if (needsReplacement) {
|
|
34927
35977
|
if (propertyDrivenReplacement && !recreateFlagged && updateReplacePolicy !== "Retain") {
|
|
34928
|
-
const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps);
|
|
35978
|
+
const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
|
|
34929
35979
|
if (statefulReason && this.options.forceStatefulRecreation !== true) {
|
|
34930
35980
|
const immutableProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
|
|
34931
35981
|
throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement (immutable property changed: ${immutableProps}) but it is a stateful resource — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.`, "STATEFUL_REPLACE_BLOCKED"));
|
|
@@ -34951,8 +36001,10 @@ var DeployEngine = class {
|
|
|
34951
36001
|
let createResult;
|
|
34952
36002
|
if (recreateFlagged) {
|
|
34953
36003
|
const recreateFlagName = recreateViaCcApi ? "--recreate-via-cc-api" : "--recreate-via-sdk-provider";
|
|
34954
|
-
if (updateReplacePolicy === "Retain")
|
|
34955
|
-
|
|
36004
|
+
if (updateReplacePolicy === "Retain") {
|
|
36005
|
+
this.retainedOldOnReplacement.add(logicalId);
|
|
36006
|
+
this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — ${recreateFlagName} will leak the old physical resource (${currentResource.physicalId}). The new resource shares the same name where applicable; if the type has user-supplied names (e.g. functionName, bucketName), the create will deterministically collide with the retained orphan.`);
|
|
36007
|
+
} else {
|
|
34956
36008
|
this.logger.info(` Destroying old ${logicalId} (${currentResource.physicalId}) before recreate...`);
|
|
34957
36009
|
const recreateFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
|
|
34958
36010
|
let recreateDeleteResult;
|
|
@@ -35003,8 +36055,10 @@ var DeployEngine = class {
|
|
|
35003
36055
|
deletedOldFirst = true;
|
|
35004
36056
|
createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateSecrets, updateReplacePolicy);
|
|
35005
36057
|
}
|
|
35006
|
-
if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain")
|
|
35007
|
-
|
|
36058
|
+
if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain") {
|
|
36059
|
+
this.retainedOldOnReplacement.add(logicalId);
|
|
36060
|
+
this.logger.info(` Retaining old ${logicalId} (${currentResource.physicalId}) - UpdateReplacePolicy: Retain`);
|
|
36061
|
+
} else {
|
|
35008
36062
|
this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
|
|
35009
36063
|
let cleanupFinalSnapshotId;
|
|
35010
36064
|
let snapshotBlockedDelete = false;
|
|
@@ -35061,6 +36115,7 @@ var DeployEngine = class {
|
|
|
35061
36115
|
const updateProps = updateDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
|
|
35062
36116
|
let result;
|
|
35063
36117
|
let resultProvisionedBy = updateDecision.provisionedBy;
|
|
36118
|
+
let captureProvider = updateProvider;
|
|
35064
36119
|
try {
|
|
35065
36120
|
result = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => updateProvider.update(logicalId, currentResource.physicalId, resourceType, updateProps, currentProps, {
|
|
35066
36121
|
maskSecrets: createSecretMasker(updateSecrets),
|
|
@@ -35071,7 +36126,7 @@ var DeployEngine = class {
|
|
|
35071
36126
|
const replaceOptIn = updateError instanceof ResourceUpdateNotSupportedError && this.options.replace === true;
|
|
35072
36127
|
if (ccUnsupported || replaceOptIn) {
|
|
35073
36128
|
const retainOldOnReplace = updateReplacePolicy === "Retain";
|
|
35074
|
-
const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps);
|
|
36129
|
+
const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
|
|
35075
36130
|
if (statefulReason && this.options.forceStatefulRecreation !== true) throw markNonRetryable(new CdkdError(replaceOptIn ? `--replace would DELETE + CREATE the stateful resource ${logicalId} (${resourceType}) — ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the immutable-property change.` : `${logicalId} (${resourceType}) cannot be updated in place by the provisioning layer it routes through, so applying this change would DELETE + CREATE it — but it is a stateful resource: ${renderStatefulReason(statefulReason)}. Re-run with --force-stateful-recreation to confirm the data loss, or change the resource definition to avoid the update.`, "STATEFUL_REPLACE_BLOCKED", updateError instanceof Error ? updateError : void 0));
|
|
35076
36131
|
this.logger.info(retainOldOnReplace ? `UPDATE not supported for ${logicalId} (${resourceType}), replacing (CREATE only — UpdateReplacePolicy: Retain keeps the old resource)` : `UPDATE not supported for ${logicalId} (${resourceType}), replacing (DELETE → CREATE)`);
|
|
35077
36132
|
if (!retainOldOnReplace) {
|
|
@@ -35102,7 +36157,7 @@ var DeployEngine = class {
|
|
|
35102
36157
|
try {
|
|
35103
36158
|
createResult = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => replProvider.create(logicalId, resourceType, replProps, { maskSecrets: createSecretMasker(updateSecrets) })), logicalId, void 0, void 0, replProvider);
|
|
35104
36159
|
} catch (createError) {
|
|
35105
|
-
if (!retainOldOnReplace) throw createError;
|
|
36160
|
+
if (!retainOldOnReplace) throw new Error(maskSecretsInText(`Failed to create ${logicalId} after the UPDATE-not-supported replacement: the old resource (${currentResource.physicalId}) is now gone. Cause: ${createError instanceof Error ? createError.message : String(createError)}. Re-run the deploy to create it fresh.`, updateSecrets), { cause: createError instanceof Error ? createError : void 0 });
|
|
35106
36161
|
if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
|
|
35107
36162
|
const nameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
|
|
35108
36163
|
throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement because the provisioning layer cannot update it in place — but its physical name is still held by the existing resource AND UpdateReplacePolicy: Retain pins that resource in place. ${nameOrigin.descriptor}. ${nameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed. Removing UpdateReplacePolicy: Retain lets cdkd delete the old resource first, which destroys it and any data it holds.`, "NAMED_REPLACEMENT_COLLISION", createError instanceof Error ? createError : void 0));
|
|
@@ -35112,6 +36167,7 @@ var DeployEngine = class {
|
|
|
35112
36167
|
const idempotentNameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
|
|
35113
36168
|
throw markNonRetryable(new CdkdError(`${logicalId} (${resourceType}) requires replacement, but its Create API is name-idempotent: the create returned the existing resource (${currentResource.physicalId}) instead of creating a new one, and UpdateReplacePolicy: Retain pins that resource in place, so the new properties were not applied. ${idempotentNameOrigin.descriptor}. ${idempotentNameOrigin.remedy} — with Retain, the old resource keeps the name, so a same-name replacement can never proceed.`, "NAMED_REPLACEMENT_IDEMPOTENT_CREATE"));
|
|
35114
36169
|
}
|
|
36170
|
+
this.retainedOldOnReplacement.add(logicalId);
|
|
35115
36171
|
this.logger.warn(` ⚠ ${logicalId} has UpdateReplacePolicy: Retain — the old physical resource (${currentResource.physicalId}) is RETAINED and is no longer tracked by cdkd: it keeps running and incurring cost, and \`cdkd destroy\` will not remove it. Delete it yourself once you no longer need its data.`);
|
|
35116
36172
|
retainedSurvivorReason = `UpdateReplacePolicy: Retain kept the old ${resourceType} (${currentResource.physicalId}), now untracked by cdkd`;
|
|
35117
36173
|
}
|
|
@@ -35129,6 +36185,7 @@ var DeployEngine = class {
|
|
|
35129
36185
|
if (createResult.effectiveProperties) replacementResult.effectiveProperties = createResult.effectiveProperties;
|
|
35130
36186
|
result = replacementResult;
|
|
35131
36187
|
resultProvisionedBy = replDecision.provisionedBy;
|
|
36188
|
+
captureProvider = replProvider;
|
|
35132
36189
|
} else throw updateError;
|
|
35133
36190
|
}
|
|
35134
36191
|
if (result.wasReplaced) this.logger.info(`Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`);
|
|
@@ -35148,7 +36205,7 @@ var DeployEngine = class {
|
|
|
35148
36205
|
provisionedBy: resultProvisionedBy
|
|
35149
36206
|
};
|
|
35150
36207
|
const updateCaptureSiblings = await this.buildObservedCaptureSiblings(resourceType, logicalId, result.physicalId, template, stateResources, stackName, parameterValues, conditions);
|
|
35151
|
-
this.kickOffObservedCapture(
|
|
36208
|
+
this.kickOffObservedCapture(captureProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
|
|
35152
36209
|
const updatePartial = updatePartialReason(result);
|
|
35153
36210
|
if (counts) if (updatePartial !== void 0) counts.updatePartial++;
|
|
35154
36211
|
else counts.updated++;
|
|
@@ -35601,5 +36658,5 @@ var DeployEngine = class {
|
|
|
35601
36658
|
};
|
|
35602
36659
|
|
|
35603
36660
|
//#endregion
|
|
35604
|
-
export {
|
|
35605
|
-
//# sourceMappingURL=deploy-engine
|
|
36661
|
+
export { findSilentDropProperties as $, escapeRegExp$1 as $n, StateError as $r, createSecretMasker as $t, WARM_THROUGHPUT_MEMBERS as A, AssetModeResolver as An, resolveBucketRegion as Ar, readConfigString as At, yellow as B, buildDenyExternalAccessPolicy as Bn, DeployCancelledError as Br, s3BucketRegionalDomainName as Bt, unsupportedFinalSnapshotError as C, AssetPublisher as Cn, expectedOwnerParam as Cr, WAFv2WebACLProvider as Ct, isStatefulRecreateTargetForReplace as D, createAssetRedirectResolver as Dn, AssemblyReader as Dr, coerceCfnBoolean as Dt, MULTI_REGION_RECREATE_BLOCKED_TYPES as E, buildAssetRedirectMap as En, derivePartitionAndUrlSuffix as Er, assertRegionMatch as Et, bold as F, isCrossRegionRedirect as Fn, AssetError as Fr, classifyReplaySecretRegion as Ft, secretBearingStateKeyWarning as G, describeDockerFailure as Gn, LockError as Gr, describeTypeWithThrottleRetry as Gt, collectPublishedOutputNames as H, buildDockerImage as Hn, IntrinsicResolutionRefusalError as Hr, applyRoleArnIfSet as Ht, cyan as I, parseBootstrapMarker as In, CdkdError as Ir, producerRegionsFromState as It, IAMRoleProvider as J, getDockerCmd as Jn, ProvisioningError as Jr, TemplateParser as Jt, stateKeySecretExposure as K, dockerSpawnEnvWithSensitive as Kn, NestedStackChildDirectDestroyError as Kr, withRetry as Kt, gray as L, readBootstrapMarkerBody as Ln, ConfigError as Lr, s3BucketArn as Lt, isWarmThroughputDecrease as M, assertAssetBucketRegion as Mn, getAwsClients as Mr, requireConfigArray as Mt, toFiniteNumber as N, ensureAssetStorage as Nn, resetAwsClients as Nr, requireConfigObject as Nt, isStatefulRecreateTargetSync as O, loadPublishableAssetManifest as On, processStackMessages as Or, configBooleanRefusal as Ot, formatResourceLine as P, getBootstrapMarkerKey as Pn, setAwsClients as Pr, requireConfigString as Pt, findActionableSilentDrops as Q, runDockerStreaming as Qn, StackTerminationProtectionError as Qr, carriesSecretMask as Qt, green as R, validateAssetBucketName as Rn, CrossAccountSecretRefusalError as Rr, s3BucketDomainName as Rt, refusesFinalSnapshot as S, shouldRetainResource as Sn, displaySafe as Sr, refStateLookupFromResource as St, extractDeploymentEventError as T, WorkGraph as Tn, canonicalizeRegion as Tr, resolveExplicitPhysicalId as Tt, exportAliasCollisionScrubWarning as U, describeDockerCapturedOutput as Un, LocalInvokeBuildError as Ur, DiffCalculator as Ut, collectDeclaredOutputNames as V, describeAwsFailure as Vn, DynamicReferenceRegionAmbiguousError as Vr, s3BucketWebsiteUrl as Vt, isExportAliasCollision as W, describeDockerExecFailure as Wn, LocalStartServiceError as Wr, INTRINSIC_KEYS as Wt, clearOnUpdateRemoval as X, redactDockerArgvValues as Xn, ResourceUpdateNotSupportedError as Xr, STATE_SOURCED_READBACK_RULES as Xt, collectInlinePolicyNamesManagedBySiblings as Y, partitionSensitiveEnv as Yn, ResourceTimeoutError as Yr, STATE_SOURCED_CROSS_GENERATION_RULES as Yt, ProviderRegistry as Z, runDockerForeground as Zn, StackHasActiveImportsError as Zr, TEMPLATE_SOURCED_RULES as Zt, PRE_DELETE_SNAPSHOT_TYPES as _, CUSTOM_RESOURCE_RESPONSE_PREFIX as _n, CFN_TEMPLATE_URL_LIMIT as _r, cfnRefValueFromPhysicalId as _t, DeploymentEventsStore as a, isMarkedNonRetryable as ai, recordMaskOnlyValue as an, getDefaultStateBucketName as ar, interruptWatchListenerCount as at, createPreDeleteFinalSnapshot as b, importableOutputKeys as bn, uploadCfnTemplate as br, isUnboundTemplateParameter as bt, replayFailedOperations as c, isTransientServerError as ci, scrubResourceRecord as cn, resolveAutoAssetStorage as cr, CloudControlProvider as ct, updatePartialReason as d, retryClassificationText as di, rebuildClientForBucketRegion as dn, resolveStateBucketWithDefault as dr, deleteIndeterminateGuards as dt, SynthesisError as ei, dynamicReferenceTokens as en, stripControlChars as er, createMaskedRetryLogger as et, withResourceDeadline as f, __exportAll as fi, UNRENDERABLE as fn, resolveStateBucketWithDefaultAndSource as fr, deleteSkipReason as ft, ATOMIC_FINAL_SNAPSHOT_TYPES as g, shellQuote as gn, CFN_TEMPLATE_BODY_LIMIT as gr, carriesDynamicReference as gt, computeImplicitDeleteEdges as h, forceQuitRecoveryClause as hn, warnDeprecatedNoPrefixCliFlag as hr, IntrinsicFunctionResolver as ht, DeploymentEventsReader as i, withErrorHandling as ii, maskSecretsInText as in, synthesisStatusMessage as ir, endCommandInterruptScope as it, coerceWarmThroughput as j, BOOTSTRAP_MARKER_PREFIX as jn, AwsClients as jr, replayWarn as jt, renderStatefulReason as k, rewriteTemplateAssetReferences as kn, clearBucketRegionCache as kr, configStringRefusal as kt, replayRollback as l, markNonRetryable as li, LockManager as ln, resolveCaptureObservedState as lr, slowCcOperationTimeoutMs as lt, IMPLICIT_DELETE_DEPENDENCIES as m, buildLockContentionMessage as mn, stateBucketExistenceConfirmed as mr, isTerminationProtectionPropagationError as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, isCdkdError as ni, isSingleDynamicReferenceToken as nn, getDockerImageBySourceHash as nr, maskerOrIdentity as nt, planFailedOps as o, isRetryableTransientError as oi, recoverMaskedOutput as on, getLegacyStateBucketName as or, isInterruptedWaitError as ot, maskingRetryLogger as p, buildForceUnlockCommand as pn, resolveUseCdkBootstrapAssets as pr, disableInstanceApiTermination as pt, getCurrentResourceSecrets as q, formatDockerLoginError as qn, PartialFailureError as qr, DagBuilder as qt, DeployEngine as r, normalizeAwsError as ri, maskSecretsInError as rn, Synthesizer as rr, beginCommandInterruptScope as rt, planRollback as s, isThrottlingError as si, redactSecretsForState as sn, resolveApp as sr, startInterruptWatch as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, formatError as ti, errorCauseChain as tn, AssetManifestLoader as tr, maskDeep as tt, updatePartialMessage as u, markRedactedCause as ui, S3StateBackend as un, resolveSkipPrefix as ur, UNSPECIFIED_SKIP_REASON as ut, buildFinalSnapshotIdentifier as v, DEFAULT_STATE_PREFIX as vn, MIGRATE_TMP_PREFIX as vr, coerceParameterTypedValue as vt, makeCanonicalizePropertiesFn as w, stringifyValue as wn, PARTITION_TABLE as wr, normalizeAwsTagsToCfn as wt, isFinalSnapshotError as x, importableOutputs as xn, CUSTOM_RESOURCE_RESPONSE_OBJECT_DESCRIPTION as xr, parameterTypeMayLoseSecretIdentity as xt, ccRoutedFinalSnapshotError as y, exportNamesCarriedFrom as yn, findLargeInlineResources as yr, getAccountInfo as yt, red as z, validateContainerRepoName as zn, DependencyError as zr, s3BucketDualStackDomainName as zt };
|
|
36662
|
+
//# sourceMappingURL=deploy-engine-DylsbAdu.js.map
|