@go-to-k/cdkd 0.121.0 → 0.123.0

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.
@@ -6,7 +6,7 @@ import { CloudControlClient, CreateResourceCommand, DeleteResourceCommand, GetRe
6
6
  import { AttachRolePolicyCommand, CreateRoleCommand, DeleteRoleCommand, DeleteRolePermissionsBoundaryCommand, DeleteRolePolicyCommand, DetachRolePolicyCommand, GetRoleCommand, GetRolePolicyCommand, IAMClient, ListAttachedRolePoliciesCommand, ListInstanceProfilesForRoleCommand, ListRolePoliciesCommand, ListRoleTagsCommand, ListRolesCommand, NoSuchEntityException, PutRolePermissionsBoundaryCommand, PutRolePolicyCommand, RemoveRoleFromInstanceProfileCommand, TagRoleCommand, UntagRoleCommand, UpdateAssumeRolePolicyCommand, UpdateRoleCommand } from "@aws-sdk/client-iam";
7
7
  import { PublishCommand, SNSClient } from "@aws-sdk/client-sns";
8
8
  import { GetFunctionUrlConfigCommand, InvokeCommand, LambdaClient, waitUntilFunctionActiveV2, waitUntilFunctionUpdatedV2 } from "@aws-sdk/client-lambda";
9
- import { GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";
9
+ import { AssumeRoleCommand, GetCallerIdentityCommand, STSClient } from "@aws-sdk/client-sts";
10
10
  import { DescribeAvailabilityZonesCommand, DescribeImagesCommand, DescribeLaunchTemplatesCommand, DescribeRouteTablesCommand, DescribeSecurityGroupsCommand, DescribeSubnetsCommand, DescribeVpcsCommand, DescribeVpnGatewaysCommand, EC2Client } from "@aws-sdk/client-ec2";
11
11
  import { DescribeTableCommand } from "@aws-sdk/client-dynamodb";
12
12
  import { GetRestApiCommand } from "@aws-sdk/client-api-gateway";
@@ -1294,6 +1294,45 @@ async function resolveBucketRegion(bucketName, opts = {}) {
1294
1294
  function clearBucketRegionCache() {
1295
1295
  cache.clear();
1296
1296
  }
1297
+ /**
1298
+ * Resolve the cdkd state bucket name + region for a sibling AWS account.
1299
+ *
1300
+ * Used by cross-account `Fn::GetStackOutput`: once the consumer's resolver
1301
+ * has assumed the producer's role, it needs to know which bucket the
1302
+ * producer's `cdkd deploy` wrote state to. cdkd's canonical bucket name
1303
+ * (since v0.7.0) is `cdkd-state-{accountId}` — region-free because S3
1304
+ * names are globally unique. The bucket's actual region is then looked
1305
+ * up via `GetBucketLocation` using the supplied (assumed) credentials.
1306
+ *
1307
+ * Why not reuse the consumer-side bucket-name resolution path: that path
1308
+ * supports legacy region-suffixed names (`cdkd-state-{accountId}-{region}`)
1309
+ * and an "empty-new-bucket" fallback, both of which require listing the
1310
+ * bucket contents to disambiguate. For cross-account reads we accept the
1311
+ * narrower scope — the producer must be on the canonical region-free
1312
+ * bucket layout (PR #60+, v0.10.0+) — because supporting the legacy
1313
+ * layout cross-account would require account-wide `s3:ListAllMyBuckets`
1314
+ * permission in the assumed role for no real-world benefit (legacy
1315
+ * accounts are nearing 5 years old; cross-account features land
1316
+ * post-legacy).
1317
+ *
1318
+ * @param accountId 12-digit AWS account ID of the producer (extracted
1319
+ * from the role ARN via `parseIamRoleArn`).
1320
+ * @param credentials Assumed credentials produced by
1321
+ * `assumeRoleForCrossAccountStateRead`. Threaded into the
1322
+ * `GetBucketLocation` call so the producer's bucket
1323
+ * policy can authorize the read against the assumed
1324
+ * principal (not the consumer's default credentials).
1325
+ *
1326
+ * @returns `{ bucket, region }` — the producer's canonical state bucket
1327
+ * name and its actual region.
1328
+ */
1329
+ async function resolveCrossAccountStateBucket(accountId, credentials) {
1330
+ const bucket = `cdkd-state-${accountId}`;
1331
+ return {
1332
+ bucket,
1333
+ region: await resolveBucketRegion(bucket, { credentials })
1334
+ };
1335
+ }
1297
1336
 
1298
1337
  //#endregion
1299
1338
  //#region src/synthesis/app-executor.ts
@@ -3591,6 +3630,18 @@ var S3StateBackend = class {
3591
3630
  this.clientOpts = clientOpts;
3592
3631
  }
3593
3632
  /**
3633
+ * Read-only accessor for the S3 key prefix this backend writes under
3634
+ * (defaults to `cdkd`). Used by the cross-account `Fn::GetStackOutput`
3635
+ * resolver when it constructs an ephemeral state backend pointed at
3636
+ * the producer account's bucket — the producer's prefix should match
3637
+ * the consumer's prefix (both sides almost always default to `cdkd`,
3638
+ * but `--state-prefix` overrides at the consumer side propagate
3639
+ * cleanly).
3640
+ */
3641
+ get prefix() {
3642
+ return this.config.prefix;
3643
+ }
3644
+ /**
3594
3645
  * Get the new (region-scoped) S3 key for a stack's state file.
3595
3646
  */
3596
3647
  getStateKey(stackName, region) {
@@ -5389,6 +5440,200 @@ var DiffCalculator = class DiffCalculator {
5389
5440
  }
5390
5441
  };
5391
5442
 
5443
+ //#endregion
5444
+ //#region src/utils/role-arn.ts
5445
+ /**
5446
+ * Process-lifetime cache of assumed credentials keyed by RoleArn.
5447
+ *
5448
+ * Storing the in-flight `Promise` (rather than the resolved value) collapses
5449
+ * concurrent first-time callers into a single `sts:AssumeRole` request. After
5450
+ * the promise resolves we keep the same entry for subsequent callers so a
5451
+ * stack that references the same producer N times via `Fn::GetStackOutput`
5452
+ * only pays the STS hop once.
5453
+ *
5454
+ * The cache is keyed by RoleArn alone (not RoleArn + region) because STS
5455
+ * credentials are global — assumed credentials work against any region's
5456
+ * service endpoint. The downstream S3 client built from these credentials
5457
+ * picks its own region via `GetBucketLocation`.
5458
+ *
5459
+ * **Expiration handling**: on every cache hit, the cached credentials'
5460
+ * `expiration` is compared against `Date.now()` with a 60-second safety
5461
+ * buffer to avoid the "STS expires 1-2s early due to clock skew" race.
5462
+ * Expired entries are evicted and a fresh AssumeRole is issued.
5463
+ *
5464
+ * **Rejection handling**: when the in-flight promise rejects (e.g.
5465
+ * transient STS throttle, AccessDenied), the cache entry is evicted so a
5466
+ * subsequent caller will retry. Without this, a single transient failure
5467
+ * would pin the rest of the deploy to the same error.
5468
+ */
5469
+ const crossAccountCredentialsCache = /* @__PURE__ */ new Map();
5470
+ /**
5471
+ * Safety buffer applied when checking whether cached credentials are still
5472
+ * valid. STS occasionally reports `Expiration` 1-2 seconds AFTER the moment
5473
+ * the token actually stops working (clock skew between AWS's auth plane and
5474
+ * the local machine), so we evict the cache entry one minute BEFORE the
5475
+ * recorded expiration to keep long-running deploys safe.
5476
+ */
5477
+ const CRED_EXPIRY_SAFETY_MS = 6e4;
5478
+ /**
5479
+ * Regex for an IAM role ARN. Accepts every published AWS partition
5480
+ * (`aws`, `aws-us-gov`, `aws-cn`, `aws-iso`, `aws-iso-b`, etc. — matched
5481
+ * loosely as `aws[a-z0-9-]*`) and any role-name shape including
5482
+ * service-linked roles with a `/path/` prefix
5483
+ * (e.g. `arn:aws:iam::111122223333:role/aws-service-role/.../AWSServiceRoleForX`).
5484
+ *
5485
+ * Capture group 1 is the partition, group 2 is the 12-digit account ID.
5486
+ */
5487
+ const IAM_ROLE_ARN_RE = /^arn:(aws[a-z0-9-]*):iam::(\d{12}):role\/[\w+=,.@-]+(?:\/[\w+=,.@-]+)*$/;
5488
+ /**
5489
+ * Parse an IAM role ARN into its component parts.
5490
+ *
5491
+ * @param roleArn The full role ARN to parse.
5492
+ * @returns `{ partition, accountId }` on success, `null` on a
5493
+ * structurally-invalid input. The caller is responsible for
5494
+ * surfacing a clear error message when this returns `null`.
5495
+ */
5496
+ function parseIamRoleArn(roleArn) {
5497
+ const match = IAM_ROLE_ARN_RE.exec(roleArn);
5498
+ if (!match || !match[1] || !match[2]) return null;
5499
+ return {
5500
+ partition: match[1],
5501
+ accountId: match[2]
5502
+ };
5503
+ }
5504
+ /**
5505
+ * Assume an IAM role across accounts and return temporary credentials for
5506
+ * reading the producer account's cdkd state bucket.
5507
+ *
5508
+ * **Why a dedicated helper (instead of reusing `applyRoleArnIfSet`).** The
5509
+ * `--role-arn` flag writes assumed credentials into the process's `AWS_*`
5510
+ * env vars so EVERY subsequent SDK client picks them up. That is the right
5511
+ * behavior for the CLI-wide flag, but the wrong behavior for cross-account
5512
+ * `Fn::GetStackOutput`: the producer's role should authorize ONLY the S3
5513
+ * state read, not the consumer's provisioning calls (which still run under
5514
+ * the consumer account's normal credentials). Threading the credentials
5515
+ * through a fresh `S3Client` via this helper keeps the scope narrow.
5516
+ *
5517
+ * **Why cache per-RoleArn for the process lifetime.** A multi-resource
5518
+ * stack typically references `Fn::GetStackOutput` from many template sites
5519
+ * (every IAM policy / Lambda env / ALB listener that pulls a shared VPC ID
5520
+ * from a platform stack). Assuming the role once per deploy is sufficient;
5521
+ * the cached credentials are valid for the STS session lifetime (default
5522
+ * 1 hour) which dwarfs the typical deploy duration.
5523
+ *
5524
+ * **Cache miss / refresh paths.** On every call we look up the cached
5525
+ * entry. If it exists AND its `Expiration` is still further in the
5526
+ * future than {@link CRED_EXPIRY_SAFETY_MS}, we return it. Otherwise the
5527
+ * entry is evicted and a fresh AssumeRole hop runs — important for
5528
+ * deploys longer than the 1-hour STS session window (multi-stack
5529
+ * `--all` runs, big Custom-Resource trees, etc.).
5530
+ *
5531
+ * **Rejection handling.** When the underlying STS call throws (e.g.
5532
+ * transient throttle, AccessDenied, trust policy mismatch), the cache
5533
+ * entry is evicted INSIDE the IIFE before the error propagates, so a
5534
+ * subsequent caller will retry the AssumeRole hop rather than getting
5535
+ * pinned to the same rejection. Concurrent first-time callers still
5536
+ * share the SAME in-flight promise (so a single failure surfaces
5537
+ * uniformly), but the next caller after rejection gets a clean slate.
5538
+ */
5539
+ async function assumeRoleForCrossAccountStateRead(roleArn) {
5540
+ const cached = crossAccountCredentialsCache.get(roleArn);
5541
+ if (cached) {
5542
+ const cachedCreds = await cached;
5543
+ if (!cachedCreds.expiration || Date.now() < cachedCreds.expiration.getTime() - CRED_EXPIRY_SAFETY_MS) return cachedCreds;
5544
+ crossAccountCredentialsCache.delete(roleArn);
5545
+ }
5546
+ const promise = (async () => {
5547
+ const logger = getLogger().child("role-arn");
5548
+ logger.debug(`Assuming role for cross-account state read: ${roleArn}`);
5549
+ const sts = new STSClient({});
5550
+ try {
5551
+ let response;
5552
+ try {
5553
+ response = await sts.send(new AssumeRoleCommand({
5554
+ RoleArn: roleArn,
5555
+ RoleSessionName: `cdkd-xacc-${Date.now()}`,
5556
+ DurationSeconds: 3600
5557
+ }));
5558
+ } catch (err) {
5559
+ const message = err instanceof Error ? err.message : String(err);
5560
+ throw new Error(`AssumeRole into ${roleArn} failed: ${message}. If this is a trust-policy issue, the producer's role must allow sts:AssumeRole from the consumer's principal. See docs/cross-stack-references.md for the trust-policy template.`, { cause: err instanceof Error ? err : void 0 });
5561
+ }
5562
+ if (!response.Credentials) throw new Error(`AssumeRole for cross-account Fn::GetStackOutput returned no credentials (RoleArn=${roleArn})`);
5563
+ const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = response.Credentials;
5564
+ if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing required credentials fields for cross-account state read (RoleArn=${roleArn})`);
5565
+ logger.info(`Assumed role for cross-account state read: ${roleArn} (session expires ${Expiration?.toISOString() ?? "unknown"})`);
5566
+ return {
5567
+ accessKeyId: AccessKeyId,
5568
+ secretAccessKey: SecretAccessKey,
5569
+ sessionToken: SessionToken,
5570
+ ...Expiration && { expiration: Expiration }
5571
+ };
5572
+ } finally {
5573
+ sts.destroy();
5574
+ }
5575
+ })().catch((err) => {
5576
+ if (crossAccountCredentialsCache.get(roleArn) === promise) crossAccountCredentialsCache.delete(roleArn);
5577
+ throw err;
5578
+ });
5579
+ crossAccountCredentialsCache.set(roleArn, promise);
5580
+ return promise;
5581
+ }
5582
+ /**
5583
+ * Resolve the role-arn argument (CLI flag or `CDKD_ROLE_ARN` env var) and,
5584
+ * when set, assume the role and write the resulting temporary credentials
5585
+ * into `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN`
5586
+ * for the rest of the process.
5587
+ *
5588
+ * **Why env vars, not threaded credentials.** cdkd constructs ~13
5589
+ * independent `AwsClients` instances across deploy / destroy / state /
5590
+ * import / etc. paths (each with its own region, sometimes — e.g. the
5591
+ * state-bucket client lives in a different region from the provisioning
5592
+ * clients). Threading a `credentials` object through every site is high
5593
+ * churn for an opt-in flag. AWS SDK v3 reads the standard `AWS_*` env
5594
+ * vars at the top of its default credentials chain, so writing into them
5595
+ * once at the command's entry makes every later `new XxxClient()` pick
5596
+ * up the assumed-role credentials automatically without touching the
5597
+ * client construction sites.
5598
+ *
5599
+ * **Why cdkd needs admin-equivalent on the assumed role.** Unlike `cdk
5600
+ * deploy`, cdkd does NOT route through CloudFormation. There is no
5601
+ * cfn-exec-role to delegate to. Every IAM / EC2 / Lambda / etc. API
5602
+ * call is issued from the cdkd process directly. The role you pass to
5603
+ * `--role-arn` (or set in `CDKD_ROLE_ARN`) MUST therefore have
5604
+ * admin-equivalent permissions on the resources being deployed; CDK
5605
+ * CLI's `cdk-hnb659fds-deploy-role-*` is NOT sufficient — that role
5606
+ * only carries CFn + asset-publish permissions.
5607
+ *
5608
+ * Default session duration is 1 hour. For longer-running deploys, the
5609
+ * caller should re-issue the cdkd command (the in-flight credentials
5610
+ * stay valid until expiry, but a re-run is the simplest recovery for
5611
+ * the rare case where a deploy outlives them).
5612
+ */
5613
+ async function applyRoleArnIfSet(opts) {
5614
+ const roleArn = opts.roleArn || process.env["CDKD_ROLE_ARN"];
5615
+ if (!roleArn) return;
5616
+ const logger = getLogger().child("role-arn");
5617
+ logger.debug(`Assuming role ${roleArn}...`);
5618
+ const sts = new STSClient({ ...opts.region && { region: opts.region } });
5619
+ try {
5620
+ const response = await sts.send(new AssumeRoleCommand({
5621
+ RoleArn: roleArn,
5622
+ RoleSessionName: `cdkd-${Date.now()}`,
5623
+ DurationSeconds: 3600
5624
+ }));
5625
+ if (!response.Credentials) throw new Error(`AssumeRole returned no credentials for role ${roleArn}`);
5626
+ const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = response.Credentials;
5627
+ if (!AccessKeyId || !SecretAccessKey || !SessionToken) throw new Error(`AssumeRole response missing credentials fields for role ${roleArn}`);
5628
+ process.env["AWS_ACCESS_KEY_ID"] = AccessKeyId;
5629
+ process.env["AWS_SECRET_ACCESS_KEY"] = SecretAccessKey;
5630
+ process.env["AWS_SESSION_TOKEN"] = SessionToken;
5631
+ logger.info(`Assumed role ${roleArn} (session expires ${Expiration?.toISOString() ?? "unknown"})`);
5632
+ } finally {
5633
+ sts.destroy();
5634
+ }
5635
+ }
5636
+
5392
5637
  //#endregion
5393
5638
  //#region src/deployment/intrinsic-function-resolver.ts
5394
5639
  /**
@@ -6122,7 +6367,8 @@ var IntrinsicFunctionResolver = class {
6122
6367
  });
6123
6368
  }
6124
6369
  /**
6125
- * Resolve Fn::GetStackOutput (cross-stack / cross-region output reference)
6370
+ * Resolve Fn::GetStackOutput (cross-stack / cross-region / cross-account
6371
+ * output reference).
6126
6372
  *
6127
6373
  * Shape: { "Fn::GetStackOutput": { "StackName": "...", "OutputName": "...",
6128
6374
  * "Region": "...", "RoleArn": "..." } }
@@ -6133,11 +6379,20 @@ var IntrinsicFunctionResolver = class {
6133
6379
  * `s3://{bucket}/cdkd/{StackName}/{Region}/state.json`. When `Region` is
6134
6380
  * omitted, the consumer's deploy region is used.
6135
6381
  *
6136
- * RoleArn (cross-account) is intentionally rejected — cdkd uses S3 state,
6137
- * not CloudFormation DescribeStacks, so a cross-account reference would
6138
- * require assuming the role and reading the producer's separate state
6139
- * bucket. That path is not yet implemented; we surface a clear error
6140
- * instead of silently downgrading.
6382
+ * **RoleArn (cross-account)**: when set, cdkd issues `sts:AssumeRole`
6383
+ * against the supplied role and reads the PRODUCER ACCOUNT's separate
6384
+ * cdkd state bucket (`cdkd-state-{producerAccountId}`) bucket name
6385
+ * derived from the role ARN's account ID and the canonical
6386
+ * region-free bucket convention. The assumed credentials are cached
6387
+ * per-RoleArn for the deploy lifetime so a stack that references the
6388
+ * same producer multiple times only pays one STS hop. **The inline
6389
+ * `RoleArn` argument is constrained to literal strings only** — no
6390
+ * `Ref` / `Fn::GetAtt` / `Fn::Sub` chains — because the resolver
6391
+ * context isn't guaranteed to have the producer-account info available
6392
+ * at intrinsic-resolution time and a typo'd role lookup is far worse
6393
+ * than a clear "literal-string required" error at template-author
6394
+ * time. Same-account references (no RoleArn) take the original
6395
+ * shared-state-backend path.
6141
6396
  */
6142
6397
  async resolveGetStackOutput(arg, context) {
6143
6398
  if (!arg || typeof arg !== "object" || Array.isArray(arg)) throw new Error(`Fn::GetStackOutput: argument must be an object with StackName/OutputName/Region/RoleArn, got ${arg === null ? "null" : Array.isArray(arg) ? "array" : typeof arg}`);
@@ -6156,26 +6411,90 @@ var IntrinsicFunctionResolver = class {
6156
6411
  }
6157
6412
  let roleArn;
6158
6413
  if ("RoleArn" in args && args["RoleArn"] !== void 0 && args["RoleArn"] !== null) {
6159
- const resolvedRoleArn = await this.resolveValue(args["RoleArn"], context);
6160
- if (typeof resolvedRoleArn !== "string" || resolvedRoleArn === "") throw new Error(`Fn::GetStackOutput: RoleArn must resolve to a non-empty string, got ${typeof resolvedRoleArn}`);
6161
- roleArn = resolvedRoleArn;
6162
- }
6163
- if (roleArn) throw new Error(`Fn::GetStackOutput: cross-account references via RoleArn are not yet supported by cdkd (StackName=${stackName}, Region=${region}, RoleArn=${roleArn}). cdkd reads outputs from S3 state instead of CloudFormation DescribeStacks, so cross-account requires assuming the role and reading the producer account's state bucket — not yet implemented.`);
6164
- if (!context.stateBackend) throw new Error("Fn::GetStackOutput: state backend is required for cross-stack references");
6165
- if (context.stackName && context.stackName === stackName && region === this.resolverRegion) throw new Error(`Fn::GetStackOutput: cannot reference own stack '${stackName}' in the same region '${region}'`);
6166
- this.logger.debug(`Resolving Fn::GetStackOutput: StackName=${stackName}, Region=${region}, OutputName=${outputName}`);
6167
- const stateData = await context.stateBackend.getState(stackName, region);
6168
- if (!stateData) throw new Error(`Fn::GetStackOutput: stack '${stackName}' not found in region '${region}'. Make sure the producer stack has been deployed via cdkd.`);
6414
+ const raw = args["RoleArn"];
6415
+ if (typeof raw !== "string" || raw === "") throw new Error(`Fn::GetStackOutput: RoleArn must be a literal string in the template (no Ref / Fn::GetAtt / Fn::Sub allowed for cross-account references). Got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw}${typeof raw === "object" ? ` (intrinsic shape: ${JSON.stringify(raw).slice(0, 80)})` : ""}.`);
6416
+ roleArn = raw;
6417
+ }
6418
+ if (!roleArn && context.stackName && context.stackName === stackName && region === this.resolverRegion) throw new Error(`Fn::GetStackOutput: cannot reference own stack '${stackName}' in the same region '${region}'`);
6419
+ this.logger.debug(`Resolving Fn::GetStackOutput: StackName=${stackName}, Region=${region}, OutputName=${outputName}${roleArn ? `, RoleArn=${roleArn}` : ""}`);
6420
+ const stateData = roleArn ? await this.getCrossAccountStackState(roleArn, stackName, region, context) : await this.getSameAccountStackState(stackName, region, context);
6421
+ if (!stateData) throw new Error(`Fn::GetStackOutput: stack '${stackName}' not found in region '${region}'${roleArn ? ` (cross-account via ${roleArn})` : ""}. Make sure the producer stack has been deployed via cdkd.`);
6169
6422
  const outputs = stateData.state.outputs ?? {};
6170
6423
  if (!(outputName in outputs)) {
6171
6424
  const available = Object.keys(outputs).join(", ") || "(none)";
6172
6425
  throw new Error(`Fn::GetStackOutput: output '${outputName}' not found in stack '${stackName}' (${region}). Available outputs: ${available}`);
6173
6426
  }
6174
6427
  const value = outputs[outputName];
6175
- this.logger.info(`Resolved Fn::GetStackOutput: StackName=${stackName}, Region=${region}, OutputName=${outputName} -> ${JSON.stringify(value)}`);
6428
+ this.logger.info(`Resolved Fn::GetStackOutput: StackName=${stackName}, Region=${region}, OutputName=${outputName}${roleArn ? `, RoleArn=${roleArn}` : ""} -> ${JSON.stringify(value)}`);
6176
6429
  return value;
6177
6430
  }
6178
6431
  /**
6432
+ * Read the producer's state from the SAME AWS account (no RoleArn).
6433
+ *
6434
+ * Uses the consumer's shared `context.stateBackend` — the same backend
6435
+ * the consumer used to read / write its own state. The same-account
6436
+ * path covers cross-region cleanly because the bucket name is
6437
+ * account-scoped (not region-scoped).
6438
+ */
6439
+ async getSameAccountStackState(stackName, region, context) {
6440
+ if (!context.stateBackend) throw new Error("Fn::GetStackOutput: state backend is required for cross-stack references");
6441
+ return context.stateBackend.getState(stackName, region);
6442
+ }
6443
+ /**
6444
+ * Read the producer's state from a DIFFERENT AWS account (RoleArn set).
6445
+ *
6446
+ * Pipeline:
6447
+ * 1. Parse `roleArn` for the producer's account id (rejects malformed
6448
+ * ARNs up front with a clear message — no opaque STS error later).
6449
+ * 2. `sts:AssumeRole` against `roleArn`, cached per role for the
6450
+ * deploy lifetime (typical: 1 STS hop covering many `Fn::GetStackOutput`
6451
+ * sites in the same deploy).
6452
+ * 3. Derive the producer's canonical state bucket
6453
+ * (`cdkd-state-{producerAccountId}`) and auto-detect its region
6454
+ * via `GetBucketLocation` with the assumed credentials.
6455
+ * 4. Build a fresh, narrowly-scoped `S3StateBackend` against that
6456
+ * bucket with the assumed credentials and call `getState` —
6457
+ * reuses the entire state-parsing + schema-version-tolerance
6458
+ * machinery (legacy `version: 1` keys, migration warnings, etc.).
6459
+ *
6460
+ * The constructed `S3Client` and backend live only for the duration of
6461
+ * this call. cdkd does NOT mutate the process's `AWS_*` env vars (that
6462
+ * would leak the assumed credentials into every subsequent provisioning
6463
+ * client — opposite of what we want; provisioning still runs under the
6464
+ * consumer's normal credentials).
6465
+ */
6466
+ async getCrossAccountStackState(roleArn, stackName, region, context) {
6467
+ const parsed = parseIamRoleArn(roleArn);
6468
+ if (!parsed) throw new Error(`Fn::GetStackOutput: RoleArn '${roleArn}' is not a valid IAM role ARN. Expected shape: arn:<partition>:iam::<12-digit-account-id>:role/<role-name> (e.g. arn:aws:iam::123456789012:role/MyRole, arn:aws-us-gov:iam::...).`);
6469
+ const credentials = await assumeRoleForCrossAccountStateRead(roleArn);
6470
+ const { bucket, region: bucketRegion } = await resolveCrossAccountStateBucket(parsed.accountId, credentials);
6471
+ const prefix = context.stateBackend?.prefix ?? "cdkd";
6472
+ return new S3StateBackend(new S3Client({
6473
+ region: bucketRegion,
6474
+ credentials: {
6475
+ accessKeyId: credentials.accessKeyId,
6476
+ secretAccessKey: credentials.secretAccessKey,
6477
+ sessionToken: credentials.sessionToken
6478
+ },
6479
+ logger: {
6480
+ debug: () => {},
6481
+ info: () => {},
6482
+ warn: () => {},
6483
+ error: () => {}
6484
+ }
6485
+ }), {
6486
+ bucket,
6487
+ prefix
6488
+ }, {
6489
+ region: bucketRegion,
6490
+ credentials: {
6491
+ accessKeyId: credentials.accessKeyId,
6492
+ secretAccessKey: credentials.secretAccessKey,
6493
+ sessionToken: credentials.sessionToken
6494
+ }
6495
+ }).getState(stackName, region);
6496
+ }
6497
+ /**
6179
6498
  * Resolve Fn::FindInMap intrinsic function
6180
6499
  *
6181
6500
  * Fn::FindInMap: [MapName, TopLevelKey, SecondLevelKey]
@@ -9856,5 +10175,5 @@ var DeployEngine = class {
9856
10175
  };
9857
10176
 
9858
10177
  //#endregion
9859
- export { LocalInvokeBuildError as $, stringifyValue as A, resolveApp as B, DiffCalculator as C, withSkipPrefix as Ct, S3StateBackend as D, LockManager as E, runDockerForeground as F, warnDeprecatedNoPrefixCliFlag as G, resolveSkipPrefix as H, runDockerStreaming as I, resolveBucketRegion as J, AssemblyReader as K, Synthesizer as L, buildDockerImage as M, formatDockerLoginError as N, shouldRetainResource as O, getDockerCmd as P, DependencyError as Q, getDefaultStateBucketName as R, IntrinsicFunctionResolver as S, generateResourceNameWithFallback as St, TemplateParser as T, resolveStateBucketWithDefault as U, resolveCaptureObservedState as V, resolveStateBucketWithDefaultAndSource as W, CdkdError as X, AssetError as Y, ConfigError as Z, normalizeAwsTagsToCfn as _, runStackBuffered as _t, withRetry as a, RouteDiscoveryError as at, CloudControlProvider as b, PATTERN_B_RESOURCE_TYPES as bt, cyan as c, StateError as ct, red as d, isCdkdError as dt, LockError as et, yellow as f, normalizeAwsError as ft, matchesCdkPath as g, setLogger as gt, CDK_PATH_TAG as h, getLogger as ht, withResourceDeadline as i, ResourceUpdateNotSupportedError as it, WorkGraph as j, AssetPublisher as k, gray as l, SynthesisError as lt, collectInlinePolicyNamesManagedBySiblings as m, ConsoleLogger as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, ProvisioningError as nt, IMPLICIT_DELETE_DEPENDENCIES as o, StackHasActiveImportsError as ot, IAMRoleProvider as p, withErrorHandling as pt, clearBucketRegionCache as q, DeployEngine as r, ResourceTimeoutError as rt, bold as s, StackTerminationProtectionError as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, PartialFailureError as tt, green as u, formatError as ut, resolveExplicitPhysicalId as v, getLiveRenderer as vt, DagBuilder as w, withStackName as wt, assertRegionMatch as x, generateResourceName as xt, ProviderRegistry as y, PATTERN_B_NAME_PROPERTIES as yt, getLegacyStateBucketName as z };
9860
- //# sourceMappingURL=deploy-engine-B2RZT3ai.js.map
10178
+ export { DependencyError as $, AssetPublisher as A, getLegacyStateBucketName as B, applyRoleArnIfSet as C, generateResourceNameWithFallback as Ct, LockManager as D, TemplateParser as E, getDockerCmd as F, resolveStateBucketWithDefaultAndSource as G, resolveCaptureObservedState as H, runDockerForeground as I, clearBucketRegionCache as J, warnDeprecatedNoPrefixCliFlag as K, runDockerStreaming as L, WorkGraph as M, buildDockerImage as N, S3StateBackend as O, formatDockerLoginError as P, ConfigError as Q, Synthesizer as R, IntrinsicFunctionResolver as S, generateResourceName as St, DagBuilder as T, withStackName as Tt, resolveSkipPrefix as U, resolveApp as V, resolveStateBucketWithDefault as W, AssetError as X, resolveBucketRegion as Y, CdkdError as Z, normalizeAwsTagsToCfn as _, setLogger as _t, withRetry as a, ResourceUpdateNotSupportedError as at, CloudControlProvider as b, PATTERN_B_NAME_PROPERTIES as bt, cyan as c, StackTerminationProtectionError as ct, red as d, formatError as dt, LocalInvokeBuildError as et, yellow as f, isCdkdError as ft, matchesCdkPath as g, getLogger as gt, CDK_PATH_TAG as h, ConsoleLogger as ht, withResourceDeadline as i, ResourceTimeoutError as it, stringifyValue as j, shouldRetainResource as k, gray as l, StateError as lt, collectInlinePolicyNamesManagedBySiblings as m, withErrorHandling as mt, DEFAULT_RESOURCE_WARN_AFTER_MS as n, PartialFailureError as nt, IMPLICIT_DELETE_DEPENDENCIES as o, RouteDiscoveryError as ot, IAMRoleProvider as p, normalizeAwsError as pt, AssemblyReader as q, DeployEngine as r, ProvisioningError as rt, bold as s, StackHasActiveImportsError as st, DEFAULT_RESOURCE_TIMEOUT_MS as t, LockError as tt, green as u, SynthesisError as ut, resolveExplicitPhysicalId as v, runStackBuffered as vt, DiffCalculator as w, withSkipPrefix as wt, assertRegionMatch as x, PATTERN_B_RESOURCE_TYPES as xt, ProviderRegistry as y, getLiveRenderer as yt, getDefaultStateBucketName as z };
10179
+ //# sourceMappingURL=deploy-engine-DWpeb9wT.js.map