cdk-local 0.64.0 → 0.65.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/dist/cli.js +3 -3
- package/dist/index.d.ts +1 -32
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +2 -2
- package/dist/{local-start-alb-CTu5lvrU.d.ts → local-list-D3BH2x6B.d.ts} +38 -2
- package/dist/local-list-D3BH2x6B.d.ts.map +1 -0
- package/dist/{local-start-alb-CIE0TMpn.js → local-list-DrkWq_1u.js} +2887 -979
- package/dist/local-list-DrkWq_1u.js.map +1 -0
- package/dist/local-start-service-DKuvzQhM.js +41 -0
- package/dist/local-start-service-DKuvzQhM.js.map +1 -0
- package/package.json +1 -1
- package/dist/local-list-BNkoH5tV.js +0 -1852
- package/dist/local-list-BNkoH5tV.js.map +0 -1
- package/dist/local-start-alb-CIE0TMpn.js.map +0 -1
- package/dist/local-start-alb-CTu5lvrU.d.ts.map +0 -1
|
@@ -7799,6 +7799,641 @@ async function writeProfileCredentialsFile(profileName, creds) {
|
|
|
7799
7799
|
};
|
|
7800
7800
|
}
|
|
7801
7801
|
|
|
7802
|
+
//#endregion
|
|
7803
|
+
//#region src/cli/commands/local-invoke.ts
|
|
7804
|
+
/**
|
|
7805
|
+
* `cdkl invoke <target>` — run a Lambda function locally inside a
|
|
7806
|
+
* Docker container that bundles the AWS Lambda Runtime Interface
|
|
7807
|
+
* Emulator (RIE). Modeled on `sam local invoke` but reusing cdk-local's
|
|
7808
|
+
* synthesis / asset / construct-path plumbing.
|
|
7809
|
+
*/
|
|
7810
|
+
async function localInvokeCommand(target, options, extraStateProviders) {
|
|
7811
|
+
const logger = getLogger();
|
|
7812
|
+
if (options.verbose) logger.setLevel("debug");
|
|
7813
|
+
warnIfDeprecatedRegion(options);
|
|
7814
|
+
let imagePlan;
|
|
7815
|
+
let containerId;
|
|
7816
|
+
let stopLogs;
|
|
7817
|
+
let sigintHandler;
|
|
7818
|
+
let profileCredsFile;
|
|
7819
|
+
/**
|
|
7820
|
+
* Unified cleanup for both the success / failure unwind path AND the
|
|
7821
|
+
* SIGINT handler.
|
|
7822
|
+
*/
|
|
7823
|
+
const cleanup = singleFlight(async () => {
|
|
7824
|
+
if (stopLogs) try {
|
|
7825
|
+
stopLogs();
|
|
7826
|
+
} catch (err) {
|
|
7827
|
+
getLogger().debug(`streamLogs stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7828
|
+
}
|
|
7829
|
+
if (containerId) try {
|
|
7830
|
+
await removeContainer(containerId);
|
|
7831
|
+
} catch (err) {
|
|
7832
|
+
getLogger().debug(`removeContainer(${containerId}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7833
|
+
}
|
|
7834
|
+
if (imagePlan?.inlineTmpDir) try {
|
|
7835
|
+
rmSync(imagePlan.inlineTmpDir, {
|
|
7836
|
+
recursive: true,
|
|
7837
|
+
force: true
|
|
7838
|
+
});
|
|
7839
|
+
} catch (err) {
|
|
7840
|
+
getLogger().debug(`Failed to remove inline-code tmpdir ${imagePlan.inlineTmpDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
7841
|
+
}
|
|
7842
|
+
if (imagePlan?.layersTmpDir) try {
|
|
7843
|
+
rmSync(imagePlan.layersTmpDir, {
|
|
7844
|
+
recursive: true,
|
|
7845
|
+
force: true
|
|
7846
|
+
});
|
|
7847
|
+
} catch (err) {
|
|
7848
|
+
getLogger().debug(`Failed to remove merged-layers tmpdir ${imagePlan.layersTmpDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
7849
|
+
}
|
|
7850
|
+
if (imagePlan?.layerArnTmpDirs) for (const dir of imagePlan.layerArnTmpDirs) try {
|
|
7851
|
+
rmSync(dir, {
|
|
7852
|
+
recursive: true,
|
|
7853
|
+
force: true
|
|
7854
|
+
});
|
|
7855
|
+
} catch (err) {
|
|
7856
|
+
getLogger().debug(`Failed to remove ARN-layer tmpdir ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
7857
|
+
}
|
|
7858
|
+
if (profileCredsFile) try {
|
|
7859
|
+
await profileCredsFile.dispose();
|
|
7860
|
+
} catch (err) {
|
|
7861
|
+
getLogger().debug(`Failed to remove profile credentials tmpdir ${profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
7862
|
+
}
|
|
7863
|
+
}, (err) => {
|
|
7864
|
+
getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
7865
|
+
});
|
|
7866
|
+
try {
|
|
7867
|
+
await applyRoleArnIfSet({
|
|
7868
|
+
roleArn: options.roleArn,
|
|
7869
|
+
region: options.region
|
|
7870
|
+
});
|
|
7871
|
+
await ensureDockerAvailable();
|
|
7872
|
+
const profileCredentials = options.profile ? await resolveProfileCredentials$1(options.profile) : void 0;
|
|
7873
|
+
if (options.profile && profileCredentials) profileCredsFile = await writeProfileCredentialsFile(options.profile, profileCredentials);
|
|
7874
|
+
const appCmd = resolveApp(options.app);
|
|
7875
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
7876
|
+
logger.info("Synthesizing CDK app...");
|
|
7877
|
+
const synthesizer = new Synthesizer();
|
|
7878
|
+
const context = parseContextOptions(options.context);
|
|
7879
|
+
const synthOpts = {
|
|
7880
|
+
app: appCmd,
|
|
7881
|
+
output: options.output,
|
|
7882
|
+
...options.region && { region: options.region },
|
|
7883
|
+
...options.profile && { profile: options.profile },
|
|
7884
|
+
...Object.keys(context).length > 0 && { context }
|
|
7885
|
+
};
|
|
7886
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
7887
|
+
const lambda = resolveLambdaTarget(await resolveSingleTarget(target, {
|
|
7888
|
+
entries: listTargets(stacks).lambdas,
|
|
7889
|
+
message: "Select a Lambda function to invoke",
|
|
7890
|
+
noun: "Lambda functions",
|
|
7891
|
+
onMissing: () => new CdkLocalError(`${getEmbedConfig().cliName} invoke requires a <target> (a Lambda display path or logical ID). Run \`${getEmbedConfig().cliName} list\` to see them, or run it in a TTY to pick interactively.`, "LOCAL_INVOKE_TARGET_REQUIRED")
|
|
7892
|
+
}), stacks);
|
|
7893
|
+
const targetLabel = lambda.kind === "zip" ? lambda.runtime : "container image";
|
|
7894
|
+
logger.info(`Target: ${lambda.stack.stackName}/${lambda.logicalId} (${targetLabel})`);
|
|
7895
|
+
imagePlan = await resolveImagePlan(lambda, options);
|
|
7896
|
+
let stateAudit;
|
|
7897
|
+
let templateEnv = getTemplateEnv$1(lambda.resource);
|
|
7898
|
+
let stateForRoleHint;
|
|
7899
|
+
const stateProvider = createLocalStateProvider(options, lambda.stack.stackName, await resolveCfnFallbackRegion(options, lambda.stack.region), extraStateProviders);
|
|
7900
|
+
if (stateProvider) try {
|
|
7901
|
+
const loaded = await stateProvider.load(lambda.stack.stackName, lambda.stack.region);
|
|
7902
|
+
if (loaded) {
|
|
7903
|
+
stateForRoleHint = {
|
|
7904
|
+
version: 1,
|
|
7905
|
+
stackName: lambda.stack.stackName,
|
|
7906
|
+
resources: loaded.resources,
|
|
7907
|
+
outputs: loaded.outputs,
|
|
7908
|
+
lastModified: 0
|
|
7909
|
+
};
|
|
7910
|
+
const subContext = {
|
|
7911
|
+
resources: loaded.resources,
|
|
7912
|
+
consumerRegion: loaded.region
|
|
7913
|
+
};
|
|
7914
|
+
if (envHasIntrinsicValue$1(templateEnv)) {
|
|
7915
|
+
const pseudo = await resolvePseudoParametersForInvoke(lambda.stack.region, options);
|
|
7916
|
+
if (pseudo) subContext.pseudoParameters = pseudo;
|
|
7917
|
+
}
|
|
7918
|
+
if (envHasIntrinsicValue$1(templateEnv) && stateProvider.resolveTemplateSsmParameters) {
|
|
7919
|
+
const ssmParams = await stateProvider.resolveTemplateSsmParameters(lambda.stack.template);
|
|
7920
|
+
if (Object.keys(ssmParams.values).length > 0) subContext.parameters = ssmParams.values;
|
|
7921
|
+
if (ssmParams.secureStringLogicalIds.length > 0) subContext.sensitiveParameters = new Set(ssmParams.secureStringLogicalIds);
|
|
7922
|
+
}
|
|
7923
|
+
if (envHasCrossStackIntrinsic(templateEnv)) {
|
|
7924
|
+
const resolver = await stateProvider.buildCrossStackResolver(loaded.region);
|
|
7925
|
+
if (resolver) subContext.crossStackResolver = resolver;
|
|
7926
|
+
}
|
|
7927
|
+
const { env, audit } = await substituteEnvVarsFromStateAsync(templateEnv, subContext);
|
|
7928
|
+
templateEnv = env;
|
|
7929
|
+
const label = stateProvider.label;
|
|
7930
|
+
for (const key of audit.resolvedKeys) logger.debug(`${label}: substituted env var ${key}`);
|
|
7931
|
+
let unresolved = audit.unresolved;
|
|
7932
|
+
const resolvedKeys = [...audit.resolvedKeys];
|
|
7933
|
+
if (unresolved.length > 0 && stateProvider.resolveDeployedFunctionEnv) {
|
|
7934
|
+
const physicalId = loaded.resources[lambda.logicalId]?.physicalId;
|
|
7935
|
+
if (physicalId) {
|
|
7936
|
+
const deployedEnv = await stateProvider.resolveDeployedFunctionEnv(physicalId);
|
|
7937
|
+
const fb = applyDeployedEnvFallback(templateEnv, unresolved, deployedEnv);
|
|
7938
|
+
templateEnv = fb.env;
|
|
7939
|
+
unresolved = fb.stillUnresolved;
|
|
7940
|
+
for (const key of fb.filled) {
|
|
7941
|
+
resolvedKeys.push(key);
|
|
7942
|
+
logger.debug(`${label}: filled env var ${key} from deployed function config`);
|
|
7943
|
+
}
|
|
7944
|
+
}
|
|
7945
|
+
}
|
|
7946
|
+
stateAudit = {
|
|
7947
|
+
resolvedKeys,
|
|
7948
|
+
unresolved,
|
|
7949
|
+
sensitiveKeys: audit.sensitiveKeys
|
|
7950
|
+
};
|
|
7951
|
+
for (const { key, reason } of unresolved) logger.warn(`${label}: could not substitute env var ${key} (${reason}). Override it via --env-vars or it will be dropped.`);
|
|
7952
|
+
}
|
|
7953
|
+
} catch (err) {
|
|
7954
|
+
stateProvider.dispose();
|
|
7955
|
+
throw err;
|
|
7956
|
+
}
|
|
7957
|
+
const overrides = readEnvOverridesFile$4(options.envVars);
|
|
7958
|
+
const lambdaCdkPath = readCdkPathOrUndefined(lambda.resource);
|
|
7959
|
+
const envResult = resolveEnvVars(lambda.logicalId, lambdaCdkPath, templateEnv, overrides);
|
|
7960
|
+
for (const key of envResult.unresolved) {
|
|
7961
|
+
if (stateAudit && stateAudit.unresolved.some((u) => u.key === key)) continue;
|
|
7962
|
+
const overrideKeyExample = lambdaCdkPath?.replace(/\/Resource$/, "") ?? lambda.logicalId;
|
|
7963
|
+
logger.warn(`Environment variable ${key} contains a CloudFormation intrinsic and was dropped. Override it with --env-vars (e.g. {"${overrideKeyExample}":{"${key}":"<literal>"}}), or pass a state-source flag (e.g. --from-cfn-stack or a host-provided extension) to recover deployed values.`);
|
|
7964
|
+
}
|
|
7965
|
+
let resolvedAssumeRoleArn;
|
|
7966
|
+
try {
|
|
7967
|
+
resolvedAssumeRoleArn = await resolveAssumeRoleArnForLambda(options.assumeRole, stateForRoleHint, stateProvider, lambda.logicalId);
|
|
7968
|
+
} finally {
|
|
7969
|
+
stateProvider?.dispose();
|
|
7970
|
+
}
|
|
7971
|
+
if (options.assumeRole === void 0 && stateForRoleHint) suggestAssumeRoleFromState(stateForRoleHint, lambda.logicalId);
|
|
7972
|
+
const event = await readEvent$1(options);
|
|
7973
|
+
const dockerEnv = {
|
|
7974
|
+
AWS_LAMBDA_FUNCTION_NAME: lambda.logicalId,
|
|
7975
|
+
AWS_LAMBDA_FUNCTION_MEMORY_SIZE: String(lambda.memoryMb),
|
|
7976
|
+
AWS_LAMBDA_FUNCTION_TIMEOUT: String(lambda.timeoutSec),
|
|
7977
|
+
AWS_LAMBDA_FUNCTION_VERSION: "$LATEST",
|
|
7978
|
+
AWS_LAMBDA_LOG_GROUP_NAME: `/aws/lambda/${lambda.logicalId}`,
|
|
7979
|
+
AWS_LAMBDA_LOG_STREAM_NAME: "local",
|
|
7980
|
+
...envResult.resolved
|
|
7981
|
+
};
|
|
7982
|
+
let assumeSucceeded = false;
|
|
7983
|
+
if (resolvedAssumeRoleArn) {
|
|
7984
|
+
const stsRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"];
|
|
7985
|
+
try {
|
|
7986
|
+
const creds = await assumeLambdaExecutionRole$1(resolvedAssumeRoleArn, stsRegion);
|
|
7987
|
+
dockerEnv["AWS_ACCESS_KEY_ID"] = creds.accessKeyId;
|
|
7988
|
+
dockerEnv["AWS_SECRET_ACCESS_KEY"] = creds.secretAccessKey;
|
|
7989
|
+
dockerEnv["AWS_SESSION_TOKEN"] = creds.sessionToken;
|
|
7990
|
+
if (stsRegion) dockerEnv["AWS_REGION"] = stsRegion;
|
|
7991
|
+
assumeSucceeded = true;
|
|
7992
|
+
} catch (err) {
|
|
7993
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
7994
|
+
logger.warn(`--assume-role: STS AssumeRole(${resolvedAssumeRoleArn}) failed: ${reason}. Falling back to the developer's shell credentials.`);
|
|
7995
|
+
}
|
|
7996
|
+
}
|
|
7997
|
+
if (!assumeSucceeded) {
|
|
7998
|
+
forwardAwsEnv$3(dockerEnv);
|
|
7999
|
+
applyProfileCredentialsOverlay(dockerEnv, profileCredentials, false);
|
|
8000
|
+
if (profileCredsFile) {
|
|
8001
|
+
dockerEnv["AWS_SHARED_CREDENTIALS_FILE"] = profileCredsFile.containerPath;
|
|
8002
|
+
dockerEnv["AWS_PROFILE"] = profileCredsFile.profileName;
|
|
8003
|
+
}
|
|
8004
|
+
}
|
|
8005
|
+
let debugPort;
|
|
8006
|
+
if (options.debugPort) {
|
|
8007
|
+
debugPort = Number(options.debugPort);
|
|
8008
|
+
if (!Number.isInteger(debugPort) || debugPort <= 0 || debugPort > 65535) throw new Error(`--debug-port must be an integer in 1..65535, got '${options.debugPort}'`);
|
|
8009
|
+
dockerEnv["NODE_OPTIONS"] = `--inspect-brk=0.0.0.0:${debugPort}`;
|
|
8010
|
+
if (lambda.kind === "image") logger.warn("--debug-port sets NODE_OPTIONS unconditionally on container Lambdas. If the image's runtime is not Node.js, this flag is a no-op.");
|
|
8011
|
+
}
|
|
8012
|
+
const hostPort = await pickFreePort();
|
|
8013
|
+
const containerHost = options.containerHost;
|
|
8014
|
+
if (lambda.layers.length > 0) logger.info(`Mounting ${lambda.layers.length} Lambda layer${lambda.layers.length === 1 ? "" : "s"} at /opt`);
|
|
8015
|
+
logger.info(`Starting container (image=${imagePlan.image}, port=${hostPort})...`);
|
|
8016
|
+
const extraMountsWithProfile = profileCredsFile ? [...imagePlan.extraMounts ?? [], {
|
|
8017
|
+
hostPath: profileCredsFile.hostPath,
|
|
8018
|
+
containerPath: profileCredsFile.containerPath,
|
|
8019
|
+
readOnly: true
|
|
8020
|
+
}] : imagePlan.extraMounts;
|
|
8021
|
+
containerId = await runDetached({
|
|
8022
|
+
image: imagePlan.image,
|
|
8023
|
+
mounts: imagePlan.mounts,
|
|
8024
|
+
extraMounts: extraMountsWithProfile,
|
|
8025
|
+
env: dockerEnv,
|
|
8026
|
+
...stateAudit && stateAudit.sensitiveKeys.length > 0 && { sensitiveEnvKeys: new Set(stateAudit.sensitiveKeys) },
|
|
8027
|
+
cmd: imagePlan.cmd,
|
|
8028
|
+
hostPort,
|
|
8029
|
+
host: containerHost,
|
|
8030
|
+
...debugPort !== void 0 && { debugPort },
|
|
8031
|
+
...imagePlan.platform !== void 0 && { platform: imagePlan.platform },
|
|
8032
|
+
...imagePlan.entryPoint !== void 0 && { entryPoint: imagePlan.entryPoint },
|
|
8033
|
+
...imagePlan.workingDir !== void 0 && { workingDir: imagePlan.workingDir },
|
|
8034
|
+
...imagePlan.tmpfs !== void 0 && { tmpfs: imagePlan.tmpfs }
|
|
8035
|
+
});
|
|
8036
|
+
stopLogs = streamLogs(containerId);
|
|
8037
|
+
sigintHandler = () => {
|
|
8038
|
+
cleanup().then(() => {
|
|
8039
|
+
process.exit(130);
|
|
8040
|
+
});
|
|
8041
|
+
};
|
|
8042
|
+
process.on("SIGINT", sigintHandler);
|
|
8043
|
+
await waitForRieReady(containerHost, hostPort, 5e3);
|
|
8044
|
+
const result = await invokeRie(containerHost, hostPort, event, Math.max(3e4, lambda.timeoutSec * 2 * 1e3));
|
|
8045
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
|
8046
|
+
process.stdout.write(`${result.raw}\n`);
|
|
8047
|
+
} finally {
|
|
8048
|
+
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
8049
|
+
await cleanup();
|
|
8050
|
+
}
|
|
8051
|
+
}
|
|
8052
|
+
async function resolveImagePlan(lambda, options) {
|
|
8053
|
+
if (lambda.kind === "zip") return resolveZipImagePlan$1(lambda, options);
|
|
8054
|
+
return resolveContainerImagePlan$1(lambda, options);
|
|
8055
|
+
}
|
|
8056
|
+
async function resolveZipImagePlan$1(lambda, options) {
|
|
8057
|
+
let inlineTmpDir;
|
|
8058
|
+
let codeDir = lambda.codePath;
|
|
8059
|
+
if (codeDir === null) {
|
|
8060
|
+
inlineTmpDir = materializeInlineCode$2(lambda.handler, lambda.inlineCode ?? "", resolveRuntimeFileExtension(lambda.runtime));
|
|
8061
|
+
codeDir = inlineTmpDir;
|
|
8062
|
+
}
|
|
8063
|
+
const image = resolveRuntimeImage(lambda.runtime);
|
|
8064
|
+
await pullImage(image, options.pull === false);
|
|
8065
|
+
const layerPlan = await materializeLambdaLayersIncludingArns(lambda.layers, options);
|
|
8066
|
+
const containerCodePath = resolveRuntimeCodeMountPath(lambda.runtime);
|
|
8067
|
+
const tmpfs = resolveTmpfsForLambda(lambda);
|
|
8068
|
+
return {
|
|
8069
|
+
image,
|
|
8070
|
+
mounts: [{
|
|
8071
|
+
hostPath: codeDir,
|
|
8072
|
+
containerPath: containerCodePath,
|
|
8073
|
+
readOnly: true
|
|
8074
|
+
}],
|
|
8075
|
+
extraMounts: layerPlan.mount ? [layerPlan.mount] : [],
|
|
8076
|
+
cmd: [lambda.handler],
|
|
8077
|
+
...inlineTmpDir !== void 0 && { inlineTmpDir },
|
|
8078
|
+
...layerPlan.tmpDir !== void 0 && { layersTmpDir: layerPlan.tmpDir },
|
|
8079
|
+
...layerPlan.extraTmpDirs.length > 0 && { layerArnTmpDirs: layerPlan.extraTmpDirs },
|
|
8080
|
+
...tmpfs !== void 0 && { tmpfs }
|
|
8081
|
+
};
|
|
8082
|
+
}
|
|
8083
|
+
async function materializeLambdaLayersIncludingArns(layers, options) {
|
|
8084
|
+
const extraTmpDirs = [];
|
|
8085
|
+
const flat = [];
|
|
8086
|
+
for (const layer of layers) {
|
|
8087
|
+
if (layer.kind === "asset") {
|
|
8088
|
+
flat.push({
|
|
8089
|
+
logicalId: layer.logicalId,
|
|
8090
|
+
assetPath: layer.assetPath
|
|
8091
|
+
});
|
|
8092
|
+
continue;
|
|
8093
|
+
}
|
|
8094
|
+
const dir = await materializeLayerFromArn(layer, { ...options.layerRoleArn !== void 0 && { roleArn: options.layerRoleArn } });
|
|
8095
|
+
extraTmpDirs.push(dir);
|
|
8096
|
+
flat.push({
|
|
8097
|
+
logicalId: layer.arn,
|
|
8098
|
+
assetPath: dir
|
|
8099
|
+
});
|
|
8100
|
+
}
|
|
8101
|
+
return {
|
|
8102
|
+
...materializeLambdaLayers$1(flat),
|
|
8103
|
+
extraTmpDirs
|
|
8104
|
+
};
|
|
8105
|
+
}
|
|
8106
|
+
function resolveTmpfsForLambda(lambda) {
|
|
8107
|
+
if (lambda.ephemeralStorageMb === void 0) return void 0;
|
|
8108
|
+
const logger = getLogger();
|
|
8109
|
+
if (lambda.kind === "image") logger.info(`Lambda ${lambda.logicalId}: capping /tmp at ${lambda.ephemeralStorageMb} MiB via --tmpfs (overlays any base-image /tmp content)`);
|
|
8110
|
+
else logger.debug(`Lambda ${lambda.logicalId}: applying EphemeralStorage cap via --tmpfs /tmp:size=${lambda.ephemeralStorageMb}m`);
|
|
8111
|
+
return {
|
|
8112
|
+
target: "/tmp",
|
|
8113
|
+
sizeMb: lambda.ephemeralStorageMb
|
|
8114
|
+
};
|
|
8115
|
+
}
|
|
8116
|
+
function materializeLambdaLayers$1(layers) {
|
|
8117
|
+
if (layers.length === 0) return {};
|
|
8118
|
+
if (layers.length === 1) return { mount: {
|
|
8119
|
+
hostPath: layers[0].assetPath,
|
|
8120
|
+
containerPath: "/opt",
|
|
8121
|
+
readOnly: true
|
|
8122
|
+
} };
|
|
8123
|
+
const tmpDir = mkdtempSync(path.join(tmpdir(), `${getEmbedConfig().resourceNamePrefix}-invoke-layers-`));
|
|
8124
|
+
for (const layer of layers) cpSync(layer.assetPath, tmpDir, {
|
|
8125
|
+
recursive: true,
|
|
8126
|
+
force: true
|
|
8127
|
+
});
|
|
8128
|
+
return {
|
|
8129
|
+
mount: {
|
|
8130
|
+
hostPath: tmpDir,
|
|
8131
|
+
containerPath: "/opt",
|
|
8132
|
+
readOnly: true
|
|
8133
|
+
},
|
|
8134
|
+
tmpDir
|
|
8135
|
+
};
|
|
8136
|
+
}
|
|
8137
|
+
async function resolveContainerImagePlan$1(lambda, options) {
|
|
8138
|
+
const logger = getLogger();
|
|
8139
|
+
const platform = architectureToPlatform(lambda.architecture);
|
|
8140
|
+
const localBuild = await resolveLocalBuildPlan$1(lambda);
|
|
8141
|
+
let imageRef;
|
|
8142
|
+
if (localBuild) imageRef = await buildContainerImage(localBuild.asset, localBuild.cdkOutDir, {
|
|
8143
|
+
architecture: lambda.architecture,
|
|
8144
|
+
noBuild: options.build === false
|
|
8145
|
+
});
|
|
8146
|
+
else {
|
|
8147
|
+
if (!parseEcrUri(lambda.imageUri)) throw new Error(`Container Lambda '${lambda.logicalId}' has no matching asset in cdk.out, and Code.ImageUri '${lambda.imageUri}' is not an ECR URI ${getEmbedConfig().binaryName} can authenticate against. Re-synthesize the CDK app (so cdk.out includes the build context) or deploy the image to ECR first.`);
|
|
8148
|
+
logger.info(`No matching cdk.out asset for ${lambda.imageUri}; falling back to ECR pull (same-acct/region only)...`);
|
|
8149
|
+
imageRef = await pullEcrImage(lambda.imageUri, {
|
|
8150
|
+
skipPull: options.pull === false,
|
|
8151
|
+
...options.region !== void 0 && { region: options.region },
|
|
8152
|
+
...options.ecrRoleArn !== void 0 && { ecrRoleArn: options.ecrRoleArn },
|
|
8153
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
8154
|
+
});
|
|
8155
|
+
}
|
|
8156
|
+
const tmpfs = resolveTmpfsForLambda(lambda);
|
|
8157
|
+
return {
|
|
8158
|
+
image: imageRef,
|
|
8159
|
+
mounts: [],
|
|
8160
|
+
extraMounts: [],
|
|
8161
|
+
cmd: lambda.imageConfig.command ?? [],
|
|
8162
|
+
platform,
|
|
8163
|
+
...lambda.imageConfig.entryPoint && lambda.imageConfig.entryPoint.length > 0 && { entryPoint: lambda.imageConfig.entryPoint },
|
|
8164
|
+
...lambda.imageConfig.workingDirectory !== void 0 && { workingDir: lambda.imageConfig.workingDirectory },
|
|
8165
|
+
...tmpfs !== void 0 && { tmpfs }
|
|
8166
|
+
};
|
|
8167
|
+
}
|
|
8168
|
+
async function resolveLocalBuildPlan$1(lambda) {
|
|
8169
|
+
const manifestPath = lambda.stack.assetManifestPath;
|
|
8170
|
+
if (!manifestPath) return void 0;
|
|
8171
|
+
const cdkOutDir = dirname(manifestPath);
|
|
8172
|
+
const manifest = await new AssetManifestLoader().loadManifest(cdkOutDir, lambda.stack.stackName);
|
|
8173
|
+
if (!manifest) return void 0;
|
|
8174
|
+
const entry = getDockerImageBySourceHash(manifest, lambda.imageUri);
|
|
8175
|
+
if (!entry) return void 0;
|
|
8176
|
+
return {
|
|
8177
|
+
asset: entry.asset,
|
|
8178
|
+
cdkOutDir
|
|
8179
|
+
};
|
|
8180
|
+
}
|
|
8181
|
+
function envHasIntrinsicValue$1(templateEnv) {
|
|
8182
|
+
if (!templateEnv) return false;
|
|
8183
|
+
for (const v of Object.values(templateEnv)) {
|
|
8184
|
+
if (v === void 0 || v === null) continue;
|
|
8185
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") continue;
|
|
8186
|
+
return true;
|
|
8187
|
+
}
|
|
8188
|
+
return false;
|
|
8189
|
+
}
|
|
8190
|
+
function envHasCrossStackIntrinsic(templateEnv) {
|
|
8191
|
+
if (!templateEnv) return false;
|
|
8192
|
+
for (const v of Object.values(templateEnv)) {
|
|
8193
|
+
if (!v || typeof v !== "object") continue;
|
|
8194
|
+
const obj = v;
|
|
8195
|
+
if ("Fn::ImportValue" in obj || "Fn::GetStackOutput" in obj) return true;
|
|
8196
|
+
}
|
|
8197
|
+
return false;
|
|
8198
|
+
}
|
|
8199
|
+
async function resolvePseudoParametersForInvoke(stackRegion, options) {
|
|
8200
|
+
const logger = getLogger();
|
|
8201
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? stackRegion;
|
|
8202
|
+
if (!region) logger.warn(`Resolver references \${AWS::Region} but ${getEmbedConfig().binaryName} could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.`);
|
|
8203
|
+
let accountId;
|
|
8204
|
+
try {
|
|
8205
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
8206
|
+
const sts = new STSClient({ ...region && { region } });
|
|
8207
|
+
try {
|
|
8208
|
+
accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
8209
|
+
} finally {
|
|
8210
|
+
sts.destroy();
|
|
8211
|
+
}
|
|
8212
|
+
} catch (err) {
|
|
8213
|
+
logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env entries will be dropped with per-key warnings.`);
|
|
8214
|
+
}
|
|
8215
|
+
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
8216
|
+
const bag = {
|
|
8217
|
+
...accountId !== void 0 && { accountId },
|
|
8218
|
+
...region !== void 0 && { region },
|
|
8219
|
+
...partitionAndSuffix && {
|
|
8220
|
+
partition: partitionAndSuffix.partition,
|
|
8221
|
+
urlSuffix: partitionAndSuffix.urlSuffix
|
|
8222
|
+
}
|
|
8223
|
+
};
|
|
8224
|
+
return Object.keys(bag).length === 0 ? void 0 : bag;
|
|
8225
|
+
}
|
|
8226
|
+
function getTemplateEnv$1(resource) {
|
|
8227
|
+
const env = (resource.Properties ?? {})["Environment"];
|
|
8228
|
+
if (!env || typeof env !== "object") return void 0;
|
|
8229
|
+
const vars = env["Variables"];
|
|
8230
|
+
if (!vars || typeof vars !== "object") return void 0;
|
|
8231
|
+
return vars;
|
|
8232
|
+
}
|
|
8233
|
+
function readEnvOverridesFile$4(filePath) {
|
|
8234
|
+
if (!filePath) return void 0;
|
|
8235
|
+
let raw;
|
|
8236
|
+
try {
|
|
8237
|
+
raw = readFileSync(filePath, "utf-8");
|
|
8238
|
+
} catch (err) {
|
|
8239
|
+
throw new Error(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
8240
|
+
}
|
|
8241
|
+
let parsed;
|
|
8242
|
+
try {
|
|
8243
|
+
parsed = JSON.parse(raw);
|
|
8244
|
+
} catch (err) {
|
|
8245
|
+
throw new Error(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
8246
|
+
}
|
|
8247
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
8248
|
+
return parsed;
|
|
8249
|
+
}
|
|
8250
|
+
async function readEvent$1(options) {
|
|
8251
|
+
if (options.event && options.eventStdin) throw new Error("--event and --event-stdin are mutually exclusive.");
|
|
8252
|
+
if (options.eventStdin) return parseEvent$1(await readStdin$1(), "<stdin>");
|
|
8253
|
+
if (options.event) return parseEvent$1(readFileSync(options.event, "utf-8"), options.event);
|
|
8254
|
+
return {};
|
|
8255
|
+
}
|
|
8256
|
+
function parseEvent$1(raw, source) {
|
|
8257
|
+
try {
|
|
8258
|
+
return JSON.parse(raw);
|
|
8259
|
+
} catch (err) {
|
|
8260
|
+
throw new Error(`Failed to parse event payload from ${source} as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
8261
|
+
}
|
|
8262
|
+
}
|
|
8263
|
+
async function readStdin$1() {
|
|
8264
|
+
const chunks = [];
|
|
8265
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
8266
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
8267
|
+
}
|
|
8268
|
+
async function assumeLambdaExecutionRole$1(roleArn, region) {
|
|
8269
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
8270
|
+
const sts = new STSClient({ ...region && { region } });
|
|
8271
|
+
try {
|
|
8272
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
8273
|
+
RoleArn: roleArn,
|
|
8274
|
+
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-invoke-${Date.now()}`,
|
|
8275
|
+
DurationSeconds: 3600
|
|
8276
|
+
}))).Credentials;
|
|
8277
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
8278
|
+
return {
|
|
8279
|
+
accessKeyId: creds.AccessKeyId,
|
|
8280
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
8281
|
+
sessionToken: creds.SessionToken
|
|
8282
|
+
};
|
|
8283
|
+
} finally {
|
|
8284
|
+
sts.destroy();
|
|
8285
|
+
}
|
|
8286
|
+
}
|
|
8287
|
+
function forwardAwsEnv$3(env) {
|
|
8288
|
+
for (const key of [
|
|
8289
|
+
"AWS_ACCESS_KEY_ID",
|
|
8290
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
8291
|
+
"AWS_SESSION_TOKEN",
|
|
8292
|
+
"AWS_REGION",
|
|
8293
|
+
"AWS_DEFAULT_REGION"
|
|
8294
|
+
]) {
|
|
8295
|
+
const value = process.env[key];
|
|
8296
|
+
if (value !== void 0) env[key] = value;
|
|
8297
|
+
}
|
|
8298
|
+
}
|
|
8299
|
+
/**
|
|
8300
|
+
* Resolve `--profile <p>` to a concrete credential set. Mirrors the
|
|
8301
|
+
* helper in `local-start-api.ts`.
|
|
8302
|
+
*/
|
|
8303
|
+
async function resolveProfileCredentials$1(profile) {
|
|
8304
|
+
const { STSClient } = await import("@aws-sdk/client-sts");
|
|
8305
|
+
const sts = new STSClient({ profile });
|
|
8306
|
+
try {
|
|
8307
|
+
const credsProvider = sts.config.credentials;
|
|
8308
|
+
const creds = typeof credsProvider === "function" ? await credsProvider() : credsProvider;
|
|
8309
|
+
if (!creds || !creds.accessKeyId || !creds.secretAccessKey) throw new Error(`--profile '${profile}': credential provider chain resolved without usable credentials. Check \`aws sso login --profile ` + profile + "` for SSO profiles, or `~/.aws/credentials` / `~/.aws/config` for regular profiles.");
|
|
8310
|
+
return {
|
|
8311
|
+
accessKeyId: creds.accessKeyId,
|
|
8312
|
+
secretAccessKey: creds.secretAccessKey,
|
|
8313
|
+
...creds.sessionToken && { sessionToken: creds.sessionToken }
|
|
8314
|
+
};
|
|
8315
|
+
} finally {
|
|
8316
|
+
sts.destroy();
|
|
8317
|
+
}
|
|
8318
|
+
}
|
|
8319
|
+
function applyProfileCredentialsOverlay(env, profileCreds, assumeRoleActive) {
|
|
8320
|
+
if (!profileCreds) return;
|
|
8321
|
+
if (assumeRoleActive) return;
|
|
8322
|
+
env["AWS_ACCESS_KEY_ID"] = profileCreds.accessKeyId;
|
|
8323
|
+
env["AWS_SECRET_ACCESS_KEY"] = profileCreds.secretAccessKey;
|
|
8324
|
+
if (profileCreds.sessionToken) env["AWS_SESSION_TOKEN"] = profileCreds.sessionToken;
|
|
8325
|
+
else delete env["AWS_SESSION_TOKEN"];
|
|
8326
|
+
}
|
|
8327
|
+
function materializeInlineCode$2(handler, source, fileExtension) {
|
|
8328
|
+
const lastDot = handler.lastIndexOf(".");
|
|
8329
|
+
if (lastDot <= 0) throw new Error(`Handler '${handler}' is malformed: expected '<modulePath>.<exportName>'.`);
|
|
8330
|
+
const modulePath = handler.substring(0, lastDot);
|
|
8331
|
+
const dir = mkdtempSync(path.join(tmpdir(), `${getEmbedConfig().resourceNamePrefix}-invoke-`));
|
|
8332
|
+
const filePath = path.join(dir, `${modulePath}${fileExtension}`);
|
|
8333
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
8334
|
+
writeFileSync(filePath, source, "utf-8");
|
|
8335
|
+
return dir;
|
|
8336
|
+
}
|
|
8337
|
+
function suggestAssumeRoleFromState(state, logicalId) {
|
|
8338
|
+
const logger = getLogger();
|
|
8339
|
+
const roleArn = resolveExecutionRoleArnFromState(state, logicalId);
|
|
8340
|
+
if (roleArn) logger.info(`Hint: the deployed function uses execution role ${roleArn}. Re-run with --assume-role to invoke under the deployed function's narrow permissions.`);
|
|
8341
|
+
}
|
|
8342
|
+
/**
|
|
8343
|
+
* Resolve the role ARN to assume for a Lambda invoke, honoring the three
|
|
8344
|
+
* `--assume-role` forms:
|
|
8345
|
+
*
|
|
8346
|
+
* - `--assume-role <arn>` (explicit) → return `<arn>`.
|
|
8347
|
+
* - `--assume-role` (bare, no value) → resolve from CFn state first;
|
|
8348
|
+
* if that misses, fall back to
|
|
8349
|
+
* `stateProvider.resolveLambdaExecutionRoleArn(<physicalId>)`
|
|
8350
|
+
* (a `lambda:GetFunctionConfiguration` call) so a sibling-stack
|
|
8351
|
+
* execution role still resolves (issue #181 — `ListStackResources`
|
|
8352
|
+
* returns the role's name, not its ARN, so `attributes.Arn` is
|
|
8353
|
+
* empty on the CFn state map and the state-only lookup misses).
|
|
8354
|
+
* - `--assume-role` absent → return undefined (no assume).
|
|
8355
|
+
*
|
|
8356
|
+
* Logs the resolution path (info on success, warn on miss) so the user
|
|
8357
|
+
* can tell why the container did or did not get assumed-role creds.
|
|
8358
|
+
*
|
|
8359
|
+
* Exported for unit testing.
|
|
8360
|
+
*/
|
|
8361
|
+
async function resolveAssumeRoleArnForLambda(assumeRole, stateForRoleHint, stateProvider, lambdaLogicalId) {
|
|
8362
|
+
const logger = getLogger();
|
|
8363
|
+
if (typeof assumeRole === "string") return assumeRole;
|
|
8364
|
+
if (assumeRole !== true) return;
|
|
8365
|
+
if (!stateForRoleHint) {
|
|
8366
|
+
logger.warn("--assume-role passed without an ARN, but no state was loaded. Pair it with a state-source flag, or pass the ARN explicitly: --assume-role <arn>. Falling back to the developer's shell credentials.");
|
|
8367
|
+
return;
|
|
8368
|
+
}
|
|
8369
|
+
const fromState = resolveExecutionRoleArnFromState(stateForRoleHint, lambdaLogicalId);
|
|
8370
|
+
if (fromState) {
|
|
8371
|
+
logger.info(`--assume-role: auto-resolved execution role from state: ${fromState}`);
|
|
8372
|
+
return fromState;
|
|
8373
|
+
}
|
|
8374
|
+
const fnPhysicalId = stateForRoleHint.resources[lambdaLogicalId]?.physicalId;
|
|
8375
|
+
if (stateProvider?.resolveLambdaExecutionRoleArn && fnPhysicalId) {
|
|
8376
|
+
const liveArn = await stateProvider.resolveLambdaExecutionRoleArn(fnPhysicalId);
|
|
8377
|
+
if (liveArn) {
|
|
8378
|
+
logger.info(`--assume-role: auto-resolved execution role from GetFunctionConfiguration: ${liveArn}`);
|
|
8379
|
+
return liveArn;
|
|
8380
|
+
}
|
|
8381
|
+
}
|
|
8382
|
+
logger.warn(`--assume-role: could not resolve the execution role ARN for '${lambdaLogicalId}'. Pass the ARN explicitly: --assume-role <arn>. Falling back to the developer's shell credentials.`);
|
|
8383
|
+
}
|
|
8384
|
+
function resolveExecutionRoleArnFromState(state, logicalId, roleProperty = "Role") {
|
|
8385
|
+
const lambda = state.resources[logicalId];
|
|
8386
|
+
if (!lambda) return void 0;
|
|
8387
|
+
const roleRef = lambda.properties?.[roleProperty] ?? lambda.observedProperties?.[roleProperty];
|
|
8388
|
+
if (typeof roleRef === "string" && roleRef.startsWith("arn:")) return roleRef;
|
|
8389
|
+
if (typeof roleRef === "object" && roleRef !== null) {
|
|
8390
|
+
const refLogicalId = pickReferencedLogicalId(roleRef);
|
|
8391
|
+
if (refLogicalId) {
|
|
8392
|
+
const cached = state.resources[refLogicalId]?.attributes?.["Arn"];
|
|
8393
|
+
if (typeof cached === "string" && cached.startsWith("arn:")) return cached;
|
|
8394
|
+
}
|
|
8395
|
+
}
|
|
8396
|
+
}
|
|
8397
|
+
function pickReferencedLogicalId(intrinsic) {
|
|
8398
|
+
if ("Ref" in intrinsic && typeof intrinsic["Ref"] === "string") return intrinsic["Ref"];
|
|
8399
|
+
if ("Fn::GetAtt" in intrinsic) {
|
|
8400
|
+
const arg = intrinsic["Fn::GetAtt"];
|
|
8401
|
+
if (Array.isArray(arg) && typeof arg[0] === "string") return arg[0];
|
|
8402
|
+
if (typeof arg === "string") return arg.split(".")[0];
|
|
8403
|
+
}
|
|
8404
|
+
}
|
|
8405
|
+
function createLocalInvokeCommand(opts = {}) {
|
|
8406
|
+
setEmbedConfig(opts.embedConfig);
|
|
8407
|
+
const invoke = new Command("invoke").description("Run a Lambda function locally in a Docker container (RIE-backed). Target accepts a CDK display path (MyStack/MyApi/Handler) or stack-qualified logical ID (MyStack:MyApiHandler1234ABCD). Single-stack apps may omit the stack prefix. Omit <target> in an interactive terminal to pick the Lambda from a list.").argument("[target]", "CDK display path or stack-qualified logical ID of the Lambda to invoke (omit to pick interactively in a TTY)").action(withErrorHandling(async (target, options) => {
|
|
8408
|
+
await localInvokeCommand(target, options, opts.extraStateProviders);
|
|
8409
|
+
}));
|
|
8410
|
+
addInvokeSpecificOptions(invoke);
|
|
8411
|
+
[
|
|
8412
|
+
...commonOptions(),
|
|
8413
|
+
...appOptions(),
|
|
8414
|
+
...contextOptions
|
|
8415
|
+
].forEach((option) => invoke.addOption(option));
|
|
8416
|
+
invoke.addOption(deprecatedRegionOption);
|
|
8417
|
+
return invoke;
|
|
8418
|
+
}
|
|
8419
|
+
/**
|
|
8420
|
+
* Register the option block that `cdkl invoke` adds on top of the shared
|
|
8421
|
+
* common / app / context option helpers. Shared between `cdkl invoke` and
|
|
8422
|
+
* any host CLI (e.g. cdkd's `local invoke`) that wraps the single-shot
|
|
8423
|
+
* RIE-backed Lambda runner, so adding or renaming an `invoke`-only flag
|
|
8424
|
+
* here propagates to every embedder without duplicate `.addOption(...)`
|
|
8425
|
+
* blocks.
|
|
8426
|
+
*
|
|
8427
|
+
* Calling order only affects `--help` presentation (Commander parses
|
|
8428
|
+
* insertion-order-independent). The host-CLI convention is host-specific
|
|
8429
|
+
* options first, then this helper, then the shared common / app / context
|
|
8430
|
+
* options — host flags / invoke flags / common flags grouped in three
|
|
8431
|
+
* `--help` clusters. Chainable: returns `cmd`.
|
|
8432
|
+
*/
|
|
8433
|
+
function addInvokeSpecificOptions(cmd) {
|
|
8434
|
+
return cmd.addOption(new Option("-e, --event <file>", "JSON event payload file (default: {})")).addOption(new Option("--event-stdin", "Read event JSON from stdin").default(false)).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}})")).addOption(new Option("--no-pull", "Skip docker pull (use cached image) — no-op for IMAGE local-build path; `docker build` does not pull base layers by default")).addOption(new Option("--no-build", "Skip docker build on the IMAGE local-build path (use the previously-built tag). Requires the deterministic tag to already be in the local registry; errors with an actionable message when missing. No-op for ZIP Lambdas and the IMAGE ECR-pull path. Compatible with --no-pull.")).addOption(new Option("--debug-port <port>", "Node --inspect-brk port (default: off)")).addOption(new Option("--container-host <host>", "Host to bind the RIE port to").default("127.0.0.1")).addOption(new Option("--assume-role [arn]", "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. Three forms: (1) `--assume-role <arn>` assumes the explicit ARN; (2) `--assume-role` (bare) auto-resolves the function's execution role ARN from state (requires an active state source); (3) `--no-assume-role` explicitly opts out. Off by default — when omitted, the developer's shell credentials are forwarded unchanged (SAM-compatible default). STS failures degrade to a warn + dev-creds fallback.")).addOption(new Option("--layer-role-arn <arn>", "Role to sts:AssumeRole before calling lambda:GetLayerVersion on every literal-ARN entry in Properties.Layers. Use only when the dev credentials cannot read the layer — typically cross-account layers. AWS-published public layers (e.g. Lambda Powertools) are readable from every account and need no role.")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries. Issues sts:AssumeRole via the default credential chain and uses the temporary credentials for ecr:GetAuthorizationToken + docker pull. Required when the caller does not have direct cross-account access to the target repository. Same-account / same-region pulls do not need this flag.")).addOption(new Option("--from-cfn-stack [cfn-stack-name]", "Read a deployed CloudFormation stack via ListStackResources and substitute Ref / Fn::ImportValue in env vars with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (`cdk deploy`). Bare form uses the resolved stack name; pass an explicit value when CFn stack name differs. Fn::GetAtt is warn-and-dropped in v1 (CFn ListStackResources does not return per-attribute values).")).addOption(new Option("--stack-region <region>", "Region of the state record to read. Used with --from-cfn-stack as the CFn client region."));
|
|
8435
|
+
}
|
|
8436
|
+
|
|
7802
8437
|
//#endregion
|
|
7803
8438
|
//#region src/local/agentcore-code-build.ts
|
|
7804
8439
|
/**
|
|
@@ -15921,7 +16556,7 @@ async function localStartApiCommand(targets, options, extraStateProviders) {
|
|
|
15921
16556
|
await ensureDockerAvailable();
|
|
15922
16557
|
const appCmd = resolveApp(options.app);
|
|
15923
16558
|
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
15924
|
-
const overrides = readEnvOverridesFile$
|
|
16559
|
+
const overrides = readEnvOverridesFile$3(options.envVars);
|
|
15925
16560
|
const debugPortBase = options.debugPortBase ? parseDebugPort(options.debugPortBase) : void 0;
|
|
15926
16561
|
const perLambdaConcurrency = parsePerLambdaConcurrency(options.perLambdaConcurrency);
|
|
15927
16562
|
const inlineTmpDirs = /* @__PURE__ */ new Set();
|
|
@@ -16478,1152 +17113,2009 @@ function deriveSynthStackPrefix(targets) {
|
|
|
16478
17113
|
if (slash === -1) return void 0;
|
|
16479
17114
|
prefixes.add(t.slice(0, slash));
|
|
16480
17115
|
}
|
|
16481
|
-
return prefixes.size === 1 ? [...prefixes][0] : void 0;
|
|
17116
|
+
return prefixes.size === 1 ? [...prefixes][0] : void 0;
|
|
17117
|
+
}
|
|
17118
|
+
/**
|
|
17119
|
+
* Decide whether synth should cover ALL stacks for the given target
|
|
17120
|
+
* subset (item 5). Synth is scoped to one stack only when a single stack
|
|
17121
|
+
* can be pinned; otherwise we synth every stack and the union is filtered
|
|
17122
|
+
* down by `apiFilters` afterwards.
|
|
17123
|
+
*
|
|
17124
|
+
* Returns `true` (synth all) when there ARE explicit targets but no
|
|
17125
|
+
* single stack can be pinned from them — i.e. `--stack` is unset, an
|
|
17126
|
+
* explicit `--from-cfn-stack <name>` is unset, AND
|
|
17127
|
+
* {@link deriveSynthStackPrefix} can't resolve one shared prefix (targets
|
|
17128
|
+
* span stacks, or any target is a bare logical id). Returns `false` when
|
|
17129
|
+
* there are no targets (serve-all already synths everything via the
|
|
17130
|
+
* regular path) or a stack is otherwise pinned.
|
|
17131
|
+
*
|
|
17132
|
+
* @internal exported for unit tests.
|
|
17133
|
+
*/
|
|
17134
|
+
function shouldSynthAllStacks(targets, stackPattern, cfnStackFallback) {
|
|
17135
|
+
if (targets.length === 0) return false;
|
|
17136
|
+
if (stackPattern !== void 0 || cfnStackFallback !== void 0) return false;
|
|
17137
|
+
return deriveSynthStackPrefix(targets) === void 0;
|
|
17138
|
+
}
|
|
17139
|
+
/**
|
|
17140
|
+
* Decide whether bare `start-api` should open the pre-selected-all
|
|
17141
|
+
* multi-select picker. The picker runs ONLY when:
|
|
17142
|
+
* - no explicit API subset was named (`apiFilters` empty — neither
|
|
17143
|
+
* positional `<targets...>` nor the deprecated `--api`),
|
|
17144
|
+
* - `--all-stacks` was not passed (that path already serves every API),
|
|
17145
|
+
* - AND stdin/stdout is a TTY.
|
|
17146
|
+
*
|
|
17147
|
+
* In a non-TTY (CI / pipe) this returns `false`, and the caller serves
|
|
17148
|
+
* EVERY discovered API without prompting — start-api's one intentional
|
|
17149
|
+
* asymmetry vs invoke / run-task (which error when bare in a non-TTY),
|
|
17150
|
+
* because start-api legitimately has a "serve all" default.
|
|
17151
|
+
*
|
|
17152
|
+
* Extracted as a pure function (TTY is passed in) so the decision is
|
|
17153
|
+
* unit-testable without booting the server or juggling global TTY state.
|
|
17154
|
+
*
|
|
17155
|
+
* @internal exported for unit tests.
|
|
17156
|
+
*/
|
|
17157
|
+
function shouldPromptBareMultiSelect(apiFilters, allStacks, isTty) {
|
|
17158
|
+
return apiFilters.length === 0 && !allStacks && isTty;
|
|
17159
|
+
}
|
|
17160
|
+
/**
|
|
17161
|
+
* Decide whether the `--from-cfn-stack <name>` redundancy tip should
|
|
17162
|
+
* fire for the current invocation. Fires only when:
|
|
17163
|
+
* - `fromCfnStack` is a non-empty STRING (explicit value, not bare `true`)
|
|
17164
|
+
* - exactly ONE stack is routed
|
|
17165
|
+
* - the explicit value equals the routed stack's `stackName`
|
|
17166
|
+
*
|
|
17167
|
+
* Extracted as a pure function so it can be unit-tested without booting
|
|
17168
|
+
* the full server. See improvement A in the start-api UX PR.
|
|
17169
|
+
*
|
|
17170
|
+
* @internal exported for unit tests.
|
|
17171
|
+
*/
|
|
17172
|
+
function shouldEmitFromCfnRedundancyTip(fromCfnStack, routedStackNames) {
|
|
17173
|
+
if (typeof fromCfnStack !== "string") return false;
|
|
17174
|
+
if (fromCfnStack.length === 0) return false;
|
|
17175
|
+
if (routedStackNames.length !== 1) return false;
|
|
17176
|
+
return fromCfnStack === routedStackNames[0];
|
|
17177
|
+
}
|
|
17178
|
+
/**
|
|
17179
|
+
* One-shot wrapper around `shouldEmitFromCfnRedundancyTip` for the
|
|
17180
|
+
* `--watch` hot-reload path. `synthesizeAndBuild` re-runs on every
|
|
17181
|
+
* reload firing, so without a gate the tip would re-emit on every
|
|
17182
|
+
* reload — noisy. This helper consults the caller-supplied ref:
|
|
17183
|
+
* - If the predicate fires AND the ref is still `false`, calls `emit`
|
|
17184
|
+
* and flips the ref to `true`.
|
|
17185
|
+
* - On subsequent invocations the ref is `true` and the helper is a
|
|
17186
|
+
* no-op for the rest of the ref's lifetime.
|
|
17187
|
+
* - When the predicate does NOT fire (no `--from-cfn-stack` value /
|
|
17188
|
+
* intentionally-different value / multi-stack run), the ref stays
|
|
17189
|
+
* `false` so a future reload whose synthesized stacks change in a
|
|
17190
|
+
* way that DOES make the value redundant still emits the tip once.
|
|
17191
|
+
*
|
|
17192
|
+
* The ref is owned by `localStartApiCommand` (one per server boot), so
|
|
17193
|
+
* independent server invocations get independent flags.
|
|
17194
|
+
*
|
|
17195
|
+
* @internal exported for unit tests.
|
|
17196
|
+
*/
|
|
17197
|
+
function tryEmitFromCfnRedundancyTipOnce(fromCfnStack, routedStackNames, emittedRef, emit) {
|
|
17198
|
+
if (emittedRef.value) return;
|
|
17199
|
+
if (!shouldEmitFromCfnRedundancyTip(fromCfnStack, routedStackNames)) return;
|
|
17200
|
+
const routedStackName = routedStackNames[0];
|
|
17201
|
+
if (routedStackName === void 0) return;
|
|
17202
|
+
emit(routedStackName);
|
|
17203
|
+
emittedRef.value = true;
|
|
17204
|
+
}
|
|
17205
|
+
/**
|
|
17206
|
+
* Distinct, stable list of Lambda logical IDs reachable through any
|
|
17207
|
+
* discovered route OR referenced by a Lambda authorizer attached to one
|
|
17208
|
+
* of those routes. Stable order = first-occurrence order in the routes
|
|
17209
|
+
* list, then any newly-introduced authorizer Lambdas, which keeps the
|
|
17210
|
+
* route-table output deterministic.
|
|
17211
|
+
*/
|
|
17212
|
+
function uniqueLambdaIds(routes, routesWithAuth, webSocketApis = []) {
|
|
17213
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17214
|
+
const out = [];
|
|
17215
|
+
for (const r of routes) {
|
|
17216
|
+
if (r.unsupported || r.mockCors || r.serviceIntegration) continue;
|
|
17217
|
+
if (r.lambdaLogicalId.length === 0) continue;
|
|
17218
|
+
if (!seen.has(r.lambdaLogicalId)) {
|
|
17219
|
+
seen.add(r.lambdaLogicalId);
|
|
17220
|
+
out.push(r.lambdaLogicalId);
|
|
17221
|
+
}
|
|
17222
|
+
}
|
|
17223
|
+
for (const entry of routesWithAuth) {
|
|
17224
|
+
if (entry.route.unsupported || entry.route.mockCors || entry.route.serviceIntegration) continue;
|
|
17225
|
+
const auth = entry.authorizer;
|
|
17226
|
+
if (!auth) continue;
|
|
17227
|
+
if (auth.kind === "lambda-token" || auth.kind === "lambda-request") {
|
|
17228
|
+
if (!seen.has(auth.lambdaLogicalId)) {
|
|
17229
|
+
seen.add(auth.lambdaLogicalId);
|
|
17230
|
+
out.push(auth.lambdaLogicalId);
|
|
17231
|
+
}
|
|
17232
|
+
}
|
|
17233
|
+
}
|
|
17234
|
+
for (const api of webSocketApis) for (const r of api.routes) if (!seen.has(r.targetLambdaLogicalId)) {
|
|
17235
|
+
seen.add(r.targetLambdaLogicalId);
|
|
17236
|
+
out.push(r.targetLambdaLogicalId);
|
|
17237
|
+
}
|
|
17238
|
+
return out;
|
|
17239
|
+
}
|
|
17240
|
+
/**
|
|
17241
|
+
* Prefetch the JWKS for every Cognito / JWT authorizer attached to a
|
|
17242
|
+
* discovered route. Failures degrade to pass-through mode (verifier
|
|
17243
|
+
* surfaces a warn line on first hit); we still issue the prefetch so
|
|
17244
|
+
* the warn lands at startup rather than mid-request.
|
|
17245
|
+
*/
|
|
17246
|
+
async function prewarmJwks(routesWithAuth, jwksCache) {
|
|
17247
|
+
const urls = /* @__PURE__ */ new Set();
|
|
17248
|
+
for (const entry of routesWithAuth) {
|
|
17249
|
+
const auth = entry.authorizer;
|
|
17250
|
+
if (!auth) continue;
|
|
17251
|
+
if (auth.kind === "cognito") for (const pool of auth.pools) urls.add(buildCognitoJwksUrl(pool.region, pool.userPoolId));
|
|
17252
|
+
else if (auth.kind === "jwt") {
|
|
17253
|
+
const url = auth.region && auth.userPoolId ? buildCognitoJwksUrl(auth.region, auth.userPoolId) : buildJwksUrlFromIssuer(auth.issuer);
|
|
17254
|
+
urls.add(url);
|
|
17255
|
+
}
|
|
17256
|
+
}
|
|
17257
|
+
await Promise.all([...urls].map((u) => jwksCache.fetchAndCache(u)));
|
|
17258
|
+
}
|
|
17259
|
+
/**
|
|
17260
|
+
* Emit a one-line warn for every VPC-config Lambda. The handler still
|
|
17261
|
+
* runs locally, but its container does not get attached to the AWS
|
|
17262
|
+
* VPC's subnets — calls to private RDS / ElastiCache will fail. cdk-local
|
|
17263
|
+
* surfaces this so the developer can pin the unexpected behavior to
|
|
17264
|
+
* the VPC config rather than chasing a "connection refused" rabbit
|
|
17265
|
+
* hole.
|
|
17266
|
+
*/
|
|
17267
|
+
function warnVpcConfigLambdas(routesWithAuth, stacks) {
|
|
17268
|
+
const logger = getLogger();
|
|
17269
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17270
|
+
const reachable = [];
|
|
17271
|
+
for (const entry of routesWithAuth) {
|
|
17272
|
+
if (!seen.has(entry.route.lambdaLogicalId)) {
|
|
17273
|
+
seen.add(entry.route.lambdaLogicalId);
|
|
17274
|
+
reachable.push(entry.route.lambdaLogicalId);
|
|
17275
|
+
}
|
|
17276
|
+
const auth = entry.authorizer;
|
|
17277
|
+
if (auth && (auth.kind === "lambda-token" || auth.kind === "lambda-request")) {
|
|
17278
|
+
if (!seen.has(auth.lambdaLogicalId)) {
|
|
17279
|
+
seen.add(auth.lambdaLogicalId);
|
|
17280
|
+
reachable.push(auth.lambdaLogicalId);
|
|
17281
|
+
}
|
|
17282
|
+
}
|
|
17283
|
+
}
|
|
17284
|
+
for (const logicalId of reachable) for (const stack of stacks) {
|
|
17285
|
+
const resource = stack.template.Resources?.[logicalId];
|
|
17286
|
+
if (!resource || resource.Type !== "AWS::Lambda::Function") continue;
|
|
17287
|
+
const vpcConfig = (resource.Properties ?? {})["VpcConfig"];
|
|
17288
|
+
if (vpcConfig && typeof vpcConfig === "object" && Object.keys(vpcConfig).length > 0) logger.warn(`Lambda ${logicalId} has VpcConfig — local container will reach external services via the host's network, NOT through the deployed VPC's NAT/private subnets. Calls to private RDS/ElastiCache will fail.`);
|
|
17289
|
+
break;
|
|
17290
|
+
}
|
|
16482
17291
|
}
|
|
16483
17292
|
/**
|
|
16484
|
-
*
|
|
16485
|
-
*
|
|
16486
|
-
*
|
|
16487
|
-
*
|
|
16488
|
-
*
|
|
16489
|
-
* Returns `true` (synth all) when there ARE explicit targets but no
|
|
16490
|
-
* single stack can be pinned from them — i.e. `--stack` is unset, an
|
|
16491
|
-
* explicit `--from-cfn-stack <name>` is unset, AND
|
|
16492
|
-
* {@link deriveSynthStackPrefix} can't resolve one shared prefix (targets
|
|
16493
|
-
* span stacks, or any target is a bare logical id). Returns `false` when
|
|
16494
|
-
* there are no targets (serve-all already synths everything via the
|
|
16495
|
-
* regular path) or a stack is otherwise pinned.
|
|
17293
|
+
* Walk the discovered routes for `AuthorizationType: 'AWS_IAM'` and emit
|
|
17294
|
+
* a one-line warn naming the affected routes. Returns `true` when at
|
|
17295
|
+
* least one IAM route is present so the caller wires the SigV4
|
|
17296
|
+
* credentials loader. Re-runs across hot reloads are silent — the warn
|
|
17297
|
+
* fires only at initial boot (matches `warnVpcConfigLambdas`'s policy).
|
|
16496
17298
|
*
|
|
16497
|
-
*
|
|
17299
|
+
* Implementation note: signature verification only — IAM policy
|
|
17300
|
+
* evaluation (resource / action / condition) is NOT emulated. See
|
|
17301
|
+
* `src/local/sigv4-verify.ts` and the help text in `docs/cli-reference.md`.
|
|
16498
17302
|
*/
|
|
16499
|
-
function
|
|
16500
|
-
|
|
16501
|
-
|
|
16502
|
-
|
|
17303
|
+
function warnIamRoutes(routesWithAuth) {
|
|
17304
|
+
const logger = getLogger();
|
|
17305
|
+
const iamRoutes = [];
|
|
17306
|
+
const oacRoutes = [];
|
|
17307
|
+
for (const entry of routesWithAuth) {
|
|
17308
|
+
if (entry.authorizer?.kind !== "iam") continue;
|
|
17309
|
+
if (entry.authorizer.oacFronted === true) oacRoutes.push(entry.route.declaredAt);
|
|
17310
|
+
else iamRoutes.push(entry.route.declaredAt);
|
|
17311
|
+
}
|
|
17312
|
+
if (iamRoutes.length === 0 && oacRoutes.length === 0) return false;
|
|
17313
|
+
if (iamRoutes.length > 0) {
|
|
17314
|
+
logger.warn(`${iamRoutes.length} route(s) declare AuthorizationType: AWS_IAM — ${getEmbedConfig().cliName} start-api verifies the SigV4 signatures it CAN (requests signed with your local AWS credentials), but cannot verify a federated / Cognito Identity Pool / cross-account signer and does NOT emulate IAM policy evaluation (resource / action / condition rules). By default such unverifiable requests warn-and-pass with a placeholder principalId — pass --strict-sigv4 to deny them instead. Downstream authorization is the dev's responsibility.`);
|
|
17315
|
+
for (const declaredAt of iamRoutes) logger.warn(` - ${declaredAt}`);
|
|
17316
|
+
}
|
|
17317
|
+
if (oacRoutes.length > 0) {
|
|
17318
|
+
logger.warn(`${oacRoutes.length} Function URL route(s) with AuthType: AWS_IAM are fronted by a CloudFront Origin Access Control. In production CloudFront re-signs the origin request, so no local client signature can be verified — ${getEmbedConfig().cliName} start-api passes these through (warn-and-pass) and they ignore --strict-sigv4. Do NOT trust the request identity in handler code.`);
|
|
17319
|
+
for (const declaredAt of oacRoutes) logger.warn(` - ${declaredAt}`);
|
|
17320
|
+
}
|
|
17321
|
+
return true;
|
|
16503
17322
|
}
|
|
16504
17323
|
/**
|
|
16505
|
-
*
|
|
16506
|
-
*
|
|
16507
|
-
*
|
|
16508
|
-
*
|
|
16509
|
-
*
|
|
16510
|
-
* - AND stdin/stdout is a TTY.
|
|
16511
|
-
*
|
|
16512
|
-
* In a non-TTY (CI / pipe) this returns `false`, and the caller serves
|
|
16513
|
-
* EVERY discovered API without prompting — start-api's one intentional
|
|
16514
|
-
* asymmetry vs invoke / run-task (which error when bare in a non-TTY),
|
|
16515
|
-
* because start-api legitimately has a "serve all" default.
|
|
16516
|
-
*
|
|
16517
|
-
* Extracted as a pure function (TTY is passed in) so the decision is
|
|
16518
|
-
* unit-testable without booting the server or juggling global TTY state.
|
|
16519
|
-
*
|
|
16520
|
-
* @internal exported for unit tests.
|
|
17324
|
+
* Build the per-Lambda container spec — code dir, env vars (template +
|
|
17325
|
+
* --env-vars overlay), STS-issued creds when --assume-role names this
|
|
17326
|
+
* Lambda, optional --debug-port reservation. Errors out with a clear
|
|
17327
|
+
* message if the Lambda's code can't be resolved (asset directory
|
|
17328
|
+
* missing, runtime not supported).
|
|
16521
17329
|
*/
|
|
16522
|
-
function
|
|
16523
|
-
|
|
17330
|
+
async function buildContainerSpec(args) {
|
|
17331
|
+
const { logicalId, stacks, overrides, assumeRole, containerHost, debugPort, stsRegion, inlineTmpDirs, layerTmpDirs, stateByStack, skipPull, layerRoleArn, profileCredentials, profileCredsFile, profileRegion, stackRegionOverride } = args;
|
|
17332
|
+
const lambda = resolveLambdaByLogicalId(logicalId, stacks);
|
|
17333
|
+
let codeDir;
|
|
17334
|
+
let optDir;
|
|
17335
|
+
let imageRef;
|
|
17336
|
+
let platform;
|
|
17337
|
+
if (lambda.kind === "zip") {
|
|
17338
|
+
codeDir = lambda.codePath ?? materializeInlineCode$1(lambda.handler, lambda.inlineCode ?? "", resolveRuntimeFileExtension(lambda.runtime), inlineTmpDirs);
|
|
17339
|
+
optDir = await materializeLambdaLayers(lambda.layers, layerTmpDirs, layerRoleArn);
|
|
17340
|
+
} else {
|
|
17341
|
+
imageRef = (await resolveContainerImageForStartApi(lambda, skipPull, args.profile)).imageRef;
|
|
17342
|
+
platform = architectureToPlatform(lambda.architecture);
|
|
17343
|
+
}
|
|
17344
|
+
let templateEnv = getTemplateEnv(lambda.resource);
|
|
17345
|
+
const stateBundle = stateByStack.get(lambda.stack.stackName);
|
|
17346
|
+
let stateAudit;
|
|
17347
|
+
if (stateBundle) {
|
|
17348
|
+
const context = { resources: stateBundle.state.resources };
|
|
17349
|
+
if (stateBundle.pseudoParameters) context.pseudoParameters = stateBundle.pseudoParameters;
|
|
17350
|
+
if (stateBundle.ssmParameters) context.parameters = stateBundle.ssmParameters;
|
|
17351
|
+
if (stateBundle.ssmSecureStringLogicalIds?.length) context.sensitiveParameters = new Set(stateBundle.ssmSecureStringLogicalIds);
|
|
17352
|
+
const { env, audit } = substituteEnvVarsFromState(templateEnv, context);
|
|
17353
|
+
templateEnv = env;
|
|
17354
|
+
for (const key of audit.resolvedKeys) getLogger().debug(`Lambda ${logicalId}: state source substituted env var ${key}`);
|
|
17355
|
+
let unresolved = audit.unresolved;
|
|
17356
|
+
const resolvedKeys = [...audit.resolvedKeys];
|
|
17357
|
+
const deployedEnv = stateBundle.deployedEnvByLambda?.get(logicalId);
|
|
17358
|
+
if (unresolved.length > 0 && deployedEnv) {
|
|
17359
|
+
const fb = applyDeployedEnvFallback(templateEnv, unresolved, deployedEnv);
|
|
17360
|
+
templateEnv = fb.env;
|
|
17361
|
+
unresolved = fb.stillUnresolved;
|
|
17362
|
+
for (const key of fb.filled) {
|
|
17363
|
+
resolvedKeys.push(key);
|
|
17364
|
+
getLogger().debug(`Lambda ${logicalId}: filled env var ${key} from deployed function config`);
|
|
17365
|
+
}
|
|
17366
|
+
}
|
|
17367
|
+
stateAudit = {
|
|
17368
|
+
resolvedKeys,
|
|
17369
|
+
unresolved,
|
|
17370
|
+
sensitiveKeys: audit.sensitiveKeys
|
|
17371
|
+
};
|
|
17372
|
+
for (const { key, reason } of unresolved) getLogger().warn(`Lambda ${logicalId}: state source could not substitute env var ${key} (${reason}). Override it via --env-vars or it will be dropped.`);
|
|
17373
|
+
}
|
|
17374
|
+
const lambdaCdkPath = readCdkPathOrUndefined(lambda.resource);
|
|
17375
|
+
const envResult = resolveEnvVars(logicalId, lambdaCdkPath, templateEnv, overrides);
|
|
17376
|
+
for (const key of envResult.unresolved) {
|
|
17377
|
+
if (stateAudit && stateAudit.unresolved.some((u) => u.key === key)) continue;
|
|
17378
|
+
const overrideKeyExample = lambdaCdkPath?.replace(/\/Resource$/, "") ?? logicalId;
|
|
17379
|
+
getLogger().warn(`Lambda ${logicalId}: env var ${key} contains a CloudFormation intrinsic and was dropped. Override it with --env-vars (e.g. {"${overrideKeyExample}":{"${key}":"<literal>"}}) or pass a state-source flag (e.g. --from-cfn-stack or a host-provided extension) to recover deployed values.`);
|
|
17380
|
+
}
|
|
17381
|
+
const dockerEnv = {
|
|
17382
|
+
AWS_LAMBDA_FUNCTION_NAME: logicalId,
|
|
17383
|
+
AWS_LAMBDA_FUNCTION_MEMORY_SIZE: String(lambda.memoryMb),
|
|
17384
|
+
AWS_LAMBDA_FUNCTION_TIMEOUT: String(lambda.timeoutSec),
|
|
17385
|
+
AWS_LAMBDA_FUNCTION_VERSION: "$LATEST",
|
|
17386
|
+
AWS_LAMBDA_LOG_GROUP_NAME: `/aws/lambda/${logicalId}`,
|
|
17387
|
+
AWS_LAMBDA_LOG_STREAM_NAME: "local",
|
|
17388
|
+
...envResult.resolved
|
|
17389
|
+
};
|
|
17390
|
+
const roleArn = effectiveAssumeRoleArn(logicalId, assumeRole);
|
|
17391
|
+
if (roleArn) {
|
|
17392
|
+
const creds = await assumeLambdaExecutionRole(roleArn, stsRegion);
|
|
17393
|
+
dockerEnv["AWS_ACCESS_KEY_ID"] = creds.accessKeyId;
|
|
17394
|
+
dockerEnv["AWS_SECRET_ACCESS_KEY"] = creds.secretAccessKey;
|
|
17395
|
+
dockerEnv["AWS_SESSION_TOKEN"] = creds.sessionToken;
|
|
17396
|
+
if (stsRegion) dockerEnv["AWS_REGION"] = stsRegion;
|
|
17397
|
+
} else {
|
|
17398
|
+
forwardAwsEnv$2(dockerEnv);
|
|
17399
|
+
if (profileCredentials) {
|
|
17400
|
+
dockerEnv["AWS_ACCESS_KEY_ID"] = profileCredentials.accessKeyId;
|
|
17401
|
+
dockerEnv["AWS_SECRET_ACCESS_KEY"] = profileCredentials.secretAccessKey;
|
|
17402
|
+
if (profileCredentials.sessionToken) dockerEnv["AWS_SESSION_TOKEN"] = profileCredentials.sessionToken;
|
|
17403
|
+
else delete dockerEnv["AWS_SESSION_TOKEN"];
|
|
17404
|
+
}
|
|
17405
|
+
if (profileCredsFile) {
|
|
17406
|
+
dockerEnv["AWS_SHARED_CREDENTIALS_FILE"] = profileCredsFile.containerPath;
|
|
17407
|
+
dockerEnv["AWS_PROFILE"] = profileCredsFile.profileName;
|
|
17408
|
+
}
|
|
17409
|
+
}
|
|
17410
|
+
if (!dockerEnv["AWS_REGION"]) {
|
|
17411
|
+
const fallbackRegion = resolveContainerFallbackRegion({
|
|
17412
|
+
stackRegionOverride,
|
|
17413
|
+
synthRegion: lambda.stack.region,
|
|
17414
|
+
profileRegion
|
|
17415
|
+
});
|
|
17416
|
+
if (fallbackRegion) dockerEnv["AWS_REGION"] = fallbackRegion;
|
|
17417
|
+
}
|
|
17418
|
+
if (debugPort !== void 0) dockerEnv["NODE_OPTIONS"] = `--inspect-brk=0.0.0.0:${debugPort}`;
|
|
17419
|
+
const tmpfs = lambda.ephemeralStorageMb !== void 0 ? {
|
|
17420
|
+
target: "/tmp",
|
|
17421
|
+
sizeMb: lambda.ephemeralStorageMb
|
|
17422
|
+
} : void 0;
|
|
17423
|
+
const sensitiveEnvKeys = stateAudit && stateAudit.sensitiveKeys.length > 0 ? new Set(stateAudit.sensitiveKeys) : void 0;
|
|
17424
|
+
if (lambda.kind === "zip") return {
|
|
17425
|
+
kind: "zip",
|
|
17426
|
+
lambda,
|
|
17427
|
+
codeDir,
|
|
17428
|
+
env: dockerEnv,
|
|
17429
|
+
...sensitiveEnvKeys && { sensitiveEnvKeys },
|
|
17430
|
+
containerHost,
|
|
17431
|
+
...optDir !== void 0 && { optDir },
|
|
17432
|
+
...debugPort !== void 0 && { debugPort },
|
|
17433
|
+
...tmpfs !== void 0 && { tmpfs },
|
|
17434
|
+
...profileCredsFile && { profileCredentialsFile: {
|
|
17435
|
+
hostPath: profileCredsFile.hostPath,
|
|
17436
|
+
containerPath: profileCredsFile.containerPath
|
|
17437
|
+
} }
|
|
17438
|
+
};
|
|
17439
|
+
return {
|
|
17440
|
+
kind: "image",
|
|
17441
|
+
lambda,
|
|
17442
|
+
image: imageRef,
|
|
17443
|
+
platform,
|
|
17444
|
+
command: lambda.imageConfig.command ?? [],
|
|
17445
|
+
...lambda.imageConfig.entryPoint !== void 0 && lambda.imageConfig.entryPoint.length > 0 && { entryPoint: lambda.imageConfig.entryPoint },
|
|
17446
|
+
...lambda.imageConfig.workingDirectory !== void 0 && { workingDir: lambda.imageConfig.workingDirectory },
|
|
17447
|
+
env: dockerEnv,
|
|
17448
|
+
...sensitiveEnvKeys && { sensitiveEnvKeys },
|
|
17449
|
+
containerHost,
|
|
17450
|
+
...debugPort !== void 0 && { debugPort },
|
|
17451
|
+
...tmpfs !== void 0 && { tmpfs },
|
|
17452
|
+
...profileCredsFile && { profileCredentialsFile: {
|
|
17453
|
+
hostPath: profileCredsFile.hostPath,
|
|
17454
|
+
containerPath: profileCredsFile.containerPath
|
|
17455
|
+
} }
|
|
17456
|
+
};
|
|
16524
17457
|
}
|
|
16525
17458
|
/**
|
|
16526
|
-
*
|
|
16527
|
-
*
|
|
16528
|
-
*
|
|
16529
|
-
*
|
|
16530
|
-
*
|
|
16531
|
-
*
|
|
16532
|
-
* Extracted as a pure function so it can be unit-tested without booting
|
|
16533
|
-
* the full server. See improvement A in the start-api UX PR.
|
|
17459
|
+
* Resolve a container Lambda's local docker image — local build from
|
|
17460
|
+
* `cdk.out` asset manifest first, ECR-pull fallback when the asset
|
|
17461
|
+
* manifest has no matching entry. Mirrors `cdkl invoke`'s
|
|
17462
|
+
* `resolveContainerImagePlan` shape; the start-api server doesn't
|
|
17463
|
+
* need the no-build flag (deterministic-tag cache reuse is automatic
|
|
17464
|
+
* across reloads because the per-Lambda tag is content-addressed).
|
|
16534
17465
|
*
|
|
16535
|
-
*
|
|
17466
|
+
* Same-account / same-region only on the ECR-pull path (matches the
|
|
17467
|
+
* `cdkl invoke` PR 5 of #224 boundary). Cross-account /
|
|
17468
|
+
* cross-region ECR pull is the W2-1 deferred follow-up.
|
|
16536
17469
|
*/
|
|
16537
|
-
function
|
|
16538
|
-
|
|
16539
|
-
|
|
16540
|
-
if (
|
|
16541
|
-
|
|
17470
|
+
async function resolveContainerImageForStartApi(lambda, skipPull, profile) {
|
|
17471
|
+
const logger = getLogger();
|
|
17472
|
+
const localBuild = await resolveLocalBuildPlan(lambda);
|
|
17473
|
+
if (localBuild) return { imageRef: await buildContainerImage(localBuild.asset, localBuild.cdkOutDir, { architecture: lambda.architecture }) };
|
|
17474
|
+
if (!parseEcrUri(lambda.imageUri)) throw new Error(`Container Lambda '${lambda.logicalId}' has no matching asset in cdk.out, and Code.ImageUri '${lambda.imageUri}' is not an ECR URI ${getEmbedConfig().binaryName} can authenticate against. Re-synthesize the CDK app (so cdk.out includes the build context) or deploy the image to ECR first.`);
|
|
17475
|
+
logger.info(`No matching cdk.out asset for ${lambda.imageUri}; falling back to ECR pull (same-acct/region only)...`);
|
|
17476
|
+
return { imageRef: await pullEcrImage(lambda.imageUri, {
|
|
17477
|
+
skipPull,
|
|
17478
|
+
...profile !== void 0 && { profile }
|
|
17479
|
+
}) };
|
|
16542
17480
|
}
|
|
16543
17481
|
/**
|
|
16544
|
-
*
|
|
16545
|
-
*
|
|
16546
|
-
*
|
|
16547
|
-
* reload — noisy. This helper consults the caller-supplied ref:
|
|
16548
|
-
* - If the predicate fires AND the ref is still `false`, calls `emit`
|
|
16549
|
-
* and flips the ref to `true`.
|
|
16550
|
-
* - On subsequent invocations the ref is `true` and the helper is a
|
|
16551
|
-
* no-op for the rest of the ref's lifetime.
|
|
16552
|
-
* - When the predicate does NOT fire (no `--from-cfn-stack` value /
|
|
16553
|
-
* intentionally-different value / multi-stack run), the ref stays
|
|
16554
|
-
* `false` so a future reload whose synthesized stacks change in a
|
|
16555
|
-
* way that DOES make the value redundant still emits the tip once.
|
|
16556
|
-
*
|
|
16557
|
-
* The ref is owned by `localStartApiCommand` (one per server boot), so
|
|
16558
|
-
* independent server invocations get independent flags.
|
|
17482
|
+
* Look up the docker image asset that backs a container Lambda.
|
|
17483
|
+
* Returns `undefined` when the asset manifest has no matching entry —
|
|
17484
|
+
* the caller falls back to the ECR-pull path.
|
|
16559
17485
|
*
|
|
16560
|
-
*
|
|
17486
|
+
* Mirrors `local-invoke.ts:resolveLocalBuildPlan`; kept separate so
|
|
17487
|
+
* the two commands evolve their asset-lookup heuristics independently.
|
|
16561
17488
|
*/
|
|
16562
|
-
function
|
|
16563
|
-
|
|
16564
|
-
if (!
|
|
16565
|
-
const
|
|
16566
|
-
|
|
16567
|
-
|
|
16568
|
-
|
|
17489
|
+
async function resolveLocalBuildPlan(lambda) {
|
|
17490
|
+
const manifestPath = lambda.stack.assetManifestPath;
|
|
17491
|
+
if (!manifestPath) return void 0;
|
|
17492
|
+
const cdkOutDir = path.dirname(manifestPath);
|
|
17493
|
+
const manifest = await new AssetManifestLoader().loadManifest(cdkOutDir, lambda.stack.stackName);
|
|
17494
|
+
if (!manifest) return void 0;
|
|
17495
|
+
const entry = getDockerImageBySourceHash(manifest, lambda.imageUri);
|
|
17496
|
+
if (!entry) return void 0;
|
|
17497
|
+
return {
|
|
17498
|
+
asset: entry.asset,
|
|
17499
|
+
cdkOutDir
|
|
17500
|
+
};
|
|
16569
17501
|
}
|
|
16570
17502
|
/**
|
|
16571
|
-
*
|
|
16572
|
-
*
|
|
16573
|
-
*
|
|
16574
|
-
*
|
|
16575
|
-
*
|
|
17503
|
+
* Build the `/opt` bind-mount source for a Lambda's layers. Mirrors
|
|
17504
|
+
* the helper in `src/cli/commands/local-invoke.ts` but stores the
|
|
17505
|
+
* merged tmpdir into the shared `layerTmpDirs` set so the server's
|
|
17506
|
+
* graceful shutdown path can clean it up. Returns `undefined` when
|
|
17507
|
+
* the function declares no layers.
|
|
17508
|
+
*
|
|
17509
|
+
* Three branches:
|
|
17510
|
+
* - 0 layers → `undefined` (no `/opt` mount).
|
|
17511
|
+
* - 1 layer → bind-mount the layer's asset dir directly (no copy)
|
|
17512
|
+
* when the entry is a same-stack asset. Literal-ARN entries always
|
|
17513
|
+
* pre-materialize first.
|
|
17514
|
+
* - 2+ layers → copy each into a fresh tmpdir IN ORDER (later
|
|
17515
|
+
* layers overwrite earlier files via `cpSync({force: true})`),
|
|
17516
|
+
* bind-mount the tmpdir at `/opt`. Records the tmpdir in
|
|
17517
|
+
* `layerTmpDirs` so `shutdown(...)` removes it.
|
|
17518
|
+
*
|
|
17519
|
+
* Issue #448: literal-ARN entries (`{kind: 'arn', ...}`) are downloaded
|
|
17520
|
+
* + unzipped via `lambda:GetLayerVersion` BEFORE the cpSync-merge
|
|
17521
|
+
* branches run. Every per-ARN tmpdir is also recorded in `layerTmpDirs`
|
|
17522
|
+
* so the same shutdown path cleans it up — even for the single-layer
|
|
17523
|
+
* fast path that bind-mounts the dir directly.
|
|
17524
|
+
*
|
|
17525
|
+
* AWS Lambda's actual runtime extracts every layer ZIP into `/opt`
|
|
17526
|
+
* in template order — the merge mirrors that. Docker rejects multiple
|
|
17527
|
+
* `-v ...:/opt:ro` entries at the same target, so cdk-local can't rely on
|
|
17528
|
+
* overlay layering and must produce a single merged dir on the host.
|
|
16576
17529
|
*/
|
|
16577
|
-
function
|
|
16578
|
-
|
|
16579
|
-
const
|
|
16580
|
-
for (const
|
|
16581
|
-
if (
|
|
16582
|
-
|
|
16583
|
-
|
|
16584
|
-
|
|
16585
|
-
|
|
16586
|
-
|
|
16587
|
-
}
|
|
16588
|
-
for (const entry of routesWithAuth) {
|
|
16589
|
-
if (entry.route.unsupported || entry.route.mockCors || entry.route.serviceIntegration) continue;
|
|
16590
|
-
const auth = entry.authorizer;
|
|
16591
|
-
if (!auth) continue;
|
|
16592
|
-
if (auth.kind === "lambda-token" || auth.kind === "lambda-request") {
|
|
16593
|
-
if (!seen.has(auth.lambdaLogicalId)) {
|
|
16594
|
-
seen.add(auth.lambdaLogicalId);
|
|
16595
|
-
out.push(auth.lambdaLogicalId);
|
|
16596
|
-
}
|
|
17530
|
+
async function materializeLambdaLayers(layers, layerTmpDirs, layerRoleArn) {
|
|
17531
|
+
if (layers.length === 0) return void 0;
|
|
17532
|
+
const flat = [];
|
|
17533
|
+
for (const layer of layers) {
|
|
17534
|
+
if (layer.kind === "asset") {
|
|
17535
|
+
flat.push({
|
|
17536
|
+
logicalId: layer.logicalId,
|
|
17537
|
+
assetPath: layer.assetPath
|
|
17538
|
+
});
|
|
17539
|
+
continue;
|
|
16597
17540
|
}
|
|
17541
|
+
const dir = await materializeLayerFromArn(layer, { ...layerRoleArn !== void 0 && { roleArn: layerRoleArn } });
|
|
17542
|
+
layerTmpDirs.add(dir);
|
|
17543
|
+
flat.push({
|
|
17544
|
+
logicalId: layer.arn,
|
|
17545
|
+
assetPath: dir
|
|
17546
|
+
});
|
|
16598
17547
|
}
|
|
16599
|
-
|
|
16600
|
-
|
|
16601
|
-
|
|
17548
|
+
if (flat.length === 1) return flat[0].assetPath;
|
|
17549
|
+
const dir = mkdtempSync(path.join(tmpdir(), `${getEmbedConfig().resourceNamePrefix}-start-api-layers-`));
|
|
17550
|
+
for (const layer of flat) cpSync(layer.assetPath, dir, {
|
|
17551
|
+
recursive: true,
|
|
17552
|
+
force: true
|
|
17553
|
+
});
|
|
17554
|
+
layerTmpDirs.add(dir);
|
|
17555
|
+
return dir;
|
|
17556
|
+
}
|
|
17557
|
+
function resolveLambdaByLogicalId(logicalId, stacks) {
|
|
17558
|
+
for (const stack of stacks) {
|
|
17559
|
+
const resource = stack.template.Resources?.[logicalId];
|
|
17560
|
+
if (!resource || resource.Type !== "AWS::Lambda::Function") continue;
|
|
17561
|
+
const props = resource.Properties ?? {};
|
|
17562
|
+
const memoryMb = typeof props["MemorySize"] === "number" ? props["MemorySize"] : 128;
|
|
17563
|
+
const timeoutSec = typeof props["Timeout"] === "number" ? props["Timeout"] : 3;
|
|
17564
|
+
const code = props["Code"] ?? {};
|
|
17565
|
+
const imageUri = extractImageUri(code["ImageUri"], logicalId, stack.stackName, stack.template.Resources ?? {}, stack.region);
|
|
17566
|
+
if (imageUri !== void 0) return resolveImageLambda({
|
|
17567
|
+
stack,
|
|
17568
|
+
logicalId,
|
|
17569
|
+
resource,
|
|
17570
|
+
props,
|
|
17571
|
+
memoryMb,
|
|
17572
|
+
timeoutSec,
|
|
17573
|
+
imageUri
|
|
17574
|
+
});
|
|
17575
|
+
const runtime = typeof props["Runtime"] === "string" ? props["Runtime"] : "";
|
|
17576
|
+
const handler = typeof props["Handler"] === "string" ? props["Handler"] : "";
|
|
17577
|
+
if (!runtime) throw new Error(`Lambda '${logicalId}' has no Runtime property and no Code.ImageUri. ${getEmbedConfig().cliName} start-api cannot tell if this is a ZIP or a container Lambda.`);
|
|
17578
|
+
if (!handler) throw new Error(`Lambda '${logicalId}' has no Handler property.`);
|
|
17579
|
+
const inlineCode = typeof code["ZipFile"] === "string" ? code["ZipFile"] : void 0;
|
|
17580
|
+
let codePath = null;
|
|
17581
|
+
if (!inlineCode) codePath = resolveAssetCodePath(stack, logicalId, resource);
|
|
17582
|
+
const layers = resolveLambdaLayers(stack, logicalId, props);
|
|
17583
|
+
const ephemeralStorageMb = extractEphemeralStorageMb(props, logicalId);
|
|
17584
|
+
return {
|
|
17585
|
+
kind: "zip",
|
|
17586
|
+
stack,
|
|
17587
|
+
logicalId,
|
|
17588
|
+
resource,
|
|
17589
|
+
runtime,
|
|
17590
|
+
handler,
|
|
17591
|
+
memoryMb,
|
|
17592
|
+
timeoutSec,
|
|
17593
|
+
codePath,
|
|
17594
|
+
layers,
|
|
17595
|
+
...inlineCode !== void 0 && { inlineCode },
|
|
17596
|
+
...ephemeralStorageMb !== void 0 && { ephemeralStorageMb }
|
|
17597
|
+
};
|
|
16602
17598
|
}
|
|
16603
|
-
|
|
17599
|
+
throw new Error(`No AWS::Lambda::Function resource named '${logicalId}' found in target stacks. This is likely a synthesis bug — the route-discovery phase resolved a route to this logical ID.`);
|
|
16604
17600
|
}
|
|
16605
17601
|
/**
|
|
16606
|
-
*
|
|
16607
|
-
*
|
|
16608
|
-
*
|
|
16609
|
-
* the
|
|
17602
|
+
* Extract `Code.ImageUri` across the shapes CDK actually synthesizes.
|
|
17603
|
+
* Mirrors the simpler subset of `lambda-resolver.ts:extractImageUri`
|
|
17604
|
+
* scoped to the shapes `cdkl start-api` consumes — flat string,
|
|
17605
|
+
* `Fn::Sub` (the canonical asset shape for
|
|
17606
|
+
* `lambda.DockerImageCode.fromImageAsset`), and `Fn::Join` (the
|
|
17607
|
+
* canonical shape for `lambda.DockerImageCode.fromImageAsset` in
|
|
17608
|
+
* CDK 2.x, which emits a `Fn::Join` over the literal bootstrap ECR
|
|
17609
|
+
* URI with `${AWS::URLSuffix}` — issues #627 + #637).
|
|
17610
|
+
*
|
|
17611
|
+
* The `Fn::Join` arm routes through the shared
|
|
17612
|
+
* `tryResolveImageFnJoin` helper (`src/local/intrinsic-image.ts`) used
|
|
17613
|
+
* by `cdkl invoke`. When the synth template recorded a deploy
|
|
17614
|
+
* region (`stack.region`), we derive `{ urlSuffix, partition, region }`
|
|
17615
|
+
* via `derivePseudoParametersFromRegion` and pass it as the resolver's
|
|
17616
|
+
* `pseudoParameters` block so the canonical `${AWS::URLSuffix}` shape
|
|
17617
|
+
* resolves cleanly (issue #637). Same-stack ECR refs still surface as
|
|
17618
|
+
* `needs-state` (those require `--from-state`); a Join that references
|
|
17619
|
+
* `${AWS::AccountId}` without state still surfaces as `not-applicable`
|
|
17620
|
+
* with a more specific error message naming the missing parameter.
|
|
17621
|
+
*
|
|
17622
|
+
* Returns `undefined` when the field is absent or non-recognized,
|
|
17623
|
+
* which routes the caller to the ZIP branch (with its existing
|
|
17624
|
+
* "no Runtime / no Handler" validations).
|
|
16610
17625
|
*/
|
|
16611
|
-
|
|
16612
|
-
|
|
16613
|
-
|
|
16614
|
-
const
|
|
16615
|
-
|
|
16616
|
-
if (
|
|
16617
|
-
|
|
16618
|
-
|
|
16619
|
-
|
|
17626
|
+
function extractImageUri(value, logicalId, stackName, resources, region) {
|
|
17627
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
17628
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
17629
|
+
const obj = value;
|
|
17630
|
+
const sub = obj["Fn::Sub"];
|
|
17631
|
+
if (typeof sub === "string" && sub.length > 0) return sub;
|
|
17632
|
+
if (Array.isArray(sub) && typeof sub[0] === "string") return sub[0];
|
|
17633
|
+
if ("Fn::Join" in obj) {
|
|
17634
|
+
const pseudoParameters = derivePseudoParametersFromRegion(region);
|
|
17635
|
+
const joinResolved = tryResolveImageFnJoin(value, resources, pseudoParameters ? { pseudoParameters } : void 0);
|
|
17636
|
+
if (joinResolved.kind === "resolved") return joinResolved.uri;
|
|
17637
|
+
if (joinResolved.kind === "needs-state") throw new Error(`Lambda '${logicalId}' in ${stackName} references same-stack ECR repository '${joinResolved.repoLogicalId}' via Fn::Join. ${getEmbedConfig().cliName} start-api cannot resolve the repository URI without state — deploy the stack first, rebuild via lambda.DockerImageCode.fromImageAsset, or pin a public image.`);
|
|
17638
|
+
if (joinResolved.kind === "unsupported-join") throw new Error(`Lambda '${logicalId}' in ${stackName} has an unsupported Fn::Join Code.ImageUri shape: ${joinResolved.reason}. ${getEmbedConfig().cliName} start-api recognizes the canonical CDK 2.x lambda.DockerImageCode.fromEcr Fn::Join shape (delimiter "" with nested Fn::Select/Fn::Split over an ECR Repository Arn GetAtt + Ref to the repo).`);
|
|
17639
|
+
const accountIdHint = pseudoParameters ? ` (likely \${AWS::AccountId}, which ${getEmbedConfig().binaryName} cannot derive without a state source or STS)` : ` (${getEmbedConfig().binaryName} could not derive AWS pseudo parameters because stack.region was undefined)`;
|
|
17640
|
+
throw new Error(`Lambda '${logicalId}' in ${stackName} has an Fn::Join Code.ImageUri that ${getEmbedConfig().cliName} start-api cannot resolve${accountIdHint}. Workarounds: deploy first and run with a state-source flag (e.g. --from-cfn-stack or a host-provided extension), or pin a fully-literal public image URI.`);
|
|
16620
17641
|
}
|
|
16621
17642
|
}
|
|
16622
|
-
await Promise.all([...urls].map((u) => jwksCache.fetchAndCache(u)));
|
|
16623
17643
|
}
|
|
16624
17644
|
/**
|
|
16625
|
-
*
|
|
16626
|
-
*
|
|
16627
|
-
*
|
|
16628
|
-
*
|
|
16629
|
-
* the VPC config rather than chasing a "connection refused" rabbit
|
|
16630
|
-
* hole.
|
|
17645
|
+
* Build the IMAGE-variant `ResolvedStartApiLambda` from a Lambda
|
|
17646
|
+
* template entry with `Code.ImageUri`. Mirrors
|
|
17647
|
+
* `lambda-resolver.ts:extractImageLambdaProperties` but trimmed to the
|
|
17648
|
+
* fields `cdkl start-api` actually consumes.
|
|
16631
17649
|
*/
|
|
16632
|
-
function
|
|
16633
|
-
const
|
|
16634
|
-
const
|
|
16635
|
-
const
|
|
16636
|
-
|
|
16637
|
-
|
|
16638
|
-
|
|
16639
|
-
|
|
16640
|
-
|
|
16641
|
-
|
|
16642
|
-
|
|
16643
|
-
|
|
16644
|
-
|
|
16645
|
-
|
|
16646
|
-
}
|
|
16647
|
-
}
|
|
16648
|
-
}
|
|
16649
|
-
for (const logicalId of reachable) for (const stack of stacks) {
|
|
16650
|
-
const resource = stack.template.Resources?.[logicalId];
|
|
16651
|
-
if (!resource || resource.Type !== "AWS::Lambda::Function") continue;
|
|
16652
|
-
const vpcConfig = (resource.Properties ?? {})["VpcConfig"];
|
|
16653
|
-
if (vpcConfig && typeof vpcConfig === "object" && Object.keys(vpcConfig).length > 0) logger.warn(`Lambda ${logicalId} has VpcConfig — local container will reach external services via the host's network, NOT through the deployed VPC's NAT/private subnets. Calls to private RDS/ElastiCache will fail.`);
|
|
16654
|
-
break;
|
|
17650
|
+
function resolveImageLambda(args) {
|
|
17651
|
+
const { stack, logicalId, resource, props, memoryMb, timeoutSec, imageUri } = args;
|
|
17652
|
+
const rawImageConfig = props["ImageConfig"] ?? {};
|
|
17653
|
+
const imageConfig = {};
|
|
17654
|
+
if (Array.isArray(rawImageConfig["Command"])) imageConfig.command = rawImageConfig["Command"].filter((s) => typeof s === "string");
|
|
17655
|
+
if (Array.isArray(rawImageConfig["EntryPoint"])) imageConfig.entryPoint = rawImageConfig["EntryPoint"].filter((s) => typeof s === "string");
|
|
17656
|
+
if (typeof rawImageConfig["WorkingDirectory"] === "string") imageConfig.workingDirectory = rawImageConfig["WorkingDirectory"];
|
|
17657
|
+
const arches = props["Architectures"];
|
|
17658
|
+
let architecture = "x86_64";
|
|
17659
|
+
if (Array.isArray(arches) && arches.length > 0) {
|
|
17660
|
+
const first = arches[0];
|
|
17661
|
+
if (first === "arm64") architecture = "arm64";
|
|
17662
|
+
else if (first === "x86_64") architecture = "x86_64";
|
|
17663
|
+
else throw new Error(`Lambda '${logicalId}' has unsupported Architectures value '${String(first)}'. ${getEmbedConfig().cliName} start-api supports x86_64 and arm64.`);
|
|
16655
17664
|
}
|
|
17665
|
+
const ephemeralStorageMb = extractEphemeralStorageMb(props, logicalId);
|
|
17666
|
+
return {
|
|
17667
|
+
kind: "image",
|
|
17668
|
+
stack,
|
|
17669
|
+
logicalId,
|
|
17670
|
+
resource,
|
|
17671
|
+
memoryMb,
|
|
17672
|
+
timeoutSec,
|
|
17673
|
+
imageUri,
|
|
17674
|
+
imageConfig,
|
|
17675
|
+
architecture,
|
|
17676
|
+
layers: [],
|
|
17677
|
+
...ephemeralStorageMb !== void 0 && { ephemeralStorageMb }
|
|
17678
|
+
};
|
|
16656
17679
|
}
|
|
16657
17680
|
/**
|
|
16658
|
-
*
|
|
16659
|
-
*
|
|
16660
|
-
*
|
|
16661
|
-
|
|
16662
|
-
|
|
17681
|
+
* Locate the Lambda's local code directory using the CDK-blessed
|
|
17682
|
+
* `Metadata['aws:asset:path']` hint. Bind-mounted directly at
|
|
17683
|
+
* `/var/task` (read-only) by the docker-runner.
|
|
17684
|
+
*/
|
|
17685
|
+
function resolveAssetCodePath(stack, logicalId, resource) {
|
|
17686
|
+
const assetPath = resource.Metadata?.["aws:asset:path"];
|
|
17687
|
+
if (typeof assetPath !== "string" || assetPath.length === 0) throw new Error(`Lambda '${logicalId}' has no Metadata['aws:asset:path']. ${getEmbedConfig().cliName} start-api needs this hint to find the local asset directory. Re-synthesize the app and retry.`);
|
|
17688
|
+
const cdkOutDir = stack.assetManifestPath ? path.dirname(stack.assetManifestPath) : process.cwd();
|
|
17689
|
+
return path.isAbsolute(assetPath) ? assetPath : path.resolve(cdkOutDir, assetPath);
|
|
17690
|
+
}
|
|
17691
|
+
/**
|
|
17692
|
+
* Print the discovered route table to stdout. Format mirrors the spec
|
|
17693
|
+
* doc's example so verify.sh / users can read it at a glance.
|
|
16663
17694
|
*
|
|
16664
|
-
*
|
|
16665
|
-
*
|
|
16666
|
-
*
|
|
17695
|
+
* Routes with `unsupported` or `mockCors` are annotated so the user can
|
|
17696
|
+
* tell at a glance which routes will dispatch to a Lambda vs which
|
|
17697
|
+
* return 501 / 204 directly:
|
|
17698
|
+
* - normal: `GET /items -> Handler (HTTP API)`
|
|
17699
|
+
* - mockCors: `OPTIONS /items -> [MOCK CORS preflight] (REST v1, stage 'prod')`
|
|
17700
|
+
* - unsupported: `POST /admin -> [501 Not Implemented] (HTTP API)`
|
|
16667
17701
|
*/
|
|
16668
|
-
function
|
|
16669
|
-
const
|
|
16670
|
-
|
|
16671
|
-
|
|
16672
|
-
|
|
16673
|
-
|
|
16674
|
-
|
|
16675
|
-
|
|
16676
|
-
|
|
16677
|
-
|
|
16678
|
-
|
|
16679
|
-
|
|
16680
|
-
for (const declaredAt of iamRoutes) logger.warn(` - ${declaredAt}`);
|
|
16681
|
-
}
|
|
16682
|
-
if (oacRoutes.length > 0) {
|
|
16683
|
-
logger.warn(`${oacRoutes.length} Function URL route(s) with AuthType: AWS_IAM are fronted by a CloudFront Origin Access Control. In production CloudFront re-signs the origin request, so no local client signature can be verified — ${getEmbedConfig().cliName} start-api passes these through (warn-and-pass) and they ignore --strict-sigv4. Do NOT trust the request identity in handler code.`);
|
|
16684
|
-
for (const declaredAt of oacRoutes) logger.warn(` - ${declaredAt}`);
|
|
17702
|
+
function printRouteTable(routes) {
|
|
17703
|
+
const sorted = [...routes.map((r) => r.route)].sort((a, b) => {
|
|
17704
|
+
if (a.pathPattern !== b.pathPattern) return a.pathPattern.localeCompare(b.pathPattern);
|
|
17705
|
+
return a.method.localeCompare(b.method);
|
|
17706
|
+
});
|
|
17707
|
+
const methodWidth = Math.max(...sorted.map((r) => r.method.length), 6);
|
|
17708
|
+
const pathWidth = Math.max(...sorted.map((r) => r.pathPattern.length), 8);
|
|
17709
|
+
process.stdout.write("Discovered routes:\n");
|
|
17710
|
+
for (const r of sorted) {
|
|
17711
|
+
const sourceLabel = r.source === "http-api" ? "HTTP API" : r.source === "rest-v1" ? `REST v1, stage '${r.stage}'` : "Function URL";
|
|
17712
|
+
const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.restV1Integration ? formatRestV1IntegrationLabel(r.restV1Integration) : r.lambdaLogicalId;
|
|
17713
|
+
process.stdout.write(` ${r.method.padEnd(methodWidth)} ${r.pathPattern.padEnd(pathWidth)} -> ${target} (${sourceLabel})\n`);
|
|
16685
17714
|
}
|
|
16686
|
-
|
|
17715
|
+
process.stdout.write("\n");
|
|
16687
17716
|
}
|
|
16688
17717
|
/**
|
|
16689
|
-
*
|
|
16690
|
-
*
|
|
16691
|
-
* Lambda
|
|
16692
|
-
*
|
|
16693
|
-
* missing, runtime not supported).
|
|
17718
|
+
* Format the route-table label for a REST v1 non-AWS_PROXY integration.
|
|
17719
|
+
* `MOCK` / `HTTP` / `HTTP_PROXY` show their integration kind directly;
|
|
17720
|
+
* `AWS` (Lambda non-proxy) shows the Lambda logical id with an `[AWS]`
|
|
17721
|
+
* suffix so it's distinguishable from AWS_PROXY rows. Closes #457.
|
|
16694
17722
|
*/
|
|
16695
|
-
|
|
16696
|
-
|
|
16697
|
-
|
|
16698
|
-
|
|
16699
|
-
|
|
16700
|
-
|
|
16701
|
-
let platform;
|
|
16702
|
-
if (lambda.kind === "zip") {
|
|
16703
|
-
codeDir = lambda.codePath ?? materializeInlineCode$1(lambda.handler, lambda.inlineCode ?? "", resolveRuntimeFileExtension(lambda.runtime), inlineTmpDirs);
|
|
16704
|
-
optDir = await materializeLambdaLayers(lambda.layers, layerTmpDirs, layerRoleArn);
|
|
16705
|
-
} else {
|
|
16706
|
-
imageRef = (await resolveContainerImageForStartApi(lambda, skipPull, args.profile)).imageRef;
|
|
16707
|
-
platform = architectureToPlatform(lambda.architecture);
|
|
16708
|
-
}
|
|
16709
|
-
let templateEnv = getTemplateEnv(lambda.resource);
|
|
16710
|
-
const stateBundle = stateByStack.get(lambda.stack.stackName);
|
|
16711
|
-
let stateAudit;
|
|
16712
|
-
if (stateBundle) {
|
|
16713
|
-
const context = { resources: stateBundle.state.resources };
|
|
16714
|
-
if (stateBundle.pseudoParameters) context.pseudoParameters = stateBundle.pseudoParameters;
|
|
16715
|
-
if (stateBundle.ssmParameters) context.parameters = stateBundle.ssmParameters;
|
|
16716
|
-
if (stateBundle.ssmSecureStringLogicalIds?.length) context.sensitiveParameters = new Set(stateBundle.ssmSecureStringLogicalIds);
|
|
16717
|
-
const { env, audit } = substituteEnvVarsFromState(templateEnv, context);
|
|
16718
|
-
templateEnv = env;
|
|
16719
|
-
for (const key of audit.resolvedKeys) getLogger().debug(`Lambda ${logicalId}: state source substituted env var ${key}`);
|
|
16720
|
-
let unresolved = audit.unresolved;
|
|
16721
|
-
const resolvedKeys = [...audit.resolvedKeys];
|
|
16722
|
-
const deployedEnv = stateBundle.deployedEnvByLambda?.get(logicalId);
|
|
16723
|
-
if (unresolved.length > 0 && deployedEnv) {
|
|
16724
|
-
const fb = applyDeployedEnvFallback(templateEnv, unresolved, deployedEnv);
|
|
16725
|
-
templateEnv = fb.env;
|
|
16726
|
-
unresolved = fb.stillUnresolved;
|
|
16727
|
-
for (const key of fb.filled) {
|
|
16728
|
-
resolvedKeys.push(key);
|
|
16729
|
-
getLogger().debug(`Lambda ${logicalId}: filled env var ${key} from deployed function config`);
|
|
16730
|
-
}
|
|
16731
|
-
}
|
|
16732
|
-
stateAudit = {
|
|
16733
|
-
resolvedKeys,
|
|
16734
|
-
unresolved,
|
|
16735
|
-
sensitiveKeys: audit.sensitiveKeys
|
|
16736
|
-
};
|
|
16737
|
-
for (const { key, reason } of unresolved) getLogger().warn(`Lambda ${logicalId}: state source could not substitute env var ${key} (${reason}). Override it via --env-vars or it will be dropped.`);
|
|
16738
|
-
}
|
|
16739
|
-
const lambdaCdkPath = readCdkPathOrUndefined(lambda.resource);
|
|
16740
|
-
const envResult = resolveEnvVars(logicalId, lambdaCdkPath, templateEnv, overrides);
|
|
16741
|
-
for (const key of envResult.unresolved) {
|
|
16742
|
-
if (stateAudit && stateAudit.unresolved.some((u) => u.key === key)) continue;
|
|
16743
|
-
const overrideKeyExample = lambdaCdkPath?.replace(/\/Resource$/, "") ?? logicalId;
|
|
16744
|
-
getLogger().warn(`Lambda ${logicalId}: env var ${key} contains a CloudFormation intrinsic and was dropped. Override it with --env-vars (e.g. {"${overrideKeyExample}":{"${key}":"<literal>"}}) or pass a state-source flag (e.g. --from-cfn-stack or a host-provided extension) to recover deployed values.`);
|
|
16745
|
-
}
|
|
16746
|
-
const dockerEnv = {
|
|
16747
|
-
AWS_LAMBDA_FUNCTION_NAME: logicalId,
|
|
16748
|
-
AWS_LAMBDA_FUNCTION_MEMORY_SIZE: String(lambda.memoryMb),
|
|
16749
|
-
AWS_LAMBDA_FUNCTION_TIMEOUT: String(lambda.timeoutSec),
|
|
16750
|
-
AWS_LAMBDA_FUNCTION_VERSION: "$LATEST",
|
|
16751
|
-
AWS_LAMBDA_LOG_GROUP_NAME: `/aws/lambda/${logicalId}`,
|
|
16752
|
-
AWS_LAMBDA_LOG_STREAM_NAME: "local",
|
|
16753
|
-
...envResult.resolved
|
|
16754
|
-
};
|
|
16755
|
-
const roleArn = effectiveAssumeRoleArn(logicalId, assumeRole);
|
|
16756
|
-
if (roleArn) {
|
|
16757
|
-
const creds = await assumeLambdaExecutionRole(roleArn, stsRegion);
|
|
16758
|
-
dockerEnv["AWS_ACCESS_KEY_ID"] = creds.accessKeyId;
|
|
16759
|
-
dockerEnv["AWS_SECRET_ACCESS_KEY"] = creds.secretAccessKey;
|
|
16760
|
-
dockerEnv["AWS_SESSION_TOKEN"] = creds.sessionToken;
|
|
16761
|
-
if (stsRegion) dockerEnv["AWS_REGION"] = stsRegion;
|
|
16762
|
-
} else {
|
|
16763
|
-
forwardAwsEnv$1(dockerEnv);
|
|
16764
|
-
if (profileCredentials) {
|
|
16765
|
-
dockerEnv["AWS_ACCESS_KEY_ID"] = profileCredentials.accessKeyId;
|
|
16766
|
-
dockerEnv["AWS_SECRET_ACCESS_KEY"] = profileCredentials.secretAccessKey;
|
|
16767
|
-
if (profileCredentials.sessionToken) dockerEnv["AWS_SESSION_TOKEN"] = profileCredentials.sessionToken;
|
|
16768
|
-
else delete dockerEnv["AWS_SESSION_TOKEN"];
|
|
16769
|
-
}
|
|
16770
|
-
if (profileCredsFile) {
|
|
16771
|
-
dockerEnv["AWS_SHARED_CREDENTIALS_FILE"] = profileCredsFile.containerPath;
|
|
16772
|
-
dockerEnv["AWS_PROFILE"] = profileCredsFile.profileName;
|
|
16773
|
-
}
|
|
16774
|
-
}
|
|
16775
|
-
if (!dockerEnv["AWS_REGION"]) {
|
|
16776
|
-
const fallbackRegion = resolveContainerFallbackRegion({
|
|
16777
|
-
stackRegionOverride,
|
|
16778
|
-
synthRegion: lambda.stack.region,
|
|
16779
|
-
profileRegion
|
|
16780
|
-
});
|
|
16781
|
-
if (fallbackRegion) dockerEnv["AWS_REGION"] = fallbackRegion;
|
|
17723
|
+
function formatRestV1IntegrationLabel(integration) {
|
|
17724
|
+
switch (integration.kind) {
|
|
17725
|
+
case "mock": return "[MOCK]";
|
|
17726
|
+
case "http-proxy": return `[HTTP_PROXY ${integration.uri}]`;
|
|
17727
|
+
case "http": return `[HTTP ${integration.uri}]`;
|
|
17728
|
+
case "aws-lambda": return `${integration.lambdaLogicalId} [AWS]`;
|
|
16782
17729
|
}
|
|
16783
|
-
if (debugPort !== void 0) dockerEnv["NODE_OPTIONS"] = `--inspect-brk=0.0.0.0:${debugPort}`;
|
|
16784
|
-
const tmpfs = lambda.ephemeralStorageMb !== void 0 ? {
|
|
16785
|
-
target: "/tmp",
|
|
16786
|
-
sizeMb: lambda.ephemeralStorageMb
|
|
16787
|
-
} : void 0;
|
|
16788
|
-
const sensitiveEnvKeys = stateAudit && stateAudit.sensitiveKeys.length > 0 ? new Set(stateAudit.sensitiveKeys) : void 0;
|
|
16789
|
-
if (lambda.kind === "zip") return {
|
|
16790
|
-
kind: "zip",
|
|
16791
|
-
lambda,
|
|
16792
|
-
codeDir,
|
|
16793
|
-
env: dockerEnv,
|
|
16794
|
-
...sensitiveEnvKeys && { sensitiveEnvKeys },
|
|
16795
|
-
containerHost,
|
|
16796
|
-
...optDir !== void 0 && { optDir },
|
|
16797
|
-
...debugPort !== void 0 && { debugPort },
|
|
16798
|
-
...tmpfs !== void 0 && { tmpfs },
|
|
16799
|
-
...profileCredsFile && { profileCredentialsFile: {
|
|
16800
|
-
hostPath: profileCredsFile.hostPath,
|
|
16801
|
-
containerPath: profileCredsFile.containerPath
|
|
16802
|
-
} }
|
|
16803
|
-
};
|
|
16804
|
-
return {
|
|
16805
|
-
kind: "image",
|
|
16806
|
-
lambda,
|
|
16807
|
-
image: imageRef,
|
|
16808
|
-
platform,
|
|
16809
|
-
command: lambda.imageConfig.command ?? [],
|
|
16810
|
-
...lambda.imageConfig.entryPoint !== void 0 && lambda.imageConfig.entryPoint.length > 0 && { entryPoint: lambda.imageConfig.entryPoint },
|
|
16811
|
-
...lambda.imageConfig.workingDirectory !== void 0 && { workingDir: lambda.imageConfig.workingDirectory },
|
|
16812
|
-
env: dockerEnv,
|
|
16813
|
-
...sensitiveEnvKeys && { sensitiveEnvKeys },
|
|
16814
|
-
containerHost,
|
|
16815
|
-
...debugPort !== void 0 && { debugPort },
|
|
16816
|
-
...tmpfs !== void 0 && { tmpfs },
|
|
16817
|
-
...profileCredsFile && { profileCredentialsFile: {
|
|
16818
|
-
hostPath: profileCredsFile.hostPath,
|
|
16819
|
-
containerPath: profileCredsFile.containerPath
|
|
16820
|
-
} }
|
|
16821
|
-
};
|
|
16822
17730
|
}
|
|
16823
17731
|
/**
|
|
16824
|
-
*
|
|
16825
|
-
*
|
|
16826
|
-
*
|
|
16827
|
-
*
|
|
16828
|
-
*
|
|
16829
|
-
*
|
|
16830
|
-
*
|
|
16831
|
-
* Same-account / same-region only on the ECR-pull path (matches the
|
|
16832
|
-
* `cdkl invoke` PR 5 of #224 boundary). Cross-account /
|
|
16833
|
-
* cross-region ECR pull is the W2-1 deferred follow-up.
|
|
17732
|
+
* Materialize an inline Lambda body (`Code.ZipFile`) to a tmpdir and
|
|
17733
|
+
* return the directory the container should mount at /var/task.
|
|
17734
|
+
* Mirrors `cdkl invoke`'s implementation; the only divergence is
|
|
17735
|
+
* the long-running-server lifecycle: every tmpdir created here is
|
|
17736
|
+
* recorded in `tmpDirsOut` so the caller's shutdown path can `rmSync`
|
|
17737
|
+
* them. (`cdkl invoke` runs once and `--rm` is the right model;
|
|
17738
|
+
* `cdkl start-api` lives across requests, so leaks compound.)
|
|
16834
17739
|
*/
|
|
16835
|
-
|
|
16836
|
-
const
|
|
16837
|
-
|
|
16838
|
-
|
|
16839
|
-
|
|
16840
|
-
|
|
16841
|
-
|
|
16842
|
-
|
|
16843
|
-
|
|
16844
|
-
|
|
17740
|
+
function materializeInlineCode$1(handler, source, fileExtension, tmpDirsOut) {
|
|
17741
|
+
const lastDot = handler.lastIndexOf(".");
|
|
17742
|
+
if (lastDot <= 0) throw new Error(`Handler '${handler}' is malformed: expected '<modulePath>.<exportName>'.`);
|
|
17743
|
+
const modulePath = handler.substring(0, lastDot);
|
|
17744
|
+
const dir = mkdtempSync(path.join(tmpdir(), `${getEmbedConfig().resourceNamePrefix}-start-api-`));
|
|
17745
|
+
tmpDirsOut.add(dir);
|
|
17746
|
+
const filePath = path.join(dir, `${modulePath}${fileExtension}`);
|
|
17747
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
17748
|
+
writeFileSync(filePath, source, "utf-8");
|
|
17749
|
+
return dir;
|
|
17750
|
+
}
|
|
17751
|
+
/** Pull `Properties.Environment.Variables` (when present). */
|
|
17752
|
+
function getTemplateEnv(resource) {
|
|
17753
|
+
const env = (resource.Properties ?? {})["Environment"];
|
|
17754
|
+
if (!env || typeof env !== "object") return void 0;
|
|
17755
|
+
const vars = env["Variables"];
|
|
17756
|
+
if (!vars || typeof vars !== "object") return void 0;
|
|
17757
|
+
return vars;
|
|
17758
|
+
}
|
|
17759
|
+
/** Read the SAM-shape `--env-vars` JSON file. */
|
|
17760
|
+
function readEnvOverridesFile$3(filePath) {
|
|
17761
|
+
if (!filePath) return void 0;
|
|
17762
|
+
let raw;
|
|
17763
|
+
try {
|
|
17764
|
+
raw = readFileSync(filePath, "utf-8");
|
|
17765
|
+
} catch (err) {
|
|
17766
|
+
throw new Error(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
17767
|
+
}
|
|
17768
|
+
let parsed;
|
|
17769
|
+
try {
|
|
17770
|
+
parsed = JSON.parse(raw);
|
|
17771
|
+
} catch (err) {
|
|
17772
|
+
throw new Error(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
17773
|
+
}
|
|
17774
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
17775
|
+
return parsed;
|
|
16845
17776
|
}
|
|
16846
17777
|
/**
|
|
16847
|
-
*
|
|
16848
|
-
*
|
|
16849
|
-
*
|
|
16850
|
-
*
|
|
16851
|
-
* Mirrors `local-invoke.ts:resolveLocalBuildPlan`; kept separate so
|
|
16852
|
-
* the two commands evolve their asset-lookup heuristics independently.
|
|
17778
|
+
* Forward the developer's AWS credentials into the container so the
|
|
17779
|
+
* handler's AWS SDK calls can authenticate. Used when --assume-role is
|
|
17780
|
+
* NOT set for that Lambda — SAM-compatible default.
|
|
16853
17781
|
*/
|
|
16854
|
-
|
|
16855
|
-
const
|
|
16856
|
-
|
|
16857
|
-
|
|
16858
|
-
|
|
16859
|
-
|
|
16860
|
-
|
|
16861
|
-
|
|
16862
|
-
|
|
16863
|
-
|
|
16864
|
-
|
|
16865
|
-
};
|
|
17782
|
+
function forwardAwsEnv$2(env) {
|
|
17783
|
+
for (const key of [
|
|
17784
|
+
"AWS_ACCESS_KEY_ID",
|
|
17785
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
17786
|
+
"AWS_SESSION_TOKEN",
|
|
17787
|
+
"AWS_REGION",
|
|
17788
|
+
"AWS_DEFAULT_REGION"
|
|
17789
|
+
]) {
|
|
17790
|
+
const value = process.env[key];
|
|
17791
|
+
if (value !== void 0) env[key] = value;
|
|
17792
|
+
}
|
|
16866
17793
|
}
|
|
16867
17794
|
/**
|
|
16868
|
-
*
|
|
16869
|
-
*
|
|
16870
|
-
* merged tmpdir into the shared `layerTmpDirs` set so the server's
|
|
16871
|
-
* graceful shutdown path can clean it up. Returns `undefined` when
|
|
16872
|
-
* the function declares no layers.
|
|
17795
|
+
* Issue #654: resolve `--profile <p>` to a concrete credential set
|
|
17796
|
+
* for forwarding to Lambda containers.
|
|
16873
17797
|
*
|
|
16874
|
-
*
|
|
16875
|
-
* -
|
|
16876
|
-
* -
|
|
16877
|
-
*
|
|
16878
|
-
*
|
|
16879
|
-
* - 2+ layers → copy each into a fresh tmpdir IN ORDER (later
|
|
16880
|
-
* layers overwrite earlier files via `cpSync({force: true})`),
|
|
16881
|
-
* bind-mount the tmpdir at `/opt`. Records the tmpdir in
|
|
16882
|
-
* `layerTmpDirs` so `shutdown(...)` removes it.
|
|
17798
|
+
* The dev's AWS credentials may live in any of:
|
|
17799
|
+
* - `~/.aws/sso/cache/*.json` (AWS IAM Identity Center / legacy SSO)
|
|
17800
|
+
* - `~/.aws/credentials` (regular long-lived access keys)
|
|
17801
|
+
* - `~/.aws/config` profiles with `role_arn` + `source_profile` (chained AssumeRole)
|
|
17802
|
+
* - `credential_process` external resolvers
|
|
16883
17803
|
*
|
|
16884
|
-
*
|
|
16885
|
-
*
|
|
16886
|
-
*
|
|
16887
|
-
*
|
|
16888
|
-
* fast path that bind-mounts the dir directly.
|
|
17804
|
+
* `forwardAwsEnv` only reads `process.env.AWS_*`, which is empty for
|
|
17805
|
+
* every shape except "user manually exported the env vars". The
|
|
17806
|
+
* Lambda container therefore boots without creds and the handler's
|
|
17807
|
+
* AWS SDK call fails with `Could not load credentials from any providers`.
|
|
16889
17808
|
*
|
|
16890
|
-
*
|
|
16891
|
-
*
|
|
16892
|
-
*
|
|
16893
|
-
*
|
|
17809
|
+
* This helper constructs a transient `STSClient({ profile })` to drive
|
|
17810
|
+
* the SDK's default credential provider chain — same code path the
|
|
17811
|
+
* host's own AWS SDK clients use when `--profile` is set, so SSO / IAM
|
|
17812
|
+
* Identity Center / role-assumption profiles all resolve the same way
|
|
17813
|
+
* they already do for the host's outbound calls. We then extract the
|
|
17814
|
+
* resolved `AwsCredentialIdentity` via `sts.config.credentials()` and
|
|
17815
|
+
* return the underlying `{ accessKeyId, secretAccessKey, sessionToken? }`
|
|
17816
|
+
* for env-var injection.
|
|
17817
|
+
*
|
|
17818
|
+
* Called ONCE at server boot; the resolved creds are reused for every
|
|
17819
|
+
* Lambda container's env overlay (when `--assume-role` is not set for
|
|
17820
|
+
* that Lambda — assume-role wins per the existing precedence). SSO
|
|
17821
|
+
* temp creds typically last 1h+, so a single resolve is fine for the
|
|
17822
|
+
* common dev session; long-running `--watch` sessions that outlive
|
|
17823
|
+
* the creds need a cdk-local restart (deferred refresh out of scope for
|
|
17824
|
+
* v1, see issue #654).
|
|
17825
|
+
*
|
|
17826
|
+
* Also resolves the profile's configured `region` (from `~/.aws/config`'s
|
|
17827
|
+
* `region = ...` for this profile) off the same client config. The
|
|
17828
|
+
* synthesized credentials file we mount into the container carries only
|
|
17829
|
+
* the credential triple — no `region =` — so without this the container
|
|
17830
|
+
* boots with creds but no region, and a handler's ambient-region SDK call
|
|
17831
|
+
* (`new XxxClient({})`) fails with "Region is missing". The caller seeds
|
|
17832
|
+
* the container's `AWS_REGION` fallback with it (see
|
|
17833
|
+
* {@link resolveContainerFallbackRegion}). Region resolution is
|
|
17834
|
+
* best-effort: a profile with no region (or any resolution failure)
|
|
17835
|
+
* yields `region: undefined` rather than throwing — a missing region is
|
|
17836
|
+
* not a missing-credentials error.
|
|
16894
17837
|
*/
|
|
16895
|
-
async function
|
|
16896
|
-
|
|
16897
|
-
const
|
|
16898
|
-
|
|
16899
|
-
|
|
16900
|
-
|
|
16901
|
-
|
|
16902
|
-
|
|
16903
|
-
|
|
16904
|
-
|
|
17838
|
+
async function resolveProfileCredentials(profile) {
|
|
17839
|
+
const { STSClient } = await import("@aws-sdk/client-sts");
|
|
17840
|
+
const sts = new STSClient({ profile });
|
|
17841
|
+
try {
|
|
17842
|
+
const credsProvider = sts.config.credentials;
|
|
17843
|
+
const creds = typeof credsProvider === "function" ? await credsProvider() : credsProvider;
|
|
17844
|
+
if (!creds || !creds.accessKeyId || !creds.secretAccessKey) throw new Error(`--profile '${profile}': credential provider chain resolved without usable credentials. Check \`aws sso login --profile ` + profile + "` for SSO profiles, or `~/.aws/credentials` / `~/.aws/config` for regular profiles.");
|
|
17845
|
+
let region;
|
|
17846
|
+
try {
|
|
17847
|
+
const regionProvider = sts.config.region;
|
|
17848
|
+
const resolved = typeof regionProvider === "function" ? await regionProvider() : regionProvider;
|
|
17849
|
+
if (typeof resolved === "string" && resolved.length > 0) region = resolved;
|
|
17850
|
+
} catch {
|
|
17851
|
+
region = void 0;
|
|
16905
17852
|
}
|
|
16906
|
-
const dir = await materializeLayerFromArn(layer, { ...layerRoleArn !== void 0 && { roleArn: layerRoleArn } });
|
|
16907
|
-
layerTmpDirs.add(dir);
|
|
16908
|
-
flat.push({
|
|
16909
|
-
logicalId: layer.arn,
|
|
16910
|
-
assetPath: dir
|
|
16911
|
-
});
|
|
16912
|
-
}
|
|
16913
|
-
if (flat.length === 1) return flat[0].assetPath;
|
|
16914
|
-
const dir = mkdtempSync(path.join(tmpdir(), `${getEmbedConfig().resourceNamePrefix}-start-api-layers-`));
|
|
16915
|
-
for (const layer of flat) cpSync(layer.assetPath, dir, {
|
|
16916
|
-
recursive: true,
|
|
16917
|
-
force: true
|
|
16918
|
-
});
|
|
16919
|
-
layerTmpDirs.add(dir);
|
|
16920
|
-
return dir;
|
|
16921
|
-
}
|
|
16922
|
-
function resolveLambdaByLogicalId(logicalId, stacks) {
|
|
16923
|
-
for (const stack of stacks) {
|
|
16924
|
-
const resource = stack.template.Resources?.[logicalId];
|
|
16925
|
-
if (!resource || resource.Type !== "AWS::Lambda::Function") continue;
|
|
16926
|
-
const props = resource.Properties ?? {};
|
|
16927
|
-
const memoryMb = typeof props["MemorySize"] === "number" ? props["MemorySize"] : 128;
|
|
16928
|
-
const timeoutSec = typeof props["Timeout"] === "number" ? props["Timeout"] : 3;
|
|
16929
|
-
const code = props["Code"] ?? {};
|
|
16930
|
-
const imageUri = extractImageUri(code["ImageUri"], logicalId, stack.stackName, stack.template.Resources ?? {}, stack.region);
|
|
16931
|
-
if (imageUri !== void 0) return resolveImageLambda({
|
|
16932
|
-
stack,
|
|
16933
|
-
logicalId,
|
|
16934
|
-
resource,
|
|
16935
|
-
props,
|
|
16936
|
-
memoryMb,
|
|
16937
|
-
timeoutSec,
|
|
16938
|
-
imageUri
|
|
16939
|
-
});
|
|
16940
|
-
const runtime = typeof props["Runtime"] === "string" ? props["Runtime"] : "";
|
|
16941
|
-
const handler = typeof props["Handler"] === "string" ? props["Handler"] : "";
|
|
16942
|
-
if (!runtime) throw new Error(`Lambda '${logicalId}' has no Runtime property and no Code.ImageUri. ${getEmbedConfig().cliName} start-api cannot tell if this is a ZIP or a container Lambda.`);
|
|
16943
|
-
if (!handler) throw new Error(`Lambda '${logicalId}' has no Handler property.`);
|
|
16944
|
-
const inlineCode = typeof code["ZipFile"] === "string" ? code["ZipFile"] : void 0;
|
|
16945
|
-
let codePath = null;
|
|
16946
|
-
if (!inlineCode) codePath = resolveAssetCodePath(stack, logicalId, resource);
|
|
16947
|
-
const layers = resolveLambdaLayers(stack, logicalId, props);
|
|
16948
|
-
const ephemeralStorageMb = extractEphemeralStorageMb(props, logicalId);
|
|
16949
17853
|
return {
|
|
16950
|
-
|
|
16951
|
-
|
|
16952
|
-
|
|
16953
|
-
|
|
16954
|
-
runtime,
|
|
16955
|
-
handler,
|
|
16956
|
-
memoryMb,
|
|
16957
|
-
timeoutSec,
|
|
16958
|
-
codePath,
|
|
16959
|
-
layers,
|
|
16960
|
-
...inlineCode !== void 0 && { inlineCode },
|
|
16961
|
-
...ephemeralStorageMb !== void 0 && { ephemeralStorageMb }
|
|
17854
|
+
accessKeyId: creds.accessKeyId,
|
|
17855
|
+
secretAccessKey: creds.secretAccessKey,
|
|
17856
|
+
...creds.sessionToken && { sessionToken: creds.sessionToken },
|
|
17857
|
+
...region && { region }
|
|
16962
17858
|
};
|
|
17859
|
+
} finally {
|
|
17860
|
+
sts.destroy();
|
|
16963
17861
|
}
|
|
16964
|
-
throw new Error(`No AWS::Lambda::Function resource named '${logicalId}' found in target stacks. This is likely a synthesis bug — the route-discovery phase resolved a route to this logical ID.`);
|
|
16965
17862
|
}
|
|
16966
17863
|
/**
|
|
16967
|
-
*
|
|
16968
|
-
*
|
|
16969
|
-
*
|
|
16970
|
-
* `Fn::Sub` (the canonical asset shape for
|
|
16971
|
-
* `lambda.DockerImageCode.fromImageAsset`), and `Fn::Join` (the
|
|
16972
|
-
* canonical shape for `lambda.DockerImageCode.fromImageAsset` in
|
|
16973
|
-
* CDK 2.x, which emits a `Fn::Join` over the literal bootstrap ECR
|
|
16974
|
-
* URI with `${AWS::URLSuffix}` — issues #627 + #637).
|
|
17864
|
+
* Resolve the fallback `AWS_REGION` for a Lambda container when neither
|
|
17865
|
+
* the assume-role STS region nor a forwarded `AWS_REGION` /
|
|
17866
|
+
* `AWS_DEFAULT_REGION` env var already set one.
|
|
16975
17867
|
*
|
|
16976
|
-
*
|
|
16977
|
-
*
|
|
16978
|
-
*
|
|
16979
|
-
* region (`stack.region`), we derive `{ urlSuffix, partition, region }`
|
|
16980
|
-
* via `derivePseudoParametersFromRegion` and pass it as the resolver's
|
|
16981
|
-
* `pseudoParameters` block so the canonical `${AWS::URLSuffix}` shape
|
|
16982
|
-
* resolves cleanly (issue #637). Same-stack ECR refs still surface as
|
|
16983
|
-
* `needs-state` (those require `--from-state`); a Join that references
|
|
16984
|
-
* `${AWS::AccountId}` without state still surfaces as `not-applicable`
|
|
16985
|
-
* with a more specific error message naming the missing parameter.
|
|
17868
|
+
* Precedence mirrors where the deployed function would actually run, so a
|
|
17869
|
+
* handler's ambient-region SDK call (`new XxxClient({})`) reaches the same
|
|
17870
|
+
* region locally as the resources it is bound to:
|
|
16986
17871
|
*
|
|
16987
|
-
*
|
|
16988
|
-
*
|
|
16989
|
-
*
|
|
17872
|
+
* 1. `--stack-region` — explicit; also drives the `--from-cfn-stack` CFn
|
|
17873
|
+
* client, so the container matches the region the bound stack was read
|
|
17874
|
+
* from.
|
|
17875
|
+
* 2. the synth-derived stack region — `env.region` on the CDK stack, read
|
|
17876
|
+
* from the cloud assembly manifest (`StackInfo.region`). Previously
|
|
17877
|
+
* used only host-side for the `--from-cfn-stack` CFn client; never
|
|
17878
|
+
* injected into the container.
|
|
17879
|
+
* 3. the `--profile`'s configured region — `~/.aws/config`'s `region =`
|
|
17880
|
+
* for the profile. `--profile` injected credentials but not this.
|
|
17881
|
+
*
|
|
17882
|
+
* Returns `undefined` when none is known; the caller leaves `AWS_REGION`
|
|
17883
|
+
* unset so the SDK's own "Region is missing" surfaces (the correct signal
|
|
17884
|
+
* for a region-agnostic stack with no profile region and no `AWS_REGION`
|
|
17885
|
+
* env).
|
|
16990
17886
|
*/
|
|
16991
|
-
function
|
|
16992
|
-
|
|
16993
|
-
|
|
16994
|
-
|
|
16995
|
-
|
|
16996
|
-
|
|
16997
|
-
|
|
16998
|
-
|
|
16999
|
-
|
|
17000
|
-
|
|
17001
|
-
|
|
17002
|
-
|
|
17003
|
-
|
|
17004
|
-
|
|
17005
|
-
|
|
17006
|
-
|
|
17887
|
+
function resolveContainerFallbackRegion(args) {
|
|
17888
|
+
return args.stackRegionOverride ?? args.synthRegion ?? args.profileRegion;
|
|
17889
|
+
}
|
|
17890
|
+
/**
|
|
17891
|
+
* Issue an STS AssumeRole and return temporary credentials. Mirrors
|
|
17892
|
+
* `cdkl invoke`'s helper byte-for-byte; lifted here so the
|
|
17893
|
+
* start-api command stays self-contained.
|
|
17894
|
+
*/
|
|
17895
|
+
async function assumeLambdaExecutionRole(roleArn, region) {
|
|
17896
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
17897
|
+
const sts = new STSClient({ ...region && { region } });
|
|
17898
|
+
try {
|
|
17899
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
17900
|
+
RoleArn: roleArn,
|
|
17901
|
+
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-start-api-${Date.now()}`,
|
|
17902
|
+
DurationSeconds: 3600
|
|
17903
|
+
}))).Credentials;
|
|
17904
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
17905
|
+
return {
|
|
17906
|
+
accessKeyId: creds.AccessKeyId,
|
|
17907
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
17908
|
+
sessionToken: creds.SessionToken
|
|
17909
|
+
};
|
|
17910
|
+
} finally {
|
|
17911
|
+
sts.destroy();
|
|
17007
17912
|
}
|
|
17008
17913
|
}
|
|
17009
17914
|
/**
|
|
17010
|
-
*
|
|
17011
|
-
*
|
|
17012
|
-
*
|
|
17013
|
-
* fields `cdkl start-api` actually consumes.
|
|
17915
|
+
* Parse / clamp the `--per-lambda-concurrency` flag. Above-cap values
|
|
17916
|
+
* are clamped to 4 with a warn line (per the spec doc's risk-mitigation
|
|
17917
|
+
* row).
|
|
17014
17918
|
*/
|
|
17015
|
-
function
|
|
17016
|
-
const
|
|
17017
|
-
|
|
17018
|
-
|
|
17019
|
-
|
|
17020
|
-
|
|
17021
|
-
if (typeof rawImageConfig["WorkingDirectory"] === "string") imageConfig.workingDirectory = rawImageConfig["WorkingDirectory"];
|
|
17022
|
-
const arches = props["Architectures"];
|
|
17023
|
-
let architecture = "x86_64";
|
|
17024
|
-
if (Array.isArray(arches) && arches.length > 0) {
|
|
17025
|
-
const first = arches[0];
|
|
17026
|
-
if (first === "arm64") architecture = "arm64";
|
|
17027
|
-
else if (first === "x86_64") architecture = "x86_64";
|
|
17028
|
-
else throw new Error(`Lambda '${logicalId}' has unsupported Architectures value '${String(first)}'. ${getEmbedConfig().cliName} start-api supports x86_64 and arm64.`);
|
|
17919
|
+
function parsePerLambdaConcurrency(raw) {
|
|
17920
|
+
const parsed = parseInt(raw, 10);
|
|
17921
|
+
if (!Number.isFinite(parsed) || parsed < 1) throw new Error(`--per-lambda-concurrency must be a positive integer (got '${raw}')`);
|
|
17922
|
+
if (parsed > 4) {
|
|
17923
|
+
getLogger().warn(`--per-lambda-concurrency ${parsed} exceeds the v1 cap of 4; clamping to 4. (Raise this in a follow-up PR if your workload needs more.)`);
|
|
17924
|
+
return 4;
|
|
17029
17925
|
}
|
|
17030
|
-
|
|
17031
|
-
return {
|
|
17032
|
-
kind: "image",
|
|
17033
|
-
stack,
|
|
17034
|
-
logicalId,
|
|
17035
|
-
resource,
|
|
17036
|
-
memoryMb,
|
|
17037
|
-
timeoutSec,
|
|
17038
|
-
imageUri,
|
|
17039
|
-
imageConfig,
|
|
17040
|
-
architecture,
|
|
17041
|
-
layers: [],
|
|
17042
|
-
...ephemeralStorageMb !== void 0 && { ephemeralStorageMb }
|
|
17043
|
-
};
|
|
17926
|
+
return parsed;
|
|
17044
17927
|
}
|
|
17045
17928
|
/**
|
|
17046
|
-
*
|
|
17047
|
-
*
|
|
17048
|
-
*
|
|
17929
|
+
* Filter the global Lambda spec map to just the Lambdas reachable from
|
|
17930
|
+
* one API server group. The container pool for that server is built
|
|
17931
|
+
* from this filtered map so per-API authorizer Lambdas + route
|
|
17932
|
+
* handlers stay scoped to their owning server — disposing one server's
|
|
17933
|
+
* pool on shutdown does NOT touch another server's still-warm
|
|
17934
|
+
* containers.
|
|
17935
|
+
*
|
|
17936
|
+
* Also includes any authorizer Lambdas attached to the group's routes
|
|
17937
|
+
* (a Lambda authorizer is a Lambda the pool needs to know about, even
|
|
17938
|
+
* though no route directly handles `lambdaLogicalId === auth.lambdaLogicalId`).
|
|
17049
17939
|
*/
|
|
17050
|
-
function
|
|
17051
|
-
const
|
|
17052
|
-
|
|
17053
|
-
|
|
17054
|
-
|
|
17940
|
+
function filterSpecsForGroup(group, allSpecs) {
|
|
17941
|
+
const ids = /* @__PURE__ */ new Set();
|
|
17942
|
+
for (const rwa of group.routes) {
|
|
17943
|
+
ids.add(rwa.route.lambdaLogicalId);
|
|
17944
|
+
const auth = rwa.authorizer;
|
|
17945
|
+
if (auth && (auth.kind === "lambda-token" || auth.kind === "lambda-request")) ids.add(auth.lambdaLogicalId);
|
|
17946
|
+
}
|
|
17947
|
+
const out = /* @__PURE__ */ new Map();
|
|
17948
|
+
for (const id of ids) {
|
|
17949
|
+
const spec = allSpecs.get(id);
|
|
17950
|
+
if (spec) out.set(id, spec);
|
|
17951
|
+
}
|
|
17952
|
+
return out;
|
|
17055
17953
|
}
|
|
17056
17954
|
/**
|
|
17057
|
-
* Print
|
|
17058
|
-
*
|
|
17059
|
-
*
|
|
17060
|
-
* Routes with `unsupported` or `mockCors` are annotated so the user can
|
|
17061
|
-
* tell at a glance which routes will dispatch to a Lambda vs which
|
|
17062
|
-
* return 501 / 204 directly:
|
|
17063
|
-
* - normal: `GET /items -> Handler (HTTP API)`
|
|
17064
|
-
* - mockCors: `OPTIONS /items -> [MOCK CORS preflight] (REST v1, stage 'prod')`
|
|
17065
|
-
* - unsupported: `POST /admin -> [501 Not Implemented] (HTTP API)`
|
|
17955
|
+
* Print one route table per server, with the server's display name as
|
|
17956
|
+
* the section header. Replaces the pre-issue #260 single flat table —
|
|
17957
|
+
* users now see exactly which routes belong to which API + port.
|
|
17066
17958
|
*/
|
|
17067
|
-
function
|
|
17068
|
-
const
|
|
17069
|
-
|
|
17070
|
-
|
|
17071
|
-
});
|
|
17072
|
-
const methodWidth = Math.max(...sorted.map((r) => r.method.length), 6);
|
|
17073
|
-
const pathWidth = Math.max(...sorted.map((r) => r.pathPattern.length), 8);
|
|
17074
|
-
process.stdout.write("Discovered routes:\n");
|
|
17075
|
-
for (const r of sorted) {
|
|
17076
|
-
const sourceLabel = r.source === "http-api" ? "HTTP API" : r.source === "rest-v1" ? `REST v1, stage '${r.stage}'` : "Function URL";
|
|
17077
|
-
const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.restV1Integration ? formatRestV1IntegrationLabel(r.restV1Integration) : r.lambdaLogicalId;
|
|
17078
|
-
process.stdout.write(` ${r.method.padEnd(methodWidth)} ${r.pathPattern.padEnd(pathWidth)} -> ${target} (${sourceLabel})\n`);
|
|
17959
|
+
function printPerServerRouteTables(servers) {
|
|
17960
|
+
for (const { group, server } of servers) {
|
|
17961
|
+
process.stdout.write(`\n${group.displayName} (http://${server.host}:${server.port})\n`);
|
|
17962
|
+
printRouteTable(group.routes);
|
|
17079
17963
|
}
|
|
17080
|
-
process.stdout.write("\n");
|
|
17081
17964
|
}
|
|
17082
17965
|
/**
|
|
17083
|
-
*
|
|
17084
|
-
*
|
|
17085
|
-
*
|
|
17086
|
-
*
|
|
17966
|
+
* Surface every `unsupported` route (deferred 501) as a startup warn so
|
|
17967
|
+
* the user sees what isn't reachable BEFORE they try to curl it. One
|
|
17968
|
+
* warn line per route — the route's `unsupported.reason` already names
|
|
17969
|
+
* the offender + the underlying limitation, so we just prefix with
|
|
17970
|
+
* method + path. Returns the number of unsupported routes so the caller
|
|
17971
|
+
* can emit a single-line summary header above the list.
|
|
17087
17972
|
*/
|
|
17088
|
-
function
|
|
17089
|
-
|
|
17090
|
-
|
|
17091
|
-
|
|
17092
|
-
|
|
17093
|
-
|
|
17094
|
-
}
|
|
17973
|
+
function warnUnsupportedRoutes(routes, logger) {
|
|
17974
|
+
const unsupported = routes.filter((r) => r.unsupported);
|
|
17975
|
+
if (unsupported.length === 0) return 0;
|
|
17976
|
+
logger.warn(`${unsupported.length} route(s) will respond HTTP 501 Not Implemented when hit (boot continued):`);
|
|
17977
|
+
for (const r of unsupported) logger.warn(` - ${r.method} ${r.pathPattern}: ${r.unsupported.reason}`);
|
|
17978
|
+
return unsupported.length;
|
|
17095
17979
|
}
|
|
17096
17980
|
/**
|
|
17097
|
-
*
|
|
17098
|
-
*
|
|
17099
|
-
*
|
|
17100
|
-
*
|
|
17101
|
-
*
|
|
17102
|
-
*
|
|
17103
|
-
*
|
|
17981
|
+
* Surface every WebSocket API tagged as unsupported at discovery as a
|
|
17982
|
+
* startup warn. The boot loop above skips attaching the server for
|
|
17983
|
+
* these APIs, so no upgrade requests are ever accepted on them —
|
|
17984
|
+
* mirrors `warnUnsupportedRoutes`'s shape but for the WebSocket axis.
|
|
17985
|
+
* Typical trigger: a Route declaring `AuthorizationType !== 'NONE'` on
|
|
17986
|
+
* `$connect` (cdkl v1 does not emulate WebSocket authorizers; closing
|
|
17987
|
+
* this gap structurally rather than silently admitting
|
|
17988
|
+
* unauthenticated clients matches the security-by-default precedent
|
|
17989
|
+
* PR #514 set for HTTP API v2 service integrations).
|
|
17104
17990
|
*/
|
|
17105
|
-
function
|
|
17106
|
-
const
|
|
17107
|
-
if (
|
|
17108
|
-
|
|
17109
|
-
const
|
|
17110
|
-
|
|
17111
|
-
const filePath = path.join(dir, `${modulePath}${fileExtension}`);
|
|
17112
|
-
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
17113
|
-
writeFileSync(filePath, source, "utf-8");
|
|
17114
|
-
return dir;
|
|
17991
|
+
function warnUnsupportedWebSocketApis(apis, logger) {
|
|
17992
|
+
const unsupported = apis.filter((api) => api.unsupported);
|
|
17993
|
+
if (unsupported.length === 0) return 0;
|
|
17994
|
+
logger.warn(`${unsupported.length} WebSocket API(s) will NOT accept upgrade requests (boot continued):`);
|
|
17995
|
+
for (const api of unsupported) logger.warn(` - ${api.declaredAt}: ${api.unsupported.reason}`);
|
|
17996
|
+
return unsupported.length;
|
|
17115
17997
|
}
|
|
17116
|
-
/**
|
|
17117
|
-
|
|
17118
|
-
|
|
17119
|
-
|
|
17120
|
-
|
|
17121
|
-
|
|
17122
|
-
|
|
17998
|
+
/**
|
|
17999
|
+
* Surface a one-line warn per HTTP / HTTP_PROXY integration whose
|
|
18000
|
+
* `Integration.Uri` points at a well-known internal address space
|
|
18001
|
+
* (AWS IMDS, loopback, link-local, RFC1918). PR #505 / issue #457
|
|
18002
|
+
* follow-up: cdk-local does NOT block these — warn-and-proceed matches the
|
|
18003
|
+
* cognito JWKS pass-through pattern — but the user should see the
|
|
18004
|
+
* destination at boot so a malicious / typo'd template Uri does not
|
|
18005
|
+
* silently exfiltrate credentials in CI. Deduplicated per-Uri.
|
|
18006
|
+
*/
|
|
18007
|
+
function warnSsrfRiskyIntegrations(routes, logger) {
|
|
18008
|
+
const seen = /* @__PURE__ */ new Set();
|
|
18009
|
+
for (const r of routes) {
|
|
18010
|
+
const integ = r.restV1Integration;
|
|
18011
|
+
if (!integ) continue;
|
|
18012
|
+
if (integ.kind !== "http" && integ.kind !== "http-proxy") continue;
|
|
18013
|
+
if (seen.has(integ.uri)) continue;
|
|
18014
|
+
seen.add(integ.uri);
|
|
18015
|
+
warnSsrfRiskyUri(integ.uri, `${r.method} ${r.pathPattern}`, (msg) => logger.warn(msg));
|
|
18016
|
+
}
|
|
17123
18017
|
}
|
|
17124
|
-
/**
|
|
17125
|
-
|
|
17126
|
-
|
|
17127
|
-
|
|
18018
|
+
/**
|
|
18019
|
+
* One reload cycle for the multi-server topology (issue #260). The
|
|
18020
|
+
* watcher serializes calls via a chain promise; this function:
|
|
18021
|
+
*
|
|
18022
|
+
* 1. Re-runs `synthesizeAndBuild()` once (failure → warn + keep
|
|
18023
|
+
* previous version serving on every server).
|
|
18024
|
+
* 2. Re-groups the new routes by API server key.
|
|
18025
|
+
* 3. For each existing server, swaps state to the new group's
|
|
18026
|
+
* routes + a freshly-built pool filtered to that group's
|
|
18027
|
+
* Lambdas. Disposes the previous pool in the background.
|
|
18028
|
+
* 4. Warns about new groups (= an API was added in CDK code) and
|
|
18029
|
+
* vanished groups (= an API was removed) — those require a
|
|
18030
|
+
* server restart in v1.
|
|
18031
|
+
*/
|
|
18032
|
+
async function reloadAllServers(args) {
|
|
18033
|
+
const { synthesizeAndBuild, servers, buildPool, logger } = args;
|
|
18034
|
+
let material;
|
|
17128
18035
|
try {
|
|
17129
|
-
|
|
18036
|
+
material = await synthesizeAndBuild();
|
|
17130
18037
|
} catch (err) {
|
|
17131
|
-
|
|
18038
|
+
logger.warn(`cdk synth failed during reload; keeping previous version. (${err instanceof Error ? err.message : String(err)})`);
|
|
18039
|
+
return;
|
|
17132
18040
|
}
|
|
17133
|
-
|
|
17134
|
-
|
|
17135
|
-
|
|
17136
|
-
|
|
17137
|
-
|
|
18041
|
+
const newGroups = groupRoutesByServer(material.routes);
|
|
18042
|
+
const newByKey = new Map(newGroups.map((g) => [g.serverKey, g]));
|
|
18043
|
+
const oldKeys = new Set(servers.map((s) => s.group.serverKey));
|
|
18044
|
+
const newKeys = new Set(newByKey.keys());
|
|
18045
|
+
const added = [...newKeys].filter((k) => !oldKeys.has(k));
|
|
18046
|
+
const removed = [...oldKeys].filter((k) => !newKeys.has(k));
|
|
18047
|
+
if (added.length > 0) logger.warn(`Reload detected new API surface(s): ${added.join(", ")}. Restart '${getEmbedConfig().cliName} start-api' to serve them.`);
|
|
18048
|
+
if (removed.length > 0) logger.warn(`Reload detected removed API surface(s): ${removed.join(", ")}. Their servers will keep serving stale routes until restart.`);
|
|
18049
|
+
for (const booted of servers) {
|
|
18050
|
+
const group = newByKey.get(booted.group.serverKey);
|
|
18051
|
+
if (!group) continue;
|
|
18052
|
+
const newPool = buildPool(filterSpecsForGroup(group, material.specs));
|
|
18053
|
+
const newState = {
|
|
18054
|
+
routes: group.routes,
|
|
18055
|
+
pool: newPool,
|
|
18056
|
+
corsConfigByApiId: material.corsConfigByApiId
|
|
18057
|
+
};
|
|
18058
|
+
const previousState = booted.server.setServerState(newState);
|
|
18059
|
+
booted.group = group;
|
|
18060
|
+
previousState.pool.dispose().catch((err) => {
|
|
18061
|
+
logger.debug(`Previous pool dispose() failed for ${group.displayName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
18062
|
+
});
|
|
18063
|
+
}
|
|
18064
|
+
printPerServerRouteTables(servers);
|
|
18065
|
+
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
18066
|
+
warnUnsupportedRoutes(allRoutes, logger);
|
|
18067
|
+
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
18068
|
+
}
|
|
18069
|
+
/**
|
|
18070
|
+
* Returns true when any value in the function's template env map is a
|
|
18071
|
+
* CFn intrinsic (non-primitive). Used to gate the pseudo-parameter STS
|
|
18072
|
+
* hop inside the `--from-state` flow: literal-only env maps don't need
|
|
18073
|
+
* the pseudo-parameter bag and shouldn't pay for an STS call. Mirrors
|
|
18074
|
+
* the same gating in `local-invoke.ts` (`envHasIntrinsicValue`) and
|
|
18075
|
+
* `ecs-task-resolver.ts` (`containerHasIntrinsicEnvOrSecret`).
|
|
18076
|
+
*/
|
|
18077
|
+
function envHasIntrinsicValue(templateEnv) {
|
|
18078
|
+
if (!templateEnv) return false;
|
|
18079
|
+
for (const v of Object.values(templateEnv)) {
|
|
18080
|
+
if (v === void 0 || v === null) continue;
|
|
18081
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") continue;
|
|
18082
|
+
return true;
|
|
17138
18083
|
}
|
|
17139
|
-
|
|
17140
|
-
return parsed;
|
|
18084
|
+
return false;
|
|
17141
18085
|
}
|
|
17142
18086
|
/**
|
|
17143
|
-
*
|
|
17144
|
-
*
|
|
17145
|
-
*
|
|
18087
|
+
* Load deployed state for every stack that owns a routed Lambda. Once
|
|
18088
|
+
* per `synthesizeAndBuild` pass (initial boot + every reload), so a
|
|
18089
|
+
* Lambda's per-spec build does not pay one round-trip per Lambda. Per-
|
|
18090
|
+
* stack failures (no state, ambiguous region, bucket resolution error)
|
|
18091
|
+
* degrade to warn-and-fall-back via the active `LocalStateProvider` —
|
|
18092
|
+
* the affected stack's reachable Lambdas behave as if `--from-state` /
|
|
18093
|
+
* `--from-cfn-stack` were not set, while sibling stacks with loadable
|
|
18094
|
+
* state still substitute.
|
|
18095
|
+
*
|
|
18096
|
+
* Pseudo parameters are resolved per stack and only when at least one
|
|
18097
|
+
* reachable Lambda in that stack has an intrinsic-valued env entry
|
|
18098
|
+
* (gated via {@link envHasIntrinsicValue}). STS failures degrade to
|
|
18099
|
+
* warn and leave `pseudoParameters: undefined` — substitution still
|
|
18100
|
+
* runs for non-`AWS::*` refs.
|
|
17146
18101
|
*/
|
|
17147
|
-
|
|
17148
|
-
|
|
17149
|
-
|
|
17150
|
-
|
|
17151
|
-
|
|
17152
|
-
|
|
17153
|
-
|
|
17154
|
-
|
|
17155
|
-
|
|
17156
|
-
|
|
18102
|
+
/**
|
|
18103
|
+
* Returns true when at least one host-provided extra state-provider flag
|
|
18104
|
+
* is active in the options bag.
|
|
18105
|
+
*/
|
|
18106
|
+
function hasExtraStateProviderActive(options, extraStateProviders) {
|
|
18107
|
+
if (!extraStateProviders) return false;
|
|
18108
|
+
for (const key of Object.keys(extraStateProviders)) if (options[key]) return true;
|
|
18109
|
+
return false;
|
|
18110
|
+
}
|
|
18111
|
+
async function loadStateForRoutedStacks(stacks, routes, routesWithAuth, options, extraStateProviders) {
|
|
18112
|
+
const logger = getLogger();
|
|
18113
|
+
const out = /* @__PURE__ */ new Map();
|
|
18114
|
+
const lambdaIds = uniqueLambdaIds(routes, routesWithAuth);
|
|
18115
|
+
const reachableStackNames = /* @__PURE__ */ new Set();
|
|
18116
|
+
for (const logicalId of lambdaIds) for (const stack of stacks) {
|
|
18117
|
+
const resource = stack.template.Resources?.[logicalId];
|
|
18118
|
+
if (resource && resource.Type === "AWS::Lambda::Function") {
|
|
18119
|
+
reachableStackNames.add(stack.stackName);
|
|
18120
|
+
break;
|
|
18121
|
+
}
|
|
18122
|
+
}
|
|
18123
|
+
const stackHasIntrinsicEnv = (stackName) => {
|
|
18124
|
+
for (const logicalId of lambdaIds) for (const stack of stacks) {
|
|
18125
|
+
if (stack.stackName !== stackName) continue;
|
|
18126
|
+
const resource = stack.template.Resources?.[logicalId];
|
|
18127
|
+
if (!resource || resource.Type !== "AWS::Lambda::Function") continue;
|
|
18128
|
+
if (envHasIntrinsicValue(getTemplateEnv(resource))) return true;
|
|
18129
|
+
}
|
|
18130
|
+
return false;
|
|
18131
|
+
};
|
|
18132
|
+
rejectExplicitCfnStackWithMultipleStacks(options, reachableStackNames.size);
|
|
18133
|
+
for (const stackName of reachableStackNames) {
|
|
18134
|
+
const stack = stacks.find((s) => s.stackName === stackName);
|
|
18135
|
+
if (!stack) continue;
|
|
18136
|
+
const provider = createLocalStateProvider(options, stack.stackName, await resolveCfnFallbackRegion(options, stack.region), extraStateProviders);
|
|
18137
|
+
if (!provider) continue;
|
|
18138
|
+
try {
|
|
18139
|
+
const loaded = await provider.load(stack.stackName, stack.region);
|
|
18140
|
+
if (!loaded) continue;
|
|
18141
|
+
const bundle = { state: {
|
|
18142
|
+
version: 1,
|
|
18143
|
+
stackName: stack.stackName,
|
|
18144
|
+
resources: loaded.resources,
|
|
18145
|
+
outputs: loaded.outputs,
|
|
18146
|
+
lastModified: 0
|
|
18147
|
+
} };
|
|
18148
|
+
if (stackHasIntrinsicEnv(stackName)) {
|
|
18149
|
+
const pseudo = await resolvePseudoParametersForStartApi(loaded.region, options);
|
|
18150
|
+
if (pseudo) bundle.pseudoParameters = pseudo;
|
|
18151
|
+
}
|
|
18152
|
+
if (provider.resolveDeployedFunctionEnv) {
|
|
18153
|
+
const deployedEnvByLambda = /* @__PURE__ */ new Map();
|
|
18154
|
+
for (const logicalId of lambdaIds) {
|
|
18155
|
+
const resource = stack.template.Resources?.[logicalId];
|
|
18156
|
+
if (!resource || resource.Type !== "AWS::Lambda::Function") continue;
|
|
18157
|
+
if (!envHasIntrinsicValue(getTemplateEnv(resource))) continue;
|
|
18158
|
+
const physicalId = loaded.resources[logicalId]?.physicalId;
|
|
18159
|
+
if (!physicalId) continue;
|
|
18160
|
+
const deployedEnv = await provider.resolveDeployedFunctionEnv(physicalId);
|
|
18161
|
+
if (deployedEnv) deployedEnvByLambda.set(logicalId, deployedEnv);
|
|
18162
|
+
}
|
|
18163
|
+
if (deployedEnvByLambda.size > 0) bundle.deployedEnvByLambda = deployedEnvByLambda;
|
|
18164
|
+
}
|
|
18165
|
+
if (stackHasIntrinsicEnv(stackName) && provider.resolveTemplateSsmParameters) {
|
|
18166
|
+
const ssmParameters = await provider.resolveTemplateSsmParameters(stack.template);
|
|
18167
|
+
if (Object.keys(ssmParameters.values).length > 0) bundle.ssmParameters = ssmParameters.values;
|
|
18168
|
+
if (ssmParameters.secureStringLogicalIds.length > 0) bundle.ssmSecureStringLogicalIds = ssmParameters.secureStringLogicalIds;
|
|
18169
|
+
}
|
|
18170
|
+
out.set(stackName, bundle);
|
|
18171
|
+
logger.debug(`${provider.label}: loaded state for ${stackName} (${loaded.region})`);
|
|
18172
|
+
} finally {
|
|
18173
|
+
provider.dispose();
|
|
18174
|
+
}
|
|
17157
18175
|
}
|
|
18176
|
+
return out;
|
|
17158
18177
|
}
|
|
17159
18178
|
/**
|
|
17160
|
-
*
|
|
17161
|
-
*
|
|
17162
|
-
*
|
|
17163
|
-
*
|
|
17164
|
-
*
|
|
17165
|
-
*
|
|
17166
|
-
* - `~/.aws/config` profiles with `role_arn` + `source_profile` (chained AssumeRole)
|
|
17167
|
-
* - `credential_process` external resolvers
|
|
17168
|
-
*
|
|
17169
|
-
* `forwardAwsEnv` only reads `process.env.AWS_*`, which is empty for
|
|
17170
|
-
* every shape except "user manually exported the env vars". The
|
|
17171
|
-
* Lambda container therefore boots without creds and the handler's
|
|
17172
|
-
* AWS SDK call fails with `Could not load credentials from any providers`.
|
|
17173
|
-
*
|
|
17174
|
-
* This helper constructs a transient `STSClient({ profile })` to drive
|
|
17175
|
-
* the SDK's default credential provider chain — same code path the
|
|
17176
|
-
* host's own AWS SDK clients use when `--profile` is set, so SSO / IAM
|
|
17177
|
-
* Identity Center / role-assumption profiles all resolve the same way
|
|
17178
|
-
* they already do for the host's outbound calls. We then extract the
|
|
17179
|
-
* resolved `AwsCredentialIdentity` via `sts.config.credentials()` and
|
|
17180
|
-
* return the underlying `{ accessKeyId, secretAccessKey, sessionToken? }`
|
|
17181
|
-
* for env-var injection.
|
|
17182
|
-
*
|
|
17183
|
-
* Called ONCE at server boot; the resolved creds are reused for every
|
|
17184
|
-
* Lambda container's env overlay (when `--assume-role` is not set for
|
|
17185
|
-
* that Lambda — assume-role wins per the existing precedence). SSO
|
|
17186
|
-
* temp creds typically last 1h+, so a single resolve is fine for the
|
|
17187
|
-
* common dev session; long-running `--watch` sessions that outlive
|
|
17188
|
-
* the creds need a cdk-local restart (deferred refresh out of scope for
|
|
17189
|
-
* v1, see issue #654).
|
|
18179
|
+
* Build the AWS pseudo-parameter bag for `--from-state` env-var
|
|
18180
|
+
* substitution. Mirrors `resolvePseudoParametersForInvoke` in
|
|
18181
|
+
* `local-invoke.ts` byte-for-byte — kept inlined here rather than
|
|
18182
|
+
* extracted into a shared helper because the two call sites differ in
|
|
18183
|
+
* region precedence (this one is per-stack so the resolved state
|
|
18184
|
+
* region takes priority).
|
|
17190
18185
|
*
|
|
17191
|
-
*
|
|
17192
|
-
*
|
|
17193
|
-
* synthesized credentials file we mount into the container carries only
|
|
17194
|
-
* the credential triple — no `region =` — so without this the container
|
|
17195
|
-
* boots with creds but no region, and a handler's ambient-region SDK call
|
|
17196
|
-
* (`new XxxClient({})`) fails with "Region is missing". The caller seeds
|
|
17197
|
-
* the container's `AWS_REGION` fallback with it (see
|
|
17198
|
-
* {@link resolveContainerFallbackRegion}). Region resolution is
|
|
17199
|
-
* best-effort: a profile with no region (or any resolution failure)
|
|
17200
|
-
* yields `region: undefined` rather than throwing — a missing region is
|
|
17201
|
-
* not a missing-credentials error.
|
|
18186
|
+
* Region precedence: `--region` > `AWS_REGION` > `AWS_DEFAULT_REGION` >
|
|
18187
|
+
* the state record's region (returned by the active `LocalStateProvider`).
|
|
17202
18188
|
*/
|
|
17203
|
-
async function
|
|
17204
|
-
const
|
|
17205
|
-
const
|
|
18189
|
+
async function resolvePseudoParametersForStartApi(stateRegion, options) {
|
|
18190
|
+
const logger = getLogger();
|
|
18191
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? stateRegion;
|
|
18192
|
+
let accountId;
|
|
17206
18193
|
try {
|
|
17207
|
-
const
|
|
17208
|
-
const
|
|
17209
|
-
if (!creds || !creds.accessKeyId || !creds.secretAccessKey) throw new Error(`--profile '${profile}': credential provider chain resolved without usable credentials. Check \`aws sso login --profile ` + profile + "` for SSO profiles, or `~/.aws/credentials` / `~/.aws/config` for regular profiles.");
|
|
17210
|
-
let region;
|
|
18194
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
18195
|
+
const sts = new STSClient({ ...region && { region } });
|
|
17211
18196
|
try {
|
|
17212
|
-
|
|
17213
|
-
|
|
17214
|
-
|
|
17215
|
-
} catch {
|
|
17216
|
-
region = void 0;
|
|
18197
|
+
accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
18198
|
+
} finally {
|
|
18199
|
+
sts.destroy();
|
|
17217
18200
|
}
|
|
17218
|
-
|
|
17219
|
-
|
|
17220
|
-
secretAccessKey: creds.secretAccessKey,
|
|
17221
|
-
...creds.sessionToken && { sessionToken: creds.sessionToken },
|
|
17222
|
-
...region && { region }
|
|
17223
|
-
};
|
|
17224
|
-
} finally {
|
|
17225
|
-
sts.destroy();
|
|
18201
|
+
} catch (err) {
|
|
18202
|
+
logger.warn(`--from-state: resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped for AWS::AccountId; affected env entries will be dropped with per-key warnings.`);
|
|
17226
18203
|
}
|
|
18204
|
+
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
18205
|
+
const bag = {
|
|
18206
|
+
...accountId !== void 0 && { accountId },
|
|
18207
|
+
...region !== void 0 && { region },
|
|
18208
|
+
...partitionAndSuffix && {
|
|
18209
|
+
partition: partitionAndSuffix.partition,
|
|
18210
|
+
urlSuffix: partitionAndSuffix.urlSuffix
|
|
18211
|
+
}
|
|
18212
|
+
};
|
|
18213
|
+
return Object.keys(bag).length === 0 ? void 0 : bag;
|
|
18214
|
+
}
|
|
18215
|
+
/** Validate `--debug-port-base`. */
|
|
18216
|
+
function parseDebugPort(raw) {
|
|
18217
|
+
const parsed = parseInt(raw, 10);
|
|
18218
|
+
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) throw new Error(`--debug-port-base must be 1..65535 (got '${raw}')`);
|
|
18219
|
+
return parsed;
|
|
17227
18220
|
}
|
|
17228
18221
|
/**
|
|
17229
|
-
* Resolve the
|
|
17230
|
-
* the
|
|
17231
|
-
*
|
|
17232
|
-
*
|
|
17233
|
-
*
|
|
17234
|
-
* handler's ambient-region SDK call (`new XxxClient({})`) reaches the same
|
|
17235
|
-
* region locally as the resources it is bound to:
|
|
18222
|
+
* Resolve the mTLS configuration from CLI options. Returns `undefined`
|
|
18223
|
+
* when none of the three `--mtls-*` flags is set (the server stays
|
|
18224
|
+
* plain-HTTP). When any of the three is set, ALL THREE must be set —
|
|
18225
|
+
* partial configurations are rejected at parse time so the server
|
|
18226
|
+
* never boots in a half-configured state.
|
|
17236
18227
|
*
|
|
17237
|
-
*
|
|
17238
|
-
|
|
17239
|
-
|
|
17240
|
-
|
|
17241
|
-
|
|
17242
|
-
|
|
17243
|
-
|
|
17244
|
-
|
|
17245
|
-
|
|
18228
|
+
* Exported for unit testing.
|
|
18229
|
+
*/
|
|
18230
|
+
function resolveMtlsConfig(options) {
|
|
18231
|
+
const present = [];
|
|
18232
|
+
const absent = [];
|
|
18233
|
+
if (options.mtlsTruststore !== void 0 && options.mtlsTruststore !== "") present.push("--mtls-truststore");
|
|
18234
|
+
else absent.push("--mtls-truststore");
|
|
18235
|
+
if (options.mtlsCert !== void 0 && options.mtlsCert !== "") present.push("--mtls-cert");
|
|
18236
|
+
else absent.push("--mtls-cert");
|
|
18237
|
+
if (options.mtlsKey !== void 0 && options.mtlsKey !== "") present.push("--mtls-key");
|
|
18238
|
+
else absent.push("--mtls-key");
|
|
18239
|
+
if (present.length === 0) return void 0;
|
|
18240
|
+
if (absent.length > 0) throw new Error(`mTLS configuration is incomplete: ${present.join(", ")} set but ${absent.join(", ")} missing. All three of --mtls-truststore, --mtls-cert, and --mtls-key must be set together to enable mTLS, or all three left unset for plain HTTP.`);
|
|
18241
|
+
return readMtlsMaterialsFromDisk({
|
|
18242
|
+
truststorePath: options.mtlsTruststore,
|
|
18243
|
+
certPath: options.mtlsCert,
|
|
18244
|
+
keyPath: options.mtlsKey
|
|
18245
|
+
});
|
|
18246
|
+
}
|
|
18247
|
+
/**
|
|
18248
|
+
* Builder for the `start-api` subcommand.
|
|
18249
|
+
*/
|
|
18250
|
+
function createLocalStartApiCommand(opts = {}) {
|
|
18251
|
+
setEmbedConfig(opts.embedConfig);
|
|
18252
|
+
const startApi = new Command("start-api").description("Run a long-running local HTTP server that maps API Gateway routes (REST v1, HTTP API, Function URL) to Lambda invocations against the AWS Lambda Runtime Interface Emulator (Docker required). Supports Lambda TOKEN/REQUEST authorizers, Cognito User Pool / HTTP v2 JWT authorizers, and AWS_IAM auth (REST v1 `AuthorizationType: AWS_IAM` and Function URL `AuthType: AWS_IAM` — SigV4 signature verification only; IAM policy evaluation is NOT emulated). When JWKS is unreachable, JWT authorizers fall back to pass-through (every token accepted) with a warn line — local dev fallback. VPC-config Lambdas run locally and surface a warn line at startup; their containers do NOT get attached to the deployed VPC subnets, so calls to private RDS / ElastiCache will fail.").argument("[targets...]", `Optional API subset filter. Pass one or more identifiers to serve exactly that subset (the union; each on its own port). Each accepts the bare CDK logical id ('MyHttpApi'; single-stack apps only), stack-qualified logical id ('MyStack:MyHttpApi'), full CDK Construct path ('MyStack/MyHttpApi/Resource'), or an ancestor Construct path that prefix-matches ('MyStack/MyHttpApi'). When omitted in a TTY, a multi-select picker opens with every API pre-selected (Enter serves all, deselect to pick a subset); when omitted in a non-TTY (CI / pipe) every discovered API is served. Mirrors \`${getEmbedConfig().cliName} invoke\` / \`${getEmbedConfig().cliName} run-task\` target syntax.`).action(withErrorHandling(async (targets, options) => {
|
|
18253
|
+
await localStartApiCommand(targets, options, opts.extraStateProviders);
|
|
18254
|
+
}));
|
|
18255
|
+
addStartApiSpecificOptions(startApi);
|
|
18256
|
+
[
|
|
18257
|
+
...commonOptions(),
|
|
18258
|
+
...appOptions(),
|
|
18259
|
+
...contextOptions
|
|
18260
|
+
].forEach((opt) => startApi.addOption(opt));
|
|
18261
|
+
startApi.addOption(deprecatedRegionOption);
|
|
18262
|
+
return startApi;
|
|
18263
|
+
}
|
|
18264
|
+
/**
|
|
18265
|
+
* Register the option block that `cdkl start-api` adds on top of the shared
|
|
18266
|
+
* common / app / context option helpers. Shared between `cdkl start-api`
|
|
18267
|
+
* and any host CLI (e.g. cdkd's `local start-api`) that wraps the
|
|
18268
|
+
* long-running API-Gateway-fronted local HTTP server, so adding or
|
|
18269
|
+
* renaming a `start-api`-only flag here propagates to every embedder
|
|
18270
|
+
* without duplicate `.addOption(...)` blocks.
|
|
17246
18271
|
*
|
|
17247
|
-
*
|
|
17248
|
-
*
|
|
17249
|
-
*
|
|
17250
|
-
*
|
|
18272
|
+
* Calling order only affects `--help` presentation (Commander parses
|
|
18273
|
+
* insertion-order-independent). The host-CLI convention is host-specific
|
|
18274
|
+
* options first, then this helper, then the shared common / app / context
|
|
18275
|
+
* options — host flags / start-api flags / common flags grouped in three
|
|
18276
|
+
* `--help` clusters. Chainable: returns `cmd`.
|
|
17251
18277
|
*/
|
|
17252
|
-
function
|
|
17253
|
-
return
|
|
18278
|
+
function addStartApiSpecificOptions(cmd) {
|
|
18279
|
+
return cmd.addOption(new Option("--port <port>", "HTTP server port (default: auto-allocate)").default("0")).addOption(new Option("--host <host>", "Bind address").default("127.0.0.1")).addOption(new Option("--stack <name>", "Stack to start (single-stack apps auto-detect)")).addOption(new Option("--all-stacks", "Serve every stack's API in a multi-stack app (each API on its own port) instead of erroring out. Mutually exclusive with a positional target subset, --stack, and an explicit --from-cfn-stack <name>; the bare --from-cfn-stack flag stays compatible (binds each routed stack to its own CFn stack).").default(false)).addOption(new Option("--warm", "Pre-start one container per Lambda at server boot").default(false)).addOption(new Option("--per-lambda-concurrency <n>", "Pool size cap per Lambda (default 2, max 4)").default("2")).addOption(new Option("--no-pull", "Skip docker pull (cached image)")).addOption(new Option("--container-host <host>", "IP the host uses to bind/probe the RIE port (must be a numeric IP — `docker run -p <ip>:<port>:8080` rejects hostnames). Defaults to 127.0.0.1.").default("127.0.0.1")).addOption(new Option("--debug-port-base <port>", "Reserve a contiguous --debug-port range (one per Lambda)")).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}, \"Parameters\": {...}})")).addOption(new Option("--assume-role <arn-or-pair>", "Assume the Lambda's execution role and forward STS-issued temp creds. Bare <arn> = global default; <LogicalId>=<arn> = per-Lambda override (repeatable). Per-Lambda > global > unset (developer creds passed through).").argParser((raw, prev) => parseAssumeRoleToken(raw, prev))).addOption(new Option("--watch", "Hot-reload: re-synth + re-discover routes when the CDK app's source changes (honors cdk.json watch.include/exclude; cdk.out, node_modules, .git are always excluded). Off by default; the server keeps the previous version serving when synth fails mid-reload.").default(false)).addOption(new Option("--stage <name>", "Select an API Gateway Stage by its 'StageName'. Default: the first Stage attached to each API. Drives event.stageVariables for both REST v1 and HTTP API v2. NOTE: For HTTP API v2 routes, requestContext.stage is always '$default' regardless of this flag (AWS-side limitation — HTTP API only exposes one stage to the integration event); only event.stageVariables is affected for v2 routes. For REST v1 routes the selected StageName is also threaded into requestContext.stage.")).addOption(new Option("--api <id>", "DEPRECATED — use the positional <targets...> argument instead. Accepts a SINGLE identifier; for a subset pass multiple positional targets. Same accepted forms (bare logical id, stack-qualified, Construct path, ancestor prefix). Will be removed in a future major release.")).addOption(new Option("--layer-role-arn <arn>", "Role to sts:AssumeRole before calling lambda:GetLayerVersion on every literal-ARN entry in Properties.Layers (issue #448). Use only when the dev credentials cannot read the layer — typically cross-account layers. AWS-published public layers (e.g. Lambda Powertools) are readable from every account and need no role.")).addOption(new Option("--from-cfn-stack [cfn-stack-name]", "Read a deployed CloudFormation stack via ListStackResources and substitute Ref / Fn::ImportValue in Lambda env vars with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (`cdk deploy`). Bare form uses the resolved stack name per routed stack; pass an explicit value when a single CFn stack should serve every routed stack. Fn::GetAtt is warn-and-dropped in v1 (CFn ListStackResources does not return per-attribute values).")).addOption(new Option("--stack-region <region>", "Region of the state record to read. Used with --from-cfn-stack as the CFn client region.")).addOption(new Option("--mtls-truststore <path>", `PEM-encoded CA bundle for client-certificate verification (mutual TLS). When set, the local server switches from HTTP to HTTPS and the TLS handshake rejects clients whose certificate doesn't chain to one of these CAs. Verified certs are surfaced on the Lambda event under requestContext.identity.clientCert (REST v1) / requestContext.authentication.clientCert (HTTP API v2). Must be set together with --mtls-cert + --mtls-key; partial flag sets are rejected. Generate a CA + server + client cert for local dev: openssl req -x509 -newkey rsa:2048 -nodes -keyout ca-key.pem -out ca.pem -subj "/CN=${getEmbedConfig().resourceNamePrefix}-ca" -days 365; openssl req -newkey rsa:2048 -nodes -keyout server-key.pem -out server-csr.pem -subj "/CN=localhost"; openssl x509 -req -in server-csr.pem -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem -days 365; openssl req -newkey rsa:2048 -nodes -keyout client-key.pem -out client-csr.pem -subj "/CN=client"; openssl x509 -req -in client-csr.pem -CA ca.pem -CAkey ca-key.pem -CAcreateserial -out client-cert.pem -days 365; curl --cacert ca.pem --cert client-cert.pem --key client-key.pem https://localhost:<port>/...`)).addOption(new Option("--mtls-cert <path>", "PEM-encoded server certificate for mutual TLS. Self-signed is fine for local dev. Must be set together with --mtls-truststore + --mtls-key.")).addOption(new Option("--mtls-key <path>", "PEM-encoded server private key matching --mtls-cert. Must be set together with --mtls-truststore + --mtls-cert.")).addOption(new Option("--strict-sigv4", "Opt-in: DENY AWS_IAM SigV4 requests that cannot be cryptographically verified (foreign access-key-id — e.g. a federated / Cognito Identity Pool / cross-account signer — OR no local AWS credentials configured) instead of the default warn-and-pass. DEFAULT off: cdk-local warn-and-passes unverifiable IAM requests with a placeholder principalId so local dev exercises app logic without reproducing an auth boundary it cannot fully emulate. OAC-fronted Function URLs always warn-and-pass regardless.").default(false));
|
|
17254
18280
|
}
|
|
18281
|
+
|
|
18282
|
+
//#endregion
|
|
18283
|
+
//#region src/cli/commands/local-invoke-agentcore.ts
|
|
17255
18284
|
/**
|
|
17256
|
-
*
|
|
17257
|
-
*
|
|
17258
|
-
* start-api command stays self-contained.
|
|
18285
|
+
* Parser for `--timeout <ms>`. Accepts a positive integer; rejects 0,
|
|
18286
|
+
* negatives, fractions, and non-numeric input.
|
|
17259
18287
|
*/
|
|
17260
|
-
|
|
17261
|
-
const
|
|
17262
|
-
|
|
18288
|
+
function parseTimeoutMs(raw) {
|
|
18289
|
+
const parsed = Number(raw);
|
|
18290
|
+
if (!Number.isInteger(parsed) || parsed <= 0) throw new CdkLocalError(`--timeout must be a positive integer number of milliseconds (got '${raw}').`, "LOCAL_INVOKE_AGENTCORE_TIMEOUT_INVALID");
|
|
18291
|
+
return parsed;
|
|
18292
|
+
}
|
|
18293
|
+
/**
|
|
18294
|
+
* `cdkl invoke-agentcore <target>` — run a Bedrock AgentCore Runtime container
|
|
18295
|
+
* locally and invoke it once over the AgentCore HTTP contract. Resolves
|
|
18296
|
+
* the `AWS::BedrockAgentCore::Runtime`, pulls / builds its container,
|
|
18297
|
+
* starts it on port 8080, waits for `GET /ping`, POSTs the event to
|
|
18298
|
+
* `POST /invocations`, prints the response, and tears down. Covers the
|
|
18299
|
+
* container artifact and the CodeConfiguration managed-runtime artifact
|
|
18300
|
+
* (fromCodeAsset, built from source) on the HTTP + MCP protocols; the agent's
|
|
18301
|
+
* calls to real AWS go to real AWS (credentials injected like `cdkl invoke`).
|
|
18302
|
+
*/
|
|
18303
|
+
async function localInvokeAgentCoreCommand(target, options, extraStateProviders) {
|
|
18304
|
+
const logger = getLogger();
|
|
18305
|
+
if (options.verbose) logger.setLevel("debug");
|
|
18306
|
+
warnIfDeprecatedRegion(options);
|
|
18307
|
+
let containerId;
|
|
18308
|
+
let stopLogs;
|
|
18309
|
+
let sigintHandler;
|
|
18310
|
+
let profileCredsFile;
|
|
18311
|
+
let stateProvider;
|
|
18312
|
+
const cleanup = singleFlight(async () => {
|
|
18313
|
+
if (stateProvider) try {
|
|
18314
|
+
stateProvider.dispose();
|
|
18315
|
+
} catch (err) {
|
|
18316
|
+
getLogger().debug(`state provider dispose failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
18317
|
+
}
|
|
18318
|
+
if (stopLogs) try {
|
|
18319
|
+
stopLogs();
|
|
18320
|
+
} catch (err) {
|
|
18321
|
+
getLogger().debug(`streamLogs stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
18322
|
+
}
|
|
18323
|
+
if (containerId) try {
|
|
18324
|
+
await removeContainer(containerId);
|
|
18325
|
+
} catch (err) {
|
|
18326
|
+
getLogger().debug(`removeContainer(${containerId}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
18327
|
+
}
|
|
18328
|
+
if (profileCredsFile) try {
|
|
18329
|
+
await profileCredsFile.dispose();
|
|
18330
|
+
} catch (err) {
|
|
18331
|
+
getLogger().debug(`Failed to remove profile credentials tmpdir ${profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
18332
|
+
}
|
|
18333
|
+
}, (err) => {
|
|
18334
|
+
getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
18335
|
+
});
|
|
17263
18336
|
try {
|
|
17264
|
-
|
|
17265
|
-
|
|
17266
|
-
|
|
17267
|
-
|
|
17268
|
-
|
|
17269
|
-
|
|
17270
|
-
|
|
17271
|
-
|
|
17272
|
-
|
|
17273
|
-
|
|
18337
|
+
await applyRoleArnIfSet({
|
|
18338
|
+
roleArn: options.roleArn,
|
|
18339
|
+
region: options.region
|
|
18340
|
+
});
|
|
18341
|
+
await ensureDockerAvailable();
|
|
18342
|
+
const profileCredentials = options.profile ? await resolveProfileCredentials(options.profile) : void 0;
|
|
18343
|
+
if (options.profile && profileCredentials) profileCredsFile = await writeProfileCredentialsFile(options.profile, profileCredentials);
|
|
18344
|
+
const appCmd = resolveApp(options.app);
|
|
18345
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
18346
|
+
logger.info("Synthesizing CDK app...");
|
|
18347
|
+
const synthesizer = new Synthesizer();
|
|
18348
|
+
const context = parseContextOptions(options.context);
|
|
18349
|
+
const synthOpts = {
|
|
18350
|
+
app: appCmd,
|
|
18351
|
+
output: options.output,
|
|
18352
|
+
...options.region && { region: options.region },
|
|
18353
|
+
...options.profile && { profile: options.profile },
|
|
18354
|
+
...Object.keys(context).length > 0 && { context }
|
|
18355
|
+
};
|
|
18356
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
18357
|
+
const resolvedTarget = await resolveSingleTarget(target, {
|
|
18358
|
+
entries: listTargets(stacks).agentCoreRuntimes,
|
|
18359
|
+
message: "Select an AgentCore Runtime to invoke",
|
|
18360
|
+
noun: "AgentCore Runtimes",
|
|
18361
|
+
onMissing: () => new CdkLocalError(`${getEmbedConfig().cliName} invoke-agentcore requires a <target> (an AgentCore Runtime display path or logical ID). Run \`${getEmbedConfig().cliName} list\` to see them, or run it in a TTY to pick interactively.`, "LOCAL_INVOKE_AGENTCORE_TARGET_REQUIRED")
|
|
18362
|
+
});
|
|
18363
|
+
const candidate = pickAgentCoreCandidateStack(resolvedTarget, stacks);
|
|
18364
|
+
stateProvider = createLocalStateProvider(options, candidate?.stackName ?? "", await resolveCfnFallbackRegion(options, candidate?.region), extraStateProviders);
|
|
18365
|
+
const { context: imageContext, loaded: loadedState } = stateProvider && candidate ? await buildAgentCoreImageContext(candidate, stateProvider, options) : {
|
|
18366
|
+
context: void 0,
|
|
18367
|
+
loaded: void 0
|
|
18368
|
+
};
|
|
18369
|
+
const resolved = resolveAgentCoreTarget(resolvedTarget, stacks, imageContext);
|
|
18370
|
+
logger.info(`Target: ${resolved.stack.stackName}/${resolved.logicalId} (${resolved.protocol})`);
|
|
18371
|
+
const isMcp = resolved.protocol === "MCP";
|
|
18372
|
+
const isA2a = resolved.protocol === "A2A";
|
|
18373
|
+
if (resolved.protocol === "AGUI") logger.info("AGUI runtime: routing through the HTTP /invocations + /ws path (AG-UI wire is SSE / WebSocket on port 8080).");
|
|
18374
|
+
if ((isMcp || isA2a) && options.ws) logger.warn(`--ws applies only to the HTTP / AGUI protocols; ignoring it for this ${resolved.protocol} runtime.`);
|
|
18375
|
+
if (options.wsInteractive && !options.ws) logger.warn("--ws-interactive is meaningful only with --ws; ignoring.");
|
|
18376
|
+
if (options.sigv4 && (isMcp || isA2a || options.ws)) logger.warn("--sigv4 signs the HTTP /invocations request only; ignoring it for the " + (isMcp ? "MCP" : isA2a ? "A2A" : "/ws WebSocket") + " path.");
|
|
18377
|
+
const sessionId = options.sessionId ?? randomUUID();
|
|
18378
|
+
const event = await readEvent(options);
|
|
18379
|
+
const mcpRequest = isMcp ? buildMcpRequest(event) : void 0;
|
|
18380
|
+
const a2aRequest = isA2a ? buildA2aRequest(event) : void 0;
|
|
18381
|
+
let authorization;
|
|
18382
|
+
if (isMcp || isA2a) {
|
|
18383
|
+
if (resolved.jwtAuthorizer || options.bearerToken) {
|
|
18384
|
+
const pathLabel = isMcp ? MCP_PATH : "/";
|
|
18385
|
+
logger.info(`${resolved.protocol} runtime: invoking the local container's ${pathLabel} directly (vanilla ${resolved.protocol}). An inbound JWT / --bearer-token is an AgentCore managed-plane concern and is not applied locally.`);
|
|
18386
|
+
}
|
|
18387
|
+
} else authorization = await resolveInboundAuthorization(resolved, options);
|
|
18388
|
+
await resolveFromS3BucketIntrinsic(resolved, stateProvider, loadedState, imageContext);
|
|
18389
|
+
const image = await resolveAgentCoreImage(resolved, options, loadedState, stateProvider);
|
|
18390
|
+
const { env: dockerEnv, sensitiveEnvKeys } = await buildContainerEnv(resolved, options, profileCredentials, profileCredsFile, stateProvider, loadedState, imageContext);
|
|
18391
|
+
const hostPort = await pickFreePort();
|
|
18392
|
+
const containerHost = options.containerHost;
|
|
18393
|
+
const containerName = `${getEmbedConfig().resourceNamePrefix}-agentcore-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
18394
|
+
const containerPort = isMcp ? MCP_CONTAINER_PORT : isA2a ? A2A_CONTAINER_PORT : void 0;
|
|
18395
|
+
const containerPortLabel = isMcp ? `${MCP_CONTAINER_PORT}${MCP_PATH}` : isA2a ? `${A2A_CONTAINER_PORT}${"/"}` : "8080";
|
|
18396
|
+
logger.info(`Starting agent container (image=${image}, port=${hostPort} -> ${containerPortLabel})...`);
|
|
18397
|
+
containerId = await runDetached({
|
|
18398
|
+
image,
|
|
18399
|
+
mounts: [],
|
|
18400
|
+
env: dockerEnv,
|
|
18401
|
+
cmd: [],
|
|
18402
|
+
hostPort,
|
|
18403
|
+
host: containerHost,
|
|
18404
|
+
platform: options.platform,
|
|
18405
|
+
name: containerName,
|
|
18406
|
+
...containerPort !== void 0 && { containerPort },
|
|
18407
|
+
...sensitiveEnvKeys.size > 0 && { sensitiveEnvKeys }
|
|
18408
|
+
});
|
|
18409
|
+
stopLogs = streamLogs(containerId);
|
|
18410
|
+
sigintHandler = () => {
|
|
18411
|
+
cleanup().then(() => process.exit(130));
|
|
17274
18412
|
};
|
|
18413
|
+
process.on("SIGINT", sigintHandler);
|
|
18414
|
+
if (isMcp && mcpRequest) {
|
|
18415
|
+
logger.info(`MCP request: ${mcpRequest.method}`);
|
|
18416
|
+
const mcp = await mcpInvokeOnce(containerHost, hostPort, mcpRequest, { requestTimeoutMs: options.timeout });
|
|
18417
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
18418
|
+
emitMcpResult(mcp);
|
|
18419
|
+
} else if (isA2a && a2aRequest) {
|
|
18420
|
+
logger.info(`A2A request: ${a2aRequest.method}`);
|
|
18421
|
+
const a2a = await a2aInvokeOnce(containerHost, hostPort, a2aRequest, { requestTimeoutMs: options.timeout });
|
|
18422
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
18423
|
+
emitA2aResult(a2a);
|
|
18424
|
+
} else if (options.ws) {
|
|
18425
|
+
await waitForAgentCorePing(containerHost, hostPort);
|
|
18426
|
+
const frameSource = options.wsInteractive ? readStdinLines() : void 0;
|
|
18427
|
+
logger.info(options.wsInteractive ? "Opening the agent /ws WebSocket (interactive — stdin lines = follow-up frames; Ctrl-D to end)..." : "Opening the agent /ws WebSocket and streaming frames...");
|
|
18428
|
+
const wsResult = await invokeAgentCoreWs(containerHost, hostPort, event, {
|
|
18429
|
+
sessionId,
|
|
18430
|
+
timeoutMs: options.timeout,
|
|
18431
|
+
onMessage: (text) => process.stdout.write(text),
|
|
18432
|
+
...authorization && { authorization },
|
|
18433
|
+
...frameSource && { frameSource }
|
|
18434
|
+
});
|
|
18435
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
18436
|
+
emitWsResult(wsResult);
|
|
18437
|
+
} else {
|
|
18438
|
+
await waitForAgentCorePing(containerHost, hostPort);
|
|
18439
|
+
const additionalHeaders = await buildSigV4HeadersIfRequested(options, resolved, loadedState, containerHost, hostPort, event, sessionId, stateProvider);
|
|
18440
|
+
const result = await invokeAgentCore(containerHost, hostPort, event, {
|
|
18441
|
+
sessionId,
|
|
18442
|
+
timeoutMs: options.timeout,
|
|
18443
|
+
onChunk: (text) => process.stdout.write(text),
|
|
18444
|
+
...authorization && { authorization },
|
|
18445
|
+
...additionalHeaders && { additionalHeaders }
|
|
18446
|
+
});
|
|
18447
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
18448
|
+
emitResult(result);
|
|
18449
|
+
}
|
|
17275
18450
|
} finally {
|
|
17276
|
-
|
|
18451
|
+
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
18452
|
+
await cleanup();
|
|
17277
18453
|
}
|
|
17278
18454
|
}
|
|
17279
18455
|
/**
|
|
17280
|
-
*
|
|
17281
|
-
*
|
|
17282
|
-
*
|
|
18456
|
+
* Enforce the runtime's inbound JWT authorizer (when declared) and return
|
|
18457
|
+
* the `Authorization` header to forward to `/invocations`.
|
|
18458
|
+
*
|
|
18459
|
+
* - No authorizer → forward the token verbatim if one was given (no-op
|
|
18460
|
+
* otherwise).
|
|
18461
|
+
* - `--no-verify-auth` → warn + forward without verifying (local-dev escape).
|
|
18462
|
+
* - Authorizer + no token → reject (AgentCore returns 401).
|
|
18463
|
+
* - Authorizer + token → verify against the OIDC discovery URL; reject on
|
|
18464
|
+
* failure (AgentCore returns 403); forward on success. An unreachable
|
|
18465
|
+
* discovery URL falls back to pass-through accept (offline-dev fallback in
|
|
18466
|
+
* {@link verifyJwtViaDiscovery}).
|
|
18467
|
+
*
|
|
18468
|
+
* Exported so a unit test can drive the gate without the full Docker pipeline.
|
|
17283
18469
|
*/
|
|
17284
|
-
function
|
|
17285
|
-
const
|
|
17286
|
-
|
|
17287
|
-
|
|
17288
|
-
|
|
17289
|
-
|
|
18470
|
+
async function resolveInboundAuthorization(resolved, options) {
|
|
18471
|
+
const logger = getLogger();
|
|
18472
|
+
const authorizer = resolved.jwtAuthorizer;
|
|
18473
|
+
const header = options.bearerToken ? `Bearer ${options.bearerToken}` : void 0;
|
|
18474
|
+
if (!authorizer) return header;
|
|
18475
|
+
if (options.verifyAuth === false) {
|
|
18476
|
+
logger.warn(`Runtime '${resolved.logicalId}' declares a customJwtAuthorizer, but --no-verify-auth was set — skipping inbound JWT verification (local-dev escape hatch).`);
|
|
18477
|
+
return header;
|
|
18478
|
+
}
|
|
18479
|
+
if (!header) throw new CdkLocalError(`Runtime '${resolved.logicalId}' requires an inbound JWT (customJwtAuthorizer). Pass --bearer-token <jwt>, or --no-verify-auth to skip verification for local dev.`, "LOCAL_INVOKE_AGENTCORE_AUTH_REQUIRED");
|
|
18480
|
+
if (!(await verifyJwtViaDiscovery({
|
|
18481
|
+
discoveryUrl: authorizer.discoveryUrl,
|
|
18482
|
+
...authorizer.allowedAudience && { allowedAudience: authorizer.allowedAudience },
|
|
18483
|
+
...authorizer.allowedClients && { allowedClients: authorizer.allowedClients },
|
|
18484
|
+
...authorizer.allowedScopes && { allowedScopes: authorizer.allowedScopes },
|
|
18485
|
+
...authorizer.customClaims && { customClaims: authorizer.customClaims }
|
|
18486
|
+
}, header, createJwksCache(), { warned: /* @__PURE__ */ new Set() })).allow) throw new CdkLocalError(`Inbound JWT rejected by the runtime's customJwtAuthorizer (signature / issuer / expiry / audience check failed against ${authorizer.discoveryUrl}).`, "LOCAL_INVOKE_AGENTCORE_AUTH_DENIED");
|
|
18487
|
+
logger.info(`Inbound JWT verified against ${authorizer.discoveryUrl}.`);
|
|
18488
|
+
return header;
|
|
18489
|
+
}
|
|
18490
|
+
/**
|
|
18491
|
+
* Compute the SigV4 headers for the `/invocations` POST when `--sigv4` is
|
|
18492
|
+
* requested. Returns `undefined` (no header overlay) when:
|
|
18493
|
+
*
|
|
18494
|
+
* - `--sigv4` is not set,
|
|
18495
|
+
* - the runtime declares a `customJwtAuthorizer` (the JWT path wins; warns),
|
|
18496
|
+
*
|
|
18497
|
+
* Throws a {@link CdkLocalError} when `--sigv4` conflicts with
|
|
18498
|
+
* `--bearer-token`, or when no AWS credentials are resolvable for signing.
|
|
18499
|
+
*
|
|
18500
|
+
* Exported so a unit test can drive the gate without the full Docker pipeline.
|
|
18501
|
+
*/
|
|
18502
|
+
async function buildSigV4HeadersIfRequested(options, resolved, loaded, host, port, event, sessionId, stateProvider) {
|
|
18503
|
+
if (!options.sigv4) return void 0;
|
|
18504
|
+
if (options.bearerToken) throw new CdkLocalError(`--sigv4 and --bearer-token are mutually exclusive: pick one inbound auth.`, "LOCAL_INVOKE_AGENTCORE_AUTH_CONFLICT");
|
|
18505
|
+
if (resolved.jwtAuthorizer) {
|
|
18506
|
+
getLogger().warn(`Runtime '${resolved.logicalId}' declares a customJwtAuthorizer; --sigv4 ignored (JWT path takes precedence).`);
|
|
18507
|
+
return;
|
|
17290
18508
|
}
|
|
17291
|
-
|
|
18509
|
+
const region = options.region ?? options.stackRegion ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? resolved.stack.region;
|
|
18510
|
+
if (!region) throw new CdkLocalError("--sigv4: no region resolved for the AgentCore signing scope. Pass --region <region>, set AWS_REGION, or use --from-cfn-stack with a region-bound stack.", "LOCAL_INVOKE_AGENTCORE_SIGV4_NO_REGION");
|
|
18511
|
+
const signed = await signAgentCoreInvocation({
|
|
18512
|
+
credentials: await resolveHostCredentialsForSigV4(options, resolved, loaded, region, stateProvider),
|
|
18513
|
+
region,
|
|
18514
|
+
host,
|
|
18515
|
+
port,
|
|
18516
|
+
path: "/invocations",
|
|
18517
|
+
body: JSON.stringify(event ?? {}),
|
|
18518
|
+
sessionId
|
|
18519
|
+
});
|
|
18520
|
+
const headers = {
|
|
18521
|
+
Authorization: signed.authorization,
|
|
18522
|
+
"X-Amz-Date": signed.amzDate,
|
|
18523
|
+
"X-Amz-Content-Sha256": signed.amzContentSha256
|
|
18524
|
+
};
|
|
18525
|
+
if (signed.amzSecurityToken) headers["X-Amz-Security-Token"] = signed.amzSecurityToken;
|
|
18526
|
+
getLogger().info(`Signed /invocations with SigV4 (region=${region}).`);
|
|
18527
|
+
return headers;
|
|
17292
18528
|
}
|
|
17293
18529
|
/**
|
|
17294
|
-
*
|
|
17295
|
-
*
|
|
17296
|
-
*
|
|
17297
|
-
*
|
|
17298
|
-
*
|
|
17299
|
-
* containers.
|
|
18530
|
+
* Resolve credentials for host-side SigV4 signing. Precedence:
|
|
18531
|
+
* 1. `--assume-role` → STS temp creds (warn + fall through on STS failure);
|
|
18532
|
+
* 2. `--profile` → profile creds (sessionToken when the profile carries one);
|
|
18533
|
+
* 3. shell env (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / optional
|
|
18534
|
+
* `AWS_SESSION_TOKEN`).
|
|
17300
18535
|
*
|
|
17301
|
-
*
|
|
17302
|
-
*
|
|
17303
|
-
* though no route directly handles `lambdaLogicalId === auth.lambdaLogicalId`).
|
|
18536
|
+
* Throws a {@link CdkLocalError} when none are available — `--sigv4` cannot
|
|
18537
|
+
* proceed without credentials, unlike the unsigned path.
|
|
17304
18538
|
*/
|
|
17305
|
-
function
|
|
17306
|
-
const
|
|
17307
|
-
|
|
17308
|
-
|
|
17309
|
-
|
|
17310
|
-
|
|
18539
|
+
async function resolveHostCredentialsForSigV4(options, resolved, loaded, region, stateProvider) {
|
|
18540
|
+
const logger = getLogger();
|
|
18541
|
+
const assumeRoleArn = await resolveAssumeRoleArn(options, resolved, loaded, stateProvider);
|
|
18542
|
+
if (assumeRoleArn) try {
|
|
18543
|
+
return await assumeAgentCoreExecutionRole(assumeRoleArn, region);
|
|
18544
|
+
} catch (err) {
|
|
18545
|
+
logger.warn(`--assume-role: STS AssumeRole(${assumeRoleArn}) failed for --sigv4 signing: ${err instanceof Error ? err.message : String(err)}. Falling back to ${options.profile ? `--profile ${options.profile}` : "shell credentials"}.`);
|
|
17311
18546
|
}
|
|
17312
|
-
|
|
17313
|
-
|
|
17314
|
-
|
|
17315
|
-
|
|
18547
|
+
if (options.profile) {
|
|
18548
|
+
const creds = await resolveProfileCredentials(options.profile);
|
|
18549
|
+
if (creds?.accessKeyId && creds.secretAccessKey) return {
|
|
18550
|
+
accessKeyId: creds.accessKeyId,
|
|
18551
|
+
secretAccessKey: creds.secretAccessKey,
|
|
18552
|
+
...creds.sessionToken && { sessionToken: creds.sessionToken }
|
|
18553
|
+
};
|
|
17316
18554
|
}
|
|
17317
|
-
|
|
17318
|
-
|
|
17319
|
-
|
|
17320
|
-
|
|
17321
|
-
|
|
17322
|
-
|
|
17323
|
-
|
|
17324
|
-
|
|
17325
|
-
|
|
17326
|
-
process.stdout.write(`\n${group.displayName} (http://${server.host}:${server.port})\n`);
|
|
17327
|
-
printRouteTable(group.routes);
|
|
18555
|
+
const accessKeyId = process.env["AWS_ACCESS_KEY_ID"];
|
|
18556
|
+
const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"];
|
|
18557
|
+
if (accessKeyId && secretAccessKey) {
|
|
18558
|
+
const sessionToken = process.env["AWS_SESSION_TOKEN"];
|
|
18559
|
+
return {
|
|
18560
|
+
accessKeyId,
|
|
18561
|
+
secretAccessKey,
|
|
18562
|
+
...sessionToken && { sessionToken }
|
|
18563
|
+
};
|
|
17328
18564
|
}
|
|
18565
|
+
throw new CdkLocalError("--sigv4: no AWS credentials available to sign the request. Set AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY, pass --profile <name>, or pass --assume-role <arn>.", "LOCAL_INVOKE_AGENTCORE_SIGV4_NO_CREDENTIALS");
|
|
17329
18566
|
}
|
|
17330
18567
|
/**
|
|
17331
|
-
*
|
|
17332
|
-
*
|
|
17333
|
-
*
|
|
17334
|
-
*
|
|
17335
|
-
*
|
|
17336
|
-
*
|
|
17337
|
-
|
|
17338
|
-
|
|
17339
|
-
|
|
17340
|
-
|
|
17341
|
-
|
|
17342
|
-
for (const r of unsupported) logger.warn(` - ${r.method} ${r.pathPattern}: ${r.unsupported.reason}`);
|
|
17343
|
-
return unsupported.length;
|
|
17344
|
-
}
|
|
17345
|
-
/**
|
|
17346
|
-
* Surface every WebSocket API tagged as unsupported at discovery as a
|
|
17347
|
-
* startup warn. The boot loop above skips attaching the server for
|
|
17348
|
-
* these APIs, so no upgrade requests are ever accepted on them —
|
|
17349
|
-
* mirrors `warnUnsupportedRoutes`'s shape but for the WebSocket axis.
|
|
17350
|
-
* Typical trigger: a Route declaring `AuthorizationType !== 'NONE'` on
|
|
17351
|
-
* `$connect` (cdkl v1 does not emulate WebSocket authorizers; closing
|
|
17352
|
-
* this gap structurally rather than silently admitting
|
|
17353
|
-
* unauthenticated clients matches the security-by-default precedent
|
|
17354
|
-
* PR #514 set for HTTP API v2 service integrations).
|
|
17355
|
-
*/
|
|
17356
|
-
function warnUnsupportedWebSocketApis(apis, logger) {
|
|
17357
|
-
const unsupported = apis.filter((api) => api.unsupported);
|
|
17358
|
-
if (unsupported.length === 0) return 0;
|
|
17359
|
-
logger.warn(`${unsupported.length} WebSocket API(s) will NOT accept upgrade requests (boot continued):`);
|
|
17360
|
-
for (const api of unsupported) logger.warn(` - ${api.declaredAt}: ${api.unsupported.reason}`);
|
|
17361
|
-
return unsupported.length;
|
|
17362
|
-
}
|
|
17363
|
-
/**
|
|
17364
|
-
* Surface a one-line warn per HTTP / HTTP_PROXY integration whose
|
|
17365
|
-
* `Integration.Uri` points at a well-known internal address space
|
|
17366
|
-
* (AWS IMDS, loopback, link-local, RFC1918). PR #505 / issue #457
|
|
17367
|
-
* follow-up: cdk-local does NOT block these — warn-and-proceed matches the
|
|
17368
|
-
* cognito JWKS pass-through pattern — but the user should see the
|
|
17369
|
-
* destination at boot so a malicious / typo'd template Uri does not
|
|
17370
|
-
* silently exfiltrate credentials in CI. Deduplicated per-Uri.
|
|
18568
|
+
* Acquire the agent image. A CODE artifact (managed runtime) is built from
|
|
18569
|
+
* source — a fromCodeAsset bundle from its cdk.out asset, a fromS3 bundle
|
|
18570
|
+
* downloaded + extracted from S3. A CONTAINER artifact mirrors the
|
|
18571
|
+
* container-Lambda path: build from a local cdk.out asset when the URI matches
|
|
18572
|
+
* one, else pull from ECR, else pull a plain registry image.
|
|
18573
|
+
*
|
|
18574
|
+
* `loaded` is the `--from-cfn-stack` state record (when available) — threaded
|
|
18575
|
+
* through so a bare `--assume-role` can resolve the execution-role ARN from
|
|
18576
|
+
* state for the fromS3 download. `stateProvider` enables the issue-#187
|
|
18577
|
+
* live-fallback to `bedrock-agentcore-control:GetAgentRuntime` when the
|
|
18578
|
+
* static state lookup misses.
|
|
17371
18579
|
*/
|
|
17372
|
-
function
|
|
17373
|
-
const
|
|
17374
|
-
|
|
17375
|
-
|
|
17376
|
-
|
|
17377
|
-
|
|
17378
|
-
|
|
17379
|
-
|
|
17380
|
-
|
|
18580
|
+
async function resolveAgentCoreImage(resolved, options, loaded, stateProvider) {
|
|
18581
|
+
const logger = getLogger();
|
|
18582
|
+
const architecture = platformToArchitecture(options.platform);
|
|
18583
|
+
if (resolved.codeArtifact) return resolveAgentCoreCodeImage(resolved, resolved.codeArtifact, options, architecture, loaded, stateProvider);
|
|
18584
|
+
const containerUri = resolved.containerUri;
|
|
18585
|
+
if (containerUri === void 0) throw new CdkLocalError(`AgentCore Runtime '${resolved.logicalId}' has neither a container image nor a code artifact to run.`, "LOCAL_INVOKE_AGENTCORE_NO_ARTIFACT");
|
|
18586
|
+
const manifestPath = resolved.stack.assetManifestPath;
|
|
18587
|
+
if (manifestPath) {
|
|
18588
|
+
const cdkOutDir = dirname(manifestPath);
|
|
18589
|
+
const manifest = await new AssetManifestLoader().loadManifest(cdkOutDir, resolved.stack.stackName);
|
|
18590
|
+
if (manifest) {
|
|
18591
|
+
const entry = getDockerImageBySourceHash(manifest, containerUri);
|
|
18592
|
+
if (entry) return buildContainerImage(entry.asset, cdkOutDir, {
|
|
18593
|
+
architecture,
|
|
18594
|
+
noBuild: options.build === false
|
|
18595
|
+
});
|
|
18596
|
+
}
|
|
18597
|
+
}
|
|
18598
|
+
if (parseEcrUri(containerUri)) {
|
|
18599
|
+
logger.info(`Pulling agent image from ECR: ${containerUri}`);
|
|
18600
|
+
return pullEcrImage(containerUri, {
|
|
18601
|
+
skipPull: options.pull === false,
|
|
18602
|
+
...options.region !== void 0 && { region: options.region },
|
|
18603
|
+
...options.ecrRoleArn !== void 0 && { ecrRoleArn: options.ecrRoleArn },
|
|
18604
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
18605
|
+
});
|
|
17381
18606
|
}
|
|
18607
|
+
await pullImage(containerUri, options.pull === false);
|
|
18608
|
+
return containerUri;
|
|
18609
|
+
}
|
|
18610
|
+
/**
|
|
18611
|
+
* Build a local image from a `CodeConfiguration` (managed-runtime) bundle.
|
|
18612
|
+
*
|
|
18613
|
+
* - fromS3 (`code.s3Source` set, a literal S3 object): download + extract the
|
|
18614
|
+
* bundle, then run the from-source build over the extracted dir.
|
|
18615
|
+
* - fromCodeAsset: locate the source dir in cdk.out via its asset hash, then
|
|
18616
|
+
* run the same from-source build (generated Dockerfile → install deps → run
|
|
18617
|
+
* EntryPoint).
|
|
18618
|
+
*/
|
|
18619
|
+
async function resolveAgentCoreCodeImage(resolved, code, options, architecture, loaded, stateProvider) {
|
|
18620
|
+
if (code.s3Source) return resolveAgentCoreCodeImageFromS3(resolved, code, code.s3Source, options, architecture, loaded, stateProvider);
|
|
18621
|
+
const manifestPath = resolved.stack.assetManifestPath;
|
|
18622
|
+
if (!manifestPath) throw new CdkLocalError(`AgentCore Runtime '${resolved.logicalId}' uses a code artifact, but its stack has no asset manifest in cdk.out to read the bundle source from.`, "LOCAL_INVOKE_AGENTCORE_CODE_NO_MANIFEST");
|
|
18623
|
+
const cdkOutDir = dirname(manifestPath);
|
|
18624
|
+
const loader = new AssetManifestLoader();
|
|
18625
|
+
const manifest = await loader.loadManifest(cdkOutDir, resolved.stack.stackName);
|
|
18626
|
+
const fileAssets = manifest ? loader.getFileAssets(manifest) : void 0;
|
|
18627
|
+
const asset = fileAssets ? fileAssets.get(code.codeAssetHash) ?? findFileAssetByObjectKey(fileAssets, code.codeAssetHash) : void 0;
|
|
18628
|
+
if (!asset) throw new CdkLocalError(`AgentCore Runtime '${resolved.logicalId}' code bundle (asset ${code.codeAssetHash}) was not found in the cdk.out asset manifest. ${getEmbedConfig().cliName} invoke-agentcore runs a local from-source build of a fromCodeAsset bundle — re-synthesize the app so the asset is staged in cdk.out and retry. (A fromS3 bundle is downloaded from S3 instead; this runtime has no literal Code.S3.Bucket.)`, "LOCAL_INVOKE_AGENTCORE_CODE_ASSET_NOT_FOUND");
|
|
18629
|
+
const sourceDir = loader.getAssetSourcePath(cdkOutDir, asset);
|
|
18630
|
+
if (!existsSync(sourceDir) || !statSync(sourceDir).isDirectory()) throw new CdkLocalError(`AgentCore Runtime '${resolved.logicalId}' code bundle source '${sourceDir}' does not exist or is not a directory. Re-synthesize the app and retry.`, "LOCAL_INVOKE_AGENTCORE_CODE_SOURCE_MISSING");
|
|
18631
|
+
return buildAgentCoreCodeImage({
|
|
18632
|
+
sourceDir,
|
|
18633
|
+
runtime: code.runtime,
|
|
18634
|
+
entryPoint: code.entryPoint,
|
|
18635
|
+
architecture,
|
|
18636
|
+
noBuild: options.build === false
|
|
18637
|
+
});
|
|
17382
18638
|
}
|
|
17383
18639
|
/**
|
|
17384
|
-
*
|
|
17385
|
-
*
|
|
18640
|
+
* Build a local image from a fromS3 CodeConfiguration bundle: download +
|
|
18641
|
+
* extract the S3 object, run the from-source build over the extracted dir, then
|
|
18642
|
+
* clean up the temp dir.
|
|
17386
18643
|
*
|
|
17387
|
-
*
|
|
17388
|
-
*
|
|
17389
|
-
*
|
|
17390
|
-
*
|
|
17391
|
-
* routes + a freshly-built pool filtered to that group's
|
|
17392
|
-
* Lambdas. Disposes the previous pool in the background.
|
|
17393
|
-
* 4. Warns about new groups (= an API was added in CDK code) and
|
|
17394
|
-
* vanished groups (= an API was removed) — those require a
|
|
17395
|
-
* server restart in v1.
|
|
18644
|
+
* Credentials mirror the rest of the command: an `--assume-role` ARN (explicit,
|
|
18645
|
+
* or resolved from `--from-cfn-stack` state for the bare form) yields STS temp
|
|
18646
|
+
* creds for the download; otherwise `--profile` / the default chain is used.
|
|
18647
|
+
* The region is `--region` / `--stack-region` / env / the stack's region.
|
|
17396
18648
|
*/
|
|
17397
|
-
async function
|
|
17398
|
-
const
|
|
17399
|
-
|
|
17400
|
-
|
|
17401
|
-
|
|
18649
|
+
async function resolveAgentCoreCodeImageFromS3(resolved, code, s3Source, options, architecture, loaded, stateProvider) {
|
|
18650
|
+
const logger = getLogger();
|
|
18651
|
+
if (typeof s3Source.bucket !== "string" || s3Source.bucket.length === 0) throw new CdkLocalError(`AgentCore Runtime '${resolved.logicalId}' fromS3 bundle reached the image step with no literal bucket. This is a cdk-local bug — please report it.`, "LOCAL_INVOKE_AGENTCORE_FROMS3_BUCKET_UNRESOLVED");
|
|
18652
|
+
const location = {
|
|
18653
|
+
bucket: s3Source.bucket,
|
|
18654
|
+
key: s3Source.key,
|
|
18655
|
+
...s3Source.versionId !== void 0 && { versionId: s3Source.versionId }
|
|
18656
|
+
};
|
|
18657
|
+
const region = options.region ?? options.stackRegion ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? resolved.stack.region;
|
|
18658
|
+
const assumeRoleArn = await resolveAssumeRoleArn(options, resolved, loaded, stateProvider);
|
|
18659
|
+
let credentials;
|
|
18660
|
+
if (assumeRoleArn) try {
|
|
18661
|
+
credentials = await assumeAgentCoreExecutionRole(assumeRoleArn, region);
|
|
17402
18662
|
} catch (err) {
|
|
17403
|
-
logger.warn(
|
|
17404
|
-
return;
|
|
18663
|
+
logger.warn(`--assume-role: STS AssumeRole(${assumeRoleArn}) failed for the fromS3 bundle download: ${err instanceof Error ? err.message : String(err)}. Falling back to ${options.profile ? `--profile ${options.profile}` : "the default credentials"}.`);
|
|
17405
18664
|
}
|
|
17406
|
-
const
|
|
17407
|
-
|
|
17408
|
-
|
|
17409
|
-
|
|
17410
|
-
|
|
17411
|
-
|
|
17412
|
-
|
|
17413
|
-
|
|
17414
|
-
|
|
17415
|
-
|
|
17416
|
-
|
|
17417
|
-
|
|
17418
|
-
const newState = {
|
|
17419
|
-
routes: group.routes,
|
|
17420
|
-
pool: newPool,
|
|
17421
|
-
corsConfigByApiId: material.corsConfigByApiId
|
|
17422
|
-
};
|
|
17423
|
-
const previousState = booted.server.setServerState(newState);
|
|
17424
|
-
booted.group = group;
|
|
17425
|
-
previousState.pool.dispose().catch((err) => {
|
|
17426
|
-
logger.debug(`Previous pool dispose() failed for ${group.displayName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
18665
|
+
const bundle = await downloadAndExtractS3Bundle(location, {
|
|
18666
|
+
...region !== void 0 && { region },
|
|
18667
|
+
...options.profile !== void 0 && { profile: options.profile },
|
|
18668
|
+
...credentials !== void 0 && { credentials }
|
|
18669
|
+
});
|
|
18670
|
+
try {
|
|
18671
|
+
return await buildAgentCoreCodeImage({
|
|
18672
|
+
sourceDir: bundle.dir,
|
|
18673
|
+
runtime: code.runtime,
|
|
18674
|
+
entryPoint: code.entryPoint,
|
|
18675
|
+
architecture,
|
|
18676
|
+
noBuild: options.build === false
|
|
17427
18677
|
});
|
|
18678
|
+
} finally {
|
|
18679
|
+
await bundle.cleanup();
|
|
17428
18680
|
}
|
|
17429
|
-
printPerServerRouteTables(servers);
|
|
17430
|
-
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
17431
|
-
warnUnsupportedRoutes(allRoutes, logger);
|
|
17432
|
-
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
17433
18681
|
}
|
|
17434
18682
|
/**
|
|
17435
|
-
*
|
|
17436
|
-
*
|
|
17437
|
-
*
|
|
17438
|
-
* the pseudo-parameter bag and shouldn't pay for an STS call. Mirrors
|
|
17439
|
-
* the same gating in `local-invoke.ts` (`envHasIntrinsicValue`) and
|
|
17440
|
-
* `ecs-task-resolver.ts` (`containerHasIntrinsicEnvOrSecret`).
|
|
18683
|
+
* Find the file asset whose destination objectKey is `<hash>.zip` (matching the
|
|
18684
|
+
* `Code.S3.Prefix`'s hash) when the source-hash-keyed lookup misses — covers a
|
|
18685
|
+
* synthesizer whose source hash differs from the destination objectKey.
|
|
17441
18686
|
*/
|
|
17442
|
-
function
|
|
17443
|
-
|
|
17444
|
-
for (const
|
|
17445
|
-
if (v === void 0 || v === null) continue;
|
|
17446
|
-
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") continue;
|
|
17447
|
-
return true;
|
|
17448
|
-
}
|
|
17449
|
-
return false;
|
|
18687
|
+
function findFileAssetByObjectKey(fileAssets, hash) {
|
|
18688
|
+
const zip = `${hash}.zip`;
|
|
18689
|
+
for (const asset of fileAssets.values()) if (Object.values(asset.destinations).some((d) => d.objectKey === zip || d.objectKey.endsWith(`/${zip}`))) return asset;
|
|
17450
18690
|
}
|
|
17451
18691
|
/**
|
|
17452
|
-
*
|
|
17453
|
-
*
|
|
17454
|
-
*
|
|
17455
|
-
*
|
|
17456
|
-
*
|
|
17457
|
-
*
|
|
17458
|
-
* `--
|
|
17459
|
-
* state still substitute.
|
|
18692
|
+
* Build the container env + the set of env keys to keep off the `docker run`
|
|
18693
|
+
* argv. Substitutes `--from-cfn-stack` state into the template env (reusing the
|
|
18694
|
+
* shared state load + image-resolution context — Ref / Fn::Sub / Fn::Join +
|
|
18695
|
+
* SSM parameters, with decrypted SecureString values flagged sensitive),
|
|
18696
|
+
* applies `--env-vars` overrides, then injects AWS credentials (`--assume-role`
|
|
18697
|
+
* STS temp creds — resolving an intrinsic RoleArn from state for bare
|
|
18698
|
+
* `--assume-role` — else `--profile` / dev creds).
|
|
17460
18699
|
*
|
|
17461
|
-
*
|
|
17462
|
-
*
|
|
17463
|
-
* (gated via {@link envHasIntrinsicValue}). STS failures degrade to
|
|
17464
|
-
* warn and leave `pseudoParameters: undefined` — substitution still
|
|
17465
|
-
* runs for non-`AWS::*` refs.
|
|
18700
|
+
* The state provider + loaded record + image context are built once by the
|
|
18701
|
+
* caller and shared here, so this does not re-load state.
|
|
17466
18702
|
*/
|
|
18703
|
+
async function buildContainerEnv(resolved, options, profileCredentials, profileCredsFile, stateProvider, loaded, imageContext) {
|
|
18704
|
+
const logger = getLogger();
|
|
18705
|
+
let templateEnv = resolved.environmentVariables;
|
|
18706
|
+
const sensitiveEnvKeys = /* @__PURE__ */ new Set();
|
|
18707
|
+
if (stateProvider && loaded) {
|
|
18708
|
+
const subContext = {
|
|
18709
|
+
resources: imageContext?.stateResources ?? loaded.resources,
|
|
18710
|
+
consumerRegion: loaded.region
|
|
18711
|
+
};
|
|
18712
|
+
const pseudo = imageContext?.pseudoParameters ?? derivePseudoParametersFromRegion(loaded.region);
|
|
18713
|
+
if (pseudo) subContext.pseudoParameters = pseudo;
|
|
18714
|
+
if (imageContext?.stateParameters) subContext.parameters = imageContext.stateParameters;
|
|
18715
|
+
if (imageContext?.stateSensitiveParameters?.length) subContext.sensitiveParameters = new Set(imageContext.stateSensitiveParameters);
|
|
18716
|
+
const resolver = await stateProvider.buildCrossStackResolver(loaded.region);
|
|
18717
|
+
if (resolver) subContext.crossStackResolver = resolver;
|
|
18718
|
+
const { env, audit } = await substituteEnvVarsFromStateAsync(templateEnv, subContext);
|
|
18719
|
+
templateEnv = env;
|
|
18720
|
+
for (const key of audit.resolvedKeys) logger.debug(`${stateProvider.label}: substituted env var ${key}`);
|
|
18721
|
+
for (const key of audit.sensitiveKeys) sensitiveEnvKeys.add(key);
|
|
18722
|
+
for (const { key, reason } of audit.unresolved) logger.warn(`${stateProvider.label}: could not substitute env var ${key} (${reason}). Override it via --env-vars or it will be dropped.`);
|
|
18723
|
+
}
|
|
18724
|
+
const overrides = readEnvOverridesFile$2(options.envVars);
|
|
18725
|
+
const cdkPath = readCdkPathOrUndefined(resolved.resource);
|
|
18726
|
+
const envResult = resolveEnvVars(resolved.logicalId, cdkPath, templateEnv, overrides);
|
|
18727
|
+
for (const key of envResult.unresolved) {
|
|
18728
|
+
const overrideKeyExample = cdkPath?.replace(/\/Resource$/, "") ?? resolved.logicalId;
|
|
18729
|
+
logger.warn(`Environment variable ${key} contains a CloudFormation intrinsic and was dropped. Override it with --env-vars (e.g. {"${overrideKeyExample}":{"${key}":"<literal>"}}), or pass a state-source flag (e.g. --from-cfn-stack) to recover deployed values.`);
|
|
18730
|
+
}
|
|
18731
|
+
const dockerEnv = { ...envResult.resolved };
|
|
18732
|
+
const assumeRoleArn = await resolveAssumeRoleArn(options, resolved, loaded, stateProvider);
|
|
18733
|
+
await applyAgentCoreCredentialEnv(dockerEnv, {
|
|
18734
|
+
...assumeRoleArn !== void 0 && { assumeRoleArn },
|
|
18735
|
+
...options.region !== void 0 && { region: options.region },
|
|
18736
|
+
...profileCredentials !== void 0 && { profileCredentials },
|
|
18737
|
+
...profileCredsFile !== void 0 && { profileCredsFile: {
|
|
18738
|
+
containerPath: profileCredsFile.containerPath,
|
|
18739
|
+
profileName: profileCredsFile.profileName
|
|
18740
|
+
} }
|
|
18741
|
+
});
|
|
18742
|
+
return {
|
|
18743
|
+
env: dockerEnv,
|
|
18744
|
+
sensitiveEnvKeys
|
|
18745
|
+
};
|
|
18746
|
+
}
|
|
18747
|
+
/**
|
|
18748
|
+
* Resolve a fromS3 bundle's intrinsic `Code.S3.Bucket` to a literal bucket
|
|
18749
|
+
* name in place on `resolved.codeArtifact.s3Source.bucket`. Uses the SAME
|
|
18750
|
+
* state-substitution machinery env vars use under `--from-cfn-stack`, so
|
|
18751
|
+
* every cross-stack intrinsic that path supports (`Ref` / `Fn::ImportValue` /
|
|
18752
|
+
* `Fn::GetStackOutput`) is supported transparently here.
|
|
18753
|
+
*
|
|
18754
|
+
* No-op when there is no intrinsic to resolve. Errors when no state is
|
|
18755
|
+
* available, or when the substitution returns a non-string / unresolved value.
|
|
18756
|
+
*
|
|
18757
|
+
* Exported so a unit test can drive the gate without the full Docker pipeline.
|
|
18758
|
+
*/
|
|
18759
|
+
async function resolveFromS3BucketIntrinsic(resolved, stateProvider, loaded, imageContext) {
|
|
18760
|
+
const s3Source = resolved.codeArtifact?.s3Source;
|
|
18761
|
+
if (!s3Source || s3Source.bucketIntrinsic === void 0) return;
|
|
18762
|
+
if (s3Source.bucket !== void 0) return;
|
|
18763
|
+
if (!stateProvider || !loaded) throw new CdkLocalError(`AgentCore Runtime '${resolved.logicalId}' fromS3 bundle's Code.S3.Bucket is an unresolved intrinsic (${describeIntrinsic(s3Source.bucketIntrinsic)}). Pass --from-cfn-stack so its physical bucket name can be resolved against the deployed stack state.`, "LOCAL_INVOKE_AGENTCORE_FROMS3_BUCKET_INTRINSIC_NO_STATE");
|
|
18764
|
+
const subContext = {
|
|
18765
|
+
resources: imageContext?.stateResources ?? loaded.resources,
|
|
18766
|
+
consumerRegion: loaded.region
|
|
18767
|
+
};
|
|
18768
|
+
const pseudo = imageContext?.pseudoParameters ?? derivePseudoParametersFromRegion(loaded.region);
|
|
18769
|
+
if (pseudo) subContext.pseudoParameters = pseudo;
|
|
18770
|
+
const crossStackResolver = await stateProvider.buildCrossStackResolver(loaded.region);
|
|
18771
|
+
if (crossStackResolver) subContext.crossStackResolver = crossStackResolver;
|
|
18772
|
+
const result = await substituteAgainstStateAsync(s3Source.bucketIntrinsic, subContext);
|
|
18773
|
+
if (result.kind !== "literal") throw new CdkLocalError(`Could not resolve AgentCore Runtime '${resolved.logicalId}' fromS3 Code.S3.Bucket intrinsic (${describeIntrinsic(s3Source.bucketIntrinsic)}) against the --from-cfn-stack state: ${result.reason}. Confirm the referenced resource / export exists in the deployed stack.`, "LOCAL_INVOKE_AGENTCORE_FROMS3_BUCKET_INTRINSIC_UNRESOLVED");
|
|
18774
|
+
if (typeof result.value !== "string" || result.value.length === 0) throw new CdkLocalError(`AgentCore Runtime '${resolved.logicalId}' fromS3 Code.S3.Bucket intrinsic resolved to a ${typeof result.value} value, not a bucket name string. (${describeIntrinsic(s3Source.bucketIntrinsic)})`, "LOCAL_INVOKE_AGENTCORE_FROMS3_BUCKET_INTRINSIC_NOT_STRING");
|
|
18775
|
+
s3Source.bucket = result.value;
|
|
18776
|
+
getLogger().info(`Resolved fromS3 Code.S3.Bucket from state: ${describeIntrinsic(s3Source.bucketIntrinsic)} -> ${result.value}`);
|
|
18777
|
+
}
|
|
18778
|
+
/** Render the intrinsic key for an error / log message (e.g. `Ref:Bucket1`). */
|
|
18779
|
+
function describeIntrinsic(value) {
|
|
18780
|
+
if (!value || typeof value !== "object") return String(value);
|
|
18781
|
+
const obj = value;
|
|
18782
|
+
const key = Object.keys(obj)[0] ?? "?";
|
|
18783
|
+
const arg = obj[key];
|
|
18784
|
+
if (typeof arg === "string") return `${key}:${arg}`;
|
|
18785
|
+
return key;
|
|
18786
|
+
}
|
|
17467
18787
|
/**
|
|
17468
|
-
*
|
|
17469
|
-
*
|
|
18788
|
+
* Build the `--from-cfn-stack` image-resolution context + return the loaded
|
|
18789
|
+
* state record (loaded once, reused by env substitution + role resolution).
|
|
18790
|
+
* Mirrors `run-task`'s `buildEcsImageResolutionContext`: pseudo parameters
|
|
18791
|
+
* (region + STS account id), the deployed resources, and SSM template
|
|
18792
|
+
* parameters (decrypted SecureString logical ids flagged sensitive).
|
|
17470
18793
|
*/
|
|
17471
|
-
function
|
|
17472
|
-
if (!extraStateProviders) return false;
|
|
17473
|
-
for (const key of Object.keys(extraStateProviders)) if (options[key]) return true;
|
|
17474
|
-
return false;
|
|
17475
|
-
}
|
|
17476
|
-
async function loadStateForRoutedStacks(stacks, routes, routesWithAuth, options, extraStateProviders) {
|
|
18794
|
+
async function buildAgentCoreImageContext(candidate, stateProvider, options) {
|
|
17477
18795
|
const logger = getLogger();
|
|
17478
|
-
const
|
|
17479
|
-
|
|
17480
|
-
|
|
17481
|
-
|
|
17482
|
-
|
|
17483
|
-
|
|
17484
|
-
reachableStackNames.add(stack.stackName);
|
|
17485
|
-
break;
|
|
17486
|
-
}
|
|
18796
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
|
|
18797
|
+
let accountId;
|
|
18798
|
+
try {
|
|
18799
|
+
accountId = await resolveCallerAccountId$2(region, options.profile);
|
|
18800
|
+
} catch (err) {
|
|
18801
|
+
logger.warn(`--from-cfn-stack: STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. A same-stack ECR image URI referencing \${AWS::AccountId} may not resolve.`);
|
|
17487
18802
|
}
|
|
17488
|
-
const
|
|
17489
|
-
|
|
17490
|
-
|
|
17491
|
-
|
|
17492
|
-
|
|
17493
|
-
|
|
18803
|
+
const context = {};
|
|
18804
|
+
const pseudo = derivePseudoParametersFromRegion(region, accountId);
|
|
18805
|
+
if (pseudo) context.pseudoParameters = pseudo;
|
|
18806
|
+
const loaded = await stateProvider.load(candidate.stackName, candidate.region);
|
|
18807
|
+
if (loaded) {
|
|
18808
|
+
context.stateResources = loaded.resources;
|
|
18809
|
+
if (stateProvider.resolveTemplateSsmParameters) {
|
|
18810
|
+
const ssm = await stateProvider.resolveTemplateSsmParameters(candidate.template);
|
|
18811
|
+
if (Object.keys(ssm.values).length > 0) context.stateParameters = ssm.values;
|
|
18812
|
+
if (ssm.secureStringLogicalIds.length > 0) context.stateSensitiveParameters = ssm.secureStringLogicalIds;
|
|
17494
18813
|
}
|
|
17495
|
-
|
|
18814
|
+
}
|
|
18815
|
+
return {
|
|
18816
|
+
context,
|
|
18817
|
+
loaded: loaded ?? void 0
|
|
17496
18818
|
};
|
|
17497
|
-
|
|
17498
|
-
|
|
17499
|
-
|
|
17500
|
-
|
|
17501
|
-
|
|
17502
|
-
|
|
18819
|
+
}
|
|
18820
|
+
/** STS `GetCallerIdentity` for the `${AWS::AccountId}` pseudo parameter (threads `--profile`). */
|
|
18821
|
+
async function resolveCallerAccountId$2(region, profile) {
|
|
18822
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
18823
|
+
const sts = new STSClient({
|
|
18824
|
+
...region && { region },
|
|
18825
|
+
...profile && { profile }
|
|
18826
|
+
});
|
|
18827
|
+
try {
|
|
18828
|
+
return (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
18829
|
+
} finally {
|
|
18830
|
+
sts.destroy();
|
|
18831
|
+
}
|
|
18832
|
+
}
|
|
18833
|
+
/**
|
|
18834
|
+
* Inject AWS credentials into the container env. Precedence:
|
|
18835
|
+
* 1. `--assume-role` → STS-issued temp creds for the resolved ARN (on
|
|
18836
|
+
* STS failure, warn + fall through to dev creds).
|
|
18837
|
+
* 2. dev shell creds (`forwardAwsEnv`) + `--profile` overlay
|
|
18838
|
+
* ({@link applyProfileCredentialsOverlay}) + the bind-mounted
|
|
18839
|
+
* credentials-file env so handler `fromIni({ profile })` resolves.
|
|
18840
|
+
*
|
|
18841
|
+
* Exported so a unit test can lock the binding (mock STS) without driving
|
|
18842
|
+
* the full synth + docker pipeline.
|
|
18843
|
+
*/
|
|
18844
|
+
async function applyAgentCoreCredentialEnv(dockerEnv, args) {
|
|
18845
|
+
const logger = getLogger();
|
|
18846
|
+
let assumeSucceeded = false;
|
|
18847
|
+
if (args.assumeRoleArn) {
|
|
18848
|
+
const stsRegion = args.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"];
|
|
17503
18849
|
try {
|
|
17504
|
-
const
|
|
17505
|
-
|
|
17506
|
-
|
|
17507
|
-
|
|
17508
|
-
|
|
17509
|
-
|
|
17510
|
-
|
|
17511
|
-
|
|
17512
|
-
|
|
17513
|
-
|
|
17514
|
-
|
|
17515
|
-
|
|
17516
|
-
|
|
17517
|
-
|
|
17518
|
-
|
|
17519
|
-
|
|
17520
|
-
|
|
17521
|
-
|
|
17522
|
-
|
|
17523
|
-
|
|
17524
|
-
|
|
17525
|
-
|
|
17526
|
-
|
|
17527
|
-
|
|
17528
|
-
|
|
17529
|
-
|
|
17530
|
-
|
|
17531
|
-
|
|
17532
|
-
|
|
17533
|
-
|
|
18850
|
+
const creds = await assumeAgentCoreExecutionRole(args.assumeRoleArn, stsRegion);
|
|
18851
|
+
dockerEnv["AWS_ACCESS_KEY_ID"] = creds.accessKeyId;
|
|
18852
|
+
dockerEnv["AWS_SECRET_ACCESS_KEY"] = creds.secretAccessKey;
|
|
18853
|
+
dockerEnv["AWS_SESSION_TOKEN"] = creds.sessionToken;
|
|
18854
|
+
if (stsRegion) dockerEnv["AWS_REGION"] = stsRegion;
|
|
18855
|
+
assumeSucceeded = true;
|
|
18856
|
+
} catch (err) {
|
|
18857
|
+
logger.warn(`--assume-role: STS AssumeRole(${args.assumeRoleArn}) failed: ${err instanceof Error ? err.message : String(err)}. Falling back to the developer's shell credentials.`);
|
|
18858
|
+
}
|
|
18859
|
+
}
|
|
18860
|
+
if (!assumeSucceeded) {
|
|
18861
|
+
forwardAwsEnv$1(dockerEnv);
|
|
18862
|
+
applyProfileCredentialsOverlay(dockerEnv, args.profileCredentials, false);
|
|
18863
|
+
if (args.profileCredsFile) {
|
|
18864
|
+
dockerEnv["AWS_SHARED_CREDENTIALS_FILE"] = args.profileCredsFile.containerPath;
|
|
18865
|
+
dockerEnv["AWS_PROFILE"] = args.profileCredsFile.profileName;
|
|
18866
|
+
}
|
|
18867
|
+
}
|
|
18868
|
+
}
|
|
18869
|
+
/**
|
|
18870
|
+
* Resolve the role ARN to assume, honoring the three `--assume-role` forms.
|
|
18871
|
+
* Bare `--assume-role` uses the runtime's literal `RoleArn`; when that is an
|
|
18872
|
+
* intrinsic (the common L2 case — `Fn::GetAtt` to an auto-created role) it
|
|
18873
|
+
* resolves the execution-role ARN from `--from-cfn-stack` state first; when
|
|
18874
|
+
* that misses (issue #187 — `ListStackResources` returns the role's name,
|
|
18875
|
+
* not its ARN, so `attributes.Arn` is empty on the CFn state map) it falls
|
|
18876
|
+
* back to `stateProvider.resolveAgentCoreRuntimeRoleArn(<physicalId>)`
|
|
18877
|
+
* (a `bedrock-agentcore-control:GetAgentRuntime` call) so a same-stack
|
|
18878
|
+
* execution role still resolves. Only warns + falls back to dev creds
|
|
18879
|
+
* when all of those miss.
|
|
18880
|
+
*/
|
|
18881
|
+
async function resolveAssumeRoleArn(options, resolved, loaded, stateProvider) {
|
|
18882
|
+
if (typeof options.assumeRole === "string") return options.assumeRole;
|
|
18883
|
+
if (options.assumeRole !== true) return void 0;
|
|
18884
|
+
if (resolved.roleArn) return resolved.roleArn;
|
|
18885
|
+
if (loaded) {
|
|
18886
|
+
const fromState = resolveExecutionRoleArnFromState(loaded, resolved.logicalId, "RoleArn");
|
|
18887
|
+
if (fromState) {
|
|
18888
|
+
getLogger().debug(`--assume-role: resolved RoleArn from state: ${fromState}`);
|
|
18889
|
+
return fromState;
|
|
18890
|
+
}
|
|
18891
|
+
const runtimePhysicalId = loaded.resources[resolved.logicalId]?.physicalId;
|
|
18892
|
+
if (stateProvider?.resolveAgentCoreRuntimeRoleArn && runtimePhysicalId) {
|
|
18893
|
+
const liveArn = await stateProvider.resolveAgentCoreRuntimeRoleArn(runtimePhysicalId);
|
|
18894
|
+
if (liveArn) {
|
|
18895
|
+
getLogger().info(`--assume-role: auto-resolved execution role from GetAgentRuntime: ${liveArn}`);
|
|
18896
|
+
return liveArn;
|
|
17534
18897
|
}
|
|
17535
|
-
out.set(stackName, bundle);
|
|
17536
|
-
logger.debug(`${provider.label}: loaded state for ${stackName} (${loaded.region})`);
|
|
17537
|
-
} finally {
|
|
17538
|
-
provider.dispose();
|
|
17539
18898
|
}
|
|
17540
18899
|
}
|
|
17541
|
-
|
|
18900
|
+
getLogger().warn("--assume-role passed without an ARN, but the runtime's RoleArn is not a literal ARN in the template " + (loaded ? "and could not be resolved from the deployed stack state. " : "and no --from-cfn-stack state is available to resolve it. ") + "Pass the ARN explicitly: --assume-role <arn>. Falling back to the developer's shell credentials.");
|
|
18901
|
+
}
|
|
18902
|
+
function emitResult(result) {
|
|
18903
|
+
const logger = getLogger();
|
|
18904
|
+
if (result.status >= 400) {
|
|
18905
|
+
logger.warn(`Agent /invocations returned HTTP ${result.status}.`);
|
|
18906
|
+
process.exitCode = 1;
|
|
18907
|
+
}
|
|
18908
|
+
if (result.streamed) {
|
|
18909
|
+
process.stdout.write("\n");
|
|
18910
|
+
return;
|
|
18911
|
+
}
|
|
18912
|
+
process.stdout.write(`${result.raw}\n`);
|
|
17542
18913
|
}
|
|
17543
18914
|
/**
|
|
17544
|
-
*
|
|
17545
|
-
*
|
|
17546
|
-
*
|
|
17547
|
-
|
|
17548
|
-
|
|
17549
|
-
|
|
18915
|
+
* Finish a `/ws` exchange: the frames were already streamed to stdout via the
|
|
18916
|
+
* onMessage sink, so just terminate with a newline (so the shell prompt resumes
|
|
18917
|
+
* cleanly) and note the frame count at debug level.
|
|
18918
|
+
*/
|
|
18919
|
+
function emitWsResult(result) {
|
|
18920
|
+
process.stdout.write("\n");
|
|
18921
|
+
getLogger().debug(`Agent /ws closed after ${result.frames} frame(s).`);
|
|
18922
|
+
}
|
|
18923
|
+
/**
|
|
18924
|
+
* Build the JSON-RPC request to send to an MCP runtime from `--event`:
|
|
18925
|
+
* - no `--event` (empty object) → `tools/list` (discover the server's tools),
|
|
18926
|
+
* - an object with a string `method` → that method + its `params`,
|
|
18927
|
+
* - anything else → a fail-fast error.
|
|
17550
18928
|
*
|
|
17551
|
-
*
|
|
17552
|
-
* the state record's region (returned by the active `LocalStateProvider`).
|
|
18929
|
+
* Exported for unit testing.
|
|
17553
18930
|
*/
|
|
17554
|
-
|
|
17555
|
-
|
|
17556
|
-
|
|
17557
|
-
|
|
18931
|
+
function buildMcpRequest(event) {
|
|
18932
|
+
if (event === void 0 || event === null) return {
|
|
18933
|
+
method: "tools/list",
|
|
18934
|
+
params: {}
|
|
18935
|
+
};
|
|
18936
|
+
if (typeof event !== "object" || Array.isArray(event)) throw new CdkLocalError("MCP --event must be a JSON object describing a JSON-RPC request (e.g. {\"method\":\"tools/call\",\"params\":{\"name\":\"...\",\"arguments\":{...}}}).", "LOCAL_INVOKE_AGENTCORE_MCP_EVENT_INVALID");
|
|
18937
|
+
const obj = event;
|
|
18938
|
+
if (Object.keys(obj).length === 0) return {
|
|
18939
|
+
method: "tools/list",
|
|
18940
|
+
params: {}
|
|
18941
|
+
};
|
|
18942
|
+
if (typeof obj["method"] !== "string") throw new CdkLocalError(`MCP --event must include a string "method" (a JSON-RPC method such as "tools/list" or "tools/call"). Got keys: ${Object.keys(obj).join(", ")}.`, "LOCAL_INVOKE_AGENTCORE_MCP_EVENT_INVALID");
|
|
18943
|
+
return {
|
|
18944
|
+
method: obj["method"],
|
|
18945
|
+
...obj["params"] !== void 0 && { params: obj["params"] }
|
|
18946
|
+
};
|
|
18947
|
+
}
|
|
18948
|
+
/** Print the MCP JSON-RPC response; exit 1 when it carried a JSON-RPC error. */
|
|
18949
|
+
function emitMcpResult(result) {
|
|
18950
|
+
if (!result.ok) {
|
|
18951
|
+
getLogger().warn("MCP server returned a JSON-RPC error.");
|
|
18952
|
+
process.exitCode = 1;
|
|
18953
|
+
}
|
|
18954
|
+
process.stdout.write(`${result.raw}\n`);
|
|
18955
|
+
}
|
|
18956
|
+
/**
|
|
18957
|
+
* Build the JSON-RPC request to send to an A2A runtime from `--event`:
|
|
18958
|
+
* - no `--event` (empty object) → `agent/getCard` (discover the agent's card),
|
|
18959
|
+
* - an object with a string `method` → that method + its `params`,
|
|
18960
|
+
* - anything else → a fail-fast error.
|
|
18961
|
+
*
|
|
18962
|
+
* Exported for unit testing.
|
|
18963
|
+
*/
|
|
18964
|
+
function buildA2aRequest(event) {
|
|
18965
|
+
if (event === void 0 || event === null) return {
|
|
18966
|
+
method: "agent/getCard",
|
|
18967
|
+
params: {}
|
|
18968
|
+
};
|
|
18969
|
+
if (typeof event !== "object" || Array.isArray(event)) throw new CdkLocalError("A2A --event must be a JSON object describing a JSON-RPC request (e.g. {\"method\":\"tasks/send\",\"params\":{\"id\":\"...\",\"message\":{...}}}).", "LOCAL_INVOKE_AGENTCORE_A2A_EVENT_INVALID");
|
|
18970
|
+
const obj = event;
|
|
18971
|
+
if (Object.keys(obj).length === 0) return {
|
|
18972
|
+
method: "agent/getCard",
|
|
18973
|
+
params: {}
|
|
18974
|
+
};
|
|
18975
|
+
if (typeof obj["method"] !== "string") throw new CdkLocalError(`A2A --event must include a string "method" (a JSON-RPC method such as "agent/getCard" or "tasks/send"). Got keys: ${Object.keys(obj).join(", ")}.`, "LOCAL_INVOKE_AGENTCORE_A2A_EVENT_INVALID");
|
|
18976
|
+
return {
|
|
18977
|
+
method: obj["method"],
|
|
18978
|
+
...obj["params"] !== void 0 && { params: obj["params"] }
|
|
18979
|
+
};
|
|
18980
|
+
}
|
|
18981
|
+
/** Print the A2A JSON-RPC response; exit 1 when it carried a JSON-RPC error. */
|
|
18982
|
+
function emitA2aResult(result) {
|
|
18983
|
+
if (!result.ok) {
|
|
18984
|
+
getLogger().warn("A2A server returned a JSON-RPC error.");
|
|
18985
|
+
process.exitCode = 1;
|
|
18986
|
+
}
|
|
18987
|
+
process.stdout.write(`${result.raw}\n`);
|
|
18988
|
+
}
|
|
18989
|
+
/** Map a `--platform` value to the architecture `buildContainerImage` expects. */
|
|
18990
|
+
function platformToArchitecture(platform) {
|
|
18991
|
+
return platform === "linux/amd64" ? "x86_64" : "arm64";
|
|
18992
|
+
}
|
|
18993
|
+
function forwardAwsEnv$1(env) {
|
|
18994
|
+
for (const key of [
|
|
18995
|
+
"AWS_ACCESS_KEY_ID",
|
|
18996
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
18997
|
+
"AWS_SESSION_TOKEN",
|
|
18998
|
+
"AWS_REGION",
|
|
18999
|
+
"AWS_DEFAULT_REGION"
|
|
19000
|
+
]) {
|
|
19001
|
+
const value = process.env[key];
|
|
19002
|
+
if (value !== void 0) env[key] = value;
|
|
19003
|
+
}
|
|
19004
|
+
}
|
|
19005
|
+
async function assumeAgentCoreExecutionRole(roleArn, region) {
|
|
19006
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
19007
|
+
const sts = new STSClient({ ...region && { region } });
|
|
17558
19008
|
try {
|
|
17559
|
-
const
|
|
17560
|
-
|
|
19009
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
19010
|
+
RoleArn: roleArn,
|
|
19011
|
+
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-invoke-agentcore-${Date.now()}`,
|
|
19012
|
+
DurationSeconds: 3600
|
|
19013
|
+
}))).Credentials;
|
|
19014
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
19015
|
+
return {
|
|
19016
|
+
accessKeyId: creds.AccessKeyId,
|
|
19017
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
19018
|
+
sessionToken: creds.SessionToken
|
|
19019
|
+
};
|
|
19020
|
+
} finally {
|
|
19021
|
+
sts.destroy();
|
|
19022
|
+
}
|
|
19023
|
+
}
|
|
19024
|
+
async function readEvent(options) {
|
|
19025
|
+
if (options.event && options.eventStdin) throw new Error("--event and --event-stdin are mutually exclusive.");
|
|
19026
|
+
if (options.eventStdin) return parseEvent(await readStdin(), "<stdin>");
|
|
19027
|
+
if (options.event) {
|
|
19028
|
+
let raw;
|
|
17561
19029
|
try {
|
|
17562
|
-
|
|
17563
|
-
}
|
|
17564
|
-
|
|
19030
|
+
raw = readFileSync(options.event, "utf-8");
|
|
19031
|
+
} catch (err) {
|
|
19032
|
+
throw new Error(`Failed to read --event file '${options.event}': ${err instanceof Error ? err.message : String(err)}`);
|
|
17565
19033
|
}
|
|
19034
|
+
return parseEvent(raw, options.event);
|
|
19035
|
+
}
|
|
19036
|
+
return {};
|
|
19037
|
+
}
|
|
19038
|
+
function parseEvent(raw, source) {
|
|
19039
|
+
try {
|
|
19040
|
+
return JSON.parse(raw);
|
|
17566
19041
|
} catch (err) {
|
|
17567
|
-
|
|
19042
|
+
throw new Error(`Failed to parse event payload from ${source} as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
17568
19043
|
}
|
|
17569
|
-
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
17570
|
-
const bag = {
|
|
17571
|
-
...accountId !== void 0 && { accountId },
|
|
17572
|
-
...region !== void 0 && { region },
|
|
17573
|
-
...partitionAndSuffix && {
|
|
17574
|
-
partition: partitionAndSuffix.partition,
|
|
17575
|
-
urlSuffix: partitionAndSuffix.urlSuffix
|
|
17576
|
-
}
|
|
17577
|
-
};
|
|
17578
|
-
return Object.keys(bag).length === 0 ? void 0 : bag;
|
|
17579
19044
|
}
|
|
17580
|
-
|
|
17581
|
-
|
|
17582
|
-
const
|
|
17583
|
-
|
|
17584
|
-
return parsed;
|
|
19045
|
+
async function readStdin() {
|
|
19046
|
+
const chunks = [];
|
|
19047
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
19048
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
17585
19049
|
}
|
|
17586
19050
|
/**
|
|
17587
|
-
*
|
|
17588
|
-
*
|
|
17589
|
-
*
|
|
17590
|
-
*
|
|
17591
|
-
*
|
|
19051
|
+
* Read `process.stdin` line-buffered and yield each line as a string (trailing
|
|
19052
|
+
* `\r?\n` stripped). The async iterable completes when stdin EOFs (Ctrl-D /
|
|
19053
|
+
* end-of-pipe), and surrenders the underlying stream when its `return()` is
|
|
19054
|
+
* called — so the WS client can close down the source when the server closes
|
|
19055
|
+
* first without leaving stdin held open.
|
|
17592
19056
|
*
|
|
17593
|
-
* Exported
|
|
19057
|
+
* Exported so a unit test can drive the iterable shape directly.
|
|
17594
19058
|
*/
|
|
17595
|
-
function
|
|
17596
|
-
const
|
|
17597
|
-
const
|
|
17598
|
-
|
|
17599
|
-
|
|
17600
|
-
if (options.mtlsCert !== void 0 && options.mtlsCert !== "") present.push("--mtls-cert");
|
|
17601
|
-
else absent.push("--mtls-cert");
|
|
17602
|
-
if (options.mtlsKey !== void 0 && options.mtlsKey !== "") present.push("--mtls-key");
|
|
17603
|
-
else absent.push("--mtls-key");
|
|
17604
|
-
if (present.length === 0) return void 0;
|
|
17605
|
-
if (absent.length > 0) throw new Error(`mTLS configuration is incomplete: ${present.join(", ")} set but ${absent.join(", ")} missing. All three of --mtls-truststore, --mtls-cert, and --mtls-key must be set together to enable mTLS, or all three left unset for plain HTTP.`);
|
|
17606
|
-
return readMtlsMaterialsFromDisk({
|
|
17607
|
-
truststorePath: options.mtlsTruststore,
|
|
17608
|
-
certPath: options.mtlsCert,
|
|
17609
|
-
keyPath: options.mtlsKey
|
|
19059
|
+
async function* readStdinLines() {
|
|
19060
|
+
const { createInterface } = await import("node:readline");
|
|
19061
|
+
const rl = createInterface({
|
|
19062
|
+
input: process.stdin,
|
|
19063
|
+
crlfDelay: Infinity
|
|
17610
19064
|
});
|
|
19065
|
+
try {
|
|
19066
|
+
for await (const line of rl) yield line;
|
|
19067
|
+
} finally {
|
|
19068
|
+
rl.close();
|
|
19069
|
+
}
|
|
17611
19070
|
}
|
|
17612
|
-
|
|
17613
|
-
|
|
17614
|
-
|
|
17615
|
-
|
|
19071
|
+
function readEnvOverridesFile$2(filePath) {
|
|
19072
|
+
if (!filePath) return void 0;
|
|
19073
|
+
let raw;
|
|
19074
|
+
try {
|
|
19075
|
+
raw = readFileSync(filePath, "utf-8");
|
|
19076
|
+
} catch (err) {
|
|
19077
|
+
throw new Error(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
19078
|
+
}
|
|
19079
|
+
let parsed;
|
|
19080
|
+
try {
|
|
19081
|
+
parsed = JSON.parse(raw);
|
|
19082
|
+
} catch (err) {
|
|
19083
|
+
throw new Error(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
19084
|
+
}
|
|
19085
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
19086
|
+
return parsed;
|
|
19087
|
+
}
|
|
19088
|
+
function createLocalInvokeAgentCoreCommand(opts = {}) {
|
|
17616
19089
|
setEmbedConfig(opts.embedConfig);
|
|
17617
|
-
const
|
|
17618
|
-
await
|
|
19090
|
+
const cmd = new Command("invoke-agentcore").description("Run a Bedrock AgentCore Runtime container locally and invoke it once over its protocol contract: HTTP (POST /invocations + GET /ping on 8080) or MCP (POST /mcp Streamable HTTP on 8000). Resolves the AWS::BedrockAgentCore::Runtime, pulls/builds its container, injects env vars + AWS credentials, and prints the response. For an MCP runtime, runs the session handshake then sends one JSON-RPC request (tools/list by default, or the method/params from --event). Target accepts a CDK display path (MyStack/MyAgent) or stack-qualified logical ID (MyStack:MyAgentRuntime1234). Single-stack apps may omit the stack prefix. Omit <target> in an interactive terminal to pick from a list. Supports the container artifact and the CodeConfiguration managed-runtime artifact (fromCodeAsset, built from source) on the HTTP + MCP protocols; the agent calls real AWS for managed services.").argument("[target]", "CDK display path or stack-qualified logical ID of the AgentCore Runtime to invoke (omit to pick interactively in a TTY)").action(withErrorHandling(async (target, options) => {
|
|
19091
|
+
await localInvokeAgentCoreCommand(target, options, opts.extraStateProviders);
|
|
17619
19092
|
}));
|
|
19093
|
+
addInvokeAgentCoreSpecificOptions(cmd);
|
|
17620
19094
|
[
|
|
17621
19095
|
...commonOptions(),
|
|
17622
19096
|
...appOptions(),
|
|
17623
19097
|
...contextOptions
|
|
17624
|
-
].forEach((opt) =>
|
|
17625
|
-
|
|
17626
|
-
return
|
|
19098
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
19099
|
+
cmd.addOption(deprecatedRegionOption);
|
|
19100
|
+
return cmd;
|
|
19101
|
+
}
|
|
19102
|
+
/**
|
|
19103
|
+
* Register the option block that `cdkl invoke-agentcore` adds on top of
|
|
19104
|
+
* the shared common / app / context option helpers. Shared between
|
|
19105
|
+
* `cdkl invoke-agentcore` and any host CLI (e.g. cdkd's
|
|
19106
|
+
* `local invoke-agentcore`) that wraps the single-shot AgentCore Runtime
|
|
19107
|
+
* container runner, so adding or renaming an `invoke-agentcore`-only flag
|
|
19108
|
+
* here propagates to every embedder without duplicate `.addOption(...)`
|
|
19109
|
+
* blocks.
|
|
19110
|
+
*
|
|
19111
|
+
* Calling order only affects `--help` presentation (Commander parses
|
|
19112
|
+
* insertion-order-independent). The host-CLI convention is host-specific
|
|
19113
|
+
* options first, then this helper, then the shared common / app / context
|
|
19114
|
+
* options — host flags / invoke-agentcore flags / common flags grouped
|
|
19115
|
+
* in three `--help` clusters. Chainable: returns `cmd`.
|
|
19116
|
+
*/
|
|
19117
|
+
function addInvokeAgentCoreSpecificOptions(cmd) {
|
|
19118
|
+
return cmd.addOption(new Option("-e, --event <file>", "JSON event payload file (default: {})")).addOption(new Option("--event-stdin", "Read event JSON from stdin").default(false)).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"LogicalId\":{\"KEY\":\"VALUE\"}})")).addOption(new Option("--session-id <id>", "AgentCore runtime session id header value (default: a random UUID)")).addOption(new Option("--ws", "Stream over the HTTP-protocol agent's bidirectional /ws WebSocket endpoint (on 8080) instead of POST /invocations: send --event as the first frame and print every received frame to stdout until the agent closes. Ignored for an MCP runtime.").default(false)).addOption(new Option("--ws-interactive", "REPL mode for --ws: after the initial --event frame, read additional frames from stdin (one frame per line, trailing newline stripped) and send each as a text frame until stdin EOFs (Ctrl-D) or the agent closes. Only meaningful with --ws.").default(false)).addOption(new Option("--bearer-token <jwt>", "Bearer JWT to present when the runtime declares a customJwtAuthorizer. Verified against the runtime OIDC discovery URL (signature / issuer / expiry / audience) before the container starts, then forwarded to /invocations as Authorization: Bearer <jwt>.")).addOption(new Option("--no-verify-auth", "Skip inbound JWT verification even when the runtime declares a customJwtAuthorizer (local-dev escape hatch). A --bearer-token, if given, is still forwarded.")).addOption(new Option("--sigv4", "Sign the /invocations POST with AWS SigV4 (service bedrock-agentcore) using the resolved credentials, matching the cloud default when the runtime declares no customJwtAuthorizer. Opt-in: default unsigned. Mutually exclusive with --bearer-token; ignored on a JWT-protected runtime.").default(false)).addOption(new Option("--platform <platform>", "docker --platform for the agent container (linux/amd64 or linux/arm64)").choices(["linux/amd64", "linux/arm64"]).default("linux/arm64")).addOption(new Option("--no-pull", "Skip docker pull (use cached image) — no-op for the local-build path")).addOption(new Option("--no-build", "Skip docker build on the local-asset path (use the previously-built tag). No-op for the ECR / registry pull paths.")).addOption(new Option("--container-host <host>", "Host to bind the agent port to").default("127.0.0.1")).addOption(new Option("--timeout <ms>", "Per-request timeout in milliseconds. Applied to POST /invocations, POST /mcp, and the /ws open-to-close window. Raise this for long-running agent calls that exceed the default.").default(12e4).argParser(parseTimeoutMs)).addOption(new Option("--assume-role [arn]", "Assume the runtime's execution role and forward STS-issued temp credentials to the container so the agent runs with the deployed role. Three forms: (1) `--assume-role <arn>` assumes the explicit ARN; (2) `--assume-role` (bare) uses the runtime's RoleArn when it is a literal ARN; (3) `--no-assume-role` opts out. Off by default — the developer's shell credentials are forwarded unchanged.")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries. Same-account / same-region pulls do not need this flag.")).addOption(new Option("--from-cfn-stack [cfn-stack-name]", "Read a deployed CloudFormation stack via ListStackResources and substitute Ref / Fn::ImportValue in env vars with the deployed physical IDs / exports. Bare form uses the resolved stack name; pass an explicit value when the CFn stack name differs.")).addOption(new Option("--stack-region <region>", "Region of the state record to read. Used with --from-cfn-stack as the CFn client region."));
|
|
17627
19119
|
}
|
|
17628
19120
|
|
|
17629
19121
|
//#endregion
|
|
@@ -18652,6 +20144,330 @@ function shellJoin(parts) {
|
|
|
18652
20144
|
}).join(" ");
|
|
18653
20145
|
}
|
|
18654
20146
|
|
|
20147
|
+
//#endregion
|
|
20148
|
+
//#region src/cli/commands/local-run-task.ts
|
|
20149
|
+
/**
|
|
20150
|
+
* `cdkl run-task <target>` — Phase 1 of the ECS local-execution
|
|
20151
|
+
* trilogy. Synthesizes the CDK app, locates the target
|
|
20152
|
+
* `AWS::ECS::TaskDefinition`, stands up a per-task docker network with
|
|
20153
|
+
* the AWS-published `amazon-ecs-local-container-endpoints` sidecar, and
|
|
20154
|
+
* starts every container in `dependsOn` order. The essential
|
|
20155
|
+
* container's exit code drives the CLI's exit.
|
|
20156
|
+
*/
|
|
20157
|
+
async function localRunTaskCommand(target, options, extraStateProviders) {
|
|
20158
|
+
const logger = getLogger();
|
|
20159
|
+
if (options.verbose) logger.setLevel("debug");
|
|
20160
|
+
warnIfDeprecatedRegion(options);
|
|
20161
|
+
const state = createEcsRunState();
|
|
20162
|
+
let sigintHandler;
|
|
20163
|
+
let sigintCount = 0;
|
|
20164
|
+
let stateProvider;
|
|
20165
|
+
let profileCredsFile;
|
|
20166
|
+
let cleanupPromise;
|
|
20167
|
+
const cleanup = async () => {
|
|
20168
|
+
if (!cleanupPromise) cleanupPromise = (async () => {
|
|
20169
|
+
try {
|
|
20170
|
+
await cleanupEcsRun(state, { keepRunning: options.keepRunning });
|
|
20171
|
+
} catch (err) {
|
|
20172
|
+
getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
20173
|
+
}
|
|
20174
|
+
if (profileCredsFile) try {
|
|
20175
|
+
await profileCredsFile.dispose();
|
|
20176
|
+
} catch (err) {
|
|
20177
|
+
getLogger().debug(`Failed to remove profile credentials tmpdir ${profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
20178
|
+
}
|
|
20179
|
+
})();
|
|
20180
|
+
await cleanupPromise;
|
|
20181
|
+
};
|
|
20182
|
+
try {
|
|
20183
|
+
await applyRoleArnIfSet({
|
|
20184
|
+
roleArn: options.roleArn,
|
|
20185
|
+
region: options.region
|
|
20186
|
+
});
|
|
20187
|
+
await ensureDockerAvailable();
|
|
20188
|
+
const appCmd = resolveApp(options.app);
|
|
20189
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
20190
|
+
logger.info("Synthesizing CDK app...");
|
|
20191
|
+
const synthesizer = new Synthesizer();
|
|
20192
|
+
const context = parseContextOptions(options.context);
|
|
20193
|
+
const synthOpts = {
|
|
20194
|
+
app: appCmd,
|
|
20195
|
+
output: options.output,
|
|
20196
|
+
...options.region && { region: options.region },
|
|
20197
|
+
...options.profile && { profile: options.profile },
|
|
20198
|
+
...Object.keys(context).length > 0 && { context }
|
|
20199
|
+
};
|
|
20200
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
20201
|
+
const resolvedTarget = await resolveSingleTarget(target, {
|
|
20202
|
+
entries: listTargets(stacks).ecsTaskDefinitions,
|
|
20203
|
+
message: "Select an ECS task definition to run",
|
|
20204
|
+
noun: "ECS task definitions",
|
|
20205
|
+
onMissing: () => new CdkLocalError(`${getEmbedConfig().cliName} run-task requires a <target> (an ECS task definition display path or logical ID). Run \`${getEmbedConfig().cliName} list\` to see them, or run it in a TTY to pick interactively.`, "LOCAL_RUN_TASK_TARGET_REQUIRED")
|
|
20206
|
+
});
|
|
20207
|
+
const candidate = pickCandidateStack$1(parseEcsTarget(resolvedTarget).stackPattern, stacks);
|
|
20208
|
+
stateProvider = createLocalStateProvider(options, candidate?.stackName ?? "", await resolveCfnFallbackRegion(options, candidate?.region), extraStateProviders);
|
|
20209
|
+
const imageContext = await buildEcsImageResolutionContext$1(candidate, stateProvider, options);
|
|
20210
|
+
const task = resolveEcsTaskTarget(resolvedTarget, stacks, imageContext);
|
|
20211
|
+
logger.info(`Target: ${task.stack.stackName}/${task.taskDefinitionLogicalId} (family=${task.family}, containers=${task.containers.length})`);
|
|
20212
|
+
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === task.stack.stackName) ?? task.stack);
|
|
20213
|
+
if (stateProvider && taskNeeds.needsCrossStackResolver) {
|
|
20214
|
+
const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? task.stack.region ?? "us-east-1";
|
|
20215
|
+
const resolver = await stateProvider.buildCrossStackResolver(consumerRegion);
|
|
20216
|
+
if (resolver) await applyCrossStackResolverToTask(task, {
|
|
20217
|
+
resources: imageContext?.stateResources ?? {},
|
|
20218
|
+
...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
|
|
20219
|
+
...imageContext?.stateParameters && { parameters: imageContext.stateParameters },
|
|
20220
|
+
...imageContext?.stateSensitiveParameters?.length && { sensitiveParameters: new Set(imageContext.stateSensitiveParameters) },
|
|
20221
|
+
consumerRegion,
|
|
20222
|
+
crossStackResolver: resolver
|
|
20223
|
+
});
|
|
20224
|
+
} else if (!stateProvider && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass a state-source flag (e.g. --from-cfn-stack or a host-provided extension) to substitute them against deployed state.");
|
|
20225
|
+
sigintHandler = () => {
|
|
20226
|
+
sigintCount += 1;
|
|
20227
|
+
if (sigintCount >= 2) {
|
|
20228
|
+
process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
|
|
20229
|
+
process.exit(130);
|
|
20230
|
+
}
|
|
20231
|
+
logger.info("Stopping task...");
|
|
20232
|
+
cleanup().then(() => process.exit(130));
|
|
20233
|
+
};
|
|
20234
|
+
process.on("SIGINT", sigintHandler);
|
|
20235
|
+
let assumedCredentials;
|
|
20236
|
+
let resolvedRoleArn;
|
|
20237
|
+
if (options.assumeTaskRole === true) {
|
|
20238
|
+
if (!task.taskRoleArn) throw new Error(`--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Either the task definition does not set TaskRoleArn, or it points at a resource ${getEmbedConfig().binaryName} cannot resolve to an IAM Role at synth time. Pass the ARN explicitly: --assume-task-role <arn>`);
|
|
20239
|
+
resolvedRoleArn = await resolvePlaceholderAccount$1(task.taskRoleArn, options.region);
|
|
20240
|
+
assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
|
|
20241
|
+
} else if (typeof options.assumeTaskRole === "string") {
|
|
20242
|
+
resolvedRoleArn = options.assumeTaskRole;
|
|
20243
|
+
assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
|
|
20244
|
+
}
|
|
20245
|
+
const sidecarCredentials = await resolveSidecarCredentials(options, assumedCredentials);
|
|
20246
|
+
if (options.profile && sidecarCredentials && !assumedCredentials) profileCredsFile = await writeProfileCredentialsFile(options.profile, sidecarCredentials);
|
|
20247
|
+
const envOverrides = readEnvOverridesFile$1(options.envVars);
|
|
20248
|
+
const runOpts = {
|
|
20249
|
+
cluster: options.cluster,
|
|
20250
|
+
containerHost: options.containerHost,
|
|
20251
|
+
skipPull: options.pull === false,
|
|
20252
|
+
keepRunning: options.keepRunning,
|
|
20253
|
+
detach: options.detach
|
|
20254
|
+
};
|
|
20255
|
+
if (envOverrides) runOpts.envOverrides = envOverrides;
|
|
20256
|
+
if (sidecarCredentials) runOpts.taskCredentials = sidecarCredentials;
|
|
20257
|
+
if (resolvedRoleArn) runOpts.taskRoleArn = resolvedRoleArn;
|
|
20258
|
+
if (options.platform) runOpts.platformOverride = options.platform;
|
|
20259
|
+
if (options.region) runOpts.region = options.region;
|
|
20260
|
+
if (options.ecrRoleArn) runOpts.ecrRoleArn = options.ecrRoleArn;
|
|
20261
|
+
if (options.profile) runOpts.profile = options.profile;
|
|
20262
|
+
const hostPortOverrides = parseHostPortOverrides(options.hostPort);
|
|
20263
|
+
if (Object.keys(hostPortOverrides).length > 0) runOpts.hostPortOverrides = hostPortOverrides;
|
|
20264
|
+
if (profileCredsFile) runOpts.profileCredentialsFile = {
|
|
20265
|
+
hostPath: profileCredsFile.hostPath,
|
|
20266
|
+
containerPath: profileCredsFile.containerPath,
|
|
20267
|
+
profileName: profileCredsFile.profileName
|
|
20268
|
+
};
|
|
20269
|
+
const result = await runEcsTask(task, runOpts, state);
|
|
20270
|
+
if (options.detach) {
|
|
20271
|
+
logger.info(`Task containers started in detached mode; ${getEmbedConfig().binaryName} is exiting.`);
|
|
20272
|
+
logger.info(`Use 'docker ps --filter network=${result.state.network?.networkName ?? "<network>"}' to inspect; tear down with 'docker rm -f' and 'docker network rm'.`);
|
|
20273
|
+
sigintCount = 99;
|
|
20274
|
+
return;
|
|
20275
|
+
}
|
|
20276
|
+
if (result.essentialContainerName) logger.info(`Essential container '${result.essentialContainerName}' exited with code ${result.exitCode}.`);
|
|
20277
|
+
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
20278
|
+
} finally {
|
|
20279
|
+
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
20280
|
+
if (stateProvider) stateProvider.dispose();
|
|
20281
|
+
if (!options.detach) await cleanup();
|
|
20282
|
+
}
|
|
20283
|
+
}
|
|
20284
|
+
/**
|
|
20285
|
+
* If `arn` contains the `${AWS::AccountId}` placeholder emitted by the
|
|
20286
|
+
* resolver for inline same-stack IAM Roles, substitute the live caller
|
|
20287
|
+
* account via STS `GetCallerIdentity`. Otherwise pass through unchanged.
|
|
20288
|
+
*/
|
|
20289
|
+
async function resolvePlaceholderAccount$1(arn, region) {
|
|
20290
|
+
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
20291
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
20292
|
+
const sts = new STSClient({ ...region && { region } });
|
|
20293
|
+
try {
|
|
20294
|
+
const account = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
20295
|
+
if (!account) throw new Error(`--assume-task-role: GetCallerIdentity returned no Account; cannot resolve placeholder ARN '${arn}'. Pass the ARN explicitly: --assume-task-role <arn>`);
|
|
20296
|
+
return arn.split(TASK_ROLE_ACCOUNT_PLACEHOLDER).join(account);
|
|
20297
|
+
} finally {
|
|
20298
|
+
sts.destroy();
|
|
20299
|
+
}
|
|
20300
|
+
}
|
|
20301
|
+
/**
|
|
20302
|
+
* Assume `roleArn` and return temp credentials.
|
|
20303
|
+
*/
|
|
20304
|
+
async function assumeTaskRole$1(roleArn, region) {
|
|
20305
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
20306
|
+
const sts = new STSClient({ ...region && { region } });
|
|
20307
|
+
try {
|
|
20308
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
20309
|
+
RoleArn: roleArn,
|
|
20310
|
+
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-run-task-${Date.now()}`,
|
|
20311
|
+
DurationSeconds: 3600
|
|
20312
|
+
}))).Credentials;
|
|
20313
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
20314
|
+
return {
|
|
20315
|
+
accessKeyId: creds.AccessKeyId,
|
|
20316
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
20317
|
+
sessionToken: creds.SessionToken
|
|
20318
|
+
};
|
|
20319
|
+
} finally {
|
|
20320
|
+
sts.destroy();
|
|
20321
|
+
}
|
|
20322
|
+
}
|
|
20323
|
+
/**
|
|
20324
|
+
* Build the substitution context the ECS task resolver consumes.
|
|
20325
|
+
* Returns `undefined` when no container's `Image` field needs
|
|
20326
|
+
* substitution — the resolver behaves as before in that case.
|
|
20327
|
+
*/
|
|
20328
|
+
async function buildEcsImageResolutionContext$1(candidate, stateProvider, options) {
|
|
20329
|
+
const logger = getLogger();
|
|
20330
|
+
if (!candidate) return void 0;
|
|
20331
|
+
const needs = detectEcsImageResolutionNeeds(candidate);
|
|
20332
|
+
if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
|
|
20333
|
+
const ctx = {};
|
|
20334
|
+
const wantsPseudoForEnvOrSecret = !!stateProvider && needs.needsEnvOrSecretSubstitution;
|
|
20335
|
+
if (needs.needsPseudoParameters || wantsPseudoForEnvOrSecret) {
|
|
20336
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
|
|
20337
|
+
if (!region) logger.warn(`Resolver references \${AWS::Region} but ${getEmbedConfig().binaryName} could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.`);
|
|
20338
|
+
let accountId;
|
|
20339
|
+
try {
|
|
20340
|
+
accountId = await resolveCallerAccountId$1(region, options.profile);
|
|
20341
|
+
} catch (err) {
|
|
20342
|
+
logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env / secret entries will be dropped with per-key warnings.`);
|
|
20343
|
+
}
|
|
20344
|
+
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
20345
|
+
ctx.pseudoParameters = {
|
|
20346
|
+
...accountId !== void 0 && { accountId },
|
|
20347
|
+
...region !== void 0 && { region },
|
|
20348
|
+
...partitionAndSuffix && {
|
|
20349
|
+
partition: partitionAndSuffix.partition,
|
|
20350
|
+
urlSuffix: partitionAndSuffix.urlSuffix
|
|
20351
|
+
}
|
|
20352
|
+
};
|
|
20353
|
+
}
|
|
20354
|
+
const wantsState = needs.needsStateResources || needs.needsEnvOrSecretSubstitution;
|
|
20355
|
+
if (stateProvider && wantsState) {
|
|
20356
|
+
const loaded = await stateProvider.load(candidate.stackName, candidate.region);
|
|
20357
|
+
if (loaded) ctx.stateResources = loaded.resources;
|
|
20358
|
+
if (needs.needsEnvOrSecretSubstitution && stateProvider.resolveTemplateSsmParameters) {
|
|
20359
|
+
const ssmParameters = await stateProvider.resolveTemplateSsmParameters(candidate.template);
|
|
20360
|
+
if (Object.keys(ssmParameters.values).length > 0) ctx.stateParameters = ssmParameters.values;
|
|
20361
|
+
if (ssmParameters.secureStringLogicalIds.length > 0) ctx.stateSensitiveParameters = ssmParameters.secureStringLogicalIds;
|
|
20362
|
+
}
|
|
20363
|
+
} else if (!stateProvider && needs.needsStateResources) logger.warn("Container Image references a same-stack AWS::ECR::Repository. Pass a state-source flag (e.g. --from-cfn-stack or a host-provided extension) to substitute the deployed repository URI. Otherwise the resolver will surface its existing error.");
|
|
20364
|
+
else if (!stateProvider && needs.needsEnvOrSecretSubstitution) logger.warn("Container Environment / Secrets entries contain CloudFormation intrinsics (Ref / Fn::GetAtt / Fn::Sub / Fn::Join). Pass a state-source flag (e.g. --from-cfn-stack or a host-provided extension) to substitute them against deployed state. Without a state source these entries are dropped (per-key warnings will follow).");
|
|
20365
|
+
return ctx;
|
|
20366
|
+
}
|
|
20367
|
+
function pickCandidateStack$1(stackPattern, stacks) {
|
|
20368
|
+
if (stackPattern === null) {
|
|
20369
|
+
if (stacks.length === 1) return stacks[0];
|
|
20370
|
+
return;
|
|
20371
|
+
}
|
|
20372
|
+
const matched = matchStacks(stacks, [stackPattern]);
|
|
20373
|
+
if (matched.length === 1) return matched[0];
|
|
20374
|
+
}
|
|
20375
|
+
async function resolveCallerAccountId$1(region, profile) {
|
|
20376
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
20377
|
+
const sts = new STSClient({
|
|
20378
|
+
...region && { region },
|
|
20379
|
+
...profile && { profile }
|
|
20380
|
+
});
|
|
20381
|
+
try {
|
|
20382
|
+
return (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
20383
|
+
} finally {
|
|
20384
|
+
sts.destroy();
|
|
20385
|
+
}
|
|
20386
|
+
}
|
|
20387
|
+
/**
|
|
20388
|
+
* Read the `--env-vars` JSON file using the same SAM-style shape as
|
|
20389
|
+
* `cdkl invoke --env-vars`: top-level keys are container names, with
|
|
20390
|
+
* `Parameters` reserved for global entries.
|
|
20391
|
+
*/
|
|
20392
|
+
function readEnvOverridesFile$1(filePath) {
|
|
20393
|
+
if (!filePath) return void 0;
|
|
20394
|
+
let raw;
|
|
20395
|
+
try {
|
|
20396
|
+
raw = readFileSync(filePath, "utf-8");
|
|
20397
|
+
} catch (err) {
|
|
20398
|
+
throw new Error(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
20399
|
+
}
|
|
20400
|
+
let parsed;
|
|
20401
|
+
try {
|
|
20402
|
+
parsed = JSON.parse(raw);
|
|
20403
|
+
} catch (err) {
|
|
20404
|
+
throw new Error(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
20405
|
+
}
|
|
20406
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
20407
|
+
return parsed;
|
|
20408
|
+
}
|
|
20409
|
+
/**
|
|
20410
|
+
* Pick the credentials forwarded to the AWS-published
|
|
20411
|
+
* `amazon-ecs-local-container-endpoints` sidecar. Precedence:
|
|
20412
|
+
* 1. `--assume-task-role <arn>` (or bare `--assume-task-role` against
|
|
20413
|
+
* a resolvable `TaskRoleArn`) → STS-assumed temp creds. Highest
|
|
20414
|
+
* priority — when the user opted in to IAM emulation, those creds
|
|
20415
|
+
* drive the sidecar regardless of `--profile`.
|
|
20416
|
+
* 2. `--profile <p>` → resolved via {@link resolveProfileCredentials}
|
|
20417
|
+
* (the SDK's default credential provider chain — SSO / IAM
|
|
20418
|
+
* Identity Center / fromIni / role-assumption). NEW in this PR.
|
|
20419
|
+
* 3. Neither set → `undefined`; the sidecar runs with its own
|
|
20420
|
+
* default credential chain (typically empty inside a fresh
|
|
20421
|
+
* container — user containers will get 4xx from the credentials
|
|
20422
|
+
* endpoint, mimicking IAM-misconfigured prod).
|
|
20423
|
+
*
|
|
20424
|
+
* Extracted as an exported helper so a unit test can exercise every
|
|
20425
|
+
* branch without having to mock the full Synth + Docker + AWS pipeline
|
|
20426
|
+
* (the strategy used for the Lambda container path).
|
|
20427
|
+
*/
|
|
20428
|
+
async function resolveSidecarCredentials(options, assumedCredentials) {
|
|
20429
|
+
if (assumedCredentials) return assumedCredentials;
|
|
20430
|
+
if (options.profile) return resolveProfileCredentials(options.profile);
|
|
20431
|
+
}
|
|
20432
|
+
function createLocalRunTaskCommand(opts = {}) {
|
|
20433
|
+
setEmbedConfig(opts.embedConfig);
|
|
20434
|
+
const cmd = new Command("run-task").description("Run an AWS::ECS::TaskDefinition locally — pulls/builds images, sets up a per-task docker network with the AWS-published metadata-endpoints sidecar, and starts every container in dependsOn order. Target accepts a CDK display path (MyStack/MyService/TaskDef) or stack-qualified logical ID (MyStack:MyServiceTaskDefXYZ1234). Single-stack apps may omit the stack prefix. Omit <target> in an interactive terminal to pick the task definition from a list.").argument("[target]", "CDK display path or stack-qualified logical ID of the AWS::ECS::TaskDefinition to run (omit to pick interactively in a TTY)").action(withErrorHandling(async (target, options) => {
|
|
20435
|
+
await localRunTaskCommand(target, options, opts.extraStateProviders);
|
|
20436
|
+
}));
|
|
20437
|
+
addRunTaskSpecificOptions(cmd);
|
|
20438
|
+
[
|
|
20439
|
+
...commonOptions(),
|
|
20440
|
+
...appOptions(),
|
|
20441
|
+
...contextOptions
|
|
20442
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
20443
|
+
cmd.addOption(deprecatedRegionOption);
|
|
20444
|
+
return cmd;
|
|
20445
|
+
}
|
|
20446
|
+
/**
|
|
20447
|
+
* Register the option block that `cdkl run-task` adds on top of the shared
|
|
20448
|
+
* common / app / context option helpers. Shared between `cdkl run-task` and
|
|
20449
|
+
* any host CLI (e.g. cdkd's `local run-task`) that wraps the single-task
|
|
20450
|
+
* ECS local runner, so adding or renaming a `run-task`-only flag here
|
|
20451
|
+
* propagates to every embedder without duplicate `.addOption(...)` blocks.
|
|
20452
|
+
*
|
|
20453
|
+
* Calling order only affects `--help` presentation (Commander parses
|
|
20454
|
+
* insertion-order-independent). The host-CLI convention is host-specific
|
|
20455
|
+
* options first, then this helper, then the shared common / app / context
|
|
20456
|
+
* options — host flags / run-task flags / common flags grouped in three
|
|
20457
|
+
* `--help` clusters. Chainable: returns `cmd`.
|
|
20458
|
+
*
|
|
20459
|
+
* NOTE: `run-task` does NOT compose with {@link addCommonEcsServiceOptions}
|
|
20460
|
+
* even though many flags overlap. The two ECS surfaces (single-task vs
|
|
20461
|
+
* multi-replica service) have intentionally divergent defaults
|
|
20462
|
+
* (`run-task` has no `--max-tasks` / `--restart-policy`; `start-service`
|
|
20463
|
+
* / `start-alb` have no `--host-port` / `--keep-running` / `--detach`),
|
|
20464
|
+
* and folding `run-task` into the service common block would mutate the
|
|
20465
|
+
* surface non-trivially. Each command keeps its own helper.
|
|
20466
|
+
*/
|
|
20467
|
+
function addRunTaskSpecificOptions(cmd) {
|
|
20468
|
+
return cmd.addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default(getEmbedConfig().resourceNamePrefix)).addOption(new Option("--env-vars <file>", "JSON env-var overrides (SAM-compatible: {\"ContainerName\":{\"KEY\":\"VALUE\"}, \"Parameters\":{}})")).addOption(new Option("--container-host <ip>", "Host IP to bind published container ports to. Must be a numeric IP (Docker rejects hostnames here)").default("127.0.0.1")).addOption(new Option("--host-port <containerPort=hostPort...>", "Publish a container port on a specific host port (e.g. 80=8080); repeatable. Default: host port == container port. Use this on macOS to map a privileged container port (< 1024) to a non-privileged host port and avoid the Docker Desktop admin-password prompt.")).addOption(new Option("--assume-task-role [arn]", "Assume the task definition's TaskRoleArn (or the supplied ARN) and forward STS-issued temp credentials via the metadata sidecar so containers run with the deployed function role. Bare flag uses the template's TaskRoleArn; pass an explicit ARN to override.")).addOption(new Option("--no-pull", "Skip docker pull for every container image and the metadata sidecar")).addOption(new Option("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries. Issues sts:AssumeRole via the default credential chain and uses the temporary credentials for ecr:GetAuthorizationToken + docker pull. Required when the caller does not have direct cross-account access to the target repository. Same-account / same-region pulls do not need this flag.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--keep-running", "Don't docker rm -f the user containers on task exit (network + sidecar are still torn down). Use when you want to docker exec into a stopped container for post-mortems.").default(false)).addOption(new Option("--detach", "Start the containers in the background and exit (skip log streaming + auto teardown). Useful in CI smoke tests; caller manages container lifecycle.").default(false)).addOption(new Option("--from-cfn-stack [cfn-stack-name]", `Read a deployed CloudFormation stack via ListStackResources and substitute Ref / Fn::ImportValue in container env vars / secrets / image URIs with the deployed physical IDs / exports. Use for CDK apps deployed via the upstream CDK CLI (\`cdk deploy\`). Bare form uses the ${getEmbedConfig().binaryName} stack name; pass an explicit value when the CFn stack name differs. Fn::GetAtt is warn-and-dropped in v1 (CFn ListStackResources does not return per-attribute values).`)).addOption(new Option("--stack-region <region>", "Region of the state record to read. Used with --from-cfn-stack as the CFn client region."));
|
|
20469
|
+
}
|
|
20470
|
+
|
|
18655
20471
|
//#endregion
|
|
18656
20472
|
//#region src/local/ecs-service-resolver.ts
|
|
18657
20473
|
/**
|
|
@@ -22890,5 +24706,97 @@ function addAlbSpecificOptions(cmd) {
|
|
|
22890
24706
|
}
|
|
22891
24707
|
|
|
22892
24708
|
//#endregion
|
|
22893
|
-
|
|
22894
|
-
|
|
24709
|
+
//#region src/cli/commands/local-list.ts
|
|
24710
|
+
async function localListCommand(options) {
|
|
24711
|
+
const logger = getLogger();
|
|
24712
|
+
if (options.verbose) logger.setLevel("debug");
|
|
24713
|
+
warnIfDeprecatedRegion(options);
|
|
24714
|
+
await applyRoleArnIfSet({
|
|
24715
|
+
roleArn: options.roleArn,
|
|
24716
|
+
region: void 0
|
|
24717
|
+
});
|
|
24718
|
+
const appCmd = resolveApp(options.app);
|
|
24719
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
24720
|
+
process.stderr.write("Synthesizing CDK app...\n");
|
|
24721
|
+
const synthesizer = new Synthesizer();
|
|
24722
|
+
const context = parseContextOptions(options.context);
|
|
24723
|
+
const synthOpts = {
|
|
24724
|
+
app: appCmd,
|
|
24725
|
+
output: options.output,
|
|
24726
|
+
...options.profile && { profile: options.profile },
|
|
24727
|
+
...Object.keys(context).length > 0 && { context }
|
|
24728
|
+
};
|
|
24729
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
24730
|
+
const listing = listTargets(stacks);
|
|
24731
|
+
process.stdout.write(`${formatTargetListing(listing, getEmbedConfig().cliName, { long: options.long })}\n`);
|
|
24732
|
+
}
|
|
24733
|
+
/**
|
|
24734
|
+
* Render a {@link TargetListing} as the grouped text list `cdkl list`
|
|
24735
|
+
* prints. Each non-empty category is preceded by a blank line and a
|
|
24736
|
+
* header naming the command that runs it, then one target per line by
|
|
24737
|
+
* CDK display path. With {@link FormatTargetListingOptions.long}, each
|
|
24738
|
+
* target's stack-qualified logical ID is printed on an indented line
|
|
24739
|
+
* beneath it. Exported so a unit test can assert the output shape
|
|
24740
|
+
* without running synthesis.
|
|
24741
|
+
*/
|
|
24742
|
+
function formatTargetListing(listing, cliName, options = {}) {
|
|
24743
|
+
if (countTargets(listing) === 0) return `No runnable targets (Lambda functions, APIs, ECS services / tasks, AgentCore Runtimes, load balancers) found in this CDK app.`;
|
|
24744
|
+
const long = options.long ?? false;
|
|
24745
|
+
return "\n" + [
|
|
24746
|
+
formatSection("Lambda Functions", `${cliName} invoke <target>`, listing.lambdas, long),
|
|
24747
|
+
formatSection("APIs", `${cliName} start-api [target...]`, listing.apis, long),
|
|
24748
|
+
formatSection("ECS Services", `${cliName} start-service <target...>`, listing.ecsServices, long),
|
|
24749
|
+
formatSection("ECS Task Definitions", `${cliName} run-task <target>`, listing.ecsTaskDefinitions, long),
|
|
24750
|
+
formatSection("AgentCore Runtimes", `${cliName} invoke-agentcore <target>`, listing.agentCoreRuntimes, long),
|
|
24751
|
+
formatSection("Application Load Balancers", `${cliName} start-alb <target...>`, listing.loadBalancers, long)
|
|
24752
|
+
].filter((lines) => lines.length > 0).map((lines) => lines.join("\n")).join("\n\n");
|
|
24753
|
+
}
|
|
24754
|
+
function formatSection(title, command, entries, long) {
|
|
24755
|
+
if (entries.length === 0) return [];
|
|
24756
|
+
const lines = [`${title} -> ${command}`];
|
|
24757
|
+
for (const entry of entries) {
|
|
24758
|
+
const primary = entry.displayPath ?? entry.qualifiedId;
|
|
24759
|
+
lines.push(entry.kind ? ` ${primary} (${entry.kind})` : ` ${primary}`);
|
|
24760
|
+
if (long && entry.displayPath) lines.push(` ${entry.qualifiedId}`);
|
|
24761
|
+
}
|
|
24762
|
+
return lines;
|
|
24763
|
+
}
|
|
24764
|
+
function createLocalListCommand(opts = {}) {
|
|
24765
|
+
setEmbedConfig(opts.embedConfig);
|
|
24766
|
+
const cmd = new Command("list").alias("ls").description("List the runnable targets in the synthesized CDK app, grouped by the command that runs them: Lambda functions (invoke), API Gateway REST v1 / HTTP v2 / Function URL / WebSocket surfaces (start-api), ECS services (start-service), ECS task definitions (run-task), AgentCore Runtimes (invoke-agentcore), and Application Load Balancers (start-alb). Each target is shown by its CDK display path; pass -l to also print the stack-qualified logical ID. Tip: you usually do not need to copy these — just run the command (e.g. `invoke`) with no target in a terminal and pick from the list.").action(withErrorHandling(async (options) => {
|
|
24767
|
+
await localListCommand(options);
|
|
24768
|
+
}));
|
|
24769
|
+
addListSpecificOptions(cmd);
|
|
24770
|
+
[
|
|
24771
|
+
...commonOptions(),
|
|
24772
|
+
...appOptions(),
|
|
24773
|
+
...contextOptions
|
|
24774
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
24775
|
+
cmd.addOption(deprecatedRegionOption);
|
|
24776
|
+
return cmd;
|
|
24777
|
+
}
|
|
24778
|
+
/**
|
|
24779
|
+
* Register the option block that `cdkl list` adds on top of the shared
|
|
24780
|
+
* common / app / context option helpers. Shared between `cdkl list` and any
|
|
24781
|
+
* host CLI (e.g. cdkd's `local list`) that wraps the synthesis-driven
|
|
24782
|
+
* target enumeration, so adding or renaming a `list`-only flag here
|
|
24783
|
+
* propagates to every embedder without duplicate `.addOption(...)` blocks.
|
|
24784
|
+
*
|
|
24785
|
+
* Calling order only affects `--help` presentation (Commander parses
|
|
24786
|
+
* insertion-order-independent). The host-CLI convention is host-specific
|
|
24787
|
+
* options first, then this helper, then the shared common / app / context
|
|
24788
|
+
* options — host flags / list flags / common flags grouped in three
|
|
24789
|
+
* `--help` clusters. Chainable: returns `cmd`.
|
|
24790
|
+
*
|
|
24791
|
+
* Today `cdkl list` only contributes one non-common flag (`-l, --long`),
|
|
24792
|
+
* but the helper is still exposed so the surface-contract test pattern
|
|
24793
|
+
* (helper + common == createLocalListCommand) is uniform across every
|
|
24794
|
+
* `add<Cmd>SpecificOptions` extraction.
|
|
24795
|
+
*/
|
|
24796
|
+
function addListSpecificOptions(cmd) {
|
|
24797
|
+
return cmd.addOption(new Option("-l, --long", "Also print each target's stack-qualified logical ID (<Stack>:<LogicalId>) beneath it").default(false));
|
|
24798
|
+
}
|
|
24799
|
+
|
|
24800
|
+
//#endregion
|
|
24801
|
+
export { pickResponseTemplate as $, substituteAgainstState as $t, createFileWatcher as A, AgentCoreResolutionError as An, MCP_PATH as At, resolveServiceIntegrationParameters as B, SUPPORTED_CODE_RUNTIMES as Bt, addInvokeAgentCoreSpecificOptions as C, pickRefLogicalId as Cn, isFunctionUrlOacFronted as Ct, createWatchPredicates as D, AGENTCORE_HTTP_PROTOCOL as Dn, A2A_PATH as Dt, createLocalStartApiCommand as E, AGENTCORE_AGUI_PROTOCOL as En, A2A_CONTAINER_PORT as Et, filterRoutesByApiIdentifiers as F, tryResolveImageFnJoin as Fn, signAgentCoreInvocation as Ft, invokeRequestAuthorizer as G, addInvokeSpecificOptions as Gt, buildMethodArn as H, computeCodeImageTag as Ht, groupRoutesByServer as I, LocalInvokeBuildError as In, AGENTCORE_SESSION_ID_HEADER as It, translateLambdaResponse as J, buildContainerImage as Jt, invokeTokenAuthorizer as K, createLocalInvokeCommand as Kt, readMtlsMaterialsFromDisk as L, LocalStartServiceError as Ln, invokeAgentCore as Lt, buildStageMap as M, resolveAgentCoreTarget as Mn, mcpInvokeOnce as Mt, availableApiIdentifiers as N, derivePseudoParametersFromRegion as Nn, parseSseForJsonRpc as Nt, resolveApiTargetSubset as O, AGENTCORE_MCP_PROTOCOL as On, a2aInvokeOnce as Ot, filterRoutesByApiIdentifier as P, substituteImagePlaceholders as Pn, AGENTCORE_SIGV4_SERVICE as Pt, evaluateResponseParameters as Q, EcsTaskResolutionError as Qt, startApiServer as R, withErrorHandling as Rn, waitForAgentCorePing as Rt, createLocalRunTaskCommand as S, discoverRoutes as Sn, buildCorsConfigFromCloudFrontChain as St, addStartApiSpecificOptions as T, AGENTCORE_A2A_PROTOCOL as Tn, invokeAgentCoreWs as Tt, computeRequestIdentityHash as U, renderCodeDockerfile as Ut, defaultCredentialsLoader as V, buildAgentCoreCodeImage as Vt, evaluateCachedLambdaPolicy as W, toCmdArgv as Wt, buildHttpApiV2Event as X, resolveRuntimeFileExtension as Xt, applyAuthorizerOverlay as Y, resolveRuntimeCodeMountPath as Yt, buildRestV1Event as Z, resolveRuntimeImage as Zt, runEcsServiceEmulator as _, countTargets as _n, verifyJwtAuthorizer as _t, albStrategy as a, LocalStateSourceError as an, bufferToBody as at, getContainerNetworkIp as b, discoverWebSocketApisOrThrow as bn, applyCorsResponseHeaders as bt, resolveAlbTarget as c, rejectExplicitCfnStackWithMultipleStacks as cn, handleConnectionsRequest as ct, MAX_TASKS_SUBNET_RANGE_CAP as d, resolveCfnStackName as dn, buildDisconnectEvent as dt, substituteAgainstStateAsync as en, selectIntegrationResponse as et, addCommonEcsServiceOptions as f, CfnLocalStateProvider as fn, buildMessageEvent as ft, resolveSharedSidecarCredentials as g, resolveSingleTarget as gn, verifyCognitoJwt as gt, parseRestartPolicy as h, resolveWatchConfig as hn, createJwksCache as ht, addAlbSpecificOptions as i, materializeLayerFromArn as in, probeHostGatewaySupport as it, attachStageContext as j, pickAgentCoreCandidateStack as jn, MCP_PROTOCOL_VERSION as jt, createAuthorizerCache as k, AGENTCORE_RUNTIME_TYPE as kn, MCP_CONTAINER_PORT as kt, isApplicationLoadBalancer as l, resolveCfnFallbackRegion as ln, parseConnectionsPath as lt, parseMaxTasks as m, resolveSsmParameters as mn, buildJwksUrlFromIssuer as mt, createLocalListCommand as n, substituteEnvVarsFromStateAsync as nn, VtlEvaluationError as nt, createLocalStartAlbCommand as o, createLocalStateProvider as on, ConnectionRegistry as ot, buildEcsImageResolutionContext as p, collectSsmParameterRefs as pn, buildCognitoJwksUrl as pt, matchRoute as q, architectureToPlatform as qt, formatTargetListing as r, resolveEnvVars as rn, HOST_GATEWAY_MIN_VERSION as rt, parseLbPortOverrides as s, isCfnFlagPresent as sn, buildMgmtEndpointEnvUrl as st, addListSpecificOptions as t, substituteEnvVarsFromState as tn, tryParseStatus as tt, resolveAlbFrontDoor as u, resolveCfnRegion as un, buildConnectEvent as ut, buildCloudMapIndex as v, listTargets as vn, verifyJwtViaDiscovery as vt, createLocalInvokeAgentCoreCommand as w, resolveLambdaArnIntrinsic as wn, matchPreflight as wt, addRunTaskSpecificOptions as x, parseSelectionExpressionPath as xn, buildCorsConfigByApiId as xt, CloudMapRegistry as y, discoverWebSocketApis as yn, attachAuthorizers as yt, resolveSelectionExpression as z, downloadAndExtractS3Bundle as zt };
|
|
24802
|
+
//# sourceMappingURL=local-list-DrkWq_1u.js.map
|