@go-to-k/cdkd 0.68.0 → 0.69.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
@@ -607,6 +607,14 @@ cdkd local invoke MyStack/Handler --assume-role arn:aws:iam::123456789012:role/M
607
607
 
608
608
  # Attach a Node debugger
609
609
  cdkd local invoke MyStack/Handler --debug-port 9229
610
+
611
+ # After `cdkd deploy`, recover intrinsic-valued env vars (Ref / Fn::GetAtt
612
+ # / Fn::Sub) from cdkd's S3 state instead of dropping them. Off by default
613
+ # — keeps the local-only / unscoped flow safe; opt in when you want the
614
+ # handler to see the deployed physical IDs (S3 bucket names, DDB table
615
+ # names, IAM role ARNs, ...). Disambiguate with `--stack-region <region>`
616
+ # when the same stack name has state in multiple regions.
617
+ cdkd local invoke MyStack/Handler --from-state
610
618
  ```
611
619
 
612
620
  See [docs/cli-reference.md](docs/cli-reference.md#local-invoke-run-lambda-functions-locally)
package/dist/cli.js CHANGED
@@ -70177,6 +70177,183 @@ function isLiteralEnvValue(value) {
70177
70177
  return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
70178
70178
  }
70179
70179
 
70180
+ // src/local-invoke/state-resolver.ts
70181
+ function substituteAgainstState(value, resources) {
70182
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
70183
+ return { kind: "literal", value };
70184
+ }
70185
+ if (value === null || typeof value !== "object") {
70186
+ return {
70187
+ kind: "unresolved",
70188
+ reason: `unsupported value type: ${value === null ? "null" : typeof value}`
70189
+ };
70190
+ }
70191
+ const obj = value;
70192
+ const keys = Object.keys(obj);
70193
+ if (keys.length !== 1) {
70194
+ return {
70195
+ kind: "unresolved",
70196
+ reason: `expected an intrinsic with one key, got ${keys.length} keys`
70197
+ };
70198
+ }
70199
+ const intrinsic = keys[0];
70200
+ const arg = obj[intrinsic];
70201
+ if (intrinsic === "Ref") {
70202
+ return resolveRef(arg, resources);
70203
+ }
70204
+ if (intrinsic === "Fn::GetAtt") {
70205
+ return resolveGetAtt(arg, resources);
70206
+ }
70207
+ if (intrinsic === "Fn::Sub") {
70208
+ return resolveSub(arg, resources);
70209
+ }
70210
+ return {
70211
+ kind: "unresolved",
70212
+ reason: `unsupported intrinsic '${intrinsic}' (only Ref, Fn::GetAtt, Fn::Sub are wired in --from-state v1)`
70213
+ };
70214
+ }
70215
+ function resolveRef(arg, resources) {
70216
+ if (typeof arg !== "string" || arg.length === 0) {
70217
+ return { kind: "unresolved", reason: `Ref expects a non-empty logical ID, got ${typeof arg}` };
70218
+ }
70219
+ const resource = resources[arg];
70220
+ if (!resource) {
70221
+ return {
70222
+ kind: "unresolved",
70223
+ reason: `Ref '${arg}': no record in cdkd state (was the resource deployed?)`
70224
+ };
70225
+ }
70226
+ return { kind: "literal", value: resource.physicalId };
70227
+ }
70228
+ function resolveGetAtt(arg, resources) {
70229
+ let logicalId;
70230
+ let attr;
70231
+ if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string") {
70232
+ logicalId = arg[0];
70233
+ if (typeof arg[1] !== "string") {
70234
+ return {
70235
+ kind: "unresolved",
70236
+ reason: `Fn::GetAtt's second arg must be a string attribute name, got ${typeof arg[1]} (nested intrinsics in attribute names are not supported in --from-state v1)`
70237
+ };
70238
+ }
70239
+ attr = arg[1];
70240
+ } else if (typeof arg === "string") {
70241
+ const dot = arg.indexOf(".");
70242
+ if (dot <= 0 || dot === arg.length - 1) {
70243
+ return {
70244
+ kind: "unresolved",
70245
+ reason: `Fn::GetAtt string form must be '<LogicalId>.<Attribute>', got '${arg}'`
70246
+ };
70247
+ }
70248
+ logicalId = arg.slice(0, dot);
70249
+ attr = arg.slice(dot + 1);
70250
+ } else {
70251
+ return {
70252
+ kind: "unresolved",
70253
+ reason: `Fn::GetAtt expects [LogicalId, Attribute] or 'LogicalId.Attribute', got ${Array.isArray(arg) ? `array of length ${arg.length}` : typeof arg}`
70254
+ };
70255
+ }
70256
+ const resource = resources[logicalId];
70257
+ if (!resource) {
70258
+ return {
70259
+ kind: "unresolved",
70260
+ reason: `Fn::GetAtt '${logicalId}.${attr}': no record in cdkd state`
70261
+ };
70262
+ }
70263
+ const cached = resource.attributes?.[attr];
70264
+ if (cached === void 0) {
70265
+ return {
70266
+ kind: "unresolved",
70267
+ reason: `Fn::GetAtt '${logicalId}.${attr}': attribute not captured in cdkd state at deploy time`
70268
+ };
70269
+ }
70270
+ if (typeof cached === "string" || typeof cached === "number" || typeof cached === "boolean") {
70271
+ return { kind: "literal", value: cached };
70272
+ }
70273
+ return { kind: "literal", value: JSON.stringify(cached) };
70274
+ }
70275
+ function resolveSub(arg, resources) {
70276
+ let template;
70277
+ let bindings = {};
70278
+ if (typeof arg === "string") {
70279
+ template = arg;
70280
+ } else if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string" && arg[1] !== null && typeof arg[1] === "object" && !Array.isArray(arg[1])) {
70281
+ template = arg[0];
70282
+ bindings = arg[1];
70283
+ } else {
70284
+ return {
70285
+ kind: "unresolved",
70286
+ reason: `Fn::Sub expects a string or [string, object], got ${Array.isArray(arg) ? "malformed array" : typeof arg}`
70287
+ };
70288
+ }
70289
+ const placeholderRegex = /\$\{([^}]+)\}/g;
70290
+ const placeholders = [];
70291
+ template.replace(placeholderRegex, (_, key) => {
70292
+ placeholders.push(key);
70293
+ return "";
70294
+ });
70295
+ const resolutions = /* @__PURE__ */ new Map();
70296
+ for (const placeholder of placeholders) {
70297
+ if (resolutions.has(placeholder))
70298
+ continue;
70299
+ if (placeholder in bindings) {
70300
+ const sub = substituteAgainstState(bindings[placeholder], resources);
70301
+ if (sub.kind !== "literal") {
70302
+ return {
70303
+ kind: "unresolved",
70304
+ reason: `Fn::Sub placeholder '\${${placeholder}}': ${sub.reason}`
70305
+ };
70306
+ }
70307
+ resolutions.set(placeholder, String(sub.value));
70308
+ continue;
70309
+ }
70310
+ const dot = placeholder.indexOf(".");
70311
+ if (dot === -1) {
70312
+ const sub = resolveRef(placeholder, resources);
70313
+ if (sub.kind !== "literal") {
70314
+ return {
70315
+ kind: "unresolved",
70316
+ reason: `Fn::Sub placeholder '\${${placeholder}}': ${sub.reason}`
70317
+ };
70318
+ }
70319
+ resolutions.set(placeholder, String(sub.value));
70320
+ } else {
70321
+ const sub = resolveGetAtt(placeholder, resources);
70322
+ if (sub.kind !== "literal") {
70323
+ return {
70324
+ kind: "unresolved",
70325
+ reason: `Fn::Sub placeholder '\${${placeholder}}': ${sub.reason}`
70326
+ };
70327
+ }
70328
+ resolutions.set(placeholder, String(sub.value));
70329
+ }
70330
+ }
70331
+ const out = template.replace(placeholderRegex, (_, key) => {
70332
+ return resolutions.get(key) ?? "";
70333
+ });
70334
+ return { kind: "literal", value: out };
70335
+ }
70336
+ function substituteEnvVarsFromState(templateEnv, resources) {
70337
+ const env = {};
70338
+ const audit = { resolvedKeys: [], unresolved: [] };
70339
+ if (!templateEnv)
70340
+ return { env, audit };
70341
+ for (const [key, value] of Object.entries(templateEnv)) {
70342
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
70343
+ env[key] = value;
70344
+ continue;
70345
+ }
70346
+ const result = substituteAgainstState(value, resources);
70347
+ if (result.kind === "literal") {
70348
+ env[key] = result.value;
70349
+ audit.resolvedKeys.push(key);
70350
+ } else {
70351
+ audit.unresolved.push({ key, reason: result.reason });
70352
+ }
70353
+ }
70354
+ return { env, audit };
70355
+ }
70356
+
70180
70357
  // src/local-invoke/runtime-image.ts
