@go-to-k/cdkd 0.117.1 → 0.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -7901,11 +7901,12 @@ var DynamoDBTableProvider = class {
7901
7901
  * is surfaced for BOTH the LOCAL replica AND cross-region replicas
7902
7902
  * via per-region SDK clients (cached in `regionalClientCache` for
7903
7903
  * the deploy run). Issue #389 lifted the v1 LOCAL-only limitation.
7904
- * - Cross-region replica Tags propagation (Issue #389): when the
7905
- * update path detects a Tags-only diff on a non-local replica,
7906
- * cdkd resolves the replica's table ARN by swapping the region
7907
- * segment of the local ARN and issues `TagResource` /
7908
- * `UntagResource` against a per-region client.
7904
+ * - Cross-region replica Tags propagation (Issue #389 / #441):
7905
+ * BOTH `create()` and `update()` resolve each non-local replica's
7906
+ * table ARN by swapping the region segment of the local ARN and
7907
+ * issue `TagResource` / `UntagResource` against a per-region
7908
+ * client. The shared helper `applyCrossRegionReplicaTagsDiff`
7909
+ * centralizes the diff + best-effort WARN-on-failure contract.
7909
7910
  */
7910
7911
  var DynamoDBGlobalTableProvider = class {
7911
7912
  dynamoDBClient;
@@ -8114,6 +8115,13 @@ var DynamoDBGlobalTableProvider = class {
8114
8115
  if (!region || region === currentRegion) continue;
8115
8116
  await this.addReplica(tableName, replica, region, logicalId);
8116
8117
  }
8118
+ for (const replica of replicas) {
8119
+ const region = replica["Region"];
8120
+ if (!region || region === currentRegion) continue;
8121
+ const replicaTags = replica["Tags"];
8122
+ if (!replicaTags || replicaTags.length === 0) continue;
8123
+ await this.applyCrossRegionReplicaTagsDiff(tableInfo.tableArn, region, void 0, replicaTags, tableName);
8124
+ }
8117
8125
  if (properties["TimeToLiveSpecification"]) {
8118
8126
  const ttl = properties["TimeToLiveSpecification"];
8119
8127
  const attributeName = ttl["AttributeName"];
@@ -8299,6 +8307,8 @@ var DynamoDBGlobalTableProvider = class {
8299
8307
  const region = replica["Region"];
8300
8308
  if (!region || region === currentRegion) continue;
8301
8309
  await this.addReplica(physicalId, replica, region, logicalId);
8310
+ const newReplicaTags = replica["Tags"];
8311
+ await this.applyCrossRegionReplicaTagsDiff(tableArn, region, void 0, newReplicaTags, physicalId);
8302
8312
  const newReadAutoScaling = (replica["ReadProvisionedThroughputSettings"] ?? {})["ReadCapacityAutoScalingSettings"];
8303
8313
  if (newBilling === "PROVISIONED" && newReadAutoScaling) {
8304
8314
  const regionalAutoScalingClient = this.getRegionalAutoScalingClient(region);
@@ -8311,16 +8321,7 @@ var DynamoDBGlobalTableProvider = class {
8311
8321
  const oldReplica = (previousProperties["Replicas"] ?? []).find((r) => r["Region"] === region);
8312
8322
  const oldReplicaTags = oldReplica?.["Tags"];
8313
8323
  const newReplicaTags = replica["Tags"];
8314
- if (!deepEqual$1(oldReplicaTags, newReplicaTags)) if (tableArn) {
8315
- const replicaArn = this.replicaArnForRegion(tableArn, region);
8316
- if (replicaArn) try {
8317
- const regionalClient = this.getRegionalClient(region);
8318
- await this.applyTagDiffOnClient(regionalClient, replicaArn, oldReplicaTags, newReplicaTags);
8319
- } catch (tagErr) {
8320
- this.logger.warn(`Could not apply Tags diff to cross-region replica ${region} of ${physicalId}: ${tagErr instanceof Error ? tagErr.message : String(tagErr)}. The replica's Tags state will surface as drift until the next successful deploy.`);
8321
- }
8322
- else this.logger.warn(`Could not derive replica ARN for region ${region} from ${tableArn} — skipping Tags propagation for ${physicalId}`);
8323
- } else this.logger.warn(`Local DescribeTable returned no TableArn — cannot propagate Tags to cross-region replica ${region} of ${physicalId}`);
8324
+ await this.applyCrossRegionReplicaTagsDiff(tableArn, region, oldReplicaTags, newReplicaTags, physicalId);
8324
8325
  const oldReadAutoScaling = (oldReplica?.["ReadProvisionedThroughputSettings"] ?? {})["ReadCapacityAutoScalingSettings"];
8325
8326
  const newReadAutoScaling = (replica["ReadProvisionedThroughputSettings"] ?? {})["ReadCapacityAutoScalingSettings"];
8326
8327
  const effectiveNewReadAutoScaling = newBilling === "PAY_PER_REQUEST" ? void 0 : newReadAutoScaling;
@@ -8439,10 +8440,49 @@ var DynamoDBGlobalTableProvider = class {
8439
8440
  await this.applyTagDiffOnClient(this.dynamoDBClient, tableArn, oldTagsRaw, newTagsRaw);
8440
8441
  }
8441
8442
  /**
8443
+ * Propagate a per-replica Tags diff to ONE cross-region replica via a
8444
+ * per-region client (Issue #389 / #441 — closes the create-side gap).
8445
+ * Centralizes the common shape used by BOTH `create()` (`oldTags`
8446
+ * undefined → every new tag is an add) and `update()` (per-replica
8447
+ * modify path's old-vs-new diff). Best-effort: a failure here logs at
8448
+ * WARN naming the offending region + ARN + reason and the deploy
8449
+ * continues — the cross-region Tags state will surface as drift on
8450
+ * the next run (or `cdkd drift --revert`) rather than aborting the
8451
+ * deploy mid-flight. Mirrors the autoscaling diff's failure contract
8452
+ * (PR #393).
8453
+ *
8454
+ * `tableArn` is the LOCAL replica's table ARN (returned by the
8455
+ * post-create `waitForTableActive` or the inline `DescribeTable` in
8456
+ * `update()`); the helper swaps the region segment via
8457
+ * `replicaArnForRegion` before issuing `TagResource` / `UntagResource`
8458
+ * against the per-region client.
8459
+ *
8460
+ * No-op when `oldTags` deep-equals `newTags` — the caller is allowed
8461
+ * to invoke unconditionally without first diffing.
8462
+ */
8463
+ async applyCrossRegionReplicaTagsDiff(tableArn, region, oldTags, newTags, physicalIdForLogs) {
8464
+ if (deepEqual$1(oldTags, newTags)) return;
8465
+ if (!tableArn) {
8466
+ this.logger.warn(`Local DescribeTable returned no TableArn — cannot propagate Tags to cross-region replica ${region} of ${physicalIdForLogs}`);
8467
+ return;
8468
+ }
8469
+ const replicaArn = this.replicaArnForRegion(tableArn, region);
8470
+ if (!replicaArn) {
8471
+ this.logger.warn(`Could not derive replica ARN for region ${region} from ${tableArn} — skipping Tags propagation for ${physicalIdForLogs}`);
8472
+ return;
8473
+ }
8474
+ try {
8475
+ const regionalClient = this.getRegionalClient(region);
8476
+ await this.applyTagDiffOnClient(regionalClient, replicaArn, oldTags, newTags);
8477
+ } catch (tagErr) {
8478
+ this.logger.warn(`Could not apply Tags diff to cross-region replica ${region} of ${physicalIdForLogs}: ${tagErr instanceof Error ? tagErr.message : String(tagErr)}. The replica's Tags state will surface as drift until the next successful deploy.`);
8479
+ }
8480
+ }
8481
+ /**
8442
8482
  * Apply a Tags diff against the given `DynamoDBClient` (which may be
8443
8483
  * the local client or a per-region client returned by
8444
8484
  * `getRegionalClient`). Used by the local-replica path AND the
8445
- * cross-region replica Tags propagation path (Issue #389).
8485
+ * cross-region replica Tags propagation path (Issue #389 / #441).
8446
8486
  */
8447
8487
  async applyTagDiffOnClient(client, tableArn, oldTagsRaw, newTagsRaw) {
8448
8488
  const toMap = (tags) => {
@@ -8732,9 +8772,12 @@ var DynamoDBGlobalTableProvider = class {
8732
8772
  *
8733
8773
  * Per-replica sub-specifications (`ContributorInsightsSpecification` /
8734
8774
  * `PointInTimeRecoverySpecification` / `KinesisStreamSpecification`)
8735
- * are surfaced only for the LOCAL replica. Cross-region replicas
8736
- * require per-region SDK clients (`new DynamoDBClient({region})`),
8737
- * deferred to a follow-up PR.
8775
+ * are surfaced for BOTH the LOCAL replica AND cross-region replicas
8776
+ * via per-region SDK clients cached in `regionalClientCache` (Issue
8777
+ * #389 lifted the v1 LOCAL-only limitation; the per-replica reads
8778
+ * happen in `readReplicaSubSpecs` below). Each cross-region call is
8779
+ * best-effort — a permissions gap in one region omits the offending
8780
+ * key rather than aborting the whole drift read.
8738
8781
  */
8739
8782
  async readCurrentState(physicalId, _logicalId, _resourceType) {
8740
8783
  try {
@@ -37254,8 +37297,11 @@ function buildSubstitutionContextFromImageContext(context) {
37254
37297
  * `physicalId`, and `Fn::GetAtt: [<Repo>, 'RepositoryUri']` shapes
37255
37298
  * are resolved via the same state record.
37256
37299
  *
37257
- * Tier 3 (cross-account / cross-region pull) is deferred — `pullEcrImage`
37258
- * surfaces the same workaround pointer it already does.
37300
+ * Cross-account / cross-region pull (#455): `pullEcrImage` auto-detects
37301
+ * cross-account from `sts:GetCallerIdentity` and authenticates against
37302
+ * the URI's region directly. Pass `--ecr-role-arn <arn>` when the caller
37303
+ * does not already have cross-account `ecr:GetAuthorizationToken` /
37304
+ * `ecr:BatchGetImage` access on the target repository.
37259
37305
  */
37260
37306
  function parseContainerImage(raw, containerName, taskLogicalId, resources, _stack, context) {
37261
37307
  const getAttImage = tryResolveImageGetAtt(raw, resources, context);
@@ -37926,16 +37972,26 @@ function redactAwsCredentialsInArgs(args) {
37926
37972
  //#endregion
37927
37973
  //#region src/local/ecr-puller.ts
37928
37974
  /**
37929
- * ECR pull fallback for `cdkd local invoke` against deployed container
37930
- * Lambdas (PR 5, D5.2). When `Code.ImageUri` resolves to an ECR URI but
37975
+ * ECR pull fallback for `cdkd local invoke` / `cdkd local start-api` /
37976
+ * `cdkd local run-task`. When the image URI resolves to an ECR repo but
37931
37977
  * doesn't match any cdk.out asset (typical when invoking a stack
37932
- * deployed elsewhere), cdkd attempts `docker pull` against the same
37933
- * account/region.
37934
- *
37935
- * **Same-account / same-region only**:
37936
- * - Cross-account requires AssumeRole + a different ECR client. Hard
37937
- * error with a pointer at the deferred follow-up PR.
37938
- * - Cross-region requires a region-aware ECR client. Same hard error.
37978
+ * deployed elsewhere or sharing a centralized registry), cdkd
37979
+ * authenticates against the target registry and runs `docker pull`.
37980
+ *
37981
+ * **Cross-account / cross-region** (#455):
37982
+ * - Same-account, same-region: fast path. No STS hop. The default
37983
+ * credential chain is used directly for `ecr:GetAuthorizationToken`.
37984
+ * - `ecrRoleArn` is provided: `sts:AssumeRole` is issued via the
37985
+ * default credential chain to obtain temporary credentials for the
37986
+ * target account. The resulting credentials authenticate the ECR
37987
+ * client (regardless of region — the ECR client is built for the
37988
+ * URI's region, which can differ from the caller's profile region).
37989
+ * - Cross-account, NO `ecrRoleArn`: cdkd falls through to the
37990
+ * default credential chain. This works when the caller has been
37991
+ * granted cross-account `ecr:GetAuthorizationToken` +
37992
+ * `ecr:BatchGetImage` permissions on the target repository via an
37993
+ * IAM policy; otherwise AWS rejects the call with `AccessDenied`
37994
+ * and the user is pointed at `--ecr-role-arn`.
37939
37995
  *
37940
37996
  * The `--no-pull` semantics (C3 in the design doc):
37941
37997
  * - When NOT set: `ecrLogin` + `docker pull <uri>`.
@@ -37962,34 +38018,87 @@ function parseEcrUri(imageUri) {
37962
38018
  };
37963
38019
  }
37964
38020
  /**
37965
- * Pull (or verify locally cached) a container Lambda image from ECR.
38021
+ * Module-level cache for STS-issued AssumeRole credentials, keyed by
38022
+ * `(ecrRoleArn, callerRegion)`. Closes the reviewer's MAJOR finding: ECS
38023
+ * run-task with N containers under one `--ecr-role-arn` would otherwise issue
38024
+ * N× `AssumeRole` and N× `GetCallerIdentity` for identical credentials valid
38025
+ * for 3600s. The cache keeps a 5-minute safety margin against the recorded
38026
+ * `Expiration` so STS-side / local-clock skew never lets a stale entry through.
37966
38027
  *
37967
- * Verifies same-account / same-region against the caller's STS identity
37968
- * before issuing any docker command. Returns the image URI the caller
37969
- * should pass to `docker run` (same as the input no rewriting).
38028
+ * Cache key is intentionally `(roleArn, region)` rather than full caller
38029
+ * identity STS issues per-region session creds, and a switch of `--region`
38030
+ * between two `local invoke` calls in the same process must re-issue.
38031
+ *
38032
+ * NOT cleared on process exit — Node's module scope evaporates with the
38033
+ * process, and no inter-process sharing is desired (each `cdkd local invoke`
38034
+ * is its own isolated runtime).
38035
+ */
38036
+ const ASSUMED_ROLE_CACHE = /* @__PURE__ */ new Map();
38037
+ /**
38038
+ * Module-level cache for `STS:GetCallerIdentity`. The result is identity-only
38039
+ * (`Account`) and invariant for the lifetime of the process under one set of
38040
+ * default credentials. Keyed by `callerRegion` to avoid a cross-region leak
38041
+ * when the caller flips `AWS_REGION` mid-process (STS is global but the SDK
38042
+ * uses regional endpoints; the result is invariant in practice, but we key
38043
+ * on region for safety).
38044
+ */
38045
+ const CALLER_IDENTITY_CACHE = /* @__PURE__ */ new Map();
38046
+ /** 5-minute safety margin against the recorded STS expiration timestamp. */
38047
+ const STS_CREDENTIAL_SAFETY_MARGIN_MS = 300 * 1e3;
38048
+ function isCredentialFresh(creds) {
38049
+ if (!creds.expiration) return false;
38050
+ return creds.expiration.getTime() - Date.now() > STS_CREDENTIAL_SAFETY_MARGIN_MS;
38051
+ }
38052
+ /**
38053
+ * Pull (or verify locally cached) a container image from ECR.
38054
+ *
38055
+ * Auto-detects cross-account from `STS:GetCallerIdentity` and assumes
38056
+ * the supplied role when set. Returns the image URI the caller should
38057
+ * pass to `docker run` (same as the input — no rewriting).
37970
38058
  */
37971
38059
  async function pullEcrImage(imageUri, options) {
37972
38060
  const logger = getLogger().child("ecr-puller");
37973
38061
  const parsed = parseEcrUri(imageUri);
37974
38062
  if (!parsed) throw new LocalInvokeBuildError(`Image URI '${imageUri}' is not an ECR URI. cdkd local invoke v1 only authenticates against ECR for the deployed-image fallback path.`);
37975
- const sts = new STSClient({ region: parsed.region });
37976
- let callerAccount;
37977
- try {
37978
- const identity = await sts.send(new GetCallerIdentityCommand({}));
37979
- if (!identity.Account) throw new LocalInvokeBuildError("STS GetCallerIdentity returned no Account. Verify your AWS credentials.");
37980
- callerAccount = identity.Account;
37981
- } finally {
37982
- sts.destroy();
37983
- }
37984
- if (callerAccount !== parsed.accountId) throw new LocalInvokeBuildError(`Image URI '${imageUri}' is in account ${parsed.accountId}, but the caller is ${callerAccount}. Cross-account ECR pull is not supported in cdkd local invoke v1 — deferred to a follow-up PR. Workaround: assume a role in the target account before invoking, or build the image locally with \`cdkd local invoke -a cdk.out\` (no ECR pull).`);
37985
38063
  const callerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"];
37986
- if (callerRegion && callerRegion !== parsed.region) throw new LocalInvokeBuildError(`Image URI '${imageUri}' is in region ${parsed.region}, but the caller's region is ${callerRegion}. Cross-region ECR pull is not supported in cdkd local invoke v1 — deferred to a follow-up PR. Workaround: re-run with AWS_REGION=${parsed.region} set, or build the image locally with -a cdk.out.`);
37987
38064
  if (options.skipPull) {
37988
38065
  logger.info(`Skipping ECR pull (--no-pull). Verifying ${imageUri} is in local cache...`);
37989
38066
  await verifyImageInLocalCache(imageUri);
37990
38067
  return imageUri;
37991
38068
  }
37992
- const ecr = new ECRClient({ region: parsed.region });
38069
+ const callerIdentityKey = callerRegion ?? "_unset";
38070
+ let callerAccount = CALLER_IDENTITY_CACHE.get(callerIdentityKey);
38071
+ if (callerAccount === void 0) {
38072
+ const sts = new STSClient({ ...callerRegion && { region: callerRegion } });
38073
+ try {
38074
+ const identity = await sts.send(new GetCallerIdentityCommand({}));
38075
+ if (!identity.Account) throw new LocalInvokeBuildError("STS GetCallerIdentity returned no Account. Verify your AWS credentials.");
38076
+ callerAccount = identity.Account;
38077
+ CALLER_IDENTITY_CACHE.set(callerIdentityKey, callerAccount);
38078
+ } finally {
38079
+ sts.destroy();
38080
+ }
38081
+ }
38082
+ const crossAccount = callerAccount !== parsed.accountId;
38083
+ const crossRegion = callerRegion !== void 0 && callerRegion !== parsed.region;
38084
+ let assumed;
38085
+ if (options.ecrRoleArn) {
38086
+ const cacheKey = `${options.ecrRoleArn}|${callerRegion ?? "_unset"}`;
38087
+ const cached = ASSUMED_ROLE_CACHE.get(cacheKey);
38088
+ if (cached && isCredentialFresh(cached)) {
38089
+ assumed = cached;
38090
+ logger.debug(`Reusing cached AssumeRole credentials for ${options.ecrRoleArn}`);
38091
+ } else {
38092
+ assumed = await assumeRoleForEcr(options.ecrRoleArn, callerRegion, logger);
38093
+ ASSUMED_ROLE_CACHE.set(cacheKey, assumed);
38094
+ logger.info(`Assumed role ${options.ecrRoleArn} for ECR pull (account=${parsed.accountId}, region=${parsed.region})`);
38095
+ }
38096
+ } 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.`);
38097
+ if (crossRegion) logger.info(`Cross-region ECR pull: image region ${parsed.region} != caller ${callerRegion ?? "(unset)"}. Authenticating against the image region directly.`);
38098
+ const ecr = new ECRClient({
38099
+ region: parsed.region,
38100
+ ...assumed && { credentials: assumed }
38101
+ });
37993
38102
  try {
37994
38103
  await ecrLogin(ecr, parsed.accountId, parsed.region);
37995
38104
  } finally {
@@ -38004,10 +38113,39 @@ async function pullEcrImage(imageUri, options) {
38004
38113
  return imageUri;
38005
38114
  }
38006
38115
  /**
38007
- * Authenticate the local docker daemon against the same-account ECR
38008
- * registry. Mirrors `DockerAssetPublisher.ecrLogin` but stays in this
38009
- * module so the local-invoke path doesn't depend on the publisher's
38010
- * larger surface area.
38116
+ * Assume the supplied role via the SDK default credential chain and
38117
+ * return the resulting temporary credentials. The STS client is built
38118
+ * with the caller's profile region (or unset) STS is a global
38119
+ * service so the region is informational, but threading it through
38120
+ * mirrors the convention used by `src/utils/role-arn.ts`.
38121
+ */
38122
+ async function assumeRoleForEcr(roleArn, callerRegion, logger) {
38123
+ logger.debug(`Assuming role ${roleArn} for ECR pull...`);
38124
+ const sts = new STSClient({ ...callerRegion && { region: callerRegion } });
38125
+ try {
38126
+ const creds = (await sts.send(new AssumeRoleCommand({
38127
+ RoleArn: roleArn,
38128
+ RoleSessionName: `cdkd-local-ecr-${Date.now()}`,
38129
+ DurationSeconds: 3600
38130
+ }))).Credentials;
38131
+ 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.`);
38132
+ return {
38133
+ accessKeyId: creds.AccessKeyId,
38134
+ secretAccessKey: creds.SecretAccessKey,
38135
+ sessionToken: creds.SessionToken,
38136
+ ...creds.Expiration && { expiration: creds.Expiration }
38137
+ };
38138
+ } catch (err) {
38139
+ if (err instanceof LocalInvokeBuildError) throw err;
38140
+ 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.`);
38141
+ } finally {
38142
+ sts.destroy();
38143
+ }
38144
+ }
38145
+ /**
38146
+ * Authenticate the local docker daemon against the target ECR registry.
38147
+ * Mirrors `DockerAssetPublisher.ecrLogin` but stays in this module so the
38148
+ * local-invoke path doesn't depend on the publisher's larger surface area.
38011
38149
  */
38012
38150
  async function ecrLogin(client, accountId, region) {
38013
38151
  getLogger().child("ecr-puller").debug(`ECR login (account=${accountId}, region=${region})`);
@@ -43702,7 +43840,8 @@ async function prepareOneImage(task, container, options) {
43702
43840
  return image.uri;
43703
43841
  case "ecr": return pullEcrImage(image.uri, {
43704
43842
  skipPull: options.skipPull,
43705
- ...options.region !== void 0 && { region: options.region }
43843
+ ...options.region !== void 0 && { region: options.region },
43844
+ ...options.ecrRoleArn !== void 0 && { ecrRoleArn: options.ecrRoleArn }
43706
43845
  });
43707
43846
  case "cdk-asset": {
43708
43847
  const cdkOutDir = task.stack.assetManifestPath ? dirname(task.stack.assetManifestPath) : void 0;
@@ -43956,6 +44095,7 @@ async function localRunTaskCommand(target, options) {
43956
44095
  if (resolvedRoleArn) runOpts.taskRoleArn = resolvedRoleArn;
43957
44096
  if (options.platform) runOpts.platformOverride = options.platform;
43958
44097
  if (options.region) runOpts.region = options.region;
44098
+ if (options.ecrRoleArn) runOpts.ecrRoleArn = options.ecrRoleArn;
43959
44099
  const result = await runEcsTask(task, runOpts, state);
43960
44100
  if (options.detach) {
43961
44101
  logger.info("Task containers started in detached mode; cdkd is exiting.");
@@ -44105,7 +44245,7 @@ function readEnvOverridesFile$1(filePath) {
44105
44245
  return parsed;
44106
44246
  }
44107
44247
  function createLocalRunTaskCommand() {
44108
- 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.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::TaskDefinition to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).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("--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("--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-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt references to same-stack AWS::ECR::Repository resources with the deployed URI. Off by default — only the AWS pseudo-parameter tier (${AWS::AccountId} / ${AWS::Region}) is resolved without this flag.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localRunTaskCommand));
44248
+ 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.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::TaskDefinition to run").addOption(new Option("--cluster <name>", "Cluster name surfaced to ECS_CONTAINER_METADATA_URI_V4 and used as the docker network prefix").default("cdkd-local")).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("--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 (#455). 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-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt references to same-stack AWS::ECR::Repository resources with the deployed URI. Off by default — only the AWS pseudo-parameter tier (${AWS::AccountId} / ${AWS::Region}) is resolved without this flag.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localRunTaskCommand));
44109
44249
  [
44110
44250
  ...commonOptions,
44111
44251
  ...appOptions,
@@ -44449,7 +44589,8 @@ async function resolveContainerImagePlan(lambda, options) {
44449
44589
  logger.info(`No matching cdk.out asset for ${lambda.imageUri}; falling back to ECR pull (same-acct/region only)...`);
44450
44590
  imageRef = await pullEcrImage(lambda.imageUri, {
44451
44591
  skipPull: options.pull === false,
44452
- ...options.region !== void 0 && { region: options.region }
44592
+ ...options.region !== void 0 && { region: options.region },
44593
+ ...options.ecrRoleArn !== void 0 && { ecrRoleArn: options.ecrRoleArn }
44453
44594
  });
44454
44595
  }
44455
44596
  const tmpfs = resolveTmpfsForLambda(lambda);
@@ -44743,7 +44884,7 @@ function pickReferencedLogicalId(intrinsic) {
44743
44884
  */
44744
44885
  function createLocalCommand() {
44745
44886
  const local = new Command("local").description("Local execution of Lambda functions (RIE) and ECS task definitions (Docker required)");
44746
- 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.").argument("<target>", "CDK display path or stack-qualified logical ID of the Lambda to invoke").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 (closes the \"developer admin / function narrow\" skew). Three forms: (1) `--assume-role <arn>` assumes the explicit ARN; (2) `--assume-role` (bare) auto-resolves the function's execution role ARN from cdkd state (requires --from-state); (3) `--no-assume-role` explicitly opts out (forces dev creds even with --from-state). 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("--from-state", "Read cdkd S3 state for the target stack and substitute Ref / Fn::GetAtt / Fn::Sub in env vars with the deployed physical IDs / attributes. Off by default — keep PR 1 warn-and-drop semantics; turn on for stacks already deployed via cdkd deploy.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localInvokeCommand));
44887
+ 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.").argument("<target>", "CDK display path or stack-qualified logical ID of the Lambda to invoke").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 (closes the \"developer admin / function narrow\" skew). Three forms: (1) `--assume-role <arn>` assumes the explicit ARN; (2) `--assume-role` (bare) auto-resolves the function's execution role ARN from cdkd state (requires --from-state); (3) `--no-assume-role` explicitly opts out (forces dev creds even with --from-state). 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("--ecr-role-arn <arn>", "Role ARN to assume before authenticating against ECR for cross-account / centralized registries (#455). 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-state", "Read cdkd S3 state for the target stack and substitute Ref / Fn::GetAtt / Fn::Sub in env vars with the deployed physical IDs / attributes. Off by default — keep PR 1 warn-and-drop semantics; turn on for stacks already deployed via cdkd deploy.").default(false)).addOption(new Option("--stack-region <region>", "Region of the cdkd state record to read (used with --from-state when the same stack name has state in multiple regions).")).action(withErrorHandling(localInvokeCommand));
44747
44888
  [
44748
44889
  ...commonOptions,
44749
44890
  ...appOptions,
@@ -46031,7 +46172,7 @@ function reorderArgs(argv) {
46031
46172
  */
46032
46173
  async function main() {
46033
46174
  const program = new Command();
46034
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.117.1");
46175
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.118.0");
46035
46176
  program.addCommand(createBootstrapCommand());
46036
46177
  program.addCommand(createSynthCommand());
46037
46178
  program.addCommand(createListCommand());