@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 +7 -3
- package/dist/cli.js +504 -9
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
|
431
|
-
|
|
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
|
|
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 = {
|
|
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
|
-
|
|
44913
|
-
|
|
44914
|
-
|
|
44915
|
-
|
|
44916
|
-
|
|
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.
|
|
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());
|