70181
70358
  var SUPPORTED_RUNTIMES = {
70182
70359
  "nodejs18.x": { image: "public.ecr.aws/lambda/nodejs:18", fileExtension: ".js" },
@@ -70353,8 +70530,10 @@ async function waitForRieReady(host, port, timeoutMs = 5e3) {
70353
70530
  while (Date.now() < deadline) {
70354
70531
  try {
70355
70532
  const ok = await httpProbe(host, port, 500);
70356
- if (ok)
70533
+ if (ok) {
70534
+ await delay(250);
70357
70535
  return;
70536
+ }
70358
70537
  } catch (err) {
70359
70538
  lastError = err;
70360
70539
  }
@@ -70408,12 +70587,7 @@ async function invokeRie(host, port, event, timeoutMs) {
70408
70587
  const timer = setTimeout(() => controller.abort(), timeoutMs);
70409
70588
  let response;
70410
70589
  try {
70411
- response = await fetch(url, {
70412
- method: "POST",
70413
- headers: { "Content-Type": "application/json" },
70414
- body,
70415
- signal: controller.signal
70416
- });
70590
+ response = await fetchWithStartupRetry(url, body, controller.signal);
70417
70591
  } catch (err) {
70418
70592
  if (err.name === "AbortError") {
70419
70593
  throw new Error(
@@ -70432,8 +70606,32 @@ async function invokeRie(host, port, event, timeoutMs) {
70432
70606
  }
70433
70607
  return { payload, raw };
70434
70608
  }
70609
+ async function fetchWithStartupRetry(url, body, signal) {
70610
+ const maxAttempts = 3;
70611
+ let lastError;
70612
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
70613
+ try {
70614
+ return await fetch(url, {
70615
+ method: "POST",
70616
+ headers: { "Content-Type": "application/json" },
70617
+ body,
70618
+ signal
70619
+ });
70620
+ } catch (err) {
70621
+ const name = err.name;
70622
+ if (name === "AbortError")
70623
+ throw err;
70624
+ lastError = err;
70625
+ if (attempt === maxAttempts)
70626
+ break;
70627
+ await delay(200);
70628
+ }
70629
+ }
70630
+ throw lastError;
70631
+ }
70435
70632
 
70436
70633
  // src/cli/commands/local-invoke.ts
70634
+ init_aws_clients();
70437
70635
  async function localInvokeCommand(target, options) {
70438
70636
  const logger = getLogger();
70439
70637
  if (options.verbose) {
@@ -70464,13 +70662,44 @@ async function localInvokeCommand(target, options) {
70464
70662
  lambda.inlineCode ?? "",
70465
70663
  resolveRuntimeFileExtension(lambda.runtime)
70466
70664
  );
70665
+ let stateAudit;
70666
+ let templateEnv = getTemplateEnv(lambda.resource);
70667
+ let stateForRoleHint;
70668
+ if (options.fromState) {
70669
+ const loaded = await loadStateForStack(lambda.stack.stackName, lambda.stack.region, {
70670
+ ...options.stackRegion !== void 0 && { stackRegion: options.stackRegion },
70671
+ ...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
70672
+ statePrefix: options.statePrefix,
70673
+ ...options.region !== void 0 && { region: options.region },
70674
+ ...options.profile !== void 0 && { profile: options.profile }
70675
+ });
70676
+ if (loaded) {
70677
+ stateForRoleHint = loaded.state;
70678
+ const { env, audit } = substituteEnvVarsFromState(templateEnv, loaded.state.resources);
70679
+ templateEnv = env;
70680
+ stateAudit = audit;
70681
+ for (const key of audit.resolvedKeys) {
70682
+ logger.debug(`--from-state: substituted env var ${key} from cdkd state`);
70683
+ }
70684
+ for (const { key, reason } of audit.unresolved) {
70685
+ logger.warn(
70686
+ `--from-state: could not substitute env var ${key} (${reason}). Override it via --env-vars or it will be dropped.`
70687
+ );
70688
+ }
70689
+ }
70690
+ }
70467
70691
  const overrides = readEnvOverridesFile(options.envVars);
70468
- const envResult = resolveEnvVars(lambda.logicalId, getTemplateEnv(lambda.resource), overrides);
70692
+ const envResult = resolveEnvVars(lambda.logicalId, templateEnv, overrides);
70469
70693
  for (const key of envResult.unresolved) {
70694
+ if (stateAudit && stateAudit.unresolved.some((u) => u.key === key))
70695
+ continue;
70470
70696
  logger.warn(
70471
- `Environment variable ${key} contains a CloudFormation intrinsic and was dropped. Override it with --env-vars (e.g. {"${lambda.logicalId}":{"${key}":"<literal>"}}) or wait for --from-state in PR 2.`
70697
+ `Environment variable ${key} contains a CloudFormation intrinsic and was dropped. Override it with --env-vars (e.g. {"${lambda.logicalId}":{"${key}":"<literal>"}}) or pass --from-state to recover deployed values.`
70472
70698
  );
70473
70699
  }
70700
+ if (options.fromState && !options.assumeRole && stateForRoleHint) {
70701
+ suggestAssumeRoleFromState(stateForRoleHint, lambda.logicalId);
70702
+ }
70474
70703
  const event = await readEvent(options);
70475
70704
  const image = resolveRuntimeImage(lambda.runtime);
70476
70705
  const dockerEnv = {
@@ -70649,6 +70878,109 @@ function materializeInlineCode(handler, source, fileExtension) {
70649
70878
  writeFileSync5(filePath, source, "utf-8");
70650
70879
  return dir;
70651
70880
  }
70881
+ async function loadStateForStack(stackName, synthRegion, opts) {
70882
+ const logger = getLogger();
70883
+ const region = opts.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? synthRegion ?? "us-east-1";
70884
+ let stateBucket;
70885
+ try {
70886
+ stateBucket = await resolveStateBucketWithDefault(opts.stateBucket, region);
70887
+ } catch (err) {
70888
+ logger.warn(
70889
+ `--from-state: could not resolve state bucket: ${err instanceof Error ? err.message : String(err)}. Falling back to PR 1 warn-and-drop semantics.`
70890
+ );
70891
+ return void 0;
70892
+ }
70893
+ const awsClients = new AwsClients({
70894
+ ...opts.region !== void 0 && { region: opts.region },
70895
+ ...opts.profile !== void 0 && { profile: opts.profile }
70896
+ });
70897
+ setAwsClients(awsClients);
70898
+ try {
70899
+ const stateConfig = { bucket: stateBucket, prefix: opts.statePrefix };
70900
+ const stateBackend = new S3StateBackend(awsClients.s3, stateConfig, {
70901
+ ...opts.region !== void 0 && { region: opts.region },
70902
+ ...opts.profile !== void 0 && { profile: opts.profile }
70903
+ });
70904
+ await stateBackend.verifyBucketExists();
70905
+ const refs = (await stateBackend.listStacks()).filter((r) => r.stackName === stackName);
70906
+ if (refs.length === 0) {
70907
+ logger.warn(
70908
+ `--from-state: no cdkd state found for stack '${stackName}' in bucket '${stateBucket}'. Was it deployed via 'cdkd deploy'? Falling back to PR 1 warn-and-drop semantics.`
70909
+ );
70910
+ return void 0;
70911
+ }
70912
+ let targetRegion;
70913
+ if (opts.stackRegion) {
70914
+ const found = refs.find((r) => r.region === opts.stackRegion);
70915
+ if (!found) {
70916
+ const seen = refs.map((r) => r.region ?? "(legacy)").join(", ");
70917
+ logger.warn(
70918
+ `--from-state: stack '${stackName}' has no state in region '${opts.stackRegion}' (available: ${seen}). Falling back.`
70919
+ );
70920
+ return void 0;
70921
+ }
70922
+ targetRegion = opts.stackRegion;
70923
+ } else if (synthRegion && refs.some((r) => r.region === synthRegion)) {
70924
+ targetRegion = synthRegion;
70925
+ } else if (refs.length === 1) {
70926
+ targetRegion = refs[0].region ?? synthRegion ?? region;
70927
+ } else {
70928
+ const seen = refs.map((r) => r.region ?? "(legacy)").join(", ");
70929
+ logger.warn(
70930
+ `--from-state: stack '${stackName}' has state in multiple regions (${seen}). Re-run with --stack-region <region>. Falling back.`
70931
+ );
70932
+ return void 0;
70933
+ }
70934
+ const stateData = await stateBackend.getState(stackName, targetRegion);
70935
+ if (!stateData) {
70936
+ logger.warn(
70937
+ `--from-state: state record for '${stackName}' (${targetRegion}) returned empty. Falling back.`
70938
+ );
70939
+ return void 0;
70940
+ }
70941
+ logger.debug(`--from-state: loaded state for ${stackName} (${targetRegion})`);
70942
+ return { state: stateData.state, region: targetRegion };
70943
+ } finally {
70944
+ awsClients.destroy();
70945
+ }
70946
+ }
70947
+ function suggestAssumeRoleFromState(state, logicalId) {
70948
+ const logger = getLogger();
70949
+ const lambda = state.resources[logicalId];
70950
+ if (!lambda)
70951
+ return;
70952
+ const roleRef = lambda.properties?.["Role"] ?? lambda.observedProperties?.["Role"];
70953
+ let roleArn;
70954
+ if (typeof roleRef === "string" && roleRef.startsWith("arn:")) {
70955
+ roleArn = roleRef;
70956
+ } else if (typeof roleRef === "object" && roleRef !== null) {
70957
+ const refLogicalId = pickReferencedLogicalId(roleRef);
70958
+ if (refLogicalId) {
70959
+ const roleResource = state.resources[refLogicalId];
70960
+ const cached = roleResource?.attributes?.["Arn"];
70961
+ if (typeof cached === "string" && cached.startsWith("arn:")) {
70962
+ roleArn = cached;
70963
+ }
70964
+ }
70965
+ }
70966
+ if (roleArn) {
70967
+ logger.info(
70968
+ `Hint: the deployed function uses execution role ${roleArn}. Re-run with --assume-role <that-arn> to invoke under the deployed function's narrow permissions.`
70969
+ );
70970
+ }
70971
+ }
70972
+ function pickReferencedLogicalId(intrinsic) {
70973
+ if ("Ref" in intrinsic && typeof intrinsic["Ref"] === "string")
70974
+ return intrinsic["Ref"];
70975
+ if ("Fn::GetAtt" in intrinsic) {
70976
+ const arg = intrinsic["Fn::GetAtt"];
70977
+ if (Array.isArray(arg) && typeof arg[0] === "string")
70978
+ return arg[0];
70979
+ if (typeof arg === "string")
70980
+ return arg.split(".")[0];
70981
+ }
70982
+ return void 0;
70983
+ }
70652
70984
  function createLocalCommand() {
70653
70985
  const local = new Command14("local").description(
70654
70986
  "Local Lambda execution against the AWS Lambda Runtime Interface Emulator (Docker required)"
@@ -70667,8 +70999,20 @@ function createLocalCommand() {
70667
70999
  "--assume-role <arn>",
70668
71000
  `Assume the Lambda's deployed execution role and forward STS-issued temp credentials to the container so the handler runs with the deployed function's narrow permissions (closes the "developer admin / function narrow" skew). Off by default \u2014 when omitted, the developer's shell credentials are forwarded unchanged (SAM-compatible default).`
70669
71001
  )
71002
+ ).addOption(
71003
+ new Option7(
71004
+ "--from-state",
71005
+ "Read cdkd S3 state for the target stack and substitute Ref / Fn::GetAtt / Fn::Sub in env vars with the deployed physical IDs / attributes. Off by default \u2014 keep PR 1 warn-and-drop semantics; turn on for stacks already deployed via cdkd deploy."
71006
+ ).default(false)
71007
+ ).addOption(
71008
+ new Option7(
71009
+ "--stack-region <region>",
71010
+ "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions)."
71011
+ )
70670
71012
  ).action(withErrorHandling(localInvokeCommand));
70671
- [...commonOptions, ...appOptions, ...contextOptions].forEach((opt) => invoke.addOption(opt));
71013
+ [...commonOptions, ...appOptions, ...contextOptions, ...stateOptions].forEach(
71014
+ (opt) => invoke.addOption(opt)
71015
+ );
70672
71016
  invoke.addOption(deprecatedRegionOption);
70673
71017
  local.addCommand(invoke);
70674
71018
  return local;
@@ -70703,7 +71047,7 @@ function reorderArgs(argv) {
70703
71047
  }
70704
71048
  async function main() {
70705
71049
  const program = new Command15();
70706
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.68.0");
71050
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.69.0");
70707
71051
  program.addCommand(createBootstrapCommand());
70708
71052
  program.addCommand(createSynthCommand());
70709
71053
  program.addCommand(createListCommand());