@go-to-k/cdkd 0.121.0 → 0.122.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.
package/README.md CHANGED
@@ -25,7 +25,7 @@ Drop-in CDK CLI for existing CDK apps — faster deploys via AWS SDK instead of
25
25
  - **Rollback on failure**: When a deploy errors mid-stack, cdkd rolls back the resources it just created so the stack state stays consistent (CloudFormation parity — but cdkd does this without round-tripping through CFn). Pass `cdkd deploy --no-rollback` to skip rollback and keep the partial state for Terraform-style inspection / repair. See [Rollback behavior](#rollback-behavior).
26
26
  - **`--no-wait` for async resources**: Skip the multi-minute wait on CloudFront / RDS / ElastiCache / NAT Gateway and return as soon as the create call returns (CloudFormation always blocks)
27
27
  - **VPC route DependsOn relaxation (on by default)**: Drop CDK-injected defensive `DependsOn` edges from VPC Lambdas onto private-subnet routes so `CloudFront::Distribution` and `Lambda::Url` start their ~3-min propagation in parallel with NAT Gateway stabilization (~50% faster on VPC + Lambda + CloudFront stacks). Pass `--no-aggressive-vpc-parallel` to opt out.
28
- - **Local execution without deploying** (`cdkd local invoke` / `cdkd local start-api` / `cdkd local run-task`): run any Lambda — stand up every API Gateway route as a local HTTP server — or start every container in an `AWS::ECS::TaskDefinition` on a per-task docker network with the AWS-published metadata-endpoints sidecar. SAM-compatible mental model but reuses cdkd's synthesis / asset / route-discovery (no `template.yaml` round-trip). All AWS Lambda runtimes (Node.js / Python / Ruby / Java / .NET / `provided.*`) and one server per discovered API (HTTP API v2 / REST v1 / Function URL) with their own port / authorizers / CORS configs. `local run-task` is Phase 1 (single task, DependsOn ordering, IAM task-role via AssumeRole) — ECS Services / ALB routing / Service Connect are Phase 2 / Phase 3 follow-ups. `cdkd local run-task --from-state` substitutes intrinsic-valued container `Environment[].Value` (`Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::Join`) and `Secrets[].ValueFrom` against the deployed cdkd state — `table.tableName` / `ecs.Secret.fromSecretsManager(secret)` / `ecs.Secret.fromSsmParameter(param)` Just Work locally instead of silently dropping.
28
+ - **Local execution without deploying** (`cdkd local invoke` / `cdkd local start-api` / `cdkd local run-task`): run any Lambda — stand up every API Gateway route as a local HTTP server — or start every container in an `AWS::ECS::TaskDefinition` on a per-task docker network with the AWS-published metadata-endpoints sidecar. SAM-compatible mental model but reuses cdkd's synthesis / asset / route-discovery (no `template.yaml` round-trip). All AWS Lambda runtimes (Node.js / Python / Ruby / Java / .NET / `provided.*`) and one server per discovered API (HTTP API v2 / REST v1 / Function URL) with their own port / authorizers / CORS configs. `local run-task` is Phase 1 (single task, DependsOn ordering, IAM task-role via AssumeRole) — ECS Services / ALB routing / Service Connect are Phase 2 / Phase 3 follow-ups. `cdkd local run-task --from-state` substitutes intrinsic-valued container `Environment[].Value` (`Ref` / `Fn::GetAtt` / `Fn::Sub` / `Fn::Join` / `Fn::ImportValue` / `Fn::GetStackOutput`) and `Secrets[].ValueFrom` against the deployed cdkd state — `table.tableName` / `ecs.Secret.fromSecretsManager(secret)` / `ecs.Secret.fromSsmParameter(param)` / cross-stack output refs Just Work locally instead of silently dropping.
29
29
  - **Bidirectional CloudFormation migration**: `cdkd import` adopts AWS-deployed resources (including `cdk deploy`-managed CloudFormation stacks via `--migrate-from-cloudformation`) into cdkd state without re-creating them; `cdkd export` hands a cdkd-managed stack back to CloudFormation when you're ready to move to production. See [Importing existing resources](#importing-existing-resources) and [Exporting a stack back to CloudFormation](#exporting-a-stack-back-to-cloudformation).
30
30
 
31
31
  > **Note**: Resource types not covered by either SDK Providers or Cloud Control API cannot be deployed with cdkd. If you encounter an unsupported resource type, deployment will fail with a clear error message.
@@ -427,8 +427,12 @@ maintain, no `cdk synth | sam ...` round-trip.
427
427
  | `cdkd local run-task <target>` | ECS RunTask — every container in a task definition started on a per-task docker network |
428
428
 
429
429
  Requires Docker. Pass `--from-state` to substitute deployed physical
430
- IDs into intrinsic-valued properties; without it, intrinsic values are
431
- dropped with a per-key warning (matches `sam local *` semantics).
430
+ IDs into intrinsic-valued properties (`Ref` / `Fn::GetAtt` / `Fn::Sub` /
431
+ `Fn::Join` against the same stack's state plus AWS pseudo parameters
432
+ via STS, AND `Fn::ImportValue` / `Fn::GetStackOutput` against the
433
+ persistent exports index for cross-stack references — same-account /
434
+ same-region); without it, intrinsic values are dropped with a per-key
435
+ warning (matches `sam local *` semantics).
432
436
 
433
437
  ### `local invoke`
434
438
 
package/dist/cli.js CHANGED
@@ -35768,6 +35768,119 @@ async function loadStateForStack(stackName, synthRegion, opts) {
35768
35768
  resetAwsClients();
35769
35769
  }
35770
35770
  }
