cdk-local 0.61.1 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +3 -3
- package/dist/{target-lister-DztbPwpu.d.ts → ecs-service-emulator-aM8AsadH.d.ts} +231 -2
- package/dist/ecs-service-emulator-aM8AsadH.d.ts.map +1 -0
- package/dist/{cloud-map-resolver-BIGVKpx5.js → elb-front-door-resolver-Vpf2Gpvg.js} +5000 -395
- package/dist/elb-front-door-resolver-Vpf2Gpvg.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/internal.d.ts +2 -2
- package/dist/internal.js +2 -2
- package/dist/local-list-n6I3s-DR.js +2022 -0
- package/dist/local-list-n6I3s-DR.js.map +1 -0
- package/package.json +1 -1
- package/dist/cloud-map-resolver-BIGVKpx5.js.map +0 -1
- package/dist/local-list-D6okp0IA.js +0 -6633
- package/dist/local-list-D6okp0IA.js.map +0 -1
- package/dist/target-lister-DztbPwpu.d.ts.map +0 -1
|
@@ -0,0 +1,2022 @@
|
|
|
1
|
+
import { c as getEmbedConfig, s as getLogger, u as setEmbedConfig } from "./docker-cmd-voNPrcRh.js";
|
|
2
|
+
import { $n as resolveCdkPathToLogicalIds, $t as resolveRuntimeCodeMountPath, An as countTargets, At as waitForAgentCorePing, Bt as getDockerImageBySourceHash, Dt as signAgentCoreInvocation, En as resolveApp, Gn as resolveAgentCoreTarget, Gt as parseEcrUri, Ht as waitForRieReady, Jt as pickFreePort, Kn as resolveLambdaTarget, Kt as pullEcrImage, Lt as writeProfileCredentialsFile, Nt as buildAgentCoreCodeImage, On as Synthesizer, Qn as readCdkPathOrUndefined, Qt as streamLogs, Rn as AGENTCORE_A2A_PROTOCOL, Rt as singleFlight, St as MCP_PATH, Ut as architectureToPlatform, Vn as AGENTCORE_MCP_PROTOCOL, Vt as invokeRie, Wn as pickAgentCoreCandidateStack, Wt as buildContainerImage, Xn as matchStacks, Xt as removeContainer, Yt as pullImage, Zn as buildCdkPathIndex, Zt as runDetached, _n as createLocalStateProvider, _t as invokeAgentCoreWs, an as derivePartitionAndUrlSuffix, ar as appOptions, b as resolveProfileCredentials$1, bn as resolveCfnFallbackRegion, bt as a2aInvokeOnce, cn as resolveEcsTaskTarget, cr as deprecatedRegionOption, dn as substituteAgainstStateAsync, en as resolveRuntimeFileExtension, er as CdkLocalError, g as runEcsTask, h as parseHostPortOverrides, hn as materializeLayerFromArn, i as addCommonEcsServiceOptions, in as applyCrossStackResolverToTask, ir as applyRoleArnIfSet, jn as listTargets, jt as downloadAndExtractS3Bundle, kn as resolveSingleTarget, kt as invokeAgentCore, l as runEcsServiceEmulator, ln as applyDeployedEnvFallback, lr as parseContextOptions, m as createEcsRunState, mn as resolveEnvVars, n as resolveAlbFrontDoor, nr as LocalStartServiceError, on as detectEcsImageResolutionNeeds, or as commonOptions, p as cleanupEcsRun, pn as substituteEnvVarsFromStateAsync, qn as derivePseudoParametersFromRegion, qt as ensureDockerAvailable, rn as TASK_ROLE_ACCOUNT_PLACEHOLDER, rr as withErrorHandling, sn as parseEcsTarget, sr as contextOptions, st as createJwksCache, t as isApplicationLoadBalancer, tn as resolveRuntimeImage, ur as warnIfDeprecatedRegion, ut as verifyJwtViaDiscovery, vt as A2A_CONTAINER_PORT, wt as mcpInvokeOnce, xt as MCP_CONTAINER_PORT, yt as A2A_PATH, zn as AGENTCORE_AGUI_PROTOCOL, zt as AssetManifestLoader } from "./elb-front-door-resolver-Vpf2Gpvg.js";
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { Command, Option } from "commander";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
//#region src/cli/commands/local-invoke.ts
|
|
11
|
+
/**
|
|
12
|
+
* `cdkl invoke <target>` — run a Lambda function locally inside a
|
|
13
|
+
* Docker container that bundles the AWS Lambda Runtime Interface
|
|
14
|
+
* Emulator (RIE). Modeled on `sam local invoke` but reusing cdk-local's
|
|
15
|
+
* synthesis / asset / construct-path plumbing.
|
|
16
|
+
*/
|
|
17
|
+
async function localInvokeCommand(target, options, extraStateProviders) {
|
|
18
|
+
const logger = getLogger();
|
|
19
|
+
if (options.verbose) logger.setLevel("debug");
|
|
20
|
+
warnIfDeprecatedRegion(options);
|
|
21
|
+
let imagePlan;
|
|
22
|
+
let containerId;
|
|
23
|
+
let stopLogs;
|
|
24
|
+
let sigintHandler;
|
|
25
|
+
let profileCredsFile;
|
|
26
|
+
/**
|
|
27
|
+
* Unified cleanup for both the success / failure unwind path AND the
|
|
28
|
+
* SIGINT handler.
|
|
29
|
+
*/
|
|
30
|
+
const cleanup = singleFlight(async () => {
|
|
31
|
+
if (stopLogs) try {
|
|
32
|
+
stopLogs();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
getLogger().debug(`streamLogs stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
35
|
+
}
|
|
36
|
+
if (containerId) try {
|
|
37
|
+
await removeContainer(containerId);
|
|
38
|
+
} catch (err) {
|
|
39
|
+
getLogger().debug(`removeContainer(${containerId}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
40
|
+
}
|
|
41
|
+
if (imagePlan?.inlineTmpDir) try {
|
|
42
|
+
rmSync(imagePlan.inlineTmpDir, {
|
|
43
|
+
recursive: true,
|
|
44
|
+
force: true
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
getLogger().debug(`Failed to remove inline-code tmpdir ${imagePlan.inlineTmpDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
48
|
+
}
|
|
49
|
+
if (imagePlan?.layersTmpDir) try {
|
|
50
|
+
rmSync(imagePlan.layersTmpDir, {
|
|
51
|
+
recursive: true,
|
|
52
|
+
force: true
|
|
53
|
+
});
|
|
54
|
+
} catch (err) {
|
|
55
|
+
getLogger().debug(`Failed to remove merged-layers tmpdir ${imagePlan.layersTmpDir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
if (imagePlan?.layerArnTmpDirs) for (const dir of imagePlan.layerArnTmpDirs) try {
|
|
58
|
+
rmSync(dir, {
|
|
59
|
+
recursive: true,
|
|
60
|
+
force: true
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
getLogger().debug(`Failed to remove ARN-layer tmpdir ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
|
+
}
|
|
65
|
+
if (profileCredsFile) try {
|
|
66
|
+
await profileCredsFile.dispose();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
getLogger().debug(`Failed to remove profile credentials tmpdir ${profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}
|
|
70
|
+
}, (err) => {
|
|
71
|
+
getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
await applyRoleArnIfSet({
|
|
75
|
+
roleArn: options.roleArn,
|
|
76
|
+
region: options.region
|
|
77
|
+
});
|
|
78
|
+
await ensureDockerAvailable();
|
|
79
|
+
const profileCredentials = options.profile ? await resolveProfileCredentials(options.profile) : void 0;
|
|
80
|
+
if (options.profile && profileCredentials) profileCredsFile = await writeProfileCredentialsFile(options.profile, profileCredentials);
|
|
81
|
+
const appCmd = resolveApp(options.app);
|
|
82
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
83
|
+
logger.info("Synthesizing CDK app...");
|
|
84
|
+
const synthesizer = new Synthesizer();
|
|
85
|
+
const context = parseContextOptions(options.context);
|
|
86
|
+
const synthOpts = {
|
|
87
|
+
app: appCmd,
|
|
88
|
+
output: options.output,
|
|
89
|
+
...options.region && { region: options.region },
|
|
90
|
+
...options.profile && { profile: options.profile },
|
|
91
|
+
...Object.keys(context).length > 0 && { context }
|
|
92
|
+
};
|
|
93
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
94
|
+
const lambda = resolveLambdaTarget(await resolveSingleTarget(target, {
|
|
95
|
+
entries: listTargets(stacks).lambdas,
|
|
96
|
+
message: "Select a Lambda function to invoke",
|
|
97
|
+
noun: "Lambda functions",
|
|
98
|
+
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")
|
|
99
|
+
}), stacks);
|
|
100
|
+
const targetLabel = lambda.kind === "zip" ? lambda.runtime : "container image";
|
|
101
|
+
logger.info(`Target: ${lambda.stack.stackName}/${lambda.logicalId} (${targetLabel})`);
|
|
102
|
+
imagePlan = await resolveImagePlan(lambda, options);
|
|
103
|
+
let stateAudit;
|
|
104
|
+
let templateEnv = getTemplateEnv(lambda.resource);
|
|
105
|
+
let stateForRoleHint;
|
|
106
|
+
const stateProvider = createLocalStateProvider(options, lambda.stack.stackName, await resolveCfnFallbackRegion(options, lambda.stack.region), extraStateProviders);
|
|
107
|
+
if (stateProvider) try {
|
|
108
|
+
const loaded = await stateProvider.load(lambda.stack.stackName, lambda.stack.region);
|
|
109
|
+
if (loaded) {
|
|
110
|
+
stateForRoleHint = {
|
|
111
|
+
version: 1,
|
|
112
|
+
stackName: lambda.stack.stackName,
|
|
113
|
+
resources: loaded.resources,
|
|
114
|
+
outputs: loaded.outputs,
|
|
115
|
+
lastModified: 0
|
|
116
|
+
};
|
|
117
|
+
const subContext = {
|
|
118
|
+
resources: loaded.resources,
|
|
119
|
+
consumerRegion: loaded.region
|
|
120
|
+
};
|
|
121
|
+
if (envHasIntrinsicValue(templateEnv)) {
|
|
122
|
+
const pseudo = await resolvePseudoParametersForInvoke(lambda.stack.region, options);
|
|
123
|
+
if (pseudo) subContext.pseudoParameters = pseudo;
|
|
124
|
+
}
|
|
125
|
+
if (envHasIntrinsicValue(templateEnv) && stateProvider.resolveTemplateSsmParameters) {
|
|
126
|
+
const ssmParams = await stateProvider.resolveTemplateSsmParameters(lambda.stack.template);
|
|
127
|
+
if (Object.keys(ssmParams.values).length > 0) subContext.parameters = ssmParams.values;
|
|
128
|
+
if (ssmParams.secureStringLogicalIds.length > 0) subContext.sensitiveParameters = new Set(ssmParams.secureStringLogicalIds);
|
|
129
|
+
}
|
|
130
|
+
if (envHasCrossStackIntrinsic(templateEnv)) {
|
|
131
|
+
const resolver = await stateProvider.buildCrossStackResolver(loaded.region);
|
|
132
|
+
if (resolver) subContext.crossStackResolver = resolver;
|
|
133
|
+
}
|
|
134
|
+
const { env, audit } = await substituteEnvVarsFromStateAsync(templateEnv, subContext);
|
|
135
|
+
templateEnv = env;
|
|
136
|
+
const label = stateProvider.label;
|
|
137
|
+
for (const key of audit.resolvedKeys) logger.debug(`${label}: substituted env var ${key}`);
|
|
138
|
+
let unresolved = audit.unresolved;
|
|
139
|
+
const resolvedKeys = [...audit.resolvedKeys];
|
|
140
|
+
if (unresolved.length > 0 && stateProvider.resolveDeployedFunctionEnv) {
|
|
141
|
+
const physicalId = loaded.resources[lambda.logicalId]?.physicalId;
|
|
142
|
+
if (physicalId) {
|
|
143
|
+
const deployedEnv = await stateProvider.resolveDeployedFunctionEnv(physicalId);
|
|
144
|
+
const fb = applyDeployedEnvFallback(templateEnv, unresolved, deployedEnv);
|
|
145
|
+
templateEnv = fb.env;
|
|
146
|
+
unresolved = fb.stillUnresolved;
|
|
147
|
+
for (const key of fb.filled) {
|
|
148
|
+
resolvedKeys.push(key);
|
|
149
|
+
logger.debug(`${label}: filled env var ${key} from deployed function config`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
stateAudit = {
|
|
154
|
+
resolvedKeys,
|
|
155
|
+
unresolved,
|
|
156
|
+
sensitiveKeys: audit.sensitiveKeys
|
|
157
|
+
};
|
|
158
|
+
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.`);
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
stateProvider.dispose();
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
const overrides = readEnvOverridesFile$2(options.envVars);
|
|
165
|
+
const lambdaCdkPath = readCdkPathOrUndefined(lambda.resource);
|
|
166
|
+
const envResult = resolveEnvVars(lambda.logicalId, lambdaCdkPath, templateEnv, overrides);
|
|
167
|
+
for (const key of envResult.unresolved) {
|
|
168
|
+
if (stateAudit && stateAudit.unresolved.some((u) => u.key === key)) continue;
|
|
169
|
+
const overrideKeyExample = lambdaCdkPath?.replace(/\/Resource$/, "") ?? lambda.logicalId;
|
|
170
|
+
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.`);
|
|
171
|
+
}
|
|
172
|
+
let resolvedAssumeRoleArn;
|
|
173
|
+
try {
|
|
174
|
+
resolvedAssumeRoleArn = await resolveAssumeRoleArnForLambda(options.assumeRole, stateForRoleHint, stateProvider, lambda.logicalId);
|
|
175
|
+
} finally {
|
|
176
|
+
stateProvider?.dispose();
|
|
177
|
+
}
|
|
178
|
+
if (options.assumeRole === void 0 && stateForRoleHint) suggestAssumeRoleFromState(stateForRoleHint, lambda.logicalId);
|
|
179
|
+
const event = await readEvent$1(options);
|
|
180
|
+
const dockerEnv = {
|
|
181
|
+
AWS_LAMBDA_FUNCTION_NAME: lambda.logicalId,
|
|
182
|
+
AWS_LAMBDA_FUNCTION_MEMORY_SIZE: String(lambda.memoryMb),
|
|
183
|
+
AWS_LAMBDA_FUNCTION_TIMEOUT: String(lambda.timeoutSec),
|
|
184
|
+
AWS_LAMBDA_FUNCTION_VERSION: "$LATEST",
|
|
185
|
+
AWS_LAMBDA_LOG_GROUP_NAME: `/aws/lambda/${lambda.logicalId}`,
|
|
186
|
+
AWS_LAMBDA_LOG_STREAM_NAME: "local",
|
|
187
|
+
...envResult.resolved
|
|
188
|
+
};
|
|
189
|
+
let assumeSucceeded = false;
|
|
190
|
+
if (resolvedAssumeRoleArn) {
|
|
191
|
+
const stsRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"];
|
|
192
|
+
try {
|
|
193
|
+
const creds = await assumeLambdaExecutionRole(resolvedAssumeRoleArn, stsRegion);
|
|
194
|
+
dockerEnv["AWS_ACCESS_KEY_ID"] = creds.accessKeyId;
|
|
195
|
+
dockerEnv["AWS_SECRET_ACCESS_KEY"] = creds.secretAccessKey;
|
|
196
|
+
dockerEnv["AWS_SESSION_TOKEN"] = creds.sessionToken;
|
|
197
|
+
if (stsRegion) dockerEnv["AWS_REGION"] = stsRegion;
|
|
198
|
+
assumeSucceeded = true;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
201
|
+
logger.warn(`--assume-role: STS AssumeRole(${resolvedAssumeRoleArn}) failed: ${reason}. Falling back to the developer's shell credentials.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!assumeSucceeded) {
|
|
205
|
+
forwardAwsEnv$1(dockerEnv);
|
|
206
|
+
applyProfileCredentialsOverlay(dockerEnv, profileCredentials, false);
|
|
207
|
+
if (profileCredsFile) {
|
|
208
|
+
dockerEnv["AWS_SHARED_CREDENTIALS_FILE"] = profileCredsFile.containerPath;
|
|
209
|
+
dockerEnv["AWS_PROFILE"] = profileCredsFile.profileName;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
let debugPort;
|
|
213
|
+
if (options.debugPort) {
|
|
214
|
+
debugPort = Number(options.debugPort);
|
|
215
|
+
if (!Number.isInteger(debugPort) || debugPort <= 0 || debugPort > 65535) throw new Error(`--debug-port must be an integer in 1..65535, got '${options.debugPort}'`);
|
|
216
|
+
dockerEnv["NODE_OPTIONS"] = `--inspect-brk=0.0.0.0:${debugPort}`;
|
|
217
|
+
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.");
|
|
218
|
+
}
|
|
219
|
+
const hostPort = await pickFreePort();
|
|
220
|
+
const containerHost = options.containerHost;
|
|
221
|
+
if (lambda.layers.length > 0) logger.info(`Mounting ${lambda.layers.length} Lambda layer${lambda.layers.length === 1 ? "" : "s"} at /opt`);
|
|
222
|
+
logger.info(`Starting container (image=${imagePlan.image}, port=${hostPort})...`);
|
|
223
|
+
const extraMountsWithProfile = profileCredsFile ? [...imagePlan.extraMounts ?? [], {
|
|
224
|
+
hostPath: profileCredsFile.hostPath,
|
|
225
|
+
containerPath: profileCredsFile.containerPath,
|
|
226
|
+
readOnly: true
|
|
227
|
+
}] : imagePlan.extraMounts;
|
|
228
|
+
containerId = await runDetached({
|
|
229
|
+
image: imagePlan.image,
|
|
230
|
+
mounts: imagePlan.mounts,
|
|
231
|
+
extraMounts: extraMountsWithProfile,
|
|
232
|
+
env: dockerEnv,
|
|
233
|
+
...stateAudit && stateAudit.sensitiveKeys.length > 0 && { sensitiveEnvKeys: new Set(stateAudit.sensitiveKeys) },
|
|
234
|
+
cmd: imagePlan.cmd,
|
|
235
|
+
hostPort,
|
|
236
|
+
host: containerHost,
|
|
237
|
+
...debugPort !== void 0 && { debugPort },
|
|
238
|
+
...imagePlan.platform !== void 0 && { platform: imagePlan.platform },
|
|
239
|
+
...imagePlan.entryPoint !== void 0 && { entryPoint: imagePlan.entryPoint },
|
|
240
|
+
...imagePlan.workingDir !== void 0 && { workingDir: imagePlan.workingDir },
|
|
241
|
+
...imagePlan.tmpfs !== void 0 && { tmpfs: imagePlan.tmpfs }
|
|
242
|
+
});
|
|
243
|
+
stopLogs = streamLogs(containerId);
|
|
244
|
+
sigintHandler = () => {
|
|
245
|
+
cleanup().then(() => {
|
|
246
|
+
process.exit(130);
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
process.on("SIGINT", sigintHandler);
|
|
250
|
+
await waitForRieReady(containerHost, hostPort, 5e3);
|
|
251
|
+
const result = await invokeRie(containerHost, hostPort, event, Math.max(3e4, lambda.timeoutSec * 2 * 1e3));
|
|
252
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250));
|
|
253
|
+
process.stdout.write(`${result.raw}\n`);
|
|
254
|
+
} finally {
|
|
255
|
+
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
256
|
+
await cleanup();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async function resolveImagePlan(lambda, options) {
|
|
260
|
+
if (lambda.kind === "zip") return resolveZipImagePlan(lambda, options);
|
|
261
|
+
return resolveContainerImagePlan(lambda, options);
|
|
262
|
+
}
|
|
263
|
+
async function resolveZipImagePlan(lambda, options) {
|
|
264
|
+
let inlineTmpDir;
|
|
265
|
+
let codeDir = lambda.codePath;
|
|
266
|
+
if (codeDir === null) {
|
|
267
|
+
inlineTmpDir = materializeInlineCode(lambda.handler, lambda.inlineCode ?? "", resolveRuntimeFileExtension(lambda.runtime));
|
|
268
|
+
codeDir = inlineTmpDir;
|
|
269
|
+
}
|
|
270
|
+
const image = resolveRuntimeImage(lambda.runtime);
|
|
271
|
+
await pullImage(image, options.pull === false);
|
|
272
|
+
const layerPlan = await materializeLambdaLayersIncludingArns(lambda.layers, options);
|
|
273
|
+
const containerCodePath = resolveRuntimeCodeMountPath(lambda.runtime);
|
|
274
|
+
const tmpfs = resolveTmpfsForLambda(lambda);
|
|
275
|
+
return {
|
|
276
|
+
image,
|
|
277
|
+
mounts: [{
|
|
278
|
+
hostPath: codeDir,
|
|
279
|
+
containerPath: containerCodePath,
|
|
280
|
+
readOnly: true
|
|
281
|
+
}],
|
|
282
|
+
extraMounts: layerPlan.mount ? [layerPlan.mount] : [],
|
|
283
|
+
cmd: [lambda.handler],
|
|
284
|
+
...inlineTmpDir !== void 0 && { inlineTmpDir },
|
|
285
|
+
...layerPlan.tmpDir !== void 0 && { layersTmpDir: layerPlan.tmpDir },
|
|
286
|
+
...layerPlan.extraTmpDirs.length > 0 && { layerArnTmpDirs: layerPlan.extraTmpDirs },
|
|
287
|
+
...tmpfs !== void 0 && { tmpfs }
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
async function materializeLambdaLayersIncludingArns(layers, options) {
|
|
291
|
+
const extraTmpDirs = [];
|
|
292
|
+
const flat = [];
|
|
293
|
+
for (const layer of layers) {
|
|
294
|
+
if (layer.kind === "asset") {
|
|
295
|
+
flat.push({
|
|
296
|
+
logicalId: layer.logicalId,
|
|
297
|
+
assetPath: layer.assetPath
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const dir = await materializeLayerFromArn(layer, { ...options.layerRoleArn !== void 0 && { roleArn: options.layerRoleArn } });
|
|
302
|
+
extraTmpDirs.push(dir);
|
|
303
|
+
flat.push({
|
|
304
|
+
logicalId: layer.arn,
|
|
305
|
+
assetPath: dir
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
...materializeLambdaLayers(flat),
|
|
310
|
+
extraTmpDirs
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
function resolveTmpfsForLambda(lambda) {
|
|
314
|
+
if (lambda.ephemeralStorageMb === void 0) return void 0;
|
|
315
|
+
const logger = getLogger();
|
|
316
|
+
if (lambda.kind === "image") logger.info(`Lambda ${lambda.logicalId}: capping /tmp at ${lambda.ephemeralStorageMb} MiB via --tmpfs (overlays any base-image /tmp content)`);
|
|
317
|
+
else logger.debug(`Lambda ${lambda.logicalId}: applying EphemeralStorage cap via --tmpfs /tmp:size=${lambda.ephemeralStorageMb}m`);
|
|
318
|
+
return {
|
|
319
|
+
target: "/tmp",
|
|
320
|
+
sizeMb: lambda.ephemeralStorageMb
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function materializeLambdaLayers(layers) {
|
|
324
|
+
if (layers.length === 0) return {};
|
|
325
|
+
if (layers.length === 1) return { mount: {
|
|
326
|
+
hostPath: layers[0].assetPath,
|
|
327
|
+
containerPath: "/opt",
|
|
328
|
+
readOnly: true
|
|
329
|
+
} };
|
|
330
|
+
const tmpDir = mkdtempSync(path.join(tmpdir(), `${getEmbedConfig().resourceNamePrefix}-invoke-layers-`));
|
|
331
|
+
for (const layer of layers) cpSync(layer.assetPath, tmpDir, {
|
|
332
|
+
recursive: true,
|
|
333
|
+
force: true
|
|
334
|
+
});
|
|
335
|
+
return {
|
|
336
|
+
mount: {
|
|
337
|
+
hostPath: tmpDir,
|
|
338
|
+
containerPath: "/opt",
|
|
339
|
+
readOnly: true
|
|
340
|
+
},
|
|
341
|
+
tmpDir
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
async function resolveContainerImagePlan(lambda, options) {
|
|
345
|
+
const logger = getLogger();
|
|
346
|
+
const platform = architectureToPlatform(lambda.architecture);
|
|
347
|
+
const localBuild = await resolveLocalBuildPlan(lambda);
|
|
348
|
+
let imageRef;
|
|
349
|
+
if (localBuild) imageRef = await buildContainerImage(localBuild.asset, localBuild.cdkOutDir, {
|
|
350
|
+
architecture: lambda.architecture,
|
|
351
|
+
noBuild: options.build === false
|
|
352
|
+
});
|
|
353
|
+
else {
|
|
354
|
+
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.`);
|
|
355
|
+
logger.info(`No matching cdk.out asset for ${lambda.imageUri}; falling back to ECR pull (same-acct/region only)...`);
|
|
356
|
+
imageRef = await pullEcrImage(lambda.imageUri, {
|
|
357
|
+
skipPull: options.pull === false,
|
|
358
|
+
...options.region !== void 0 && { region: options.region },
|
|
359
|
+
...options.ecrRoleArn !== void 0 && { ecrRoleArn: options.ecrRoleArn },
|
|
360
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const tmpfs = resolveTmpfsForLambda(lambda);
|
|
364
|
+
return {
|
|
365
|
+
image: imageRef,
|
|
366
|
+
mounts: [],
|
|
367
|
+
extraMounts: [],
|
|
368
|
+
cmd: lambda.imageConfig.command ?? [],
|
|
369
|
+
platform,
|
|
370
|
+
...lambda.imageConfig.entryPoint && lambda.imageConfig.entryPoint.length > 0 && { entryPoint: lambda.imageConfig.entryPoint },
|
|
371
|
+
...lambda.imageConfig.workingDirectory !== void 0 && { workingDir: lambda.imageConfig.workingDirectory },
|
|
372
|
+
...tmpfs !== void 0 && { tmpfs }
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
async function resolveLocalBuildPlan(lambda) {
|
|
376
|
+
const manifestPath = lambda.stack.assetManifestPath;
|
|
377
|
+
if (!manifestPath) return void 0;
|
|
378
|
+
const cdkOutDir = dirname(manifestPath);
|
|
379
|
+
const manifest = await new AssetManifestLoader().loadManifest(cdkOutDir, lambda.stack.stackName);
|
|
380
|
+
if (!manifest) return void 0;
|
|
381
|
+
const entry = getDockerImageBySourceHash(manifest, lambda.imageUri);
|
|
382
|
+
if (!entry) return void 0;
|
|
383
|
+
return {
|
|
384
|
+
asset: entry.asset,
|
|
385
|
+
cdkOutDir
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function envHasIntrinsicValue(templateEnv) {
|
|
389
|
+
if (!templateEnv) return false;
|
|
390
|
+
for (const v of Object.values(templateEnv)) {
|
|
391
|
+
if (v === void 0 || v === null) continue;
|
|
392
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") continue;
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
function envHasCrossStackIntrinsic(templateEnv) {
|
|
398
|
+
if (!templateEnv) return false;
|
|
399
|
+
for (const v of Object.values(templateEnv)) {
|
|
400
|
+
if (!v || typeof v !== "object") continue;
|
|
401
|
+
const obj = v;
|
|
402
|
+
if ("Fn::ImportValue" in obj || "Fn::GetStackOutput" in obj) return true;
|
|
403
|
+
}
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
async function resolvePseudoParametersForInvoke(stackRegion, options) {
|
|
407
|
+
const logger = getLogger();
|
|
408
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? stackRegion;
|
|
409
|
+
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.`);
|
|
410
|
+
let accountId;
|
|
411
|
+
try {
|
|
412
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
413
|
+
const sts = new STSClient({ ...region && { region } });
|
|
414
|
+
try {
|
|
415
|
+
accountId = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
416
|
+
} finally {
|
|
417
|
+
sts.destroy();
|
|
418
|
+
}
|
|
419
|
+
} catch (err) {
|
|
420
|
+
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.`);
|
|
421
|
+
}
|
|
422
|
+
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
423
|
+
const bag = {
|
|
424
|
+
...accountId !== void 0 && { accountId },
|
|
425
|
+
...region !== void 0 && { region },
|
|
426
|
+
...partitionAndSuffix && {
|
|
427
|
+
partition: partitionAndSuffix.partition,
|
|
428
|
+
urlSuffix: partitionAndSuffix.urlSuffix
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
return Object.keys(bag).length === 0 ? void 0 : bag;
|
|
432
|
+
}
|
|
433
|
+
function getTemplateEnv(resource) {
|
|
434
|
+
const env = (resource.Properties ?? {})["Environment"];
|
|
435
|
+
if (!env || typeof env !== "object") return void 0;
|
|
436
|
+
const vars = env["Variables"];
|
|
437
|
+
if (!vars || typeof vars !== "object") return void 0;
|
|
438
|
+
return vars;
|
|
439
|
+
}
|
|
440
|
+
function readEnvOverridesFile$2(filePath) {
|
|
441
|
+
if (!filePath) return void 0;
|
|
442
|
+
let raw;
|
|
443
|
+
try {
|
|
444
|
+
raw = readFileSync(filePath, "utf-8");
|
|
445
|
+
} catch (err) {
|
|
446
|
+
throw new Error(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
447
|
+
}
|
|
448
|
+
let parsed;
|
|
449
|
+
try {
|
|
450
|
+
parsed = JSON.parse(raw);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
throw new Error(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
453
|
+
}
|
|
454
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
455
|
+
return parsed;
|
|
456
|
+
}
|
|
457
|
+
async function readEvent$1(options) {
|
|
458
|
+
if (options.event && options.eventStdin) throw new Error("--event and --event-stdin are mutually exclusive.");
|
|
459
|
+
if (options.eventStdin) return parseEvent$1(await readStdin$1(), "<stdin>");
|
|
460
|
+
if (options.event) return parseEvent$1(readFileSync(options.event, "utf-8"), options.event);
|
|
461
|
+
return {};
|
|
462
|
+
}
|
|
463
|
+
function parseEvent$1(raw, source) {
|
|
464
|
+
try {
|
|
465
|
+
return JSON.parse(raw);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
throw new Error(`Failed to parse event payload from ${source} as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function readStdin$1() {
|
|
471
|
+
const chunks = [];
|
|
472
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
473
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
474
|
+
}
|
|
475
|
+
async function assumeLambdaExecutionRole(roleArn, region) {
|
|
476
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
477
|
+
const sts = new STSClient({ ...region && { region } });
|
|
478
|
+
try {
|
|
479
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
480
|
+
RoleArn: roleArn,
|
|
481
|
+
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-invoke-${Date.now()}`,
|
|
482
|
+
DurationSeconds: 3600
|
|
483
|
+
}))).Credentials;
|
|
484
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
485
|
+
return {
|
|
486
|
+
accessKeyId: creds.AccessKeyId,
|
|
487
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
488
|
+
sessionToken: creds.SessionToken
|
|
489
|
+
};
|
|
490
|
+
} finally {
|
|
491
|
+
sts.destroy();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function forwardAwsEnv$1(env) {
|
|
495
|
+
for (const key of [
|
|
496
|
+
"AWS_ACCESS_KEY_ID",
|
|
497
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
498
|
+
"AWS_SESSION_TOKEN",
|
|
499
|
+
"AWS_REGION",
|
|
500
|
+
"AWS_DEFAULT_REGION"
|
|
501
|
+
]) {
|
|
502
|
+
const value = process.env[key];
|
|
503
|
+
if (value !== void 0) env[key] = value;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Resolve `--profile <p>` to a concrete credential set. Mirrors the
|
|
508
|
+
* helper in `local-start-api.ts`.
|
|
509
|
+
*/
|
|
510
|
+
async function resolveProfileCredentials(profile) {
|
|
511
|
+
const { STSClient } = await import("@aws-sdk/client-sts");
|
|
512
|
+
const sts = new STSClient({ profile });
|
|
513
|
+
try {
|
|
514
|
+
const credsProvider = sts.config.credentials;
|
|
515
|
+
const creds = typeof credsProvider === "function" ? await credsProvider() : credsProvider;
|
|
516
|
+
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.");
|
|
517
|
+
return {
|
|
518
|
+
accessKeyId: creds.accessKeyId,
|
|
519
|
+
secretAccessKey: creds.secretAccessKey,
|
|
520
|
+
...creds.sessionToken && { sessionToken: creds.sessionToken }
|
|
521
|
+
};
|
|
522
|
+
} finally {
|
|
523
|
+
sts.destroy();
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function applyProfileCredentialsOverlay(env, profileCreds, assumeRoleActive) {
|
|
527
|
+
if (!profileCreds) return;
|
|
528
|
+
if (assumeRoleActive) return;
|
|
529
|
+
env["AWS_ACCESS_KEY_ID"] = profileCreds.accessKeyId;
|
|
530
|
+
env["AWS_SECRET_ACCESS_KEY"] = profileCreds.secretAccessKey;
|
|
531
|
+
if (profileCreds.sessionToken) env["AWS_SESSION_TOKEN"] = profileCreds.sessionToken;
|
|
532
|
+
else delete env["AWS_SESSION_TOKEN"];
|
|
533
|
+
}
|
|
534
|
+
function materializeInlineCode(handler, source, fileExtension) {
|
|
535
|
+
const lastDot = handler.lastIndexOf(".");
|
|
536
|
+
if (lastDot <= 0) throw new Error(`Handler '${handler}' is malformed: expected '<modulePath>.<exportName>'.`);
|
|
537
|
+
const modulePath = handler.substring(0, lastDot);
|
|
538
|
+
const dir = mkdtempSync(path.join(tmpdir(), `${getEmbedConfig().resourceNamePrefix}-invoke-`));
|
|
539
|
+
const filePath = path.join(dir, `${modulePath}${fileExtension}`);
|
|
540
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
541
|
+
writeFileSync(filePath, source, "utf-8");
|
|
542
|
+
return dir;
|
|
543
|
+
}
|
|
544
|
+
function suggestAssumeRoleFromState(state, logicalId) {
|
|
545
|
+
const logger = getLogger();
|
|
546
|
+
const roleArn = resolveExecutionRoleArnFromState(state, logicalId);
|
|
547
|
+
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.`);
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Resolve the role ARN to assume for a Lambda invoke, honoring the three
|
|
551
|
+
* `--assume-role` forms:
|
|
552
|
+
*
|
|
553
|
+
* - `--assume-role <arn>` (explicit) → return `<arn>`.
|
|
554
|
+
* - `--assume-role` (bare, no value) → resolve from CFn state first;
|
|
555
|
+
* if that misses, fall back to
|
|
556
|
+
* `stateProvider.resolveLambdaExecutionRoleArn(<physicalId>)`
|
|
557
|
+
* (a `lambda:GetFunctionConfiguration` call) so a sibling-stack
|
|
558
|
+
* execution role still resolves (issue #181 — `ListStackResources`
|
|
559
|
+
* returns the role's name, not its ARN, so `attributes.Arn` is
|
|
560
|
+
* empty on the CFn state map and the state-only lookup misses).
|
|
561
|
+
* - `--assume-role` absent → return undefined (no assume).
|
|
562
|
+
*
|
|
563
|
+
* Logs the resolution path (info on success, warn on miss) so the user
|
|
564
|
+
* can tell why the container did or did not get assumed-role creds.
|
|
565
|
+
*
|
|
566
|
+
* Exported for unit testing.
|
|
567
|
+
*/
|
|
568
|
+
async function resolveAssumeRoleArnForLambda(assumeRole, stateForRoleHint, stateProvider, lambdaLogicalId) {
|
|
569
|
+
const logger = getLogger();
|
|
570
|
+
if (typeof assumeRole === "string") return assumeRole;
|
|
571
|
+
if (assumeRole !== true) return;
|
|
572
|
+
if (!stateForRoleHint) {
|
|
573
|
+
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.");
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const fromState = resolveExecutionRoleArnFromState(stateForRoleHint, lambdaLogicalId);
|
|
577
|
+
if (fromState) {
|
|
578
|
+
logger.info(`--assume-role: auto-resolved execution role from state: ${fromState}`);
|
|
579
|
+
return fromState;
|
|
580
|
+
}
|
|
581
|
+
const fnPhysicalId = stateForRoleHint.resources[lambdaLogicalId]?.physicalId;
|
|
582
|
+
if (stateProvider?.resolveLambdaExecutionRoleArn && fnPhysicalId) {
|
|
583
|
+
const liveArn = await stateProvider.resolveLambdaExecutionRoleArn(fnPhysicalId);
|
|
584
|
+
if (liveArn) {
|
|
585
|
+
logger.info(`--assume-role: auto-resolved execution role from GetFunctionConfiguration: ${liveArn}`);
|
|
586
|
+
return liveArn;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
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.`);
|
|
590
|
+
}
|
|
591
|
+
function resolveExecutionRoleArnFromState(state, logicalId, roleProperty = "Role") {
|
|
592
|
+
const lambda = state.resources[logicalId];
|
|
593
|
+
if (!lambda) return void 0;
|
|
594
|
+
const roleRef = lambda.properties?.[roleProperty] ?? lambda.observedProperties?.[roleProperty];
|
|
595
|
+
if (typeof roleRef === "string" && roleRef.startsWith("arn:")) return roleRef;
|
|
596
|
+
if (typeof roleRef === "object" && roleRef !== null) {
|
|
597
|
+
const refLogicalId = pickReferencedLogicalId(roleRef);
|
|
598
|
+
if (refLogicalId) {
|
|
599
|
+
const cached = state.resources[refLogicalId]?.attributes?.["Arn"];
|
|
600
|
+
if (typeof cached === "string" && cached.startsWith("arn:")) return cached;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function pickReferencedLogicalId(intrinsic) {
|
|
605
|
+
if ("Ref" in intrinsic && typeof intrinsic["Ref"] === "string") return intrinsic["Ref"];
|
|
606
|
+
if ("Fn::GetAtt" in intrinsic) {
|
|
607
|
+
const arg = intrinsic["Fn::GetAtt"];
|
|
608
|
+
if (Array.isArray(arg) && typeof arg[0] === "string") return arg[0];
|
|
609
|
+
if (typeof arg === "string") return arg.split(".")[0];
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function createLocalInvokeCommand(opts = {}) {
|
|
613
|
+
setEmbedConfig(opts.embedConfig);
|
|
614
|
+
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)").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.")).action(withErrorHandling(async (target, options) => {
|
|
615
|
+
await localInvokeCommand(target, options, opts.extraStateProviders);
|
|
616
|
+
}));
|
|
617
|
+
[
|
|
618
|
+
...commonOptions(),
|
|
619
|
+
...appOptions(),
|
|
620
|
+
...contextOptions
|
|
621
|
+
].forEach((option) => invoke.addOption(option));
|
|
622
|
+
invoke.addOption(deprecatedRegionOption);
|
|
623
|
+
return invoke;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/cli/commands/local-invoke-agentcore.ts
|
|
628
|
+
/**
|
|
629
|
+
* Parser for `--timeout <ms>`. Accepts a positive integer; rejects 0,
|
|
630
|
+
* negatives, fractions, and non-numeric input.
|
|
631
|
+
*/
|
|
632
|
+
function parseTimeoutMs(raw) {
|
|
633
|
+
const parsed = Number(raw);
|
|
634
|
+
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");
|
|
635
|
+
return parsed;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* `cdkl invoke-agentcore <target>` — run a Bedrock AgentCore Runtime container
|
|
639
|
+
* locally and invoke it once over the AgentCore HTTP contract. Resolves
|
|
640
|
+
* the `AWS::BedrockAgentCore::Runtime`, pulls / builds its container,
|
|
641
|
+
* starts it on port 8080, waits for `GET /ping`, POSTs the event to
|
|
642
|
+
* `POST /invocations`, prints the response, and tears down. Covers the
|
|
643
|
+
* container artifact and the CodeConfiguration managed-runtime artifact
|
|
644
|
+
* (fromCodeAsset, built from source) on the HTTP + MCP protocols; the agent's
|
|
645
|
+
* calls to real AWS go to real AWS (credentials injected like `cdkl invoke`).
|
|
646
|
+
*/
|
|
647
|
+
async function localInvokeAgentCoreCommand(target, options, extraStateProviders) {
|
|
648
|
+
const logger = getLogger();
|
|
649
|
+
if (options.verbose) logger.setLevel("debug");
|
|
650
|
+
warnIfDeprecatedRegion(options);
|
|
651
|
+
let containerId;
|
|
652
|
+
let stopLogs;
|
|
653
|
+
let sigintHandler;
|
|
654
|
+
let profileCredsFile;
|
|
655
|
+
let stateProvider;
|
|
656
|
+
const cleanup = singleFlight(async () => {
|
|
657
|
+
if (stateProvider) try {
|
|
658
|
+
stateProvider.dispose();
|
|
659
|
+
} catch (err) {
|
|
660
|
+
getLogger().debug(`state provider dispose failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
661
|
+
}
|
|
662
|
+
if (stopLogs) try {
|
|
663
|
+
stopLogs();
|
|
664
|
+
} catch (err) {
|
|
665
|
+
getLogger().debug(`streamLogs stop failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
666
|
+
}
|
|
667
|
+
if (containerId) try {
|
|
668
|
+
await removeContainer(containerId);
|
|
669
|
+
} catch (err) {
|
|
670
|
+
getLogger().debug(`removeContainer(${containerId}) failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
671
|
+
}
|
|
672
|
+
if (profileCredsFile) try {
|
|
673
|
+
await profileCredsFile.dispose();
|
|
674
|
+
} catch (err) {
|
|
675
|
+
getLogger().debug(`Failed to remove profile credentials tmpdir ${profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
676
|
+
}
|
|
677
|
+
}, (err) => {
|
|
678
|
+
getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
679
|
+
});
|
|
680
|
+
try {
|
|
681
|
+
await applyRoleArnIfSet({
|
|
682
|
+
roleArn: options.roleArn,
|
|
683
|
+
region: options.region
|
|
684
|
+
});
|
|
685
|
+
await ensureDockerAvailable();
|
|
686
|
+
const profileCredentials = options.profile ? await resolveProfileCredentials$1(options.profile) : void 0;
|
|
687
|
+
if (options.profile && profileCredentials) profileCredsFile = await writeProfileCredentialsFile(options.profile, profileCredentials);
|
|
688
|
+
const appCmd = resolveApp(options.app);
|
|
689
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
690
|
+
logger.info("Synthesizing CDK app...");
|
|
691
|
+
const synthesizer = new Synthesizer();
|
|
692
|
+
const context = parseContextOptions(options.context);
|
|
693
|
+
const synthOpts = {
|
|
694
|
+
app: appCmd,
|
|
695
|
+
output: options.output,
|
|
696
|
+
...options.region && { region: options.region },
|
|
697
|
+
...options.profile && { profile: options.profile },
|
|
698
|
+
...Object.keys(context).length > 0 && { context }
|
|
699
|
+
};
|
|
700
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
701
|
+
const resolvedTarget = await resolveSingleTarget(target, {
|
|
702
|
+
entries: listTargets(stacks).agentCoreRuntimes,
|
|
703
|
+
message: "Select an AgentCore Runtime to invoke",
|
|
704
|
+
noun: "AgentCore Runtimes",
|
|
705
|
+
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")
|
|
706
|
+
});
|
|
707
|
+
const candidate = pickAgentCoreCandidateStack(resolvedTarget, stacks);
|
|
708
|
+
stateProvider = createLocalStateProvider(options, candidate?.stackName ?? "", await resolveCfnFallbackRegion(options, candidate?.region), extraStateProviders);
|
|
709
|
+
const { context: imageContext, loaded: loadedState } = stateProvider && candidate ? await buildAgentCoreImageContext(candidate, stateProvider, options) : {
|
|
710
|
+
context: void 0,
|
|
711
|
+
loaded: void 0
|
|
712
|
+
};
|
|
713
|
+
const resolved = resolveAgentCoreTarget(resolvedTarget, stacks, imageContext);
|
|
714
|
+
logger.info(`Target: ${resolved.stack.stackName}/${resolved.logicalId} (${resolved.protocol})`);
|
|
715
|
+
const isMcp = resolved.protocol === "MCP";
|
|
716
|
+
const isA2a = resolved.protocol === "A2A";
|
|
717
|
+
if (resolved.protocol === "AGUI") logger.info("AGUI runtime: routing through the HTTP /invocations + /ws path (AG-UI wire is SSE / WebSocket on port 8080).");
|
|
718
|
+
if ((isMcp || isA2a) && options.ws) logger.warn(`--ws applies only to the HTTP / AGUI protocols; ignoring it for this ${resolved.protocol} runtime.`);
|
|
719
|
+
if (options.wsInteractive && !options.ws) logger.warn("--ws-interactive is meaningful only with --ws; ignoring.");
|
|
720
|
+
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.");
|
|
721
|
+
const sessionId = options.sessionId ?? randomUUID();
|
|
722
|
+
const event = await readEvent(options);
|
|
723
|
+
const mcpRequest = isMcp ? buildMcpRequest(event) : void 0;
|
|
724
|
+
const a2aRequest = isA2a ? buildA2aRequest(event) : void 0;
|
|
725
|
+
let authorization;
|
|
726
|
+
if (isMcp || isA2a) {
|
|
727
|
+
if (resolved.jwtAuthorizer || options.bearerToken) {
|
|
728
|
+
const pathLabel = isMcp ? MCP_PATH : "/";
|
|
729
|
+
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.`);
|
|
730
|
+
}
|
|
731
|
+
} else authorization = await resolveInboundAuthorization(resolved, options);
|
|
732
|
+
await resolveFromS3BucketIntrinsic(resolved, stateProvider, loadedState, imageContext);
|
|
733
|
+
const image = await resolveAgentCoreImage(resolved, options, loadedState);
|
|
734
|
+
const { env: dockerEnv, sensitiveEnvKeys } = await buildContainerEnv(resolved, options, profileCredentials, profileCredsFile, stateProvider, loadedState, imageContext);
|
|
735
|
+
const hostPort = await pickFreePort();
|
|
736
|
+
const containerHost = options.containerHost;
|
|
737
|
+
const containerName = `${getEmbedConfig().resourceNamePrefix}-agentcore-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
738
|
+
const containerPort = isMcp ? MCP_CONTAINER_PORT : isA2a ? A2A_CONTAINER_PORT : void 0;
|
|
739
|
+
const containerPortLabel = isMcp ? `${MCP_CONTAINER_PORT}${MCP_PATH}` : isA2a ? `${A2A_CONTAINER_PORT}${"/"}` : "8080";
|
|
740
|
+
logger.info(`Starting agent container (image=${image}, port=${hostPort} -> ${containerPortLabel})...`);
|
|
741
|
+
containerId = await runDetached({
|
|
742
|
+
image,
|
|
743
|
+
mounts: [],
|
|
744
|
+
env: dockerEnv,
|
|
745
|
+
cmd: [],
|
|
746
|
+
hostPort,
|
|
747
|
+
host: containerHost,
|
|
748
|
+
platform: options.platform,
|
|
749
|
+
name: containerName,
|
|
750
|
+
...containerPort !== void 0 && { containerPort },
|
|
751
|
+
...sensitiveEnvKeys.size > 0 && { sensitiveEnvKeys }
|
|
752
|
+
});
|
|
753
|
+
stopLogs = streamLogs(containerId);
|
|
754
|
+
sigintHandler = () => {
|
|
755
|
+
cleanup().then(() => process.exit(130));
|
|
756
|
+
};
|
|
757
|
+
process.on("SIGINT", sigintHandler);
|
|
758
|
+
if (isMcp && mcpRequest) {
|
|
759
|
+
logger.info(`MCP request: ${mcpRequest.method}`);
|
|
760
|
+
const mcp = await mcpInvokeOnce(containerHost, hostPort, mcpRequest, { requestTimeoutMs: options.timeout });
|
|
761
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
762
|
+
emitMcpResult(mcp);
|
|
763
|
+
} else if (isA2a && a2aRequest) {
|
|
764
|
+
logger.info(`A2A request: ${a2aRequest.method}`);
|
|
765
|
+
const a2a = await a2aInvokeOnce(containerHost, hostPort, a2aRequest, { requestTimeoutMs: options.timeout });
|
|
766
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
767
|
+
emitA2aResult(a2a);
|
|
768
|
+
} else if (options.ws) {
|
|
769
|
+
await waitForAgentCorePing(containerHost, hostPort);
|
|
770
|
+
const frameSource = options.wsInteractive ? readStdinLines() : void 0;
|
|
771
|
+
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...");
|
|
772
|
+
const wsResult = await invokeAgentCoreWs(containerHost, hostPort, event, {
|
|
773
|
+
sessionId,
|
|
774
|
+
timeoutMs: options.timeout,
|
|
775
|
+
onMessage: (text) => process.stdout.write(text),
|
|
776
|
+
...authorization && { authorization },
|
|
777
|
+
...frameSource && { frameSource }
|
|
778
|
+
});
|
|
779
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
780
|
+
emitWsResult(wsResult);
|
|
781
|
+
} else {
|
|
782
|
+
await waitForAgentCorePing(containerHost, hostPort);
|
|
783
|
+
const additionalHeaders = await buildSigV4HeadersIfRequested(options, resolved, loadedState, containerHost, hostPort, event, sessionId);
|
|
784
|
+
const result = await invokeAgentCore(containerHost, hostPort, event, {
|
|
785
|
+
sessionId,
|
|
786
|
+
timeoutMs: options.timeout,
|
|
787
|
+
onChunk: (text) => process.stdout.write(text),
|
|
788
|
+
...authorization && { authorization },
|
|
789
|
+
...additionalHeaders && { additionalHeaders }
|
|
790
|
+
});
|
|
791
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
792
|
+
emitResult(result);
|
|
793
|
+
}
|
|
794
|
+
} finally {
|
|
795
|
+
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
796
|
+
await cleanup();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Enforce the runtime's inbound JWT authorizer (when declared) and return
|
|
801
|
+
* the `Authorization` header to forward to `/invocations`.
|
|
802
|
+
*
|
|
803
|
+
* - No authorizer → forward the token verbatim if one was given (no-op
|
|
804
|
+
* otherwise).
|
|
805
|
+
* - `--no-verify-auth` → warn + forward without verifying (local-dev escape).
|
|
806
|
+
* - Authorizer + no token → reject (AgentCore returns 401).
|
|
807
|
+
* - Authorizer + token → verify against the OIDC discovery URL; reject on
|
|
808
|
+
* failure (AgentCore returns 403); forward on success. An unreachable
|
|
809
|
+
* discovery URL falls back to pass-through accept (offline-dev fallback in
|
|
810
|
+
* {@link verifyJwtViaDiscovery}).
|
|
811
|
+
*
|
|
812
|
+
* Exported so a unit test can drive the gate without the full Docker pipeline.
|
|
813
|
+
*/
|
|
814
|
+
async function resolveInboundAuthorization(resolved, options) {
|
|
815
|
+
const logger = getLogger();
|
|
816
|
+
const authorizer = resolved.jwtAuthorizer;
|
|
817
|
+
const header = options.bearerToken ? `Bearer ${options.bearerToken}` : void 0;
|
|
818
|
+
if (!authorizer) return header;
|
|
819
|
+
if (options.verifyAuth === false) {
|
|
820
|
+
logger.warn(`Runtime '${resolved.logicalId}' declares a customJwtAuthorizer, but --no-verify-auth was set — skipping inbound JWT verification (local-dev escape hatch).`);
|
|
821
|
+
return header;
|
|
822
|
+
}
|
|
823
|
+
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");
|
|
824
|
+
if (!(await verifyJwtViaDiscovery({
|
|
825
|
+
discoveryUrl: authorizer.discoveryUrl,
|
|
826
|
+
...authorizer.allowedAudience && { allowedAudience: authorizer.allowedAudience },
|
|
827
|
+
...authorizer.allowedClients && { allowedClients: authorizer.allowedClients },
|
|
828
|
+
...authorizer.allowedScopes && { allowedScopes: authorizer.allowedScopes },
|
|
829
|
+
...authorizer.customClaims && { customClaims: authorizer.customClaims }
|
|
830
|
+
}, 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");
|
|
831
|
+
logger.info(`Inbound JWT verified against ${authorizer.discoveryUrl}.`);
|
|
832
|
+
return header;
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Compute the SigV4 headers for the `/invocations` POST when `--sigv4` is
|
|
836
|
+
* requested. Returns `undefined` (no header overlay) when:
|
|
837
|
+
*
|
|
838
|
+
* - `--sigv4` is not set,
|
|
839
|
+
* - the runtime declares a `customJwtAuthorizer` (the JWT path wins; warns),
|
|
840
|
+
*
|
|
841
|
+
* Throws a {@link CdkLocalError} when `--sigv4` conflicts with
|
|
842
|
+
* `--bearer-token`, or when no AWS credentials are resolvable for signing.
|
|
843
|
+
*
|
|
844
|
+
* Exported so a unit test can drive the gate without the full Docker pipeline.
|
|
845
|
+
*/
|
|
846
|
+
async function buildSigV4HeadersIfRequested(options, resolved, loaded, host, port, event, sessionId) {
|
|
847
|
+
if (!options.sigv4) return void 0;
|
|
848
|
+
if (options.bearerToken) throw new CdkLocalError(`--sigv4 and --bearer-token are mutually exclusive: pick one inbound auth.`, "LOCAL_INVOKE_AGENTCORE_AUTH_CONFLICT");
|
|
849
|
+
if (resolved.jwtAuthorizer) {
|
|
850
|
+
getLogger().warn(`Runtime '${resolved.logicalId}' declares a customJwtAuthorizer; --sigv4 ignored (JWT path takes precedence).`);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const region = options.region ?? options.stackRegion ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? resolved.stack.region;
|
|
854
|
+
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");
|
|
855
|
+
const signed = await signAgentCoreInvocation({
|
|
856
|
+
credentials: await resolveHostCredentialsForSigV4(options, resolved, loaded, region),
|
|
857
|
+
region,
|
|
858
|
+
host,
|
|
859
|
+
port,
|
|
860
|
+
path: "/invocations",
|
|
861
|
+
body: JSON.stringify(event ?? {}),
|
|
862
|
+
sessionId
|
|
863
|
+
});
|
|
864
|
+
const headers = {
|
|
865
|
+
Authorization: signed.authorization,
|
|
866
|
+
"X-Amz-Date": signed.amzDate,
|
|
867
|
+
"X-Amz-Content-Sha256": signed.amzContentSha256
|
|
868
|
+
};
|
|
869
|
+
if (signed.amzSecurityToken) headers["X-Amz-Security-Token"] = signed.amzSecurityToken;
|
|
870
|
+
getLogger().info(`Signed /invocations with SigV4 (region=${region}).`);
|
|
871
|
+
return headers;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Resolve credentials for host-side SigV4 signing. Precedence:
|
|
875
|
+
* 1. `--assume-role` → STS temp creds (warn + fall through on STS failure);
|
|
876
|
+
* 2. `--profile` → profile creds (sessionToken when the profile carries one);
|
|
877
|
+
* 3. shell env (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / optional
|
|
878
|
+
* `AWS_SESSION_TOKEN`).
|
|
879
|
+
*
|
|
880
|
+
* Throws a {@link CdkLocalError} when none are available — `--sigv4` cannot
|
|
881
|
+
* proceed without credentials, unlike the unsigned path.
|
|
882
|
+
*/
|
|
883
|
+
async function resolveHostCredentialsForSigV4(options, resolved, loaded, region) {
|
|
884
|
+
const logger = getLogger();
|
|
885
|
+
const assumeRoleArn = resolveAssumeRoleArn(options, resolved, loaded);
|
|
886
|
+
if (assumeRoleArn) try {
|
|
887
|
+
return await assumeAgentCoreExecutionRole(assumeRoleArn, region);
|
|
888
|
+
} catch (err) {
|
|
889
|
+
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"}.`);
|
|
890
|
+
}
|
|
891
|
+
if (options.profile) {
|
|
892
|
+
const creds = await resolveProfileCredentials$1(options.profile);
|
|
893
|
+
if (creds?.accessKeyId && creds.secretAccessKey) return {
|
|
894
|
+
accessKeyId: creds.accessKeyId,
|
|
895
|
+
secretAccessKey: creds.secretAccessKey,
|
|
896
|
+
...creds.sessionToken && { sessionToken: creds.sessionToken }
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
const accessKeyId = process.env["AWS_ACCESS_KEY_ID"];
|
|
900
|
+
const secretAccessKey = process.env["AWS_SECRET_ACCESS_KEY"];
|
|
901
|
+
if (accessKeyId && secretAccessKey) {
|
|
902
|
+
const sessionToken = process.env["AWS_SESSION_TOKEN"];
|
|
903
|
+
return {
|
|
904
|
+
accessKeyId,
|
|
905
|
+
secretAccessKey,
|
|
906
|
+
...sessionToken && { sessionToken }
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
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");
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Acquire the agent image. A CODE artifact (managed runtime) is built from
|
|
913
|
+
* source — a fromCodeAsset bundle from its cdk.out asset, a fromS3 bundle
|
|
914
|
+
* downloaded + extracted from S3. A CONTAINER artifact mirrors the
|
|
915
|
+
* container-Lambda path: build from a local cdk.out asset when the URI matches
|
|
916
|
+
* one, else pull from ECR, else pull a plain registry image.
|
|
917
|
+
*
|
|
918
|
+
* `loaded` is the `--from-cfn-stack` state record (when available) — threaded
|
|
919
|
+
* through so a bare `--assume-role` can resolve the execution-role ARN from
|
|
920
|
+
* state for the fromS3 download.
|
|
921
|
+
*/
|
|
922
|
+
async function resolveAgentCoreImage(resolved, options, loaded) {
|
|
923
|
+
const logger = getLogger();
|
|
924
|
+
const architecture = platformToArchitecture(options.platform);
|
|
925
|
+
if (resolved.codeArtifact) return resolveAgentCoreCodeImage(resolved, resolved.codeArtifact, options, architecture, loaded);
|
|
926
|
+
const containerUri = resolved.containerUri;
|
|
927
|
+
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");
|
|
928
|
+
const manifestPath = resolved.stack.assetManifestPath;
|
|
929
|
+
if (manifestPath) {
|
|
930
|
+
const cdkOutDir = dirname(manifestPath);
|
|
931
|
+
const manifest = await new AssetManifestLoader().loadManifest(cdkOutDir, resolved.stack.stackName);
|
|
932
|
+
if (manifest) {
|
|
933
|
+
const entry = getDockerImageBySourceHash(manifest, containerUri);
|
|
934
|
+
if (entry) return buildContainerImage(entry.asset, cdkOutDir, {
|
|
935
|
+
architecture,
|
|
936
|
+
noBuild: options.build === false
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
if (parseEcrUri(containerUri)) {
|
|
941
|
+
logger.info(`Pulling agent image from ECR: ${containerUri}`);
|
|
942
|
+
return pullEcrImage(containerUri, {
|
|
943
|
+
skipPull: options.pull === false,
|
|
944
|
+
...options.region !== void 0 && { region: options.region },
|
|
945
|
+
...options.ecrRoleArn !== void 0 && { ecrRoleArn: options.ecrRoleArn },
|
|
946
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
await pullImage(containerUri, options.pull === false);
|
|
950
|
+
return containerUri;
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Build a local image from a `CodeConfiguration` (managed-runtime) bundle.
|
|
954
|
+
*
|
|
955
|
+
* - fromS3 (`code.s3Source` set, a literal S3 object): download + extract the
|
|
956
|
+
* bundle, then run the from-source build over the extracted dir.
|
|
957
|
+
* - fromCodeAsset: locate the source dir in cdk.out via its asset hash, then
|
|
958
|
+
* run the same from-source build (generated Dockerfile → install deps → run
|
|
959
|
+
* EntryPoint).
|
|
960
|
+
*/
|
|
961
|
+
async function resolveAgentCoreCodeImage(resolved, code, options, architecture, loaded) {
|
|
962
|
+
if (code.s3Source) return resolveAgentCoreCodeImageFromS3(resolved, code, code.s3Source, options, architecture, loaded);
|
|
963
|
+
const manifestPath = resolved.stack.assetManifestPath;
|
|
964
|
+
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");
|
|
965
|
+
const cdkOutDir = dirname(manifestPath);
|
|
966
|
+
const loader = new AssetManifestLoader();
|
|
967
|
+
const manifest = await loader.loadManifest(cdkOutDir, resolved.stack.stackName);
|
|
968
|
+
const fileAssets = manifest ? loader.getFileAssets(manifest) : void 0;
|
|
969
|
+
const asset = fileAssets ? fileAssets.get(code.codeAssetHash) ?? findFileAssetByObjectKey(fileAssets, code.codeAssetHash) : void 0;
|
|
970
|
+
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");
|
|
971
|
+
const sourceDir = loader.getAssetSourcePath(cdkOutDir, asset);
|
|
972
|
+
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");
|
|
973
|
+
return buildAgentCoreCodeImage({
|
|
974
|
+
sourceDir,
|
|
975
|
+
runtime: code.runtime,
|
|
976
|
+
entryPoint: code.entryPoint,
|
|
977
|
+
architecture,
|
|
978
|
+
noBuild: options.build === false
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Build a local image from a fromS3 CodeConfiguration bundle: download +
|
|
983
|
+
* extract the S3 object, run the from-source build over the extracted dir, then
|
|
984
|
+
* clean up the temp dir.
|
|
985
|
+
*
|
|
986
|
+
* Credentials mirror the rest of the command: an `--assume-role` ARN (explicit,
|
|
987
|
+
* or resolved from `--from-cfn-stack` state for the bare form) yields STS temp
|
|
988
|
+
* creds for the download; otherwise `--profile` / the default chain is used.
|
|
989
|
+
* The region is `--region` / `--stack-region` / env / the stack's region.
|
|
990
|
+
*/
|
|
991
|
+
async function resolveAgentCoreCodeImageFromS3(resolved, code, s3Source, options, architecture, loaded) {
|
|
992
|
+
const logger = getLogger();
|
|
993
|
+
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");
|
|
994
|
+
const location = {
|
|
995
|
+
bucket: s3Source.bucket,
|
|
996
|
+
key: s3Source.key,
|
|
997
|
+
...s3Source.versionId !== void 0 && { versionId: s3Source.versionId }
|
|
998
|
+
};
|
|
999
|
+
const region = options.region ?? options.stackRegion ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? resolved.stack.region;
|
|
1000
|
+
const assumeRoleArn = resolveAssumeRoleArn(options, resolved, loaded);
|
|
1001
|
+
let credentials;
|
|
1002
|
+
if (assumeRoleArn) try {
|
|
1003
|
+
credentials = await assumeAgentCoreExecutionRole(assumeRoleArn, region);
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
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"}.`);
|
|
1006
|
+
}
|
|
1007
|
+
const bundle = await downloadAndExtractS3Bundle(location, {
|
|
1008
|
+
...region !== void 0 && { region },
|
|
1009
|
+
...options.profile !== void 0 && { profile: options.profile },
|
|
1010
|
+
...credentials !== void 0 && { credentials }
|
|
1011
|
+
});
|
|
1012
|
+
try {
|
|
1013
|
+
return await buildAgentCoreCodeImage({
|
|
1014
|
+
sourceDir: bundle.dir,
|
|
1015
|
+
runtime: code.runtime,
|
|
1016
|
+
entryPoint: code.entryPoint,
|
|
1017
|
+
architecture,
|
|
1018
|
+
noBuild: options.build === false
|
|
1019
|
+
});
|
|
1020
|
+
} finally {
|
|
1021
|
+
await bundle.cleanup();
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Find the file asset whose destination objectKey is `<hash>.zip` (matching the
|
|
1026
|
+
* `Code.S3.Prefix`'s hash) when the source-hash-keyed lookup misses — covers a
|
|
1027
|
+
* synthesizer whose source hash differs from the destination objectKey.
|
|
1028
|
+
*/
|
|
1029
|
+
function findFileAssetByObjectKey(fileAssets, hash) {
|
|
1030
|
+
const zip = `${hash}.zip`;
|
|
1031
|
+
for (const asset of fileAssets.values()) if (Object.values(asset.destinations).some((d) => d.objectKey === zip || d.objectKey.endsWith(`/${zip}`))) return asset;
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Build the container env + the set of env keys to keep off the `docker run`
|
|
1035
|
+
* argv. Substitutes `--from-cfn-stack` state into the template env (reusing the
|
|
1036
|
+
* shared state load + image-resolution context — Ref / Fn::Sub / Fn::Join +
|
|
1037
|
+
* SSM parameters, with decrypted SecureString values flagged sensitive),
|
|
1038
|
+
* applies `--env-vars` overrides, then injects AWS credentials (`--assume-role`
|
|
1039
|
+
* STS temp creds — resolving an intrinsic RoleArn from state for bare
|
|
1040
|
+
* `--assume-role` — else `--profile` / dev creds).
|
|
1041
|
+
*
|
|
1042
|
+
* The state provider + loaded record + image context are built once by the
|
|
1043
|
+
* caller and shared here, so this does not re-load state.
|
|
1044
|
+
*/
|
|
1045
|
+
async function buildContainerEnv(resolved, options, profileCredentials, profileCredsFile, stateProvider, loaded, imageContext) {
|
|
1046
|
+
const logger = getLogger();
|
|
1047
|
+
let templateEnv = resolved.environmentVariables;
|
|
1048
|
+
const sensitiveEnvKeys = /* @__PURE__ */ new Set();
|
|
1049
|
+
if (stateProvider && loaded) {
|
|
1050
|
+
const subContext = {
|
|
1051
|
+
resources: imageContext?.stateResources ?? loaded.resources,
|
|
1052
|
+
consumerRegion: loaded.region
|
|
1053
|
+
};
|
|
1054
|
+
const pseudo = imageContext?.pseudoParameters ?? derivePseudoParametersFromRegion(loaded.region);
|
|
1055
|
+
if (pseudo) subContext.pseudoParameters = pseudo;
|
|
1056
|
+
if (imageContext?.stateParameters) subContext.parameters = imageContext.stateParameters;
|
|
1057
|
+
if (imageContext?.stateSensitiveParameters?.length) subContext.sensitiveParameters = new Set(imageContext.stateSensitiveParameters);
|
|
1058
|
+
const resolver = await stateProvider.buildCrossStackResolver(loaded.region);
|
|
1059
|
+
if (resolver) subContext.crossStackResolver = resolver;
|
|
1060
|
+
const { env, audit } = await substituteEnvVarsFromStateAsync(templateEnv, subContext);
|
|
1061
|
+
templateEnv = env;
|
|
1062
|
+
for (const key of audit.resolvedKeys) logger.debug(`${stateProvider.label}: substituted env var ${key}`);
|
|
1063
|
+
for (const key of audit.sensitiveKeys) sensitiveEnvKeys.add(key);
|
|
1064
|
+
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.`);
|
|
1065
|
+
}
|
|
1066
|
+
const overrides = readEnvOverridesFile$1(options.envVars);
|
|
1067
|
+
const cdkPath = readCdkPathOrUndefined(resolved.resource);
|
|
1068
|
+
const envResult = resolveEnvVars(resolved.logicalId, cdkPath, templateEnv, overrides);
|
|
1069
|
+
for (const key of envResult.unresolved) {
|
|
1070
|
+
const overrideKeyExample = cdkPath?.replace(/\/Resource$/, "") ?? resolved.logicalId;
|
|
1071
|
+
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.`);
|
|
1072
|
+
}
|
|
1073
|
+
const dockerEnv = { ...envResult.resolved };
|
|
1074
|
+
const assumeRoleArn = resolveAssumeRoleArn(options, resolved, loaded);
|
|
1075
|
+
await applyAgentCoreCredentialEnv(dockerEnv, {
|
|
1076
|
+
...assumeRoleArn !== void 0 && { assumeRoleArn },
|
|
1077
|
+
...options.region !== void 0 && { region: options.region },
|
|
1078
|
+
...profileCredentials !== void 0 && { profileCredentials },
|
|
1079
|
+
...profileCredsFile !== void 0 && { profileCredsFile: {
|
|
1080
|
+
containerPath: profileCredsFile.containerPath,
|
|
1081
|
+
profileName: profileCredsFile.profileName
|
|
1082
|
+
} }
|
|
1083
|
+
});
|
|
1084
|
+
return {
|
|
1085
|
+
env: dockerEnv,
|
|
1086
|
+
sensitiveEnvKeys
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Resolve a fromS3 bundle's intrinsic `Code.S3.Bucket` to a literal bucket
|
|
1091
|
+
* name in place on `resolved.codeArtifact.s3Source.bucket`. Uses the SAME
|
|
1092
|
+
* state-substitution machinery env vars use under `--from-cfn-stack`, so
|
|
1093
|
+
* every cross-stack intrinsic that path supports (`Ref` / `Fn::ImportValue` /
|
|
1094
|
+
* `Fn::GetStackOutput`) is supported transparently here.
|
|
1095
|
+
*
|
|
1096
|
+
* No-op when there is no intrinsic to resolve. Errors when no state is
|
|
1097
|
+
* available, or when the substitution returns a non-string / unresolved value.
|
|
1098
|
+
*
|
|
1099
|
+
* Exported so a unit test can drive the gate without the full Docker pipeline.
|
|
1100
|
+
*/
|
|
1101
|
+
async function resolveFromS3BucketIntrinsic(resolved, stateProvider, loaded, imageContext) {
|
|
1102
|
+
const s3Source = resolved.codeArtifact?.s3Source;
|
|
1103
|
+
if (!s3Source || s3Source.bucketIntrinsic === void 0) return;
|
|
1104
|
+
if (s3Source.bucket !== void 0) return;
|
|
1105
|
+
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");
|
|
1106
|
+
const subContext = {
|
|
1107
|
+
resources: imageContext?.stateResources ?? loaded.resources,
|
|
1108
|
+
consumerRegion: loaded.region
|
|
1109
|
+
};
|
|
1110
|
+
const pseudo = imageContext?.pseudoParameters ?? derivePseudoParametersFromRegion(loaded.region);
|
|
1111
|
+
if (pseudo) subContext.pseudoParameters = pseudo;
|
|
1112
|
+
const crossStackResolver = await stateProvider.buildCrossStackResolver(loaded.region);
|
|
1113
|
+
if (crossStackResolver) subContext.crossStackResolver = crossStackResolver;
|
|
1114
|
+
const result = await substituteAgainstStateAsync(s3Source.bucketIntrinsic, subContext);
|
|
1115
|
+
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");
|
|
1116
|
+
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");
|
|
1117
|
+
s3Source.bucket = result.value;
|
|
1118
|
+
getLogger().info(`Resolved fromS3 Code.S3.Bucket from state: ${describeIntrinsic(s3Source.bucketIntrinsic)} -> ${result.value}`);
|
|
1119
|
+
}
|
|
1120
|
+
/** Render the intrinsic key for an error / log message (e.g. `Ref:Bucket1`). */
|
|
1121
|
+
function describeIntrinsic(value) {
|
|
1122
|
+
if (!value || typeof value !== "object") return String(value);
|
|
1123
|
+
const obj = value;
|
|
1124
|
+
const key = Object.keys(obj)[0] ?? "?";
|
|
1125
|
+
const arg = obj[key];
|
|
1126
|
+
if (typeof arg === "string") return `${key}:${arg}`;
|
|
1127
|
+
return key;
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* Build the `--from-cfn-stack` image-resolution context + return the loaded
|
|
1131
|
+
* state record (loaded once, reused by env substitution + role resolution).
|
|
1132
|
+
* Mirrors `run-task`'s `buildEcsImageResolutionContext`: pseudo parameters
|
|
1133
|
+
* (region + STS account id), the deployed resources, and SSM template
|
|
1134
|
+
* parameters (decrypted SecureString logical ids flagged sensitive).
|
|
1135
|
+
*/
|
|
1136
|
+
async function buildAgentCoreImageContext(candidate, stateProvider, options) {
|
|
1137
|
+
const logger = getLogger();
|
|
1138
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
|
|
1139
|
+
let accountId;
|
|
1140
|
+
try {
|
|
1141
|
+
accountId = await resolveCallerAccountId$1(region, options.profile);
|
|
1142
|
+
} catch (err) {
|
|
1143
|
+
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.`);
|
|
1144
|
+
}
|
|
1145
|
+
const context = {};
|
|
1146
|
+
const pseudo = derivePseudoParametersFromRegion(region, accountId);
|
|
1147
|
+
if (pseudo) context.pseudoParameters = pseudo;
|
|
1148
|
+
const loaded = await stateProvider.load(candidate.stackName, candidate.region);
|
|
1149
|
+
if (loaded) {
|
|
1150
|
+
context.stateResources = loaded.resources;
|
|
1151
|
+
if (stateProvider.resolveTemplateSsmParameters) {
|
|
1152
|
+
const ssm = await stateProvider.resolveTemplateSsmParameters(candidate.template);
|
|
1153
|
+
if (Object.keys(ssm.values).length > 0) context.stateParameters = ssm.values;
|
|
1154
|
+
if (ssm.secureStringLogicalIds.length > 0) context.stateSensitiveParameters = ssm.secureStringLogicalIds;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
return {
|
|
1158
|
+
context,
|
|
1159
|
+
loaded: loaded ?? void 0
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
/** STS `GetCallerIdentity` for the `${AWS::AccountId}` pseudo parameter (threads `--profile`). */
|
|
1163
|
+
async function resolveCallerAccountId$1(region, profile) {
|
|
1164
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
1165
|
+
const sts = new STSClient({
|
|
1166
|
+
...region && { region },
|
|
1167
|
+
...profile && { profile }
|
|
1168
|
+
});
|
|
1169
|
+
try {
|
|
1170
|
+
return (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
1171
|
+
} finally {
|
|
1172
|
+
sts.destroy();
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Inject AWS credentials into the container env. Precedence:
|
|
1177
|
+
* 1. `--assume-role` → STS-issued temp creds for the resolved ARN (on
|
|
1178
|
+
* STS failure, warn + fall through to dev creds).
|
|
1179
|
+
* 2. dev shell creds (`forwardAwsEnv`) + `--profile` overlay
|
|
1180
|
+
* ({@link applyProfileCredentialsOverlay}) + the bind-mounted
|
|
1181
|
+
* credentials-file env so handler `fromIni({ profile })` resolves.
|
|
1182
|
+
*
|
|
1183
|
+
* Exported so a unit test can lock the binding (mock STS) without driving
|
|
1184
|
+
* the full synth + docker pipeline.
|
|
1185
|
+
*/
|
|
1186
|
+
async function applyAgentCoreCredentialEnv(dockerEnv, args) {
|
|
1187
|
+
const logger = getLogger();
|
|
1188
|
+
let assumeSucceeded = false;
|
|
1189
|
+
if (args.assumeRoleArn) {
|
|
1190
|
+
const stsRegion = args.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"];
|
|
1191
|
+
try {
|
|
1192
|
+
const creds = await assumeAgentCoreExecutionRole(args.assumeRoleArn, stsRegion);
|
|
1193
|
+
dockerEnv["AWS_ACCESS_KEY_ID"] = creds.accessKeyId;
|
|
1194
|
+
dockerEnv["AWS_SECRET_ACCESS_KEY"] = creds.secretAccessKey;
|
|
1195
|
+
dockerEnv["AWS_SESSION_TOKEN"] = creds.sessionToken;
|
|
1196
|
+
if (stsRegion) dockerEnv["AWS_REGION"] = stsRegion;
|
|
1197
|
+
assumeSucceeded = true;
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
logger.warn(`--assume-role: STS AssumeRole(${args.assumeRoleArn}) failed: ${err instanceof Error ? err.message : String(err)}. Falling back to the developer's shell credentials.`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
if (!assumeSucceeded) {
|
|
1203
|
+
forwardAwsEnv(dockerEnv);
|
|
1204
|
+
applyProfileCredentialsOverlay(dockerEnv, args.profileCredentials, false);
|
|
1205
|
+
if (args.profileCredsFile) {
|
|
1206
|
+
dockerEnv["AWS_SHARED_CREDENTIALS_FILE"] = args.profileCredsFile.containerPath;
|
|
1207
|
+
dockerEnv["AWS_PROFILE"] = args.profileCredsFile.profileName;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Resolve the role ARN to assume, honoring the three `--assume-role` forms.
|
|
1213
|
+
* Bare `--assume-role` uses the runtime's literal `RoleArn`; when that is an
|
|
1214
|
+
* intrinsic (the common L2 case — `Fn::GetAtt` to an auto-created role) it
|
|
1215
|
+
* resolves the execution-role ARN from `--from-cfn-stack` state, and only
|
|
1216
|
+
* warns + falls back to dev creds when neither is available.
|
|
1217
|
+
*/
|
|
1218
|
+
function resolveAssumeRoleArn(options, resolved, loaded) {
|
|
1219
|
+
if (typeof options.assumeRole === "string") return options.assumeRole;
|
|
1220
|
+
if (options.assumeRole === true) {
|
|
1221
|
+
if (resolved.roleArn) return resolved.roleArn;
|
|
1222
|
+
if (loaded) {
|
|
1223
|
+
const fromState = resolveExecutionRoleArnFromState(loaded, resolved.logicalId, "RoleArn");
|
|
1224
|
+
if (fromState) {
|
|
1225
|
+
getLogger().debug(`--assume-role: resolved RoleArn from state: ${fromState}`);
|
|
1226
|
+
return fromState;
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
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.");
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
function emitResult(result) {
|
|
1233
|
+
const logger = getLogger();
|
|
1234
|
+
if (result.status >= 400) {
|
|
1235
|
+
logger.warn(`Agent /invocations returned HTTP ${result.status}.`);
|
|
1236
|
+
process.exitCode = 1;
|
|
1237
|
+
}
|
|
1238
|
+
if (result.streamed) {
|
|
1239
|
+
process.stdout.write("\n");
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
process.stdout.write(`${result.raw}\n`);
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Finish a `/ws` exchange: the frames were already streamed to stdout via the
|
|
1246
|
+
* onMessage sink, so just terminate with a newline (so the shell prompt resumes
|
|
1247
|
+
* cleanly) and note the frame count at debug level.
|
|
1248
|
+
*/
|
|
1249
|
+
function emitWsResult(result) {
|
|
1250
|
+
process.stdout.write("\n");
|
|
1251
|
+
getLogger().debug(`Agent /ws closed after ${result.frames} frame(s).`);
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Build the JSON-RPC request to send to an MCP runtime from `--event`:
|
|
1255
|
+
* - no `--event` (empty object) → `tools/list` (discover the server's tools),
|
|
1256
|
+
* - an object with a string `method` → that method + its `params`,
|
|
1257
|
+
* - anything else → a fail-fast error.
|
|
1258
|
+
*
|
|
1259
|
+
* Exported for unit testing.
|
|
1260
|
+
*/
|
|
1261
|
+
function buildMcpRequest(event) {
|
|
1262
|
+
if (event === void 0 || event === null) return {
|
|
1263
|
+
method: "tools/list",
|
|
1264
|
+
params: {}
|
|
1265
|
+
};
|
|
1266
|
+
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");
|
|
1267
|
+
const obj = event;
|
|
1268
|
+
if (Object.keys(obj).length === 0) return {
|
|
1269
|
+
method: "tools/list",
|
|
1270
|
+
params: {}
|
|
1271
|
+
};
|
|
1272
|
+
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");
|
|
1273
|
+
return {
|
|
1274
|
+
method: obj["method"],
|
|
1275
|
+
...obj["params"] !== void 0 && { params: obj["params"] }
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
/** Print the MCP JSON-RPC response; exit 1 when it carried a JSON-RPC error. */
|
|
1279
|
+
function emitMcpResult(result) {
|
|
1280
|
+
if (!result.ok) {
|
|
1281
|
+
getLogger().warn("MCP server returned a JSON-RPC error.");
|
|
1282
|
+
process.exitCode = 1;
|
|
1283
|
+
}
|
|
1284
|
+
process.stdout.write(`${result.raw}\n`);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Build the JSON-RPC request to send to an A2A runtime from `--event`:
|
|
1288
|
+
* - no `--event` (empty object) → `agent/getCard` (discover the agent's card),
|
|
1289
|
+
* - an object with a string `method` → that method + its `params`,
|
|
1290
|
+
* - anything else → a fail-fast error.
|
|
1291
|
+
*
|
|
1292
|
+
* Exported for unit testing.
|
|
1293
|
+
*/
|
|
1294
|
+
function buildA2aRequest(event) {
|
|
1295
|
+
if (event === void 0 || event === null) return {
|
|
1296
|
+
method: "agent/getCard",
|
|
1297
|
+
params: {}
|
|
1298
|
+
};
|
|
1299
|
+
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");
|
|
1300
|
+
const obj = event;
|
|
1301
|
+
if (Object.keys(obj).length === 0) return {
|
|
1302
|
+
method: "agent/getCard",
|
|
1303
|
+
params: {}
|
|
1304
|
+
};
|
|
1305
|
+
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");
|
|
1306
|
+
return {
|
|
1307
|
+
method: obj["method"],
|
|
1308
|
+
...obj["params"] !== void 0 && { params: obj["params"] }
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
/** Print the A2A JSON-RPC response; exit 1 when it carried a JSON-RPC error. */
|
|
1312
|
+
function emitA2aResult(result) {
|
|
1313
|
+
if (!result.ok) {
|
|
1314
|
+
getLogger().warn("A2A server returned a JSON-RPC error.");
|
|
1315
|
+
process.exitCode = 1;
|
|
1316
|
+
}
|
|
1317
|
+
process.stdout.write(`${result.raw}\n`);
|
|
1318
|
+
}
|
|
1319
|
+
/** Map a `--platform` value to the architecture `buildContainerImage` expects. */
|
|
1320
|
+
function platformToArchitecture(platform) {
|
|
1321
|
+
return platform === "linux/amd64" ? "x86_64" : "arm64";
|
|
1322
|
+
}
|
|
1323
|
+
function forwardAwsEnv(env) {
|
|
1324
|
+
for (const key of [
|
|
1325
|
+
"AWS_ACCESS_KEY_ID",
|
|
1326
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
1327
|
+
"AWS_SESSION_TOKEN",
|
|
1328
|
+
"AWS_REGION",
|
|
1329
|
+
"AWS_DEFAULT_REGION"
|
|
1330
|
+
]) {
|
|
1331
|
+
const value = process.env[key];
|
|
1332
|
+
if (value !== void 0) env[key] = value;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
async function assumeAgentCoreExecutionRole(roleArn, region) {
|
|
1336
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
1337
|
+
const sts = new STSClient({ ...region && { region } });
|
|
1338
|
+
try {
|
|
1339
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
1340
|
+
RoleArn: roleArn,
|
|
1341
|
+
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-invoke-agentcore-${Date.now()}`,
|
|
1342
|
+
DurationSeconds: 3600
|
|
1343
|
+
}))).Credentials;
|
|
1344
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
1345
|
+
return {
|
|
1346
|
+
accessKeyId: creds.AccessKeyId,
|
|
1347
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
1348
|
+
sessionToken: creds.SessionToken
|
|
1349
|
+
};
|
|
1350
|
+
} finally {
|
|
1351
|
+
sts.destroy();
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
async function readEvent(options) {
|
|
1355
|
+
if (options.event && options.eventStdin) throw new Error("--event and --event-stdin are mutually exclusive.");
|
|
1356
|
+
if (options.eventStdin) return parseEvent(await readStdin(), "<stdin>");
|
|
1357
|
+
if (options.event) {
|
|
1358
|
+
let raw;
|
|
1359
|
+
try {
|
|
1360
|
+
raw = readFileSync(options.event, "utf-8");
|
|
1361
|
+
} catch (err) {
|
|
1362
|
+
throw new Error(`Failed to read --event file '${options.event}': ${err instanceof Error ? err.message : String(err)}`);
|
|
1363
|
+
}
|
|
1364
|
+
return parseEvent(raw, options.event);
|
|
1365
|
+
}
|
|
1366
|
+
return {};
|
|
1367
|
+
}
|
|
1368
|
+
function parseEvent(raw, source) {
|
|
1369
|
+
try {
|
|
1370
|
+
return JSON.parse(raw);
|
|
1371
|
+
} catch (err) {
|
|
1372
|
+
throw new Error(`Failed to parse event payload from ${source} as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
async function readStdin() {
|
|
1376
|
+
const chunks = [];
|
|
1377
|
+
for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
1378
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Read `process.stdin` line-buffered and yield each line as a string (trailing
|
|
1382
|
+
* `\r?\n` stripped). The async iterable completes when stdin EOFs (Ctrl-D /
|
|
1383
|
+
* end-of-pipe), and surrenders the underlying stream when its `return()` is
|
|
1384
|
+
* called — so the WS client can close down the source when the server closes
|
|
1385
|
+
* first without leaving stdin held open.
|
|
1386
|
+
*
|
|
1387
|
+
* Exported so a unit test can drive the iterable shape directly.
|
|
1388
|
+
*/
|
|
1389
|
+
async function* readStdinLines() {
|
|
1390
|
+
const { createInterface } = await import("node:readline");
|
|
1391
|
+
const rl = createInterface({
|
|
1392
|
+
input: process.stdin,
|
|
1393
|
+
crlfDelay: Infinity
|
|
1394
|
+
});
|
|
1395
|
+
try {
|
|
1396
|
+
for await (const line of rl) yield line;
|
|
1397
|
+
} finally {
|
|
1398
|
+
rl.close();
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
function readEnvOverridesFile$1(filePath) {
|
|
1402
|
+
if (!filePath) return void 0;
|
|
1403
|
+
let raw;
|
|
1404
|
+
try {
|
|
1405
|
+
raw = readFileSync(filePath, "utf-8");
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
throw new Error(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
1408
|
+
}
|
|
1409
|
+
let parsed;
|
|
1410
|
+
try {
|
|
1411
|
+
parsed = JSON.parse(raw);
|
|
1412
|
+
} catch (err) {
|
|
1413
|
+
throw new Error(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
1414
|
+
}
|
|
1415
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
1416
|
+
return parsed;
|
|
1417
|
+
}
|
|
1418
|
+
function createLocalInvokeAgentCoreCommand(opts = {}) {
|
|
1419
|
+
setEmbedConfig(opts.embedConfig);
|
|
1420
|
+
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)").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.")).action(withErrorHandling(async (target, options) => {
|
|
1421
|
+
await localInvokeAgentCoreCommand(target, options, opts.extraStateProviders);
|
|
1422
|
+
}));
|
|
1423
|
+
[
|
|
1424
|
+
...commonOptions(),
|
|
1425
|
+
...appOptions(),
|
|
1426
|
+
...contextOptions
|
|
1427
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
1428
|
+
cmd.addOption(deprecatedRegionOption);
|
|
1429
|
+
return cmd;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
//#endregion
|
|
1433
|
+
//#region src/cli/commands/local-run-task.ts
|
|
1434
|
+
/**
|
|
1435
|
+
* `cdkl run-task <target>` — Phase 1 of the ECS local-execution
|
|
1436
|
+
* trilogy. Synthesizes the CDK app, locates the target
|
|
1437
|
+
* `AWS::ECS::TaskDefinition`, stands up a per-task docker network with
|
|
1438
|
+
* the AWS-published `amazon-ecs-local-container-endpoints` sidecar, and
|
|
1439
|
+
* starts every container in `dependsOn` order. The essential
|
|
1440
|
+
* container's exit code drives the CLI's exit.
|
|
1441
|
+
*/
|
|
1442
|
+
async function localRunTaskCommand(target, options, extraStateProviders) {
|
|
1443
|
+
const logger = getLogger();
|
|
1444
|
+
if (options.verbose) logger.setLevel("debug");
|
|
1445
|
+
warnIfDeprecatedRegion(options);
|
|
1446
|
+
const state = createEcsRunState();
|
|
1447
|
+
let sigintHandler;
|
|
1448
|
+
let sigintCount = 0;
|
|
1449
|
+
let stateProvider;
|
|
1450
|
+
let profileCredsFile;
|
|
1451
|
+
let cleanupPromise;
|
|
1452
|
+
const cleanup = async () => {
|
|
1453
|
+
if (!cleanupPromise) cleanupPromise = (async () => {
|
|
1454
|
+
try {
|
|
1455
|
+
await cleanupEcsRun(state, { keepRunning: options.keepRunning });
|
|
1456
|
+
} catch (err) {
|
|
1457
|
+
getLogger().debug(`cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1458
|
+
}
|
|
1459
|
+
if (profileCredsFile) try {
|
|
1460
|
+
await profileCredsFile.dispose();
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
getLogger().debug(`Failed to remove profile credentials tmpdir ${profileCredsFile.hostPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1463
|
+
}
|
|
1464
|
+
})();
|
|
1465
|
+
await cleanupPromise;
|
|
1466
|
+
};
|
|
1467
|
+
try {
|
|
1468
|
+
await applyRoleArnIfSet({
|
|
1469
|
+
roleArn: options.roleArn,
|
|
1470
|
+
region: options.region
|
|
1471
|
+
});
|
|
1472
|
+
await ensureDockerAvailable();
|
|
1473
|
+
const appCmd = resolveApp(options.app);
|
|
1474
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
1475
|
+
logger.info("Synthesizing CDK app...");
|
|
1476
|
+
const synthesizer = new Synthesizer();
|
|
1477
|
+
const context = parseContextOptions(options.context);
|
|
1478
|
+
const synthOpts = {
|
|
1479
|
+
app: appCmd,
|
|
1480
|
+
output: options.output,
|
|
1481
|
+
...options.region && { region: options.region },
|
|
1482
|
+
...options.profile && { profile: options.profile },
|
|
1483
|
+
...Object.keys(context).length > 0 && { context }
|
|
1484
|
+
};
|
|
1485
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
1486
|
+
const resolvedTarget = await resolveSingleTarget(target, {
|
|
1487
|
+
entries: listTargets(stacks).ecsTaskDefinitions,
|
|
1488
|
+
message: "Select an ECS task definition to run",
|
|
1489
|
+
noun: "ECS task definitions",
|
|
1490
|
+
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")
|
|
1491
|
+
});
|
|
1492
|
+
const candidate = pickCandidateStack(parseEcsTarget(resolvedTarget).stackPattern, stacks);
|
|
1493
|
+
stateProvider = createLocalStateProvider(options, candidate?.stackName ?? "", await resolveCfnFallbackRegion(options, candidate?.region), extraStateProviders);
|
|
1494
|
+
const imageContext = await buildEcsImageResolutionContext(candidate, stateProvider, options);
|
|
1495
|
+
const task = resolveEcsTaskTarget(resolvedTarget, stacks, imageContext);
|
|
1496
|
+
logger.info(`Target: ${task.stack.stackName}/${task.taskDefinitionLogicalId} (family=${task.family}, containers=${task.containers.length})`);
|
|
1497
|
+
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === task.stack.stackName) ?? task.stack);
|
|
1498
|
+
if (stateProvider && taskNeeds.needsCrossStackResolver) {
|
|
1499
|
+
const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? task.stack.region ?? "us-east-1";
|
|
1500
|
+
const resolver = await stateProvider.buildCrossStackResolver(consumerRegion);
|
|
1501
|
+
if (resolver) await applyCrossStackResolverToTask(task, {
|
|
1502
|
+
resources: imageContext?.stateResources ?? {},
|
|
1503
|
+
...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
|
|
1504
|
+
...imageContext?.stateParameters && { parameters: imageContext.stateParameters },
|
|
1505
|
+
...imageContext?.stateSensitiveParameters?.length && { sensitiveParameters: new Set(imageContext.stateSensitiveParameters) },
|
|
1506
|
+
consumerRegion,
|
|
1507
|
+
crossStackResolver: resolver
|
|
1508
|
+
});
|
|
1509
|
+
} 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.");
|
|
1510
|
+
sigintHandler = () => {
|
|
1511
|
+
sigintCount += 1;
|
|
1512
|
+
if (sigintCount >= 2) {
|
|
1513
|
+
process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
|
|
1514
|
+
process.exit(130);
|
|
1515
|
+
}
|
|
1516
|
+
logger.info("Stopping task...");
|
|
1517
|
+
cleanup().then(() => process.exit(130));
|
|
1518
|
+
};
|
|
1519
|
+
process.on("SIGINT", sigintHandler);
|
|
1520
|
+
let assumedCredentials;
|
|
1521
|
+
let resolvedRoleArn;
|
|
1522
|
+
if (options.assumeTaskRole === true) {
|
|
1523
|
+
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>`);
|
|
1524
|
+
resolvedRoleArn = await resolvePlaceholderAccount(task.taskRoleArn, options.region);
|
|
1525
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
1526
|
+
} else if (typeof options.assumeTaskRole === "string") {
|
|
1527
|
+
resolvedRoleArn = options.assumeTaskRole;
|
|
1528
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
1529
|
+
}
|
|
1530
|
+
const sidecarCredentials = await resolveSidecarCredentials(options, assumedCredentials);
|
|
1531
|
+
if (options.profile && sidecarCredentials && !assumedCredentials) profileCredsFile = await writeProfileCredentialsFile(options.profile, sidecarCredentials);
|
|
1532
|
+
const envOverrides = readEnvOverridesFile(options.envVars);
|
|
1533
|
+
const runOpts = {
|
|
1534
|
+
cluster: options.cluster,
|
|
1535
|
+
containerHost: options.containerHost,
|
|
1536
|
+
skipPull: options.pull === false,
|
|
1537
|
+
keepRunning: options.keepRunning,
|
|
1538
|
+
detach: options.detach
|
|
1539
|
+
};
|
|
1540
|
+
if (envOverrides) runOpts.envOverrides = envOverrides;
|
|
1541
|
+
if (sidecarCredentials) runOpts.taskCredentials = sidecarCredentials;
|
|
1542
|
+
if (resolvedRoleArn) runOpts.taskRoleArn = resolvedRoleArn;
|
|
1543
|
+
if (options.platform) runOpts.platformOverride = options.platform;
|
|
1544
|
+
if (options.region) runOpts.region = options.region;
|
|
1545
|
+
if (options.ecrRoleArn) runOpts.ecrRoleArn = options.ecrRoleArn;
|
|
1546
|
+
if (options.profile) runOpts.profile = options.profile;
|
|
1547
|
+
const hostPortOverrides = parseHostPortOverrides(options.hostPort);
|
|
1548
|
+
if (Object.keys(hostPortOverrides).length > 0) runOpts.hostPortOverrides = hostPortOverrides;
|
|
1549
|
+
if (profileCredsFile) runOpts.profileCredentialsFile = {
|
|
1550
|
+
hostPath: profileCredsFile.hostPath,
|
|
1551
|
+
containerPath: profileCredsFile.containerPath,
|
|
1552
|
+
profileName: profileCredsFile.profileName
|
|
1553
|
+
};
|
|
1554
|
+
const result = await runEcsTask(task, runOpts, state);
|
|
1555
|
+
if (options.detach) {
|
|
1556
|
+
logger.info(`Task containers started in detached mode; ${getEmbedConfig().binaryName} is exiting.`);
|
|
1557
|
+
logger.info(`Use 'docker ps --filter network=${result.state.network?.networkName ?? "<network>"}' to inspect; tear down with 'docker rm -f' and 'docker network rm'.`);
|
|
1558
|
+
sigintCount = 99;
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
if (result.essentialContainerName) logger.info(`Essential container '${result.essentialContainerName}' exited with code ${result.exitCode}.`);
|
|
1562
|
+
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
1563
|
+
} finally {
|
|
1564
|
+
if (sigintHandler) process.off("SIGINT", sigintHandler);
|
|
1565
|
+
if (stateProvider) stateProvider.dispose();
|
|
1566
|
+
if (!options.detach) await cleanup();
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* If `arn` contains the `${AWS::AccountId}` placeholder emitted by the
|
|
1571
|
+
* resolver for inline same-stack IAM Roles, substitute the live caller
|
|
1572
|
+
* account via STS `GetCallerIdentity`. Otherwise pass through unchanged.
|
|
1573
|
+
*/
|
|
1574
|
+
async function resolvePlaceholderAccount(arn, region) {
|
|
1575
|
+
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
1576
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
1577
|
+
const sts = new STSClient({ ...region && { region } });
|
|
1578
|
+
try {
|
|
1579
|
+
const account = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
1580
|
+
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>`);
|
|
1581
|
+
return arn.split(TASK_ROLE_ACCOUNT_PLACEHOLDER).join(account);
|
|
1582
|
+
} finally {
|
|
1583
|
+
sts.destroy();
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Assume `roleArn` and return temp credentials.
|
|
1588
|
+
*/
|
|
1589
|
+
async function assumeTaskRole(roleArn, region) {
|
|
1590
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
1591
|
+
const sts = new STSClient({ ...region && { region } });
|
|
1592
|
+
try {
|
|
1593
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
1594
|
+
RoleArn: roleArn,
|
|
1595
|
+
RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-run-task-${Date.now()}`,
|
|
1596
|
+
DurationSeconds: 3600
|
|
1597
|
+
}))).Credentials;
|
|
1598
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new Error(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
1599
|
+
return {
|
|
1600
|
+
accessKeyId: creds.AccessKeyId,
|
|
1601
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
1602
|
+
sessionToken: creds.SessionToken
|
|
1603
|
+
};
|
|
1604
|
+
} finally {
|
|
1605
|
+
sts.destroy();
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Build the substitution context the ECS task resolver consumes.
|
|
1610
|
+
* Returns `undefined` when no container's `Image` field needs
|
|
1611
|
+
* substitution — the resolver behaves as before in that case.
|
|
1612
|
+
*/
|
|
1613
|
+
async function buildEcsImageResolutionContext(candidate, stateProvider, options) {
|
|
1614
|
+
const logger = getLogger();
|
|
1615
|
+
if (!candidate) return void 0;
|
|
1616
|
+
const needs = detectEcsImageResolutionNeeds(candidate);
|
|
1617
|
+
if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
|
|
1618
|
+
const ctx = {};
|
|
1619
|
+
const wantsPseudoForEnvOrSecret = !!stateProvider && needs.needsEnvOrSecretSubstitution;
|
|
1620
|
+
if (needs.needsPseudoParameters || wantsPseudoForEnvOrSecret) {
|
|
1621
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
|
|
1622
|
+
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.`);
|
|
1623
|
+
let accountId;
|
|
1624
|
+
try {
|
|
1625
|
+
accountId = await resolveCallerAccountId(region, options.profile);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
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.`);
|
|
1628
|
+
}
|
|
1629
|
+
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
1630
|
+
ctx.pseudoParameters = {
|
|
1631
|
+
...accountId !== void 0 && { accountId },
|
|
1632
|
+
...region !== void 0 && { region },
|
|
1633
|
+
...partitionAndSuffix && {
|
|
1634
|
+
partition: partitionAndSuffix.partition,
|
|
1635
|
+
urlSuffix: partitionAndSuffix.urlSuffix
|
|
1636
|
+
}
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
const wantsState = needs.needsStateResources || needs.needsEnvOrSecretSubstitution;
|
|
1640
|
+
if (stateProvider && wantsState) {
|
|
1641
|
+
const loaded = await stateProvider.load(candidate.stackName, candidate.region);
|
|
1642
|
+
if (loaded) ctx.stateResources = loaded.resources;
|
|
1643
|
+
if (needs.needsEnvOrSecretSubstitution && stateProvider.resolveTemplateSsmParameters) {
|
|
1644
|
+
const ssmParameters = await stateProvider.resolveTemplateSsmParameters(candidate.template);
|
|
1645
|
+
if (Object.keys(ssmParameters.values).length > 0) ctx.stateParameters = ssmParameters.values;
|
|
1646
|
+
if (ssmParameters.secureStringLogicalIds.length > 0) ctx.stateSensitiveParameters = ssmParameters.secureStringLogicalIds;
|
|
1647
|
+
}
|
|
1648
|
+
} 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.");
|
|
1649
|
+
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).");
|
|
1650
|
+
return ctx;
|
|
1651
|
+
}
|
|
1652
|
+
function pickCandidateStack(stackPattern, stacks) {
|
|
1653
|
+
if (stackPattern === null) {
|
|
1654
|
+
if (stacks.length === 1) return stacks[0];
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1657
|
+
const matched = matchStacks(stacks, [stackPattern]);
|
|
1658
|
+
if (matched.length === 1) return matched[0];
|
|
1659
|
+
}
|
|
1660
|
+
async function resolveCallerAccountId(region, profile) {
|
|
1661
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
1662
|
+
const sts = new STSClient({
|
|
1663
|
+
...region && { region },
|
|
1664
|
+
...profile && { profile }
|
|
1665
|
+
});
|
|
1666
|
+
try {
|
|
1667
|
+
return (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
1668
|
+
} finally {
|
|
1669
|
+
sts.destroy();
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Read the `--env-vars` JSON file using the same SAM-style shape as
|
|
1674
|
+
* `cdkl invoke --env-vars`: top-level keys are container names, with
|
|
1675
|
+
* `Parameters` reserved for global entries.
|
|
1676
|
+
*/
|
|
1677
|
+
function readEnvOverridesFile(filePath) {
|
|
1678
|
+
if (!filePath) return void 0;
|
|
1679
|
+
let raw;
|
|
1680
|
+
try {
|
|
1681
|
+
raw = readFileSync(filePath, "utf-8");
|
|
1682
|
+
} catch (err) {
|
|
1683
|
+
throw new Error(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
1684
|
+
}
|
|
1685
|
+
let parsed;
|
|
1686
|
+
try {
|
|
1687
|
+
parsed = JSON.parse(raw);
|
|
1688
|
+
} catch (err) {
|
|
1689
|
+
throw new Error(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
1690
|
+
}
|
|
1691
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
1692
|
+
return parsed;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Pick the credentials forwarded to the AWS-published
|
|
1696
|
+
* `amazon-ecs-local-container-endpoints` sidecar. Precedence:
|
|
1697
|
+
* 1. `--assume-task-role <arn>` (or bare `--assume-task-role` against
|
|
1698
|
+
* a resolvable `TaskRoleArn`) → STS-assumed temp creds. Highest
|
|
1699
|
+
* priority — when the user opted in to IAM emulation, those creds
|
|
1700
|
+
* drive the sidecar regardless of `--profile`.
|
|
1701
|
+
* 2. `--profile <p>` → resolved via {@link resolveProfileCredentials}
|
|
1702
|
+
* (the SDK's default credential provider chain — SSO / IAM
|
|
1703
|
+
* Identity Center / fromIni / role-assumption). NEW in this PR.
|
|
1704
|
+
* 3. Neither set → `undefined`; the sidecar runs with its own
|
|
1705
|
+
* default credential chain (typically empty inside a fresh
|
|
1706
|
+
* container — user containers will get 4xx from the credentials
|
|
1707
|
+
* endpoint, mimicking IAM-misconfigured prod).
|
|
1708
|
+
*
|
|
1709
|
+
* Extracted as an exported helper so a unit test can exercise every
|
|
1710
|
+
* branch without having to mock the full Synth + Docker + AWS pipeline
|
|
1711
|
+
* (the strategy used for the Lambda container path).
|
|
1712
|
+
*/
|
|
1713
|
+
async function resolveSidecarCredentials(options, assumedCredentials) {
|
|
1714
|
+
if (assumedCredentials) return assumedCredentials;
|
|
1715
|
+
if (options.profile) return resolveProfileCredentials$1(options.profile);
|
|
1716
|
+
}
|
|
1717
|
+
function createLocalRunTaskCommand(opts = {}) {
|
|
1718
|
+
setEmbedConfig(opts.embedConfig);
|
|
1719
|
+
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)").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.")).action(withErrorHandling(async (target, options) => {
|
|
1720
|
+
await localRunTaskCommand(target, options, opts.extraStateProviders);
|
|
1721
|
+
}));
|
|
1722
|
+
[
|
|
1723
|
+
...commonOptions(),
|
|
1724
|
+
...appOptions(),
|
|
1725
|
+
...contextOptions
|
|
1726
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
1727
|
+
cmd.addOption(deprecatedRegionOption);
|
|
1728
|
+
return cmd;
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
//#endregion
|
|
1732
|
+
//#region src/cli/commands/local-start-service.ts
|
|
1733
|
+
/**
|
|
1734
|
+
* `cdkl start-service` strategy — a pure ECS replica runner. It picks
|
|
1735
|
+
* `AWS::ECS::Service` targets and boots each with NO front-door (the ALB
|
|
1736
|
+
* front-door is its own command, `cdkl start-alb`). This keeps `start-service`
|
|
1737
|
+
* a leaf compute runner, symmetric with `invoke` / `run-task`.
|
|
1738
|
+
*/
|
|
1739
|
+
function serviceStrategy() {
|
|
1740
|
+
return {
|
|
1741
|
+
pickEntries: (stacks) => listTargets(stacks).ecsServices,
|
|
1742
|
+
pickerMessage: "Select one or more ECS services to run",
|
|
1743
|
+
pickerNoun: "ECS services",
|
|
1744
|
+
onMissing: () => new LocalStartServiceError(`${getEmbedConfig().cliName} start-service requires at least one <target>. Pass one or more service paths like 'Stack/Orders' 'Stack/Frontend', or run it in a TTY to pick interactively.`),
|
|
1745
|
+
resolveBoots: (_stacks, chosenTargets) => ({
|
|
1746
|
+
boots: chosenTargets.map((target) => ({ target })),
|
|
1747
|
+
warnings: []
|
|
1748
|
+
}),
|
|
1749
|
+
lbPortOverrides: {}
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* `cdkl start-service <Stack/Service>` — Phase 2 of #262. Spins up
|
|
1754
|
+
* `DesiredCount` task replicas locally (clamped by `--max-tasks`) using the
|
|
1755
|
+
* existing `ecs-task-runner` per replica. Long-running; ^C cleans every replica
|
|
1756
|
+
* + sidecar + shared network. Pure compute: to put a local ALB front-door in
|
|
1757
|
+
* front of an ALB-fronted service, use `cdkl start-alb`.
|
|
1758
|
+
*/
|
|
1759
|
+
function createLocalStartServiceCommand(opts = {}) {
|
|
1760
|
+
setEmbedConfig(opts.embedConfig);
|
|
1761
|
+
return addCommonEcsServiceOptions(new Command("start-service").description(`Run one or more AWS::ECS::Service resources locally as a long-running emulator. Spins up DesiredCount task replicas per service (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as \`${getEmbedConfig().cliName} run-task\`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Each <target> accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix. When two or more <target>s are supplied, every service is booted into a shared Cloud Map / Service Connect registry so peer services discover each other via docker --add-host overlay. Omit <targets> in an interactive terminal to multi-select the services from a list. To put a local ALB front-door in front of an ALB-fronted service, use \`${getEmbedConfig().cliName} start-alb\` instead.`).argument("[targets...]", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ECS::Service resources to run (omit to multi-select interactively in a TTY)").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. (Single-replica services only — multi-replica services do not publish host ports.)")).action(withErrorHandling(async (targets, options) => {
|
|
1762
|
+
await runEcsServiceEmulator(targets, options, serviceStrategy(), opts.extraStateProviders);
|
|
1763
|
+
})));
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
//#endregion
|
|
1767
|
+
//#region src/cli/commands/local-start-alb.ts
|
|
1768
|
+
/**
|
|
1769
|
+
* Issue #86 v1 — parse `--lb-port <listenerPort>=<hostPort>` overrides into a
|
|
1770
|
+
* `listenerPort -> hostPort` map. The local ALB front-door binds the listener
|
|
1771
|
+
* port on the host by default, but a privileged listener port (e.g. 80 / 443)
|
|
1772
|
+
* fails to bind as non-root on macOS, so the user opts in to a non-privileged
|
|
1773
|
+
* host port (e.g. `--lb-port 80=8080`). Repeatable; each value is
|
|
1774
|
+
* `<listenerPort>=<hostPort>` with both in 1-65535.
|
|
1775
|
+
*/
|
|
1776
|
+
function parseLbPortOverrides(values) {
|
|
1777
|
+
const out = {};
|
|
1778
|
+
for (const raw of values ?? []) {
|
|
1779
|
+
const m = /^(\d+)=(\d+)$/.exec(raw.trim());
|
|
1780
|
+
if (!m) throw new LocalStartServiceError(`Invalid --lb-port '${raw}'. Expected <listenerPort>=<hostPort> (e.g. 80=8080).`);
|
|
1781
|
+
const listenerPort = Number(m[1]);
|
|
1782
|
+
const hostPort = Number(m[2]);
|
|
1783
|
+
for (const [label, p] of [["listener", listenerPort], ["host", hostPort]]) if (p < 1 || p > 65535) throw new LocalStartServiceError(`Invalid --lb-port '${raw}': ${label} port must be 1-65535.`);
|
|
1784
|
+
out[listenerPort] = hostPort;
|
|
1785
|
+
}
|
|
1786
|
+
return out;
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Resolve an ALB target string (`Stack/Path` display path or `Stack:LogicalId`)
|
|
1790
|
+
* to its stack + `AWS::ElasticLoadBalancingV2::LoadBalancer` logical id. Mirrors
|
|
1791
|
+
* the ECS service resolver's target grammar.
|
|
1792
|
+
*/
|
|
1793
|
+
function resolveAlbTarget(target, stacks) {
|
|
1794
|
+
if (stacks.length === 0) throw new LocalStartServiceError("No stacks found in the synthesized assembly.");
|
|
1795
|
+
const parsed = parseEcsTarget(target);
|
|
1796
|
+
const stack = pickStack(parsed.stackPattern, stacks, target);
|
|
1797
|
+
const resources = stack.template.Resources ?? {};
|
|
1798
|
+
if (parsed.isPath) {
|
|
1799
|
+
const index = buildCdkPathIndex(stack.template);
|
|
1800
|
+
const albs = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId }) => {
|
|
1801
|
+
const r = resources[logicalId];
|
|
1802
|
+
return r !== void 0 && isApplicationLoadBalancer(r);
|
|
1803
|
+
});
|
|
1804
|
+
if (albs.length === 0) throw notFound(target, stack, resources);
|
|
1805
|
+
if (albs.length > 1) throw new LocalStartServiceError(`Target '${target}' matches ${albs.length} load balancers in ${stack.stackName}: ${albs.map((a) => a.logicalId).join(", ")}. Refine the path or use the stack:LogicalId form.`);
|
|
1806
|
+
return {
|
|
1807
|
+
stack,
|
|
1808
|
+
albLogicalId: albs[0].logicalId
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
const res = resources[parsed.pathOrId];
|
|
1812
|
+
if (!res || !isApplicationLoadBalancer(res)) throw notFound(target, stack, resources);
|
|
1813
|
+
return {
|
|
1814
|
+
stack,
|
|
1815
|
+
albLogicalId: parsed.pathOrId
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
function pickStack(stackPattern, stacks, target) {
|
|
1819
|
+
if (stackPattern === null) {
|
|
1820
|
+
if (stacks.length === 1) return stacks[0];
|
|
1821
|
+
throw new LocalStartServiceError(`Target '${target}' has no stack prefix, and the assembly contains ${stacks.length} stacks: ${stacks.map((s) => s.stackName).join(", ")}. Pass it as 'Stack/Path' or 'Stack:LogicalId'.`);
|
|
1822
|
+
}
|
|
1823
|
+
const matched = matchStacks(stacks, [stackPattern]);
|
|
1824
|
+
if (matched.length === 0) throw new LocalStartServiceError(`No stack matches '${stackPattern}'. Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
|
|
1825
|
+
if (matched.length > 1) throw new LocalStartServiceError(`Multiple stacks match '${stackPattern}': ${matched.map((s) => s.stackName).join(", ")}. Refine the pattern.`);
|
|
1826
|
+
return matched[0];
|
|
1827
|
+
}
|
|
1828
|
+
function notFound(target, stack, resources) {
|
|
1829
|
+
const albs = Object.entries(resources).filter(([, r]) => r.Type === "AWS::ElasticLoadBalancingV2::LoadBalancer").map(([logicalId]) => logicalId);
|
|
1830
|
+
const available = albs.length > 0 ? ` Available load balancers in ${stack.stackName}: ${albs.join(", ")}.` : ` ${stack.stackName} declares no AWS::ElasticLoadBalancingV2::LoadBalancer resources.`;
|
|
1831
|
+
return new LocalStartServiceError(`Target '${target}' did not match an application Load Balancer in ${stack.stackName}.${available}`);
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* `cdkl start-alb` strategy — name the ALB, boot the ECS service(s) behind it,
|
|
1835
|
+
* and expose each listener via a local front-door. Mirrors how `start-api`
|
|
1836
|
+
* names the API and serves its backing Lambdas.
|
|
1837
|
+
*/
|
|
1838
|
+
function albStrategy(options) {
|
|
1839
|
+
const lbPortOverrides = parseLbPortOverrides(options.lbPort);
|
|
1840
|
+
return {
|
|
1841
|
+
pickEntries: (stacks) => listTargets(stacks).loadBalancers,
|
|
1842
|
+
pickerMessage: "Select one or more Application Load Balancers to run",
|
|
1843
|
+
pickerNoun: "Application Load Balancers",
|
|
1844
|
+
onMissing: () => new LocalStartServiceError(`${getEmbedConfig().cliName} start-alb requires at least one <target>. Pass one or more ALB paths like 'Stack/MyAlb', or run it in a TTY to pick interactively.`),
|
|
1845
|
+
resolveBoots: (stacks, chosenTargets) => {
|
|
1846
|
+
const warnings = [];
|
|
1847
|
+
const serviceTargets = /* @__PURE__ */ new Set();
|
|
1848
|
+
const listeners = [];
|
|
1849
|
+
const claimedHostPorts = /* @__PURE__ */ new Map();
|
|
1850
|
+
for (const albTarget of chosenTargets) {
|
|
1851
|
+
const { stack, albLogicalId } = resolveAlbTarget(albTarget, stacks);
|
|
1852
|
+
const resolution = resolveAlbFrontDoor(stack, albLogicalId);
|
|
1853
|
+
warnings.push(...resolution.warnings);
|
|
1854
|
+
const qualifyTarget = (t) => {
|
|
1855
|
+
if (t.kind === "lambda") return {
|
|
1856
|
+
kind: "lambda",
|
|
1857
|
+
lambda: resolveLambdaTarget(`${stack.stackName}:${t.lambdaLogicalId}`, stacks),
|
|
1858
|
+
targetGroupArn: `${stack.stackName}:${t.targetGroupLogicalId}`,
|
|
1859
|
+
multiValueHeaders: t.multiValueHeaders,
|
|
1860
|
+
weight: t.weight
|
|
1861
|
+
};
|
|
1862
|
+
const serviceTarget = `${stack.stackName}:${t.serviceLogicalId}`;
|
|
1863
|
+
serviceTargets.add(serviceTarget);
|
|
1864
|
+
return {
|
|
1865
|
+
kind: "ecs",
|
|
1866
|
+
serviceTarget,
|
|
1867
|
+
targetContainerName: t.targetContainerName,
|
|
1868
|
+
targetContainerPort: t.targetContainerPort,
|
|
1869
|
+
weight: t.weight
|
|
1870
|
+
};
|
|
1871
|
+
};
|
|
1872
|
+
const qualify = (action) => {
|
|
1873
|
+
if (action.kind === "forward") return {
|
|
1874
|
+
kind: "forward",
|
|
1875
|
+
targets: action.targets.map(qualifyTarget)
|
|
1876
|
+
};
|
|
1877
|
+
if (action.kind === "redirect") return {
|
|
1878
|
+
kind: "redirect",
|
|
1879
|
+
statusCode: action.statusCode,
|
|
1880
|
+
...action.protocol !== void 0 && { protocol: action.protocol },
|
|
1881
|
+
...action.host !== void 0 && { host: action.host },
|
|
1882
|
+
...action.port !== void 0 && { port: action.port },
|
|
1883
|
+
...action.path !== void 0 && { path: action.path },
|
|
1884
|
+
...action.query !== void 0 && { query: action.query }
|
|
1885
|
+
};
|
|
1886
|
+
return {
|
|
1887
|
+
kind: "fixed-response",
|
|
1888
|
+
statusCode: action.statusCode,
|
|
1889
|
+
...action.contentType !== void 0 && { contentType: action.contentType },
|
|
1890
|
+
...action.messageBody !== void 0 && { messageBody: action.messageBody }
|
|
1891
|
+
};
|
|
1892
|
+
};
|
|
1893
|
+
for (const listener of resolution.listeners) {
|
|
1894
|
+
const hostPort = lbPortOverrides[listener.listenerPort] ?? listener.listenerPort;
|
|
1895
|
+
const claimedBy = claimedHostPorts.get(hostPort);
|
|
1896
|
+
if (claimedBy !== void 0) {
|
|
1897
|
+
warnings.push(`Listener port ${listener.listenerPort} would bind host port ${hostPort}, already claimed by listener port ${claimedBy}; the local front-door fronts only the first. Use --lb-port to remap one of them.`);
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
claimedHostPorts.set(hostPort, listener.listenerPort);
|
|
1901
|
+
listeners.push({
|
|
1902
|
+
listenerPort: listener.listenerPort,
|
|
1903
|
+
hostPort,
|
|
1904
|
+
protocol: listener.listenerProtocol,
|
|
1905
|
+
...listener.defaultAction ? { defaultAction: qualify(listener.defaultAction) } : {},
|
|
1906
|
+
...listener.defaultAuthGuard ? { defaultAuthGuard: listener.defaultAuthGuard } : {},
|
|
1907
|
+
rules: listener.rules.map((r) => ({
|
|
1908
|
+
priority: r.priority,
|
|
1909
|
+
pathPatterns: r.pathPatterns,
|
|
1910
|
+
hostPatterns: r.hostPatterns,
|
|
1911
|
+
httpHeaderConditions: r.httpHeaderConditions,
|
|
1912
|
+
httpRequestMethods: r.httpRequestMethods,
|
|
1913
|
+
queryStringConditions: r.queryStringConditions,
|
|
1914
|
+
sourceIpCidrs: r.sourceIpCidrs,
|
|
1915
|
+
action: qualify(r.action),
|
|
1916
|
+
...r.authGuard ? { authGuard: r.authGuard } : {}
|
|
1917
|
+
}))
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
const boots = [...serviceTargets].map((target) => ({ target }));
|
|
1922
|
+
const resolvedPorts = new Set(listeners.map((l) => l.listenerPort));
|
|
1923
|
+
for (const portStr of Object.keys(lbPortOverrides)) {
|
|
1924
|
+
const port = Number(portStr);
|
|
1925
|
+
if (!resolvedPorts.has(port)) warnings.push(`--lb-port override for listener port ${port} matched no ALB listener resolved for the named target(s); it was ignored.`);
|
|
1926
|
+
}
|
|
1927
|
+
return {
|
|
1928
|
+
boots,
|
|
1929
|
+
...listeners.length > 0 ? { frontDoor: { listeners } } : {},
|
|
1930
|
+
warnings
|
|
1931
|
+
};
|
|
1932
|
+
},
|
|
1933
|
+
lbPortOverrides
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* `cdkl start-alb <Stack/Alb>` — Issue #86 v1. Names an
|
|
1938
|
+
* `AWS::ElasticLoadBalancingV2::LoadBalancer`, discovers the ECS service(s)
|
|
1939
|
+
* behind its HTTP `forward` listeners, boots their replicas, and stands up a
|
|
1940
|
+
* local front-door on each listener port that round-robins across the replicas.
|
|
1941
|
+
* The symmetric ALB counterpart of `start-api`.
|
|
1942
|
+
*/
|
|
1943
|
+
function createLocalStartAlbCommand(opts = {}) {
|
|
1944
|
+
setEmbedConfig(opts.embedConfig);
|
|
1945
|
+
return addCommonEcsServiceOptions(new Command("start-alb").description("Run an Application Load Balancer locally: name the ALB, and cdk-local boots the ECS service(s) behind its listeners and stands up a local front-door on each listener port that round-robins across the running replicas and routes its listener rules across the backing services — a stable host endpoint, like behind a real load balancer. The symmetric ALB counterpart of `start-api`. Each <target> accepts a CDK display path (MyStack/MyAlb) or stack-qualified logical ID; single-stack apps may omit the stack prefix. Supports HTTP and HTTPS listeners (TLS terminated locally with --tls-cert/--tls-key or an auto-generated self-signed cert); all six ALB rule-condition fields (path-pattern / host-header / http-header / http-request-method / query-string / source-ip); forward (single and weighted), redirect, and fixed-response actions; and ECS or Lambda targets (a Lambda target group is invoked locally via the Lambda RIE). authenticate-cognito / authenticate-oidc actions enforce a local Bearer-JWT check (or AWSELBAuthSessionCookie pass-through) against the same JWKS / OIDC discovery URL the deployed ALB would; use --bearer-token <jwt> to inject a default token or --no-verify-auth to disable the guard. Omit <targets> in an interactive terminal to multi-select the load balancers from a list.").argument("[targets...]", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ElasticLoadBalancingV2::LoadBalancer resources to run (omit to multi-select interactively in a TTY)").addOption(new Option("--lb-port <listenerPort=hostPort...>", "Bind the local front-door on a specific host port (e.g. 80=8080); repeatable. Default: host port == ALB listener port. Use this on macOS to remap a privileged listener port (< 1024) to a non-privileged host port.")).addOption(new Option("--tls-cert <path>", "PEM-encoded server certificate for HTTPS front-door listeners. Must be set together with --tls-key. Omit both flags to auto-generate a self-signed cert (cached under $XDG_CACHE_HOME/cdk-local/alb-https/, default ~/.cache/cdk-local/alb-https/); requires openssl on PATH. The deployed Listener Certificates[] are NOT fetched (ACM private keys are not retrievable by design). The auto-generated cert lists DNS:localhost,IP:127.0.0.1 as SubjectAltName, so a client validating a non-loopback --container-host will fail the SAN check — pass --tls-cert / --tls-key with a SAN covering that host instead.")).addOption(new Option("--tls-key <path>", "PEM-encoded server private key matching --tls-cert. Must be set together with --tls-cert.")).addOption(new Option("--no-verify-auth", "Disable local enforcement of authenticate-cognito / authenticate-oidc actions. Every request is served as if the auth check passed. Useful for local dev where you do not want to mint a Bearer token at all.")).addOption(new Option("--bearer-token <jwt>", "Default Bearer JWT injected as Authorization: Bearer <jwt> when the inbound request has none. Verified against the same JWKS / OIDC discovery URL the deployed ALB would (signature + iss + aud + exp). Local-dev convenience; cookie pass-through (AWSELBAuthSessionCookie-*) also works.")).action(withErrorHandling(async (targets, options) => {
|
|
1946
|
+
await runEcsServiceEmulator(targets, options, albStrategy(options), opts.extraStateProviders);
|
|
1947
|
+
})));
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
//#endregion
|
|
1951
|
+
//#region src/cli/commands/local-list.ts
|
|
1952
|
+
async function localListCommand(options) {
|
|
1953
|
+
const logger = getLogger();
|
|
1954
|
+
if (options.verbose) logger.setLevel("debug");
|
|
1955
|
+
warnIfDeprecatedRegion(options);
|
|
1956
|
+
await applyRoleArnIfSet({
|
|
1957
|
+
roleArn: options.roleArn,
|
|
1958
|
+
region: void 0
|
|
1959
|
+
});
|
|
1960
|
+
const appCmd = resolveApp(options.app);
|
|
1961
|
+
if (!appCmd) throw new Error(`No CDK app specified. Pass --app, set ${getEmbedConfig().envPrefix}_APP, or add "app" to cdk.json.`);
|
|
1962
|
+
process.stderr.write("Synthesizing CDK app...\n");
|
|
1963
|
+
const synthesizer = new Synthesizer();
|
|
1964
|
+
const context = parseContextOptions(options.context);
|
|
1965
|
+
const synthOpts = {
|
|
1966
|
+
app: appCmd,
|
|
1967
|
+
output: options.output,
|
|
1968
|
+
...options.profile && { profile: options.profile },
|
|
1969
|
+
...Object.keys(context).length > 0 && { context }
|
|
1970
|
+
};
|
|
1971
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
1972
|
+
const listing = listTargets(stacks);
|
|
1973
|
+
process.stdout.write(`${formatTargetListing(listing, getEmbedConfig().cliName, { long: options.long })}\n`);
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Render a {@link TargetListing} as the grouped text list `cdkl list`
|
|
1977
|
+
* prints. Each non-empty category is preceded by a blank line and a
|
|
1978
|
+
* header naming the command that runs it, then one target per line by
|
|
1979
|
+
* CDK display path. With {@link FormatTargetListingOptions.long}, each
|
|
1980
|
+
* target's stack-qualified logical ID is printed on an indented line
|
|
1981
|
+
* beneath it. Exported so a unit test can assert the output shape
|
|
1982
|
+
* without running synthesis.
|
|
1983
|
+
*/
|
|
1984
|
+
function formatTargetListing(listing, cliName, options = {}) {
|
|
1985
|
+
if (countTargets(listing) === 0) return `No runnable targets (Lambda functions, APIs, ECS services / tasks, AgentCore Runtimes, load balancers) found in this CDK app.`;
|
|
1986
|
+
const long = options.long ?? false;
|
|
1987
|
+
return "\n" + [
|
|
1988
|
+
formatSection("Lambda Functions", `${cliName} invoke <target>`, listing.lambdas, long),
|
|
1989
|
+
formatSection("APIs", `${cliName} start-api [target...]`, listing.apis, long),
|
|
1990
|
+
formatSection("ECS Services", `${cliName} start-service <target...>`, listing.ecsServices, long),
|
|
1991
|
+
formatSection("ECS Task Definitions", `${cliName} run-task <target>`, listing.ecsTaskDefinitions, long),
|
|
1992
|
+
formatSection("AgentCore Runtimes", `${cliName} invoke-agentcore <target>`, listing.agentCoreRuntimes, long),
|
|
1993
|
+
formatSection("Application Load Balancers", `${cliName} start-alb <target...>`, listing.loadBalancers, long)
|
|
1994
|
+
].filter((lines) => lines.length > 0).map((lines) => lines.join("\n")).join("\n\n");
|
|
1995
|
+
}
|
|
1996
|
+
function formatSection(title, command, entries, long) {
|
|
1997
|
+
if (entries.length === 0) return [];
|
|
1998
|
+
const lines = [`${title} -> ${command}`];
|
|
1999
|
+
for (const entry of entries) {
|
|
2000
|
+
const primary = entry.displayPath ?? entry.qualifiedId;
|
|
2001
|
+
lines.push(entry.kind ? ` ${primary} (${entry.kind})` : ` ${primary}`);
|
|
2002
|
+
if (long && entry.displayPath) lines.push(` ${entry.qualifiedId}`);
|
|
2003
|
+
}
|
|
2004
|
+
return lines;
|
|
2005
|
+
}
|
|
2006
|
+
function createLocalListCommand(opts = {}) {
|
|
2007
|
+
setEmbedConfig(opts.embedConfig);
|
|
2008
|
+
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.").addOption(new Option("-l, --long", "Also print each target's stack-qualified logical ID (<Stack>:<LogicalId>) beneath it").default(false)).action(withErrorHandling(async (options) => {
|
|
2009
|
+
await localListCommand(options);
|
|
2010
|
+
}));
|
|
2011
|
+
[
|
|
2012
|
+
...commonOptions(),
|
|
2013
|
+
...appOptions(),
|
|
2014
|
+
...contextOptions
|
|
2015
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
2016
|
+
cmd.addOption(deprecatedRegionOption);
|
|
2017
|
+
return cmd;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
//#endregion
|
|
2021
|
+
export { createLocalRunTaskCommand as a, createLocalStartServiceCommand as i, formatTargetListing as n, createLocalInvokeAgentCoreCommand as o, createLocalStartAlbCommand as r, createLocalInvokeCommand as s, createLocalListCommand as t };
|
|
2022
|
+
//# sourceMappingURL=local-list-n6I3s-DR.js.map
|