@go-to-k/cdkd 0.137.3 → 0.139.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -5
- package/dist/cli.js +995 -200
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -55,7 +55,7 @@ import { AddTagsCommand as AddTagsCommand$1, CloudTrailClient, CreateTrailComman
|
|
|
55
55
|
import { BatchGetProjectsCommand, CodeBuildClient, CreateProjectCommand, DeleteProjectCommand, ListProjectsCommand, ResourceNotFoundException as ResourceNotFoundException$9, UpdateProjectCommand } from "@aws-sdk/client-codebuild";
|
|
56
56
|
import { CreateVectorBucketCommand, DeleteIndexCommand, DeleteVectorBucketCommand, GetVectorBucketCommand, ListIndexesCommand, ListTagsForResourceCommand as ListTagsForResourceCommand$18, ListVectorBucketsCommand, S3VectorsClient } from "@aws-sdk/client-s3vectors";
|
|
57
57
|
import { CreateNamespaceCommand, CreateTableBucketCommand, CreateTableCommand as CreateTableCommand$2, DeleteNamespaceCommand as DeleteNamespaceCommand$1, DeleteTableBucketCommand, DeleteTableCommand as DeleteTableCommand$2, GetTableBucketCommand, GetTableCommand as GetTableCommand$1, ListNamespacesCommand as ListNamespacesCommand$1, ListTableBucketsCommand, ListTablesCommand as ListTablesCommand$1, ListTagsForResourceCommand as ListTagsForResourceCommand$19, NotFoundException as NotFoundException$5, S3TablesClient } from "@aws-sdk/client-s3tables";
|
|
58
|
-
import { AttachTrafficSourcesCommand, AutoScalingClient, CreateAutoScalingGroupCommand, DeleteAutoScalingGroupCommand, DeleteLifecycleHookCommand, DeleteNotificationConfigurationCommand, DescribeAutoScalingGroupsCommand, DescribeLifecycleHooksCommand, DescribeNotificationConfigurationsCommand, DescribeTrafficSourcesCommand, DetachTrafficSourcesCommand, DisableMetricsCollectionCommand, EnableMetricsCollectionCommand, PutLifecycleHookCommand, PutNotificationConfigurationCommand, UpdateAutoScalingGroupCommand } from "@aws-sdk/client-auto-scaling";
|
|
58
|
+
import { AttachLoadBalancerTargetGroupsCommand, AttachLoadBalancersCommand, AttachTrafficSourcesCommand, AutoScalingClient, CreateAutoScalingGroupCommand, CreateOrUpdateTagsCommand, DeleteAutoScalingGroupCommand, DeleteLifecycleHookCommand, DeleteNotificationConfigurationCommand, DeleteTagsCommand as DeleteTagsCommand$1, DescribeAutoScalingGroupsCommand, DescribeLifecycleHooksCommand, DescribeNotificationConfigurationsCommand, DescribeTrafficSourcesCommand, DetachLoadBalancerTargetGroupsCommand, DetachLoadBalancersCommand, DetachTrafficSourcesCommand, DisableMetricsCollectionCommand, EnableMetricsCollectionCommand, PutLifecycleHookCommand, PutNotificationConfigurationCommand, UpdateAutoScalingGroupCommand } from "@aws-sdk/client-auto-scaling";
|
|
59
59
|
import * as readline from "node:readline/promises";
|
|
60
60
|
import { Document, Pair, Scalar, YAMLMap, YAMLSeq, parse as parse$1, stringify } from "yaml";
|
|
61
61
|
import { mkdir, mkdtemp } from "node:fs/promises";
|
|
@@ -29963,27 +29963,36 @@ var ECRProvider = class {
|
|
|
29963
29963
|
* Groups` already provides.
|
|
29964
29964
|
*
|
|
29965
29965
|
* Update has narrower coverage than create: AWS does not support modifying
|
|
29966
|
-
* `AutoScalingGroupName` (immutable)
|
|
29967
|
-
*
|
|
29968
|
-
*
|
|
29969
|
-
* `
|
|
29970
|
-
*
|
|
29971
|
-
*
|
|
29972
|
-
*
|
|
29973
|
-
*
|
|
29974
|
-
*
|
|
29975
|
-
*
|
|
29976
|
-
*
|
|
29977
|
-
*
|
|
29978
|
-
* InstancesPolicy / LaunchTemplate.
|
|
29966
|
+
* `AutoScalingGroupName` (immutable) — that diff still surfaces
|
|
29967
|
+
* `ResourceUpdateNotSupportedError` so the caller can `cdkd deploy
|
|
29968
|
+
* --replace`. The mutable fields handled in-place via
|
|
29969
|
+
* `UpdateAutoScalingGroup` include MinSize / MaxSize / DesiredCapacity /
|
|
29970
|
+
* VPCZoneIdentifier / HealthCheckType / HealthCheckGracePeriod /
|
|
29971
|
+
* DefaultCooldown / Cooldown / NewInstancesProtectedFromScaleIn /
|
|
29972
|
+
* MaxInstanceLifetime / TerminationPolicies / CapacityRebalance /
|
|
29973
|
+
* ServiceLinkedRoleARN / Context / DesiredCapacityType /
|
|
29974
|
+
* DefaultInstanceWarmup / AvailabilityZones / AvailabilityZoneDistribution
|
|
29975
|
+
* / AvailabilityZoneImpairmentPolicy / SkipZonalShiftValidation /
|
|
29976
|
+
* CapacityReservationSpecification / InstanceMaintenancePolicy /
|
|
29977
|
+
* DeletionProtection / MixedInstancesPolicy / LaunchTemplate.
|
|
29979
29978
|
*
|
|
29980
29979
|
* Sub-shape diffs are applied via dedicated AWS APIs before the main
|
|
29981
|
-
* `UpdateAutoScalingGroup` call:
|
|
29982
|
-
* `
|
|
29983
|
-
* `
|
|
29984
|
-
*
|
|
29985
|
-
* `
|
|
29986
|
-
*
|
|
29980
|
+
* `UpdateAutoScalingGroup` call:
|
|
29981
|
+
* - `Tags` → `CreateOrUpdateTags` / `DeleteTags` (#475)
|
|
29982
|
+
* - `LoadBalancerNames` → `AttachLoadBalancers` /
|
|
29983
|
+
* `DetachLoadBalancers` (#476)
|
|
29984
|
+
* - `TargetGroupARNs` → `AttachLoadBalancerTargetGroups` /
|
|
29985
|
+
* `DetachLoadBalancerTargetGroups` (#476)
|
|
29986
|
+
* - `MetricsCollection` → `EnableMetricsCollection` /
|
|
29987
|
+
* `DisableMetricsCollection`
|
|
29988
|
+
* - `LifecycleHookSpecificationList` → per-entry `PutLifecycleHook` /
|
|
29989
|
+
* `DeleteLifecycleHook`
|
|
29990
|
+
* - `TrafficSources` → `AttachTrafficSources` /
|
|
29991
|
+
* `DetachTrafficSources`
|
|
29992
|
+
* - `NotificationConfigurations` → per-topic
|
|
29993
|
+
* `PutNotificationConfiguration` /
|
|
29994
|
+
* `DeleteNotificationConfiguration`
|
|
29995
|
+
*
|
|
29987
29996
|
* Each helper is a no-op when the before/after JSON is identical.
|
|
29988
29997
|
*/
|
|
29989
29998
|
var ASGProvider = class {
|
|
@@ -30091,10 +30100,10 @@ var ASGProvider = class {
|
|
|
30091
30100
|
this.logger.debug(`Updating AutoScalingGroup ${logicalId}: ${physicalId}`);
|
|
30092
30101
|
const stringEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
30093
30102
|
if (!stringEq(properties["AutoScalingGroupName"], previousProperties["AutoScalingGroupName"])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "AutoScalingGroupName is immutable on AWS — UpdateAutoScalingGroup does not accept a new name; the name is fixed at creation. Use cdkd deploy --replace to replace the group.");
|
|
30094
|
-
if (!stringEq(properties["Tags"] ?? [], previousProperties["Tags"] ?? [])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "Tags updates on AWS::AutoScaling::AutoScalingGroup are not yet implemented in cdkd (AWS exposes CreateOrUpdateTags / DeleteTags); use cdkd deploy --replace, or update the tags via AWS console / CLI.");
|
|
30095
|
-
if (!stringEq(properties["LoadBalancerNames"] ?? [], previousProperties["LoadBalancerNames"] ?? [])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "LoadBalancerNames diffs on AWS::AutoScaling::AutoScalingGroup are not yet implemented in cdkd (AWS exposes AttachLoadBalancers / DetachLoadBalancers); use cdkd deploy --replace.");
|
|
30096
|
-
if (!stringEq(properties["TargetGroupARNs"] ?? [], previousProperties["TargetGroupARNs"] ?? [])) throw new ResourceUpdateNotSupportedError(resourceType, logicalId, "TargetGroupARNs diffs on AWS::AutoScaling::AutoScalingGroup are not yet implemented in cdkd (AWS exposes AttachLoadBalancerTargetGroups / DetachLoadBalancerTargetGroups); use cdkd deploy --replace.");
|
|
30097
30103
|
try {
|
|
30104
|
+
await this.applyTagsDiff(physicalId, properties["Tags"], previousProperties["Tags"]);
|
|
30105
|
+
await this.applyLoadBalancerNamesDiff(physicalId, properties["LoadBalancerNames"], previousProperties["LoadBalancerNames"]);
|
|
30106
|
+
await this.applyTargetGroupArnsDiff(physicalId, properties["TargetGroupARNs"], previousProperties["TargetGroupARNs"]);
|
|
30098
30107
|
await this.applyMetricsCollectionDiff(physicalId, properties["MetricsCollection"], previousProperties["MetricsCollection"]);
|
|
30099
30108
|
await this.applyLifecycleHooksDiff(physicalId, properties["LifecycleHookSpecificationList"], previousProperties["LifecycleHookSpecificationList"]);
|
|
30100
30109
|
await this.applyTrafficSourcesDiff(physicalId, properties["TrafficSources"], previousProperties["TrafficSources"]);
|
|
@@ -30354,6 +30363,99 @@ var ASGProvider = class {
|
|
|
30354
30363
|
sleep(ms) {
|
|
30355
30364
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
30356
30365
|
}
|
|
30366
|
+
/**
|
|
30367
|
+
* Diff and apply changes to the ASG's `Tags` property via the
|
|
30368
|
+
* `CreateOrUpdateTags` / `DeleteTags` AWS APIs (#475). CFn Tags shape is
|
|
30369
|
+
* `[{Key, Value, PropagateAtLaunch}]`; AWS Tag input adds `ResourceId`
|
|
30370
|
+
* (= the ASG name) and `ResourceType: 'auto-scaling-group'`.
|
|
30371
|
+
*
|
|
30372
|
+
* Diff semantics:
|
|
30373
|
+
* - Removed keys → `DeleteTags`.
|
|
30374
|
+
* - Added keys → `CreateOrUpdateTags`.
|
|
30375
|
+
* - Modified value or `PropagateAtLaunch` flag → `CreateOrUpdateTags`
|
|
30376
|
+
* (the AWS API upserts by `(ResourceId, ResourceType, Key)` tuple, so
|
|
30377
|
+
* a single upsert call replaces the old value).
|
|
30378
|
+
*
|
|
30379
|
+
* No-op when before/after JSON is identical.
|
|
30380
|
+
*/
|
|
30381
|
+
async applyTagsDiff(physicalId, next, prev) {
|
|
30382
|
+
if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
|
|
30383
|
+
const nextEntries = Array.isArray(next) ? next : [];
|
|
30384
|
+
const prevEntries = Array.isArray(prev) ? prev : [];
|
|
30385
|
+
const nextByKey = /* @__PURE__ */ new Map();
|
|
30386
|
+
for (const t of nextEntries) if (t.Key) nextByKey.set(t.Key, t);
|
|
30387
|
+
const prevByKey = /* @__PURE__ */ new Map();
|
|
30388
|
+
for (const t of prevEntries) if (t.Key) prevByKey.set(t.Key, t);
|
|
30389
|
+
const toDelete = [];
|
|
30390
|
+
for (const [key, tag] of prevByKey) if (!nextByKey.has(key)) toDelete.push(tag);
|
|
30391
|
+
if (toDelete.length > 0) await this.getClient().send(new DeleteTagsCommand$1({ Tags: toDelete.map((t) => ({
|
|
30392
|
+
ResourceId: physicalId,
|
|
30393
|
+
ResourceType: "auto-scaling-group",
|
|
30394
|
+
Key: t.Key
|
|
30395
|
+
})) }));
|
|
30396
|
+
const toUpsert = [];
|
|
30397
|
+
for (const [key, tag] of nextByKey) {
|
|
30398
|
+
const before = prevByKey.get(key);
|
|
30399
|
+
if (JSON.stringify(before) === JSON.stringify(tag)) continue;
|
|
30400
|
+
toUpsert.push(tag);
|
|
30401
|
+
}
|
|
30402
|
+
if (toUpsert.length > 0) await this.getClient().send(new CreateOrUpdateTagsCommand({ Tags: toUpsert.map((t) => ({
|
|
30403
|
+
ResourceId: physicalId,
|
|
30404
|
+
ResourceType: "auto-scaling-group",
|
|
30405
|
+
Key: t.Key,
|
|
30406
|
+
...t.Value !== void 0 && { Value: t.Value },
|
|
30407
|
+
...t.PropagateAtLaunch !== void 0 && { PropagateAtLaunch: t.PropagateAtLaunch }
|
|
30408
|
+
})) }));
|
|
30409
|
+
}
|
|
30410
|
+
/**
|
|
30411
|
+
* Diff `LoadBalancerNames` (Classic Load Balancers) and issue
|
|
30412
|
+
* `AttachLoadBalancers` / `DetachLoadBalancers` for the delta (#476).
|
|
30413
|
+
* Names are opaque strings; AWS allows N attached LBs per ASG so this
|
|
30414
|
+
* helper batches every add into one Attach call and every remove into
|
|
30415
|
+
* one Detach call. No-op when before/after JSON is identical.
|
|
30416
|
+
*/
|
|
30417
|
+
async applyLoadBalancerNamesDiff(physicalId, next, prev) {
|
|
30418
|
+
if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
|
|
30419
|
+
const nextNames = (Array.isArray(next) ? next : []).filter((n) => typeof n === "string");
|
|
30420
|
+
const prevNames = (Array.isArray(prev) ? prev : []).filter((n) => typeof n === "string");
|
|
30421
|
+
const nextSet = new Set(nextNames);
|
|
30422
|
+
const prevSet = new Set(prevNames);
|
|
30423
|
+
const toAttach = nextNames.filter((n) => !prevSet.has(n));
|
|
30424
|
+
const toDetach = prevNames.filter((n) => !nextSet.has(n));
|
|
30425
|
+
if (toDetach.length > 0) await this.getClient().send(new DetachLoadBalancersCommand({
|
|
30426
|
+
AutoScalingGroupName: physicalId,
|
|
30427
|
+
LoadBalancerNames: toDetach
|
|
30428
|
+
}));
|
|
30429
|
+
if (toAttach.length > 0) await this.getClient().send(new AttachLoadBalancersCommand({
|
|
30430
|
+
AutoScalingGroupName: physicalId,
|
|
30431
|
+
LoadBalancerNames: toAttach
|
|
30432
|
+
}));
|
|
30433
|
+
}
|
|
30434
|
+
/**
|
|
30435
|
+
* Diff `TargetGroupARNs` (ALB / NLB target groups) and issue
|
|
30436
|
+
* `AttachLoadBalancerTargetGroups` /
|
|
30437
|
+
* `DetachLoadBalancerTargetGroups` for the delta (#476). Target-group
|
|
30438
|
+
* ARNs are opaque strings; same per-call batching pattern as
|
|
30439
|
+
* `applyLoadBalancerNamesDiff`. No-op when before/after JSON is
|
|
30440
|
+
* identical.
|
|
30441
|
+
*/
|
|
30442
|
+
async applyTargetGroupArnsDiff(physicalId, next, prev) {
|
|
30443
|
+
if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
|
|
30444
|
+
const nextArns = (Array.isArray(next) ? next : []).filter((a) => typeof a === "string");
|
|
30445
|
+
const prevArns = (Array.isArray(prev) ? prev : []).filter((a) => typeof a === "string");
|
|
30446
|
+
const nextSet = new Set(nextArns);
|
|
30447
|
+
const prevSet = new Set(prevArns);
|
|
30448
|
+
const toAttach = nextArns.filter((a) => !prevSet.has(a));
|
|
30449
|
+
const toDetach = prevArns.filter((a) => !nextSet.has(a));
|
|
30450
|
+
if (toDetach.length > 0) await this.getClient().send(new DetachLoadBalancerTargetGroupsCommand({
|
|
30451
|
+
AutoScalingGroupName: physicalId,
|
|
30452
|
+
TargetGroupARNs: toDetach
|
|
30453
|
+
}));
|
|
30454
|
+
if (toAttach.length > 0) await this.getClient().send(new AttachLoadBalancerTargetGroupsCommand({
|
|
30455
|
+
AutoScalingGroupName: physicalId,
|
|
30456
|
+
TargetGroupARNs: toAttach
|
|
30457
|
+
}));
|
|
30458
|
+
}
|
|
30357
30459
|
async applyMetricsCollectionDiff(physicalId, next, prev) {
|
|
30358
30460
|
if (JSON.stringify(next ?? []) === JSON.stringify(prev ?? [])) return;
|
|
30359
30461
|
const nextEntries = Array.isArray(next) ? next : [];
|
|
@@ -37995,6 +38097,8 @@ function parseContainerDefinition(raw, idx, taskLogicalId, resources, stack, con
|
|
|
37995
38097
|
protocol: pickString(p["Protocol"]) === "udp" ? "udp" : "tcp"
|
|
37996
38098
|
};
|
|
37997
38099
|
if (hostPort !== void 0) pm.hostPort = hostPort;
|
|
38100
|
+
const portName = typeof p["Name"] === "string" ? p["Name"] : void 0;
|
|
38101
|
+
if (portName !== void 0) pm.name = portName;
|
|
37998
38102
|
portMappings.push(pm);
|
|
37999
38103
|
}
|
|
38000
38104
|
const mountPoints = [];
|
|
@@ -38643,7 +38747,7 @@ function resolveRuntimeCodeMountPath(runtime) {
|
|
|
38643
38747
|
|
|
38644
38748
|
//#endregion
|
|
38645
38749
|
//#region src/local/docker-runner.ts
|
|
38646
|
-
const execFileAsync$
|
|
38750
|
+
const execFileAsync$4 = promisify(execFile);
|
|
38647
38751
|
/**
|
|
38648
38752
|
* Wraps `docker pull` / `docker run` / `docker rm` for `cdkd local invoke`.
|
|
38649
38753
|
*
|
|
@@ -38736,7 +38840,7 @@ async function runDetached(opts) {
|
|
|
38736
38840
|
args.push(opts.image, ...entryPointTail, ...opts.cmd);
|
|
38737
38841
|
getLogger().child("docker").debug(`${getDockerCmd()} ${redactAwsCredentialsInArgs(args).join(" ")}`);
|
|
38738
38842
|
try {
|
|
38739
|
-
const { stdout } = await execFileAsync$
|
|
38843
|
+
const { stdout } = await execFileAsync$4(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
|
|
38740
38844
|
return stdout.trim();
|
|
38741
38845
|
} catch (error) {
|
|
38742
38846
|
const err = error;
|
|
@@ -38773,7 +38877,7 @@ async function removeContainer(containerId) {
|
|
|
38773
38877
|
if (!containerId) return;
|
|
38774
38878
|
const logger = getLogger().child("docker");
|
|
38775
38879
|
try {
|
|
38776
|
-
await execFileAsync$
|
|
38880
|
+
await execFileAsync$4(getDockerCmd(), [
|
|
38777
38881
|
"rm",
|
|
38778
38882
|
"-f",
|
|
38779
38883
|
containerId
|
|
@@ -38792,7 +38896,7 @@ async function removeContainer(containerId) {
|
|
|
38792
38896
|
async function ensureDockerAvailable() {
|
|
38793
38897
|
const cmd = getDockerCmd();
|
|
38794
38898
|
try {
|
|
38795
|
-
await execFileAsync$
|
|
38899
|
+
await execFileAsync$4(cmd, [
|
|
38796
38900
|
"version",
|
|
38797
38901
|
"--format",
|
|
38798
38902
|
"{{.Server.Version}}"
|
|
@@ -39898,6 +40002,25 @@ function resolveFnSubInvokeArn(arg) {
|
|
|
39898
40002
|
};
|
|
39899
40003
|
}
|
|
39900
40004
|
|
|
40005
|
+
//#endregion
|
|
40006
|
+
//#region src/local/intrinsic-utils.ts
|
|
40007
|
+
/**
|
|
40008
|
+
* If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
|
|
40009
|
+
* logical ID. Otherwise return `null`.
|
|
40010
|
+
*
|
|
40011
|
+
* Shared across the `src/local/*` resolvers (route discovery, authorizer
|
|
40012
|
+
* resolution, stage attachment) so future intrinsic-shape extensions
|
|
40013
|
+
* (e.g. accepting `Fn::Sub`-bound Refs in REST v1 ResourceId / ParentId)
|
|
40014
|
+
* land in one place instead of three.
|
|
40015
|
+
*/
|
|
40016
|
+
function pickRefLogicalId(value) {
|
|
40017
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
40018
|
+
const ref = value["Ref"];
|
|
40019
|
+
if (typeof ref === "string") return ref;
|
|
40020
|
+
}
|
|
40021
|
+
return null;
|
|
40022
|
+
}
|
|
40023
|
+
|
|
39901
40024
|
//#endregion
|
|
39902
40025
|
//#region src/local/httpv2-service-integration.ts
|
|
39903
40026
|
const logger = getLogger();
|
|
@@ -40363,7 +40486,7 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
|
|
|
40363
40486
|
const integration = props["Integration"];
|
|
40364
40487
|
if (!integration) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): missing Integration property`);
|
|
40365
40488
|
const restApiId = props["RestApiId"];
|
|
40366
|
-
const restApiLogicalId = pickRefLogicalId
|
|
40489
|
+
const restApiLogicalId = pickRefLogicalId(restApiId);
|
|
40367
40490
|
if (!restApiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGateway::Method): RestApiId must be a { Ref: '...' } reference (got ${shortJson$1(restApiId)}).`);
|
|
40368
40491
|
const resourceId = props["ResourceId"];
|
|
40369
40492
|
const path = buildRestV1Path(resourceId, restApiLogicalId, template, stackName, logicalId);
|
|
@@ -40723,7 +40846,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
|
|
|
40723
40846
|
if (Array.isArray(arg) && arg.length === 2 && arg[1] === "RootResourceId") return "/";
|
|
40724
40847
|
}
|
|
40725
40848
|
}
|
|
40726
|
-
const resourceLogicalId = pickRefLogicalId
|
|
40849
|
+
const resourceLogicalId = pickRefLogicalId(resourceIdIntrinsic);
|
|
40727
40850
|
if (!resourceLogicalId) throw new Error(`${stackName}/${methodLogicalId}: ResourceId must be { Ref: '...' } or { 'Fn::GetAtt': [..., 'RootResourceId'] } (got ${shortJson$1(resourceIdIntrinsic)}).`);
|
|
40728
40851
|
const segments = [];
|
|
40729
40852
|
const visited = /* @__PURE__ */ new Set();
|
|
@@ -40743,7 +40866,7 @@ function buildRestV1Path(resourceIdIntrinsic, restApiLogicalId, template, stackN
|
|
|
40743
40866
|
const arg = parentId["Fn::GetAtt"];
|
|
40744
40867
|
if (Array.isArray(arg) && arg[1] === "RootResourceId") break;
|
|
40745
40868
|
}
|
|
40746
|
-
cursor = pickRefLogicalId
|
|
40869
|
+
cursor = pickRefLogicalId(parentId) ?? void 0;
|
|
40747
40870
|
}
|
|
40748
40871
|
return "/" + segments.join("/");
|
|
40749
40872
|
}
|
|
@@ -40758,7 +40881,7 @@ function pickRestV1Stage(restApiLogicalId, template) {
|
|
|
40758
40881
|
for (const [, resource] of Object.entries(resources)) {
|
|
40759
40882
|
if (resource.Type !== "AWS::ApiGateway::Stage") continue;
|
|
40760
40883
|
const props = resource.Properties ?? {};
|
|
40761
|
-
if (pickRefLogicalId
|
|
40884
|
+
if (pickRefLogicalId(props["RestApiId"]) === restApiLogicalId) {
|
|
40762
40885
|
const stageName = props["StageName"];
|
|
40763
40886
|
if (typeof stageName === "string") return stageName;
|
|
40764
40887
|
}
|
|
@@ -40777,7 +40900,7 @@ function pickRestV1Stage(restApiLogicalId, template) {
|
|
|
40777
40900
|
function discoverHttpApiRoute(logicalId, resource, template, stackName) {
|
|
40778
40901
|
const props = resource.Properties ?? {};
|
|
40779
40902
|
const apiId = props["ApiId"];
|
|
40780
|
-
const apiLogicalId = pickRefLogicalId
|
|
40903
|
+
const apiLogicalId = pickRefLogicalId(apiId);
|
|
40781
40904
|
if (!apiLogicalId) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): ApiId must be { Ref: '...' } (got ${shortJson$1(apiId)}).`);
|
|
40782
40905
|
const routeKey = props["RouteKey"];
|
|
40783
40906
|
if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${stackName}/${logicalId} (AWS::ApiGatewayV2::Route): RouteKey must be a string`);
|
|
@@ -40990,11 +41113,11 @@ function parseHttpApiTargetIntegration(target, location) {
|
|
|
40990
41113
|
const sep = join[0];
|
|
40991
41114
|
const parts = join[1];
|
|
40992
41115
|
if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
|
|
40993
|
-
const ref = pickRefLogicalId
|
|
41116
|
+
const ref = pickRefLogicalId(parts[1]);
|
|
40994
41117
|
if (ref) return ref;
|
|
40995
41118
|
}
|
|
40996
41119
|
if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
|
|
40997
|
-
const ref = pickRefLogicalId
|
|
41120
|
+
const ref = pickRefLogicalId(parts[1]);
|
|
40998
41121
|
if (ref) return ref;
|
|
40999
41122
|
}
|
|
41000
41123
|
}
|
|
@@ -41014,7 +41137,7 @@ function parseHttpApiTargetIntegration(target, location) {
|
|
|
41014
41137
|
if (m) {
|
|
41015
41138
|
const bound = bindings[m[1]];
|
|
41016
41139
|
if (bound !== void 0) {
|
|
41017
|
-
const ref = pickRefLogicalId
|
|
41140
|
+
const ref = pickRefLogicalId(bound);
|
|
41018
41141
|
if (ref) return ref;
|
|
41019
41142
|
}
|
|
41020
41143
|
}
|
|
@@ -41040,17 +41163,6 @@ function parseRouteKey(routeKey) {
|
|
|
41040
41163
|
};
|
|
41041
41164
|
}
|
|
41042
41165
|
/**
|
|
41043
|
-
* If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
|
|
41044
|
-
* logical ID. Otherwise return `null`.
|
|
41045
|
-
*/
|
|
41046
|
-
function pickRefLogicalId$3(value) {
|
|
41047
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41048
|
-
const ref = value["Ref"];
|
|
41049
|
-
if (typeof ref === "string") return ref;
|
|
41050
|
-
}
|
|
41051
|
-
return null;
|
|
41052
|
-
}
|
|
41053
|
-
/**
|
|
41054
41166
|
* Compact JSON for error messages — caps long objects so a malformed
|
|
41055
41167
|
* intrinsic doesn't dump the whole template into a stderr line.
|
|
41056
41168
|
*/
|
|
@@ -41142,7 +41254,7 @@ function collectAuthRoutesForApi(apiLogicalId, template, _stackName) {
|
|
|
41142
41254
|
for (const [, resource] of Object.entries(resources)) {
|
|
41143
41255
|
if (resource.Type !== "AWS::ApiGatewayV2::Route") continue;
|
|
41144
41256
|
const props = resource.Properties ?? {};
|
|
41145
|
-
if (pickRefLogicalId
|
|
41257
|
+
if (pickRefLogicalId(props["ApiId"]) !== apiLogicalId) continue;
|
|
41146
41258
|
const authType = props["AuthorizationType"];
|
|
41147
41259
|
if (authType === void 0) continue;
|
|
41148
41260
|
const routeKey = props["RouteKey"];
|
|
@@ -41194,7 +41306,7 @@ function pickStage$1(apiLogicalId, template) {
|
|
|
41194
41306
|
for (const [, resource] of Object.entries(resources)) {
|
|
41195
41307
|
if (resource.Type !== "AWS::ApiGatewayV2::Stage") continue;
|
|
41196
41308
|
const props = resource.Properties ?? {};
|
|
41197
|
-
if (pickRefLogicalId
|
|
41309
|
+
if (pickRefLogicalId(props["ApiId"]) === apiLogicalId) {
|
|
41198
41310
|
const stageName = props["StageName"];
|
|
41199
41311
|
if (typeof stageName === "string" && stageName.length > 0) return stageName;
|
|
41200
41312
|
}
|
|
@@ -41215,7 +41327,7 @@ function collectRoutesForApi(apiLogicalId, template, stackName) {
|
|
|
41215
41327
|
for (const [routeLogicalId, resource] of Object.entries(resources)) {
|
|
41216
41328
|
if (resource.Type !== "AWS::ApiGatewayV2::Route") continue;
|
|
41217
41329
|
const props = resource.Properties ?? {};
|
|
41218
|
-
if (pickRefLogicalId
|
|
41330
|
+
if (pickRefLogicalId(props["ApiId"]) !== apiLogicalId) continue;
|
|
41219
41331
|
const declaredAt = `${stackName}/${routeLogicalId}`;
|
|
41220
41332
|
const routeKey = props["RouteKey"];
|
|
41221
41333
|
if (typeof routeKey !== "string" || routeKey.length === 0) throw new Error(`${declaredAt}: RouteKey must be a non-empty string.`);
|
|
@@ -41263,11 +41375,11 @@ function parseRouteTarget(target, location) {
|
|
|
41263
41375
|
const sep = join[0];
|
|
41264
41376
|
const parts = join[1];
|
|
41265
41377
|
if (sep === "/" && parts.length === 2 && parts[0] === "integrations") {
|
|
41266
|
-
const ref = pickRefLogicalId
|
|
41378
|
+
const ref = pickRefLogicalId(parts[1]);
|
|
41267
41379
|
if (ref) return ref;
|
|
41268
41380
|
}
|
|
41269
41381
|
if (sep === "" && parts.length === 2 && parts[0] === "integrations/") {
|
|
41270
|
-
const ref = pickRefLogicalId
|
|
41382
|
+
const ref = pickRefLogicalId(parts[1]);
|
|
41271
41383
|
if (ref) return ref;
|
|
41272
41384
|
}
|
|
41273
41385
|
}
|
|
@@ -41288,7 +41400,7 @@ function parseRouteTarget(target, location) {
|
|
|
41288
41400
|
if (m) {
|
|
41289
41401
|
const bound = bindings[m[1]];
|
|
41290
41402
|
if (bound !== void 0) {
|
|
41291
|
-
const ref = pickRefLogicalId
|
|
41403
|
+
const ref = pickRefLogicalId(bound);
|
|
41292
41404
|
if (ref) return ref;
|
|
41293
41405
|
}
|
|
41294
41406
|
}
|
|
@@ -41297,13 +41409,6 @@ function parseRouteTarget(target, location) {
|
|
|
41297
41409
|
}
|
|
41298
41410
|
throw new Error(`${location}: Target must be 'integrations/<id>' literal, Fn::Join with the documented shapes, or Fn::Sub with an 'integrations/\${...}' template.`);
|
|
41299
41411
|
}
|
|
41300
|
-
function pickRefLogicalId$2(value) {
|
|
41301
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
41302
|
-
const ref = value["Ref"];
|
|
41303
|
-
if (typeof ref === "string") return ref;
|
|
41304
|
-
}
|
|
41305
|
-
return null;
|
|
41306
|
-
}
|
|
41307
41412
|
function readApiCdkPath(logicalId, template) {
|
|
41308
41413
|
const resource = template.Resources?.[logicalId];
|
|
41309
41414
|
if (!resource) return void 0;
|
|
@@ -45119,11 +45224,22 @@ function parseCognitoIssuer(issuer) {
|
|
|
45119
45224
|
};
|
|
45120
45225
|
}
|
|
45121
45226
|
/**
|
|
45122
|
-
* Pull a string out of a
|
|
45123
|
-
*
|
|
45124
|
-
*
|
|
45125
|
-
*
|
|
45126
|
-
*
|
|
45227
|
+
* Pull a string out of a literal / `Fn::GetAtt` entry under `ProviderARNs`.
|
|
45228
|
+
*
|
|
45229
|
+
* CDK's `apigateway.CognitoUserPoolsAuthorizer` emits a `Fn::GetAtt:
|
|
45230
|
+
* [<UserPool>, 'Arn']` reference, which is the canonical shape any user
|
|
45231
|
+
* who writes `new CognitoUserPoolsAuthorizer(this, 'auth', { cognitoUserPools: [pool] })`
|
|
45232
|
+
* ends up with (#470). Without `--from-state` we cannot resolve the
|
|
45233
|
+
* deployed pool ARN, so we synthesize an obviously-unreachable placeholder
|
|
45234
|
+
* pointing at a non-existent pool id — the JWKS fetch will fail and
|
|
45235
|
+
* cognito-jwt.ts's pass-through fallback (PR #234) admits every JWT
|
|
45236
|
+
* without signature verification. The warn log names the affected
|
|
45237
|
+
* authorizer + the recommended explicit `providerArns` workaround so
|
|
45238
|
+
* developers who DO want real verification know how to switch over.
|
|
45239
|
+
*
|
|
45240
|
+
* The `location` argument carries the full
|
|
45241
|
+
* `<stack>/<authorizer>.ProviderARNs[<idx>]` path so the warn / error
|
|
45242
|
+
* names the offending entry exactly.
|
|
45127
45243
|
*/
|
|
45128
45244
|
function pickStringFromArn(value, location) {
|
|
45129
45245
|
if (typeof value === "string") return value;
|
|
@@ -45131,7 +45247,11 @@ function pickStringFromArn(value, location) {
|
|
|
45131
45247
|
const obj = value;
|
|
45132
45248
|
if ("Fn::GetAtt" in obj) {
|
|
45133
45249
|
const arg = obj["Fn::GetAtt"];
|
|
45134
|
-
if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string" && arg[1] === "Arn")
|
|
45250
|
+
if (Array.isArray(arg) && arg.length === 2 && typeof arg[0] === "string" && arg[1] === "Arn") {
|
|
45251
|
+
const logicalId = arg[0];
|
|
45252
|
+
getLogger().warn(`${location}: uses Fn::GetAtt against logical ID '${logicalId}'. cdkd local start-api cannot resolve the deployed user pool ARN — synthesizing an unreachable placeholder so JWKS pass-through admits every token. For real signature verification, set 'providerArns: [pool.userPoolArn]' explicitly on the CDK construct.`);
|
|
45253
|
+
return `arn:aws:cognito-idp:us-east-1:000000000000:userpool/us-east-1_cdkdplaceholder${logicalId}`;
|
|
45254
|
+
}
|
|
45135
45255
|
}
|
|
45136
45256
|
}
|
|
45137
45257
|
throw new RouteDiscoveryError(`${location}: must be a literal string (got ${shortJson(value)}).`);
|
|
@@ -45218,7 +45338,7 @@ function detectRestV1Authorizer(methodResource, methodLogicalId, stack) {
|
|
|
45218
45338
|
declaredAt: `${stack.stackName}/${methodLogicalId}`
|
|
45219
45339
|
};
|
|
45220
45340
|
const authorizerId = props["AuthorizerId"];
|
|
45221
|
-
const refLogicalId = pickRefLogicalId
|
|
45341
|
+
const refLogicalId = pickRefLogicalId(authorizerId);
|
|
45222
45342
|
if (!refLogicalId) throw new RouteDiscoveryError(`${stack.stackName}/${methodLogicalId}: AuthorizationType='${stringifyValue(authType)}' but AuthorizerId is missing or not a {Ref:...}.`);
|
|
45223
45343
|
return resolveRestV1Authorizer(refLogicalId, stack.template, stack.stackName, `${stack.stackName}/${methodLogicalId}`);
|
|
45224
45344
|
}
|
|
@@ -45227,18 +45347,11 @@ function detectHttpApiAuthorizer(routeResource, routeLogicalId, stack) {
|
|
|
45227
45347
|
const authType = props["AuthorizationType"];
|
|
45228
45348
|
if (authType === void 0 || authType === "NONE") return void 0;
|
|
45229
45349
|
const authorizerId = props["AuthorizerId"];
|
|
45230
|
-
const refLogicalId = pickRefLogicalId
|
|
45350
|
+
const refLogicalId = pickRefLogicalId(authorizerId);
|
|
45231
45351
|
if (!refLogicalId) throw new RouteDiscoveryError(`${stack.stackName}/${routeLogicalId}: AuthorizationType='${stringifyValue(authType)}' but AuthorizerId is missing or not a {Ref:...}.`);
|
|
45232
45352
|
const scopesRaw = props["AuthorizationScopes"];
|
|
45233
45353
|
return resolveHttpApiAuthorizer(refLogicalId, Array.isArray(scopesRaw) ? scopesRaw.filter((s) => typeof s === "string") : void 0, stack.template, stack.stackName, `${stack.stackName}/${routeLogicalId}`);
|
|
45234
45354
|
}
|
|
45235
|
-
function pickRefLogicalId$1(value) {
|
|
45236
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
45237
|
-
const ref = value["Ref"];
|
|
45238
|
-
if (typeof ref === "string") return ref;
|
|
45239
|
-
}
|
|
45240
|
-
return null;
|
|
45241
|
-
}
|
|
45242
45355
|
function shortJson(value) {
|
|
45243
45356
|
try {
|
|
45244
45357
|
const s = JSON.stringify(value);
|
|
@@ -47809,19 +47922,6 @@ function attachStageContext(routes, stageMap) {
|
|
|
47809
47922
|
route.stage = stage.stageName;
|
|
47810
47923
|
}
|
|
47811
47924
|
}
|
|
47812
|
-
/**
|
|
47813
|
-
* If `value` is a `{ Ref: <string> }` intrinsic, return the referenced
|
|
47814
|
-
* logical ID. Otherwise return `null`. (Duplicated structurally from
|
|
47815
|
-
* `route-discovery.ts` — both modules walk the template independently
|
|
47816
|
-
* and shouldn't grow a coupling for a 5-line helper.)
|
|
47817
|
-
*/
|
|
47818
|
-
function pickRefLogicalId(value) {
|
|
47819
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
47820
|
-
const ref = value["Ref"];
|
|
47821
|
-
if (typeof ref === "string") return ref;
|
|
47822
|
-
}
|
|
47823
|
-
return null;
|
|
47824
|
-
}
|
|
47825
47925
|
|
|
47826
47926
|
//#endregion
|
|
47827
47927
|
//#region src/local/file-watcher.ts
|
|
@@ -49242,7 +49342,7 @@ function createLocalStartApiCommand() {
|
|
|
49242
49342
|
|
|
49243
49343
|
//#endregion
|
|
49244
49344
|
//#region src/local/ecs-network.ts
|
|
49245
|
-
const execFileAsync$
|
|
49345
|
+
const execFileAsync$3 = promisify(execFile);
|
|
49246
49346
|
/**
|
|
49247
49347
|
* Docker network + AWS-published metadata-endpoints sidecar lifecycle for
|
|
49248
49348
|
* `cdkd local run-task`. The sidecar (a small Go binary maintained by
|
|
@@ -49260,8 +49360,10 @@ const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:lat
|
|
|
49260
49360
|
* matches the documented AWS task-metadata endpoint address. Containers
|
|
49261
49361
|
* inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
|
|
49262
49362
|
* to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
|
|
49263
|
-
* start-service`
|
|
49264
|
-
*
|
|
49363
|
+
* start-service` creates ONE shared network at CLI startup (design
|
|
49364
|
+
* § 5 Option A) — the shared sidecar lives at `169.254.171.2` (see
|
|
49365
|
+
* `SHARED_SVC_SUBNET_OCTET` below), one octet up so the two CLI
|
|
49366
|
+
* variants can run on the same host without bridge-pool collision.
|
|
49265
49367
|
*/
|
|
49266
49368
|
const METADATA_ENDPOINT_IP = "169.254.170.2";
|
|
49267
49369
|
/** Default subnet — used when no `subnetOctet` override is supplied. */
|
|
@@ -49289,23 +49391,58 @@ function buildEndpointSubnet(subnetOctet) {
|
|
|
49289
49391
|
};
|
|
49290
49392
|
}
|
|
49291
49393
|
/**
|
|
49292
|
-
*
|
|
49293
|
-
*
|
|
49294
|
-
*
|
|
49295
|
-
*
|
|
49394
|
+
* Subnet octet for the shared-service docker network used by
|
|
49395
|
+
* `cdkd local start-service`. One octet up from `cdkd local run-task`'s
|
|
49396
|
+
* default (170 → 171) so the two CLI variants can run on the same host
|
|
49397
|
+
* without docker rejecting the second `--subnet`. The shared-service
|
|
49398
|
+
* network reuses the same `createTaskNetwork` machinery; the sidecar at
|
|
49399
|
+
* `169.254.171.2` serves the same metadata-endpoint API to every
|
|
49400
|
+
* container that joins this one network.
|
|
49296
49401
|
*/
|
|
49297
|
-
|
|
49402
|
+
const SHARED_SVC_SUBNET_OCTET = 171;
|
|
49403
|
+
/**
|
|
49404
|
+
* Create the one shared docker network + metadata-endpoints sidecar
|
|
49405
|
+
* used by every service-replica boot in a single
|
|
49406
|
+
* `cdkd local start-service` invocation. This is design doc § 5
|
|
49407
|
+
* Option A — one network per CLI invocation instead of one network
|
|
49408
|
+
* per task — so peer services can reach each other by IP / network
|
|
49409
|
+
* alias without docker `--network connect` choreography (Option B,
|
|
49410
|
+
* rejected in design § 5 as "unwieldy and racy"). The returned
|
|
49411
|
+
* `TaskNetwork` carries `ownedByCaller: true` so `cleanupEcsRun()`
|
|
49412
|
+
* (called per replica by the service runner) does NOT teardown — the
|
|
49413
|
+
* CLI tears down ONCE at the end of the run.
|
|
49414
|
+
*/
|
|
49415
|
+
async function createSharedSvcNetwork(options = {}) {
|
|
49416
|
+
const networkName = `${options.prefix ?? "cdkd-local"}-svc-${randomBytes(4).toString("hex")}`;
|
|
49417
|
+
const { cidr, sidecarIp } = buildEndpointSubnet(171);
|
|
49418
|
+
return {
|
|
49419
|
+
networkName,
|
|
49420
|
+
sidecarContainerId: await createNetworkAndSidecar({
|
|
49421
|
+
networkName,
|
|
49422
|
+
cidr,
|
|
49423
|
+
sidecarIp,
|
|
49424
|
+
skipPull: options.skipPull ?? false,
|
|
49425
|
+
...options.credentials !== void 0 ? { credentials: options.credentials } : {},
|
|
49426
|
+
...options.cluster !== void 0 ? { cluster: options.cluster } : {}
|
|
49427
|
+
}),
|
|
49428
|
+
sidecarIp,
|
|
49429
|
+
ownedByCaller: true
|
|
49430
|
+
};
|
|
49431
|
+
}
|
|
49432
|
+
/**
|
|
49433
|
+
* Internal helper shared by `createTaskNetwork` (per-task) and
|
|
49434
|
+
* `createSharedSvcNetwork` (per-CLI-run). Creates the docker network,
|
|
49435
|
+
* pulls the sidecar image, and starts the sidecar at the documented
|
|
49436
|
+
* IP. Throws `DockerRunnerError` with a hint when the network already
|
|
49437
|
+
* exists (the typical "leftover from previous run" path).
|
|
49438
|
+
*/
|
|
49439
|
+
async function createNetworkAndSidecar(args) {
|
|
49298
49440
|
const logger = getLogger().child("ecs-network");
|
|
49299
|
-
const networkName
|
|
49300
|
-
|
|
49301
|
-
const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
|
|
49302
|
-
cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
|
|
49303
|
-
sidecarIp: METADATA_ENDPOINT_IP
|
|
49304
|
-
} : buildEndpointSubnet(subnetOctet);
|
|
49305
|
-
await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
|
|
49441
|
+
const { networkName, cidr, sidecarIp, credentials, cluster, skipPull } = args;
|
|
49442
|
+
await pullImage(METADATA_ENDPOINT_IMAGE, skipPull);
|
|
49306
49443
|
logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
|
|
49307
49444
|
try {
|
|
49308
|
-
await execFileAsync$
|
|
49445
|
+
await execFileAsync$3(getDockerCmd(), [
|
|
49309
49446
|
"network",
|
|
49310
49447
|
"create",
|
|
49311
49448
|
"--driver",
|
|
@@ -49316,7 +49453,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
49316
49453
|
]);
|
|
49317
49454
|
} catch (err) {
|
|
49318
49455
|
const e = err;
|
|
49319
|
-
throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run
|
|
49456
|
+
throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run may already own subnet ${cidr}; wait for it to finish, or remove the leftover network with \`docker network ls\` + \`docker network rm\`. \`cdkd local start-service\` shares one network across every service in the run; bare \`cdkd local run-task\` uses a per-task network so only one run can be active at a time.`);
|
|
49320
49457
|
}
|
|
49321
49458
|
const sidecarArgs = [
|
|
49322
49459
|
"run",
|
|
@@ -49330,27 +49467,46 @@ async function createTaskNetwork(options = {}) {
|
|
|
49330
49467
|
sidecarIp
|
|
49331
49468
|
];
|
|
49332
49469
|
const sidecarEnv = {};
|
|
49333
|
-
if (
|
|
49334
|
-
sidecarEnv["AWS_ACCESS_KEY_ID"] =
|
|
49335
|
-
sidecarEnv["AWS_SECRET_ACCESS_KEY"] =
|
|
49336
|
-
if (
|
|
49470
|
+
if (credentials) {
|
|
49471
|
+
sidecarEnv["AWS_ACCESS_KEY_ID"] = credentials.accessKeyId;
|
|
49472
|
+
sidecarEnv["AWS_SECRET_ACCESS_KEY"] = credentials.secretAccessKey;
|
|
49473
|
+
if (credentials.sessionToken) sidecarEnv["AWS_SESSION_TOKEN"] = credentials.sessionToken;
|
|
49337
49474
|
}
|
|
49338
|
-
if (
|
|
49475
|
+
if (cluster) sidecarEnv["CLUSTER"] = cluster;
|
|
49339
49476
|
for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
|
|
49340
49477
|
sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
|
|
49341
49478
|
logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
|
|
49342
|
-
let sidecarContainerId;
|
|
49343
49479
|
try {
|
|
49344
|
-
const { stdout } = await execFileAsync$
|
|
49345
|
-
|
|
49480
|
+
const { stdout } = await execFileAsync$3(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
|
|
49481
|
+
return stdout.trim();
|
|
49346
49482
|
} catch (err) {
|
|
49347
49483
|
await destroyNetworkOnly(networkName);
|
|
49348
49484
|
const e = err;
|
|
49349
49485
|
throw new DockerRunnerError(`Failed to start metadata-endpoints sidecar: ${e.stderr?.trim() || e.message || String(err)}`);
|
|
49350
49486
|
}
|
|
49487
|
+
}
|
|
49488
|
+
/**
|
|
49489
|
+
* Create the per-task docker network + start the metadata-endpoints
|
|
49490
|
+
* sidecar. The sidecar must come up at the well-known address BEFORE any
|
|
49491
|
+
* user container starts so the `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`
|
|
49492
|
+
* lookup at container start doesn't race.
|
|
49493
|
+
*/
|
|
49494
|
+
async function createTaskNetwork(options = {}) {
|
|
49495
|
+
const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
|
|
49496
|
+
const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
|
|
49497
|
+
cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
|
|
49498
|
+
sidecarIp: METADATA_ENDPOINT_IP
|
|
49499
|
+
} : buildEndpointSubnet(options.subnetOctet);
|
|
49351
49500
|
return {
|
|
49352
49501
|
networkName,
|
|
49353
|
-
sidecarContainerId
|
|
49502
|
+
sidecarContainerId: await createNetworkAndSidecar({
|
|
49503
|
+
networkName,
|
|
49504
|
+
cidr,
|
|
49505
|
+
sidecarIp,
|
|
49506
|
+
skipPull: options.skipPull ?? false,
|
|
49507
|
+
...options.credentials !== void 0 ? { credentials: options.credentials } : {},
|
|
49508
|
+
...options.cluster !== void 0 ? { cluster: options.cluster } : {}
|
|
49509
|
+
}),
|
|
49354
49510
|
sidecarIp
|
|
49355
49511
|
};
|
|
49356
49512
|
}
|
|
@@ -49389,7 +49545,7 @@ async function destroyNetworkOnly(networkName) {
|
|
|
49389
49545
|
if (!networkName) return;
|
|
49390
49546
|
const logger = getLogger().child("ecs-network");
|
|
49391
49547
|
try {
|
|
49392
|
-
await execFileAsync$
|
|
49548
|
+
await execFileAsync$3(getDockerCmd(), [
|
|
49393
49549
|
"network",
|
|
49394
49550
|
"rm",
|
|
49395
49551
|
networkName
|
|
@@ -49522,7 +49678,7 @@ async function resolveSsm(entry, shape, client) {
|
|
|
49522
49678
|
|
|
49523
49679
|
//#endregion
|
|
49524
49680
|
//#region src/local/ecs-task-runner.ts
|
|
49525
|
-
const execFileAsync$
|
|
49681
|
+
const execFileAsync$2 = promisify(execFile);
|
|
49526
49682
|
/**
|
|
49527
49683
|
* Top-level orchestrator for `cdkd local run-task`. Coordinates image
|
|
49528
49684
|
* preparation, secret resolution, docker-network bring-up, container
|
|
@@ -49579,10 +49735,10 @@ async function cleanupEcsRun(state, options) {
|
|
|
49579
49735
|
}
|
|
49580
49736
|
state.startedContainers = [];
|
|
49581
49737
|
}
|
|
49582
|
-
await destroyTaskNetwork(state.network);
|
|
49738
|
+
if (state.network && !state.network.ownedByCaller) await destroyTaskNetwork(state.network);
|
|
49583
49739
|
state.network = void 0;
|
|
49584
49740
|
for (const v of state.dockerVolumeNames) try {
|
|
49585
|
-
await execFileAsync$
|
|
49741
|
+
await execFileAsync$2(getDockerCmd(), [
|
|
49586
49742
|
"volume",
|
|
49587
49743
|
"rm",
|
|
49588
49744
|
v
|
|
@@ -49612,14 +49768,20 @@ async function runEcsTask(task, options, state) {
|
|
|
49612
49768
|
valueFrom: s.valueFrom
|
|
49613
49769
|
});
|
|
49614
49770
|
const secretsByContainer = groupSecretsByContainer(await resolveEcsSecrets(allSecrets, { ...options.region !== void 0 && { region: options.region } }));
|
|
49615
|
-
|
|
49616
|
-
|
|
49617
|
-
|
|
49618
|
-
};
|
|
49619
|
-
|
|
49620
|
-
|
|
49621
|
-
|
|
49622
|
-
|
|
49771
|
+
if (options.existingNetwork) state.network = {
|
|
49772
|
+
...options.existingNetwork,
|
|
49773
|
+
ownedByCaller: true
|
|
49774
|
+
};
|
|
49775
|
+
else {
|
|
49776
|
+
const netCreateOpts = {
|
|
49777
|
+
prefix: options.cluster,
|
|
49778
|
+
skipPull: options.skipPull
|
|
49779
|
+
};
|
|
49780
|
+
if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
|
|
49781
|
+
if (options.cluster) netCreateOpts.cluster = options.cluster;
|
|
49782
|
+
if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
|
|
49783
|
+
state.network = await createTaskNetwork(netCreateOpts);
|
|
49784
|
+
}
|
|
49623
49785
|
const volumeByName = await realizeDockerVolumes(task.volumes, state);
|
|
49624
49786
|
const dockerCmds = /* @__PURE__ */ new Map();
|
|
49625
49787
|
for (const container of task.containers) {
|
|
@@ -49637,7 +49799,9 @@ async function runEcsTask(task, options, state) {
|
|
|
49637
49799
|
roleArn: options.taskRoleArn,
|
|
49638
49800
|
platformOverride: options.platformOverride,
|
|
49639
49801
|
region: options.region,
|
|
49640
|
-
sidecarIp: state.network.sidecarIp
|
|
49802
|
+
sidecarIp: state.network.sidecarIp,
|
|
49803
|
+
...options.addHostFlags && options.addHostFlags.length > 0 ? { addHostFlags: options.addHostFlags } : {},
|
|
49804
|
+
...(options.networkAliasesByContainer?.get(container.name)?.length ?? 0) > 0 ? { networkAliases: options.networkAliasesByContainer.get(container.name) } : {}
|
|
49641
49805
|
}));
|
|
49642
49806
|
}
|
|
49643
49807
|
const startedByName = /* @__PURE__ */ new Map();
|
|
@@ -49648,7 +49812,7 @@ async function runEcsTask(task, options, state) {
|
|
|
49648
49812
|
logger.info(`Starting container '${container.name}' (image=${imagePlan.get(container.name)})`);
|
|
49649
49813
|
let id;
|
|
49650
49814
|
try {
|
|
49651
|
-
const { stdout } = await execFileAsync$
|
|
49815
|
+
const { stdout } = await execFileAsync$2(getDockerCmd(), args, { maxBuffer: 10 * 1024 * 1024 });
|
|
49652
49816
|
id = stdout.trim();
|
|
49653
49817
|
} catch (err) {
|
|
49654
49818
|
const e = err;
|
|
@@ -49757,7 +49921,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
49757
49921
|
let lastStatus = "";
|
|
49758
49922
|
while (Date.now() < deadline) {
|
|
49759
49923
|
try {
|
|
49760
|
-
const { stdout } = await execFileAsync$
|
|
49924
|
+
const { stdout } = await execFileAsync$2(getDockerCmd(), [
|
|
49761
49925
|
"inspect",
|
|
49762
49926
|
"--format",
|
|
49763
49927
|
"{{.State.Health.Status}}",
|
|
@@ -49780,7 +49944,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
49780
49944
|
}
|
|
49781
49945
|
async function waitForContainerExit(containerId) {
|
|
49782
49946
|
try {
|
|
49783
|
-
const { stdout } = await execFileAsync$
|
|
49947
|
+
const { stdout } = await execFileAsync$2(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
|
|
49784
49948
|
const code = Number.parseInt(stdout.trim(), 10);
|
|
49785
49949
|
return Number.isFinite(code) ? code : 1;
|
|
49786
49950
|
} catch (err) {
|
|
@@ -49790,7 +49954,7 @@ async function waitForContainerExit(containerId) {
|
|
|
49790
49954
|
}
|
|
49791
49955
|
async function stopContainer(containerId, graceSeconds) {
|
|
49792
49956
|
try {
|
|
49793
|
-
await execFileAsync$
|
|
49957
|
+
await execFileAsync$2(getDockerCmd(), [
|
|
49794
49958
|
"stop",
|
|
49795
49959
|
"-t",
|
|
49796
49960
|
String(graceSeconds),
|
|
@@ -49915,7 +50079,7 @@ async function realizeDockerVolumes(volumes, state) {
|
|
|
49915
50079
|
const dockerVolumeName = `cdkd-local-${v.name}-${randHex(4)}`;
|
|
49916
50080
|
args.push(dockerVolumeName);
|
|
49917
50081
|
try {
|
|
49918
|
-
await execFileAsync$
|
|
50082
|
+
await execFileAsync$2(getDockerCmd(), args);
|
|
49919
50083
|
state.dockerVolumeNames.push(dockerVolumeName);
|
|
49920
50084
|
logger.debug(`Created docker volume ${dockerVolumeName} for task volume '${v.name}'`);
|
|
49921
50085
|
} catch (err) {
|
|
@@ -49955,6 +50119,14 @@ function buildDockerRunArgs(opts) {
|
|
|
49955
50119
|
args.push("--name", `cdkd-local-${task.family}-${container.name}-${randHex(3)}`);
|
|
49956
50120
|
args.push("--network", network);
|
|
49957
50121
|
args.push("--network-alias", container.name);
|
|
50122
|
+
if (opts.networkAliases && opts.networkAliases.length > 0) {
|
|
50123
|
+
const seen = new Set([container.name]);
|
|
50124
|
+
for (const a of opts.networkAliases) if (!seen.has(a)) {
|
|
50125
|
+
args.push("--network-alias", a);
|
|
50126
|
+
seen.add(a);
|
|
50127
|
+
}
|
|
50128
|
+
}
|
|
50129
|
+
if (opts.addHostFlags && opts.addHostFlags.length > 0) for (const f of opts.addHostFlags) args.push(f);
|
|
49958
50130
|
if (opts.platformOverride) args.push("--platform", opts.platformOverride);
|
|
49959
50131
|
else if (task.runtimePlatform) args.push("--platform", task.runtimePlatform.cpuArchitecture === "ARM64" ? "linux/arm64" : "linux/amd64");
|
|
49960
50132
|
for (const pm of container.portMappings) {
|
|
@@ -50361,8 +50533,8 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
|
|
|
50361
50533
|
const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
|
|
50362
50534
|
const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
|
|
50363
50535
|
if (Array.isArray(props["LoadBalancers"]) && props["LoadBalancers"].length > 0) warnings.push(`ECS Service '${serviceLogicalId}' declares LoadBalancers, but local load-balancer emulation is deferred to a follow-up PR. Containers are NOT registered to a local listener; reach them via their published ports.`);
|
|
50364
|
-
|
|
50365
|
-
|
|
50536
|
+
const serviceConnect = extractServiceConnect(props["ServiceConnectConfiguration"], task);
|
|
50537
|
+
const out = {
|
|
50366
50538
|
stack,
|
|
50367
50539
|
serviceLogicalId,
|
|
50368
50540
|
resource,
|
|
@@ -50370,8 +50542,103 @@ function extractServiceProperties(stack, serviceLogicalId, resource, stacks, con
|
|
|
50370
50542
|
desiredCount,
|
|
50371
50543
|
healthCheckGracePeriodSeconds,
|
|
50372
50544
|
task,
|
|
50545
|
+
serviceRegistries: extractServiceRegistries(props["ServiceRegistries"]),
|
|
50373
50546
|
warnings
|
|
50374
50547
|
};
|
|
50548
|
+
if (serviceConnect) out.serviceConnect = serviceConnect;
|
|
50549
|
+
return out;
|
|
50550
|
+
}
|
|
50551
|
+
/**
|
|
50552
|
+
* Parse `ServiceConnectConfiguration` against the producer TaskDef.
|
|
50553
|
+
* Returns `undefined` when the block is missing OR `Enabled: false`.
|
|
50554
|
+
*
|
|
50555
|
+
* Reject conditions (surface as resolver-time errors so the user sees
|
|
50556
|
+
* them BEFORE the docker network is created):
|
|
50557
|
+
* - `Namespace` is not a literal string. CDK 2.x always emits a
|
|
50558
|
+
* literal string here (verified 2026-05-22); cross-stack /
|
|
50559
|
+
* intrinsic shapes are out of scope.
|
|
50560
|
+
* - `Services[].PortName` doesn't match any of the TaskDef's
|
|
50561
|
+
* `ContainerDefinitions[].PortMappings[].Name` entries.
|
|
50562
|
+
*
|
|
50563
|
+
* Note on `clientAliases[]` shape: each ClientAlias can declare a
|
|
50564
|
+
* `DnsName` (the bare short-name peers connect to, e.g. `orders`) AND
|
|
50565
|
+
* a `Port` (the listening port the alias maps to inside the consumer).
|
|
50566
|
+
* cdkd surfaces both verbatim; the registry / `--add-host` overlay
|
|
50567
|
+
* publishes each `DnsName` as a bare alias pointing at the same IP as
|
|
50568
|
+
* the canonical fqdn.
|
|
50569
|
+
*/
|
|
50570
|
+
function extractServiceConnect(raw, task) {
|
|
50571
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
50572
|
+
const cfg = raw;
|
|
50573
|
+
if (cfg["Enabled"] === false) return void 0;
|
|
50574
|
+
const namespaceName = pickServiceConnectNamespace(cfg["Namespace"]);
|
|
50575
|
+
if (!namespaceName) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Namespace must be a literal string (the Cloud Map namespace name like 'cdkd-local.local'); got ${JSON.stringify(cfg["Namespace"])}. Intrinsic / cross-stack namespace references are not supported in v1.`);
|
|
50576
|
+
const rawServices = cfg["Services"];
|
|
50577
|
+
if (!Array.isArray(rawServices) || rawServices.length === 0) return {
|
|
50578
|
+
namespaceName,
|
|
50579
|
+
services: []
|
|
50580
|
+
};
|
|
50581
|
+
const portByName = /* @__PURE__ */ new Map();
|
|
50582
|
+
for (const c of task.containers) for (const pm of c.portMappings) if (pm.name) portByName.set(pm.name, pm.containerPort);
|
|
50583
|
+
const services = [];
|
|
50584
|
+
for (const entry of rawServices) {
|
|
50585
|
+
if (!entry || typeof entry !== "object") continue;
|
|
50586
|
+
const e = entry;
|
|
50587
|
+
const portName = typeof e["PortName"] === "string" ? e["PortName"] : void 0;
|
|
50588
|
+
if (!portName) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Services[] entry has no PortName: ${JSON.stringify(entry)}. Every Service entry must reference a producer-side PortMappings[].Name.`);
|
|
50589
|
+
const containerPort = portByName.get(portName);
|
|
50590
|
+
if (containerPort === void 0) throw new EcsTaskResolutionError(`ServiceConnectConfiguration.Services[].PortName='${portName}' does not match any PortMappings[].Name on the producer TaskDef (available: ${[...portByName.keys()].join(", ") || "(none)"}).`);
|
|
50591
|
+
const clientAliases = [];
|
|
50592
|
+
if (Array.isArray(e["ClientAliases"])) for (const ca of e["ClientAliases"]) {
|
|
50593
|
+
if (!ca || typeof ca !== "object") continue;
|
|
50594
|
+
const caObj = ca;
|
|
50595
|
+
const dnsName = typeof caObj["DnsName"] === "string" ? caObj["DnsName"] : void 0;
|
|
50596
|
+
const aliasEntry = { port: typeof caObj["Port"] === "number" ? caObj["Port"] : containerPort };
|
|
50597
|
+
if (dnsName !== void 0) aliasEntry.dnsName = dnsName;
|
|
50598
|
+
clientAliases.push(aliasEntry);
|
|
50599
|
+
}
|
|
50600
|
+
const discoveryName = clientAliases.find((c) => c.dnsName !== void 0)?.dnsName ?? portName;
|
|
50601
|
+
services.push({
|
|
50602
|
+
portName,
|
|
50603
|
+
containerPort,
|
|
50604
|
+
discoveryName,
|
|
50605
|
+
clientAliases
|
|
50606
|
+
});
|
|
50607
|
+
}
|
|
50608
|
+
return {
|
|
50609
|
+
namespaceName,
|
|
50610
|
+
services
|
|
50611
|
+
};
|
|
50612
|
+
}
|
|
50613
|
+
/**
|
|
50614
|
+
* Parse `ServiceRegistries[]`. Each entry's `RegistryArn` is the
|
|
50615
|
+
* canonical `Fn::GetAtt: [<CloudMapServiceLogicalId>, 'Arn']` shape;
|
|
50616
|
+
* cdkd surfaces the logical id (the AWS-side ARN is irrelevant
|
|
50617
|
+
* locally — the registry is in-process).
|
|
50618
|
+
*/
|
|
50619
|
+
function extractServiceRegistries(raw) {
|
|
50620
|
+
if (!Array.isArray(raw)) return [];
|
|
50621
|
+
const out = [];
|
|
50622
|
+
for (const entry of raw) {
|
|
50623
|
+
if (!entry || typeof entry !== "object") continue;
|
|
50624
|
+
const e = entry;
|
|
50625
|
+
const registryArn = e["RegistryArn"];
|
|
50626
|
+
let cloudMapServiceLogicalId;
|
|
50627
|
+
if (typeof registryArn === "string") continue;
|
|
50628
|
+
if (registryArn && typeof registryArn === "object" && !Array.isArray(registryArn)) {
|
|
50629
|
+
const getAtt = registryArn["Fn::GetAtt"];
|
|
50630
|
+
if (Array.isArray(getAtt) && typeof getAtt[0] === "string") cloudMapServiceLogicalId = getAtt[0];
|
|
50631
|
+
}
|
|
50632
|
+
if (!cloudMapServiceLogicalId) continue;
|
|
50633
|
+
const reg = { cloudMapServiceLogicalId };
|
|
50634
|
+
if (typeof e["ContainerName"] === "string") reg.containerName = e["ContainerName"];
|
|
50635
|
+
if (typeof e["ContainerPort"] === "number") reg.containerPort = e["ContainerPort"];
|
|
50636
|
+
out.push(reg);
|
|
50637
|
+
}
|
|
50638
|
+
return out;
|
|
50639
|
+
}
|
|
50640
|
+
function pickServiceConnectNamespace(raw) {
|
|
50641
|
+
if (typeof raw === "string" && raw.length > 0) return raw;
|
|
50375
50642
|
}
|
|
50376
50643
|
/**
|
|
50377
50644
|
* Resolve `Properties.TaskDefinition` to a logical id in the same stack.
|
|
@@ -50436,6 +50703,42 @@ function notFoundError(target, stack, resources) {
|
|
|
50436
50703
|
return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
|
|
50437
50704
|
}
|
|
50438
50705
|
|
|
50706
|
+
//#endregion
|
|
50707
|
+
//#region src/local/docker-inspect.ts
|
|
50708
|
+
const execFileAsync$1 = promisify(execFile);
|
|
50709
|
+
/**
|
|
50710
|
+
* Phase 3 of #262 (Issue #460) helper — query the docker network IP
|
|
50711
|
+
* assigned to a freshly-started container so the Cloud Map registry
|
|
50712
|
+
* can publish reachable endpoints for peer discovery.
|
|
50713
|
+
*
|
|
50714
|
+
* Returns `undefined` when:
|
|
50715
|
+
* - The container is not found (docker rm raced us).
|
|
50716
|
+
* - The container is not attached to the named network.
|
|
50717
|
+
* - The IP is empty (docker hasn't fully wired the network yet —
|
|
50718
|
+
* caller should retry-or-skip; the helper deliberately does NOT
|
|
50719
|
+
* embed a retry loop to keep the caller in charge of timing).
|
|
50720
|
+
*
|
|
50721
|
+
* Errors propagate verbatim to the caller (`DockerRunnerError`-style
|
|
50722
|
+
* wrapping is the caller's concern; this helper is structurally a
|
|
50723
|
+
* simple inspect wrapper).
|
|
50724
|
+
*/
|
|
50725
|
+
async function getContainerNetworkIp(containerId, networkName) {
|
|
50726
|
+
const format = `{{with index .NetworkSettings.Networks "${networkName}"}}{{.IPAddress}}{{end}}`;
|
|
50727
|
+
try {
|
|
50728
|
+
const { stdout } = await execFileAsync$1(getDockerCmd(), [
|
|
50729
|
+
"inspect",
|
|
50730
|
+
"--format",
|
|
50731
|
+
format,
|
|
50732
|
+
containerId
|
|
50733
|
+
]);
|
|
50734
|
+
const ip = stdout.trim();
|
|
50735
|
+
if (!ip) return void 0;
|
|
50736
|
+
return ip;
|
|
50737
|
+
} catch {
|
|
50738
|
+
return;
|
|
50739
|
+
}
|
|
50740
|
+
}
|
|
50741
|
+
|
|
50439
50742
|
//#endregion
|
|
50440
50743
|
//#region src/local/ecs-service-runner.ts
|
|
50441
50744
|
/**
|
|
@@ -50453,10 +50756,20 @@ function notFoundError(target, stack, resources) {
|
|
|
50453
50756
|
* stops compounding cleanup work.
|
|
50454
50757
|
* - Long-running lifecycle (returns only on shutdown).
|
|
50455
50758
|
*
|
|
50759
|
+
* Phase 3 of #262 (Issue #460) — Cloud Map / Service Connect peer
|
|
50760
|
+
* discovery is wired through `ServiceRunnerOptions.discovery`. When
|
|
50761
|
+
* supplied, every booted replica discovers its docker IP, registers
|
|
50762
|
+
* itself into the shared in-process `CloudMapRegistry`, and emits
|
|
50763
|
+
* `--add-host` flags so consumer containers reach peer services via
|
|
50764
|
+
* the canonical `<discoveryName>.<namespace>` fqdn. Envoy L7 sidecar
|
|
50765
|
+
* emulation (design Layer B) is deferred to a follow-up PR per the
|
|
50766
|
+
* design's §O5 "--no-envoy by default" recommendation.
|
|
50767
|
+
*
|
|
50456
50768
|
* Deferred to follow-up PRs:
|
|
50457
50769
|
* - Local load-balancer emulation (LB listener + target-group health
|
|
50458
50770
|
* check + round-robin) — separate PR per the issue's PR-split.
|
|
50459
|
-
* - Service Connect
|
|
50771
|
+
* - Envoy sidecar for Service Connect L7 routing / retries / circuit
|
|
50772
|
+
* breaking (Cloud Map DNS-only mode ships now).
|
|
50460
50773
|
* - Rolling deployment (`--reload` / `--watch`).
|
|
50461
50774
|
*/
|
|
50462
50775
|
var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
|
|
@@ -50522,7 +50835,8 @@ async function startEcsService(service, options, runState) {
|
|
|
50522
50835
|
state: createEcsRunState(),
|
|
50523
50836
|
restartCount: 0,
|
|
50524
50837
|
shuttingDown: false,
|
|
50525
|
-
inFlightBoot: void 0
|
|
50838
|
+
inFlightBoot: void 0,
|
|
50839
|
+
cloudMapHandles: []
|
|
50526
50840
|
};
|
|
50527
50841
|
runState.replicas.push(instance);
|
|
50528
50842
|
const bootPromise = bootReplica(service, options, instance);
|
|
@@ -50604,6 +50918,12 @@ var ServiceController = class {
|
|
|
50604
50918
|
await Promise.allSettled(inFlightBoots);
|
|
50605
50919
|
}
|
|
50606
50920
|
await Promise.allSettled(this.runState.replicas.map(async (instance) => {
|
|
50921
|
+
if (this.options.discovery) {
|
|
50922
|
+
for (const handle of instance.cloudMapHandles) try {
|
|
50923
|
+
this.options.discovery.registry.unregister(handle);
|
|
50924
|
+
} catch {}
|
|
50925
|
+
instance.cloudMapHandles = [];
|
|
50926
|
+
}
|
|
50607
50927
|
try {
|
|
50608
50928
|
await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
|
|
50609
50929
|
} catch (err) {
|
|
@@ -50614,6 +50934,39 @@ var ServiceController = class {
|
|
|
50614
50934
|
}
|
|
50615
50935
|
};
|
|
50616
50936
|
/**
|
|
50937
|
+
* Build the `--network-alias` map for one service's containers (design
|
|
50938
|
+
* doc § 5 Option A). For every Service Connect entry, attach the
|
|
50939
|
+
* fqdn (`<discoveryName>.<namespaceName>`), the bare discoveryName,
|
|
50940
|
+
* AND every ClientAlias DnsName to the container that owns the
|
|
50941
|
+
* matching PortName. Other containers in the task get NO extra
|
|
50942
|
+
* aliases (only their default `--name`-derived alias from
|
|
50943
|
+
* `buildDockerRunArgs`).
|
|
50944
|
+
*
|
|
50945
|
+
* Aliases per container are de-duplicated so docker doesn't reject
|
|
50946
|
+
* a `--network-alias X` repeated against the same container.
|
|
50947
|
+
*
|
|
50948
|
+
* Returns an empty map when the service has no Service Connect — the
|
|
50949
|
+
* runner's `... .size > 0 ? { networkAliasesByContainer } : {}` guard
|
|
50950
|
+
* short-circuits in that case so backward-compat callers pay no cost.
|
|
50951
|
+
*/
|
|
50952
|
+
function buildNetworkAliasesByContainer(service) {
|
|
50953
|
+
const out = /* @__PURE__ */ new Map();
|
|
50954
|
+
const sc = service.serviceConnect;
|
|
50955
|
+
if (!sc) return out;
|
|
50956
|
+
for (const entry of sc.services) {
|
|
50957
|
+
const owner = service.task.containers.find((c) => c.portMappings.some((pm) => pm.name === entry.portName));
|
|
50958
|
+
if (!owner) continue;
|
|
50959
|
+
const aliases = [];
|
|
50960
|
+
aliases.push(entry.discoveryName);
|
|
50961
|
+
aliases.push(`${entry.discoveryName}.${sc.namespaceName}`);
|
|
50962
|
+
for (const ca of entry.clientAliases) if (ca.dnsName) aliases.push(ca.dnsName);
|
|
50963
|
+
const existing = out.get(owner.name) ?? [];
|
|
50964
|
+
for (const a of aliases) if (!existing.includes(a)) existing.push(a);
|
|
50965
|
+
out.set(owner.name, existing);
|
|
50966
|
+
}
|
|
50967
|
+
return out;
|
|
50968
|
+
}
|
|
50969
|
+
/**
|
|
50617
50970
|
* Boot a single replica. Mutates the supplied `instance.state` so the
|
|
50618
50971
|
* shutdown path's `cleanupEcsRun(instance.state)` covers every partial
|
|
50619
50972
|
* side effect. Network names are suffixed with the replica index so
|
|
@@ -50622,15 +50975,99 @@ var ServiceController = class {
|
|
|
50622
50975
|
async function bootReplica(service, options, instance) {
|
|
50623
50976
|
const logger = getLogger().child("ecs-service");
|
|
50624
50977
|
const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
|
|
50625
|
-
const
|
|
50978
|
+
const ownerKeyPrefix = `${service.serviceLogicalId}:r${instance.index}`;
|
|
50979
|
+
const addHostFlags = options.discovery?.registry ? options.discovery.registry.buildAddHostFlags(ownerKeyPrefix) : [];
|
|
50980
|
+
const sharedNetwork = options.discovery?.sharedNetwork;
|
|
50981
|
+
const networkAliasesByContainer = buildNetworkAliasesByContainer(service);
|
|
50626
50982
|
const perReplicaTaskOptions = {
|
|
50627
50983
|
...options.taskOptions,
|
|
50628
50984
|
cluster: perReplicaCluster,
|
|
50629
|
-
|
|
50630
|
-
|
|
50985
|
+
detach: true,
|
|
50986
|
+
...sharedNetwork ? { existingNetwork: sharedNetwork } : { subnetOctet: 170 + instance.index % 84 },
|
|
50987
|
+
...addHostFlags.length > 0 ? { addHostFlags } : {},
|
|
50988
|
+
...networkAliasesByContainer.size > 0 ? { networkAliasesByContainer } : {}
|
|
50631
50989
|
};
|
|
50632
50990
|
logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
|
|
50633
50991
|
await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
|
|
50992
|
+
if (options.discovery) await publishReplicaToCloudMap(service, instance, options.discovery, ownerKeyPrefix);
|
|
50993
|
+
}
|
|
50994
|
+
/**
|
|
50995
|
+
* After the replica's main container is up, discover its docker
|
|
50996
|
+
* network IP and publish the configured Service Connect + Cloud Map
|
|
50997
|
+
* endpoints into the shared registry. The handles are tracked on the
|
|
50998
|
+
* instance so the shutdown / restart path can unregister symmetrically.
|
|
50999
|
+
*
|
|
51000
|
+
* Errors here are best-effort: docker inspect can fail right after run
|
|
51001
|
+
* (container vanished, network not fully wired), and the registry is
|
|
51002
|
+
* advisory — losing one replica's registration means peer services
|
|
51003
|
+
* can't reach it via the overlay, but it doesn't break that replica's
|
|
51004
|
+
* own work or AWS SDK calls.
|
|
51005
|
+
*/
|
|
51006
|
+
async function publishReplicaToCloudMap(service, instance, discovery, ownerKeyPrefix) {
|
|
51007
|
+
const logger = getLogger().child("ecs-service");
|
|
51008
|
+
const networkName = instance.state.network?.networkName;
|
|
51009
|
+
if (!networkName) return;
|
|
51010
|
+
const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
|
|
51011
|
+
if (!essential) return;
|
|
51012
|
+
const started = instance.state.startedContainers.find((c) => c.name === essential.name);
|
|
51013
|
+
if (!started) return;
|
|
51014
|
+
let ip;
|
|
51015
|
+
try {
|
|
51016
|
+
ip = await getContainerNetworkIp(started.id, networkName);
|
|
51017
|
+
} catch (err) {
|
|
51018
|
+
logger.warn(`Replica ${instance.index}: docker inspect failed before Cloud Map publish: ${err instanceof Error ? err.message : String(err)}`);
|
|
51019
|
+
return;
|
|
51020
|
+
}
|
|
51021
|
+
if (!ip) {
|
|
51022
|
+
logger.warn(`Replica ${instance.index}: no docker IP discovered on network ${networkName}; skipping Cloud Map publish for this replica.`);
|
|
51023
|
+
return;
|
|
51024
|
+
}
|
|
51025
|
+
if (service.serviceConnect) {
|
|
51026
|
+
const ns = service.serviceConnect.namespaceName;
|
|
51027
|
+
const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
|
|
51028
|
+
if (index && !index.namespacesByName.has(ns)) logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceConnectConfiguration.Namespace='${ns}' does not match any AWS::ServiceDiscovery::PrivateDnsNamespace declared in stack ${service.stack.stackName}. Publishing under the literal name anyway; peer services using the same literal will still discover this endpoint.`);
|
|
51029
|
+
let i = 0;
|
|
51030
|
+
for (const entry of service.serviceConnect.services) {
|
|
51031
|
+
const ownerKey = `${ownerKeyPrefix}:sc:${i}`;
|
|
51032
|
+
const handle = discovery.registry.register(ns, entry.discoveryName, {
|
|
51033
|
+
ip,
|
|
51034
|
+
port: entry.containerPort,
|
|
51035
|
+
ownerKey
|
|
51036
|
+
});
|
|
51037
|
+
instance.cloudMapHandles.push(handle);
|
|
51038
|
+
for (const alias of entry.clientAliases) if (alias.dnsName) discovery.registry.registerAlias(alias.dnsName, handle.fqdn);
|
|
51039
|
+
i++;
|
|
51040
|
+
}
|
|
51041
|
+
}
|
|
51042
|
+
if (service.serviceRegistries.length > 0) {
|
|
51043
|
+
const index = discovery.cloudMapIndexByStack.get(service.stack.stackName);
|
|
51044
|
+
if (!index) {
|
|
51045
|
+
logger.warn(`ECS Service '${service.serviceLogicalId}' declares ServiceRegistries[] but cdkd has no Cloud Map index for stack ${service.stack.stackName}. Skipping registration.`);
|
|
51046
|
+
return;
|
|
51047
|
+
}
|
|
51048
|
+
let j = 0;
|
|
51049
|
+
for (const reg of service.serviceRegistries) {
|
|
51050
|
+
const cm = index.servicesByLogicalId.get(reg.cloudMapServiceLogicalId);
|
|
51051
|
+
if (!cm) {
|
|
51052
|
+
logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceRegistries[].cloudMapServiceLogicalId='${reg.cloudMapServiceLogicalId}' did not resolve to an AWS::ServiceDiscovery::Service in stack ${service.stack.stackName}. Skipping this registration.`);
|
|
51053
|
+
continue;
|
|
51054
|
+
}
|
|
51055
|
+
let port = reg.containerPort;
|
|
51056
|
+
if (port === void 0 && essential.portMappings.length > 0) port = essential.portMappings[0].containerPort;
|
|
51057
|
+
if (port === void 0) {
|
|
51058
|
+
logger.warn(`ECS Service '${service.serviceLogicalId}' ServiceRegistries[] entry for Cloud Map service '${cm.logicalId}' has no resolvable container port; skipping.`);
|
|
51059
|
+
continue;
|
|
51060
|
+
}
|
|
51061
|
+
const ownerKey = `${ownerKeyPrefix}:sr:${j}`;
|
|
51062
|
+
const handle = discovery.registry.register(cm.namespaceName, cm.name, {
|
|
51063
|
+
ip,
|
|
51064
|
+
port,
|
|
51065
|
+
ownerKey
|
|
51066
|
+
});
|
|
51067
|
+
instance.cloudMapHandles.push(handle);
|
|
51068
|
+
j++;
|
|
51069
|
+
}
|
|
51070
|
+
}
|
|
50634
51071
|
}
|
|
50635
51072
|
/**
|
|
50636
51073
|
* Long-running watcher loop for one replica. Polls the essential
|
|
@@ -50664,6 +51101,12 @@ async function watchReplica(service, options, instance, runState) {
|
|
|
50664
51101
|
logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
|
|
50665
51102
|
await sleep(delay);
|
|
50666
51103
|
if (instance.shuttingDown || runState.shuttingDown) return;
|
|
51104
|
+
if (options.discovery) {
|
|
51105
|
+
for (const handle of instance.cloudMapHandles) try {
|
|
51106
|
+
options.discovery.registry.unregister(handle);
|
|
51107
|
+
} catch {}
|
|
51108
|
+
instance.cloudMapHandles = [];
|
|
51109
|
+
}
|
|
50667
51110
|
try {
|
|
50668
51111
|
await cleanupEcsRun(instance.state, { keepRunning: false });
|
|
50669
51112
|
} catch (err) {
|
|
@@ -50718,6 +51161,305 @@ function sleep(ms) {
|
|
|
50718
51161
|
return sleepImpl(ms);
|
|
50719
51162
|
}
|
|
50720
51163
|
|
|
51164
|
+
//#endregion
|
|
51165
|
+
//#region src/local/cloud-map-registry.ts
|
|
51166
|
+
/**
|
|
51167
|
+
* In-process registry. `register()` is called by the service runner
|
|
51168
|
+
* after each replica's main container boot; `unregister()` is called by
|
|
51169
|
+
* the shutdown / restart paths. `lookupHosts()` produces the
|
|
51170
|
+
* `--add-host` flag list a consumer task's `docker run` injects so
|
|
51171
|
+
* `<discoveryName>.<namespace>` and (when registered as an alias)
|
|
51172
|
+
* bare `<discoveryName>` both resolve to a registered endpoint inside
|
|
51173
|
+
* the consumer container.
|
|
51174
|
+
*
|
|
51175
|
+
* Concurrency note: docker-run callers are not concurrent for the same
|
|
51176
|
+
* replica (the runner boots replicas sequentially), but `lookupHosts()`
|
|
51177
|
+
* MAY be called concurrently with `register()` / `unregister()` of an
|
|
51178
|
+
* unrelated service. The implementation uses synchronous Map mutations
|
|
51179
|
+
* so a stale read returns the previous snapshot — never a partially-
|
|
51180
|
+
* mutated one. No async / mutex needed.
|
|
51181
|
+
*/
|
|
51182
|
+
var CloudMapRegistry = class {
|
|
51183
|
+
/** Map<fqdn, RegistryEntry[]> — one per replica registered under that fqdn. */
|
|
51184
|
+
byFqdn = /* @__PURE__ */ new Map();
|
|
51185
|
+
/**
|
|
51186
|
+
* Map<alias, fqdn> — secondary index for ClientAlias short forms.
|
|
51187
|
+
* Multiple aliases can point at the same fqdn; one alias only points
|
|
51188
|
+
* at one fqdn (last write wins, with a warn surfaced by the resolver
|
|
51189
|
+
* when a cross-namespace collision is detected — see design §O6).
|
|
51190
|
+
*/
|
|
51191
|
+
aliasIndex = /* @__PURE__ */ new Map();
|
|
51192
|
+
/**
|
|
51193
|
+
* Register a replica's endpoint under `{namespace}/{discoveryName}`.
|
|
51194
|
+
*
|
|
51195
|
+
* @param namespace Cloud Map namespace name, e.g. `cdkd-local.local`.
|
|
51196
|
+
* An empty string is rejected — Cloud Map requires a
|
|
51197
|
+
* named namespace.
|
|
51198
|
+
* @param discoveryName Cloud Map service name, e.g. `orders`. Rejected
|
|
51199
|
+
* when empty.
|
|
51200
|
+
* @param endpoint Reachable endpoint + owner key for symmetric
|
|
51201
|
+
* unregister.
|
|
51202
|
+
* @returns A handle the caller stores for later `unregister(handle)`.
|
|
51203
|
+
*/
|
|
51204
|
+
register(namespace, discoveryName, endpoint) {
|
|
51205
|
+
if (!namespace) throw new Error("CloudMapRegistry.register: namespace must be a non-empty string.");
|
|
51206
|
+
if (!discoveryName) throw new Error("CloudMapRegistry.register: discoveryName must be a non-empty string.");
|
|
51207
|
+
const fqdn = `${discoveryName}.${namespace}`;
|
|
51208
|
+
const filtered = (this.byFqdn.get(fqdn) ?? []).filter((e) => e.endpoint.ownerKey !== endpoint.ownerKey);
|
|
51209
|
+
filtered.push({
|
|
51210
|
+
namespace,
|
|
51211
|
+
discoveryName,
|
|
51212
|
+
endpoint
|
|
51213
|
+
});
|
|
51214
|
+
this.byFqdn.set(fqdn, filtered);
|
|
51215
|
+
return {
|
|
51216
|
+
fqdn,
|
|
51217
|
+
ownerKey: endpoint.ownerKey
|
|
51218
|
+
};
|
|
51219
|
+
}
|
|
51220
|
+
/**
|
|
51221
|
+
* Register a bare-name alias (`<discoveryName>` without the namespace
|
|
51222
|
+
* suffix). Cloud Map / Service Connect does NOT auto-create such
|
|
51223
|
+
* aliases — they're populated by `ClientAliases[].DnsName` entries in
|
|
51224
|
+
* the consumer service's `ServiceConnectConfiguration`. Aliases are
|
|
51225
|
+
* scoped per-CLI-invocation and last-write-wins on collision (the
|
|
51226
|
+
* resolver surfaces a `warn` when this happens — see §O6).
|
|
51227
|
+
*
|
|
51228
|
+
* @param alias The bare discovery name (e.g. `orders` for an alias to
|
|
51229
|
+
* `orders.cdkd-local.local`).
|
|
51230
|
+
* @param targetFqdn The full `{discoveryName}.{namespace}` the alias
|
|
51231
|
+
* resolves to.
|
|
51232
|
+
*/
|
|
51233
|
+
registerAlias(alias, targetFqdn) {
|
|
51234
|
+
if (!alias) throw new Error("CloudMapRegistry.registerAlias: alias must be a non-empty string.");
|
|
51235
|
+
if (!targetFqdn) throw new Error("CloudMapRegistry.registerAlias: targetFqdn must be a non-empty string.");
|
|
51236
|
+
this.aliasIndex.set(alias, targetFqdn);
|
|
51237
|
+
}
|
|
51238
|
+
/**
|
|
51239
|
+
* Remove a single endpoint registered under the supplied handle.
|
|
51240
|
+
* Idempotent — unknown handles return false without throwing.
|
|
51241
|
+
*/
|
|
51242
|
+
unregister(handle) {
|
|
51243
|
+
const entries = this.byFqdn.get(handle.fqdn);
|
|
51244
|
+
if (!entries) return false;
|
|
51245
|
+
const filtered = entries.filter((e) => e.endpoint.ownerKey !== handle.ownerKey);
|
|
51246
|
+
if (filtered.length === entries.length) return false;
|
|
51247
|
+
if (filtered.length === 0) this.byFqdn.delete(handle.fqdn);
|
|
51248
|
+
else this.byFqdn.set(handle.fqdn, filtered);
|
|
51249
|
+
return true;
|
|
51250
|
+
}
|
|
51251
|
+
/**
|
|
51252
|
+
* Drop every registration with the supplied owner key (e.g. teardown
|
|
51253
|
+
* of every replica of a service). Used by the service controller's
|
|
51254
|
+
* shutdown path; complementary to per-replica `unregister`.
|
|
51255
|
+
*/
|
|
51256
|
+
unregisterByOwner(ownerKeyPrefix) {
|
|
51257
|
+
let removed = 0;
|
|
51258
|
+
for (const [fqdn, entries] of [...this.byFqdn.entries()]) {
|
|
51259
|
+
const filtered = entries.filter((e) => !e.endpoint.ownerKey.startsWith(ownerKeyPrefix));
|
|
51260
|
+
removed += entries.length - filtered.length;
|
|
51261
|
+
if (filtered.length === 0) this.byFqdn.delete(fqdn);
|
|
51262
|
+
else this.byFqdn.set(fqdn, filtered);
|
|
51263
|
+
}
|
|
51264
|
+
return removed;
|
|
51265
|
+
}
|
|
51266
|
+
/**
|
|
51267
|
+
* Look up every endpoint registered under `{discoveryName}.{namespace}`.
|
|
51268
|
+
* Returns `undefined` when no endpoint exists (which is the consumer
|
|
51269
|
+
* runner's signal to log a warn — the user likely forgot to start the
|
|
51270
|
+
* producer). Returns the underlying array verbatim so callers cannot
|
|
51271
|
+
* accidentally mutate registry state — call `.slice()` if you need a
|
|
51272
|
+
* detachable copy.
|
|
51273
|
+
*/
|
|
51274
|
+
lookup(namespace, discoveryName) {
|
|
51275
|
+
const fqdn = `${discoveryName}.${namespace}`;
|
|
51276
|
+
const entries = this.byFqdn.get(fqdn);
|
|
51277
|
+
if (!entries || entries.length === 0) return void 0;
|
|
51278
|
+
return entries.map((e) => e.endpoint);
|
|
51279
|
+
}
|
|
51280
|
+
/**
|
|
51281
|
+
* Resolve a bare alias to its target endpoints. Returns the same
|
|
51282
|
+
* shape as `lookup` for the alias's target fqdn, or `undefined` when
|
|
51283
|
+
* no such alias exists (which is distinct from "alias known but
|
|
51284
|
+
* target has no live replica" — caller may want different warns).
|
|
51285
|
+
*/
|
|
51286
|
+
lookupAlias(alias) {
|
|
51287
|
+
const fqdn = this.aliasIndex.get(alias);
|
|
51288
|
+
if (!fqdn) return void 0;
|
|
51289
|
+
const entries = this.byFqdn.get(fqdn);
|
|
51290
|
+
if (!entries || entries.length === 0) return void 0;
|
|
51291
|
+
return entries.map((e) => e.endpoint);
|
|
51292
|
+
}
|
|
51293
|
+
/**
|
|
51294
|
+
* Build the `--add-host` flag list for a consumer container's
|
|
51295
|
+
* `docker run`. Returns each unique `(hostname, ip)` pair as a flat
|
|
51296
|
+
* `['--add-host', 'name:ip', ...]` array consumable verbatim by the
|
|
51297
|
+
* docker-runner. Includes both the fqdn AND every alias mapped to it.
|
|
51298
|
+
*
|
|
51299
|
+
* Multiple replicas per fqdn cannot be expressed as multiple
|
|
51300
|
+
* `--add-host` entries with the same name (docker's resolver takes
|
|
51301
|
+
* the *last* entry on duplicate keys per `getent hosts` semantics),
|
|
51302
|
+
* so this returns the **first** registered endpoint per fqdn /
|
|
51303
|
+
* alias. Multi-instance round-robin via the static `--add-host`
|
|
51304
|
+
* shape is structurally impossible; a true rotation requires the
|
|
51305
|
+
* DNS-sidecar option (deferred). Documented as a v1 limitation.
|
|
51306
|
+
*/
|
|
51307
|
+
buildAddHostFlags(excludeOwnerKeyPrefix) {
|
|
51308
|
+
const flags = [];
|
|
51309
|
+
const seen = /* @__PURE__ */ new Set();
|
|
51310
|
+
for (const [fqdn, entries] of this.byFqdn.entries()) {
|
|
51311
|
+
const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
|
|
51312
|
+
if (!candidate) continue;
|
|
51313
|
+
if (seen.has(fqdn)) continue;
|
|
51314
|
+
flags.push("--add-host", `${fqdn}:${candidate.endpoint.ip}`);
|
|
51315
|
+
seen.add(fqdn);
|
|
51316
|
+
}
|
|
51317
|
+
for (const [alias, targetFqdn] of this.aliasIndex.entries()) {
|
|
51318
|
+
if (seen.has(alias)) continue;
|
|
51319
|
+
const entries = this.byFqdn.get(targetFqdn);
|
|
51320
|
+
if (!entries || entries.length === 0) continue;
|
|
51321
|
+
const candidate = entries.find((e) => !excludeOwnerKeyPrefix || !e.endpoint.ownerKey.startsWith(excludeOwnerKeyPrefix));
|
|
51322
|
+
if (!candidate) continue;
|
|
51323
|
+
flags.push("--add-host", `${alias}:${candidate.endpoint.ip}`);
|
|
51324
|
+
seen.add(alias);
|
|
51325
|
+
}
|
|
51326
|
+
return flags;
|
|
51327
|
+
}
|
|
51328
|
+
/**
|
|
51329
|
+
* Diagnostic snapshot used by the boot banner / test assertions.
|
|
51330
|
+
* Stable iteration order (insertion-order is preserved by JS Maps).
|
|
51331
|
+
*/
|
|
51332
|
+
list() {
|
|
51333
|
+
const out = [];
|
|
51334
|
+
for (const [, entries] of this.byFqdn.entries()) {
|
|
51335
|
+
if (entries.length === 0) continue;
|
|
51336
|
+
const first = entries[0];
|
|
51337
|
+
out.push({
|
|
51338
|
+
namespace: first.namespace,
|
|
51339
|
+
discoveryName: first.discoveryName,
|
|
51340
|
+
endpoints: entries.map((e) => e.endpoint),
|
|
51341
|
+
isAlias: false
|
|
51342
|
+
});
|
|
51343
|
+
}
|
|
51344
|
+
for (const [alias, fqdn] of this.aliasIndex.entries()) {
|
|
51345
|
+
const entries = this.byFqdn.get(fqdn);
|
|
51346
|
+
if (!entries || entries.length === 0) continue;
|
|
51347
|
+
out.push({
|
|
51348
|
+
namespace: "",
|
|
51349
|
+
discoveryName: alias,
|
|
51350
|
+
endpoints: entries.map((e) => e.endpoint),
|
|
51351
|
+
isAlias: true
|
|
51352
|
+
});
|
|
51353
|
+
}
|
|
51354
|
+
return out;
|
|
51355
|
+
}
|
|
51356
|
+
/** True when no endpoint is registered. Used by the runner to short-circuit. */
|
|
51357
|
+
isEmpty() {
|
|
51358
|
+
return this.byFqdn.size === 0;
|
|
51359
|
+
}
|
|
51360
|
+
};
|
|
51361
|
+
|
|
51362
|
+
//#endregion
|
|
51363
|
+
//#region src/local/cloud-map-resolver.ts
|
|
51364
|
+
/**
|
|
51365
|
+
* Build the `CloudMapIndex` for one stack's template. Empty index when
|
|
51366
|
+
* the stack declares no `AWS::ServiceDiscovery::*` resources.
|
|
51367
|
+
*
|
|
51368
|
+
* Hard-reject errors (throw `EcsTaskResolutionError`):
|
|
51369
|
+
* - `AWS::ServiceDiscovery::PublicDnsNamespace` — defeats "local" semantics.
|
|
51370
|
+
* - `AWS::ServiceDiscovery::HttpNamespace` — DiscoverInstances-only,
|
|
51371
|
+
* no DNS, would require shimming the AWS SDK inside every container.
|
|
51372
|
+
* - An `AWS::ServiceDiscovery::Service` with a `NamespaceId` that
|
|
51373
|
+
* doesn't resolve to a same-stack `PrivateDnsNamespace`.
|
|
51374
|
+
*/
|
|
51375
|
+
function buildCloudMapIndex(stack) {
|
|
51376
|
+
const namespacesByLogicalId = /* @__PURE__ */ new Map();
|
|
51377
|
+
const namespacesByName = /* @__PURE__ */ new Map();
|
|
51378
|
+
const servicesByLogicalId = /* @__PURE__ */ new Map();
|
|
51379
|
+
const warnings = [];
|
|
51380
|
+
const resources = stack.template.Resources ?? {};
|
|
51381
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
51382
|
+
if (resource.Type === "AWS::ServiceDiscovery::PublicDnsNamespace") throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::PublicDnsNamespace '${logicalId}' is not supported by local emulation — public DNS defeats the "local" point. Use a PrivateDnsNamespace for cdkd local start-service.`);
|
|
51383
|
+
if (resource.Type === "AWS::ServiceDiscovery::HttpNamespace") throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::HttpNamespace '${logicalId}' is not supported by local emulation — HttpNamespace uses the AWS Cloud Map DiscoverInstances API directly (no DNS), which would require shimming the AWS SDK inside every container. Use a PrivateDnsNamespace + DnsConfig instead.`);
|
|
51384
|
+
if (resource.Type !== "AWS::ServiceDiscovery::PrivateDnsNamespace") continue;
|
|
51385
|
+
const props = resource.Properties ?? {};
|
|
51386
|
+
const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
|
|
51387
|
+
if (!name) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: PrivateDnsNamespace '${logicalId}' has no literal Name property. Intrinsic-valued names are not supported (cross-stack / dynamic namespace names require deploy-state resolution which is out of scope for v1).`);
|
|
51388
|
+
const entry = {
|
|
51389
|
+
logicalId,
|
|
51390
|
+
name
|
|
51391
|
+
};
|
|
51392
|
+
namespacesByLogicalId.set(logicalId, entry);
|
|
51393
|
+
if (namespacesByName.has(name)) warnings.push(`Stack ${stack.stackName}: two PrivateDnsNamespace resources share Name='${name}' ('${namespacesByName.get(name).logicalId}' and '${logicalId}'). Local emulation routes registrations to the first; the second will silently shadow.`);
|
|
51394
|
+
else namespacesByName.set(name, entry);
|
|
51395
|
+
}
|
|
51396
|
+
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
51397
|
+
if (resource.Type !== "AWS::ServiceDiscovery::Service") continue;
|
|
51398
|
+
const props = resource.Properties ?? {};
|
|
51399
|
+
const namespaceLogicalId = resolveNamespaceIdRef(props["NamespaceId"] ?? props["DnsConfig"]?.["NamespaceId"], stack.stackName, logicalId);
|
|
51400
|
+
const ns = namespacesByLogicalId.get(namespaceLogicalId);
|
|
51401
|
+
if (!ns) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::Service '${logicalId}' references NamespaceId '${namespaceLogicalId}' but no PrivateDnsNamespace with that logical id exists in this stack. Cross-stack Cloud Map namespaces are not supported in v1.`);
|
|
51402
|
+
const name = typeof props["Name"] === "string" ? props["Name"] : void 0;
|
|
51403
|
+
if (!name) throw new EcsTaskResolutionError(`Stack ${stack.stackName}: AWS::ServiceDiscovery::Service '${logicalId}' has no literal Name property. Intrinsic-valued names are not supported in v1.`);
|
|
51404
|
+
const dnsRecords = extractDnsRecords(props);
|
|
51405
|
+
servicesByLogicalId.set(logicalId, {
|
|
51406
|
+
logicalId,
|
|
51407
|
+
namespaceLogicalId,
|
|
51408
|
+
namespaceName: ns.name,
|
|
51409
|
+
name,
|
|
51410
|
+
dnsRecords
|
|
51411
|
+
});
|
|
51412
|
+
}
|
|
51413
|
+
return {
|
|
51414
|
+
namespacesByLogicalId,
|
|
51415
|
+
namespacesByName,
|
|
51416
|
+
servicesByLogicalId,
|
|
51417
|
+
warnings
|
|
51418
|
+
};
|
|
51419
|
+
}
|
|
51420
|
+
/**
|
|
51421
|
+
* Resolve `NamespaceId` to the parent's logical id. CDK 2.x synthesizes
|
|
51422
|
+
* this as `{Fn::GetAtt: ['<NsLogicalId>', 'Id']}` (verified via real
|
|
51423
|
+
* `cdk synth` on 2026-05-22). `Ref` is also accepted defensively
|
|
51424
|
+
* (returns the namespace's physical id, but inside one synth template
|
|
51425
|
+
* the Ref target IS the logical id we want).
|
|
51426
|
+
*
|
|
51427
|
+
* Cross-stack / intrinsic shapes that we cannot resolve at synth time
|
|
51428
|
+
* are hard-rejected — cdkd would otherwise silently route to no
|
|
51429
|
+
* namespace and the consumer would get an unhelpful "DNS lookup failed"
|
|
51430
|
+
* at runtime.
|
|
51431
|
+
*/
|
|
51432
|
+
function resolveNamespaceIdRef(raw, stackName, serviceLogicalId) {
|
|
51433
|
+
if (typeof raw === "string") return raw;
|
|
51434
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
51435
|
+
const obj = raw;
|
|
51436
|
+
if (typeof obj["Ref"] === "string") return obj["Ref"];
|
|
51437
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
51438
|
+
if (Array.isArray(getAtt) && typeof getAtt[0] === "string") return getAtt[0];
|
|
51439
|
+
}
|
|
51440
|
+
throw new EcsTaskResolutionError(`Stack ${stackName}: AWS::ServiceDiscovery::Service '${serviceLogicalId}' has an unsupported NamespaceId reference shape: ${JSON.stringify(raw)}. Accepted shapes are {Fn::GetAtt: [<NsLogicalId>, 'Id']} or {Ref: <NsLogicalId>} pointing at a same-stack PrivateDnsNamespace.`);
|
|
51441
|
+
}
|
|
51442
|
+
function extractDnsRecords(serviceProps) {
|
|
51443
|
+
const dnsConfig = serviceProps["DnsConfig"];
|
|
51444
|
+
if (!dnsConfig || typeof dnsConfig !== "object") return [];
|
|
51445
|
+
const records = dnsConfig["DnsRecords"];
|
|
51446
|
+
if (!Array.isArray(records)) return [];
|
|
51447
|
+
const out = [];
|
|
51448
|
+
for (const r of records) {
|
|
51449
|
+
if (!r || typeof r !== "object") continue;
|
|
51450
|
+
const obj = r;
|
|
51451
|
+
const type = obj["Type"];
|
|
51452
|
+
if (type !== "A" && type !== "SRV") continue;
|
|
51453
|
+
const ttl = obj["TTL"];
|
|
51454
|
+
const ttlSeconds = typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? Math.floor(ttl) : typeof ttl === "string" && /^\d+$/.test(ttl) ? parseInt(ttl, 10) : 60;
|
|
51455
|
+
out.push({
|
|
51456
|
+
type,
|
|
51457
|
+
ttlSeconds
|
|
51458
|
+
});
|
|
51459
|
+
}
|
|
51460
|
+
return out;
|
|
51461
|
+
}
|
|
51462
|
+
|
|
50721
51463
|
//#endregion
|
|
50722
51464
|
//#region src/cli/commands/local-start-service.ts
|
|
50723
51465
|
/**
|
|
@@ -50732,17 +51474,34 @@ function sleep(ms) {
|
|
|
50732
51474
|
* - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
|
|
50733
51475
|
* - Service Connect / Cloud Map — tracked separately in #460.
|
|
50734
51476
|
*/
|
|
50735
|
-
async function localStartServiceCommand(
|
|
51477
|
+
async function localStartServiceCommand(targets, options) {
|
|
50736
51478
|
const logger = getLogger();
|
|
50737
51479
|
if (options.verbose) logger.setLevel("debug");
|
|
50738
51480
|
warnIfDeprecatedRegion(options);
|
|
50739
|
-
|
|
51481
|
+
if (!targets || targets.length === 0) throw new LocalStartServiceError("cdkd local start-service requires at least one <target>. Pass one or more service paths like 'Stack/Orders' 'Stack/Frontend'.");
|
|
51482
|
+
const perTarget = targets.map((t) => ({
|
|
51483
|
+
target: t,
|
|
51484
|
+
runState: createServiceRunState()
|
|
51485
|
+
}));
|
|
50740
51486
|
let sigintHandler;
|
|
50741
51487
|
let sigintCount = 0;
|
|
50742
|
-
let
|
|
51488
|
+
let sharedNetwork;
|
|
50743
51489
|
const cleanup = singleFlight(async () => {
|
|
50744
|
-
|
|
50745
|
-
|
|
51490
|
+
await Promise.allSettled(perTarget.map(async (pt) => {
|
|
51491
|
+
if (pt.controller) await pt.controller.shutdown();
|
|
51492
|
+
else {
|
|
51493
|
+
await Promise.allSettled(pt.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0));
|
|
51494
|
+
await Promise.allSettled(pt.runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
|
|
51495
|
+
}
|
|
51496
|
+
}));
|
|
51497
|
+
if (sharedNetwork) {
|
|
51498
|
+
try {
|
|
51499
|
+
await destroyTaskNetwork(sharedNetwork);
|
|
51500
|
+
} catch (err) {
|
|
51501
|
+
getLogger().warn(`shared service network teardown failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
51502
|
+
}
|
|
51503
|
+
sharedNetwork = void 0;
|
|
51504
|
+
}
|
|
50746
51505
|
}, (err) => getLogger().warn(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
50747
51506
|
try {
|
|
50748
51507
|
await applyRoleArnIfSet({
|
|
@@ -50765,72 +51524,42 @@ async function localStartServiceCommand(target, options) {
|
|
|
50765
51524
|
...options.profile && { macroExpandS3ClientOpts: { profile: options.profile } }
|
|
50766
51525
|
};
|
|
50767
51526
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
50768
|
-
const
|
|
50769
|
-
const
|
|
50770
|
-
|
|
50771
|
-
|
|
50772
|
-
|
|
50773
|
-
|
|
50774
|
-
|
|
50775
|
-
|
|
50776
|
-
|
|
50777
|
-
|
|
50778
|
-
|
|
51527
|
+
const cloudMapIndexByStack = /* @__PURE__ */ new Map();
|
|
51528
|
+
for (const stack of stacks) {
|
|
51529
|
+
const index = buildCloudMapIndex(stack);
|
|
51530
|
+
cloudMapIndexByStack.set(stack.stackName, index);
|
|
51531
|
+
for (const w of index.warnings) logger.warn(w);
|
|
51532
|
+
}
|
|
51533
|
+
const registry = new CloudMapRegistry();
|
|
51534
|
+
try {
|
|
51535
|
+
sharedNetwork = await createSharedSvcNetwork({
|
|
51536
|
+
prefix: options.cluster,
|
|
51537
|
+
skipPull: options.pull === false,
|
|
51538
|
+
cluster: options.cluster
|
|
50779
51539
|
});
|
|
50780
|
-
|
|
50781
|
-
|
|
50782
|
-
|
|
50783
|
-
|
|
50784
|
-
|
|
50785
|
-
|
|
50786
|
-
|
|
50787
|
-
|
|
50788
|
-
} finally {
|
|
50789
|
-
built.dispose();
|
|
50790
|
-
}
|
|
50791
|
-
} else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
|
|
51540
|
+
} catch (err) {
|
|
51541
|
+
throw new LocalStartServiceError(`Failed to create shared service network: ${err instanceof Error ? err.message : String(err)}`);
|
|
51542
|
+
}
|
|
51543
|
+
const discovery = {
|
|
51544
|
+
registry,
|
|
51545
|
+
cloudMapIndexByStack,
|
|
51546
|
+
sharedNetwork
|
|
51547
|
+
};
|
|
50792
51548
|
sigintHandler = () => {
|
|
50793
51549
|
sigintCount += 1;
|
|
50794
51550
|
if (sigintCount >= 2) {
|
|
50795
51551
|
process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
|
|
50796
51552
|
process.exit(130);
|
|
50797
51553
|
}
|
|
50798
|
-
logger.info("Stopping service...");
|
|
51554
|
+
logger.info("Stopping service(s)...");
|
|
50799
51555
|
cleanup().then(() => process.exit(130));
|
|
50800
51556
|
};
|
|
50801
51557
|
process.on("SIGINT", sigintHandler);
|
|
50802
51558
|
process.on("SIGTERM", sigintHandler);
|
|
50803
|
-
|
|
50804
|
-
|
|
50805
|
-
|
|
50806
|
-
|
|
50807
|
-
resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
|
|
50808
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
50809
|
-
} else if (typeof options.assumeTaskRole === "string") {
|
|
50810
|
-
resolvedRoleArn = options.assumeTaskRole;
|
|
50811
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
50812
|
-
}
|
|
50813
|
-
const envOverrides = readEnvOverridesFile$1(options.envVars);
|
|
50814
|
-
const taskOpts = {
|
|
50815
|
-
cluster: options.cluster,
|
|
50816
|
-
containerHost: options.containerHost,
|
|
50817
|
-
skipPull: options.pull === false,
|
|
50818
|
-
keepRunning: false,
|
|
50819
|
-
detach: true
|
|
50820
|
-
};
|
|
50821
|
-
if (envOverrides) taskOpts.envOverrides = envOverrides;
|
|
50822
|
-
if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
|
|
50823
|
-
if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
|
|
50824
|
-
if (options.platform) taskOpts.platformOverride = options.platform;
|
|
50825
|
-
if (options.region) taskOpts.region = options.region;
|
|
50826
|
-
if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
|
|
50827
|
-
controller = await startEcsService(service, {
|
|
50828
|
-
maxTasks: options.maxTasks,
|
|
50829
|
-
restartPolicy: options.restartPolicy,
|
|
50830
|
-
taskOptions: taskOpts
|
|
50831
|
-
}, runState);
|
|
50832
|
-
logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
|
|
50833
|
-
await controller.waitForShutdown();
|
|
51559
|
+
for (const pt of perTarget) pt.controller = await bootOneTarget(pt.target, pt.runState, stacks, options, discovery);
|
|
51560
|
+
const summary = perTarget.map((pt) => `${pt.controller.service.serviceName} (${pt.controller.activeReplicaCount()} replica(s))`).join(", ");
|
|
51561
|
+
logger.info(`Service(s) running: ${summary}. Press ^C to shut down.`);
|
|
51562
|
+
await Promise.all(perTarget.map((pt) => pt.controller.waitForShutdown()));
|
|
50834
51563
|
} finally {
|
|
50835
51564
|
if (sigintHandler) {
|
|
50836
51565
|
process.off("SIGINT", sigintHandler);
|
|
@@ -50839,6 +51568,72 @@ async function localStartServiceCommand(target, options) {
|
|
|
50839
51568
|
await cleanup();
|
|
50840
51569
|
}
|
|
50841
51570
|
}
|
|
51571
|
+
/**
|
|
51572
|
+
* Boot one target. Extracted from the loop so each per-service block
|
|
51573
|
+
* (image context, cross-stack resolver, task-role credentials, runner
|
|
51574
|
+
* options) is scoped locally. Returns the started controller for the
|
|
51575
|
+
* outer code to wait + tear down.
|
|
51576
|
+
*/
|
|
51577
|
+
async function bootOneTarget(target, runState, stacks, options, discovery) {
|
|
51578
|
+
const logger = getLogger();
|
|
51579
|
+
const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
|
|
51580
|
+
const service = resolveEcsServiceTarget(target, stacks, imageContext);
|
|
51581
|
+
logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
|
|
51582
|
+
for (const w of service.warnings) logger.warn(w);
|
|
51583
|
+
if (service.serviceConnect) logger.info(`Service Connect: namespace='${service.serviceConnect.namespaceName}', ${service.serviceConnect.services.length} service(s) registered for peer discovery.`);
|
|
51584
|
+
if (service.serviceRegistries.length > 0) logger.info(`Cloud Map: ${service.serviceRegistries.length} ServiceRegistry binding(s).`);
|
|
51585
|
+
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
|
|
51586
|
+
if (options.fromState && taskNeeds.needsCrossStackResolver) {
|
|
51587
|
+
const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
|
|
51588
|
+
const built = await buildCrossStackResolver(consumerRegion, {
|
|
51589
|
+
...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
|
|
51590
|
+
statePrefix: options.statePrefix,
|
|
51591
|
+
...options.region !== void 0 && { region: options.region },
|
|
51592
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
51593
|
+
});
|
|
51594
|
+
if (built) try {
|
|
51595
|
+
const subContext = {
|
|
51596
|
+
resources: imageContext?.stateResources ?? {},
|
|
51597
|
+
...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
|
|
51598
|
+
consumerRegion,
|
|
51599
|
+
crossStackResolver: built.resolver
|
|
51600
|
+
};
|
|
51601
|
+
await applyCrossStackResolverToTask(service.task, subContext);
|
|
51602
|
+
} finally {
|
|
51603
|
+
built.dispose();
|
|
51604
|
+
}
|
|
51605
|
+
} else if (!options.fromState && taskNeeds.needsCrossStackResolver) logger.warn("Container Environment / Secrets entries contain Fn::ImportValue / Fn::GetStackOutput intrinsics. Pass --from-state to substitute them against deployed cdkd state.");
|
|
51606
|
+
let assumedCredentials;
|
|
51607
|
+
let resolvedRoleArn;
|
|
51608
|
+
if (options.assumeTaskRole === true) {
|
|
51609
|
+
if (!service.task.taskRoleArn) throw new LocalStartServiceError(`--assume-task-role passed without an ARN but service '${service.serviceLogicalId}' has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>`);
|
|
51610
|
+
resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
|
|
51611
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
51612
|
+
} else if (typeof options.assumeTaskRole === "string") {
|
|
51613
|
+
resolvedRoleArn = options.assumeTaskRole;
|
|
51614
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
51615
|
+
}
|
|
51616
|
+
const envOverrides = readEnvOverridesFile$1(options.envVars);
|
|
51617
|
+
const taskOpts = {
|
|
51618
|
+
cluster: options.cluster,
|
|
51619
|
+
containerHost: options.containerHost,
|
|
51620
|
+
skipPull: options.pull === false,
|
|
51621
|
+
keepRunning: false,
|
|
51622
|
+
detach: true
|
|
51623
|
+
};
|
|
51624
|
+
if (envOverrides) taskOpts.envOverrides = envOverrides;
|
|
51625
|
+
if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
|
|
51626
|
+
if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
|
|
51627
|
+
if (options.platform) taskOpts.platformOverride = options.platform;
|
|
51628
|
+
if (options.region) taskOpts.region = options.region;
|
|
51629
|
+
if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
|
|
51630
|
+
return startEcsService(service, {
|
|
51631
|
+
maxTasks: options.maxTasks,
|
|
51632
|
+
restartPolicy: options.restartPolicy,
|
|
51633
|
+
taskOptions: taskOpts,
|
|
51634
|
+
discovery
|
|
51635
|
+
}, runState);
|
|
51636
|
+
}
|
|
50842
51637
|
async function resolvePlaceholderAccount(arn, region) {
|
|
50843
51638
|
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
50844
51639
|
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
@@ -50981,7 +51776,7 @@ function parseRestartPolicy(raw) {
|
|
|
50981
51776
|
throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
|
|
50982
51777
|
}
|
|
50983
51778
|
function createLocalStartServiceCommand() {
|
|
50984
|
-
const cmd = new Command("start-service").description("Run
|
|
51779
|
+
const cmd = new Command("start-service").description("Run one or more AWS::ECS::Service resources locally as a long-running emulator. Spins up DesiredCount task replicas per service (clamped by --max-tasks) using the same per-task docker network + metadata sidecar pattern as `cdkd local run-task`, then keeps each replica running and restarts it on exit per --restart-policy. ^C tears every replica + sidecar + network down. Each <target> accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix. When two or more <target>s are supplied, every service is booted into a shared Cloud Map / Service Connect registry so peer services discover each other via docker --add-host overlay (Issue #460).").argument("<targets...>", "One or more CDK display paths or stack-qualified logical IDs of the AWS::ECS::Service resources 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 task 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.")).addOption(new Option("--platform <platform>", "Force docker --platform (linux/amd64 or linux/arm64). Default: inferred from task RuntimePlatform.CpuArchitecture")).addOption(new Option("--max-tasks <n>", `Hard cap on local replica count. Caps the template DesiredCount so local dev machines don't run an unbounded number of containers. Cannot exceed ${84} due to the per-replica link-local /24 subnet allocator's range.`).default(3).argParser(parseMaxTasks)).addOption(new Option("--restart-policy <policy>", "How to react when an essential container exits. 'on-failure' (default) restarts only on non-zero exit; 'always' restarts on every exit; 'none' shuts the replica down and runs the service degraded.").default("on-failure").argParser(parseRestartPolicy)).addOption(new Option("--from-state", "Read cdkd S3 state for the target stack and substitute Fn::Sub / Fn::GetAtt / Fn::ImportValue / Fn::GetStackOutput intrinsics in container images, environment variables, secrets, role ARNs, and volumes.").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(localStartServiceCommand));
|
|
50985
51780
|
[
|
|
50986
51781
|
...commonOptions,
|
|
50987
51782
|
...appOptions,
|
|
@@ -54164,7 +54959,7 @@ function reorderArgs(argv) {
|
|
|
54164
54959
|
*/
|
|
54165
54960
|
async function main() {
|
|
54166
54961
|
const program = new Command();
|
|
54167
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
54962
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.139.0");
|
|
54168
54963
|
program.addCommand(createBootstrapCommand());
|
|
54169
54964
|
program.addCommand(createSynthCommand());
|
|
54170
54965
|
program.addCommand(createListCommand());
|