35771
+ /**
35772
+ * Build a {@link CrossStackResolver} that walks cdkd's S3 state to look
35773
+ * up `Fn::ImportValue` / `Fn::GetStackOutput` references the same way
35774
+ * `cdkd deploy`'s `IntrinsicFunctionResolver` does. Returns `undefined`
35775
+ * when the state bucket cannot be resolved (warn + fall back; matches
35776
+ * `loadStateForStack`'s policy).
35777
+ *
35778
+ * The returned `dispose` closes the AWS clients owned by the resolver
35779
+ * when the caller is done — callers MUST call it (typically in a
35780
+ * `try / finally`) so the per-request S3 client isn't leaked across the
35781
+ * CLI's lifetime.
35782
+ *
35783
+ * Why a separate AwsClients instance from `loadStateForStack`: the
35784
+ * existing helper destroys its clients in a `finally` immediately after
35785
+ * loading the consumer stack's state. The cross-stack resolver lives
35786
+ * longer — every env-var that references a cross-stack output triggers a
35787
+ * new state read. Owning a fresh `AwsClients` here gives the resolver
35788
+ * an independent lifetime managed by the caller.
35789
+ *
35790
+ * Same-account / same-region only in v1 (the resolver's `producerRegion`
35791
+ * arg is honored, but only for state lookups within the same cdkd state
35792
+ * bucket). Cross-region `Fn::ImportValue` is tracked under #451;
35793
+ * cross-account `Fn::GetStackOutput.RoleArn` is tracked under #449.
35794
+ */
35795
+ async function buildCrossStackResolver(consumerRegion, opts) {
35796
+ const logger = getLogger();
35797
+ const prefix = opts.logPrefix ?? "--from-state";
35798
+ let stateBucket;
35799
+ try {
35800
+ stateBucket = await resolveStateBucketWithDefault(opts.stateBucket, consumerRegion);
35801
+ } catch (err) {
35802
+ logger.warn(`${prefix}: cross-stack resolver could not resolve state bucket: ${err instanceof Error ? err.message : String(err)}. Fn::ImportValue / Fn::GetStackOutput env entries will warn-and-drop.`);
35803
+ return;
35804
+ }
35805
+ const awsClients = new AwsClients({
35806
+ ...opts.region !== void 0 && { region: opts.region },
35807
+ ...opts.profile !== void 0 && { profile: opts.profile }
35808
+ });
35809
+ const stateConfig = {
35810
+ bucket: stateBucket,
35811
+ prefix: opts.statePrefix
35812
+ };
35813
+ const stateBackend = new S3StateBackend(awsClients.s3, stateConfig, {
35814
+ ...opts.region !== void 0 && { region: opts.region },
35815
+ ...opts.profile !== void 0 && { profile: opts.profile }
35816
+ });
35817
+ try {
35818
+ await stateBackend.verifyBucketExists();
35819
+ } catch (err) {
35820
+ awsClients.destroy();
35821
+ logger.warn(`${prefix}: cross-stack resolver could not access state bucket '${stateBucket}': ${err instanceof Error ? err.message : String(err)}. Fn::ImportValue / Fn::GetStackOutput env entries will warn-and-drop.`);
35822
+ return;
35823
+ }
35824
+ const exportIndex = new ExportIndexStore(awsClients.s3, stateBucket, opts.statePrefix, consumerRegion, stateBackend);
35825
+ return {
35826
+ resolver: {
35827
+ async resolveImport(exportName) {
35828
+ try {
35829
+ const entry = await exportIndex.lookup(exportName);
35830
+ if (entry) {
35831
+ const value = entry.value;
35832
+ if (typeof value === "string") return value;
35833
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
35834
+ return JSON.stringify(value);
35835
+ }
35836
+ } catch (err) {
35837
+ logger.debug(`${prefix}: exports index lookup failed for '${exportName}': ${err instanceof Error ? err.message : String(err)}; falling back to per-stack state scan`);
35838
+ }
35839
+ let refs;
35840
+ try {
35841
+ refs = await stateBackend.listStacks();
35842
+ } catch (err) {
35843
+ logger.debug(`${prefix}: failed to list stacks during Fn::ImportValue fallback for '${exportName}': ${err instanceof Error ? err.message : String(err)}`);
35844
+ return;
35845
+ }
35846
+ for (const ref of refs) {
35847
+ const region = ref.region ?? consumerRegion;
35848
+ if (region !== consumerRegion) continue;
35849
+ try {
35850
+ const got = await stateBackend.getState(ref.stackName, region);
35851
+ if (!got || !got.state.outputs) continue;
35852
+ if (exportName in got.state.outputs) {
35853
+ const value = got.state.outputs[exportName];
35854
+ if (typeof value === "string") return value;
35855
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
35856
+ return JSON.stringify(value);
35857
+ }
35858
+ } catch (err) {
35859
+ logger.debug(`${prefix}: state read failed for ${ref.stackName} (${region}) during Fn::ImportValue fallback: ${err instanceof Error ? err.message : String(err)}`);
35860
+ continue;
35861
+ }
35862
+ }
35863
+ },
35864
+ async resolveGetStackOutput(producerStack, producerRegion, outputName) {
35865
+ try {
35866
+ const got = await stateBackend.getState(producerStack, producerRegion);
35867
+ if (!got || !got.state.outputs) return void 0;
35868
+ if (!(outputName in got.state.outputs)) return void 0;
35869
+ const value = got.state.outputs[outputName];
35870
+ if (typeof value === "string") return value;
35871
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
35872
+ return JSON.stringify(value);
35873
+ } catch (err) {
35874
+ logger.debug(`${prefix}: state read failed for Fn::GetStackOutput '${producerStack}.${outputName}' (${producerRegion}): ${err instanceof Error ? err.message : String(err)}`);
35875
+ return;
35876
+ }
35877
+ }
35878
+ },
35879
+ dispose: () => {
35880
+ awsClients.destroy();
35881
+ }
35882
+ };
35883
+ }
35771
35884
 
