cdk-local 0.129.1 → 0.130.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.
@@ -6687,719 +6687,763 @@ function resolveRuntimeCodeMountPath(runtime) {
6687
6687
  }
6688
6688
 
6689
6689
  //#endregion
6690
- //#region src/local/docker-runner.ts
6691
- const execFileAsync$5 = promisify(execFile);
6690
+ //#region src/assets/docker-build.ts
6692
6691
  /**
6693
- * Wraps `docker pull` / `docker run` / `docker rm` for `cdkl invoke`.
6692
+ * Build a Docker image from a CDK asset source. Returns the local image
6693
+ * tag the caller should use for `docker tag` / `docker push` (publisher)
6694
+ * or `docker run` (local-invoke).
6694
6695
  *
6695
- * Mirrors the style of `src/assets/docker-asset-publisher.ts` (execFile for
6696
- * one-shot calls, spawn for long-running ones). Kept as a separate file so
6697
- * the command layer's wiring stays small; PR 5 (container Lambda) is
6698
- * expected to add a second non-build call site, at which point the
6699
- * common surface can be lifted into a shared helper.
6700
- */
6701
- var DockerRunnerError = class DockerRunnerError extends Error {
6702
- constructor(message) {
6703
- super(message);
6704
- this.name = "DockerRunnerError";
6705
- Object.setPrototypeOf(this, DockerRunnerError.prototype);
6706
- }
6707
- };
6708
- /**
6709
- * Pull the image. No-op when `skipPull` is true.
6696
+ * Two source modes (mirrors CDK CLI):
6697
+ * - `executable`: run the user-supplied command, capture stdout, return
6698
+ * it as the local tag. The script is responsible for building AND
6699
+ * tagging; cdk-local just reads the tag from stdout. Used for Bazel /
6700
+ * custom build pipelines that produce images outside `docker build`.
6701
+ * - `directory`: standard `docker build <dir>` with the full BuildKit
6702
+ * flag set described above. Caller must pass `options.tag`.
6710
6703
  *
6711
- * In verbose mode (`--verbose` / global log level `debug`), streams the
6712
- * full `docker pull` progress to stdout so the user sees per-layer
6713
- * downloads. In the default compact mode the call is silent (cached
6714
- * images are the common case; a fresh pull still shows progress only
6715
- * via `--verbose`). Errors are always surfaced: the captured stderr is
6716
- * folded into the thrown `DockerRunnerError` message.
6704
+ * `executable` takes precedence when both fields are set (matches CDK CLI).
6717
6705
  */
