@go-to-k/cdkd 0.286.2 → 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-CSEPihOt.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-BoSlW08T.js → deploy-engine-DylsbAdu.js} +2004 -164
- package/dist/deploy-engine-DylsbAdu.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/{program-BuZzDR4s.js → program-dEYAo80R.js} +655 -460
- package/dist/{program-BuZzDR4s.js.map → program-dEYAo80R.js.map} +1 -1
- package/dist/{version-Crg_SdRh.js → version-CKOnsgg1.js} +2 -2
- package/dist/{version-Crg_SdRh.js.map → version-CKOnsgg1.js.map} +1 -1
- package/package.json +2 -1
- package/dist/asg-provider-CSEPihOt.js.map +0 -1
- package/dist/deploy-engine-BoSlW08T.js.map +0 -1
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
6
|
+
import { CreateBucketCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketLocationCommand, GetBucketReplicationCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchKey, PutBucketEncryptionCommand, PutBucketPolicyCommand, PutObjectCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
7
7
|
import { CloudControlClient, CreateResourceCommand, DeleteResourceCommand, GetResourceCommand, GetResourceRequestStatusCommand, ListResourcesCommand, UpdateResourceCommand } from "@aws-sdk/client-cloudcontrol";
|
|
8
8
|
import { AttachRolePolicyCommand, CreateRoleCommand, DeleteRoleCommand, DeleteRolePermissionsBoundaryCommand, DeleteRolePolicyCommand, DetachRolePolicyCommand, GetRoleCommand, GetRolePolicyCommand, IAMClient, ListAttachedRolePoliciesCommand, ListInstanceProfilesForRoleCommand, ListRolePoliciesCommand, ListRoleTagsCommand, NoSuchEntityException, PutRolePermissionsBoundaryCommand, PutRolePolicyCommand, RemoveRoleFromInstanceProfileCommand, TagRoleCommand, UntagRoleCommand, UpdateAssumeRolePolicyCommand, UpdateRoleCommand } from "@aws-sdk/client-iam";
|
|
9
9
|
import { SQSClient } from "@aws-sdk/client-sqs";
|
|
@@ -35,6 +35,7 @@ import { KMSClient, ListAliasesCommand } from "@aws-sdk/client-kms";
|
|
|
35
35
|
import { readFile } from "fs/promises";
|
|
36
36
|
import { join as join$1 } from "path";
|
|
37
37
|
import { CreateRepositoryCommand, DescribeImagesCommand as DescribeImagesCommand$1, DescribeRepositoriesCommand, ECRClient, GetAuthorizationTokenCommand, PutImageTagMutabilityCommand } from "@aws-sdk/client-ecr";
|
|
38
|
+
import { inspect } from "node:util";
|
|
38
39
|
import { hostname } from "os";
|
|
39
40
|
import graphlib from "graphlib";
|
|
40
41
|
import { DescribeDBClustersCommand, DescribeDBInstancesCommand, RDSClient } from "@aws-sdk/client-rds";
|
|
@@ -4027,16 +4028,402 @@ function displaySafe(value, opts) {
|
|
|
4027
4028
|
}
|
|
4028
4029
|
|
|
4029
4030
|
//#endregion
|
|
4030
|
-
//#region src/state/s3-
|
|
4031
|
+
//#region src/state/s3-replication-purge-gap.ts
|
|
4032
|
+
/**
|
|
4033
|
+
* Tell the user when S3 REPLICATION has silently kept the bodies that
|
|
4034
|
+
* {@link ../state/s3-noncurrent-version-purge.js | the noncurrent-version
|
|
4035
|
+
* purge} just removed (issue
|
|
4036
|
+
* [#2447](https://github.com/go-to-k/cdkd/issues/2447)).
|
|
4037
|
+
*
|
|
4038
|
+
* ## The hole this closes
|
|
4039
|
+
*
|
|
4040
|
+
* `purgeNoncurrentKeyVersions` exists so that "cdkd deleted the object" means
|
|
4041
|
+
* the body is no longer retrievable. It reaches that by issuing
|
|
4042
|
+
* `DeleteObjects` entries that name a specific `VersionId`.
|
|
4043
|
+
*
|
|
4044
|
+
* **S3 never replicates a version-id delete.** Replication propagates PUTs
|
|
4045
|
+
* and — only when `DeleteMarkerReplication` is enabled — delete markers; a
|
|
4046
|
+
* delete naming a version id is deliberately not replicated, so that a delete
|
|
4047
|
+
* on the source cannot destroy data on the destination. On a state bucket
|
|
4048
|
+
* with Cross-Region (CRR) or Same-Region (SRR) replication the purge is
|
|
4049
|
+
* therefore SOURCE-ONLY: the replica keeps its own copy of every version,
|
|
4050
|
+
* `GetObject` with a `VersionId` still returns the secret, and cdkd reports
|
|
4051
|
+
* the purge as complete because on the only bucket it knows about, it was.
|
|
4052
|
+
*
|
|
4053
|
+
* That is exactly the shape the purge was built to remove — an operation that
|
|
4054
|
+
* reports success while the value stays readable — reproduced one bucket over
|
|
4055
|
+
* and invisible from every signal cdkd emitted. The user is not merely
|
|
4056
|
+
* unprotected, they are REASSURED, which is the direction this repo treats as
|
|
4057
|
+
* a defect rather than a gap.
|
|
4058
|
+
*
|
|
4059
|
+
* ## Why a probe and not only a doc line
|
|
4060
|
+
*
|
|
4061
|
+
* Documentation reaches the reader who goes looking. This reaches the one who
|
|
4062
|
+
* does not, at the moment the false belief would be formed. The whole cost is
|
|
4063
|
+
* one bucket-metadata call per process (see the cache below) and one IAM
|
|
4064
|
+
* action the caller may well not hold.
|
|
4065
|
+
*
|
|
4066
|
+
* ## Degrading is the DEFAULT, not the failure
|
|
4067
|
+
*
|
|
4068
|
+
* The purge's contract is that a missing permission produces a warning and
|
|
4069
|
+
* never an abort, and a detector bolted onto it must not be able to break
|
|
4070
|
+
* that. So this module NEVER THROWS, and treats every non-answer as "we do
|
|
4071
|
+
* not know" rather than as either polarity:
|
|
4072
|
+
*
|
|
4073
|
+
* - `ReplicationConfigurationNotFoundError` — the answer "no replication
|
|
4074
|
+
* configuration exists". S3 reports the ABSENCE of a configuration as an
|
|
4075
|
+
* ERROR, not as an empty response body, so this arm is the ordinary case
|
|
4076
|
+
* for almost every bucket and must not read as a failure.
|
|
4077
|
+
* `ReplicationConfiguration` ABSENT from a 200 response is treated the same
|
|
4078
|
+
* way: the SDK models the field as optional, and a modelled field is not a
|
|
4079
|
+
* promise that the API populates it.
|
|
4080
|
+
* - `AccessDenied` and friends — the caller lacks
|
|
4081
|
+
* `s3:GetReplicationConfiguration`. Logged at DEBUG and never at `warn`:
|
|
4082
|
+
* the overwhelming majority of buckets are not replicated, so warning here
|
|
4083
|
+
* would tell almost every user to grant a permission in order to be told
|
|
4084
|
+
* nothing. The doc names the action for the user who wants the check.
|
|
4085
|
+
* - anything else (throttle, 5xx, network) — DEBUG, and deliberately NOT
|
|
4086
|
+
* cached, so the next purge in the same process retries.
|
|
4087
|
+
*
|
|
4088
|
+
* ## What is cached, what is deduped, and why they are different keys
|
|
4089
|
+
*
|
|
4090
|
+
* The PROBE is cached per (bucket, asserted owner) for the process lifetime, as
|
|
4091
|
+
* the sibling `src/provisioning/create-only-properties.ts` caches its
|
|
4092
|
+
* `DescribeType` lookups and for the same reason: the answer cannot change
|
|
4093
|
+
* mid-run, the value stored is the in-flight PROMISE so concurrent purges share
|
|
4094
|
+
* one call, and only non-transient outcomes are kept. Without it a stack with
|
|
4095
|
+
* thirty custom resources would issue thirty identical `GetBucketReplication`
|
|
4096
|
+
* calls.
|
|
4097
|
+
*
|
|
4098
|
+
* The WARNING is deduped too, but on a DIFFERENT and deliberately wider key —
|
|
4099
|
+
* (bucket, object description, destinations) — with the repeats sent to
|
|
4100
|
+
* `debug`. The two keys answer two questions and must not be merged:
|
|
4101
|
+
*
|
|
4102
|
+
* - the bucket alone is not enough, because `lock-manager.ts` routes this
|
|
4103
|
+
* module's `warn` sink to `debug` on the ordinary release path (it purges a
|
|
4104
|
+
* heartbeat record on every command and must not warn about it). Keyed on the
|
|
4105
|
+
* bucket, that demoted line would consume the one warning the rollback
|
|
4106
|
+
* journal or the response sidecar needed;
|
|
4107
|
+
* - adding the description is still not enough, because two rules covering
|
|
4108
|
+
* different prefixes can name DIFFERENT destinations. Purging a `cdkd/dev/…`
|
|
4109
|
+
* key can match only the first rule; a later `cdkd/prod/…` purge under the
|
|
4110
|
+
* same description matches both, and without the destinations in the key the
|
|
4111
|
+
* user would clean the first replica and never learn the second one holds the
|
|
4112
|
+
* production journal.
|
|
4113
|
+
*
|
|
4114
|
+
* Deduping at all is a departure from the sibling purge module, which
|
|
4115
|
+
* deliberately repeats its own failure warning. The difference is that a
|
|
4116
|
+
* failure RECURS — each one is another object cdkd could not remove — while
|
|
4117
|
+
* this is a fact about a BUCKET, identical every time it is re-derived.
|
|
4118
|
+
* Repeating it thirty times in a deploy adds nothing and erodes the failure
|
|
4119
|
+
* warning standing next to it.
|
|
4120
|
+
*/
|
|
4031
4121
|
/**
|
|
4032
4122
|
* Parenthetical used when a caller names nothing.
|
|
4033
4123
|
*
|
|
4034
|
-
* True of ANY object
|
|
4035
|
-
*
|
|
4036
|
-
*
|
|
4037
|
-
*
|
|
4124
|
+
* True of ANY object the purge is pointed at, which is the bar for a default
|
|
4125
|
+
* here: a caller that forgets to describe its object must still emit a warning
|
|
4126
|
+
* that is correct, just less specific. It deliberately does not guess at
|
|
4127
|
+
* content.
|
|
4128
|
+
*
|
|
4129
|
+
* Lives HERE rather than in `s3-noncurrent-version-purge.ts` only to keep the
|
|
4130
|
+
* import one-directional — the purge imports this module, so a shared constant
|
|
4131
|
+
* defined there and read here would be a cycle. It is the same sentence both
|
|
4132
|
+
* warnings fall back to, and one definition is what stops the two drifting.
|
|
4133
|
+
*/
|
|
4134
|
+
const DEFAULT_PURGED_OBJECT_DESCRIPTION = "the body of an object cdkd has just reported as removed";
|
|
4135
|
+
/**
|
|
4136
|
+
* Cache of the replication probe, holding the in-flight promise.
|
|
4137
|
+
*
|
|
4138
|
+
* Keyed on the bucket AND the `ExpectedBucketOwner` the caller asserts. The
|
|
4139
|
+
* SUCCESS answer does not depend on the owner assertion — two callers
|
|
4140
|
+
* differing only in whether they assert it read the same configuration — but
|
|
4141
|
+
* the FAILURE does: a mismatched-owner 403 is an answer about that caller's
|
|
4142
|
+
* request, and caching it under the bare bucket name silenced the detector for
|
|
4143
|
+
* every correctly-scoped caller in the process. cdkd has two shapes today (the
|
|
4144
|
+
* provider probes without an owner, the state backend and lock manager with
|
|
4145
|
+
* one), so the two are distinguishable in practice.
|
|
4146
|
+
*/
|
|
4147
|
+
const replicationProbeCache = /* @__PURE__ */ new Map();
|
|
4148
|
+
/**
|
|
4149
|
+
* (bucket, object description, destinations) triples already warned about.
|
|
4150
|
+
*
|
|
4151
|
+
* The one thing the warning says that VARIES is `objectDescription`; the rest
|
|
4152
|
+
* is a fact about the bucket, identical every time. So a stack with thirty
|
|
4153
|
+
* custom resources repeated a ~600-character warning thirty times while adding
|
|
4154
|
+
* no information after the first — and warning fatigue erodes the purge's own
|
|
4155
|
+
* failure warning standing next to it.
|
|
4156
|
+
*
|
|
4157
|
+
* Keyed on all three precisely so this cannot become a silence hole; the
|
|
4158
|
+
* module doc above gives the two failures a narrower key produces. Entries are
|
|
4159
|
+
* added only after the warning is actually emitted, so a throwing sink does
|
|
4160
|
+
* not burn a slot.
|
|
4161
|
+
*
|
|
4162
|
+
* RESIDUAL, bounded and recorded: cdkd cannot see whether the caller's sink
|
|
4163
|
+
* DEMOTED the line, so a `lock-manager.ts` release (which routes `warn` to
|
|
4164
|
+
* `debug`) claims the lock's slot and a later reap of a stale lock — which
|
|
4165
|
+
* would have warned — is suppressed. It is bounded to the LOCK description,
|
|
4166
|
+
* whose object the docs describe as carrying no secret, and to one process.
|
|
4167
|
+
*/
|
|
4168
|
+
const warnedWarnings = /* @__PURE__ */ new Set();
|
|
4169
|
+
/** How many destinations the warning names before it truncates. */
|
|
4170
|
+
const MAX_NAMED_DESTINATIONS = 3;
|
|
4171
|
+
/** Shown when a rule carries no `Destination.Bucket` for us to name. */
|
|
4172
|
+
const UNNAMED_DESTINATION = "<destination not named by the replication rule>";
|
|
4173
|
+
/**
|
|
4174
|
+
* Error codes meaning "this principal will never get an answer here".
|
|
4175
|
+
*
|
|
4176
|
+
* Cached like a real answer, because unlike a throttle they do not clear
|
|
4177
|
+
* within the process: retrying on every purge would spend one denied API call
|
|
4178
|
+
* per custom resource for a result that cannot change.
|
|
4179
|
+
*/
|
|
4180
|
+
const PERMANENT_DENIALS = /* @__PURE__ */ new Set([
|
|
4181
|
+
"AccessDenied",
|
|
4182
|
+
"AccessDeniedException",
|
|
4183
|
+
"AllAccessDisabled",
|
|
4184
|
+
"MethodNotAllowed",
|
|
4185
|
+
"NotImplemented"
|
|
4186
|
+
]);
|
|
4187
|
+
/**
|
|
4188
|
+
* Credential-shaped failures that ARRIVE AS 403 and are nonetheless transient.
|
|
4189
|
+
*
|
|
4190
|
+
* Checked BEFORE the blanket 403 rule, which is the only way they can win:
|
|
4191
|
+
* `InvalidAccessKeyId` is the classic blip while an IAM principal or an
|
|
4192
|
+
* assumed-role credential propagates — this repo retries that whole class over
|
|
4193
|
+
* a 47.75 s schedule elsewhere — and an expired or skewed token clears on
|
|
4194
|
+
* refresh. Cached as permanent, ONE such blip on the first purge of a long
|
|
4195
|
+
* deploy would silence the detector for the rest of the process, which is the
|
|
4196
|
+
* "false silence leaves them believing a secret is gone" direction this module
|
|
4197
|
+
* forbids.
|
|
4198
|
+
*/
|
|
4199
|
+
const TRANSIENT_CREDENTIAL_CODES = /* @__PURE__ */ new Set([
|
|
4200
|
+
"InvalidAccessKeyId",
|
|
4201
|
+
"ExpiredToken",
|
|
4202
|
+
"ExpiredTokenException",
|
|
4203
|
+
"InvalidToken",
|
|
4204
|
+
"TokenRefreshRequired",
|
|
4205
|
+
"RequestTimeTooSkewed",
|
|
4206
|
+
"RequestExpired"
|
|
4207
|
+
]);
|
|
4208
|
+
const describe$2 = (error) => error instanceof Error ? error.message : String(error);
|
|
4209
|
+
/**
|
|
4210
|
+
* Every wire error code the value offers, `name` and `Code` alike.
|
|
4211
|
+
*
|
|
4212
|
+
* NO PRECEDENCE, deliberately. An earlier revision collapsed the two to one
|
|
4213
|
+
* string and preferred a specific `name`, which had two failure modes in
|
|
4214
|
+
* opposite directions: preferring `name` unconditionally made the `Code`
|
|
4215
|
+
* fallback dead (`{ name: 'Error', Code: 'AccessDenied' }` classified as
|
|
4216
|
+
* retryable, so a denied principal re-probed on every purge), while
|
|
4217
|
+
* preferring a SPECIFIC `name` let a wrapper's own name hide
|
|
4218
|
+
* `Code: 'InvalidAccessKeyId'` and cache a credential blip as a permanent
|
|
4219
|
+
* denial. Membership is tested against every code the error carries, so
|
|
4220
|
+
* neither field can mask the other. (Verified against real S3: a missing
|
|
4221
|
+
* replication configuration arrives with that string on BOTH fields.)
|
|
4222
|
+
*
|
|
4223
|
+
* Null-safe because a `send` that rejects with `null` used to throw a
|
|
4224
|
+
* TypeError INSIDE the probe's own catch, leaving a REJECTED promise in the
|
|
4225
|
+
* cache forever: every later purge on that bucket then fell into the outer
|
|
4226
|
+
* `catch {}` with no warning and no debug line — a silently dead detector,
|
|
4227
|
+
* which is the worst possible failure for this module.
|
|
4228
|
+
*
|
|
4229
|
+
* This guard and `probe`'s rejection arm both serve one invariant — **no
|
|
4230
|
+
* rejection ever reaches the cache** — and the honest statement of their
|
|
4231
|
+
* relationship, measured one mutation at a time on 2026-09-05, is:
|
|
4232
|
+
*
|
|
4233
|
+
* - removing the ARM alone REDS a case (a rejection whose own `toString`
|
|
4234
|
+
* throws: the guards pass that value through and `describe()` is what
|
|
4235
|
+
* raises), so the arm is independently fenced;
|
|
4236
|
+
* - removing these GUARDS alone leaves the CACHING behaviour identical:
|
|
4237
|
+
* `errorCodes` runs inside `runProbe`'s own catch, so a TypeError here is
|
|
4238
|
+
* caught by the arm and produces the same `unknown` / `retry: true` /
|
|
4239
|
+
* evicted outcome. What DOES change is the debug line's reason — guarded it
|
|
4240
|
+
* reports the rejection (`: null`), unguarded it reports the classifier's
|
|
4241
|
+
* own crash — and that difference is what the `throw null` case asserts, so
|
|
4242
|
+
* the guards are fenced after all.
|
|
4243
|
+
*
|
|
4244
|
+
* They also earn their place structurally: classification is inside the catch
|
|
4245
|
+
* TODAY. An edit that hoists it out — or that adds a caller of `errorCodes`
|
|
4246
|
+
* outside a try — reinstates the original defect, in which a `null` rejection
|
|
4247
|
+
* crashed the classifier and left a REJECTED promise cached for the process,
|
|
4248
|
+
* so every later purge on that bucket fell into the outer `catch {}` with no
|
|
4249
|
+
* warning and no debug line: a silently dead detector.
|
|
4250
|
+
*/
|
|
4251
|
+
const errorCodes = (error) => {
|
|
4252
|
+
if (error === null || typeof error !== "object" && typeof error !== "function") return [];
|
|
4253
|
+
const e = error;
|
|
4254
|
+
const codes = [];
|
|
4255
|
+
if (typeof e.name === "string" && e.name.length > 0) codes.push(e.name);
|
|
4256
|
+
if (typeof e.Code === "string" && e.Code.length > 0) codes.push(e.Code);
|
|
4257
|
+
return codes;
|
|
4258
|
+
};
|
|
4259
|
+
/** True when ANY code the error carries is in `set`. */
|
|
4260
|
+
const hasCode = (error, set) => errorCodes(error).some((code) => set.has(code));
|
|
4261
|
+
const httpStatus = (error) => {
|
|
4262
|
+
if (error === null || typeof error !== "object" && typeof error !== "function") return void 0;
|
|
4263
|
+
const meta = error.$metadata;
|
|
4264
|
+
return typeof meta?.httpStatusCode === "number" ? meta.httpStatusCode : void 0;
|
|
4265
|
+
};
|
|
4266
|
+
/**
|
|
4267
|
+
* Strip the ARN wrapper off `Destination.Bucket`, which S3 returns as
|
|
4268
|
+
* `arn:<partition>:s3:::<name>`.
|
|
4269
|
+
*
|
|
4270
|
+
* Partition-agnostic on purpose: `aws-cn` and `aws-us-gov` produce a
|
|
4271
|
+
* different second segment, and a hard-coded `arn:aws:s3:::` would leave the
|
|
4272
|
+
* whole ARN in the message there rather than mis-parse it — survivable, but
|
|
4273
|
+
* the regex costs nothing. A value that is not ARN-shaped is passed through
|
|
4274
|
+
* unchanged rather than rejected; the field is only ever displayed.
|
|
4275
|
+
*/
|
|
4276
|
+
function destinationName(bucketArn, account) {
|
|
4277
|
+
if (bucketArn === void 0 || bucketArn === "") return UNNAMED_DESTINATION;
|
|
4278
|
+
const name = bucketArn.replace(/^arn:[^:]*:s3:::/, "");
|
|
4279
|
+
return account !== void 0 && account !== "" ? `${name} (account ${account})` : name;
|
|
4280
|
+
}
|
|
4281
|
+
/**
|
|
4282
|
+
* Reduce a `ReplicationConfiguration` to the ENABLED rules and their effective
|
|
4283
|
+
* prefixes.
|
|
4284
|
+
*
|
|
4285
|
+
* Exported for its own unit tests: the mapping from the four filter shapes S3
|
|
4286
|
+
* accepts onto one prefix string is the part with real cases in it, and
|
|
4287
|
+
* pinning it through a stubbed client would test the plumbing instead.
|
|
4288
|
+
*
|
|
4289
|
+
* Over-approximates DELIBERATELY, in the direction of warning:
|
|
4290
|
+
*
|
|
4291
|
+
* - a rule filtering on a TAG (`Filter.Tag`, or `Filter.And.Tags` with no
|
|
4292
|
+
* `Prefix`) cannot be evaluated without reading each object's tag set, so
|
|
4293
|
+
* its prefix reduces to `''` and it covers everything. A false warning
|
|
4294
|
+
* sends the reader to look at a replica; a false silence leaves them
|
|
4295
|
+
* believing a secret is gone.
|
|
4296
|
+
* - a `Disabled` rule is KEPT, flagged rather than dropped. Disabling a rule
|
|
4297
|
+
* stops FUTURE replication; it does not remove what the rule already
|
|
4298
|
+
* copied, so a bucket whose rule covered `cdkd/` last month and is disabled
|
|
4299
|
+
* today still holds every body ever purged. Dropping it was the first cut
|
|
4300
|
+
* and is the reassured-user failure this module exists to remove, arriving
|
|
4301
|
+
* inside the module that removes it. A rule with no `Status` at all is kept
|
|
4302
|
+
* as ENABLED: `Status` is required by the model, so an absent one means the
|
|
4303
|
+
* response was not what we assumed — the same reason the sibling purge
|
|
4304
|
+
* treats an absent `IsLatest` as "assume the unsafe thing is true".
|
|
4305
|
+
*
|
|
4306
|
+
* Pure: it reads only its argument, so no ambient locale, clock or
|
|
4307
|
+
* environment can change the answer.
|
|
4308
|
+
*/
|
|
4309
|
+
function normalizeReplicationRules(config) {
|
|
4310
|
+
const targets = [];
|
|
4311
|
+
for (const rule of config.Rules ?? []) {
|
|
4312
|
+
const prefix = rule.Filter !== void 0 ? rule.Filter.Prefix ?? rule.Filter.And?.Prefix ?? "" : rule.Prefix ?? "";
|
|
4313
|
+
targets.push({
|
|
4314
|
+
prefix,
|
|
4315
|
+
destination: destinationName(rule.Destination?.Bucket, rule.Destination?.Account),
|
|
4316
|
+
enabled: rule.Status !== "Disabled"
|
|
4317
|
+
});
|
|
4318
|
+
}
|
|
4319
|
+
return targets;
|
|
4320
|
+
}
|
|
4321
|
+
async function runProbe(s3Client, bucket, requestFields) {
|
|
4322
|
+
try {
|
|
4323
|
+
const config = (await s3Client.send(new GetBucketReplicationCommand({
|
|
4324
|
+
Bucket: bucket,
|
|
4325
|
+
...requestFields
|
|
4326
|
+
}))).ReplicationConfiguration;
|
|
4327
|
+
if (config === void 0) return { kind: "none" };
|
|
4328
|
+
const rules = normalizeReplicationRules(config);
|
|
4329
|
+
return rules.length === 0 ? { kind: "none" } : {
|
|
4330
|
+
kind: "rules",
|
|
4331
|
+
rules
|
|
4332
|
+
};
|
|
4333
|
+
} catch (error) {
|
|
4334
|
+
if (errorCodes(error).includes("ReplicationConfigurationNotFoundError")) return { kind: "none" };
|
|
4335
|
+
if (hasCode(error, TRANSIENT_CREDENTIAL_CODES)) return {
|
|
4336
|
+
kind: "unknown",
|
|
4337
|
+
reason: describe$2(error),
|
|
4338
|
+
retry: true
|
|
4339
|
+
};
|
|
4340
|
+
const permanent = hasCode(error, PERMANENT_DENIALS) || httpStatus(error) === 403;
|
|
4341
|
+
return {
|
|
4342
|
+
kind: "unknown",
|
|
4343
|
+
reason: describe$2(error),
|
|
4344
|
+
retry: !permanent
|
|
4345
|
+
};
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
function probe(s3Client, bucket, requestFields) {
|
|
4349
|
+
const key = `${bucket}\u0000${requestFields.ExpectedBucketOwner ?? ""}`;
|
|
4350
|
+
const cached = replicationProbeCache.get(key);
|
|
4351
|
+
if (cached) return cached;
|
|
4352
|
+
const pending = runProbe(s3Client, bucket, requestFields).then((result) => {
|
|
4353
|
+
if (result.kind === "unknown" && result.retry) evictIfOwn(key, pending);
|
|
4354
|
+
return result;
|
|
4355
|
+
}, (error) => {
|
|
4356
|
+
evictIfOwn(key, pending);
|
|
4357
|
+
return {
|
|
4358
|
+
kind: "unknown",
|
|
4359
|
+
reason: describe$2(error),
|
|
4360
|
+
retry: true
|
|
4361
|
+
};
|
|
4362
|
+
});
|
|
4363
|
+
replicationProbeCache.set(key, pending);
|
|
4364
|
+
return pending;
|
|
4365
|
+
}
|
|
4366
|
+
/**
|
|
4367
|
+
* Remove a cache entry only when it is still the one we put there.
|
|
4368
|
+
*
|
|
4369
|
+
* The identity check is UNFENCED by a test, deliberately and stated rather
|
|
4370
|
+
* than left to be discovered: the only way a stale probe can settle against a
|
|
4371
|
+
* newer entry under the same key is for something to have cleared the cache
|
|
4372
|
+
* mid-flight, and the sole clearer is `clearReplicationProbeCache()`, a
|
|
4373
|
+
* test-only helper. Writing a test for it would be writing a test for the test
|
|
4374
|
+
* helper. It is kept because the alternative is an unconditional `delete` that
|
|
4375
|
+
* is wrong the moment a non-test clearer appears.
|
|
4038
4376
|
*/
|
|
4039
|
-
|
|
4377
|
+
function evictIfOwn(key, own) {
|
|
4378
|
+
if (replicationProbeCache.get(key) === own) replicationProbeCache.delete(key);
|
|
4379
|
+
}
|
|
4380
|
+
/**
|
|
4381
|
+
* Warn if replication on `bucket` covers any of `keys`.
|
|
4382
|
+
*
|
|
4383
|
+
* `keys` are the keys whose bodies were actually removed (or could not be
|
|
4384
|
+
* settled) — NOT everything the caller asked about. The purge scopes them; see
|
|
4385
|
+
* its call site for why announcing a survival that never happened is worse
|
|
4386
|
+
* than saying nothing.
|
|
4387
|
+
*
|
|
4388
|
+
* NEVER THROWS and never rejects: the caller runs on a cleanup path that must
|
|
4389
|
+
* not abort, and this is a diagnostic bolted onto it.
|
|
4390
|
+
*/
|
|
4391
|
+
async function warnIfPurgeIsReplicated(s3Client, bucket, keys, options = {}) {
|
|
4392
|
+
if (keys.length === 0) return;
|
|
4393
|
+
const emitDebug = (message) => {
|
|
4394
|
+
const sink = options.logger;
|
|
4395
|
+
if (sink?.debug) sink.debug(message);
|
|
4396
|
+
else getLogger().child("s3-replication-check").debug(message);
|
|
4397
|
+
};
|
|
4398
|
+
try {
|
|
4399
|
+
const safeBucket = displaySafe(bucket, { asciiOnly: true });
|
|
4400
|
+
const result = await probe(s3Client, bucket, options.requestFields ?? {});
|
|
4401
|
+
if (result.kind === "none") return;
|
|
4402
|
+
if (result.kind === "unknown") {
|
|
4403
|
+
emitDebug(`Could not determine whether s3://${safeBucket} is replicated (grant s3:GetReplicationConfiguration to enable the check): ${displaySafe(result.reason)}`);
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4406
|
+
const covered = result.rules.filter((rule) => keys.some((key) => key.startsWith(rule.prefix)));
|
|
4407
|
+
if (covered.length === 0) return;
|
|
4408
|
+
const destinations = [...new Set(covered.map((rule) => rule.enabled ? rule.destination : `${rule.destination} (rule currently Disabled -- it stops FUTURE replication, not what it already copied)`))].sort();
|
|
4409
|
+
const warnKey = [
|
|
4410
|
+
bucket,
|
|
4411
|
+
options.objectDescription ?? "",
|
|
4412
|
+
...destinations
|
|
4413
|
+
].join("\0");
|
|
4414
|
+
if (warnedWarnings.has(warnKey)) {
|
|
4415
|
+
emitDebug(`S3 replication on s3://${safeBucket} still covers purged keys (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}); already reported once this run for ${destinations.map((d) => displaySafe(d, { asciiOnly: true })).join(", ")}.`);
|
|
4416
|
+
return;
|
|
4417
|
+
}
|
|
4418
|
+
const named = destinations.slice(0, MAX_NAMED_DESTINATIONS).map((d) => displaySafe(d, { asciiOnly: true }));
|
|
4419
|
+
const elided = destinations.length - named.length;
|
|
4420
|
+
(options.logger ?? getLogger().child("s3-replication-check")).warn(`S3 replication is enabled on s3://${safeBucket} and covers the key(s) cdkd just purged. S3 NEVER replicates a version-id delete, so the purge removed those versions from THIS bucket only — the copies in the destination bucket survive and remain readable there via GetObject with a VersionId (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}). cdkd cannot delete them. Remove them in the destination bucket yourself (aws s3api list-object-versions, then delete-object --version-id), or narrow the replication rule so it excludes the prefixes cdkd purges under. Destination(s): ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more; aws s3api get-bucket-replication --bucket ${safeBucket} lists them all)` : ""));
|
|
4421
|
+
warnedWarnings.add(warnKey);
|
|
4422
|
+
} catch {}
|
|
4423
|
+
}
|
|
4424
|
+
|
|
4425
|
+
//#endregion
|
|
4426
|
+
//#region src/state/s3-noncurrent-version-purge.ts
|
|
4040
4427
|
/**
|
|
4041
4428
|
* `objectDescription` for the custom-resource response sidecar.
|
|
4042
4429
|
*
|
|
@@ -4110,17 +4497,27 @@ async function purgeNoncurrentKeyVersions(s3Client, bucket, keys, options = {})
|
|
|
4110
4497
|
const prefixes = options.listPrefix !== void 0 ? [options.listPrefix] : keys;
|
|
4111
4498
|
const failed = /* @__PURE__ */ new Map();
|
|
4112
4499
|
const unknown = { n: 0 };
|
|
4500
|
+
const purged = /* @__PURE__ */ new Set();
|
|
4501
|
+
const unsettledBodies = /* @__PURE__ */ new Set();
|
|
4113
4502
|
for (const prefix of prefixes) try {
|
|
4114
|
-
await purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown);
|
|
4503
|
+
await purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown, purged, unsettledBodies);
|
|
4115
4504
|
} catch (error) {
|
|
4116
4505
|
const affected = options.listPrefix !== void 0 ? keys : [prefix];
|
|
4117
|
-
for (const key of affected)
|
|
4506
|
+
for (const key of affected) {
|
|
4507
|
+
recordFailure(failed, key, describe$1(error));
|
|
4508
|
+
unsettledBodies.add(key);
|
|
4509
|
+
}
|
|
4118
4510
|
}
|
|
4119
4511
|
if (failed.size > 0) {
|
|
4120
4512
|
const named = [...failed.entries()].slice(0, MAX_NAMED_FAILURES).map(([key, reasons]) => displaySafe(`${key} (${reasons.join("; ")})`));
|
|
4121
4513
|
const elided = failed.size - named.length;
|
|
4122
|
-
logger.warn(`Could not purge noncurrent versions of ${failed.size} key(s) in s3://${bucket}. Their previous versions survive and remain readable via GetObject with a VersionId (${options.objectDescription ??
|
|
4514
|
+
logger.warn(`Could not purge noncurrent versions of ${failed.size} key(s) in s3://${displaySafe(bucket, { asciiOnly: true })}. Their previous versions survive and remain readable via GetObject with a VersionId (${options.objectDescription ?? "the body of an object cdkd has just reported as removed"}). Grant s3:ListBucketVersions and s3:DeleteObjectVersion on the state bucket, or purge the key(s) by hand. Failures: ${named.join(", ")}` + (elided > 0 ? ` (and ${elided} more)` : ""));
|
|
4123
4515
|
}
|
|
4516
|
+
await warnIfPurgeIsReplicated(s3Client, bucket, [.../* @__PURE__ */ new Set([...purged, ...unsettledBodies])], {
|
|
4517
|
+
requestFields,
|
|
4518
|
+
logger,
|
|
4519
|
+
...options.objectDescription !== void 0 && { objectDescription: options.objectDescription }
|
|
4520
|
+
});
|
|
4124
4521
|
}
|
|
4125
4522
|
/**
|
|
4126
4523
|
* Paginate `ListObjectVersions` under one prefix and delete every returned
|
|
@@ -4135,7 +4532,7 @@ async function purgeNoncurrentKeyVersions(s3Client, bucket, keys, options = {})
|
|
|
4135
4532
|
* id is NOT filtered out on its own, because a bucket whose versioning was
|
|
4136
4533
|
* SUSPENDED can carry a genuine noncurrent `'null'` version holding the body.
|
|
4137
4534
|
*/
|
|
4138
|
-
async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown) {
|
|
4535
|
+
async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields, failed, unknown, purged, unsettledBodies) {
|
|
4139
4536
|
let keyMarker;
|
|
4140
4537
|
let versionIdMarker;
|
|
4141
4538
|
do {
|
|
@@ -4147,15 +4544,30 @@ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields,
|
|
|
4147
4544
|
...versionIdMarker !== void 0 && { VersionIdMarker: versionIdMarker }
|
|
4148
4545
|
}));
|
|
4149
4546
|
const stale = [];
|
|
4150
|
-
|
|
4547
|
+
const entries = [...(resp.Versions ?? []).map((entry) => ({
|
|
4548
|
+
entry,
|
|
4549
|
+
hasBody: true
|
|
4550
|
+
})), ...(resp.DeleteMarkers ?? []).map((entry) => ({
|
|
4551
|
+
entry,
|
|
4552
|
+
hasBody: false
|
|
4553
|
+
}))];
|
|
4554
|
+
for (const { entry, hasBody } of entries) {
|
|
4151
4555
|
if (entry.Key === void 0 || !wanted.has(entry.Key)) continue;
|
|
4152
|
-
if (entry.IsLatest === void 0)
|
|
4556
|
+
if (entry.IsLatest === void 0) {
|
|
4557
|
+
recordFailure(failed, entry.Key, `version ${entry.VersionId ?? "<unknown>"}: listing omitted IsLatest, so the entry was left alone rather than risk deleting a current version`);
|
|
4558
|
+
if (hasBody) unsettledBodies.add(entry.Key);
|
|
4559
|
+
}
|
|
4153
4560
|
if (entry.IsLatest !== false) continue;
|
|
4154
|
-
if (!entry.VersionId)
|
|
4561
|
+
if (!entry.VersionId) {
|
|
4562
|
+
recordFailure(failed, entry.Key, `listing returned a noncurrent entry with no VersionId, so it could not be deleted`);
|
|
4563
|
+
if (hasBody) unsettledBodies.add(entry.Key);
|
|
4564
|
+
continue;
|
|
4565
|
+
}
|
|
4155
4566
|
stale.push({
|
|
4156
4567
|
Key: entry.Key,
|
|
4157
4568
|
VersionId: entry.VersionId
|
|
4158
4569
|
});
|
|
4570
|
+
if (hasBody) purged.add(entry.Key);
|
|
4159
4571
|
}
|
|
4160
4572
|
for (let i = 0; i < stale.length; i += DELETE_BATCH_SIZE) {
|
|
4161
4573
|
const batch = stale.slice(i, i + DELETE_BATCH_SIZE);
|
|
@@ -4182,7 +4594,10 @@ async function purgeUnderPrefix(s3Client, bucket, prefix, wanted, requestFields,
|
|
|
4182
4594
|
}
|
|
4183
4595
|
}
|
|
4184
4596
|
if (resp.IsTruncated === true && resp.NextKeyMarker === void 0) {
|
|
4185
|
-
for (const key of wanted) if (key.startsWith(prefix))
|
|
4597
|
+
for (const key of wanted) if (key.startsWith(prefix)) {
|
|
4598
|
+
recordFailure(failed, key, TRUNCATED_NO_MARKER);
|
|
4599
|
+
unsettledBodies.add(key);
|
|
4600
|
+
}
|
|
4186
4601
|
}
|
|
4187
4602
|
keyMarker = resp.IsTruncated === true ? resp.NextKeyMarker : void 0;
|
|
4188
4603
|
versionIdMarker = keyMarker !== void 0 ? resp.NextVersionIdMarker : void 0;
|
|
@@ -5670,6 +6085,49 @@ var FileAssetPublisher = class {
|
|
|
5670
6085
|
}
|
|
5671
6086
|
};
|
|
5672
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
|
+
|
|
5673
6131
|
//#endregion
|
|
5674
6132
|
//#region src/utils/docker-cmd.ts
|
|
5675
6133
|
/**
|
|
@@ -6073,6 +6531,717 @@ function mergeEnv(overrides) {
|
|
|
6073
6531
|
else merged[k] = v;
|
|
6074
6532
|
return merged;
|
|
6075
6533
|
}
|
|
6534
|
+
/** Replaces a redacted argv VALUE wherever this module masks one. */
|
|
6535
|
+
const REDACTED_ARGV_VALUE = "***";
|
|
6536
|
+
/**
|
|
6537
|
+
* `docker` argv flags whose NEXT token is a `KEY=VALUE` pair built from
|
|
6538
|
+
* user-supplied template data, and whose VALUE therefore must not reach a
|
|
6539
|
+
* user-visible error string:
|
|
6540
|
+
*
|
|
6541
|
+
* - `-e` / `--env` — a container's `Environment.Variables` (Lambda) or
|
|
6542
|
+
* `ContainerDefinition.Environment` (ECS), plus `--env-vars` overrides.
|
|
6543
|
+
* Values that cdkd classifies as sensitive never get here at all
|
|
6544
|
+
* ({@link partitionSensitiveEnv} emits those as a value-less `-e KEY`),
|
|
6545
|
+
* but everything else does: connection strings, endpoints, API base URLs.
|
|
6546
|
+
* - `--opt` — `DockerVolumeConfiguration.DriverOpts`, which for the `local`
|
|
6547
|
+
* driver carries mount options (`o=addr=…,username=…,password=…`).
|
|
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.
|
|
6564
|
+
*
|
|
6565
|
+
* A flag NOT in this list keeps its value. For most of the argv that is
|
|
6566
|
+
* because the value is cdkd-authored or infrastructure-shaped — a container
|
|
6567
|
+
* id, an image ref, a `--subnet` CIDR, a `--format` template — and is the
|
|
6568
|
+
* diagnostic.
|
|
6569
|
+
*
|
|
6570
|
+
* That is NOT true of the whole remainder, and the residual is recorded here
|
|
6571
|
+
* rather than quietly implied: `--health-cmd`, `--ulimit`, `--link`,
|
|
6572
|
+
* `--entrypoint`, `--workdir` and the positional image + container command are
|
|
6573
|
+
* all template-supplied and still echoed. They stay unmasked deliberately —
|
|
6574
|
+
* each is a COMMAND or a structural knob whose text IS what an operator reads
|
|
6575
|
+
* the failure for, and none is a documented place to put a secret, unlike
|
|
6576
|
+
* `Environment` / `Secrets` / `DriverOpts`. Revisit per flag if a real leak is
|
|
6577
|
+
* found through one; do not widen the set on suspicion, since every addition
|
|
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}.
|
|
6601
|
+
*/
|
|
6602
|
+
const ARGV_VALUE_BEARING_FLAGS = /* @__PURE__ */ new Set([
|
|
6603
|
+
"-e",
|
|
6604
|
+
"--env",
|
|
6605
|
+
"--opt",
|
|
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"
|
|
6714
|
+
]);
|
|
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
|
+
/**
|
|
6854
|
+
* Structural, whitespace-delimited form of {@link ARGV_VALUE_BEARING_FLAGS}
|
|
6855
|
+
* for scanning a STRING that embeds a space-joined argv. Keyed on the FLAG's
|
|
6856
|
+
* position — never on the secret's value, so an unrelated literal that merely
|
|
6857
|
+
* coincides with a value is left alone.
|
|
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
|
+
*
|
|
6885
|
+
* What each piece actually buys, since one of them was mis-credited in review:
|
|
6886
|
+
* the `(^|\s)` lead is what stops `-e` matching inside `--env` (there is no
|
|
6887
|
+
* `m` flag, so `^` is start-of-INPUT), NOT the alternation order — reordering
|
|
6888
|
+
* the branches leaves every test green. The order is kept as belt and braces
|
|
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
|
+
})();
|
|
6907
|
+
/**
|
|
6908
|
+
* Return a copy of `args` with the VALUE of every
|
|
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)).
|
|
6921
|
+
* A value-less `-e KEY` (the form {@link partitionSensitiveEnv} emits for a
|
|
6922
|
+
* sensitive key) has nothing to mask and is returned unchanged. `args` is
|
|
6923
|
+
* never mutated: the redacted copy is for DISPLAY only, never for `spawn`.
|
|
6924
|
+
*/
|
|
6925
|
+
function redactDockerArgvValues(args) {
|
|
6926
|
+
const out = [];
|
|
6927
|
+
for (let i = 0; i < args.length; i++) {
|
|
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
|
+
}
|
|
6942
|
+
const next = args[i + 1];
|
|
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);
|
|
6947
|
+
i++;
|
|
6948
|
+
continue;
|
|
6949
|
+
}
|
|
6950
|
+
}
|
|
6951
|
+
out.push(cur);
|
|
6952
|
+
}
|
|
6953
|
+
return out;
|
|
6954
|
+
}
|
|
6955
|
+
/**
|
|
6956
|
+
* Redact argv-borne values inside an error TEXT before it reaches the user.
|
|
6957
|
+
*
|
|
6958
|
+
* **Wrap EVERY `execFile`-derived docker failure text in this**, including one
|
|
6959
|
+
* whose argv carries no user data today — `execFile` puts the WHOLE command
|
|
6960
|
+
* line into `err.message` (`Command failed: <file> <args joined by ' '>\n<stderr>`,
|
|
6961
|
+
* measured on Node 24), so any argv that later gains a `-e` pair starts leaking
|
|
6962
|
+
* with no edit to the error site. That silent-gap shape is exactly what
|
|
6963
|
+
* [#2440](https://github.com/go-to-k/cdkd/issues/2440) reported: the debug log
|
|
6964
|
+
* one screen earlier redacted the same argv and the error path did not.
|
|
6965
|
+
*
|
|
6966
|
+
* Two passes, both structural (positional), never value-based — matching on a
|
|
6967
|
+
* secret's VALUE would also blank an unrelated string that happens to equal it:
|
|
6968
|
+
*
|
|
6969
|
+
* 1. **Exact command-line substitution** (only when `args` is given). The raw
|
|
6970
|
+
* `args.join(' ')` is replaced by {@link redactDockerArgvValues}' rendering
|
|
6971
|
+
* of the same array. This is the pass that matters, because it is the only
|
|
6972
|
+
* one that can mask a value CONTAINING WHITESPACE (`-e CFG={"a": 1}`), which
|
|
6973
|
+
* no token scan of a space-joined string can delimit.
|
|
6974
|
+
* 2. **Token scan** ({@link ARGV_VALUE_TOKEN_RE}) over the result. Catches an
|
|
6975
|
+
* argv echoed in a shape pass 1 cannot see — a future Node message format,
|
|
6976
|
+
* a docker stderr quoting the flag back, or a call site with no `args` to
|
|
6977
|
+
* hand. Deliberately fail-loud rather than fail-open: a `-e X=y` occurring
|
|
6978
|
+
* in unrelated stderr prose loses its value and keeps its key.
|
|
6979
|
+
*
|
|
6980
|
+
* Idempotent (`-e KEY=***` re-masks to itself), so double-wrapping is safe.
|
|
6981
|
+
*
|
|
6982
|
+
* **Call sites should use a composer** ({@link describeDockerFailure} and its
|
|
6983
|
+
* siblings) rather than this function: the composers take a REQUIRED `args`,
|
|
6984
|
+
* which is what makes the redaction impossible to forget. This is the
|
|
6985
|
+
* primitive they are built from, exported so the four passes can be tested
|
|
6986
|
+
* against Node's real message shapes directly — it has no other caller in
|
|
6987
|
+
* `src/`, and that is deliberate rather than an oversight.
|
|
6988
|
+
*/
|
|
6989
|
+
function redactDockerArgvInText(text, args) {
|
|
6990
|
+
let out = text;
|
|
6991
|
+
if (args && args.length > 0) {
|
|
6992
|
+
const maskedArgs = redactDockerArgvValues(args);
|
|
6993
|
+
const raw = args.join(" ");
|
|
6994
|
+
const masked = maskedArgs.join(" ");
|
|
6995
|
+
if (masked !== raw) out = out.split(raw).join(masked);
|
|
6996
|
+
for (let i = 0; i < args.length; i++) {
|
|
6997
|
+
const rawArg = args[i];
|
|
6998
|
+
const maskedArg = maskedArgs[i];
|
|
6999
|
+
if (maskedArg === rawArg) continue;
|
|
7000
|
+
if (!isSubstitutableToken(rawArg)) continue;
|
|
7001
|
+
out = out.split(rawArg).join(maskedArg);
|
|
7002
|
+
const escapedRaw = nodeQuotedRendering(rawArg);
|
|
7003
|
+
const escapedMasked = nodeQuotedRendering(maskedArg);
|
|
7004
|
+
if (escapedRaw !== void 0 && escapedMasked !== void 0 && escapedRaw !== rawArg) out = out.split(escapedRaw).join(escapedMasked);
|
|
7005
|
+
}
|
|
7006
|
+
out = repairSpawnRefusal(out, args, maskedArgs);
|
|
7007
|
+
}
|
|
7008
|
+
return out.replace(ARGV_VALUE_TOKEN_RE, (_match, lead, flag, gap, key) => `${lead}${flag}${gap}${key}=${REDACTED_ARGV_VALUE}`);
|
|
7009
|
+
}
|
|
7010
|
+
/**
|
|
7011
|
+
* Shortest VALUE that pass 1b will substitute as a bare token.
|
|
7012
|
+
*
|
|
7013
|
+
* Pass 1b's needle is the whole `KEY=VALUE` element, and it is replaced
|
|
7014
|
+
* EVERYWHERE in the text — so a tiny needle is a liability rather than a
|
|
7015
|
+
* protection. The empty-key case makes that concrete: a non-sensitive
|
|
7016
|
+
* `{ Name: '', Value: '1' }` produces the two-character token `=1`, and
|
|
7017
|
+
* substituting that rewrites every `=1` in the message (`--cpus=1`,
|
|
7018
|
+
* `status=1`, a path segment). Below this floor the value is not worth
|
|
7019
|
+
* protecting by substring match, and passes 1 and 3 still cover it in the
|
|
7020
|
+
* message shapes that carry a `-e ` prefix or the joined command line.
|
|
7021
|
+
*/
|
|
7022
|
+
const MIN_SUBSTITUTABLE_VALUE_LENGTH = 4;
|
|
7023
|
+
/** Is this `KEY=VALUE` element long enough to substitute as a bare token? */
|
|
7024
|
+
function isSubstitutableToken(arg) {
|
|
7025
|
+
const eqIdx = arg.indexOf("=");
|
|
7026
|
+
if (eqIdx < 0) return false;
|
|
7027
|
+
return arg.length - eqIdx - 1 >= MIN_SUBSTITUTABLE_VALUE_LENGTH;
|
|
7028
|
+
}
|
|
7029
|
+
/**
|
|
7030
|
+
* Render `value` the way Node renders an argv element it quotes back at you —
|
|
7031
|
+
* i.e. with the SAME function Node used, `util.inspect`, minus the quote
|
|
7032
|
+
* characters it chose.
|
|
7033
|
+
*
|
|
7034
|
+
* This started as a hand-rolled `\xNN` escaper and it was WRONG for 16 of the
|
|
7035
|
+
* first 128 code points. Measured against real `execFile` rejections on Node
|
|
7036
|
+
* 24.19.0 (this repo, 2026-09-05): hand-rolled matched 4 of 13 cases,
|
|
7037
|
+
* `inspect` matched 13 of 13.
|
|
7038
|
+
*
|
|
7039
|
+
* input Node emits hand-rolled built
|
|
7040
|
+
* LF `K=a\nb\x00S` `K=a\x0ab\x00S` (Node uses a short escape)
|
|
7041
|
+
* DEL `K=a\x7Fb\x00S` `K=a\x7fb\x00S` (Node uses UPPERCASE hex)
|
|
7042
|
+
* backslash `K=a\\b\x00S` `K=a\b\x00S` (not escaped at all)
|
|
7043
|
+
*
|
|
7044
|
+
* Every miss printed the whole secret. The trigger for this path is a NUL in
|
|
7045
|
+
* a value — binary-ish data, which almost always carries a second control
|
|
7046
|
+
* byte or a backslash — so the table missed the REALISTIC case and hit only
|
|
7047
|
+
* the synthetic NUL-alone one its own fixture happened to use.
|
|
7048
|
+
*
|
|
7049
|
+
* `slice(1, -1)` is safe across the quote Node picks: `inspect` switches
|
|
7050
|
+
* between `'`, `"` and a backtick depending on content and escapes whichever
|
|
7051
|
+
* it chose; because this is the same call Node made, the rendering matches
|
|
7052
|
+
* whatever it picked (verified for a value containing a single quote, a
|
|
7053
|
+
* double quote, both, and a backtick).
|
|
7054
|
+
*
|
|
7055
|
+
* The general lesson, and why this is no longer a table: do not re-implement
|
|
7056
|
+
* another program's formatter — call it.
|
|
7057
|
+
*/
|
|
7058
|
+
function nodeQuotedRendering(value) {
|
|
7059
|
+
const rendered = inspect(value);
|
|
7060
|
+
const quote = rendered[0];
|
|
7061
|
+
if (quote === void 0 || !`'"\``.includes(quote)) return void 0;
|
|
7062
|
+
if (rendered.length < 2 || !rendered.endsWith(quote)) return void 0;
|
|
7063
|
+
const inner = rendered.slice(1, -1);
|
|
7064
|
+
return inner.includes(`${quote} +`) ? void 0 : inner;
|
|
7065
|
+
}
|
|
7066
|
+
/**
|
|
7067
|
+
* Node's refusal to spawn, which QUOTES ONE argv element and names its INDEX:
|
|
7068
|
+
*
|
|
7069
|
+
* The argument 'args[2]' must be a string without null bytes. Received '…'
|
|
7070
|
+
*
|
|
7071
|
+
* The one message shape where the index tells us exactly which of OUR args is
|
|
7072
|
+
* being echoed — so the clause can be REWRITTEN from cdkd's own copy of the
|
|
7073
|
+
* element rather than searched for. That matters because Node does not print
|
|
7074
|
+
* the element whole: it truncates near 200 characters and renders a
|
|
7075
|
+
* newline-bearing value as concatenated chunks, so no needle can match it.
|
|
7076
|
+
*
|
|
7077
|
+
* Global, and scanned to exhaustion rather than bailing on the first match.
|
|
7078
|
+
* Anchoring at `^` was an earlier attempt at forgery resistance and it was
|
|
7079
|
+
* WORSE: it made a refusal anywhere but position 0 unrepairable, which is a
|
|
7080
|
+
* LEAK, and a leak beats a truncated message every time. Pinning Node's
|
|
7081
|
+
* literal prefix keeps the forgery bar high without that trade.
|
|
7082
|
+
*
|
|
7083
|
+
* Pinning the wording has its own cost: if Node rewords the message the repair
|
|
7084
|
+
* silently stops firing. That is why `tests/unit/utils/docker-cmd.test.ts`
|
|
7085
|
+
* drives this path from a REAL rejection — a reword turns CI red instead of
|
|
7086
|
+
* turning the redaction off.
|
|
7087
|
+
*/
|
|
7088
|
+
const SPAWN_REFUSAL_RE = /The argument 'args\[(\d+)\]'[^']*?Received /g;
|
|
7089
|
+
/**
|
|
7090
|
+
* The quoted element Node prints after `Received `.
|
|
7091
|
+
*
|
|
7092
|
+
* Three shapes, and the third is the one that matters. `inspect` renders a
|
|
7093
|
+
* newline-bearing value as chunks joined by ` +`, and Node then SLICES the
|
|
7094
|
+
* whole rendering at 128 characters and appends `...` — so the last chunk is
|
|
7095
|
+
* usually UNTERMINATED:
|
|
7096
|
+
*
|
|
7097
|
+
* Received 'DB_URL=AAA…\n' +
|
|
7098
|
+
* 'hunter2SECRETCCC...
|
|
7099
|
+
*
|
|
7100
|
+
* A pattern that only accepts complete chunks stops after the second `'` and
|
|
7101
|
+
* leaves that tail in place. Measured on a real rejection: 272 of 288 probe
|
|
7102
|
+
* shapes leaked the secret verbatim, and the end-of-string version this
|
|
7103
|
+
* replaced did not — the bound was a regression, not a hardening, until the
|
|
7104
|
+
* trailing open chunk was admitted.
|
|
7105
|
+
*
|
|
7106
|
+
* The separator is Node's own (` +\n `) with HORIZONTAL space only, and an
|
|
7107
|
+
* open chunk runs to end of LINE. Both are deliberate: `\s*\+\s*` and an
|
|
7108
|
+
* unbounded tail let a crafted value swallow the diagnostic lines that follow
|
|
7109
|
+
* the clause.
|
|
7110
|
+
*/
|
|
7111
|
+
const QUOTED_CHUNK = String.raw`'(?:[^'\\\n]|\\.)*'|"(?:[^"\\\n]|\\.)*"|\`(?:[^\`\\\n]|\\.)*\``;
|
|
7112
|
+
/** An opening quote whose closing one was truncated away; bounded to its line. */
|
|
7113
|
+
const OPEN_CHUNK = String.raw`['"\`](?:[^\\\n]|\\.)*`;
|
|
7114
|
+
/** Node's chunk join: horizontal space, `+`, at most one newline, horizontal space. */
|
|
7115
|
+
const CHUNK_SEPARATOR = String.raw`[^\S\n]*\+[^\S\n]*\n?[^\S\n]*`;
|
|
7116
|
+
const QUOTED_ELEMENT_RE = new RegExp(`^(?:(?:${QUOTED_CHUNK})(?:${CHUNK_SEPARATOR}(?:${QUOTED_CHUNK}))*(?:${CHUNK_SEPARATOR}(?:${OPEN_CHUNK}))?|(?:${OPEN_CHUNK}))`);
|
|
7117
|
+
/**
|
|
7118
|
+
* Replace the quoted argv element in a spawn-refusal message with the masked
|
|
7119
|
+
* rendering of the arg its OWN index names.
|
|
7120
|
+
*
|
|
7121
|
+
* Known cosmetic effect: the replacement prints the FULL key where Node had
|
|
7122
|
+
* truncated, so a pathologically long env-var NAME makes the message longer
|
|
7123
|
+
* than Node's ~200 characters. Keys are not secret, and the width is not worth
|
|
7124
|
+
* a second truncation rule with its own edge cases.
|
|
7125
|
+
*/
|
|
7126
|
+
function repairSpawnRefusal(text, args, maskedArgs) {
|
|
7127
|
+
const scanner = new RegExp(SPAWN_REFUSAL_RE.source, "g");
|
|
7128
|
+
let out = "";
|
|
7129
|
+
let cursor = 0;
|
|
7130
|
+
for (let match = scanner.exec(text); match !== null; match = scanner.exec(text)) {
|
|
7131
|
+
if (match.index < cursor) continue;
|
|
7132
|
+
const index = Number(match[1]);
|
|
7133
|
+
const rawArg = args[index];
|
|
7134
|
+
const maskedArg = maskedArgs[index];
|
|
7135
|
+
if (rawArg !== void 0 && maskedArg === rawArg) continue;
|
|
7136
|
+
const clauseEnd = match.index + match[0].length;
|
|
7137
|
+
const quoted = QUOTED_ELEMENT_RE.exec(text.slice(clauseEnd));
|
|
7138
|
+
if (quoted === null) continue;
|
|
7139
|
+
const replacement = maskedArg === void 0 ? `'${REDACTED_ARGV_VALUE}'` : inspect(maskedArg);
|
|
7140
|
+
out += text.slice(cursor, clauseEnd) + replacement;
|
|
7141
|
+
cursor = clauseEnd + quoted[0].length;
|
|
7142
|
+
scanner.lastIndex = cursor;
|
|
7143
|
+
}
|
|
7144
|
+
return out + text.slice(cursor);
|
|
7145
|
+
}
|
|
7146
|
+
/**
|
|
7147
|
+
* Compose a user-visible description of a `child_process` rejection from a
|
|
7148
|
+
* docker call, ALREADY REDACTED against that call's own argv.
|
|
7149
|
+
*
|
|
7150
|
+
* Moved here from `src/local/invoke-agentcore-watch-loop.ts` in issue #2440's
|
|
7151
|
+
* review round 3, and the move is the point: `args` is REQUIRED, so a call
|
|
7152
|
+
* site cannot obtain the text without handing over the argv to redact it
|
|
7153
|
+
* with. That is a stronger guarantee than any text fence over the call sites
|
|
7154
|
+
* — which is what the round-2 reviewers demonstrated, by writing four
|
|
7155
|
+
* spellings of an unredacted read that the fence could not see.
|
|
7156
|
+
*
|
|
7157
|
+
* Shape differs from the `stderr || message` composition the other sites use,
|
|
7158
|
+
* and deliberately so: `err.stderr` is where docker writes its actionable
|
|
7159
|
+
* diagnostics, while `err.message` carries the exit status, so the AgentCore
|
|
7160
|
+
* soft-reload path APPENDS rather than prefers — without stderr the wrapped
|
|
7161
|
+
* error would only say "Command failed with exit code N".
|
|
7162
|
+
*/
|
|
7163
|
+
function describeDockerExecFailure(error, args) {
|
|
7164
|
+
const message = thrownMessageText(error) || safeStringify(error);
|
|
7165
|
+
const stderrText = capturedStreamText(error, "stderr");
|
|
7166
|
+
return redactDockerArgvInText(stderrText ? `${message}\n${stderrText}` : message, args);
|
|
7167
|
+
}
|
|
7168
|
+
/**
|
|
7169
|
+
* A captured stream of a `child_process` rejection as trimmed text, or `''`.
|
|
7170
|
+
*
|
|
7171
|
+
* Takes `unknown`, not `Error`, and duck-types the field. Every composer here
|
|
7172
|
+
* is called from a `catch`, where the value is whatever was thrown — and the
|
|
7173
|
+
* shapes the call sites actually see are plain objects
|
|
7174
|
+
* (`{ stderr, message }`), `SpawnError`, and cross-realm `Error`s that
|
|
7175
|
+
* `instanceof` misses. An earlier revision narrowed this to `Error` and the
|
|
7176
|
+
* standard composer then wrapped non-Errors in a FRESH `Error`, which has no
|
|
7177
|
+
* `.stderr` at all — so the whole diagnostic silently became
|
|
7178
|
+
* `'[object Object]'` for exactly the shape most of the call sites throw.
|
|
7179
|
+
*
|
|
7180
|
+
* `execFile` hands back a string under the default encoding and a `Buffer`
|
|
7181
|
+
* under `encoding: 'buffer'`; `ArrayBuffer.isView` covers a plain
|
|
7182
|
+
* `Uint8Array` too.
|
|
7183
|
+
*/
|
|
7184
|
+
function capturedStreamText(error, field) {
|
|
7185
|
+
try {
|
|
7186
|
+
const raw = error?.[field];
|
|
7187
|
+
if (typeof raw === "string") return raw.trim();
|
|
7188
|
+
if (ArrayBuffer.isView(raw)) return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString("utf8").trim();
|
|
7189
|
+
} catch {}
|
|
7190
|
+
return "";
|
|
7191
|
+
}
|
|
7192
|
+
/** A thrown value's `message` as text, or `''`. Duck-typed, for the same reason. */
|
|
7193
|
+
function thrownMessageText(error) {
|
|
7194
|
+
try {
|
|
7195
|
+
const raw = error?.["message"];
|
|
7196
|
+
return typeof raw === "string" ? raw : "";
|
|
7197
|
+
} catch {
|
|
7198
|
+
return "";
|
|
7199
|
+
}
|
|
7200
|
+
}
|
|
7201
|
+
/**
|
|
7202
|
+
* `String(value)` that cannot itself throw. A rejection with a null prototype
|
|
7203
|
+
* (or a `toString` that throws) would otherwise blow up INSIDE a `catch` — and
|
|
7204
|
+
* two of these call sites are in `cleanupEcsRun`, where an exception aborts
|
|
7205
|
+
* the remaining volume / network teardown and leaks real Docker resources.
|
|
7206
|
+
*/
|
|
7207
|
+
function safeStringify(error) {
|
|
7208
|
+
try {
|
|
7209
|
+
return String(error);
|
|
7210
|
+
} catch {
|
|
7211
|
+
return "[unstringifiable rejection]";
|
|
7212
|
+
}
|
|
7213
|
+
}
|
|
7214
|
+
/**
|
|
7215
|
+
* The STANDARD composer for a docker failure text: the captured stderr if
|
|
7216
|
+
* there is any, else the error's own message, else its string form —
|
|
7217
|
+
* ALREADY REDACTED against that call's argv.
|
|
7218
|
+
*
|
|
7219
|
+
* Use this at every site that wraps a docker `execFile` / spawn rejection.
|
|
7220
|
+
* `args` is REQUIRED, which is the whole point: a call site cannot obtain the
|
|
7221
|
+
* text without handing over the argv to redact it with, so the guarantee is
|
|
7222
|
+
* type-checked rather than fenced. Issue #2440's review spent three rounds
|
|
7223
|
+
* showing that a text fence over hand-composed sites is evadable — an
|
|
7224
|
+
* intermediate variable, an inline cast, a destructure, a computed member, a
|
|
7225
|
+
* concat — while this shape has nothing to evade.
|
|
7226
|
+
*
|
|
7227
|
+
* Prefer stderr over message because docker writes its actionable diagnostic
|
|
7228
|
+
* there, and `execFile`'s message is mostly the command line plus an exit
|
|
7229
|
+
* status. {@link describeDockerExecFailure} keeps BOTH, for a caller whose
|
|
7230
|
+
* wrapper text needs the status as well.
|
|
7231
|
+
*/
|
|
7232
|
+
function describeDockerFailure(error, args) {
|
|
7233
|
+
return redactDockerArgvInText(capturedStreamText(error, "stderr") || thrownMessageText(error) || safeStringify(error), args);
|
|
7234
|
+
}
|
|
7235
|
+
/**
|
|
7236
|
+
* Composer for a captured-output failure where the diagnostic may be on
|
|
7237
|
+
* STDOUT rather than stderr (`runDockerStreaming`'s non-zero-exit path, whose
|
|
7238
|
+
* `SpawnError` carries both). `fallback` is used when neither stream said
|
|
7239
|
+
* anything. Redacted, and `args` is required, for the same reason as
|
|
7240
|
+
* {@link describeDockerFailure}.
|
|
7241
|
+
*/
|
|
7242
|
+
function describeDockerCapturedOutput(error, args, fallback) {
|
|
7243
|
+
return redactDockerArgvInText(capturedStreamText(error, "stderr") || capturedStreamText(error, "stdout") || fallback, args);
|
|
7244
|
+
}
|
|
6076
7245
|
|
|
6077
7246
|
//#endregion
|
|
6078
7247
|
//#region src/assets/docker-build.ts
|
|
@@ -6098,32 +7267,39 @@ async function buildDockerImage(asset, cdkOutDir, options) {
|
|
|
6098
7267
|
const [cmd, ...args] = source.executable;
|
|
6099
7268
|
if (!cmd) throw options.wrapError("asset source.executable[] is empty");
|
|
6100
7269
|
const cwd = source.directory ? `${cdkOutDir}/${source.directory}` : cdkOutDir;
|
|
6101
|
-
|
|
7270
|
+
const shownExecutable = redactDockerArgvValues(source.executable).join(" ");
|
|
7271
|
+
logger.debug(`Building Docker image via executable: ${shownExecutable} (cwd=${cwd})`);
|
|
6102
7272
|
let result;
|
|
6103
7273
|
try {
|
|
6104
7274
|
result = await spawnStreaming(cmd, args, { cwd });
|
|
6105
7275
|
} catch (err) {
|
|
6106
|
-
|
|
6107
|
-
throw options.wrapError(e.stderr || e.message || String(err));
|
|
7276
|
+
throw options.wrapError(describeDockerFailure(err, args));
|
|
6108
7277
|
}
|
|
6109
7278
|
const tag = result.stdout.trim();
|
|
6110
|
-
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}`);
|
|
6111
7280
|
return tag;
|
|
6112
7281
|
}
|
|
6113
|
-
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
|
+
}
|
|
6114
7291
|
if (!options.tag) throw options.wrapError("buildDockerImage(directory mode) requires options.tag");
|
|
6115
7292
|
const buildArgs = buildDockerBuildCommand(source, options.tag, options.platform);
|
|
6116
7293
|
const contextDir = `${cdkOutDir}/${source.directory}`;
|
|
6117
7294
|
buildArgs.push(".");
|
|
6118
|
-
logger.debug(`${getDockerCmd()} ${buildArgs.join(" ")} (cwd=${contextDir})`);
|
|
7295
|
+
logger.debug(`${getDockerCmd()} ${redactDockerArgvValues(buildArgs).join(" ")} (cwd=${contextDir})`);
|
|
6119
7296
|
try {
|
|
6120
7297
|
await runDockerStreaming(buildArgs, {
|
|
6121
7298
|
cwd: contextDir,
|
|
6122
7299
|
env: { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" }
|
|
6123
7300
|
});
|
|
6124
7301
|
} catch (err) {
|
|
6125
|
-
|
|
6126
|
-
throw options.wrapError(e.stderr || e.message || String(err));
|
|
7302
|
+
throw options.wrapError(describeDockerFailure(err, buildArgs));
|
|
6127
7303
|
}
|
|
6128
7304
|
return options.tag;
|
|
6129
7305
|
}
|
|
@@ -6291,8 +7467,9 @@ var DockerAssetPublisher = class {
|
|
|
6291
7467
|
}
|
|
6292
7468
|
/**
|
|
6293
7469
|
* Build Docker image — delegates to the shared `buildDockerImage`
|
|
6294
|
-
* helper so this code path stays in sync with `cdkd local
|
|
6295
|
-
*
|
|
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
|
|
6296
7473
|
* manifest's `source.platform` (when set); cdkd does not currently
|
|
6297
7474
|
* inject a publish-side override.
|
|
6298
7475
|
*
|
|
@@ -6376,33 +7553,33 @@ var DockerAssetPublisher = class {
|
|
|
6376
7553
|
const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
|
|
6377
7554
|
if (!username || password === void 0) throw new AssetError("ECR authorization token has unexpected shape (missing username/password)");
|
|
6378
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
|
+
];
|
|
6379
7563
|
try {
|
|
6380
|
-
await runDockerStreaming(
|
|
6381
|
-
"login",
|
|
6382
|
-
"--username",
|
|
6383
|
-
username,
|
|
6384
|
-
"--password-stdin",
|
|
6385
|
-
endpoint
|
|
6386
|
-
], { input: password });
|
|
7564
|
+
await runDockerStreaming(loginArgs, { input: password });
|
|
6387
7565
|
loggedInRegistries.add(registryKey);
|
|
6388
7566
|
} catch (err) {
|
|
6389
|
-
|
|
6390
|
-
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)}`);
|
|
6391
7568
|
}
|
|
6392
7569
|
}
|
|
6393
7570
|
/**
|
|
6394
7571
|
* Tag Docker image
|
|
6395
7572
|
*/
|
|
6396
7573
|
async tagImage(source, target) {
|
|
7574
|
+
const tagArgs = [
|
|
7575
|
+
"tag",
|
|
7576
|
+
source,
|
|
7577
|
+
target
|
|
7578
|
+
];
|
|
6397
7579
|
try {
|
|
6398
|
-
await runDockerStreaming(
|
|
6399
|
-
"tag",
|
|
6400
|
-
source,
|
|
6401
|
-
target
|
|
6402
|
-
]);
|
|
7580
|
+
await runDockerStreaming(tagArgs);
|
|
6403
7581
|
} catch (err) {
|
|
6404
|
-
|
|
6405
|
-
throw new AssetError(`Docker tag failed: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
7582
|
+
throw new AssetError(`Docker tag failed: ${describeDockerFailure(err, tagArgs)}`);
|
|
6406
7583
|
}
|
|
6407
7584
|
}
|
|
6408
7585
|
/**
|
|
@@ -6412,11 +7589,11 @@ var DockerAssetPublisher = class {
|
|
|
6412
7589
|
*/
|
|
6413
7590
|
async pushImage(uri) {
|
|
6414
7591
|
this.logger.debug(`Pushing: ${uri}`);
|
|
7592
|
+
const pushArgs = ["push", uri];
|
|
6415
7593
|
try {
|
|
6416
|
-
await runDockerStreaming(
|
|
7594
|
+
await runDockerStreaming(pushArgs);
|
|
6417
7595
|
} catch (err) {
|
|
6418
|
-
|
|
6419
|
-
throw new AssetError(`Docker push failed: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
7596
|
+
throw new AssetError(`Docker push failed: ${describeDockerFailure(err, pushArgs)}`);
|
|
6420
7597
|
}
|
|
6421
7598
|
}
|
|
6422
7599
|
/**
|
|
@@ -7154,49 +8331,6 @@ var AssetModeResolver = class {
|
|
|
7154
8331
|
}
|
|
7155
8332
|
};
|
|
7156
8333
|
|
|
7157
|
-
//#endregion
|
|
7158
|
-
//#region src/utils/regexp.ts
|
|
7159
|
-
/**
|
|
7160
|
-
* Escape a literal string for embedding in a RegExp.
|
|
7161
|
-
*
|
|
7162
|
-
* Lives here because the same four-line helper was spelled THREE times — in
|
|
7163
|
-
* `src/utils/ecr-uri.ts`, `src/cli/commands/gc.ts` and
|
|
7164
|
-
* `src/assets/asset-redirect.ts` — each interpolating a user-controlled name
|
|
7165
|
-
* (an AWS region, a bootstrap-marker asset bucket / container repo, an ECR host
|
|
7166
|
-
* label) into a pattern. Copies of a security-shaped helper are exactly the
|
|
7167
|
-
* shape that drifts: the escaped set is what stops a `.` in an interpolated
|
|
7168
|
-
* literal matching ANY character, and in `gc.ts` a widened match decides which
|
|
7169
|
-
* live assets are treated as referenced.
|
|
7170
|
-
*
|
|
7171
|
-
* The character set is the union every JS engine treats as special in a
|
|
7172
|
-
* non-`u` pattern. `-` is deliberately absent: it is only special INSIDE a
|
|
7173
|
-
* character class, and no caller embeds into one.
|
|
7174
|
-
*/
|
|
7175
|
-
function escapeRegExp$1(value) {
|
|
7176
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7177
|
-
}
|
|
7178
|
-
/**
|
|
7179
|
-
* Remove control characters from a value about to be PRINTED.
|
|
7180
|
-
*
|
|
7181
|
-
* Lives beside {@link escapeRegExp} for the reason that helper's own note
|
|
7182
|
-
* gives: this was spelled twice — in `src/cli/commands/diff-recursive.ts` and
|
|
7183
|
-
* in `src/deployment/outputs-export-alias.ts` — and copies of a
|
|
7184
|
-
* security-shaped helper drift. Both print the same class of string: a
|
|
7185
|
-
* resolved CloudFormation Output / `Export.Name`, which passed no CFn
|
|
7186
|
-
* validator, so it can carry ANSI escapes or bidi overrides straight into a
|
|
7187
|
-
* terminal or a CI log.
|
|
7188
|
-
*
|
|
7189
|
-
* C0 + DEL + C1 + the bidi marks and isolates. Apply on HUMAN-render paths
|
|
7190
|
-
* only: a `--json` payload is a machine interface where an export name is data
|
|
7191
|
-
* a consumer may match on, and mutating it there trades a correctness
|
|
7192
|
-
* regression for a display concern (see `stripDisplayOnlyChars`, which is the
|
|
7193
|
-
* narrower guard for an already-serialized payload and stays local to its
|
|
7194
|
-
* caller because its C0 exclusion is specific to that path).
|
|
7195
|
-
*/
|
|
7196
|
-
function stripControlChars(value) {
|
|
7197
|
-
return value.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "");
|
|
7198
|
-
}
|
|
7199
|
-
|
|
7200
8334
|
//#endregion
|
|
7201
8335
|
//#region src/assets/asset-redirect.ts
|
|
7202
8336
|
/**
|
|
@@ -8078,6 +9212,16 @@ function formatRemaining(ms) {
|
|
|
8078
9212
|
if (minutes < 1) return "in under a minute";
|
|
8079
9213
|
return `in ~${minutes}m`;
|
|
8080
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
|
+
*/
|
|
8081
9225
|
function shellQuote(value) {
|
|
8082
9226
|
return /^[A-Za-z0-9._/@:+-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
8083
9227
|
}
|
|
@@ -8097,6 +9241,18 @@ function buildForceUnlockCommand(stackName, region, recovery) {
|
|
|
8097
9241
|
return parts.join(" ");
|
|
8098
9242
|
}
|
|
8099
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
|
+
/**
|
|
8100
9256
|
* The force-quit banner's recovery sentence.
|
|
8101
9257
|
*
|
|
8102
9258
|
* Exported so the two `destroy-runner.ts` banners do not each decide what to
|
|
@@ -8107,7 +9263,7 @@ function buildForceUnlockCommand(stackName, region, recovery) {
|
|
|
8107
9263
|
*/
|
|
8108
9264
|
function forceQuitRecoveryClause(stackName, region, recovery) {
|
|
8109
9265
|
const command = buildForceUnlockCommand(stackName, region, recovery);
|
|
8110
|
-
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}`;
|
|
8111
9267
|
}
|
|
8112
9268
|
/**
|
|
8113
9269
|
* Build the contention message, reading the holder's identity best-effort.
|
|
@@ -9355,7 +10511,7 @@ var LockManager = class {
|
|
|
9355
10511
|
}
|
|
9356
10512
|
return false;
|
|
9357
10513
|
}
|
|
9358
|
-
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);
|
|
9359
10515
|
}
|
|
9360
10516
|
}
|
|
9361
10517
|
/**
|
|
@@ -9579,8 +10735,11 @@ var LockManager = class {
|
|
|
9579
10735
|
/**
|
|
9580
10736
|
* Force release a lock regardless of owner or expiry status
|
|
9581
10737
|
*
|
|
9582
|
-
* This is intended for CLI usage
|
|
9583
|
-
* 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.
|
|
9584
10743
|
*
|
|
9585
10744
|
* Pass `region: undefined` to operate on a legacy
|
|
9586
10745
|
* `{prefix}/{stackName}/lock.json` file.
|
|
@@ -9923,7 +11082,7 @@ var LockManager = class {
|
|
|
9923
11082
|
if (lockInfo) {
|
|
9924
11083
|
const remainingMs = lockInfo.expiresAt - Date.now();
|
|
9925
11084
|
if (attempt < maxRetries) {
|
|
9926
|
-
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})`);
|
|
9927
11086
|
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
|
9928
11087
|
continue;
|
|
9929
11088
|
}
|
|
@@ -9931,7 +11090,9 @@ var LockManager = class {
|
|
|
9931
11090
|
}
|
|
9932
11091
|
const lockInfo = await this.getLockInfo(stackName, region);
|
|
9933
11092
|
const expiresIn = lockInfo ? this.formatDuration(lockInfo.expiresAt - Date.now()) : "unknown";
|
|
9934
|
-
|
|
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}`));
|
|
9935
11096
|
}
|
|
9936
11097
|
};
|
|
9937
11098
|
|
|
@@ -10685,7 +11846,7 @@ function storeAssociation(associations, key, expression, plaintext) {
|
|
|
10685
11846
|
* matters: an AWS resource type string is fixed by AWS, and a typo in either
|
|
10686
11847
|
* copy makes that copy's gate simply never fire (no-op), never fire wrongly.
|
|
10687
11848
|
*/
|
|
10688
|
-
const NESTED_STACK_RESOURCE_TYPE$
|
|
11849
|
+
const NESTED_STACK_RESOURCE_TYPE$2 = "AWS::CloudFormation::Stack";
|
|
10689
11850
|
/**
|
|
10690
11851
|
* What a PARENT stack's resolution proved about each `Parameters` entry of an
|
|
10691
11852
|
* `AWS::CloudFormation::Stack` row it is about to provision, keyed by the
|
|
@@ -10792,7 +11953,7 @@ const nestedStackParameterExpressions = /* @__PURE__ */ new WeakMap();
|
|
|
10792
11953
|
* `secret-redaction-nested-parameter-source.test.ts`.
|
|
10793
11954
|
*/
|
|
10794
11955
|
function recordNestedStackParameterExpressions(secrets, resourceType, resolvedProperties, sourceProperties, rules = TEMPLATE_DERIVED_RULES) {
|
|
10795
|
-
if (resourceType !== NESTED_STACK_RESOURCE_TYPE$
|
|
11956
|
+
if (resourceType !== NESTED_STACK_RESOURCE_TYPE$2) return;
|
|
10796
11957
|
if (secrets.size === 0) return;
|
|
10797
11958
|
if (!isPlainObject$2(resolvedProperties) || !isPlainObject$2(sourceProperties)) return;
|
|
10798
11959
|
if (!Object.hasOwn(resolvedProperties, "Parameters")) return;
|
|
@@ -15970,9 +17131,28 @@ function secretsManagerSecretId(inner) {
|
|
|
15970
17131
|
* - A trailing `:<version>` / `:<label>` is part of the name AS SSM PARSES IT
|
|
15971
17132
|
* (`GetParameter` accepts `name:3` / `name:prod`), so stripping it would name
|
|
15972
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.
|
|
15973
17152
|
*/
|
|
15974
17153
|
function ssmParameterName(inner) {
|
|
15975
|
-
|
|
17154
|
+
const serviceEnd = inner.indexOf(":");
|
|
17155
|
+
return serviceEnd < 0 ? "" : inner.substring(serviceEnd + 1);
|
|
15976
17156
|
}
|
|
15977
17157
|
/**
|
|
15978
17158
|
* The region an ARN names, or `undefined` for anything that is not an ARN with
|
|
@@ -16110,6 +17290,101 @@ function producerRegionsFromState(state) {
|
|
|
16110
17290
|
* A same-region ARN answers `local` even when a foreign producer region IS on
|
|
16111
17291
|
* record: the expression settles the question itself, so the weaker evidence
|
|
16112
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.
|
|
16113
17388
|
*/
|
|
16114
17389
|
function classifyReplaySecretRegion(expression, consumerRegion, importedProducerRegions) {
|
|
16115
17390
|
const inner = expression.startsWith("{{resolve:") ? expression.slice(10, -2) : void 0;
|
|
@@ -17803,7 +19078,7 @@ function carriesDynamicReference(value) {
|
|
|
17803
19078
|
return false;
|
|
17804
19079
|
}
|
|
17805
19080
|
/** The nested-stack resource type, whose `Outputs.<Name>` attributes are re-resolved (issue #2055). */
|
|
17806
|
-
const NESTED_STACK_RESOURCE_TYPE = "AWS::CloudFormation::Stack";
|
|
19081
|
+
const NESTED_STACK_RESOURCE_TYPE$1 = "AWS::CloudFormation::Stack";
|
|
17807
19082
|
/** Prefix `NestedStackProvider` records a child stack output under. */
|
|
17808
19083
|
const NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX = "Outputs.";
|
|
17809
19084
|
/**
|
|
@@ -19294,7 +20569,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
19294
20569
|
return this.noteAttributeSecrecy(logicalId, attributeName, nameServers, context);
|
|
19295
20570
|
}
|
|
19296
20571
|
this.logger.debug(`Resolved Fn::GetAtt from attributes: ${logicalId}.${attributeName} -> ${stringifyAttributeForLog(attributeName, flatValue)}`);
|
|
19297
|
-
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 }));
|
|
19298
20573
|
return this.noteAttributeSecrecy(logicalId, attributeName, flatValue, context);
|
|
19299
20574
|
}
|
|
19300
20575
|
if (attributeName.includes(".")) {
|
|
@@ -19311,7 +20586,7 @@ var IntrinsicFunctionResolver = class IntrinsicFunctionResolver {
|
|
|
19311
20586
|
}
|
|
19312
20587
|
}
|
|
19313
20588
|
}
|
|
19314
|
-
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)) {
|
|
19315
20590
|
const declared = Object.keys(resource.attributes ?? {}).filter((k) => k.startsWith(NESTED_STACK_OUTPUT_ATTRIBUTE_PREFIX)).map((k) => k.slice(8)).sort();
|
|
19316
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.`));
|
|
19317
20592
|
}
|
|
@@ -22525,7 +23800,7 @@ var CloudControlProvider = class {
|
|
|
22525
23800
|
const indeterminateGuard = await this.confirmDeleteTargetIdentity(logicalId, resourceType, physicalId, context);
|
|
22526
23801
|
if (context?.removeProtection === true && resourceType === "AWS::AutoScaling::AutoScalingGroup") {
|
|
22527
23802
|
this.logger.debug(`Delegating protected AutoScalingGroup ${logicalId} delete to the SDK ASGProvider (Cloud Control cannot force-delete a protected ASG)`);
|
|
22528
|
-
const { ASGProvider } = await import("./asg-provider-
|
|
23803
|
+
const { ASGProvider } = await import("./asg-provider-dBSdt3nB.js").then((n) => n.n);
|
|
22529
23804
|
return withIndeterminateGuard(await new ASGProvider().delete(logicalId, physicalId, resourceType, _properties, context), indeterminateGuard);
|
|
22530
23805
|
}
|
|
22531
23806
|
const isProtectedEc2Instance = context?.removeProtection === true && resourceType === "AWS::EC2::Instance";
|
|
@@ -24710,7 +25985,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
24710
25985
|
reuseClientCredentials: true,
|
|
24711
25986
|
tolerateNonStandardClient: true,
|
|
24712
25987
|
onRebuild: ({ bucketRegion, currentRegion }) => {
|
|
24713
|
-
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.`);
|
|
24714
25989
|
}
|
|
24715
25990
|
});
|
|
24716
25991
|
if (generation !== this.responseClientGeneration) {
|
|
@@ -25387,7 +26662,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
25387
26662
|
Key: responseKey
|
|
25388
26663
|
});
|
|
25389
26664
|
const presignedUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 7200 });
|
|
25390
|
-
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)}`);
|
|
25391
26666
|
return presignedUrl;
|
|
25392
26667
|
}
|
|
25393
26668
|
/**
|
|
@@ -25491,7 +26766,7 @@ var CustomResourceProvider = class CustomResourceProvider {
|
|
|
25491
26766
|
Key: responseKey
|
|
25492
26767
|
}));
|
|
25493
26768
|
} catch (error) {
|
|
25494
|
-
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>"}`);
|
|
25495
26770
|
}
|
|
25496
26771
|
await purgeNoncurrentKeyVersions(this.s3Client, bucket, [responseKey], {
|
|
25497
26772
|
logger: this.logger,
|
|
@@ -28988,6 +30263,70 @@ function getCurrentResourceSecrets() {
|
|
|
28988
30263
|
return currentResourceSecretsStore.getStore();
|
|
28989
30264
|
}
|
|
28990
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
|
+
|
|
28991
30330
|
//#endregion
|
|
28992
30331
|
//#region src/deployment/outputs-export-alias.ts
|
|
28993
30332
|
/**
|
|
@@ -29311,6 +30650,218 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
|
|
|
29311
30650
|
}
|
|
29312
30651
|
}
|
|
29313
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
|
+
|
|
29314
30865
|
//#endregion
|
|
29315
30866
|
//#region src/provisioning/stateful-types.ts
|
|
29316
30867
|
/**
|
|
@@ -29357,9 +30908,12 @@ function formatResourceLine(op, logicalId, resourceType, verbOverride) {
|
|
|
29357
30908
|
* "never expire" — the most data-bearing configuration the type
|
|
29358
30909
|
* has, and what `LogsLogGroupProvider` records `0` for — so reading
|
|
29359
30910
|
* it as "holds nothing" destroyed years of events on a plain
|
|
29360
|
-
* `cdkd deploy`. A
|
|
29361
|
-
* `has-retention` from
|
|
29362
|
-
*
|
|
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}.
|
|
29363
30917
|
* The pre-flight resolves the deferral with a live
|
|
29364
30918
|
* `logs:DescribeLogStreams` probe (a log group with no stream can
|
|
29365
30919
|
* hold no event, since every event belongs to a stream); mid-deploy,
|
|
@@ -29544,10 +31098,66 @@ const STATEFUL_TYPES = /* @__PURE__ */ new Set([
|
|
|
29544
31098
|
*/
|
|
29545
31099
|
const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::DynamoDB::GlobalTable"]);
|
|
29546
31100
|
/**
|
|
29547
|
-
*
|
|
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
|
+
*
|
|
29548
31158
|
* TWO types return `null` meaning DEFER rather than "not stateful":
|
|
29549
|
-
* `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever
|
|
29550
|
-
*
|
|
31159
|
+
* `AWS::S3::Bucket` always, and `AWS::Logs::LogGroup` whenever neither
|
|
31160
|
+
* bag already proves `has-retention`. The live probes
|
|
29551
31161
|
* that resolve both deferrals (`ListObjectVersions` for the bucket,
|
|
29552
31162
|
* `DescribeLogStreams` for the log group) live in
|
|
29553
31163
|
* `src/deployment/recreate-targets.ts#probeStatefulRecreateTargetsAsync`
|
|
@@ -29560,11 +31170,10 @@ const MULTI_REGION_RECREATE_BLOCKED_TYPES = /* @__PURE__ */ new Set(["AWS::Dynam
|
|
|
29560
31170
|
* Returns the {@link StatefulReason} when the type is stateful (or
|
|
29561
31171
|
* `null` for non-stateful types).
|
|
29562
31172
|
*/
|
|
29563
|
-
function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
|
|
31173
|
+
function isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties) {
|
|
29564
31174
|
if (!STATEFUL_TYPES.has(resourceType)) return null;
|
|
29565
31175
|
if (resourceType === "AWS::Logs::LogGroup") {
|
|
29566
|
-
|
|
29567
|
-
if (typeof retention === "number" && retention > 0) return "has-retention";
|
|
31176
|
+
if (logGroupHasPositiveRetention(recordedProperties, observedProperties)) return "has-retention";
|
|
29568
31177
|
return null;
|
|
29569
31178
|
}
|
|
29570
31179
|
if (resourceType === "AWS::S3::Bucket") return null;
|
|
@@ -29591,16 +31200,38 @@ function isStatefulRecreateTargetSync(resourceType, recordedProperties) {
|
|
|
29591
31200
|
* — be DELETE + CREATEd (data loss) without `--force-stateful-recreation`. To
|
|
29592
31201
|
* stay fail-safe, both deferrals resolve to stateful here: the user must pass
|
|
29593
31202
|
* `--force-stateful-recreation` to replace ANY S3 bucket, and any log group
|
|
29594
|
-
* whose recorded
|
|
29595
|
-
* 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.
|
|
29596
31227
|
*
|
|
29597
31228
|
* The log group's arm is the one issue [#2558] added, and the reason it is
|
|
29598
31229
|
* needed is that the old predicate treated "no retention recorded" as "holds
|
|
29599
31230
|
* nothing" when it is CloudWatch Logs' never-expire. Every other type matches
|
|
29600
31231
|
* {@link isStatefulRecreateTargetSync} exactly.
|
|
29601
31232
|
*/
|
|
29602
|
-
function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
|
|
29603
|
-
const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties);
|
|
31233
|
+
function isStatefulRecreateTargetForReplace(resourceType, recordedProperties, observedProperties) {
|
|
31234
|
+
const sync = isStatefulRecreateTargetSync(resourceType, recordedProperties, observedProperties);
|
|
29604
31235
|
if (sync) return sync;
|
|
29605
31236
|
if (resourceType === "AWS::S3::Bucket") return "has-objects";
|
|
29606
31237
|
if (resourceType === "AWS::Logs::LogGroup") return "has-log-events";
|
|
@@ -29614,7 +31245,7 @@ function isStatefulRecreateTargetForReplace(resourceType, recordedProperties) {
|
|
|
29614
31245
|
function renderStatefulReason(reason) {
|
|
29615
31246
|
switch (reason) {
|
|
29616
31247
|
case "always": return "destroy loses all data in the resource";
|
|
29617
|
-
case "has-objects": return "S3 bucket is
|
|
31248
|
+
case "has-objects": return "S3 bucket is not provably empty";
|
|
29618
31249
|
case "has-retention": return "log group retains data (RetentionInDays > 0)";
|
|
29619
31250
|
case "has-log-events": return "log group is not provably empty";
|
|
29620
31251
|
case null: return "(not stateful)";
|
|
@@ -30762,6 +32393,84 @@ function rollbackFinalSnapshotId(resourceType, record, fallbackProvisionedBy) {
|
|
|
30762
32393
|
return buildFinalSnapshotIdentifier(record.physicalId, resourceType);
|
|
30763
32394
|
}
|
|
30764
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
|
+
/**
|
|
30765
32474
|
* `DeletionPolicy: Snapshot` on a rolled-back CREATE (issue #1358) — the
|
|
30766
32475
|
* executor's copy of the deploy engine's `prepareFinalSnapshotForDelete`
|
|
30767
32476
|
* mechanism matrix, run BEFORE the delete. Shared with the FAILED in-flight
|
|
@@ -30860,7 +32569,7 @@ function classifyRollbackOp(op, stateResources, orphanLogicalIds) {
|
|
|
30860
32569
|
if (deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
|
|
30861
32570
|
return "skip-mismatch";
|
|
30862
32571
|
}
|
|
30863
|
-
return op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
|
|
32572
|
+
return op.oldResourceRetained ?? op.previousState.updateReplacePolicy === "Retain" ? "reverse-replacement-readopt" : "reverse-replacement";
|
|
30864
32573
|
}
|
|
30865
32574
|
if (op.previousState && deepEqual(current.properties, op.previousState.properties)) return "skip-already-done";
|
|
30866
32575
|
return "revert";
|
|
@@ -30900,12 +32609,16 @@ function planFailedOps(failedOps, stateResources) {
|
|
|
30900
32609
|
*/
|
|
30901
32610
|
function planRollback(operations, stateResources, orphanLogicalIds = /* @__PURE__ */ new Set()) {
|
|
30902
32611
|
const { createOps, otherOps } = partitionOps(operations);
|
|
30903
|
-
return [...[...otherOps].reverse(), ...sortRollbackCreates(createOps, stateResources)].map((op) =>
|
|
30904
|
-
op,
|
|
30905
|
-
|
|
30906
|
-
|
|
30907
|
-
|
|
30908
|
-
|
|
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
|
+
});
|
|
30909
32622
|
}
|
|
30910
32623
|
function partitionOps(operations) {
|
|
30911
32624
|
const createOps = [];
|
|
@@ -31263,11 +32976,32 @@ async function resolveLeafByRegion(leaf, propertyPath, logicalId, execCtx, resol
|
|
|
31263
32976
|
* writer is one of only two that can honestly claim it.
|
|
31264
32977
|
*
|
|
31265
32978
|
* Using `STATE_SOURCED_READBACK_RULES` here reads plausible and is wrong in the
|
|
31266
|
-
* quiet direction: it turns positional array descent off.
|
|
31267
|
-
*
|
|
31268
|
-
*
|
|
31269
|
-
*
|
|
31270
|
-
*
|
|
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).
|
|
31271
33005
|
*
|
|
31272
33006
|
* No-op when the op resolved no secret.
|
|
31273
33007
|
*/
|
|
@@ -31477,7 +33211,8 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31477
33211
|
return;
|
|
31478
33212
|
case "orphan-flag":
|
|
31479
33213
|
if (op.changeType === "CREATE") {
|
|
31480
|
-
const
|
|
33214
|
+
const record = stateResources[op.logicalId];
|
|
33215
|
+
const orphanFlagProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
|
|
31481
33216
|
createRollbackRoute = orphanFlagProvisionedBy;
|
|
31482
33217
|
delete stateResources[op.logicalId];
|
|
31483
33218
|
logger.info(` Rollback: Orphaning created resource ${op.logicalId} (--orphan)`);
|
|
@@ -31488,12 +33223,17 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31488
33223
|
operation: "CREATE",
|
|
31489
33224
|
logicalId: op.logicalId,
|
|
31490
33225
|
resourceType: op.resourceType,
|
|
31491
|
-
...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
|
+
}
|
|
31492
33231
|
});
|
|
31493
33232
|
} else logger.info(` Rollback: Leaving ${op.logicalId} at its new state (--orphan)`);
|
|
31494
33233
|
return;
|
|
31495
33234
|
case "orphan-retain": {
|
|
31496
|
-
const
|
|
33235
|
+
const record = stateResources[op.logicalId];
|
|
33236
|
+
const orphanProvisionedBy = effectiveProvisionedBy(record, op.provisionedBy);
|
|
31497
33237
|
createRollbackRoute = orphanProvisionedBy;
|
|
31498
33238
|
delete stateResources[op.logicalId];
|
|
31499
33239
|
logger.info(` Rollback: Leaving ${op.logicalId} (${op.resourceType}) in AWS (DeletionPolicy: Retain) — removed from state`);
|
|
@@ -31504,7 +33244,11 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31504
33244
|
operation: "CREATE",
|
|
31505
33245
|
logicalId: op.logicalId,
|
|
31506
33246
|
resourceType: op.resourceType,
|
|
31507
|
-
...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
|
+
}
|
|
31508
33252
|
});
|
|
31509
33253
|
return;
|
|
31510
33254
|
}
|
|
@@ -31547,11 +33291,32 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31547
33291
|
const current = stateResources[op.logicalId];
|
|
31548
33292
|
const prev = op.previousState;
|
|
31549
33293
|
logger.info(` Rollback: Reversing replacement of ${op.logicalId} (${op.resourceType}) — deleting the new resource and re-adopting the retained old one (${prev.physicalId})`);
|
|
31550
|
-
|
|
31551
|
-
|
|
31552
|
-
|
|
31553
|
-
|
|
31554
|
-
|
|
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
|
+
});
|
|
31555
33320
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
31556
33321
|
throwIfDeleteSkipped(await newDeleteProvider.delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
31557
33322
|
expectedRegion: ctx.region,
|
|
@@ -31561,13 +33326,19 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31561
33326
|
stateResources[op.logicalId] = prev;
|
|
31562
33327
|
logger.info(` Rollback: ${op.logicalId} restored to the retained old resource`);
|
|
31563
33328
|
await afterOp?.(op.logicalId);
|
|
33329
|
+
const survivorProvisionedBy = current.provisionedBy;
|
|
31564
33330
|
ctx.recordEvent?.({
|
|
31565
33331
|
eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
|
|
31566
33332
|
stackName,
|
|
31567
33333
|
operation: "UPDATE",
|
|
31568
33334
|
logicalId: op.logicalId,
|
|
31569
33335
|
resourceType: op.resourceType,
|
|
31570
|
-
...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
|
+
}
|
|
31571
33342
|
});
|
|
31572
33343
|
return;
|
|
31573
33344
|
}
|
|
@@ -31583,10 +33354,10 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31583
33354
|
resourceType: op.resourceType,
|
|
31584
33355
|
provisionedBy: prev.provisionedBy
|
|
31585
33356
|
});
|
|
31586
|
-
const
|
|
33357
|
+
const resolveNewDeleteProvider = () => ctx.providerRegistry.getProviderFor({
|
|
31587
33358
|
resourceType: op.resourceType,
|
|
31588
33359
|
provisionedBy: current.provisionedBy ?? op.provisionedBy
|
|
31589
|
-
});
|
|
33360
|
+
}).provider;
|
|
31590
33361
|
let deletedNewFirst = false;
|
|
31591
33362
|
let createResult;
|
|
31592
33363
|
try {
|
|
@@ -31595,11 +33366,13 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31595
33366
|
interruptedMessage: "Rollback interrupted while waiting out the name cooldown"
|
|
31596
33367
|
});
|
|
31597
33368
|
} catch (createError) {
|
|
31598
|
-
|
|
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)));
|
|
31599
33372
|
logger.info(` Rollback: re-create collided with the new resource's name — deleting the new resource (${current.physicalId}) first...`);
|
|
31600
33373
|
{
|
|
31601
33374
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
31602
|
-
throwIfDeleteSkipped(await
|
|
33375
|
+
throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
31603
33376
|
expectedRegion: ctx.region,
|
|
31604
33377
|
...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
|
|
31605
33378
|
}), op.logicalId, current.physicalId, "while clearing the new resource so the old one could be re-created");
|
|
@@ -31613,7 +33386,7 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31613
33386
|
interruptedMessage: "Rollback interrupted while waiting for the old name to release"
|
|
31614
33387
|
});
|
|
31615
33388
|
} catch (recreateError) {
|
|
31616
|
-
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) });
|
|
31617
33390
|
}
|
|
31618
33391
|
}
|
|
31619
33392
|
const adoptedLiveNewResource = !deletedNewFirst && createResult.physicalId === current.physicalId;
|
|
@@ -31629,24 +33402,37 @@ async function replaySingle(op, stateResources, stackName, ctx, resolver, orphan
|
|
|
31629
33402
|
properties: recordedPropertiesAfterReplayCreate(prevRecord, createResult)
|
|
31630
33403
|
}, secrets, prevRecord.properties);
|
|
31631
33404
|
await afterOp?.(op.logicalId);
|
|
31632
|
-
|
|
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 {
|
|
31633
33412
|
const finalSnapshotIdentifier = rollbackFinalSnapshotId(op.resourceType, current, op.provisionedBy);
|
|
31634
|
-
throwIfDeleteSkipped(await
|
|
33413
|
+
throwIfDeleteSkipped(await resolveNewDeleteProvider().delete(op.logicalId, current.physicalId, op.resourceType, current.properties, {
|
|
31635
33414
|
expectedRegion: ctx.region,
|
|
31636
33415
|
...finalSnapshotIdentifier !== void 0 && { finalSnapshotIdentifier }
|
|
31637
33416
|
}), op.logicalId, current.physicalId, "while deleting the new resource after re-creating the old one");
|
|
31638
33417
|
} catch (deleteError) {
|
|
31639
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.`;
|
|
31640
33420
|
result.warnings++;
|
|
31641
33421
|
}
|
|
31642
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;
|
|
31643
33424
|
ctx.recordEvent?.({
|
|
31644
33425
|
eventType: "ROLLBACK_RESOURCE_SUCCEEDED",
|
|
31645
33426
|
stackName,
|
|
31646
33427
|
operation: "UPDATE",
|
|
31647
33428
|
logicalId: op.logicalId,
|
|
31648
33429
|
resourceType: op.resourceType,
|
|
31649
|
-
...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
|
+
}
|
|
31650
33436
|
});
|
|
31651
33437
|
return;
|
|
31652
33438
|
}
|
|
@@ -32335,7 +34121,10 @@ var DeploymentEventsReader = class {
|
|
|
32335
34121
|
* window and rewrites (or removes) `index.json` to match.
|
|
32336
34122
|
*
|
|
32337
34123
|
* Retention semantics (see {@link DeploymentEventsPruneOptions}):
|
|
32338
|
-
* - `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).
|
|
32339
34128
|
* - `keep N` — retain the newest N runs, delete the rest.
|
|
32340
34129
|
* - `olderThanMs`— delete runs whose run-id timestamp is older than the
|
|
32341
34130
|
* cutoff; a run id with no parseable timestamp is kept.
|
|
@@ -32750,6 +34539,39 @@ var DeployEngine = class {
|
|
|
32750
34539
|
*/
|
|
32751
34540
|
attemptedResolvedProps = /* @__PURE__ */ new Map();
|
|
32752
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
|
+
/**
|
|
32753
34575
|
* Target region for this stack. Required — load-bearing for the
|
|
32754
34576
|
* region-prefixed S3 state key and recorded in state.json for
|
|
32755
34577
|
* cross-region destroy.
|
|
@@ -32788,6 +34610,7 @@ var DeployEngine = class {
|
|
|
32788
34610
|
this.outputSecrets = /* @__PURE__ */ new Map();
|
|
32789
34611
|
this.outputsTemplateSource = {};
|
|
32790
34612
|
this.outputsSourceUsable = true;
|
|
34613
|
+
this.retainedOldOnReplacement = /* @__PURE__ */ new Set();
|
|
32791
34614
|
this.resolver.resetPhysicalIdFallbackCount();
|
|
32792
34615
|
return withStackName(stackName, () => this.doDeploy(stackName, template));
|
|
32793
34616
|
}
|
|
@@ -33209,7 +35032,10 @@ var DeployEngine = class {
|
|
|
33209
35032
|
for (const { logicalId, resource } of candidates) {
|
|
33210
35033
|
let provider;
|
|
33211
35034
|
try {
|
|
33212
|
-
provider = this.providerRegistry.
|
|
35035
|
+
provider = this.providerRegistry.getProviderFor({
|
|
35036
|
+
resourceType: resource.resourceType,
|
|
35037
|
+
provisionedBy: resource.provisionedBy
|
|
35038
|
+
}).provider;
|
|
33213
35039
|
} catch {
|
|
33214
35040
|
continue;
|
|
33215
35041
|
}
|
|
@@ -33301,6 +35127,11 @@ var DeployEngine = class {
|
|
|
33301
35127
|
diffResolverContext.skipDynamicReferences = true;
|
|
33302
35128
|
const diffResolveFn = (value) => this.resolver.resolve(value, diffResolverContext);
|
|
33303
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"));
|
|
33304
35135
|
if (!this.diffCalculator.hasChanges(changes)) {
|
|
33305
35136
|
this.logger.info("No changes detected. Stack is up to date.");
|
|
33306
35137
|
let persistedOutputs = currentState.outputs ?? {};
|
|
@@ -33510,7 +35341,8 @@ var DeployEngine = class {
|
|
|
33510
35341
|
provisionedBy: newResources[logicalId]?.provisionedBy ?? previousState?.provisionedBy,
|
|
33511
35342
|
previousState,
|
|
33512
35343
|
physicalId: newResources[logicalId]?.physicalId,
|
|
33513
|
-
properties: newResources[logicalId]?.properties
|
|
35344
|
+
properties: newResources[logicalId]?.properties,
|
|
35345
|
+
...change.changeType === "UPDATE" && { oldResourceRetained: this.retainedOldOnReplacement.has(logicalId) }
|
|
33514
35346
|
});
|
|
33515
35347
|
saveStateAfterResource(logicalId);
|
|
33516
35348
|
}, () => this.interrupted);
|
|
@@ -34037,7 +35869,7 @@ var DeployEngine = class {
|
|
|
34037
35869
|
isRetryable: isRecreateRetryableError
|
|
34038
35870
|
});
|
|
34039
35871
|
} catch (recreateError) {
|
|
34040
|
-
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 });
|
|
34041
35873
|
}
|
|
34042
35874
|
}
|
|
34043
35875
|
/**
|
|
@@ -34134,15 +35966,16 @@ var DeployEngine = class {
|
|
|
34134
35966
|
break;
|
|
34135
35967
|
}
|
|
34136
35968
|
const propertyDrivenReplacement = change.propertyChanges?.some((pc) => pc.requiresReplacement);
|
|
34137
|
-
const
|
|
34138
|
-
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;
|
|
34139
35972
|
const recreateFlagged = recreateViaCcApi || recreateViaSdkProvider;
|
|
34140
35973
|
const needsReplacement = propertyDrivenReplacement || recreateFlagged;
|
|
34141
35974
|
const dependencies = this.extractAllDependencies(template, logicalId);
|
|
34142
35975
|
const updateReplacePolicy = template?.Resources?.[logicalId]?.UpdateReplacePolicy;
|
|
34143
35976
|
if (needsReplacement) {
|
|
34144
35977
|
if (propertyDrivenReplacement && !recreateFlagged && updateReplacePolicy !== "Retain") {
|
|
34145
|
-
const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps);
|
|
35978
|
+
const statefulReason = isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
|
|
34146
35979
|
if (statefulReason && this.options.forceStatefulRecreation !== true) {
|
|
34147
35980
|
const immutableProps = change.propertyChanges?.filter((pc) => pc.requiresReplacement).map((pc) => pc.path).join(", ");
|
|
34148
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"));
|
|
@@ -34168,8 +36001,10 @@ var DeployEngine = class {
|
|
|
34168
36001
|
let createResult;
|
|
34169
36002
|
if (recreateFlagged) {
|
|
34170
36003
|
const recreateFlagName = recreateViaCcApi ? "--recreate-via-cc-api" : "--recreate-via-sdk-provider";
|
|
34171
|
-
if (updateReplacePolicy === "Retain")
|
|
34172
|
-
|
|
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 {
|
|
34173
36008
|
this.logger.info(` Destroying old ${logicalId} (${currentResource.physicalId}) before recreate...`);
|
|
34174
36009
|
const recreateFinalSnapshotId = await this.prepareFinalSnapshotForDelete(logicalId, resourceType, currentResource, updateReplacePolicy);
|
|
34175
36010
|
let recreateDeleteResult;
|
|
@@ -34220,8 +36055,10 @@ var DeployEngine = class {
|
|
|
34220
36055
|
deletedOldFirst = true;
|
|
34221
36056
|
createResult = await this.replaceDeleteFirstAndRecreate(logicalId, resourceType, currentResource, oldDeleteProvider, replaceProvider, replaceProps, updateSecrets, updateReplacePolicy);
|
|
34222
36057
|
}
|
|
34223
|
-
if (deletedOldFirst) {} else if (updateReplacePolicy === "Retain")
|
|
34224
|
-
|
|
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 {
|
|
34225
36062
|
this.logger.info(` Deleting old ${logicalId} (${currentResource.physicalId})...`);
|
|
34226
36063
|
let cleanupFinalSnapshotId;
|
|
34227
36064
|
let snapshotBlockedDelete = false;
|
|
@@ -34278,6 +36115,7 @@ var DeployEngine = class {
|
|
|
34278
36115
|
const updateProps = updateDecision.provisionedBy === "cc-api" ? this.preparePropertiesForCcApi(resourceType, resolvedProps, logicalId) : resolvedProps;
|
|
34279
36116
|
let result;
|
|
34280
36117
|
let resultProvisionedBy = updateDecision.provisionedBy;
|
|
36118
|
+
let captureProvider = updateProvider;
|
|
34281
36119
|
try {
|
|
34282
36120
|
result = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => updateProvider.update(logicalId, currentResource.physicalId, resourceType, updateProps, currentProps, {
|
|
34283
36121
|
maskSecrets: createSecretMasker(updateSecrets),
|
|
@@ -34288,7 +36126,7 @@ var DeployEngine = class {
|
|
|
34288
36126
|
const replaceOptIn = updateError instanceof ResourceUpdateNotSupportedError && this.options.replace === true;
|
|
34289
36127
|
if (ccUnsupported || replaceOptIn) {
|
|
34290
36128
|
const retainOldOnReplace = updateReplacePolicy === "Retain";
|
|
34291
|
-
const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps);
|
|
36129
|
+
const statefulReason = retainOldOnReplace ? null : isStatefulRecreateTargetForReplace(resourceType, currentProps, currentResource.observedProperties);
|
|
34292
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));
|
|
34293
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)`);
|
|
34294
36132
|
if (!retainOldOnReplace) {
|
|
@@ -34319,7 +36157,7 @@ var DeployEngine = class {
|
|
|
34319
36157
|
try {
|
|
34320
36158
|
createResult = await this.withRetry(() => withCurrentResourceSecrets(updateSecrets, () => replProvider.create(logicalId, resourceType, replProps, { maskSecrets: createSecretMasker(updateSecrets) })), logicalId, void 0, void 0, replProvider);
|
|
34321
36159
|
} catch (createError) {
|
|
34322
|
-
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 });
|
|
34323
36161
|
if (!isNameCollisionError(createError instanceof Error ? createError.message : String(createError))) throw createError;
|
|
34324
36162
|
const nameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
|
|
34325
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));
|
|
@@ -34329,6 +36167,7 @@ var DeployEngine = class {
|
|
|
34329
36167
|
const idempotentNameOrigin = this.replacementNameOrigin(logicalId, currentResource.physicalId);
|
|
34330
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"));
|
|
34331
36169
|
}
|
|
36170
|
+
this.retainedOldOnReplacement.add(logicalId);
|
|
34332
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.`);
|
|
34333
36172
|
retainedSurvivorReason = `UpdateReplacePolicy: Retain kept the old ${resourceType} (${currentResource.physicalId}), now untracked by cdkd`;
|
|
34334
36173
|
}
|
|
@@ -34346,6 +36185,7 @@ var DeployEngine = class {
|
|
|
34346
36185
|
if (createResult.effectiveProperties) replacementResult.effectiveProperties = createResult.effectiveProperties;
|
|
34347
36186
|
result = replacementResult;
|
|
34348
36187
|
resultProvisionedBy = replDecision.provisionedBy;
|
|
36188
|
+
captureProvider = replProvider;
|
|
34349
36189
|
} else throw updateError;
|
|
34350
36190
|
}
|
|
34351
36191
|
if (result.wasReplaced) this.logger.info(`Resource ${logicalId} was replaced: ${currentResource.physicalId} -> ${result.physicalId}`);
|
|
@@ -34365,7 +36205,7 @@ var DeployEngine = class {
|
|
|
34365
36205
|
provisionedBy: resultProvisionedBy
|
|
34366
36206
|
};
|
|
34367
36207
|
const updateCaptureSiblings = await this.buildObservedCaptureSiblings(resourceType, logicalId, result.physicalId, template, stateResources, stackName, parameterValues, conditions);
|
|
34368
|
-
this.kickOffObservedCapture(
|
|
36208
|
+
this.kickOffObservedCapture(captureProvider, logicalId, result.physicalId, resourceType, resolvedProps, updateCaptureSiblings);
|
|
34369
36209
|
const updatePartial = updatePartialReason(result);
|
|
34370
36210
|
if (counts) if (updatePartial !== void 0) counts.updatePartial++;
|
|
34371
36211
|
else counts.updated++;
|
|
@@ -34818,5 +36658,5 @@ var DeployEngine = class {
|
|
|
34818
36658
|
};
|
|
34819
36659
|
|
|
34820
36660
|
//#endregion
|
|
34821
|
-
export {
|
|
34822
|
-
//# 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
|