35772
35885
  //#endregion
35773
35886
  //#region src/local/intrinsic-image.ts
@@ -36733,6 +36846,159 @@ function resolveJoin(arg, context) {
36733
36846
  };
36734
36847
  }
36735
36848
  /**
36849
+ * Async sibling of {@link substituteAgainstState}. Same semantics for every
36850
+ * intrinsic the sync path supports; additionally consults the
36851
+ * `crossStackResolver` (when supplied on the context) for `Fn::ImportValue`
36852
+ * and `Fn::GetStackOutput`.
36853
+ *
36854
+ * Callers that don't need cross-stack support should keep using the sync
36855
+ * helper. Code paths that wire `--from-state` env / secret substitution
36856
+ * (e.g. `cdkd local invoke --from-state`, `cdkd local run-task --from-state`)
36857
+ * route through this async version so a single env-var referencing a
36858
+ * cross-stack output is no longer warn-and-dropped.
36859
+ */
36860
+ async function substituteAgainstStateAsync(value, contextOrResources) {
36861
+ const context = isContext(contextOrResources) ? contextOrResources : { resources: contextOrResources };
36862
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return {
36863
+ kind: "literal",
36864
+ value
36865
+ };
36866
+ if (value === null || typeof value !== "object") return {
36867
+ kind: "unresolved",
36868
+ reason: `unsupported value type: ${value === null ? "null" : typeof value}`
36869
+ };
36870
+ const obj = value;
36871
+ const keys = Object.keys(obj);
36872
+ if (keys.length !== 1) return {
36873
+ kind: "unresolved",
36874
+ reason: `expected an intrinsic with one key, got ${keys.length} keys`
36875
+ };
36876
+ const intrinsic = keys[0];
36877
+ const arg = obj[intrinsic];
36878
+ if (intrinsic === "Ref" || intrinsic === "Fn::GetAtt" || intrinsic === "Fn::Sub" || intrinsic === "Fn::Join") return substituteAgainstState(value, context);
36879
+ if (intrinsic === "Fn::ImportValue") return resolveImportValueAsync(arg, context);
36880
+ if (intrinsic === "Fn::GetStackOutput") return resolveGetStackOutputAsync(arg, context);
36881
+ return {
36882
+ kind: "unresolved",
36883
+ reason: `unsupported intrinsic '${intrinsic}' (supported: Ref, Fn::GetAtt, Fn::Sub, Fn::Join, Fn::ImportValue, Fn::GetStackOutput)`
36884
+ };
36885
+ }
36886
+ /**
36887
+ * `Fn::ImportValue: <exportName>` — the argument may itself be an
36888
+ * intrinsic that resolves to a string (e.g.
36889
+ * `{Fn::ImportValue: {Fn::Sub: 'MyStack-${AWS::Region}-Bucket'}}` — the
36890
+ * inner `Fn::Sub` is resolved against `pseudoParameters` first, then the
36891
+ * resulting string is looked up via the cross-stack resolver).
36892
+ */
36893
+ async function resolveImportValueAsync(arg, context) {
36894
+ const inner = substituteAgainstState(arg, context);
36895
+ if (inner.kind !== "literal") return {
36896
+ kind: "unresolved",
36897
+ reason: `Fn::ImportValue argument: ${inner.reason}`
36898
+ };
36899
+ if (typeof inner.value !== "string" || inner.value.length === 0) return {
36900
+ kind: "unresolved",
36901
+ reason: `Fn::ImportValue argument must resolve to a non-empty string, got ${typeof inner.value}`
36902
+ };
36903
+ const exportName = inner.value;
36904
+ if (!context.crossStackResolver) return {
36905
+ kind: "unresolved",
36906
+ reason: `Fn::ImportValue '${exportName}': no cross-stack resolver supplied (pass --from-state and ensure the producer stack was deployed via cdkd deploy)`
36907
+ };
36908
+ let resolved;
36909
+ try {
36910
+ resolved = await context.crossStackResolver.resolveImport(exportName);
36911
+ } catch (err) {
36912
+ return {
36913
+ kind: "unresolved",
36914
+ reason: `Fn::ImportValue '${exportName}': lookup failed: ${err instanceof Error ? err.message : String(err)}`
36915
+ };
36916
+ }
36917
+ if (resolved === void 0) return {
36918
+ kind: "unresolved",
36919
+ reason: `Fn::ImportValue '${exportName}': export not found in any cdkd-managed stack in this region`
36920
+ };
36921
+ return {
36922
+ kind: "literal",
36923
+ value: resolved
36924
+ };
36925
+ }
36926
+ /**
36927
+ * `Fn::GetStackOutput: { StackName, OutputName, Region? }`. Same shape as
36928
+ * the deploy-engine resolver. `RoleArn` (cross-account) is intentionally
36929
+ * NOT supported in this path — the user-visible error message points at
36930
+ * the followup issue tracking it.
36931
+ */
36932
+ async function resolveGetStackOutputAsync(arg, context) {
36933
+ if (!arg || typeof arg !== "object" || Array.isArray(arg)) return {
36934
+ kind: "unresolved",
36935
+ reason: `Fn::GetStackOutput argument must be an object with StackName / OutputName / Region, got ${arg === null ? "null" : Array.isArray(arg) ? "array" : typeof arg}`
36936
+ };
36937
+ const args = arg;
36938
+ const stackNameSub = substituteAgainstState(args["StackName"], context);
36939
+ if (stackNameSub.kind !== "literal") return {
36940
+ kind: "unresolved",
36941
+ reason: `Fn::GetStackOutput.StackName: ${stackNameSub.reason}`
36942
+ };
36943
+ if (typeof stackNameSub.value !== "string" || stackNameSub.value.length === 0) return {
36944
+ kind: "unresolved",
36945
+ reason: `Fn::GetStackOutput.StackName must resolve to a non-empty string, got ${typeof stackNameSub.value}`
36946
+ };
36947
+ const stackName = stackNameSub.value;
36948
+ const outputNameSub = substituteAgainstState(args["OutputName"], context);
36949
+ if (outputNameSub.kind !== "literal") return {
36950
+ kind: "unresolved",
36951
+ reason: `Fn::GetStackOutput.OutputName: ${outputNameSub.reason}`
36952
+ };
36953
+ if (typeof outputNameSub.value !== "string" || outputNameSub.value.length === 0) return {
36954
+ kind: "unresolved",
36955
+ reason: `Fn::GetStackOutput.OutputName must resolve to a non-empty string, got ${typeof outputNameSub.value}`
36956
+ };
36957
+ const outputName = outputNameSub.value;
36958
+ let region;
36959
+ if (args["Region"] !== void 0 && args["Region"] !== null) {
36960
+ const regionSub = substituteAgainstState(args["Region"], context);
36961
+ if (regionSub.kind !== "literal") return {
36962
+ kind: "unresolved",
36963
+ reason: `Fn::GetStackOutput.Region: ${regionSub.reason}`
36964
+ };
36965
+ if (typeof regionSub.value !== "string" || regionSub.value.length === 0) return {
36966
+ kind: "unresolved",
36967
+ reason: `Fn::GetStackOutput.Region must resolve to a non-empty string, got ${typeof regionSub.value}`
36968
+ };
36969
+ region = regionSub.value;
36970
+ } else region = context.consumerRegion ?? context.pseudoParameters?.region;
36971
+ if (!region) return {
36972
+ kind: "unresolved",
36973
+ reason: `Fn::GetStackOutput '${stackName}.${outputName}': no Region supplied and consumer region is unknown (set --region, AWS_REGION, or env.region on the CDK stack)`
36974
+ };
36975
+ if (args["RoleArn"] !== void 0 && args["RoleArn"] !== null) return {
36976
+ kind: "unresolved",
36977
+ reason: `Fn::GetStackOutput '${stackName}.${outputName}': RoleArn (cross-account) is not yet supported by --from-state — tracked under issue #449`
36978
+ };
36979
+ if (!context.crossStackResolver) return {
36980
+ kind: "unresolved",
36981
+ reason: `Fn::GetStackOutput '${stackName}.${outputName}': no cross-stack resolver supplied (pass --from-state and ensure the producer stack was deployed via cdkd deploy)`
36982
+ };
36983
+ let resolved;
36984
+ try {
36985
+ resolved = await context.crossStackResolver.resolveGetStackOutput(stackName, region, outputName);
36986
+ } catch (err) {
36987
+ return {
36988
+ kind: "unresolved",
36989
+ reason: `Fn::GetStackOutput '${stackName}.${outputName}' (${region}): lookup failed: ${err instanceof Error ? err.message : String(err)}`
36990
+ };
36991
+ }
36992
+ if (resolved === void 0) return {
36993
+ kind: "unresolved",
36994
+ reason: `Fn::GetStackOutput '${stackName}.${outputName}' (${region}): output not found in producer stack state`
36995
+ };
36996
+ return {
36997
+ kind: "literal",
36998
+ value: resolved
36999
+ };
37000
+ }
37001
+ /**
36736
37002
  * Build a pre-substituted env map from the template entry by feeding each
36737
37003
  * intrinsic value through `substituteAgainstState`. Literal entries pass
36738
37004
  * through untouched (the env-resolver handles them).
@@ -36773,6 +37039,50 @@ function substituteEnvVarsFromState(templateEnv, contextOrResources) {
36773
37039
  audit
36774
37040
  };
36775
37041
  }
37042
+ /**
37043
+ * Async sibling of {@link substituteEnvVarsFromState}. Routes every
37044
+ * intrinsic-valued entry through {@link substituteAgainstStateAsync} so
37045
+ * `Fn::ImportValue` / `Fn::GetStackOutput` resolve via the context's
37046
+ * `crossStackResolver` (when supplied). Mirrors the sync version in every
37047
+ * other respect: literal entries pass through unchanged, unresolved
37048
+ * entries are dropped with a per-key audit reason, and the env-resolver
37049
+ * sees the same "no template value" shape so the warn-and-drop path
37050
+ * fires consistently.
37051
+ *
37052
+ * Closes issue #454 — `cdkd local invoke --from-state` and
37053
+ * `cdkd local run-task --from-state` can now resolve cross-stack output
37054
+ * references in env vars / secrets instead of warn-and-dropping them.
37055
+ */
37056
+ async function substituteEnvVarsFromStateAsync(templateEnv, contextOrResources) {
37057
+ const env = {};
37058
+ const audit = {
37059
+ resolvedKeys: [],
37060
+ unresolved: []
37061
+ };
37062
+ if (!templateEnv) return {
37063
+ env,
37064
+ audit
37065
+ };
37066
+ const context = isContext(contextOrResources) ? contextOrResources : { resources: contextOrResources };
37067
+ for (const [key, value] of Object.entries(templateEnv)) {
37068
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
37069
+ env[key] = value;
37070
+ continue;
37071
+ }
37072
+ const result = await substituteAgainstStateAsync(value, context);
37073
+ if (result.kind === "literal") {
37074
+ env[key] = result.value;
37075
+ audit.resolvedKeys.push(key);
37076
+ } else audit.unresolved.push({
37077
+ key,
37078
+ reason: result.reason
37079
+ });
37080
+ }
37081
+ return {
37082
+ env,
37083
+ audit
37084
+ };
37085
+ }
36776
37086
 