6718
- async function pullImage(image, skipPull, platform) {
6719
- const logger = getLogger().child("docker");
6720
- if (skipPull) {
6721
- logger.debug(`Skipping docker pull for ${image} (--no-pull)`);
6722
- return;
6723
- }
6724
- const platformArgs = platform ? ["--platform", platform] : [];
6725
- if (getLogger().getLevel() === "debug") {
6726
- logger.info(`Pulling ${image}${platform ? ` (${platform})` : ""}...`);
6706
+ async function buildDockerImage(asset, cdkOutDir, options) {
6707
+ const source = asset.source;
6708
+ const logger = getLogger().child("docker-build");
6709
+ if (source.executable && source.executable.length > 0) {
6710
+ const [cmd, ...args] = source.executable;
6711
+ if (!cmd) throw options.wrapError("asset source.executable[] is empty");
6712
+ const cwd = source.directory ? `${cdkOutDir}/${source.directory}` : cdkOutDir;
6713
+ logger.debug(`Building Docker image via executable: ${source.executable.join(" ")} (cwd=${cwd})`);
6714
+ let result;
6727
6715
  try {
6728
- await runDockerForeground([
6729
- "pull",
6730
- ...platformArgs,
6731
- image
6732
- ]);
6716
+ result = await spawnStreaming(cmd, args, { cwd });
6733
6717
  } catch (err) {
6734
- throw new DockerRunnerError(`docker pull ${image} failed: ${err.message}`);
6718
+ const e = err;
6719
+ throw options.wrapError(e.stderr || e.message || String(err));
6735
6720
  }
6736
- return;
6721
+ const tag = result.stdout.trim();
6722
+ if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${cmd} ${args.join(" ")}`);
6723
+ return tag;
6737
6724
  }
6738
- logger.debug(`Pulling ${image} (silent pass --verbose to stream progress)`);
6725
+ if (!source.directory) throw options.wrapError(`DockerImageAssetSource must set either 'directory' or 'executable' (got: ${JSON.stringify(source)})`);
6726
+ if (!options.tag) throw options.wrapError("buildDockerImage(directory mode) requires options.tag");
6727
+ const buildArgs = buildDockerBuildCommand(source, options.tag, options.platform);
6728
+ const contextDir = `${cdkOutDir}/${source.directory}`;
6729
+ buildArgs.push(".");
6730
+ logger.debug(`${getDockerCmd()} ${buildArgs.join(" ")} (cwd=${contextDir})`);
6739
6731
  try {
6740
- await runDockerStreaming([
6741
- "pull",
6742
- ...platformArgs,
6743
- image
6744
- ], {
6745
- streamLive: false,
6746
- progressLabel: `Pulling ${image}`
6732
+ await runDockerStreaming(buildArgs, {
6733
+ cwd: contextDir,
6734
+ env: { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" },
6735
+ ...options.progressLabel !== void 0 && { progressLabel: options.progressLabel }
6747
6736
  });
6748
6737
  } catch (err) {
6749
6738
  const e = err;
6750
- if (e.exitCode === void 0 || e.exitCode === null) throw new DockerRunnerError(`docker pull ${image} failed: ${e.message ?? String(err)}`);
6751
- const detail = e.stderr?.trim() || e.stdout?.trim() || "(no output)";
6752
- throw new DockerRunnerError(`docker pull ${image} exited with code ${e.exitCode}: ${detail}`);
6739
+ throw options.wrapError(e.stderr || e.message || String(err));
6753
6740
  }
6741
+ return options.tag;
6754
6742
  }
6755
6743
  /**
6756
- * Run the container detached. Returns the container ID.
6744
+ * Construct the `docker build` argv (without the trailing context directory).
6757
6745
  *
6758
- * The caller is responsible for:
6759
- * - polling `host:port` for RIE readiness,
6760
- * - issuing the invoke,
6761
- * - calling `removeContainer` from a `try`/`finally` so the container
6762
- * is cleaned up on any error path including SIGINT.
6746
+ * Exported for unit-test inspection — keeps the flag-ordering assertions
6747
+ * independent of the spawn machinery.
6763
6748
  */
6764
- async function runDetached(opts) {
6749
+ function buildDockerBuildCommand(source, tag, platformOverride) {
6765
6750
  const args = [
6766
- "run",
6767
- "-d",
6768
- "--rm"
6751
+ "build",
6752
+ "--tag",
6753
+ tag
6769
6754
  ];
6770
- if (opts.name) args.push("--name", opts.name);
6771
- if (opts.network) args.push("--network", opts.network);
6772
- if (opts.platform) args.push("--platform", opts.platform);
6773
- if (opts.extraHosts) for (const entry of opts.extraHosts) args.push("--add-host", `${entry.host}:${entry.ip}`);
6774
- const host = opts.host ?? "127.0.0.1";
6775
- args.push("-p", `${host}:${opts.hostPort}:${opts.containerPort ?? 8080}`);
6776
- if (opts.debugPort !== void 0) args.push("-p", `${host}:${opts.debugPort}:${opts.debugPort}`);
6777
- for (const mount of opts.mounts) {
6778
- const ro = mount.readOnly ? ":ro" : "";
6779
- args.push("-v", `${mount.hostPath}:${mount.containerPath}${ro}`);
6780
- }
6781
- if (opts.extraMounts) for (const mount of opts.extraMounts) {
6782
- const ro = mount.readOnly ? ":ro" : "";
6783
- args.push("-v", `${mount.hostPath}:${mount.containerPath}${ro}`);
6784
- }
6785
- const sensitiveKeys = opts.sensitiveEnvKeys && opts.sensitiveEnvKeys.size > 0 ? new Set([...SENSITIVE_ENV_KEYS, ...opts.sensitiveEnvKeys]) : SENSITIVE_ENV_KEYS;
6786
- const passthroughEnv = appendEnvFlags(args, opts.env, sensitiveKeys);
6787
- if (opts.tmpfs) args.push("--tmpfs", `${opts.tmpfs.target}:rw,size=${opts.tmpfs.sizeMb}m`);
6788
- if (opts.workingDir) args.push("--workdir", opts.workingDir);
6789
- let entryPointTail = [];
6790
- if (opts.entryPoint && opts.entryPoint.length > 0) {
6791
- args.push("--entrypoint", opts.entryPoint[0]);
6792
- entryPointTail = opts.entryPoint.slice(1);
6793
- }
6794
- args.push(opts.image, ...entryPointTail, ...opts.cmd);
6795
- getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
6796
- try {
6797
- const { stdout } = await execFileAsync$5(getDockerCmd(), args, {
6798
- maxBuffer: 10 * 1024 * 1024,
6799
- ...execEnvForSecrets(passthroughEnv)
6800
- });
6801
- return stdout.trim();
6802
- } catch (error) {
6803
- const err = error;
6804
- throw new DockerRunnerError(`docker run failed: ${err.stderr?.trim() || err.message || String(error)}`);
6805
- }
6755
+ if (source.dockerBuildArgs) for (const [k, v] of Object.entries(source.dockerBuildArgs)) args.push("--build-arg", `${k}=${v}`);
6756
+ if (source.dockerBuildContexts) for (const [k, v] of Object.entries(source.dockerBuildContexts)) args.push("--build-context", `${k}=${v}`);
6757
+ if (source.dockerBuildSecrets) for (const [k, v] of Object.entries(source.dockerBuildSecrets)) args.push("--secret", `id=${k},${v}`);
6758
+ if (source.dockerBuildSsh) args.push("--ssh", source.dockerBuildSsh);
6759
+ if (source.dockerBuildTarget) args.push("--target", source.dockerBuildTarget);
6760
+ if (source.dockerFile) args.push("--file", source.dockerFile);
6761
+ if (source.networkMode) args.push("--network", source.networkMode);
6762
+ const platform = platformOverride ?? source.platform;
6763
+ if (platform) args.push("--platform", platform);
6764
+ if (source.dockerOutputs) for (const output of source.dockerOutputs) args.push(`--output=${output}`);
6765
+ if (source.cacheFrom) for (const c of source.cacheFrom) args.push("--cache-from", cacheOptionToFlag(c));
6766
+ if (source.cacheTo) args.push("--cache-to", cacheOptionToFlag(source.cacheTo));
6767
+ if (source.cacheDisabled) args.push("--no-cache");
6768
+ return args;
6769
+ }
6770
+ function cacheOptionToFlag(option) {
6771
+ let flag = `type=${option.type}`;
6772
+ if (option.params) for (const [k, v] of Object.entries(option.params)) flag += `,${k}=${v}`;
6773
+ return flag;
6806
6774
  }
6775
+
6776
+ //#endregion
6777
+ //#region src/local/ecr-puller.ts
6807
6778
  /**
6808
- * `docker logs -f <id>` plumbed to stdout/stderr. Returns a function that
6809
- * stops the stream (used by the caller in a `finally` block).
6779
+ * ECR pull fallback for `cdkl invoke` / `cdkl start-api` /
6780
+ * `cdkl run-task`. When the image URI resolves to an ECR repo but
6781
+ * doesn't match any cdk.out asset (typical when invoking a stack
6782
+ * deployed elsewhere or sharing a centralized registry), cdk-local
6783
+ * authenticates against the target registry and runs `docker pull`.
6784
+ *
6785
+ * **Cross-account / cross-region** (#455):
6786
+ * - Same-account, same-region: fast path. No STS hop. The default
6787
+ * credential chain is used directly for `ecr:GetAuthorizationToken`.
6788
+ * - `ecrRoleArn` is provided: `sts:AssumeRole` is issued via the
6789
+ * default credential chain to obtain temporary credentials for the
6790
+ * target account. The resulting credentials authenticate the ECR
6791
+ * client (regardless of region — the ECR client is built for the
6792
+ * URI's region, which can differ from the caller's profile region).
6793
+ * - Cross-account, NO `ecrRoleArn`: cdk-local falls through to the
6794
+ * default credential chain. This works when the caller has been
6795
+ * granted cross-account `ecr:GetAuthorizationToken` +
6796
+ * `ecr:BatchGetImage` permissions on the target repository via an
6797
+ * IAM policy; otherwise AWS rejects the call with `AccessDenied`
6798
+ * and the user is pointed at `--ecr-role-arn`.
6799
+ *
6800
+ * The `--no-pull` semantics (C3 in the design doc):
6801
+ * - When NOT set: `ecrLogin` + `docker pull <uri>`.
6802
+ * - When set: skip `docker pull`. If the image isn't in the local
6803
+ * cache, the subsequent `docker run` will fail; we surface a clearer
6804
+ * "image not in local cache" error here so the user knows to drop
6805
+ * `--no-pull` or pre-pull manually.
6810
6806
  */
6811
- function streamLogs(containerId) {
6812
- const proc = spawn(getDockerCmd(), [
6813
- "logs",
6814
- "-f",
6815
- containerId
6816
- ], { stdio: [
6817
- "ignore",
6818
- "pipe",
6819
- "pipe"
6820
- ] });
6821
- proc.stdout?.on("data", (chunk) => process.stdout.write(chunk));
6822
- proc.stderr?.on("data", (chunk) => process.stderr.write(chunk));
6823
- proc.on("error", () => {});
6824
- return () => {
6825
- if (!proc.killed) proc.kill("SIGTERM");
6807
+ /** Regex matching the `<acct>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>` shape. */
6808
+ const ECR_URI_REGEX = /^(\d{12})\.dkr\.ecr\.([^.]+)\.amazonaws\.com(?:\.cn)?\/([^:]+):(.+)$/;
6809
+ /**
6810
+ * Parse an ECR image URI. Returns `undefined` for non-ECR URIs (typically:
6811
+ * Docker Hub, public.ecr.aws, gcr.io, ...) — those are user-managed
6812
+ * images we don't try to authenticate against.
6813
+ */
6814
+ function parseEcrUri(imageUri) {
6815
+ const m = ECR_URI_REGEX.exec(imageUri);
6816
+ if (!m) return void 0;
6817
+ return {
6818
+ accountId: m[1],
6819
+ region: m[2],
6820
+ repository: m[3],
6821
+ tag: m[4]
6826
6822
  };
6827
6823
  }
6828
6824
  /**
6829
- * Best-effort `docker rm -f <id>`. Errors are swallowed (logged at debug)
6830
- * because this typically runs from a `finally` and the parent has its own
6831
- * error to surface.
6825
+ * Module-level cache for STS-issued AssumeRole credentials, keyed by
6826
+ * `(ecrRoleArn, callerRegion)`. Closes the reviewer's MAJOR finding: ECS
6827
+ * run-task with N containers under one `--ecr-role-arn` would otherwise issue
6828
+ * N× `AssumeRole` and N× `GetCallerIdentity` for identical credentials valid
6829
+ * for 3600s. The cache keeps a 5-minute safety margin against the recorded
6830
+ * `Expiration` so STS-side / local-clock skew never lets a stale entry through.
6831
+ *
6832
+ * Cache key is intentionally `(roleArn, region)` rather than full caller
6833
+ * identity — STS issues per-region session creds, and a switch of `--region`
6834
+ * between two `local invoke` calls in the same process must re-issue.
6835
+ *
6836
+ * NOT cleared on process exit — Node's module scope evaporates with the
6837
+ * process, and no inter-process sharing is desired (each `cdkl invoke`
6838
+ * is its own isolated runtime).
6832
6839
  */
6833
- async function removeContainer(containerId) {
6834
- if (!containerId) return;
6835
- const logger = getLogger().child("docker");
6836
- try {
6837
- await execFileAsync$5(getDockerCmd(), [
6838
- "rm",
6839
- "-f",
6840
- containerId
6841
- ]);
6842
- logger.debug(`Removed container ${containerId}`);
6843
- } catch (error) {
6844
- const err = error;
6845
- logger.debug(`docker rm -f ${containerId} failed: ${err.stderr || err.message || String(error)}`);
6846
- }
6847
- }
6840
+ const ASSUMED_ROLE_CACHE = /* @__PURE__ */ new Map();
6848
6841
  /**
6849
- * Verify the docker daemon is reachable. Surfaces a friendlier error than
6850
- * the raw `ENOENT` / "Cannot connect to the Docker daemon" the user would
6851
- * otherwise see at the first run call. Called once up front.
6842
+ * Module-level cache for `STS:GetCallerIdentity`. The result is identity-only
6843
+ * (`Account`) and invariant for the lifetime of the process under one set of
6844
+ * default credentials. Keyed by `callerRegion` to avoid a cross-region leak
6845
+ * when the caller flips `AWS_REGION` mid-process (STS is global but the SDK
6846
+ * uses regional endpoints; the result is invariant in practice, but we key
6847
+ * on region for safety).
6852
6848
  */
6853
- async function ensureDockerAvailable() {
6854
- const cmd = getDockerCmd();
6855
- try {
6856
- await execFileAsync$5(cmd, [
6857
- "version",
6858
- "--format",
6859
- "{{.Server.Version}}"
6860
- ]);
6861
- } catch (error) {
6862
- const err = error;
6863
- if (err.code === "ENOENT") throw new DockerRunnerError(`${cmd} is not installed or not on PATH. ${getEmbedConfig().cliName} invoke needs Docker (or a compatible CLI specified via CDK_DOCKER) — install it and retry.`);
6864
- throw new DockerRunnerError(`${cmd} daemon is not reachable: ${err.stderr?.trim() || err.message || String(error)}. Start Docker Desktop / the docker daemon and retry.`);
6865
- }
6849
+ const CALLER_IDENTITY_CACHE = /* @__PURE__ */ new Map();
6850
+ /** 5-minute safety margin against the recorded STS expiration timestamp. */
6851
+ const STS_CREDENTIAL_SAFETY_MARGIN_MS = 300 * 1e3;
6852
+ function isCredentialFresh(creds) {
6853
+ if (!creds.expiration) return false;
6854
+ return creds.expiration.getTime() - Date.now() > STS_CREDENTIAL_SAFETY_MARGIN_MS;
6866
6855
  }
6867
6856
  /**
6868
- * Allocate a free TCP port on `127.0.0.1`. Used to pick a host port for
6869
- * publishing the RIE :8080 endpoint without colliding with whatever else
6870
- * the user has running. The OS assigns a port via `port: 0` and we
6871
- * close the probe before returning so docker can bind it next.
6857
+ * Pull (or verify locally cached) a container image from ECR.
6872
6858
  *
6873
- * There is a tiny race window between close and `docker run -p` in
6874
- * practice it's never been observed for local invoke; if it ever
6875
- * surfaces, the caller can retry with a fresh port.
6859
+ * Auto-detects cross-account from `STS:GetCallerIdentity` and assumes
6860
+ * the supplied role when set. Returns the image URI the caller should
6861
+ * pass to `docker run` (same as the input — no rewriting).
6876
6862
  */
6877
- function pickFreePort() {
6878
- return new Promise((resolvePort, rejectPort) => {
6879
- const server = createServer();
6880
- server.unref();
6881
- server.on("error", rejectPort);
6882
- server.listen(0, "127.0.0.1", () => {
6883
- const address = server.address();
6884
- if (!address || typeof address === "string") {
6885
- server.close();
6886
- rejectPort(/* @__PURE__ */ new Error("Could not allocate a host port"));
6887
- return;
6888
- }
6889
- const port = address.port;
6890
- server.close(() => resolvePort(port));
6891
- });
6863
+ async function pullEcrImage(imageUri, options) {
6864
+ const logger = getLogger().child("ecr-puller");
6865
+ const parsed = parseEcrUri(imageUri);
6866
+ if (!parsed) throw new LocalInvokeBuildError(`Image URI '${imageUri}' is not an ECR URI. ${getEmbedConfig().cliName} invoke v1 only authenticates against ECR for the deployed-image fallback path.`);
6867
+ const callerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"];
6868
+ if (options.skipPull) {
6869
+ logger.info(`Skipping ECR pull (--no-pull). Verifying ${imageUri} is in local cache...`);
6870
+ await verifyImageInLocalCache(imageUri);
6871
+ return imageUri;
6872
+ }
6873
+ const callerIdentityKey = `${options.profile ?? "_noprofile"}|${callerRegion ?? "_unset"}`;
6874
+ let callerAccount = CALLER_IDENTITY_CACHE.get(callerIdentityKey);
6875
+ if (callerAccount === void 0) {
6876
+ const sts = new STSClient(buildStsClientConfig({
6877
+ region: callerRegion,
6878
+ profile: options.profile
6879
+ }));
6880
+ try {
6881
+ const identity = await sts.send(new GetCallerIdentityCommand({}));
6882
+ if (!identity.Account) throw new LocalInvokeBuildError("STS GetCallerIdentity returned no Account. Verify your AWS credentials.");
6883
+ callerAccount = identity.Account;
6884
+ CALLER_IDENTITY_CACHE.set(callerIdentityKey, callerAccount);
6885
+ } finally {
6886
+ sts.destroy();
6887
+ }
6888
+ }
6889
+ const crossAccount = callerAccount !== parsed.accountId;
6890
+ const crossRegion = callerRegion !== void 0 && callerRegion !== parsed.region;
6891
+ let assumed;
6892
+ if (options.ecrRoleArn) {
6893
+ const cacheKey = `${options.profile ?? "_noprofile"}|${options.ecrRoleArn}|${callerRegion ?? "_unset"}`;
6894
+ const cached = ASSUMED_ROLE_CACHE.get(cacheKey);
6895
+ if (cached && isCredentialFresh(cached)) {
6896
+ assumed = cached;
6897
+ logger.debug(`Reusing cached AssumeRole credentials for ${options.ecrRoleArn}`);
6898
+ } else {
6899
+ assumed = await assumeRoleForEcr(options.ecrRoleArn, callerRegion, options.profile, logger);
6900
+ ASSUMED_ROLE_CACHE.set(cacheKey, assumed);
6901
+ logger.info(`Assumed role ${options.ecrRoleArn} for ECR pull (account=${parsed.accountId}, region=${parsed.region})`);
6902
+ }
6903
+ } else if (crossAccount) logger.info(`Cross-account ECR pull: image account ${parsed.accountId} != caller ${callerAccount}. Using the caller's credentials; pass --ecr-role-arn <arn> if AWS rejects with AccessDenied.`);
6904
+ if (crossRegion) logger.info(`Cross-region ECR pull: image region ${parsed.region} != caller ${callerRegion ?? "(unset)"}. Authenticating against the image region directly.`);
6905
+ const ecr = new ECRClient({
6906
+ region: parsed.region,
6907
+ ...assumed ? { credentials: assumed } : options.profile ? { profile: options.profile } : {}
6892
6908
  });
6909
+ try {
6910
+ await ecrLogin(ecr, parsed.accountId, parsed.region);
6911
+ } finally {
6912
+ ecr.destroy();
6913
+ }
6914
+ logger.info(`Pulling ${imageUri}...`);
6915
+ try {
6916
+ await runDockerForeground(["pull", imageUri]);
6917
+ } catch (err) {
6918
+ throw new LocalInvokeBuildError(`docker pull ${imageUri} failed: ${err.message}`);
6919
+ }
6920
+ return imageUri;
6893
6921
  }
6894
6922
  /**
6895
- * Env keys whose VALUES are sensitive (AWS credentials). These are
6896
- * routed through docker's value-from-process-env form by
6897
- * {@link appendEnvFlags} so the value never appears in `docker run`'s
6898
- * argv (`ps` / `/proc/<pid>/cmdline`), and they are also redacted by
6899
- * {@link redactAwsCredentialsInArgs} as a defense for any path that
6900
- * still emits the inline `-e <KEY>=<value>` form into the debug log.
6923
+ * Assume the supplied role via the SDK default credential chain and
6924
+ * return the resulting temporary credentials. The STS client is built
6925
+ * with the caller's profile region (or unset) STS is a global
6926
+ * service so the region is informational, but threading it through
6927
+ * mirrors the convention used by `src/utils/role-arn.ts`.
6901
6928
  */
6902
- const SENSITIVE_ENV_KEYS = new Set([
6903
- "AWS_ACCESS_KEY_ID",
6904
- "AWS_SECRET_ACCESS_KEY",
6905
- "AWS_SESSION_TOKEN"
6906
- ]);
6929
+ async function assumeRoleForEcr(roleArn, callerRegion, profile, logger) {
6930
+ logger.debug(`Assuming role ${roleArn} for ECR pull...`);
6931
+ const sts = new STSClient(buildStsClientConfig({
6932
+ region: callerRegion,
6933
+ profile
6934
+ }));
6935
+ try {
6936
+ const creds = (await sts.send(new AssumeRoleCommand({
6937
+ RoleArn: roleArn,
6938
+ RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-ecr-${Date.now()}`,
6939
+ DurationSeconds: 3600
6940
+ }))).Credentials;
6941
+ if (!creds || !creds.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalInvokeBuildError(`AssumeRole(${roleArn}) returned no usable credentials. Verify the role's trust policy allows your identity to assume it.`);
6942
+ return {
6943
+ accessKeyId: creds.AccessKeyId,
6944
+ secretAccessKey: creds.SecretAccessKey,
6945
+ sessionToken: creds.SessionToken,
6946
+ ...creds.Expiration && { expiration: creds.Expiration }
6947
+ };
6948
+ } catch (err) {
6949
+ if (err instanceof LocalInvokeBuildError) throw err;
6950
+ throw new LocalInvokeBuildError(`Failed to assume role ${roleArn} for ECR pull: ${err instanceof Error ? err.message : String(err)}. Verify the role exists and its trust policy permits the caller's identity to assume it.`);
6951
+ } finally {
6952
+ sts.destroy();
6953
+ }
6954
+ }
6907
6955
  /**
6908
- * Append `-e` flags for `env` to `args`, routing keys in `sensitiveKeys`
6909
- * through docker's value-from-process-env form (`-e KEY`, with NO
6910
- * `=value`). Docker reads the value from its OWN environment at run time,
6911
- * so the secret never lands in `docker run`'s argv — invisible to
6912
- * `ps aux` / `/proc/<pid>/cmdline` (other users on a shared host) and to
6913
- * shell history. Non-sensitive keys keep the inline `-e KEY=VALUE` form
6914
- * (fine for non-secret config, and keeps `--debug` output readable).
6915
- *
6916
- * Returns the `{ KEY: value }` map of routed-through keys; the caller MUST
6917
- * merge it into the spawned docker process's `env` (see
6918
- * {@link execEnvForSecrets}) so docker can resolve each passed-through
6919
- * key. Values still appear in `docker inspect` Config.Env — that is
6920
- * inherent to container env and matches production behavior.
6921
- *
6922
- * Unlike `--env-file`, this handles multi-line values (e.g. PEM secrets)
6923
- * and needs no temp file on disk.
6956
+ * Authenticate the local docker daemon against the target ECR registry.
6957
+ * Self-contained in this module so the local-invoke path stays
6958
+ * independent of any full asset-publish path's larger surface area.
6924
6959
  */
6925
- function appendEnvFlags(args, env, sensitiveKeys) {
6926
- const passthrough = {};
6927
- for (const [k, v] of Object.entries(env)) if (sensitiveKeys.has(k)) {
6928
- args.push("-e", k);
6929
- passthrough[k] = v;
6930
- } else args.push("-e", `${k}=${v}`);
6931
- return passthrough;
6960
+ async function ecrLogin(client, accountId, region) {
6961
+ getLogger().child("ecr-puller").debug(`ECR login (account=${accountId}, region=${region})`);
6962
+ const authData = (await client.send(new GetAuthorizationTokenCommand({}))).authorizationData?.[0];
6963
+ if (!authData?.authorizationToken) throw new LocalInvokeBuildError("Failed to get ECR authorization token");
6964
+ const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
6965
+ if (!username || password === void 0) throw new LocalInvokeBuildError("ECR authorization token has unexpected shape (missing username/password)");
6966
+ const endpoint = authData.proxyEndpoint || `https://${accountId}.dkr.ecr.${region}.amazonaws.com`;
6967
+ try {
6968
+ await runDockerStreaming([
6969
+ "login",
6970
+ "--username",
6971
+ username,
6972
+ "--password-stdin",
6973
+ endpoint
6974
+ ], { input: password });
6975
+ } catch (err) {
6976
+ const e = err;
6977
+ throw new LocalInvokeBuildError(`ECR login failed: ${formatDockerLoginError(e.stderr || e.message || String(err), endpoint)}`);
6978
+ }
6932
6979
  }
6933
6980
  /**
6934
- * Build the `env` option for an `execFile` / `spawn` call that runs a
6935
- * `docker run` whose args include passed-through sensitive keys (from
6936
- * {@link appendEnvFlags}). Returns `{ env: {...process.env, ...passthrough} }`
6937
- * so docker inherits the normal environment PLUS the sensitive values it
6938
- * must resolve, or `{}` when there is nothing to pass through (preserving
6939
- * the default inherited-environment behavior).
6981
+ * `docker image inspect <uri>` returns non-zero when the image is not in
6982
+ * the local cache. Surface a clearer error than docker's raw output so
6983
+ * the user knows the `--no-pull` path requires a pre-cached image.
6940
6984
  */
6941
- function execEnvForSecrets(passthrough) {
6942
- if (Object.keys(passthrough).length === 0) return {};
6943
- return { env: {
6944
- ...process.env,
6945
- ...passthrough
6946
- } };
6985
+ async function verifyImageInLocalCache(imageUri) {
6986
+ try {
6987
+ await runDockerStreaming([
6988
+ "image",
6989
+ "inspect",
6990
+ imageUri
6991
+ ]);
6992
+ } catch {
6993
+ throw new LocalInvokeBuildError(`Image '${imageUri}' is not in the local docker cache and --no-pull was set. Either remove --no-pull (${getEmbedConfig().productName} will pull from ECR) or pre-pull the image manually with \`docker pull\`.`);
6994
+ }
6947
6995
  }
6948
6996
  /**
6949
- * Returns a copy of `args` with any `-e <KEY>=<value>` pair whose KEY is
6950
- * in {@link SENSITIVE_ENV_KEYS} replaced with `-e <KEY>=***`. The actual
6951
- * `args` passed to `spawn` are never mutated this is for log output
6952
- * only. With {@link appendEnvFlags} routing credentials through the
6953
- * value-less `-e KEY` form this rarely fires, but it stays as defense for
6954
- * any inline-form credential that slips into a logged command.
6997
+ * Check whether a docker image is in the local registry. Pure boolean
6998
+ * the caller decides what message to surface on miss. Reused by the
6999
+ * `docker-image-builder` `--no-build` path so both the ECR-pull verifier
7000
+ * (above) and the local-build verifier route through one `docker image
7001
+ * inspect` shape.
6955
7002
  */
6956
- function redactAwsCredentialsInArgs(args) {
6957
- const out = [];
6958
- for (let i = 0; i < args.length; i++) {
6959
- const cur = args[i];
6960
- const next = args[i + 1];
6961
- if (cur === "-e" && typeof next === "string") {
6962
- const eqIdx = next.indexOf("=");
6963
- if (eqIdx > 0) {
6964
- const key = next.substring(0, eqIdx);
6965
- if (SENSITIVE_ENV_KEYS.has(key)) {
6966
- out.push("-e", `${key}=***`);
6967
- i++;
6968
- continue;
6969
- }
6970
- }
6971
- }
6972
- out.push(cur);
7003
+ async function isImageInLocalCache(imageRef) {
7004
+ try {
7005
+ await runDockerStreaming([
7006
+ "image",
7007
+ "inspect",
7008
+ imageRef
7009
+ ]);
7010
+ return true;
7011
+ } catch {
7012
+ return false;
6973
7013
  }
6974
- return out;
6975
7014
  }
6976
7015
 
6977
7016
  //#endregion
6978
- //#region src/assets/docker-build.ts
7017
+ //#region src/local/docker-image-builder.ts
6979
7018
  /**
6980
- * Build a Docker image from a CDK asset source. Returns the local image
6981
- * tag the caller should use for `docker tag` / `docker push` (publisher)
6982
- * or `docker run` (local-invoke).
6983
- *
6984
- * Two source modes (mirrors CDK CLI):
6985
- * - `executable`: run the user-supplied command, capture stdout, return
6986
- * it as the local tag. The script is responsible for building AND
6987
- * tagging; cdk-local just reads the tag from stdout. Used for Bazel /
6988
- * custom build pipelines that produce images outside `docker build`.
6989
- * - `directory`: standard `docker build <dir>` with the full BuildKit
6990
- * flag set described above. Caller must pass `options.tag`.
7019
+ * Build a Lambda container image from a CDK asset entry. Returns the
7020
+ * local image tag the caller should pass to `docker run`.
6991
7021
  *
6992
- * `executable` takes precedence when both fields are set (matches CDK CLI).
7022
+ * When `options.noBuild` is set, skips `docker build` entirely and
7023
+ * verifies the deterministic local tag is already in the docker
7024
+ * registry; throws `LocalInvokeBuildError` with an actionable message
7025
+ * when the tag is missing.
6993
7026
  */
6994
- async function buildDockerImage(asset, cdkOutDir, options) {
6995
- const source = asset.source;
6996
- const logger = getLogger().child("docker-build");
6997
- if (source.executable && source.executable.length > 0) {
6998
- const [cmd, ...args] = source.executable;
6999
- if (!cmd) throw options.wrapError("asset source.executable[] is empty");
7000
- const cwd = source.directory ? `${cdkOutDir}/${source.directory}` : cdkOutDir;
7001
- logger.debug(`Building Docker image via executable: ${source.executable.join(" ")} (cwd=${cwd})`);
7002
- let result;
7027
+ async function buildContainerImage(asset, cdkOutDir, options) {
7028
+ const tag = computeLocalTag(asset.source);
7029
+ const platform = architectureToPlatform(options.architecture);
7030
+ const logger = getLogger().child("local-invoke-build");
7031
+ if (options.noBuild === true) {
7032
+ logger.info(`Skipping docker build (--no-build). Verifying ${tag} is in local registry...`);
7033
+ if (!await isImageInLocalCache(tag)) throw new LocalInvokeBuildError(`image '${tag}' not in local registry and --no-build is set; remove --no-build or run \`docker build\` manually first.`);
7034
+ logger.debug(`Local tag ${tag} is cached; skipping build.`);
7035
+ return tag;
7036
+ }
7037
+ logger.info(`Building container image (platform=${platform})...`);
7038
+ logger.debug(`Local tag: ${tag}`);
7039
+ const actualTag = await buildDockerImage(asset, cdkOutDir, {
7040
+ tag,
7041
+ platform,
7042
+ wrapError: (stderr) => new LocalInvokeBuildError(`docker build failed for container Lambda asset (${asset.source.directory ?? asset.source.executable?.join(" ")}): ${stderr}`),
7043
+ progressLabel: `Building container image (platform=${platform})`
7044
+ });
7045
+ if (actualTag !== tag) {
7046
+ logger.debug(`Re-tagging executable-built image '${actualTag}' → '${tag}'`);
7003
7047
  try {
7004
- result = await spawnStreaming(cmd, args, { cwd });
7048
+ await runDockerStreaming([
7049
+ "tag",
7050
+ actualTag,
7051
+ tag
7052
+ ]);
7005
7053
  } catch (err) {
7006
7054
  const e = err;
7007
- throw options.wrapError(e.stderr || e.message || String(err));
7055
+ throw new LocalInvokeBuildError(`docker tag failed re-tagging '${actualTag}' → '${tag}': ${e.stderr?.trim() || e.message || String(err)}`);
7008
7056
  }
7009
- const tag = result.stdout.trim();
7010
- if (!tag) throw options.wrapError(`docker build executable produced no output (expected the local image tag on stdout): ${cmd} ${args.join(" ")}`);
7011
- return tag;
7012
7057
  }
7013
- if (!source.directory) throw options.wrapError(`DockerImageAssetSource must set either 'directory' or 'executable' (got: ${JSON.stringify(source)})`);
7014
- if (!options.tag) throw options.wrapError("buildDockerImage(directory mode) requires options.tag");
7015
- const buildArgs = buildDockerBuildCommand(source, options.tag, options.platform);
7016
- const contextDir = `${cdkOutDir}/${source.directory}`;
7017
- buildArgs.push(".");
7018
- logger.debug(`${getDockerCmd()} ${buildArgs.join(" ")} (cwd=${contextDir})`);
7019
- try {
7020
- await runDockerStreaming(buildArgs, {
7021
- cwd: contextDir,
7022
- env: { BUILDX_NO_DEFAULT_ATTESTATIONS: "1" },
7023
- ...options.progressLabel !== void 0 && { progressLabel: options.progressLabel }
7024
- });
7025
- } catch (err) {
7026
- const e = err;
7027
- throw options.wrapError(e.stderr || e.message || String(err));
7028
- }
7029
- return options.tag;
7058
+ return tag;
7030
7059
  }
7031
7060
  /**
7032
- * Construct the `docker build` argv (without the trailing context directory).
7061
+ * Translate Lambda's `Architectures` enum to a Docker `--platform` value.
7033
7062
  *
7034
- * Exported for unit-test inspectionkeeps the flag-ordering assertions
7035
- * independent of the spawn machinery.
7063
+ * Critical bug fix C2 from the design doc without this the build /
7064
+ * run step uses the host's default arch, which races on M1/M2 Macs
7065
+ * (arm64 host) with x86_64 Lambdas. Threaded into BOTH the build (here)
7066
+ * and the run path (`docker-runner.runDetached`).
7036
7067
  */
7037
- function buildDockerBuildCommand(source, tag, platformOverride) {
7038
- const args = [
7039
- "build",
7040
- "--tag",
7041
- tag
7042
- ];
7043
- if (source.dockerBuildArgs) for (const [k, v] of Object.entries(source.dockerBuildArgs)) args.push("--build-arg", `${k}=${v}`);
7044
- if (source.dockerBuildContexts) for (const [k, v] of Object.entries(source.dockerBuildContexts)) args.push("--build-context", `${k}=${v}`);
7045
- if (source.dockerBuildSecrets) for (const [k, v] of Object.entries(source.dockerBuildSecrets)) args.push("--secret", `id=${k},${v}`);
7046
- if (source.dockerBuildSsh) args.push("--ssh", source.dockerBuildSsh);
7047
- if (source.dockerBuildTarget) args.push("--target", source.dockerBuildTarget);
7048
- if (source.dockerFile) args.push("--file", source.dockerFile);
7049
- if (source.networkMode) args.push("--network", source.networkMode);
7050
- const platform = platformOverride ?? source.platform;
7051
- if (platform) args.push("--platform", platform);
7052
- if (source.dockerOutputs) for (const output of source.dockerOutputs) args.push(`--output=${output}`);
7053
- if (source.cacheFrom) for (const c of source.cacheFrom) args.push("--cache-from", cacheOptionToFlag(c));
7054
- if (source.cacheTo) args.push("--cache-to", cacheOptionToFlag(source.cacheTo));
7055
- if (source.cacheDisabled) args.push("--no-cache");
7056
- return args;
7068
+ function architectureToPlatform(architecture) {
7069
+ return architecture === "arm64" ? "linux/arm64" : "linux/amd64";
7057
7070
  }
7058
- function cacheOptionToFlag(option) {
7059
- let flag = `type=${option.type}`;
7060
- if (option.params) for (const [k, v] of Object.entries(option.params)) flag += `,${k}=${v}`;
7061
- return flag;
7071
+ /**
7072
+ * The Docker `--platform` value matching the host CPU arch.
7073
+ *
7074
+ * `process.arch` is `arm64` on Apple Silicon and `x64` on Intel/amd64
7075
+ * hosts; everything else is treated as amd64 for the purpose of the
7076
+ * emulation comparison (the only two `--platform` values cdk-local emits
7077
+ * are `linux/arm64` / `linux/amd64`).
7078
+ */
7079
+ function hostPlatform() {
7080
+ return process.arch === "arm64" ? "linux/arm64" : "linux/amd64";
7062
7081
  }
7063
-
7064
- //#endregion
7065
- //#region src/local/ecr-puller.ts
7082
+ /** Process-global dedupe so a warm pool / per-request boot warns once per arch. */
7083
+ const warnedEmulatedPlatforms = /* @__PURE__ */ new Set();
7066
7084
  /**
7067
- * ECR pull fallback for `cdkl invoke` / `cdkl start-api` /
7068
- * `cdkl run-task`. When the image URI resolves to an ECR repo but
7069
- * doesn't match any cdk.out asset (typical when invoking a stack
7070
- * deployed elsewhere or sharing a centralized registry), cdk-local
7071
- * authenticates against the target registry and runs `docker pull`.
7085
+ * Warn once per process when a container is about to run at a `--platform`
7086
+ * whose CPU arch differs from the host's i.e. it will run under CPU
7087
+ * emulation.
7072
7088
  *
7073
- * **Cross-account / cross-region** (#455):
7074
- * - Same-account, same-region: fast path. No STS hop. The default
7075
- * credential chain is used directly for `ecr:GetAuthorizationToken`.
7076
- * - `ecrRoleArn` is provided: `sts:AssumeRole` is issued via the
7077
- * default credential chain to obtain temporary credentials for the
7078
- * target account. The resulting credentials authenticate the ECR
7079
- * client (regardless of region the ECR client is built for the
7080
- * URI's region, which can differ from the caller's profile region).
7081
- * - Cross-account, NO `ecrRoleArn`: cdk-local falls through to the
7082
- * default credential chain. This works when the caller has been
7083
- * granted cross-account `ecr:GetAuthorizationToken` +
7084
- * `ecr:BatchGetImage` permissions on the target repository via an
7085
- * IAM policy; otherwise AWS rejects the call with `AccessDenied`
7086
- * and the user is pointed at `--ecr-role-arn`.
7089
+ * The arch is chosen automatically from the function's declared
7090
+ * `Architectures`, so the user gets no signal that emulation is in play
7091
+ * until a container dies. Emulated containers usually run fine, but some
7092
+ * compiled custom-runtime binaries (notably a Swift `provided.*`
7093
+ * bootstrap) crash under emulation with `exec format error` or
7094
+ * `illegal instruction`, which is opaque without this hint. Surface the
7095
+ * two real fixes: enable Rosetta for x86/amd64 emulation in the Docker
7096
+ * engine settings, or build the function for the host arch.
7087
7097
  *
7088
- * The `--no-pull` semantics (C3 in the design doc):
7089
- * - When NOT set: `ecrLogin` + `docker pull <uri>`.
7090
- * - When set: skip `docker pull`. If the image isn't in the local
7091
- * cache, the subsequent `docker run` will fail; we surface a clearer
7092
- * "image not in local cache" error here so the user knows to drop
7093
- * `--no-pull` or pre-pull manually.
7098
+ * Deduped by target platform so a warm pool / per-request boot doesn't
7099
+ * spam. `platform` is the resolved `--platform` value (`undefined` =>
7100
+ * docker picks the host arch, so there is nothing to warn about).
7094
7101
  */
7095
- /** Regex matching the `<acct>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>` shape. */
7096
- const ECR_URI_REGEX = /^(\d{12})\.dkr\.ecr\.([^.]+)\.amazonaws\.com(?:\.cn)?\/([^:]+):(.+)$/;
7102
+ function warnIfEmulatedPlatform(platform, opts = {}) {
7103
+ if (!platform) return;
7104
+ const host = hostPlatform();
7105
+ if (platform === host) return;
7106
+ if (warnedEmulatedPlatforms.has(platform)) return;
7107
+ warnedEmulatedPlatforms.add(platform);
7108
+ const logger = opts.logger ?? getLogger();
7109
+ const what = opts.label ? `'${opts.label}' ` : "";
7110
+ logger.warn(`Running ${what}under ${platform} emulation on a ${host} host. Emulated containers can crash some compiled custom-runtime binaries (e.g. a Swift 'provided.*' bootstrap) with 'exec format error' or 'illegal instruction'. If you hit that, enable Rosetta for x86/amd64 emulation in your Docker engine settings, or build the function for ${host}.`);
7111
+ }
7097
7112
  /**
7098
- * Parse an ECR image URI. Returns `undefined` for non-ECR URIs (typically:
7099
- * Docker Hub, public.ecr.aws, gcr.io, ...) — those are user-managed
7100
- * images we don't try to authenticate against.
7113
+ * Build a stable local tag derived from the asset's build context.
7114
+ *
7115
+ * Fingerprints every field that affects the produced image so an iteration
7116
+ * that doesn't change those fields hits Docker's layer cache; an iteration
7117
+ * that DOES change them gets a fresh tag (the old tag stays around in
7118
+ * `docker images` but harmlessly). The fingerprint covers the full CDK
7119
+ * `DockerImageSource` schema so `dockerBuildSecrets` / `dockerBuildContexts`
7120
+ * / `cacheFrom` / etc. changes also bust the local cache as expected.
7101
7121
  */
7102
- function parseEcrUri(imageUri) {
7103
- const m = ECR_URI_REGEX.exec(imageUri);
7104
- if (!m) return void 0;
7105
- return {
7106
- accountId: m[1],
7107
- region: m[2],
7108
- repository: m[3],
7109
- tag: m[4]
7110
- };
7122
+ function computeLocalTag(source) {
7123
+ const hash = createHash("sha256");
7124
+ pushField(hash, "directory", source.directory ?? "");
7125
+ pushField(hash, "executable", (source.executable ?? []).join(" "));
7126
+ pushField(hash, "dockerFile", source.dockerFile ?? "");
7127
+ pushField(hash, "dockerBuildTarget", source.dockerBuildTarget ?? "");
7128
+ pushField(hash, "networkMode", source.networkMode ?? "");
7129
+ pushField(hash, "platform", source.platform ?? "");
7130
+ pushField(hash, "dockerBuildSsh", source.dockerBuildSsh ?? "");
7131
+ pushField(hash, "cacheDisabled", source.cacheDisabled ? "1" : "0");
7132
+ pushMap(hash, "dockerBuildArgs", source.dockerBuildArgs);
7133
+ pushMap(hash, "dockerBuildContexts", source.dockerBuildContexts);
7134
+ pushMap(hash, "dockerBuildSecrets", source.dockerBuildSecrets);
7135
+ pushField(hash, "dockerOutputs", (source.dockerOutputs ?? []).join(""));
7136
+ pushField(hash, "cacheFrom", (source.cacheFrom ?? []).map((o) => JSON.stringify(o)).join(""));
7137
+ pushField(hash, "cacheTo", source.cacheTo ? JSON.stringify(source.cacheTo) : "");
7138
+ return `${getEmbedConfig().resourceNamePrefix}-invoke-${hash.digest("hex").slice(0, 16)}`;
7139
+ }
7140
+ function pushField(hash, name, value) {
7141
+ hash.update(name);
7142
+ hash.update("=");
7143
+ hash.update(value);
7144
+ hash.update("\0");
7145
+ }
7146
+ function pushMap(hash, name, value) {
7147
+ hash.update(name);
7148
+ hash.update("={");
7149
+ if (value) for (const [k, v] of Object.entries(value)) {
7150
+ hash.update(k);
7151
+ hash.update("=");
7152
+ hash.update(v);
7153
+ hash.update(";");
7154
+ }
7155
+ hash.update("}\0");
7111
7156
  }
7157
+
7158
+ //#endregion
7159
+ //#region src/local/docker-runner.ts
7160
+ const execFileAsync$5 = promisify(execFile);
7112
7161
  /**
7113
- * Module-level cache for STS-issued AssumeRole credentials, keyed by
7114
- * `(ecrRoleArn, callerRegion)`. Closes the reviewer's MAJOR finding: ECS
7115
- * run-task with N containers under one `--ecr-role-arn` would otherwise issue
7116
- * N× `AssumeRole` and N× `GetCallerIdentity` for identical credentials valid
7117
- * for 3600s. The cache keeps a 5-minute safety margin against the recorded
7118
- * `Expiration` so STS-side / local-clock skew never lets a stale entry through.
7119
- *
7120
- * Cache key is intentionally `(roleArn, region)` rather than full caller
7121
- * identity — STS issues per-region session creds, and a switch of `--region`
7122
- * between two `local invoke` calls in the same process must re-issue.
7162
+ * Wraps `docker pull` / `docker run` / `docker rm` for `cdkl invoke`.
7123
7163
  *
7124
- * NOT cleared on process exit Node's module scope evaporates with the
7125
- * process, and no inter-process sharing is desired (each `cdkl invoke`
7126
- * is its own isolated runtime).
7127
- */
7128
- const ASSUMED_ROLE_CACHE = /* @__PURE__ */ new Map();
7129
- /**
7130
- * Module-level cache for `STS:GetCallerIdentity`. The result is identity-only
7131
- * (`Account`) and invariant for the lifetime of the process under one set of
7132
- * default credentials. Keyed by `callerRegion` to avoid a cross-region leak
7133
- * when the caller flips `AWS_REGION` mid-process (STS is global but the SDK
7134
- * uses regional endpoints; the result is invariant in practice, but we key
7135
- * on region for safety).
7164
+ * Mirrors the style of `src/assets/docker-asset-publisher.ts` (execFile for
7165
+ * one-shot calls, spawn for long-running ones). Kept as a separate file so
7166
+ * the command layer's wiring stays small; PR 5 (container Lambda) is
7167
+ * expected to add a second non-build call site, at which point the
7168
+ * common surface can be lifted into a shared helper.
7136
7169
  */
7137
- const CALLER_IDENTITY_CACHE = /* @__PURE__ */ new Map();
7138
- /** 5-minute safety margin against the recorded STS expiration timestamp. */
7139
- const STS_CREDENTIAL_SAFETY_MARGIN_MS = 300 * 1e3;
7140
- function isCredentialFresh(creds) {
7141
- if (!creds.expiration) return false;
7142
- return creds.expiration.getTime() - Date.now() > STS_CREDENTIAL_SAFETY_MARGIN_MS;
7143
- }
7170
+ var DockerRunnerError = class DockerRunnerError extends Error {
7171
+ constructor(message) {
7172
+ super(message);
7173
+ this.name = "DockerRunnerError";
7174
+ Object.setPrototypeOf(this, DockerRunnerError.prototype);
7175
+ }
7176
+ };
7144
7177
  /**
7145
- * Pull (or verify locally cached) a container image from ECR.
7178
+ * Pull the image. No-op when `skipPull` is true.
7146
7179
  *
7147
- * Auto-detects cross-account from `STS:GetCallerIdentity` and assumes
7148
- * the supplied role when set. Returns the image URI the caller should
7149
- * pass to `docker run` (same as the input no rewriting).
7180
+ * In verbose mode (`--verbose` / global log level `debug`), streams the
7181
+ * full `docker pull` progress to stdout so the user sees per-layer
7182
+ * downloads. In the default compact mode the call is silent (cached
7183
+ * images are the common case; a fresh pull still shows progress only
7184
+ * via `--verbose`). Errors are always surfaced: the captured stderr is
7185
+ * folded into the thrown `DockerRunnerError` message.
7150
7186
  */
7151
- async function pullEcrImage(imageUri, options) {
7152
- const logger = getLogger().child("ecr-puller");
7153
- const parsed = parseEcrUri(imageUri);
7154
- if (!parsed) throw new LocalInvokeBuildError(`Image URI '${imageUri}' is not an ECR URI. ${getEmbedConfig().cliName} invoke v1 only authenticates against ECR for the deployed-image fallback path.`);
7155
- const callerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"];
7156
- if (options.skipPull) {
7157
- logger.info(`Skipping ECR pull (--no-pull). Verifying ${imageUri} is in local cache...`);
7158
- await verifyImageInLocalCache(imageUri);
7159
- return imageUri;
7187
+ async function pullImage(image, skipPull, platform) {
7188
+ const logger = getLogger().child("docker");
7189
+ if (skipPull) {
7190
+ logger.debug(`Skipping docker pull for ${image} (--no-pull)`);
7191
+ return;
7160
7192
  }
7161
- const callerIdentityKey = `${options.profile ?? "_noprofile"}|${callerRegion ?? "_unset"}`;
7162
- let callerAccount = CALLER_IDENTITY_CACHE.get(callerIdentityKey);
7163
- if (callerAccount === void 0) {
7164
- const sts = new STSClient(buildStsClientConfig({
7165
- region: callerRegion,
7166
- profile: options.profile
7167
- }));
7193
+ const platformArgs = platform ? ["--platform", platform] : [];
7194
+ if (getLogger().getLevel() === "debug") {
7195
+ logger.info(`Pulling ${image}${platform ? ` (${platform})` : ""}...`);
7168
7196
  try {
7169
- const identity = await sts.send(new GetCallerIdentityCommand({}));
7170
- if (!identity.Account) throw new LocalInvokeBuildError("STS GetCallerIdentity returned no Account. Verify your AWS credentials.");
7171
- callerAccount = identity.Account;
7172
- CALLER_IDENTITY_CACHE.set(callerIdentityKey, callerAccount);
7173
- } finally {
7174
- sts.destroy();
7175
- }
7176
- }
7177
- const crossAccount = callerAccount !== parsed.accountId;
7178
- const crossRegion = callerRegion !== void 0 && callerRegion !== parsed.region;
7179
- let assumed;
7180
- if (options.ecrRoleArn) {
7181
- const cacheKey = `${options.profile ?? "_noprofile"}|${options.ecrRoleArn}|${callerRegion ?? "_unset"}`;
7182
- const cached = ASSUMED_ROLE_CACHE.get(cacheKey);
7183
- if (cached && isCredentialFresh(cached)) {
7184
- assumed = cached;
7185
- logger.debug(`Reusing cached AssumeRole credentials for ${options.ecrRoleArn}`);
7186
- } else {
7187
- assumed = await assumeRoleForEcr(options.ecrRoleArn, callerRegion, options.profile, logger);
7188
- ASSUMED_ROLE_CACHE.set(cacheKey, assumed);
7189
- logger.info(`Assumed role ${options.ecrRoleArn} for ECR pull (account=${parsed.accountId}, region=${parsed.region})`);
7197
+ await runDockerForeground([
7198
+ "pull",
7199
+ ...platformArgs,
7200
+ image
7201
+ ]);
7202
+ } catch (err) {
7203
+ throw new DockerRunnerError(`docker pull ${image} failed: ${err.message}`);
7190
7204
  }
7191
- } else if (crossAccount) logger.info(`Cross-account ECR pull: image account ${parsed.accountId} != caller ${callerAccount}. Using the caller's credentials; pass --ecr-role-arn <arn> if AWS rejects with AccessDenied.`);
7192
- if (crossRegion) logger.info(`Cross-region ECR pull: image region ${parsed.region} != caller ${callerRegion ?? "(unset)"}. Authenticating against the image region directly.`);
7193
- const ecr = new ECRClient({
7194
- region: parsed.region,
7195
- ...assumed ? { credentials: assumed } : options.profile ? { profile: options.profile } : {}
7196
- });
7197
- try {
7198
- await ecrLogin(ecr, parsed.accountId, parsed.region);
7199
- } finally {
7200
- ecr.destroy();
7205
+ return;
7201
7206
  }
7202
- logger.info(`Pulling ${imageUri}...`);
7207
+ logger.debug(`Pulling ${image} (silent — pass --verbose to stream progress)`);
7203
7208
  try {
7204
- await runDockerForeground(["pull", imageUri]);
7209
+ await runDockerStreaming([
7210
+ "pull",
7211
+ ...platformArgs,
7212
+ image
7213
+ ], {
7214
+ streamLive: false,
7215
+ progressLabel: `Pulling ${image}`
7216
+ });
7205
7217
  } catch (err) {
7206
- throw new LocalInvokeBuildError(`docker pull ${imageUri} failed: ${err.message}`);
7218
+ const e = err;
7219
+ if (e.exitCode === void 0 || e.exitCode === null) throw new DockerRunnerError(`docker pull ${image} failed: ${e.message ?? String(err)}`);
7220
+ const detail = e.stderr?.trim() || e.stdout?.trim() || "(no output)";
7221
+ throw new DockerRunnerError(`docker pull ${image} exited with code ${e.exitCode}: ${detail}`);
7207
7222
  }
7208
- return imageUri;
7209
7223
  }
7210
7224
  /**
7211
- * Assume the supplied role via the SDK default credential chain and
7212
- * return the resulting temporary credentials. The STS client is built
7213
- * with the caller's profile region (or unset) — STS is a global
7214
- * service so the region is informational, but threading it through
7215
- * mirrors the convention used by `src/utils/role-arn.ts`.
7225
+ * Run the container detached. Returns the container ID.
7226
+ *
7227
+ * The caller is responsible for:
7228
+ * - polling `host:port` for RIE readiness,
7229
+ * - issuing the invoke,
7230
+ * - calling `removeContainer` from a `try`/`finally` so the container
7231
+ * is cleaned up on any error path including SIGINT.
7216
7232
  */
7217
- async function assumeRoleForEcr(roleArn, callerRegion, profile, logger) {
7218
- logger.debug(`Assuming role ${roleArn} for ECR pull...`);
7219
- const sts = new STSClient(buildStsClientConfig({
7220
- region: callerRegion,
7221
- profile
7222
- }));
7223
- try {
7224
- const creds = (await sts.send(new AssumeRoleCommand({
7225
- RoleArn: roleArn,
7226
- RoleSessionName: `${getEmbedConfig().resourceNamePrefix}-ecr-${Date.now()}`,
7227
- DurationSeconds: 3600
7228
- }))).Credentials;
7229
- if (!creds || !creds.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalInvokeBuildError(`AssumeRole(${roleArn}) returned no usable credentials. Verify the role's trust policy allows your identity to assume it.`);
7230
- return {
7231
- accessKeyId: creds.AccessKeyId,
7232
- secretAccessKey: creds.SecretAccessKey,
7233
- sessionToken: creds.SessionToken,
7234
- ...creds.Expiration && { expiration: creds.Expiration }
7235
- };
7236
- } catch (err) {
7237
- if (err instanceof LocalInvokeBuildError) throw err;
7238
- throw new LocalInvokeBuildError(`Failed to assume role ${roleArn} for ECR pull: ${err instanceof Error ? err.message : String(err)}. Verify the role exists and its trust policy permits the caller's identity to assume it.`);
7239
- } finally {
7240
- sts.destroy();
7233
+ async function runDetached(opts) {
7234
+ const args = [
7235
+ "run",
7236
+ "-d",
7237
+ "--rm"
7238
+ ];
7239
+ if (opts.name) args.push("--name", opts.name);
7240
+ if (opts.network) args.push("--network", opts.network);
7241
+ if (opts.platform) {
7242
+ args.push("--platform", opts.platform);
7243
+ warnIfEmulatedPlatform(opts.platform, opts.name ? { label: opts.name } : {});
7241
7244
  }
7242
- }
7243
- /**
7244
- * Authenticate the local docker daemon against the target ECR registry.
7245
- * Self-contained in this module so the local-invoke path stays
7246
- * independent of any full asset-publish path's larger surface area.
7247
- */
7248
- async function ecrLogin(client, accountId, region) {
7249
- getLogger().child("ecr-puller").debug(`ECR login (account=${accountId}, region=${region})`);
7250
- const authData = (await client.send(new GetAuthorizationTokenCommand({}))).authorizationData?.[0];
7251
- if (!authData?.authorizationToken) throw new LocalInvokeBuildError("Failed to get ECR authorization token");
7252
- const [username, password] = Buffer.from(authData.authorizationToken, "base64").toString().split(":");
7253
- if (!username || password === void 0) throw new LocalInvokeBuildError("ECR authorization token has unexpected shape (missing username/password)");
7254
- const endpoint = authData.proxyEndpoint || `https://${accountId}.dkr.ecr.${region}.amazonaws.com`;
7245
+ if (opts.extraHosts) for (const entry of opts.extraHosts) args.push("--add-host", `${entry.host}:${entry.ip}`);
7246
+ const host = opts.host ?? "127.0.0.1";
7247
+ args.push("-p", `${host}:${opts.hostPort}:${opts.containerPort ?? 8080}`);
7248
+ if (opts.debugPort !== void 0) args.push("-p", `${host}:${opts.debugPort}:${opts.debugPort}`);
7249
+ for (const mount of opts.mounts) {
7250
+ const ro = mount.readOnly ? ":ro" : "";
7251
+ args.push("-v", `${mount.hostPath}:${mount.containerPath}${ro}`);
7252
+ }
7253
+ if (opts.extraMounts) for (const mount of opts.extraMounts) {
7254
+ const ro = mount.readOnly ? ":ro" : "";
7255
+ args.push("-v", `${mount.hostPath}:${mount.containerPath}${ro}`);
7256
+ }
7257
+ const sensitiveKeys = opts.sensitiveEnvKeys && opts.sensitiveEnvKeys.size > 0 ? new Set([...SENSITIVE_ENV_KEYS, ...opts.sensitiveEnvKeys]) : SENSITIVE_ENV_KEYS;
7258
+ const passthroughEnv = appendEnvFlags(args, opts.env, sensitiveKeys);
7259
+ if (opts.tmpfs) args.push("--tmpfs", `${opts.tmpfs.target}:rw,size=${opts.tmpfs.sizeMb}m`);
7260
+ if (opts.workingDir) args.push("--workdir", opts.workingDir);
7261
+ let entryPointTail = [];
7262
+ if (opts.entryPoint && opts.entryPoint.length > 0) {
7263
+ args.push("--entrypoint", opts.entryPoint[0]);
7264
+ entryPointTail = opts.entryPoint.slice(1);
7265
+ }
7266
+ args.push(opts.image, ...entryPointTail, ...opts.cmd);
7267
+ getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
7255
7268
  try {
7256
- await runDockerStreaming([
7257
- "login",
7258
- "--username",
7259
- username,
7260
- "--password-stdin",
7261
- endpoint
7262
- ], { input: password });
7263
- } catch (err) {
7264
- const e = err;
7265
- throw new LocalInvokeBuildError(`ECR login failed: ${formatDockerLoginError(e.stderr || e.message || String(err), endpoint)}`);
7269
+ const { stdout } = await execFileAsync$5(getDockerCmd(), args, {
7270
+ maxBuffer: 10 * 1024 * 1024,
7271
+ ...execEnvForSecrets(passthroughEnv)
7272
+ });
7273
+ return stdout.trim();
7274
+ } catch (error) {
7275
+ const err = error;
7276
+ throw new DockerRunnerError(`docker run failed: ${err.stderr?.trim() || err.message || String(error)}`);
7266
7277
  }
7267
7278
  }
7268
7279
  /**
7269
- * `docker image inspect <uri>` returns non-zero when the image is not in
7270
- * the local cache. Surface a clearer error than docker's raw output so
7271
- * the user knows the `--no-pull` path requires a pre-cached image.
7280
+ * `docker logs -f <id>` plumbed to stdout/stderr. Returns a function that
7281
+ * stops the stream (used by the caller in a `finally` block).
7272
7282
  */
7273
- async function verifyImageInLocalCache(imageUri) {
7283
+ function streamLogs(containerId) {
7284
+ const proc = spawn(getDockerCmd(), [
7285
+ "logs",
7286
+ "-f",
7287
+ containerId
7288
+ ], { stdio: [
7289
+ "ignore",
7290
+ "pipe",
7291
+ "pipe"
7292
+ ] });
7293
+ proc.stdout?.on("data", (chunk) => process.stdout.write(chunk));
7294
+ proc.stderr?.on("data", (chunk) => process.stderr.write(chunk));
7295
+ proc.on("error", () => {});
7296
+ return () => {
7297
+ if (!proc.killed) proc.kill("SIGTERM");
7298
+ };
7299
+ }
7300
+ /**
7301
+ * Best-effort `docker rm -f <id>`. Errors are swallowed (logged at debug)
7302
+ * because this typically runs from a `finally` and the parent has its own
7303
+ * error to surface.
7304
+ */
7305
+ async function removeContainer(containerId) {
7306
+ if (!containerId) return;
7307
+ const logger = getLogger().child("docker");
7274
7308
  try {
7275
- await runDockerStreaming([
7276
- "image",
7277
- "inspect",
7278
- imageUri
7309
+ await execFileAsync$5(getDockerCmd(), [
7310
+ "rm",
7311
+ "-f",
7312
+ containerId
7279
7313
  ]);
7280
- } catch {
7281
- throw new LocalInvokeBuildError(`Image '${imageUri}' is not in the local docker cache and --no-pull was set. Either remove --no-pull (${getEmbedConfig().productName} will pull from ECR) or pre-pull the image manually with \`docker pull\`.`);
7314
+ logger.debug(`Removed container ${containerId}`);
7315
+ } catch (error) {
7316
+ const err = error;
7317
+ logger.debug(`docker rm -f ${containerId} failed: ${err.stderr || err.message || String(error)}`);
7282
7318
  }
7283
7319
  }
7284
7320
  /**
7285
- * Check whether a docker image is in the local registry. Pure boolean —
7286
- * the caller decides what message to surface on miss. Reused by the
7287
- * `docker-image-builder` `--no-build` path so both the ECR-pull verifier
7288
- * (above) and the local-build verifier route through one `docker image
7289
- * inspect` shape.
7321
+ * Verify the docker daemon is reachable. Surfaces a friendlier error than
7322
+ * the raw `ENOENT` / "Cannot connect to the Docker daemon" the user would
7323
+ * otherwise see at the first run call. Called once up front.
7290
7324
  */
7291
- async function isImageInLocalCache(imageRef) {
7325
+ async function ensureDockerAvailable() {
7326
+ const cmd = getDockerCmd();
7292
7327
  try {
7293
- await runDockerStreaming([
7294
- "image",
7295
- "inspect",
7296
- imageRef
7328
+ await execFileAsync$5(cmd, [
7329
+ "version",
7330
+ "--format",
7331
+ "{{.Server.Version}}"
7297
7332
  ]);
7298
- return true;
7299
- } catch {
7300
- return false;
7333
+ } catch (error) {
7334
+ const err = error;
7335
+ if (err.code === "ENOENT") throw new DockerRunnerError(`${cmd} is not installed or not on PATH. ${getEmbedConfig().cliName} invoke needs Docker (or a compatible CLI specified via CDK_DOCKER) — install it and retry.`);
7336
+ throw new DockerRunnerError(`${cmd} daemon is not reachable: ${err.stderr?.trim() || err.message || String(error)}. Start Docker Desktop / the docker daemon and retry.`);
7301
7337
  }
7302
7338
  }
7303
-
7304
- //#endregion
7305
- //#region src/local/docker-image-builder.ts
7306
7339
  /**
7307
- * Build a Lambda container image from a CDK asset entry. Returns the
7308
- * local image tag the caller should pass to `docker run`.
7340
+ * Allocate a free TCP port on `127.0.0.1`. Used to pick a host port for
7341
+ * publishing the RIE :8080 endpoint without colliding with whatever else
7342
+ * the user has running. The OS assigns a port via `port: 0` and we
7343
+ * close the probe before returning so docker can bind it next.
7309
7344
  *
7310
- * When `options.noBuild` is set, skips `docker build` entirely and
7311
- * verifies the deterministic local tag is already in the docker
7312
- * registry; throws `LocalInvokeBuildError` with an actionable message
7313
- * when the tag is missing.
7345
+ * There is a tiny race window between close and `docker run -p` in
7346
+ * practice it's never been observed for local invoke; if it ever
7347
+ * surfaces, the caller can retry with a fresh port.
7314
7348
  */
7315
- async function buildContainerImage(asset, cdkOutDir, options) {
7316
- const tag = computeLocalTag(asset.source);
7317
- const platform = architectureToPlatform(options.architecture);
7318
- const logger = getLogger().child("local-invoke-build");
7319
- if (options.noBuild === true) {
7320
- logger.info(`Skipping docker build (--no-build). Verifying ${tag} is in local registry...`);
7321
- if (!await isImageInLocalCache(tag)) throw new LocalInvokeBuildError(`image '${tag}' not in local registry and --no-build is set; remove --no-build or run \`docker build\` manually first.`);
7322
- logger.debug(`Local tag ${tag} is cached; skipping build.`);
7323
- return tag;
7324
- }
7325
- logger.info(`Building container image (platform=${platform})...`);
7326
- logger.debug(`Local tag: ${tag}`);
7327
- const actualTag = await buildDockerImage(asset, cdkOutDir, {
7328
- tag,
7329
- platform,
7330
- wrapError: (stderr) => new LocalInvokeBuildError(`docker build failed for container Lambda asset (${asset.source.directory ?? asset.source.executable?.join(" ")}): ${stderr}`),
7331
- progressLabel: `Building container image (platform=${platform})`
7349
+ function pickFreePort() {
7350
+ return new Promise((resolvePort, rejectPort) => {
7351
+ const server = createServer();
7352
+ server.unref();
7353
+ server.on("error", rejectPort);
7354
+ server.listen(0, "127.0.0.1", () => {
7355
+ const address = server.address();
7356
+ if (!address || typeof address === "string") {
7357
+ server.close();
7358
+ rejectPort(/* @__PURE__ */ new Error("Could not allocate a host port"));
7359
+ return;
7360
+ }
7361
+ const port = address.port;
7362
+ server.close(() => resolvePort(port));
7363
+ });
7332
7364
  });
7333
- if (actualTag !== tag) {
7334
- logger.debug(`Re-tagging executable-built image '${actualTag}' → '${tag}'`);
7335
- try {
7336
- await runDockerStreaming([
7337
- "tag",
7338
- actualTag,
7339
- tag
7340
- ]);
7341
- } catch (err) {
7342
- const e = err;
7343
- throw new LocalInvokeBuildError(`docker tag failed re-tagging '${actualTag}' → '${tag}': ${e.stderr?.trim() || e.message || String(err)}`);
7344
- }
7345
- }
7346
- return tag;
7347
7365
  }
7348
7366
  /**
7349
- * Translate Lambda's `Architectures` enum to a Docker `--platform` value.
7350
- *
7351
- * Critical bug fix C2 from the design doc without this the build /
7352
- * run step uses the host's default arch, which races on M1/M2 Macs
7353
- * (arm64 host) with x86_64 Lambdas. Threaded into BOTH the build (here)
7354
- * and the run path (`docker-runner.runDetached`).
7367
+ * Env keys whose VALUES are sensitive (AWS credentials). These are
7368
+ * routed through docker's value-from-process-env form by
7369
+ * {@link appendEnvFlags} so the value never appears in `docker run`'s
7370
+ * argv (`ps` / `/proc/<pid>/cmdline`), and they are also redacted by
7371
+ * {@link redactAwsCredentialsInArgs} as a defense for any path that
7372
+ * still emits the inline `-e <KEY>=<value>` form into the debug log.
7355
7373
  */
7356
- function architectureToPlatform(architecture) {
7357
- return architecture === "arm64" ? "linux/arm64" : "linux/amd64";
7358
- }
7374
+ const SENSITIVE_ENV_KEYS = new Set([
7375
+ "AWS_ACCESS_KEY_ID",
7376
+ "AWS_SECRET_ACCESS_KEY",
7377
+ "AWS_SESSION_TOKEN"
7378
+ ]);
7359
7379
  /**
7360
- * Build a stable local tag derived from the asset's build context.
7380
+ * Append `-e` flags for `env` to `args`, routing keys in `sensitiveKeys`
7381
+ * through docker's value-from-process-env form (`-e KEY`, with NO
7382
+ * `=value`). Docker reads the value from its OWN environment at run time,
7383
+ * so the secret never lands in `docker run`'s argv — invisible to
7384
+ * `ps aux` / `/proc/<pid>/cmdline` (other users on a shared host) and to
7385
+ * shell history. Non-sensitive keys keep the inline `-e KEY=VALUE` form
7386
+ * (fine for non-secret config, and keeps `--debug` output readable).
7361
7387
  *
7362
- * Fingerprints every field that affects the produced image so an iteration
7363
- * that doesn't change those fields hits Docker's layer cache; an iteration
7364
- * that DOES change them gets a fresh tag (the old tag stays around in
7365
- * `docker images` but harmlessly). The fingerprint covers the full CDK
7366
- * `DockerImageSource` schema so `dockerBuildSecrets` / `dockerBuildContexts`
7367
- * / `cacheFrom` / etc. changes also bust the local cache as expected.
7388
+ * Returns the `{ KEY: value }` map of routed-through keys; the caller MUST
7389
+ * merge it into the spawned docker process's `env` (see
7390
+ * {@link execEnvForSecrets}) so docker can resolve each passed-through
7391
+ * key. Values still appear in `docker inspect` Config.Env that is
7392
+ * inherent to container env and matches production behavior.
7393
+ *
7394
+ * Unlike `--env-file`, this handles multi-line values (e.g. PEM secrets)
7395
+ * and needs no temp file on disk.
7368
7396
  */
7369
- function computeLocalTag(source) {
7370
- const hash = createHash("sha256");
7371
- pushField(hash, "directory", source.directory ?? "");
7372
- pushField(hash, "executable", (source.executable ?? []).join(" "));
7373
- pushField(hash, "dockerFile", source.dockerFile ?? "");
7374
- pushField(hash, "dockerBuildTarget", source.dockerBuildTarget ?? "");
7375
- pushField(hash, "networkMode", source.networkMode ?? "");
7376
- pushField(hash, "platform", source.platform ?? "");
7377
- pushField(hash, "dockerBuildSsh", source.dockerBuildSsh ?? "");
7378
- pushField(hash, "cacheDisabled", source.cacheDisabled ? "1" : "0");
7379
- pushMap(hash, "dockerBuildArgs", source.dockerBuildArgs);
7380
- pushMap(hash, "dockerBuildContexts", source.dockerBuildContexts);
7381
- pushMap(hash, "dockerBuildSecrets", source.dockerBuildSecrets);
7382
- pushField(hash, "dockerOutputs", (source.dockerOutputs ?? []).join(""));
7383
- pushField(hash, "cacheFrom", (source.cacheFrom ?? []).map((o) => JSON.stringify(o)).join(""));
7384
- pushField(hash, "cacheTo", source.cacheTo ? JSON.stringify(source.cacheTo) : "");
7385
- return `${getEmbedConfig().resourceNamePrefix}-invoke-${hash.digest("hex").slice(0, 16)}`;
7397
+ function appendEnvFlags(args, env, sensitiveKeys) {
7398
+ const passthrough = {};
7399
+ for (const [k, v] of Object.entries(env)) if (sensitiveKeys.has(k)) {
7400
+ args.push("-e", k);
7401
+ passthrough[k] = v;
7402
+ } else args.push("-e", `${k}=${v}`);
7403
+ return passthrough;
7386
7404
  }
7387
- function pushField(hash, name, value) {
7388
- hash.update(name);
7389
- hash.update("=");
7390
- hash.update(value);
7391
- hash.update("\0");
7405
+ /**
7406
+ * Build the `env` option for an `execFile` / `spawn` call that runs a
7407
+ * `docker run` whose args include passed-through sensitive keys (from
7408
+ * {@link appendEnvFlags}). Returns `{ env: {...process.env, ...passthrough} }`
7409
+ * so docker inherits the normal environment PLUS the sensitive values it
7410
+ * must resolve, or `{}` when there is nothing to pass through (preserving
7411
+ * the default inherited-environment behavior).
7412
+ */
7413
+ function execEnvForSecrets(passthrough) {
7414
+ if (Object.keys(passthrough).length === 0) return {};
7415
+ return { env: {
7416
+ ...process.env,
7417
+ ...passthrough
7418
+ } };
7392
7419
  }
7393
- function pushMap(hash, name, value) {
7394
- hash.update(name);
7395
- hash.update("={");
7396
- if (value) for (const [k, v] of Object.entries(value)) {
7397
- hash.update(k);
7398
- hash.update("=");
7399
- hash.update(v);
7400
- hash.update(";");
7420
+ /**
7421
+ * Returns a copy of `args` with any `-e <KEY>=<value>` pair whose KEY is
7422
+ * in {@link SENSITIVE_ENV_KEYS} replaced with `-e <KEY>=***`. The actual
7423
+ * `args` passed to `spawn` are never mutated — this is for log output
7424
+ * only. With {@link appendEnvFlags} routing credentials through the
7425
+ * value-less `-e KEY` form this rarely fires, but it stays as defense for
7426
+ * any inline-form credential that slips into a logged command.
7427
+ */
7428
+ function redactAwsCredentialsInArgs(args) {
7429
+ const out = [];
7430
+ for (let i = 0; i < args.length; i++) {
7431
+ const cur = args[i];
7432
+ const next = args[i + 1];
7433
+ if (cur === "-e" && typeof next === "string") {
7434
+ const eqIdx = next.indexOf("=");
7435
+ if (eqIdx > 0) {
7436
+ const key = next.substring(0, eqIdx);
7437
+ if (SENSITIVE_ENV_KEYS.has(key)) {
7438
+ out.push("-e", `${key}=***`);
7439
+ i++;
7440
+ continue;
7441
+ }
7442
+ }
7443
+ }
7444
+ out.push(cur);
7401
7445
  }
7402
- hash.update("}\0");
7446
+ return out;
7403
7447
  }
7404
7448
 
7405
7449
  //#endregion
@@ -21397,8 +21441,13 @@ function buildDockerRunArgs(opts) {
21397
21441
  }
21398
21442
  }
21399
21443
  if (opts.addHostFlags && opts.addHostFlags.length > 0) for (const f of opts.addHostFlags) args.push(f);
21400
- if (opts.platformOverride) args.push("--platform", opts.platformOverride);
21401
- else if (task.runtimePlatform) args.push("--platform", task.runtimePlatform.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64");
21444
+ let resolvedPlatform;
21445
+ if (opts.platformOverride) resolvedPlatform = opts.platformOverride;
21446
+ else if (task.runtimePlatform) resolvedPlatform = task.runtimePlatform.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64";
21447
+ if (resolvedPlatform) {
21448
+ args.push("--platform", resolvedPlatform);
21449
+ warnIfEmulatedPlatform(resolvedPlatform, { label: `${task.family}/${container.name}` });
21450
+ }
21402
21451
  const ephemeralPorts = new Set(opts.ephemeralPublishContainerPorts ?? []);
21403
21452
  if (!opts.skipHostPortPublish) for (const pm of container.portMappings) {
21404
21453
  if (ephemeralPorts.has(pm.containerPort)) continue;
@@ -36504,4 +36553,4 @@ function addStudioSpecificOptions(cmd) {
36504
36553
 
36505
36554
  //#endregion
36506
36555
  export { CLOUDFRONT_DISTRIBUTION_TYPE as $, computeRequestIdentityHash as $n, availableWebSocketApiIdentifiers as $r, getContainerNetworkIp as $t, addStartCloudFrontSpecificOptions as A, resolveApiTargetSubset as An, buildContainerImage as Ar, addImageOverrideOptions as At, classifyS3Error as B, groupRoutesByServer as Bn, createLocalStateProvider as Br, enforceImageOverrideOrphans as Bt, addListSpecificOptions as C, toCmdArgv as Cn, buildMgmtEndpointEnvUrl as Cr, createLocalStartServiceCommand as Ct, createLocalStartAgentCoreCommand as D, addStartApiSpecificOptions as Dn, buildDisconnectEvent as Dr, MAX_TASKS_SUBNET_RANGE_CAP as Dt, addStartAgentCoreSpecificOptions as E, createLocalInvokeCommand as En, buildConnectEvent as Er, createLocalRunTaskCommand as Et, idFromArn as F, materializeLayerFromArn as Fn, substituteAgainstState as Fr, resolveEcsAssumeRoleOption as Ft, serveFromStaticOrigin as G, defaultCredentialsLoader as Gn, resolveCfnStackName as Gr, describePinnedImageUri as Gt, matchBehavior as H, startApiServer as Hn, rejectExplicitCfnStackWithMultipleStacks as Hr, parseImageOverrideFlags as Ht, resolveKvsModulesForDistribution as I, resolveEnvVars$1 as In, substituteAgainstStateAsync as Ir, resolveSharedSidecarCredentials as It, applyEdgeResponseResult as J, createJwksCache as Jn, resolveSsmParameters as Jr, buildCloudMapIndex as Jt, serveLambdaUrlOrigin as K, buildCognitoJwksUrl as Kn, CfnLocalStateProvider as Kr, isLocalCdkAssetImage as Kt, createDeployedKvsDataSource as L, availableApiIdentifiers as Ln, substituteEnvVarsFromState as Lr, runEcsServiceEmulator as Lt, parseKvsFileOverrides as M, createFileWatcher as Mn, resolveRuntimeFileExtension as Mr, ecsClusterOption as Mt, parseOriginOverrides as N, attachStageContext as Nn, resolveRuntimeImage as Nr, parseMaxTasks as Nt, startAgentCoreWsBridge as O, createLocalStartApiCommand as On, buildMessageEvent as Or, addCommonEcsServiceOptions as Ot, resolveCloudFrontTarget as P, buildStageMap as Pn, EcsTaskResolutionError as Pr, parseRestartPolicy as Pt, httpHeadersToEdge as Q, buildMethodArn as Qn, listTargets as Qr, setShadowReadyTimeoutMs as Qt, resolveDeployedKvsArnByName as R, filterRoutesByApiIdentifier as Rn, substituteEnvVarsFromStateAsync as Rr, ImageOverrideError as Rt, StudioEventBus as S, resolveProfileCredentials as Si, renderCodeDockerfile as Sn, ConnectionRegistry as Sr, addStartServiceSpecificOptions as St, formatTargetListing as T, addInvokeSpecificOptions as Tn, parseConnectionsPath as Tr, addRunTaskSpecificOptions as Tt, startCloudFrontServer as U, resolveSelectionExpression as Un, resolveCfnFallbackRegion as Ur, resolveImageOverrides as Ut, createS3OriginReader as V, readMtlsMaterialsFromDisk as Vn, isCfnFlagPresent as Vr, mergeForService as Vt, resolveErrorResponseCandidates as W, resolveServiceIntegrationParameters as Wn, resolveCfnRegion as Wr, runImageOverrideBuilds as Wt, buildEdgeResponseEvent as X, verifyJwtAuthorizer as Xn, resolveSingleTarget as Xr, DEFAULT_SHADOW_READY_TIMEOUT_MS as Xt, buildEdgeRequestEvent as Y, verifyCognitoJwt as Yn, resolveWatchConfig as Yr, CloudMapRegistry as Yt, edgeHeadersToHttp as Z, verifyJwtViaDiscovery as Zn, countTargets as Zr, SOFT_RELOAD_COMPLETION_LOG_SUFFIX as Zt, filterStudioTargetGroups as _, formatStateRemedy as _i, waitForAgentCorePing as _n, tryParseStatus as _r, createLocalStartAlbCommand as _t, createLocalStudioCommand as a, discoverRoutes as ai, A2A_CONTAINER_PORT as an, buildCorsConfigByApiId as ar, pickLambdaEdgeFunctionLogicalId as at, renderStudioHtml as b, LocalInvokeBuildError as bi, buildAgentCoreCodeImage as bn, probeHostGatewaySupport as br, isApplicationLoadBalancer as bt, startStudioProxy as c, AGENTCORE_A2A_PROTOCOL as ci, MCP_CONTAINER_PORT as cn, matchPreflight as cr, compileCloudFrontFunction as ct, createStudioDispatcher as d, AGENTCORE_MCP_PROTOCOL as di, mcpInvokeOnce as dn, applyAuthorizerOverlay as dr, stripCloudFrontImport as dt, discoverWebSocketApis as ei, attachContainerLogStreamer as en, evaluateCachedLambdaPolicy as er, describeS3OriginDomain as et, filterStudioCustomResources as f, AGENTCORE_RUNTIME_TYPE as fi, parseSseForJsonRpc as fn, buildHttpApiV2Event as fr, createCloudFrontModule as ft, annotatePinnedEcsTargets as g, derivePseudoParametersFromRegion as gi, invokeAgentCore as gn, selectIntegrationResponse as gr, albStrategy as gt, annotateEcsTaskPinnedTargets as h, resolveAgentCoreTarget as hi, AGENTCORE_SESSION_ID_HEADER as hn, pickResponseTemplate as hr, addAlbSpecificOptions as ht, coerceStopRequest as i, webSocketApiMatchesIdentifier as ii, invokeAgentCoreWs as in, applyCorsResponseHeaders as ir, pickKvsLogicalIdFromArn as it, createLocalStartCloudFrontCommand as j, createAuthorizerCache as jn, resolveRuntimeCodeMountPath as jr, buildEcsImageResolutionContext$1 as jt, LocalStartCloudFrontError as k, createWatchPredicates as kn, architectureToPlatform as kr, addEcsAssumeRoleOptions as kt, relayServeRequest as l, AGENTCORE_AGUI_PROTOCOL as li, MCP_PATH as ln, matchRoute as lr, runViewerRequest as lt, annotateAlbPinnedBackingServices as m, pickAgentCoreCandidateStack as mi, signAgentCoreInvocation as mn, evaluateResponseParameters as mr, createUnboundCloudFrontModule as mt, coerceRunRequest as n, filterWebSocketApisByIdentifiers as ni, createLocalInvokeAgentCoreCommand as nn, invokeTokenAuthorizer as nr, isCloudFrontDistribution as nt, resolveServeBaseUrl as o, pickRefLogicalId as oi, A2A_PATH as on, buildCorsConfigFromCloudFrontChain as or, pickTargetFunctionLogicalId as ot, isCustomResourceLambdaTarget as p, AgentCoreResolutionError as pi, AGENTCORE_SIGV4_SERVICE as pn, buildRestV1Event as pr, createLocalFileKvsDataSource as pt, applyEdgeRequestResult as q, buildJwksUrlFromIssuer as qn, collectSsmParameterRefs as qr, listPinnedTargets as qt, coerceServeRequest as r, parseSelectionExpressionPath as ri, bridgeAgentCoreWs as rn, attachAuthorizers as rr, pickFunctionUrlLogicalIdFromOrigin as rt, createStudioServeManager as s, resolveLambdaArnIntrinsic as si, a2aInvokeOnce as sn, isFunctionUrlOacFronted as sr, resolveCloudFrontDistribution as st, addStudioSpecificOptions as t, discoverWebSocketApisOrThrow as ti, addInvokeAgentCoreSpecificOptions as tn, invokeRequestAuthorizer as tr, extractKvsAssociations as tt, reinvoke as u, AGENTCORE_HTTP_PROTOCOL as ui, MCP_PROTOCOL_VERSION as un, translateLambdaResponse as ur, runViewerResponse as ut, startStudioServer as v, substituteImagePlaceholders as vi, downloadAndExtractS3Bundle as vn, VtlEvaluationError as vr, parseLbPortOverrides as vt, createLocalListCommand as w, classifySourceChange as wn, handleConnectionsRequest as wr, serviceStrategy as wt, createStudioStore as x, buildStsClientConfig as xi, computeCodeImageTag as xn, bufferToBody as xr, resolveAlbFrontDoor as xt, toStudioTargetGroups as y, tryResolveImageFnJoin as yi, SUPPORTED_CODE_RUNTIMES as yn, HOST_GATEWAY_MIN_VERSION as yr, resolveAlbTarget as yt, resolveDeployedOriginBucket as z, filterRoutesByApiIdentifiers as zn, LocalStateSourceError as zr, buildImageOverrideTag as zt };
36507
- //# sourceMappingURL=local-studio-DHe0Uv7k.js.map
36556
+ //# sourceMappingURL=local-studio-BlFoXpwl.js.map