36777
37087
  //#endregion
36778
37088
  //#region src/local/ecs-task-resolver.ts
@@ -36816,6 +37126,7 @@ function detectEcsImageResolutionNeeds(stack) {
36816
37126
  let needsPseudoParameters = false;
36817
37127
  let needsStateResources = false;
36818
37128
  let needsEnvOrSecretSubstitution = false;
37129
+ let needsCrossStackResolver = false;
36819
37130
  for (const res of Object.values(resources)) {
36820
37131
  if (res.Type !== "AWS::ECS::TaskDefinition") continue;
36821
37132
  const props = res.Properties ?? {};
@@ -36828,6 +37139,10 @@ function detectEcsImageResolutionNeeds(stack) {
36828
37139
  if (need.pseudo) needsPseudoParameters = true;
36829
37140
  if (need.state) needsStateResources = true;
36830
37141
  if (containerHasIntrinsicEnvOrSecret(co)) needsEnvOrSecretSubstitution = true;
37142
+ if (containerHasCrossStackEnvOrSecret(co)) {
37143
+ needsEnvOrSecretSubstitution = true;
37144
+ needsCrossStackResolver = true;
37145
+ }
36831
37146
  }
36832
37147
  const rawVolumes = props["Volumes"];
36833
37148
  if (Array.isArray(rawVolumes)) {
@@ -36841,10 +37156,37 @@ function detectEcsImageResolutionNeeds(stack) {
36841
37156
  return {
36842
37157
  needsPseudoParameters,
36843
37158
  needsStateResources,
36844
- needsEnvOrSecretSubstitution
37159
+ needsEnvOrSecretSubstitution,
37160
+ needsCrossStackResolver
36845
37161
  };
36846
37162
  }
36847
37163
  /**
37164
+ * Returns true when any `Environment[].Value` or `Secrets[].ValueFrom`
37165
+ * is a top-level `Fn::ImportValue` / `Fn::GetStackOutput` intrinsic.
37166
+ * Issue #454 — gates cross-stack resolver construction so literal +
37167
+ * same-stack-intrinsic env / secret maps don't pay the extra cost.
37168
+ */
37169
+ function containerHasCrossStackEnvOrSecret(c) {
37170
+ const env = c["Environment"];
37171
+ if (Array.isArray(env)) for (const entry of env) {
37172
+ if (!entry || typeof entry !== "object") continue;
37173
+ const v = entry["Value"];
37174
+ if (isCrossStackIntrinsic(v)) return true;
37175
+ }
37176
+ const secrets = c["Secrets"];
37177
+ if (Array.isArray(secrets)) for (const entry of secrets) {
37178
+ if (!entry || typeof entry !== "object") continue;
37179
+ const v = entry["ValueFrom"];
37180
+ if (isCrossStackIntrinsic(v)) return true;
37181
+ }
37182
+ return false;
37183
+ }
37184
+ function isCrossStackIntrinsic(value) {
37185
+ if (!value || typeof value !== "object") return false;
37186
+ const obj = value;
37187
+ return "Fn::ImportValue" in obj || "Fn::GetStackOutput" in obj;
37188
+ }
37189
+ /**
36848
37190
  * Detect whether a Volume entry has an intrinsic-valued `Host.SourcePath`.
36849
37191
  * Used by `detectEcsImageResolutionNeeds` (Gap 6 of #286) to trigger
36850
37192
  * state-load + pseudo-parameter resolution under `--from-state` when the
@@ -37589,6 +37931,87 @@ function checkVolumeHostPath(hostPath) {
37589
37931
  return false;
37590
37932
  }
37591
37933
  }
37934
+ /**
37935
+ * Async post-pass that walks the resolved task's container Environment +
37936
+ * Secrets entries from the raw template (preserved in `task.resource`)
37937
+ * and re-attempts substitution via {@link substituteAgainstStateAsync}
37938
+ * against the supplied context. The sync `parseContainerDefinition`
37939
+ * pass already substituted everything the legacy resolver handles; this
37940
+ * pass picks up the additional shapes the async resolver supports —
37941
+ * specifically `Fn::ImportValue` / `Fn::GetStackOutput` (issue #454).
37942
+ *
37943
+ * Resolved entries are patched onto the container's `environment` /
37944
+ * `secrets` map AND the corresponding `warnings` entries are filtered
37945
+ * out so the CLI doesn't print a stale per-container warn for an entry
37946
+ * the post-pass successfully resolved. Entries that STILL can't resolve
37947
+ * (e.g. producer stack not deployed) keep their original warning so the
37948
+ * CLI's UX matches the sync path.
37949
+ *
37950
+ * Pure-functional on the task structure outside of in-place mutation of
37951
+ * the container's `environment` / `secrets` / `warnings` arrays. The
37952
+ * task is expected to be the same `ResolvedEcsTask` instance returned
37953
+ * by `resolveEcsTaskTarget` — the runner downstream reads from these
37954
+ * fields directly.
37955
+ */
37956
+ async function applyCrossStackResolverToTask(task, context) {
37957
+ if (!context.crossStackResolver) return;
37958
+ const rawContainers = (task.resource.Properties ?? {})["ContainerDefinitions"];
37959
+ if (!Array.isArray(rawContainers)) return;
37960
+ for (let idx = 0; idx < task.containers.length; idx += 1) {
37961
+ const container = task.containers[idx];
37962
+ const raw = rawContainers[idx];
37963
+ if (!raw || typeof raw !== "object") continue;
37964
+ const c = raw;
37965
+ const containerName = pickString(c["Name"]) ?? container.name;
37966
+ const resolvedEnvKeys = /* @__PURE__ */ new Set();
37967
+ const resolvedSecretNames = /* @__PURE__ */ new Set();
37968
+ if (Array.isArray(c["Environment"])) for (const entry of c["Environment"]) {
37969
+ if (!entry || typeof entry !== "object") continue;
37970
+ const e = entry;
37971
+ const key = pickString(e["Name"]);
37972
+ const value = e["Value"];
37973
+ if (!key) continue;
37974
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") continue;
37975
+ if (key in container.environment) continue;
37976
+ if (!isCrossStackIntrinsic(value)) continue;
37977
+ const sub = await substituteAgainstStateAsync(value, context);
37978
+ if (sub.kind === "literal") {
37979
+ container.environment[key] = String(sub.value);
37980
+ resolvedEnvKeys.add(key);
37981
+ }
37982
+ }
37983
+ if (Array.isArray(c["Secrets"])) for (const entry of c["Secrets"]) {
37984
+ if (!entry || typeof entry !== "object") continue;
37985
+ const e = entry;
37986
+ const sName = pickString(e["Name"]);
37987
+ const valueFromRaw = e["ValueFrom"];
37988
+ if (!sName) continue;
37989
+ if (typeof valueFromRaw === "string" && valueFromRaw.length > 0) continue;
37990
+ if (container.secrets.some((s) => s.name === sName)) continue;
37991
+ if (!isCrossStackIntrinsic(valueFromRaw)) continue;
37992
+ const sub = await substituteAgainstStateAsync(valueFromRaw, context);
37993
+ if (sub.kind === "literal" && typeof sub.value === "string" && sub.value.length > 0) {
37994
+ container.secrets.push({
37995
+ name: sName,
37996
+ valueFrom: sub.value
37997
+ });
37998
+ resolvedSecretNames.add(sName);
37999
+ }
38000
+ }
38001
+ if (resolvedEnvKeys.size > 0 || resolvedSecretNames.size > 0) {
38002
+ container.warnings = container.warnings.filter((w) => {
38003
+ for (const k of resolvedEnvKeys) if (w.startsWith(`Environment '${k}' dropped:`)) return false;
38004
+ for (const k of resolvedSecretNames) if (w.startsWith(`Secret '${k}' dropped:`)) return false;
38005
+ return true;
38006
+ });
38007
+ task.warnings = task.warnings.filter((w) => {
38008
+ for (const k of resolvedEnvKeys) if (w.startsWith(`Container '${containerName}': Environment '${k}' dropped:`)) return false;
38009
+ for (const k of resolvedSecretNames) if (w.startsWith(`Container '${containerName}': Secret '${k}' dropped:`)) return false;
38010
+ return true;
38011
+ });
38012
+ }
38013
+ }
38014
+ }
37592
38015
 
37593
38016
  //#endregion
37594
38017
  //#region src/local/runtime-image.ts
@@ -44605,8 +45028,34 @@ async function localRunTaskCommand(target, options) {
44605
45028
  ...Object.keys(context).length > 0 && { context }
44606
45029
  };
44607
45030
  const { stacks } = await synthesizer.synthesize(synthOpts);
44608
- const task = resolveEcsTaskTarget(target, stacks, await buildEcsImageResolutionContext(target, stacks, options));
45031
+ const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
45032
+ const task = resolveEcsTaskTarget(target, stacks, imageContext);
44609
45033
  logger.info(`Target: ${task.stack.stackName}/${task.taskDefinitionLogicalId} (family=${task.family}, containers=${task.containers.length})`);
45034
+ const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === task.stack.stackName) ?? task.stack);
45035
+ let taskCrossStackDispose;
45036
+ if (options.fromState && taskNeeds.needsCrossStackResolver) {
45037
+ const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? task.stack.region ?? "us-east-1";
45038
+ const built = await buildCrossStackResolver(consumerRegion, {
45039
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
45040
+ statePrefix: options.statePrefix,
45041
+ ...options.region !== void 0 && { region: options.region },
45042
+ ...options.profile !== void 0 && { profile: options.profile }
45043
+ });
45044
+ if (built) {
45045
+ taskCrossStackDispose = built.dispose;
45046
+ try {
45047
+ await applyCrossStackResolverToTask(task, {
45048
+ resources: imageContext?.stateResources ?? {},
45049
+ ...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
45050
+ consumerRegion,
45051
+ crossStackResolver: built.resolver
45052
+ });
45053
+ } finally {
45054
+ taskCrossStackDispose();
45055
+ taskCrossStackDispose = void 0;
45056
+ }
45057
+ }
45058
+ } else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
44610
45059
  sigintHandler = () => {
44611
45060
  sigintCount += 1;
44612
45061
  if (sigintCount >= 2) {
@@ -44894,6 +45343,7 @@ async function localInvokeCommand(target, options) {
44894
45343
  let stateAudit;
44895
45344
  let templateEnv = getTemplateEnv(lambda.resource);
44896
45345
  let stateForRoleHint;
45346
+ let crossStackDispose;
44897
45347
  if (options.fromState) {
44898
45348
  const loaded = await loadStateForStack(lambda.stack.stackName, lambda.stack.region, {
44899
45349
  ...options.stackRegion !== void 0 && { stackRegion: options.stackRegion },
@@ -44904,16 +45354,38 @@ async function localInvokeCommand(target, options) {
44904
45354
  });
44905
45355
  if (loaded) {
44906
45356
  stateForRoleHint = loaded.state;
44907
- const subContext = { resources: loaded.state.resources };
45357
+ const subContext = {
45358
+ resources: loaded.state.resources,
45359
+ consumerRegion: loaded.region
45360
+ };
44908
45361
  if (envHasIntrinsicValue(templateEnv)) {
44909
45362
  const pseudo = await resolvePseudoParametersForInvoke(lambda.stack.region, options);
44910
45363
  if (pseudo) subContext.pseudoParameters = pseudo;
44911
45364
  }
44912
- const { env, audit } = substituteEnvVarsFromState(templateEnv, subContext);
44913
- templateEnv = env;
44914
- stateAudit = audit;
44915
- for (const key of audit.resolvedKeys) logger.debug(`--from-state: substituted env var ${key} from cdkd state`);
44916
- for (const { key, reason } of audit.unresolved) logger.warn(`--from-state: could not substitute env var ${key} (${reason}). Override it via --env-vars or it will be dropped.`);
45365
+ if (envHasCrossStackIntrinsic(templateEnv)) {
45366
+ const built = await buildCrossStackResolver(loaded.region, {
45367
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
45368
+ statePrefix: options.statePrefix,
45369
+ ...options.region !== void 0 && { region: options.region },
45370
+ ...options.profile !== void 0 && { profile: options.profile }
45371
+ });
45372
+ if (built) {
45373
+ subContext.crossStackResolver = built.resolver;
45374
+ crossStackDispose = built.dispose;
45375
+ }
45376
+ }
45377
+ try {
45378
+ const { env, audit } = await substituteEnvVarsFromStateAsync(templateEnv, subContext);
45379
+ templateEnv = env;
45380
+ stateAudit = audit;
45381
+ for (const key of audit.resolvedKeys) logger.debug(`--from-state: substituted env var ${key} from cdkd state`);
45382
+ for (const { key, reason } of audit.unresolved) logger.warn(`--from-state: could not substitute env var ${key} (${reason}). Override it via --env-vars or it will be dropped.`);
45383
+ } finally {
45384
+ if (crossStackDispose) {
45385
+ crossStackDispose();
45386
+ crossStackDispose = void 0;
45387
+ }
45388
+ }
44917
45389
  }
44918
45390
  }
44919
45391
  const overrides = readEnvOverridesFile(options.envVars);
@@ -45186,6 +45658,29 @@ function envHasIntrinsicValue(templateEnv) {
45186
45658
  return false;
45187
45659
  }
45188
45660
  /**
45661
+ * Returns true when any value in the function's template env map carries
45662
+ * a top-level `Fn::ImportValue` / `Fn::GetStackOutput` intrinsic. Used to
45663
+ * gate the cross-stack resolver construction inside the `--from-state`
45664
+ * flow: literal + same-stack-intrinsic env maps shouldn't pay for the
45665
+ * extra S3 client / index-load cost (issue #454).
45666
+ *
45667
+ * Detection is one level deep — same heuristic CDK 2.x uses for these
45668
+ * intrinsics in practice. Nested cross-stack intrinsics (e.g. an
45669
+ * `Fn::ImportValue` buried inside a `Fn::Join`) are not detected here;
45670
+ * those won't resolve in v1 anyway because the async resolver path
45671
+ * defers to the sync helper for `Fn::Join` / `Fn::Sub` bodies (see
45672
+ * `substituteAgainstStateAsync` docstring).
45673
+ */
45674
+ function envHasCrossStackIntrinsic(templateEnv) {
45675
+ if (!templateEnv) return false;
45676
+ for (const v of Object.values(templateEnv)) {
45677
+ if (!v || typeof v !== "object") continue;
45678
+ const obj = v;
45679
+ if ("Fn::ImportValue" in obj || "Fn::GetStackOutput" in obj) return true;
45680
+ }
45681
+ return false;
45682
+ }
45683
+ /**
45189
45684
  * Build the AWS pseudo-parameter bag for `--from-state` env-var
45190
45685
  * substitution. Issues a single `sts:GetCallerIdentity` for the account
45191
45686
  * id and derives `partition` / `urlSuffix` from the resolved region. Any
@@ -46717,7 +47212,7 @@ function reorderArgs(argv) {
46717
47212
  */
46718
47213
  async function main() {
46719
47214
  const program = new Command();
46720
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.121.0");
47215
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.122.0");
46721
47216
  program.addCommand(createBootstrapCommand());
46722
47217
  program.addCommand(createSynthCommand());
46723
47218
  program.addCommand(createListCommand());