@go-to-k/cdkd 0.129.0 → 0.131.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 +38 -23
- package/dist/{aws-clients-CuHRHcyW.js → aws-clients-BF03Alpe.js} +2 -18
- package/dist/{aws-clients-CuHRHcyW.js.map → aws-clients-BF03Alpe.js.map} +1 -1
- package/dist/cli.js +992 -55
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-DWpeb9wT.js → deploy-engine-Dn7oV5rA.js} +22 -991
- package/dist/deploy-engine-Dn7oV5rA.js.map +1 -0
- package/dist/docker-cmd-EtWSTAje.js +998 -0
- package/dist/docker-cmd-EtWSTAje.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/rolldown-runtime-CjeV3_4I.js +18 -0
- package/package.json +1 -1
- package/dist/deploy-engine-DWpeb9wT.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as
|
|
3
|
-
import { A as AssetPublisher, B as
|
|
2
|
+
import { _ as withSkipPrefix, a as runDockerStreaming, c as getLogger, d as getLiveRenderer, f as PATTERN_B_NAME_PROPERTIES, g as generateResourceNameWithFallback, h as generateResourceName, i as runDockerForeground, n as formatDockerLoginError, p as PATTERN_B_RESOURCE_TYPES, r as getDockerCmd, u as runStackBuffered, v as withStackName } from "./docker-cmd-EtWSTAje.js";
|
|
3
|
+
import { $ as PartialFailureError, A as AssetPublisher, B as resolveStateBucketWithDefault, C as applyRoleArnIfSet, D as LockManager, E as TemplateParser, F as getDefaultStateBucketName, G as resolveBucketRegion, H as warnDeprecatedNoPrefixCliFlag, I as getLegacyStateBucketName, L as resolveApp, M as WorkGraph, N as buildDockerImage, O as S3StateBackend, P as Synthesizer, R as resolveCaptureObservedState, S as IntrinsicFunctionResolver, T as DagBuilder, U as AssemblyReader, V as resolveStateBucketWithDefaultAndSource, X as LocalInvokeBuildError, Z as LocalStartServiceError, _ as normalizeAwsTagsToCfn, a as withRetry, at as StackTerminationProtectionError, b as CloudControlProvider, c as cyan, d as red, dt as withErrorHandling, et as ProvisioningError, f as yellow, g as matchesCdkPath, h as CDK_PATH_TAG, i as withResourceDeadline, it as StackHasActiveImportsError, j as stringifyValue, k as shouldRetainResource, l as gray, m as collectInlinePolicyNamesManagedBySiblings, n as DEFAULT_RESOURCE_WARN_AFTER_MS, nt as ResourceUpdateNotSupportedError, o as IMPLICIT_DELETE_DEPENDENCIES, p as IAMRoleProvider, q as CdkdError, r as DeployEngine, rt as RouteDiscoveryError, s as bold, t as DEFAULT_RESOURCE_TIMEOUT_MS, tt as ResourceTimeoutError, u as green, ut as normalizeAwsError, v as resolveExplicitPhysicalId, w as DiffCalculator, x as assertRegionMatch, y as ProviderRegistry, z as resolveSkipPrefix } from "./deploy-engine-Dn7oV5rA.js";
|
|
4
|
+
import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-BF03Alpe.js";
|
|
4
5
|
import { createHash, createHmac, createPublicKey, createVerify, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
6
|
import { CopyObjectCommand, CreateBucketCommand, DeleteBucketAnalyticsConfigurationCommand, DeleteBucketCommand, DeleteBucketCorsCommand, DeleteBucketIntelligentTieringConfigurationCommand, DeleteBucketInventoryConfigurationCommand, DeleteBucketLifecycleCommand, DeleteBucketMetricsConfigurationCommand, DeleteBucketPolicyCommand, DeleteBucketReplicationCommand, DeleteBucketTaggingCommand, DeleteBucketWebsiteCommand, DeleteObjectCommand, DeleteObjectsCommand, GetBucketAccelerateConfigurationCommand, GetBucketCorsCommand, GetBucketEncryptionCommand, GetBucketLifecycleConfigurationCommand, GetBucketLocationCommand, GetBucketLoggingCommand, GetBucketNotificationConfigurationCommand, GetBucketPolicyCommand, GetBucketReplicationCommand, GetBucketTaggingCommand, GetBucketVersioningCommand, GetBucketWebsiteCommand, GetObjectCommand, GetObjectLockConfigurationCommand, GetPublicAccessBlockCommand, HeadBucketCommand, ListBucketAnalyticsConfigurationsCommand, ListBucketIntelligentTieringConfigurationsCommand, ListBucketInventoryConfigurationsCommand, ListBucketMetricsConfigurationsCommand, ListBucketsCommand, ListDirectoryBucketsCommand, ListObjectVersionsCommand, ListObjectsV2Command, NoSuchBucket, PutBucketAccelerateConfigurationCommand, PutBucketAnalyticsConfigurationCommand, PutBucketCorsCommand, PutBucketEncryptionCommand, PutBucketIntelligentTieringConfigurationCommand, PutBucketInventoryConfigurationCommand, PutBucketLifecycleConfigurationCommand, PutBucketLoggingCommand, PutBucketMetricsConfigurationCommand, PutBucketNotificationConfigurationCommand, PutBucketOwnershipControlsCommand, PutBucketPolicyCommand, PutBucketReplicationCommand, PutBucketTaggingCommand, PutBucketVersioningCommand, PutBucketWebsiteCommand, PutObjectCommand, PutObjectLockConfigurationCommand, PutPublicAccessBlockCommand, S3Client, S3ServiceException } from "@aws-sdk/client-s3";
|
|
6
7
|
import { AddRoleToInstanceProfileCommand, AddUserToGroupCommand, AttachGroupPolicyCommand, AttachUserPolicyCommand, CreateGroupCommand, CreateInstanceProfileCommand, CreateLoginProfileCommand, CreateUserCommand, DeleteAccessKeyCommand, DeleteGroupCommand, DeleteGroupPolicyCommand, DeleteInstanceProfileCommand, DeleteLoginProfileCommand, DeleteRolePolicyCommand, DeleteUserCommand, DeleteUserPermissionsBoundaryCommand, DeleteUserPolicyCommand, DetachGroupPolicyCommand, DetachUserPolicyCommand, GetGroupCommand, GetGroupPolicyCommand, GetInstanceProfileCommand, GetRolePolicyCommand, GetUserCommand, GetUserPolicyCommand, IAMClient, ListAccessKeysCommand, ListAttachedGroupPoliciesCommand, ListAttachedUserPoliciesCommand, ListGroupPoliciesCommand, ListGroupsForUserCommand, ListInstanceProfilesCommand, ListUserPoliciesCommand, ListUserTagsCommand, ListUsersCommand, NoSuchEntityException, PutGroupPolicyCommand, PutRolePolicyCommand, PutUserPermissionsBoundaryCommand, PutUserPolicyCommand, RemoveRoleFromInstanceProfileCommand, RemoveUserFromGroupCommand, TagUserCommand, UntagUserCommand, UpdateLoginProfileCommand } from "@aws-sdk/client-iam";
|
|
@@ -36541,14 +36542,14 @@ function parseTarget(target) {
|
|
|
36541
36542
|
function resolveLambdaTarget(target, stacks) {
|
|
36542
36543
|
if (stacks.length === 0) throw new LocalInvokeResolutionError("No stacks found in the synthesized assembly.");
|
|
36543
36544
|
const parsed = parseTarget(target);
|
|
36544
|
-
const stack = pickStack$
|
|
36545
|
+
const stack = pickStack$2(parsed, stacks);
|
|
36545
36546
|
const template = stack.template;
|
|
36546
36547
|
const resources = template.Resources ?? {};
|
|
36547
36548
|
let match;
|
|
36548
36549
|
if (parsed.isPath) {
|
|
36549
36550
|
const index = buildCdkPathIndex(template);
|
|
36550
36551
|
const lambdaMatches = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId }) => resources[logicalId]?.Type === "AWS::Lambda::Function");
|
|
36551
|
-
if (lambdaMatches.length === 0) throw notFoundError$
|
|
36552
|
+
if (lambdaMatches.length === 0) throw notFoundError$2(target, stack, resources);
|
|
36552
36553
|
if (lambdaMatches.length > 1) throw new LocalInvokeResolutionError(`Target '${target}' matches ${lambdaMatches.length} Lambda functions in ${stack.stackName}: ` + lambdaMatches.map((m) => m.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
|
|
36553
36554
|
const m = lambdaMatches[0];
|
|
36554
36555
|
match = {
|
|
@@ -36557,7 +36558,7 @@ function resolveLambdaTarget(target, stacks) {
|
|
|
36557
36558
|
};
|
|
36558
36559
|
} else {
|
|
36559
36560
|
const resource = resources[parsed.pathOrId];
|
|
36560
|
-
if (!resource) throw notFoundError$
|
|
36561
|
+
if (!resource) throw notFoundError$2(target, stack, resources);
|
|
36561
36562
|
match = {
|
|
36562
36563
|
logicalId: parsed.pathOrId,
|
|
36563
36564
|
resource
|
|
@@ -36575,7 +36576,7 @@ function resolveLambdaTarget(target, stacks) {
|
|
|
36575
36576
|
* user may omit the stack prefix. Otherwise an explicit stack pattern is
|
|
36576
36577
|
* required.
|
|
36577
36578
|
*/
|
|
36578
|
-
function pickStack$
|
|
36579
|
+
function pickStack$2(parsed, stacks) {
|
|
36579
36580
|
if (parsed.stackPattern === null) {
|
|
36580
36581
|
if (stacks.length === 1) return stacks[0];
|
|
36581
36582
|
throw new LocalInvokeResolutionError(`Multiple stacks in app, target '${parsed.pathOrId}' is missing a stack prefix. Use 'StackName:${parsed.pathOrId}' or 'StackName/...' (path form). Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
|
|
@@ -36912,7 +36913,7 @@ function describeLayerEntry(entry) {
|
|
|
36912
36913
|
* the resolved stack so the user can copy/paste a valid target. Mirrors
|
|
36913
36914
|
* the format the issue spec calls out.
|
|
36914
36915
|
*/
|
|
36915
|
-
function notFoundError$
|
|
36916
|
+
function notFoundError$2(target, stack, resources) {
|
|
36916
36917
|
const lambdas = [];
|
|
36917
36918
|
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
36918
36919
|
if (resource.Type !== "AWS::Lambda::Function") continue;
|
|
@@ -37936,28 +37937,28 @@ function parseEcsTarget(target) {
|
|
|
37936
37937
|
function resolveEcsTaskTarget(target, stacks, context) {
|
|
37937
37938
|
if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
|
|
37938
37939
|
const parsed = parseEcsTarget(target);
|
|
37939
|
-
const stack = pickStack(parsed, stacks);
|
|
37940
|
+
const stack = pickStack$1(parsed, stacks);
|
|
37940
37941
|
const resources = stack.template.Resources ?? {};
|
|
37941
37942
|
let logicalId;
|
|
37942
37943
|
let resource;
|
|
37943
37944
|
if (parsed.isPath) {
|
|
37944
37945
|
const index = buildCdkPathIndex(stack.template);
|
|
37945
37946
|
const taskDefs = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId: l }) => resources[l]?.Type === "AWS::ECS::TaskDefinition");
|
|
37946
|
-
if (taskDefs.length === 0) throw notFoundError(target, stack, resources);
|
|
37947
|
+
if (taskDefs.length === 0) throw notFoundError$1(target, stack, resources);
|
|
37947
37948
|
if (taskDefs.length > 1) throw new EcsTaskResolutionError(`Target '${target}' matches ${taskDefs.length} task definitions in ${stack.stackName}: ` + taskDefs.map((t) => t.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
|
|
37948
37949
|
logicalId = taskDefs[0].logicalId;
|
|
37949
37950
|
resource = resources[logicalId];
|
|
37950
37951
|
} else {
|
|
37951
37952
|
resource = resources[parsed.pathOrId];
|
|
37952
|
-
if (!resource) throw notFoundError(target, stack, resources);
|
|
37953
|
+
if (!resource) throw notFoundError$1(target, stack, resources);
|
|
37953
37954
|
logicalId = parsed.pathOrId;
|
|
37954
37955
|
}
|
|
37955
|
-
if (!logicalId || !resource) throw notFoundError(target, stack, resources);
|
|
37956
|
+
if (!logicalId || !resource) throw notFoundError$1(target, stack, resources);
|
|
37956
37957
|
if (resource.Type === "AWS::Lambda::Function") throw new EcsTaskResolutionError(`Resource '${logicalId}' in ${stack.stackName} is a Lambda function, not an ECS task definition. Use \`cdkd local invoke\` for Lambda; \`cdkd local run-task\` is ECS only.`);
|
|
37957
37958
|
if (resource.Type !== "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`Resource '${logicalId}' in ${stack.stackName} is ${resource.Type}, not an AWS::ECS::TaskDefinition.`);
|
|
37958
37959
|
return extractTaskDefinitionProperties(stack, logicalId, resource, context);
|
|
37959
37960
|
}
|
|
37960
|
-
function pickStack(parsed, stacks) {
|
|
37961
|
+
function pickStack$1(parsed, stacks) {
|
|
37961
37962
|
if (parsed.stackPattern === null) {
|
|
37962
37963
|
if (stacks.length === 1) return stacks[0];
|
|
37963
37964
|
throw new EcsTaskResolutionError(`Multiple stacks in app, target '${parsed.pathOrId}' is missing a stack prefix. Use 'StackName:${parsed.pathOrId}' or 'StackName/...' (path form). Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
|
|
@@ -38480,7 +38481,7 @@ function pickStringArray$1(value) {
|
|
|
38480
38481
|
for (const v of value) if (typeof v === "string") out.push(v);
|
|
38481
38482
|
return out;
|
|
38482
38483
|
}
|
|
38483
|
-
function notFoundError(target, stack, resources) {
|
|
38484
|
+
function notFoundError$1(target, stack, resources) {
|
|
38484
38485
|
const tasks = [];
|
|
38485
38486
|
for (const [logicalId, resource] of Object.entries(resources)) {
|
|
38486
38487
|
if (resource.Type !== "AWS::ECS::TaskDefinition") continue;
|
|
@@ -39464,13 +39465,16 @@ async function invokeRie(host, port, event, timeoutMs) {
|
|
|
39464
39465
|
* something we should retry. Abort errors propagate immediately so the
|
|
39465
39466
|
* outer timeout still wins.
|
|
39466
39467
|
*/
|
|
39467
|
-
async function fetchWithStartupRetry(url, body, signal) {
|
|
39468
|
+
async function fetchWithStartupRetry(url, body, signal, extraHeaders) {
|
|
39468
39469
|
const maxAttempts = 3;
|
|
39469
39470
|
let lastError;
|
|
39470
39471
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
|
|
39471
39472
|
return await fetch(url, {
|
|
39472
39473
|
method: "POST",
|
|
39473
|
-
headers: {
|
|
39474
|
+
headers: {
|
|
39475
|
+
"Content-Type": "application/json",
|
|
39476
|
+
...extraHeaders
|
|
39477
|
+
},
|
|
39474
39478
|
body,
|
|
39475
39479
|
signal
|
|
39476
39480
|
});
|
|
@@ -39482,6 +39486,157 @@ async function fetchWithStartupRetry(url, body, signal) {
|
|
|
39482
39486
|
}
|
|
39483
39487
|
throw lastError;
|
|
39484
39488
|
}
|
|
39489
|
+
/**
|
|
39490
|
+
* The 8-NULL-byte separator AWS Lambda RIE writes between the JSON
|
|
39491
|
+
* metadata prelude and the streaming body chunks. Empirically verified
|
|
39492
|
+
* — see `StreamingPrelude` docstring above.
|
|
39493
|
+
*/
|
|
39494
|
+
const STREAM_PRELUDE_SEPARATOR = Buffer.from([
|
|
39495
|
+
0,
|
|
39496
|
+
0,
|
|
39497
|
+
0,
|
|
39498
|
+
0,
|
|
39499
|
+
0,
|
|
39500
|
+
0,
|
|
39501
|
+
0,
|
|
39502
|
+
0
|
|
39503
|
+
]);
|
|
39504
|
+
/**
|
|
39505
|
+
* Maximum bytes we'll buffer searching for the prelude separator before
|
|
39506
|
+
* giving up. 1 MiB is far past anything Lambda's streamifyResponse would
|
|
39507
|
+
* emit as metadata (typical preludes are <500 bytes) — a runaway here
|
|
39508
|
+
* indicates the handler didn't call `HttpResponseStream.from` at all, in
|
|
39509
|
+
* which case we want to fail fast rather than buffer the whole body.
|
|
39510
|
+
*/
|
|
39511
|
+
const STREAM_PRELUDE_MAX_BYTES = 1024 * 1024;
|
|
39512
|
+
/**
|
|
39513
|
+
* POST the event payload to RIE with the `streaming` response-mode
|
|
39514
|
+
* header, parse the JSON prelude out of the response bytes, and return
|
|
39515
|
+
* a Readable carrying the post-separator body chunks.
|
|
39516
|
+
*
|
|
39517
|
+
* Why a separate function from `invokeRie`: the prelude/separator/body
|
|
39518
|
+
* framing is incompatible with the buffered-response `text()` consumer.
|
|
39519
|
+
* Buffered routes still use `invokeRie`; only Function URLs with
|
|
39520
|
+
* `InvokeMode: RESPONSE_STREAM` use this path.
|
|
39521
|
+
*
|
|
39522
|
+
* The `Lambda-Runtime-Function-Response-Mode: streaming` request header
|
|
39523
|
+
* tells RIE we want the streaming protocol. (RIE happens to emit the
|
|
39524
|
+
* same protocol for `streamifyResponse`-wrapped handlers regardless of
|
|
39525
|
+
* the header, but setting it makes the contract explicit and survives
|
|
39526
|
+
* future RIE behavior changes.)
|
|
39527
|
+
*
|
|
39528
|
+
* `timeoutMs` bounds the total wall time including the prelude wait —
|
|
39529
|
+
* the handler can stream for the full Lambda timeout, so callers should
|
|
39530
|
+
* pass a generous value (typically the function's configured Timeout * 2
|
|
39531
|
+
* with a 30s floor, matching `invokeRie`'s convention).
|
|
39532
|
+
*/
|
|
39533
|
+
async function invokeRieStreaming(host, port, event, timeoutMs) {
|
|
39534
|
+
const url = `http://${host}:${port}${INVOKE_PATH}`;
|
|
39535
|
+
const body = JSON.stringify(event ?? {});
|
|
39536
|
+
const controller = new AbortController();
|
|
39537
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
39538
|
+
let response;
|
|
39539
|
+
try {
|
|
39540
|
+
response = await fetchWithStartupRetry(url, body, controller.signal, { "Lambda-Runtime-Function-Response-Mode": "streaming" });
|
|
39541
|
+
} catch (err) {
|
|
39542
|
+
clearTimeout(timer);
|
|
39543
|
+
if (err.name === "AbortError") throw new Error(`RIE streaming invoke at ${url} timed out after ${timeoutMs}ms. The handler may be hung; check container logs.`);
|
|
39544
|
+
throw err;
|
|
39545
|
+
}
|
|
39546
|
+
if (!response.body) {
|
|
39547
|
+
clearTimeout(timer);
|
|
39548
|
+
throw new Error(`RIE streaming invoke at ${url} returned no response body.`);
|
|
39549
|
+
}
|
|
39550
|
+
const reader = response.body.getReader();
|
|
39551
|
+
let preludeBytes = Buffer.alloc(0);
|
|
39552
|
+
let bodyTail;
|
|
39553
|
+
let separatorIdx = -1;
|
|
39554
|
+
while (separatorIdx < 0) {
|
|
39555
|
+
const { value, done } = await reader.read();
|
|
39556
|
+
if (done) break;
|
|
39557
|
+
const chunk = Buffer.from(value);
|
|
39558
|
+
preludeBytes = Buffer.concat([preludeBytes, chunk]);
|
|
39559
|
+
separatorIdx = preludeBytes.indexOf(STREAM_PRELUDE_SEPARATOR);
|
|
39560
|
+
if (separatorIdx >= 0) {
|
|
39561
|
+
bodyTail = preludeBytes.subarray(separatorIdx + STREAM_PRELUDE_SEPARATOR.length);
|
|
39562
|
+
preludeBytes = preludeBytes.subarray(0, separatorIdx);
|
|
39563
|
+
break;
|
|
39564
|
+
}
|
|
39565
|
+
if (preludeBytes.length > STREAM_PRELUDE_MAX_BYTES) {
|
|
39566
|
+
clearTimeout(timer);
|
|
39567
|
+
reader.cancel().catch(() => void 0);
|
|
39568
|
+
throw new Error(`RIE streaming response did not emit the prelude/body separator within ${STREAM_PRELUDE_MAX_BYTES} bytes. The handler likely did not call awslambda.HttpResponseStream.from(stream, metadata).`);
|
|
39569
|
+
}
|
|
39570
|
+
}
|
|
39571
|
+
if (separatorIdx < 0) {
|
|
39572
|
+
clearTimeout(timer);
|
|
39573
|
+
throw new Error(`RIE streaming response ended before the prelude/body separator (got ${preludeBytes.length} bytes). The handler likely threw before streaming the prelude — check container logs.`);
|
|
39574
|
+
}
|
|
39575
|
+
let prelude;
|
|
39576
|
+
try {
|
|
39577
|
+
prelude = parseStreamingPrelude(preludeBytes.toString("utf8"));
|
|
39578
|
+
} catch (err) {
|
|
39579
|
+
clearTimeout(timer);
|
|
39580
|
+
reader.cancel().catch(() => void 0);
|
|
39581
|
+
throw new Error(`RIE streaming response prelude is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
39582
|
+
}
|
|
39583
|
+
const stream = new Readable({ read() {} });
|
|
39584
|
+
(async () => {
|
|
39585
|
+
try {
|
|
39586
|
+
if (bodyTail && bodyTail.length > 0) stream.push(bodyTail);
|
|
39587
|
+
while (true) {
|
|
39588
|
+
const { value, done } = await reader.read();
|
|
39589
|
+
if (done) break;
|
|
39590
|
+
stream.push(Buffer.from(value));
|
|
39591
|
+
}
|
|
39592
|
+
stream.push(null);
|
|
39593
|
+
} catch (err) {
|
|
39594
|
+
stream.destroy(err instanceof Error ? err : new Error(String(err)));
|
|
39595
|
+
} finally {
|
|
39596
|
+
clearTimeout(timer);
|
|
39597
|
+
}
|
|
39598
|
+
})();
|
|
39599
|
+
return {
|
|
39600
|
+
prelude,
|
|
39601
|
+
body: stream
|
|
39602
|
+
};
|
|
39603
|
+
}
|
|
39604
|
+
/**
|
|
39605
|
+
* Parse a streaming prelude payload (JSON text). Normalizes the shape
|
|
39606
|
+
* the http-server consumes: `statusCode` is coerced to a number (RIE
|
|
39607
|
+
* sometimes emits it as a string), `headers` is always an object (the
|
|
39608
|
+
* handler may omit it), `cookies` is preserved only when an array.
|
|
39609
|
+
*
|
|
39610
|
+
* Exported for unit tests. Throws on invalid JSON or a non-numeric
|
|
39611
|
+
* statusCode (cdkd cannot map that to HTTP).
|
|
39612
|
+
*/
|
|
39613
|
+
function parseStreamingPrelude(text) {
|
|
39614
|
+
const trimmed = text.trim();
|
|
39615
|
+
if (trimmed.length === 0) throw new Error("empty prelude");
|
|
39616
|
+
const raw = JSON.parse(trimmed);
|
|
39617
|
+
if (!raw || typeof raw !== "object") throw new Error("prelude is not a JSON object");
|
|
39618
|
+
const obj = raw;
|
|
39619
|
+
const statusRaw = obj["statusCode"];
|
|
39620
|
+
let statusCode;
|
|
39621
|
+
if (typeof statusRaw === "number" && Number.isFinite(statusRaw)) statusCode = Math.trunc(statusRaw);
|
|
39622
|
+
else if (typeof statusRaw === "string" && /^[0-9]+$/.test(statusRaw)) statusCode = Number.parseInt(statusRaw, 10);
|
|
39623
|
+
else throw new Error(`statusCode must be a number (got ${typeof statusRaw})`);
|
|
39624
|
+
const headers = {};
|
|
39625
|
+
const headersRaw = obj["headers"];
|
|
39626
|
+
if (headersRaw && typeof headersRaw === "object") for (const [k, v] of Object.entries(headersRaw)) {
|
|
39627
|
+
if (v === null || v === void 0) continue;
|
|
39628
|
+
if (typeof v === "string") headers[k] = v;
|
|
39629
|
+
else if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") headers[k] = String(v);
|
|
39630
|
+
else headers[k] = JSON.stringify(v) ?? "";
|
|
39631
|
+
}
|
|
39632
|
+
const result = {
|
|
39633
|
+
statusCode,
|
|
39634
|
+
headers
|
|
39635
|
+
};
|
|
39636
|
+
const cookiesRaw = obj["cookies"];
|
|
39637
|
+
if (Array.isArray(cookiesRaw)) result.cookies = cookiesRaw.map((c) => String(c));
|
|
39638
|
+
return result;
|
|
39639
|
+
}
|
|
39485
39640
|
|
|
39486
39641
|
//#endregion
|
|
39487
39642
|
//#region src/assets/asset-manifest-loader.ts
|
|
@@ -40538,12 +40693,15 @@ function classifyServiceIntegrationRoute(baseRoute, integrationProps, stackName,
|
|
|
40538
40693
|
* `AWS::Lambda::Url` resource.
|
|
40539
40694
|
*
|
|
40540
40695
|
* Per-shape classification:
|
|
40541
|
-
* - `AuthType === 'NONE'` + `InvokeMode
|
|
40696
|
+
* - `AuthType === 'NONE'` + `InvokeMode === 'BUFFERED'` (or unset) → normal route.
|
|
40697
|
+
* - `AuthType === 'NONE'` + `InvokeMode === 'RESPONSE_STREAM'` → normal route
|
|
40698
|
+
* dispatched via the RIE streaming protocol (the response body is a
|
|
40699
|
+
* JSON prelude — `{statusCode, headers, cookies?}` — followed by 8
|
|
40700
|
+
* NULL bytes and then the raw body chunks). The HTTP server pipes
|
|
40701
|
+
* the chunks to the client with `Transfer-Encoding: chunked` (#467).
|
|
40542
40702
|
* - `AuthType !== 'NONE'` (e.g. `AWS_IAM`) → deferred-error
|
|
40543
40703
|
* unsupported. Boot proceeds; HTTP 501 + `reason` at request time.
|
|
40544
40704
|
* IAM auth would need SigV4 verification cdkd cannot emulate.
|
|
40545
|
-
* - `InvokeMode === 'RESPONSE_STREAM'` → deferred-error unsupported.
|
|
40546
|
-
* The RIE container does not implement `InvokeWithResponseStream`.
|
|
40547
40705
|
*
|
|
40548
40706
|
* The Lambda Arn intrinsic resolution still **hard-errors** when it
|
|
40549
40707
|
* cannot pin down a same-template Lambda — Function URLs have no other
|
|
@@ -40573,14 +40731,18 @@ function discoverFunctionUrl(logicalId, resource, template, stackName) {
|
|
|
40573
40731
|
lambdaLogicalId,
|
|
40574
40732
|
unsupported: { reason: `${stackName}/${logicalId}: AuthType '${String(authType)}' is not supported (only NONE — IAM auth requires SigV4 verification cdkd cannot emulate locally).` }
|
|
40575
40733
|
}];
|
|
40576
|
-
|
|
40734
|
+
const invokeModeRaw = props["InvokeMode"];
|
|
40735
|
+
let invokeMode = "BUFFERED";
|
|
40736
|
+
if (invokeModeRaw === "RESPONSE_STREAM") invokeMode = "RESPONSE_STREAM";
|
|
40737
|
+
else if (invokeModeRaw !== void 0 && invokeModeRaw !== "BUFFERED") return [{
|
|
40577
40738
|
...baseRoute,
|
|
40578
40739
|
lambdaLogicalId,
|
|
40579
|
-
unsupported: { reason: `${stackName}/${logicalId}: InvokeMode
|
|
40740
|
+
unsupported: { reason: `${stackName}/${logicalId}: InvokeMode ${shortJson$1(invokeModeRaw)} is not a recognized value (expected 'BUFFERED' or 'RESPONSE_STREAM').` }
|
|
40580
40741
|
}];
|
|
40581
40742
|
return [{
|
|
40582
40743
|
...baseRoute,
|
|
40583
|
-
lambdaLogicalId
|
|
40744
|
+
lambdaLogicalId,
|
|
40745
|
+
invokeMode
|
|
40584
40746
|
}];
|
|
40585
40747
|
}
|
|
40586
40748
|
/**
|
|
@@ -43717,6 +43879,26 @@ async function handleRequest(req, res, state, opts) {
|
|
|
43717
43879
|
writeError(res, 502);
|
|
43718
43880
|
return;
|
|
43719
43881
|
}
|
|
43882
|
+
if (match.route.invokeMode === "RESPONSE_STREAM") {
|
|
43883
|
+
let streamResult;
|
|
43884
|
+
try {
|
|
43885
|
+
streamResult = await invokeRieStreaming(handle.containerHost, handle.hostPort, baseEvent, opts.rieTimeoutMs);
|
|
43886
|
+
try {
|
|
43887
|
+
writeStreamingResponse(res, streamResult, () => state.pool.release(handle));
|
|
43888
|
+
} catch (writeErr) {
|
|
43889
|
+
streamResult.body.on("error", () => {});
|
|
43890
|
+
streamResult.body.destroy(writeErr instanceof Error ? writeErr : new Error(String(writeErr)));
|
|
43891
|
+
throw writeErr;
|
|
43892
|
+
}
|
|
43893
|
+
return;
|
|
43894
|
+
} catch (err) {
|
|
43895
|
+
logger.error(`RIE streaming invoke failed for ${match.route.lambdaLogicalId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
43896
|
+
if (!res.headersSent) writeError(res, 502);
|
|
43897
|
+
else res.end();
|
|
43898
|
+
state.pool.release(handle);
|
|
43899
|
+
return;
|
|
43900
|
+
}
|
|
43901
|
+
}
|
|
43720
43902
|
try {
|
|
43721
43903
|
const translated = translateLambdaResponse((await invokeRie(handle.containerHost, handle.hostPort, baseEvent, opts.rieTimeoutMs)).payload, match.route.apiVersion);
|
|
43722
43904
|
res.statusCode = translated.statusCode;
|
|
@@ -43732,6 +43914,42 @@ async function handleRequest(req, res, state, opts) {
|
|
|
43732
43914
|
}
|
|
43733
43915
|
}
|
|
43734
43916
|
/**
|
|
43917
|
+
* Pipe a streaming RIE response into an `http.ServerResponse`. The
|
|
43918
|
+
* prelude's status + headers are written via `res.writeHead(...)`; the
|
|
43919
|
+
* body Readable is `pipe`'d through to the response so Node's
|
|
43920
|
+
* `Transfer-Encoding: chunked` machinery handles backpressure +
|
|
43921
|
+
* chunked framing automatically.
|
|
43922
|
+
*
|
|
43923
|
+
* `releasePool` runs in a `finally`-equivalent path so the warm
|
|
43924
|
+
* container is returned to the pool whether the stream ends cleanly
|
|
43925
|
+
* (`'end'` event) or errors mid-body (`'error'` event). Errors after
|
|
43926
|
+
* the prelude has been written can no longer be reported as HTTP
|
|
43927
|
+
* status — the stream is destroyed and the connection aborts.
|
|
43928
|
+
*
|
|
43929
|
+
* Cookies in the prelude are emitted as multiple `Set-Cookie` headers
|
|
43930
|
+
* (HTTP API v2 semantics — matching the buffered path's behavior).
|
|
43931
|
+
*/
|
|
43932
|
+
function writeStreamingResponse(res, result, releasePool) {
|
|
43933
|
+
const logger = getLogger().child("start-api");
|
|
43934
|
+
const { prelude, body } = result;
|
|
43935
|
+
const headersOut = { ...prelude.headers };
|
|
43936
|
+
if (prelude.cookies && prelude.cookies.length > 0) headersOut["set-cookie"] = prelude.cookies;
|
|
43937
|
+
res.writeHead(prelude.statusCode, headersOut);
|
|
43938
|
+
let released = false;
|
|
43939
|
+
const releaseOnce = () => {
|
|
43940
|
+
if (released) return;
|
|
43941
|
+
released = true;
|
|
43942
|
+
releasePool();
|
|
43943
|
+
};
|
|
43944
|
+
body.on("error", (err) => {
|
|
43945
|
+
logger.error(`Streaming Lambda response body errored mid-stream: ${err instanceof Error ? err.message : String(err)}`);
|
|
43946
|
+
res.destroy(err);
|
|
43947
|
+
releaseOnce();
|
|
43948
|
+
});
|
|
43949
|
+
res.on("close", releaseOnce);
|
|
43950
|
+
body.pipe(res);
|
|
43951
|
+
}
|
|
43952
|
+
/**
|
|
43735
43953
|
* Attempt CORS preflight interception. Returns `true` when the
|
|
43736
43954
|
* preflight response was written (caller must NOT continue to route
|
|
43737
43955
|
* dispatch); `false` when no preflight match (caller falls through to
|
|
@@ -44857,7 +45075,7 @@ async function localStartApiCommand(target, options) {
|
|
|
44857
45075
|
await ensureDockerAvailable();
|
|
44858
45076
|
const appCmd = resolveApp(options.app);
|
|
44859
45077
|
if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
|
|
44860
|
-
const overrides = readEnvOverridesFile$
|
|
45078
|
+
const overrides = readEnvOverridesFile$3(options.envVars);
|
|
44861
45079
|
const debugPortBase = options.debugPortBase ? parseDebugPort(options.debugPortBase) : void 0;
|
|
44862
45080
|
const perLambdaConcurrency = parsePerLambdaConcurrency(options.perLambdaConcurrency);
|
|
44863
45081
|
const inlineTmpDirs = /* @__PURE__ */ new Set();
|
|
@@ -45630,7 +45848,7 @@ function getTemplateEnv$1(resource) {
|
|
|
45630
45848
|
return vars;
|
|
45631
45849
|
}
|
|
45632
45850
|
/** Read the SAM-shape `--env-vars` JSON file. */
|
|
45633
|
-
function readEnvOverridesFile$
|
|
45851
|
+
function readEnvOverridesFile$3(filePath) {
|
|
45634
45852
|
if (!filePath) return void 0;
|
|
45635
45853
|
let raw;
|
|
45636
45854
|
try {
|
|
@@ -45979,14 +46197,38 @@ const execFileAsync$1 = promisify(execFile);
|
|
|
45979
46197
|
/** AWS-published sidecar image (latest tag). amd64 is the only image AWS ships. */
|
|
45980
46198
|
const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:latest-amd64";
|
|
45981
46199
|
/**
|
|
45982
|
-
*
|
|
45983
|
-
* the documented AWS task-metadata endpoint address. Containers
|
|
45984
|
-
* `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<
|
|
45985
|
-
* to reach it.
|
|
46200
|
+
* Default well-known IP for the ECS local-container-endpoints sidecar —
|
|
46201
|
+
* matches the documented AWS task-metadata endpoint address. Containers
|
|
46202
|
+
* inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
|
|
46203
|
+
* to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
|
|
46204
|
+
* start-service` allocates a per-replica subnet (via `subnetOctet`) so
|
|
46205
|
+
* concurrent replicas don't collide on a single docker network range.
|
|
45986
46206
|
*/
|
|
45987
46207
|
const METADATA_ENDPOINT_IP = "169.254.170.2";
|
|
45988
|
-
/**
|
|
45989
|
-
const
|
|
46208
|
+
/** Default subnet — used when no `subnetOctet` override is supplied. */
|
|
46209
|
+
const DEFAULT_METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
|
|
46210
|
+
/**
|
|
46211
|
+
* Pure-functional subnet allocator. `cdkd local run-task` uses the
|
|
46212
|
+
* default subnet; `cdkd local start-service` walks `subnetOctet=170,
|
|
46213
|
+
* 171, 172, ...` (one per replica) to keep parallel docker networks
|
|
46214
|
+
* from clashing. The link-local 169.254.0.0/16 space is reserved AWS-
|
|
46215
|
+
* wide for cloud metadata so collisions with user workloads are
|
|
46216
|
+
* unlikely, but each replica still gets its own /24 to ensure
|
|
46217
|
+
* docker's `--subnet` allocator does not reject "Pool overlaps".
|
|
46218
|
+
*
|
|
46219
|
+
* `subnetOctet` is the second-from-last byte of the network: 170 →
|
|
46220
|
+
* 169.254.170.0/24 (default), 171 → 169.254.171.0/24, etc. Valid
|
|
46221
|
+
* range is 1..254; the runner clamps to `(170 + replicaIndex) % 84`
|
|
46222
|
+
* + 170 in practice (rolling window) — exported here so the runner
|
|
46223
|
+
* keeps the allocation logic in one place.
|
|
46224
|
+
*/
|
|
46225
|
+
function buildEndpointSubnet(subnetOctet) {
|
|
46226
|
+
if (subnetOctet < 1 || subnetOctet > 254 || !Number.isInteger(subnetOctet)) throw new Error(`buildEndpointSubnet: subnetOctet must be an integer in 1..254 (got ${subnetOctet}).`);
|
|
46227
|
+
return {
|
|
46228
|
+
cidr: `169.254.${subnetOctet}.0/24`,
|
|
46229
|
+
sidecarIp: `169.254.${subnetOctet}.2`
|
|
46230
|
+
};
|
|
46231
|
+
}
|
|
45990
46232
|
/**
|
|
45991
46233
|
* Create the per-task docker network + start the metadata-endpoints
|
|
45992
46234
|
* sidecar. The sidecar must come up at the well-known address BEFORE any
|
|
@@ -45996,8 +46238,13 @@ const METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
|
|
|
45996
46238
|
async function createTaskNetwork(options = {}) {
|
|
45997
46239
|
const logger = getLogger().child("ecs-network");
|
|
45998
46240
|
const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
|
|
46241
|
+
const subnetOctet = options.subnetOctet ?? 170;
|
|
46242
|
+
const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
|
|
46243
|
+
cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
|
|
46244
|
+
sidecarIp: METADATA_ENDPOINT_IP
|
|
46245
|
+
} : buildEndpointSubnet(subnetOctet);
|
|
45999
46246
|
await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
|
|
46000
|
-
logger.info(`Creating docker network ${networkName} (subnet ${
|
|
46247
|
+
logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
|
|
46001
46248
|
try {
|
|
46002
46249
|
await execFileAsync$1(getDockerCmd(), [
|
|
46003
46250
|
"network",
|
|
@@ -46005,12 +46252,12 @@ async function createTaskNetwork(options = {}) {
|
|
|
46005
46252
|
"--driver",
|
|
46006
46253
|
"bridge",
|
|
46007
46254
|
"--subnet",
|
|
46008
|
-
|
|
46255
|
+
cidr,
|
|
46009
46256
|
networkName
|
|
46010
46257
|
]);
|
|
46011
46258
|
} catch (err) {
|
|
46012
46259
|
const e = err;
|
|
46013
|
-
throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host may already own subnet ${
|
|
46260
|
+
throw new DockerRunnerError(`docker network create failed: ${e.stderr?.trim() || e.message || String(err)}. Hint: another cdkd run-task on the same host 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\` walks subnetOctet per replica to avoid this; bare \`cdkd local run-task\` uses the default subnet so only one run can be active at a time.`);
|
|
46014
46261
|
}
|
|
46015
46262
|
const sidecarArgs = [
|
|
46016
46263
|
"run",
|
|
@@ -46021,7 +46268,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
46021
46268
|
"--network",
|
|
46022
46269
|
networkName,
|
|
46023
46270
|
"--ip",
|
|
46024
|
-
|
|
46271
|
+
sidecarIp
|
|
46025
46272
|
];
|
|
46026
46273
|
const sidecarEnv = {};
|
|
46027
46274
|
if (options.credentials) {
|
|
@@ -46032,7 +46279,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
46032
46279
|
if (options.cluster) sidecarEnv["CLUSTER"] = options.cluster;
|
|
46033
46280
|
for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
|
|
46034
46281
|
sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
|
|
46035
|
-
logger.info(
|
|
46282
|
+
logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
|
|
46036
46283
|
let sidecarContainerId;
|
|
46037
46284
|
try {
|
|
46038
46285
|
const { stdout } = await execFileAsync$1(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
|
|
@@ -46044,7 +46291,8 @@ async function createTaskNetwork(options = {}) {
|
|
|
46044
46291
|
}
|
|
46045
46292
|
return {
|
|
46046
46293
|
networkName,
|
|
46047
|
-
sidecarContainerId
|
|
46294
|
+
sidecarContainerId,
|
|
46295
|
+
sidecarIp
|
|
46048
46296
|
};
|
|
46049
46297
|
}
|
|
46050
46298
|
/**
|
|
@@ -46059,9 +46307,10 @@ async function createTaskNetwork(options = {}) {
|
|
|
46059
46307
|
* to whichever credentials AWS SDK chains find).
|
|
46060
46308
|
*/
|
|
46061
46309
|
function buildMetadataEnv(opts) {
|
|
46310
|
+
const ip = opts.sidecarIp ?? "169.254.170.2";
|
|
46062
46311
|
const env = {
|
|
46063
|
-
ECS_CONTAINER_METADATA_URI_V4: `http://${
|
|
46064
|
-
ECS_CONTAINER_METADATA_URI: `http://${
|
|
46312
|
+
ECS_CONTAINER_METADATA_URI_V4: `http://${ip}/v4/${opts.containerName}`,
|
|
46313
|
+
ECS_CONTAINER_METADATA_URI: `http://${ip}/v3/${opts.containerName}`
|
|
46065
46314
|
};
|
|
46066
46315
|
if (opts.roleArn) env["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] = `/role/${encodeURIComponent(opts.roleArn)}`;
|
|
46067
46316
|
if (opts.region) env["AWS_REGION"] = opts.region;
|
|
@@ -46310,6 +46559,7 @@ async function runEcsTask(task, options, state) {
|
|
|
46310
46559
|
};
|
|
46311
46560
|
if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
|
|
46312
46561
|
if (options.cluster) netCreateOpts.cluster = options.cluster;
|
|
46562
|
+
if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
|
|
46313
46563
|
state.network = await createTaskNetwork(netCreateOpts);
|
|
46314
46564
|
const volumeByName = await realizeDockerVolumes(task.volumes, state);
|
|
46315
46565
|
const dockerCmds = /* @__PURE__ */ new Map();
|
|
@@ -46327,7 +46577,8 @@ async function runEcsTask(task, options, state) {
|
|
|
46327
46577
|
containerHost: options.containerHost,
|
|
46328
46578
|
roleArn: options.taskRoleArn,
|
|
46329
46579
|
platformOverride: options.platformOverride,
|
|
46330
|
-
region: options.region
|
|
46580
|
+
region: options.region,
|
|
46581
|
+
sidecarIp: state.network.sidecarIp
|
|
46331
46582
|
}));
|
|
46332
46583
|
}
|
|
46333
46584
|
const startedByName = /* @__PURE__ */ new Map();
|
|
@@ -46464,7 +46715,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
46464
46715
|
if (err instanceof EcsTaskRunnerError) throw err;
|
|
46465
46716
|
logger.debug(`docker inspect on '${displayName}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
46466
46717
|
}
|
|
46467
|
-
await sleep(1e3);
|
|
46718
|
+
await sleep$1(1e3);
|
|
46468
46719
|
}
|
|
46469
46720
|
throw new EcsTaskRunnerError(`Container '${displayName}' did not become healthy within 5 minutes.`);
|
|
46470
46721
|
}
|
|
@@ -46488,7 +46739,7 @@ async function stopContainer(containerId, graceSeconds) {
|
|
|
46488
46739
|
]);
|
|
46489
46740
|
} catch {}
|
|
46490
46741
|
}
|
|
46491
|
-
function sleep(ms) {
|
|
46742
|
+
function sleep$1(ms) {
|
|
46492
46743
|
return new Promise((res) => setTimeout(res, ms));
|
|
46493
46744
|
}
|
|
46494
46745
|
/**
|
|
@@ -46668,7 +46919,8 @@ function buildDockerRunArgs(opts) {
|
|
|
46668
46919
|
const metaEnv = buildMetadataEnv({
|
|
46669
46920
|
containerName: container.name,
|
|
46670
46921
|
...roleArn !== void 0 && { roleArn },
|
|
46671
|
-
...opts.region !== void 0 && { region: opts.region }
|
|
46922
|
+
...opts.region !== void 0 && { region: opts.region },
|
|
46923
|
+
...opts.sidecarIp !== void 0 && { sidecarIp: opts.sidecarIp }
|
|
46672
46924
|
});
|
|
46673
46925
|
Object.assign(finalEnv, metaEnv);
|
|
46674
46926
|
Object.assign(finalEnv, container.environment);
|
|
@@ -46769,7 +47021,7 @@ async function localRunTaskCommand(target, options) {
|
|
|
46769
47021
|
...Object.keys(context).length > 0 && { context }
|
|
46770
47022
|
};
|
|
46771
47023
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
46772
|
-
const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
|
|
47024
|
+
const imageContext = await buildEcsImageResolutionContext$1(target, stacks, options);
|
|
46773
47025
|
const task = resolveEcsTaskTarget(target, stacks, imageContext);
|
|
46774
47026
|
logger.info(`Target: ${task.stack.stackName}/${task.taskDefinitionLogicalId} (family=${task.family}, containers=${task.containers.length})`);
|
|
46775
47027
|
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === task.stack.stackName) ?? task.stack);
|
|
@@ -46811,13 +47063,13 @@ async function localRunTaskCommand(target, options) {
|
|
|
46811
47063
|
let resolvedRoleArn;
|
|
46812
47064
|
if (options.assumeTaskRole === true) {
|
|
46813
47065
|
if (!task.taskRoleArn) throw new Error("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Either the task definition does not set TaskRoleArn, or it points at a resource cdkd cannot resolve to an IAM Role at synth time. Pass the ARN explicitly: --assume-task-role <arn>");
|
|
46814
|
-
resolvedRoleArn = await resolvePlaceholderAccount(task.taskRoleArn, options.region);
|
|
46815
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
47066
|
+
resolvedRoleArn = await resolvePlaceholderAccount$1(task.taskRoleArn, options.region);
|
|
47067
|
+
assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
|
|
46816
47068
|
} else if (typeof options.assumeTaskRole === "string") {
|
|
46817
47069
|
resolvedRoleArn = options.assumeTaskRole;
|
|
46818
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
47070
|
+
assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
|
|
46819
47071
|
}
|
|
46820
|
-
const envOverrides = readEnvOverridesFile$
|
|
47072
|
+
const envOverrides = readEnvOverridesFile$2(options.envVars);
|
|
46821
47073
|
const runOpts = {
|
|
46822
47074
|
cluster: options.cluster,
|
|
46823
47075
|
containerHost: options.containerHost,
|
|
@@ -46852,7 +47104,7 @@ async function localRunTaskCommand(target, options) {
|
|
|
46852
47104
|
* Lazy: callers should only invoke this when the resolved ARN is actually
|
|
46853
47105
|
* going to be used (i.e. on the bare `--assume-task-role` path).
|
|
46854
47106
|
*/
|
|
46855
|
-
async function resolvePlaceholderAccount(arn, region) {
|
|
47107
|
+
async function resolvePlaceholderAccount$1(arn, region) {
|
|
46856
47108
|
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
46857
47109
|
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
46858
47110
|
const sts = new STSClient({ ...region && { region } });
|
|
@@ -46868,7 +47120,7 @@ async function resolvePlaceholderAccount(arn, region) {
|
|
|
46868
47120
|
* Assume `roleArn` and return temp credentials. Mirrors the same flow
|
|
46869
47121
|
* `cdkd local invoke --assume-role` uses.
|
|
46870
47122
|
*/
|
|
46871
|
-
async function assumeTaskRole(roleArn, region) {
|
|
47123
|
+
async function assumeTaskRole$1(roleArn, region) {
|
|
46872
47124
|
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
46873
47125
|
const sts = new STSClient({ ...region && { region } });
|
|
46874
47126
|
try {
|
|
@@ -46899,9 +47151,9 @@ async function assumeTaskRole(roleArn, region) {
|
|
|
46899
47151
|
* for the candidate stack — same warn-and-fall-back error policy as
|
|
46900
47152
|
* `cdkd local invoke --from-state`.
|
|
46901
47153
|
*/
|
|
46902
|
-
async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
47154
|
+
async function buildEcsImageResolutionContext$1(target, stacks, options) {
|
|
46903
47155
|
const logger = getLogger();
|
|
46904
|
-
const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
|
|
47156
|
+
const candidate = pickCandidateStack$1(parseEcsTarget(target).stackPattern, stacks);
|
|
46905
47157
|
if (!candidate) return void 0;
|
|
46906
47158
|
const needs = detectEcsImageResolutionNeeds(candidate);
|
|
46907
47159
|
if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
|
|
@@ -46912,7 +47164,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
|
46912
47164
|
if (!region) logger.warn("Resolver references ${AWS::Region} but cdkd could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.");
|
|
46913
47165
|
let accountId;
|
|
46914
47166
|
try {
|
|
46915
|
-
accountId = await resolveCallerAccountId(region);
|
|
47167
|
+
accountId = await resolveCallerAccountId$1(region);
|
|
46916
47168
|
} catch (err) {
|
|
46917
47169
|
logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env / secret entries will be dropped with per-key warnings.`);
|
|
46918
47170
|
}
|
|
@@ -46940,7 +47192,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
|
46940
47192
|
else if (!options.fromState && needs.needsEnvOrSecretSubstitution) logger.warn("Container Environment / Secrets entries contain CloudFormation intrinsics (Ref / Fn::GetAtt / Fn::Sub / Fn::Join). Pass --from-state to substitute them against the deployed cdkd state. Without --from-state these entries are dropped (per-key warnings will follow).");
|
|
46941
47193
|
return ctx;
|
|
46942
47194
|
}
|
|
46943
|
-
function pickCandidateStack(stackPattern, stacks) {
|
|
47195
|
+
function pickCandidateStack$1(stackPattern, stacks) {
|
|
46944
47196
|
if (stackPattern === null) {
|
|
46945
47197
|
if (stacks.length === 1) return stacks[0];
|
|
46946
47198
|
return;
|
|
@@ -46948,7 +47200,7 @@ function pickCandidateStack(stackPattern, stacks) {
|
|
|
46948
47200
|
const matched = matchStacks(stacks, [stackPattern]);
|
|
46949
47201
|
if (matched.length === 1) return matched[0];
|
|
46950
47202
|
}
|
|
46951
|
-
async function resolveCallerAccountId(region) {
|
|
47203
|
+
async function resolveCallerAccountId$1(region) {
|
|
46952
47204
|
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
46953
47205
|
const sts = new STSClient({ ...region && { region } });
|
|
46954
47206
|
try {
|
|
@@ -46962,7 +47214,7 @@ async function resolveCallerAccountId(region) {
|
|
|
46962
47214
|
* `cdkd local invoke --env-vars`: top-level keys are container names, with
|
|
46963
47215
|
* `Parameters` reserved for global entries.
|
|
46964
47216
|
*/
|
|
46965
|
-
function readEnvOverridesFile$
|
|
47217
|
+
function readEnvOverridesFile$2(filePath) {
|
|
46966
47218
|
if (!filePath) return void 0;
|
|
46967
47219
|
let raw;
|
|
46968
47220
|
try {
|
|
@@ -46991,6 +47243,690 @@ function createLocalRunTaskCommand() {
|
|
|
46991
47243
|
return cmd;
|
|
46992
47244
|
}
|
|
46993
47245
|
|
|
47246
|
+
//#endregion
|
|
47247
|
+
//#region src/local/ecs-service-resolver.ts
|
|
47248
|
+
/**
|
|
47249
|
+
* Walk the synth template to locate an `AWS::ECS::Service` by display
|
|
47250
|
+
* path or stack-qualified logical id, resolve its `TaskDefinition`
|
|
47251
|
+
* reference, and chain into the existing `resolveEcsTaskTarget` machinery
|
|
47252
|
+
* to produce a `ResolvedEcsService` carrying both the service knobs and
|
|
47253
|
+
* the underlying task descriptor.
|
|
47254
|
+
*
|
|
47255
|
+
* Target shape mirrors `cdkd local run-task`: `<Stack>/<DisplayPath>` or
|
|
47256
|
+
* `<Stack>:<LogicalId>`; single-stack apps may omit the stack prefix.
|
|
47257
|
+
*
|
|
47258
|
+
* Optional `context` (same as the task resolver) carries the ECR image
|
|
47259
|
+
* substitution data — pseudo parameters (Tier 1) + state-recorded
|
|
47260
|
+
* resources (Tier 2). The CLI builds it lazily when the candidate
|
|
47261
|
+
* service's task definition actually needs substitution.
|
|
47262
|
+
*/
|
|
47263
|
+
function resolveEcsServiceTarget(target, stacks, context) {
|
|
47264
|
+
if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
|
|
47265
|
+
const parsed = parseEcsTarget(target);
|
|
47266
|
+
const stack = pickStack(parsed, stacks);
|
|
47267
|
+
const resources = stack.template.Resources ?? {};
|
|
47268
|
+
let serviceLogicalId;
|
|
47269
|
+
let serviceResource;
|
|
47270
|
+
if (parsed.isPath) {
|
|
47271
|
+
const index = buildCdkPathIndex(stack.template);
|
|
47272
|
+
const services = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId: l }) => resources[l]?.Type === "AWS::ECS::Service");
|
|
47273
|
+
if (services.length === 0) throw notFoundError(target, stack, resources);
|
|
47274
|
+
if (services.length > 1) throw new EcsTaskResolutionError(`Target '${target}' matches ${services.length} ECS services in ${stack.stackName}: ` + services.map((s) => s.logicalId).join(", ") + ". Refine the path or use the stack:LogicalId form.");
|
|
47275
|
+
serviceLogicalId = services[0].logicalId;
|
|
47276
|
+
serviceResource = resources[serviceLogicalId];
|
|
47277
|
+
} else {
|
|
47278
|
+
serviceResource = resources[parsed.pathOrId];
|
|
47279
|
+
if (!serviceResource) throw notFoundError(target, stack, resources);
|
|
47280
|
+
serviceLogicalId = parsed.pathOrId;
|
|
47281
|
+
}
|
|
47282
|
+
if (!serviceLogicalId || !serviceResource) throw notFoundError(target, stack, resources);
|
|
47283
|
+
if (serviceResource.Type === "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`Resource '${serviceLogicalId}' in ${stack.stackName} is an ECS TaskDefinition, not a Service. Use \`cdkd local run-task\` for one-shot tasks; \`cdkd local start-service\` is Service-only.`);
|
|
47284
|
+
if (serviceResource.Type !== "AWS::ECS::Service") throw new EcsTaskResolutionError(`Resource '${serviceLogicalId}' in ${stack.stackName} is ${serviceResource.Type}, not an AWS::ECS::Service.`);
|
|
47285
|
+
return extractServiceProperties(stack, serviceLogicalId, serviceResource, stacks, context);
|
|
47286
|
+
}
|
|
47287
|
+
/**
|
|
47288
|
+
* Pure-functional extraction from the synth resource. Exposed for unit
|
|
47289
|
+
* testing the per-field resolution rules (DesiredCount default, missing
|
|
47290
|
+
* TaskDefinition, intrinsic shapes).
|
|
47291
|
+
*/
|
|
47292
|
+
function extractServiceProperties(stack, serviceLogicalId, resource, stacks, context) {
|
|
47293
|
+
const props = resource.Properties ?? {};
|
|
47294
|
+
const warnings = [];
|
|
47295
|
+
const taskDefRef = props["TaskDefinition"];
|
|
47296
|
+
if (taskDefRef === void 0 || taskDefRef === null) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' in ${stack.stackName} has no TaskDefinition property.`);
|
|
47297
|
+
const taskDefLogicalId = resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId);
|
|
47298
|
+
const task = resolveEcsTaskTarget(`${stack.stackName}:${taskDefLogicalId}`, stacks, context);
|
|
47299
|
+
const desiredCount = parseDesiredCount(props["DesiredCount"], serviceLogicalId);
|
|
47300
|
+
const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
|
|
47301
|
+
const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
|
|
47302
|
+
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.`);
|
|
47303
|
+
if (props["ServiceConnectConfiguration"]) warnings.push(`ECS Service '${serviceLogicalId}' declares ServiceConnectConfiguration, but Service Connect / Cloud Map emulation is deferred (tracked in #460). Cross-service discovery between locally-run services is not provided.`);
|
|
47304
|
+
return {
|
|
47305
|
+
stack,
|
|
47306
|
+
serviceLogicalId,
|
|
47307
|
+
resource,
|
|
47308
|
+
serviceName,
|
|
47309
|
+
desiredCount,
|
|
47310
|
+
healthCheckGracePeriodSeconds,
|
|
47311
|
+
task,
|
|
47312
|
+
warnings
|
|
47313
|
+
};
|
|
47314
|
+
}
|
|
47315
|
+
/**
|
|
47316
|
+
* Resolve `Properties.TaskDefinition` to a logical id in the same stack.
|
|
47317
|
+
* Accepted shapes — verified against real CDK 2.x `cdk synth` output on
|
|
47318
|
+
* 2026-05-22 (per `feedback_verify_cdk_synth_shape_before_resolver.md`):
|
|
47319
|
+
* - `{Ref: '<TaskDefLogicalId>'}` — the CDK-canonical shape emitted by
|
|
47320
|
+
* `new ecs.FargateService({ taskDefinition })`.
|
|
47321
|
+
* - flat string `'<TaskDefLogicalId>'` — accepted defensively but CDK
|
|
47322
|
+
* rarely emits this for cross-resource refs.
|
|
47323
|
+
* Other intrinsic shapes (`Fn::ImportValue` / `Fn::GetAtt` / etc.) are
|
|
47324
|
+
* rejected — cross-stack task definitions and `Fn::GetAtt` shapes have
|
|
47325
|
+
* no clean local resolution and would land here only as user errors.
|
|
47326
|
+
*/
|
|
47327
|
+
function resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId) {
|
|
47328
|
+
if (typeof taskDefRef === "string") return taskDefRef;
|
|
47329
|
+
if (taskDefRef && typeof taskDefRef === "object" && !Array.isArray(taskDefRef)) {
|
|
47330
|
+
const refValue = taskDefRef["Ref"];
|
|
47331
|
+
if (typeof refValue === "string") {
|
|
47332
|
+
const target = (stack.template.Resources ?? {})[refValue];
|
|
47333
|
+
if (!target) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' references TaskDefinition '${refValue}' but no such resource exists in ${stack.stackName}.`);
|
|
47334
|
+
if (target.Type !== "AWS::ECS::TaskDefinition") throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' references '${refValue}' as TaskDefinition but it is of type ${target.Type}, not AWS::ECS::TaskDefinition.`);
|
|
47335
|
+
return refValue;
|
|
47336
|
+
}
|
|
47337
|
+
}
|
|
47338
|
+
throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' has an unsupported TaskDefinition reference shape: ${JSON.stringify(taskDefRef)}. cdkd local start-service v1 supports only Ref to a same-stack AWS::ECS::TaskDefinition; cross-stack TaskDefinitions are deferred.`);
|
|
47339
|
+
}
|
|
47340
|
+
function parseDesiredCount(raw, serviceLogicalId) {
|
|
47341
|
+
if (raw === void 0 || raw === null) return 1;
|
|
47342
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
|
|
47343
|
+
if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
|
|
47344
|
+
throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' has an unsupported DesiredCount value: ${JSON.stringify(raw)}. Must be a non-negative integer.`);
|
|
47345
|
+
}
|
|
47346
|
+
function parseHealthCheckGrace(raw, _serviceLogicalId) {
|
|
47347
|
+
if (raw === void 0 || raw === null) return 30;
|
|
47348
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
|
|
47349
|
+
if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
|
|
47350
|
+
return 30;
|
|
47351
|
+
}
|
|
47352
|
+
function parseServiceName(raw, serviceLogicalId) {
|
|
47353
|
+
if (typeof raw === "string" && raw.length > 0) return raw;
|
|
47354
|
+
return serviceLogicalId;
|
|
47355
|
+
}
|
|
47356
|
+
/**
|
|
47357
|
+
* Local copy of the same `pickStack` helper used by the task resolver.
|
|
47358
|
+
* Kept in-file rather than exported from `ecs-task-resolver.ts` so future
|
|
47359
|
+
* service-specific extensions (e.g. cross-stack service-to-task refs)
|
|
47360
|
+
* can diverge without breaking the run-task code path.
|
|
47361
|
+
*/
|
|
47362
|
+
function pickStack(parsed, stacks) {
|
|
47363
|
+
if (parsed.stackPattern === null) {
|
|
47364
|
+
if (stacks.length === 1) return stacks[0];
|
|
47365
|
+
throw new EcsTaskResolutionError(`Target has no stack prefix, and the assembly contains ${stacks.length} stacks: ${stacks.map((s) => s.stackName).join(", ")}. Pass the target as 'Stack/Path' or 'Stack:LogicalId'.`);
|
|
47366
|
+
}
|
|
47367
|
+
const matched = matchStacks(stacks, [parsed.stackPattern]);
|
|
47368
|
+
if (matched.length === 0) throw new EcsTaskResolutionError(`No stack matches '${parsed.stackPattern}'. Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
|
|
47369
|
+
if (matched.length > 1) throw new EcsTaskResolutionError(`Multiple stacks match '${parsed.stackPattern}': ${matched.map((s) => s.stackName).join(", ")}. Refine the pattern.`);
|
|
47370
|
+
return matched[0];
|
|
47371
|
+
}
|
|
47372
|
+
function notFoundError(target, stack, resources) {
|
|
47373
|
+
const services = Object.entries(resources).filter(([, r]) => r.Type === "AWS::ECS::Service").map(([id]) => id);
|
|
47374
|
+
if (services.length === 0) return new EcsTaskResolutionError(`Target '${target}' did not match any resource in ${stack.stackName}, and the stack declares no AWS::ECS::Service resources at all.`);
|
|
47375
|
+
return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
|
|
47376
|
+
}
|
|
47377
|
+
|
|
47378
|
+
//#endregion
|
|
47379
|
+
//#region src/local/ecs-service-runner.ts
|
|
47380
|
+
/**
|
|
47381
|
+
* Phase 2 of #262 — long-running ECS Service emulator. Wraps the existing
|
|
47382
|
+
* `ecs-task-runner` machinery in a replica pool: N concurrent task
|
|
47383
|
+
* instances per `DesiredCount`, each with its own docker network +
|
|
47384
|
+
* metadata sidecar + container set. Tasks that exit non-zero AFTER the
|
|
47385
|
+
* health-check grace period are restarted with exponential backoff so a
|
|
47386
|
+
* crash-looping container does not hammer docker.
|
|
47387
|
+
*
|
|
47388
|
+
* v1 scope (per the issue's PR-split recommendation):
|
|
47389
|
+
* - Replica pool sizing via `DesiredCount` clamped by `--max-tasks`.
|
|
47390
|
+
* - Restart-on-exit with exponential backoff (1s → 30s, capped) +
|
|
47391
|
+
* a per-instance retry counter so a permanently-broken container
|
|
47392
|
+
* stops compounding cleanup work.
|
|
47393
|
+
* - Long-running lifecycle (returns only on shutdown).
|
|
47394
|
+
*
|
|
47395
|
+
* Deferred to follow-up PRs:
|
|
47396
|
+
* - Local load-balancer emulation (LB listener + target-group health
|
|
47397
|
+
* check + round-robin) — separate PR per the issue's PR-split.
|
|
47398
|
+
* - Service Connect / Cloud Map (tracked in #460).
|
|
47399
|
+
* - Rolling deployment (`--reload` / `--watch`).
|
|
47400
|
+
*/
|
|
47401
|
+
var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
|
|
47402
|
+
constructor(message) {
|
|
47403
|
+
super(message);
|
|
47404
|
+
this.name = "EcsServiceRunnerError";
|
|
47405
|
+
Object.setPrototypeOf(this, EcsServiceRunnerError.prototype);
|
|
47406
|
+
}
|
|
47407
|
+
};
|
|
47408
|
+
function createServiceRunState() {
|
|
47409
|
+
return {
|
|
47410
|
+
replicas: [],
|
|
47411
|
+
shuttingDown: false
|
|
47412
|
+
};
|
|
47413
|
+
}
|
|
47414
|
+
/**
|
|
47415
|
+
* Compute the effective replica count for a service: the smaller of
|
|
47416
|
+
* `service.desiredCount` and `--max-tasks`, floored at 1. Pure-
|
|
47417
|
+
* functional so the CLI can show the user what cdkd is about to do
|
|
47418
|
+
* before any docker calls fire.
|
|
47419
|
+
*/
|
|
47420
|
+
function computeReplicaCount(desiredCount, maxTasks) {
|
|
47421
|
+
if (maxTasks < 1) throw new EcsServiceRunnerError(`--max-tasks must be >= 1 (got ${maxTasks}); local dev needs at least one running replica.`);
|
|
47422
|
+
if (desiredCount <= 0) return 1;
|
|
47423
|
+
return Math.min(desiredCount, maxTasks);
|
|
47424
|
+
}
|
|
47425
|
+
/**
|
|
47426
|
+
* Exponential backoff schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... Used
|
|
47427
|
+
* between restarts of a crash-looping replica so docker is not hammered
|
|
47428
|
+
* by the watcher loop. Exposed for unit testing.
|
|
47429
|
+
*/
|
|
47430
|
+
function backoffDelayMs(restartCount) {
|
|
47431
|
+
return Math.min(1e3 * Math.pow(2, Math.min(restartCount, 10)), 3e4);
|
|
47432
|
+
}
|
|
47433
|
+
/**
|
|
47434
|
+
* Decide whether a replica that just exited should restart. Pure-
|
|
47435
|
+
* functional so the watcher loop's policy is easy to unit-test.
|
|
47436
|
+
*/
|
|
47437
|
+
function shouldRestart(exitCode, policy) {
|
|
47438
|
+
if (policy === "none") return false;
|
|
47439
|
+
if (policy === "always") return true;
|
|
47440
|
+
return exitCode !== 0;
|
|
47441
|
+
}
|
|
47442
|
+
/**
|
|
47443
|
+
* Long-running entry point. Boots `replicaCount` instances of the
|
|
47444
|
+
* service's task descriptor, returns a controller object the CLI uses
|
|
47445
|
+
* to (1) wait for the first failure that gives up restarting and (2)
|
|
47446
|
+
* shut every replica down on SIGINT / SIGTERM.
|
|
47447
|
+
*
|
|
47448
|
+
* The returned `shutdown()` is idempotent and safe to call from
|
|
47449
|
+
* multiple SIGINT handlers (CLI's single-flight pattern wraps it
|
|
47450
|
+
* anyway).
|
|
47451
|
+
*/
|
|
47452
|
+
async function startEcsService(service, options, runState) {
|
|
47453
|
+
const logger = getLogger().child("ecs-service");
|
|
47454
|
+
for (const w of service.warnings) logger.warn(w);
|
|
47455
|
+
const replicaCount = computeReplicaCount(service.desiredCount, options.maxTasks);
|
|
47456
|
+
if (replicaCount < service.desiredCount) logger.warn(`Service '${service.serviceName}' template DesiredCount=${service.desiredCount} exceeds --max-tasks=${options.maxTasks}; running ${replicaCount} replica(s) locally. Raise --max-tasks to lift the cap, or accept the reduced concurrency for local dev.`);
|
|
47457
|
+
logger.info(`Starting ECS service '${service.serviceName}' with ${replicaCount} replica(s) (restartPolicy=${options.restartPolicy})`);
|
|
47458
|
+
for (let i = 0; i < replicaCount; i++) {
|
|
47459
|
+
const instance = {
|
|
47460
|
+
index: i,
|
|
47461
|
+
state: createEcsRunState(),
|
|
47462
|
+
restartCount: 0,
|
|
47463
|
+
shuttingDown: false,
|
|
47464
|
+
inFlightBoot: void 0
|
|
47465
|
+
};
|
|
47466
|
+
runState.replicas.push(instance);
|
|
47467
|
+
const bootPromise = bootReplica(service, options, instance);
|
|
47468
|
+
instance.inFlightBoot = bootPromise;
|
|
47469
|
+
try {
|
|
47470
|
+
await bootPromise;
|
|
47471
|
+
} catch (err) {
|
|
47472
|
+
instance.lastError = err instanceof Error ? err : new Error(String(err));
|
|
47473
|
+
throw new EcsServiceRunnerError(`Failed to boot replica ${i} of service '${service.serviceName}': ${instance.lastError.message}`);
|
|
47474
|
+
} finally {
|
|
47475
|
+
instance.inFlightBoot = void 0;
|
|
47476
|
+
}
|
|
47477
|
+
}
|
|
47478
|
+
for (const instance of runState.replicas) watchReplica(service, options, instance, runState);
|
|
47479
|
+
return new ServiceController(service, runState, options);
|
|
47480
|
+
}
|
|
47481
|
+
/**
|
|
47482
|
+
* Public controller surface. The CLI awaits `controller.waitForShutdown()`
|
|
47483
|
+
* to block until the user ^Cs. `controller.shutdown()` is wired into the
|
|
47484
|
+
* SIGINT / SIGTERM handlers.
|
|
47485
|
+
*/
|
|
47486
|
+
var ServiceController = class {
|
|
47487
|
+
service;
|
|
47488
|
+
runState;
|
|
47489
|
+
options;
|
|
47490
|
+
shutdownResolve;
|
|
47491
|
+
shutdownPromise;
|
|
47492
|
+
/**
|
|
47493
|
+
* Single-flight wrapper for `shutdown()` so the fan-out cleanup runs
|
|
47494
|
+
* exactly once even when SIGINT and the CLI's outer `finally` both
|
|
47495
|
+
* fire (the canonical pattern documented in
|
|
47496
|
+
* `feedback_sigint_finally_cleanup_singleflight.md`). Built in the
|
|
47497
|
+
* constructor so every call to `shutdown()` resolves against the same
|
|
47498
|
+
* underlying promise.
|
|
47499
|
+
*/
|
|
47500
|
+
runShutdown;
|
|
47501
|
+
constructor(service, runState, options) {
|
|
47502
|
+
this.service = service;
|
|
47503
|
+
this.runState = runState;
|
|
47504
|
+
this.options = options;
|
|
47505
|
+
this.shutdownPromise = new Promise((resolve) => {
|
|
47506
|
+
this.shutdownResolve = resolve;
|
|
47507
|
+
});
|
|
47508
|
+
this.runShutdown = singleFlight(() => this.doShutdown());
|
|
47509
|
+
}
|
|
47510
|
+
/**
|
|
47511
|
+
* Returns the count of currently-active (non-shutting-down) replicas.
|
|
47512
|
+
* Exposed so the CLI can surface a one-line "service is degraded"
|
|
47513
|
+
* banner when restarts stop firing.
|
|
47514
|
+
*/
|
|
47515
|
+
activeReplicaCount() {
|
|
47516
|
+
return this.runState.replicas.filter((r) => !r.shuttingDown).length;
|
|
47517
|
+
}
|
|
47518
|
+
/**
|
|
47519
|
+
* Block until `shutdown()` is called. Used by the CLI as the
|
|
47520
|
+
* long-running blocking point — the SIGINT handler resolves it.
|
|
47521
|
+
*/
|
|
47522
|
+
waitForShutdown() {
|
|
47523
|
+
return this.shutdownPromise;
|
|
47524
|
+
}
|
|
47525
|
+
/**
|
|
47526
|
+
* Idempotent fan-out shutdown across every active replica. Wired into
|
|
47527
|
+
* both SIGINT and the outer `finally` of the CLI command; the
|
|
47528
|
+
* `singleFlight`-wrapped `runShutdown` collapses concurrent / repeated
|
|
47529
|
+
* callers to one underlying invocation.
|
|
47530
|
+
*/
|
|
47531
|
+
async shutdown() {
|
|
47532
|
+
await this.runShutdown();
|
|
47533
|
+
return this.shutdownPromise;
|
|
47534
|
+
}
|
|
47535
|
+
async doShutdown() {
|
|
47536
|
+
this.runState.shuttingDown = true;
|
|
47537
|
+
const logger = getLogger().child("ecs-service");
|
|
47538
|
+
logger.info(`Shutting down service '${this.service.serviceName}'...`);
|
|
47539
|
+
for (const r of this.runState.replicas) r.shuttingDown = true;
|
|
47540
|
+
const inFlightBoots = this.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0);
|
|
47541
|
+
if (inFlightBoots.length > 0) {
|
|
47542
|
+
logger.debug(`Awaiting ${inFlightBoots.length} in-flight bootReplica() call(s) before cleanup...`);
|
|
47543
|
+
await Promise.allSettled(inFlightBoots);
|
|
47544
|
+
}
|
|
47545
|
+
await Promise.allSettled(this.runState.replicas.map(async (instance) => {
|
|
47546
|
+
try {
|
|
47547
|
+
await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
|
|
47548
|
+
} catch (err) {
|
|
47549
|
+
logger.debug(`Replica ${instance.index} cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
47550
|
+
}
|
|
47551
|
+
}));
|
|
47552
|
+
this.shutdownResolve?.();
|
|
47553
|
+
}
|
|
47554
|
+
};
|
|
47555
|
+
/**
|
|
47556
|
+
* Boot a single replica. Mutates the supplied `instance.state` so the
|
|
47557
|
+
* shutdown path's `cleanupEcsRun(instance.state)` covers every partial
|
|
47558
|
+
* side effect. Network names are suffixed with the replica index so
|
|
47559
|
+
* docker doesn't collide on shared per-task network names when N > 1.
|
|
47560
|
+
*/
|
|
47561
|
+
async function bootReplica(service, options, instance) {
|
|
47562
|
+
const logger = getLogger().child("ecs-service");
|
|
47563
|
+
const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
|
|
47564
|
+
const perReplicaSubnetOctet = 170 + instance.index % 84;
|
|
47565
|
+
const perReplicaTaskOptions = {
|
|
47566
|
+
...options.taskOptions,
|
|
47567
|
+
cluster: perReplicaCluster,
|
|
47568
|
+
subnetOctet: perReplicaSubnetOctet,
|
|
47569
|
+
detach: true
|
|
47570
|
+
};
|
|
47571
|
+
logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
|
|
47572
|
+
await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
|
|
47573
|
+
}
|
|
47574
|
+
/**
|
|
47575
|
+
* Long-running watcher loop for one replica. Polls the essential
|
|
47576
|
+
* container's exit code via `docker wait`; on exit, decides whether to
|
|
47577
|
+
* restart per `restartPolicy` + applies exponential backoff. The loop
|
|
47578
|
+
* exits only when the replica's `shuttingDown` flag is set.
|
|
47579
|
+
*/
|
|
47580
|
+
async function watchReplica(service, options, instance, runState) {
|
|
47581
|
+
const logger = getLogger().child("ecs-service");
|
|
47582
|
+
while (!instance.shuttingDown && !runState.shuttingDown) {
|
|
47583
|
+
const essentialId = pickEssentialContainerId(instance, service);
|
|
47584
|
+
if (!essentialId) {
|
|
47585
|
+
await sleep(500);
|
|
47586
|
+
continue;
|
|
47587
|
+
}
|
|
47588
|
+
let exitCode;
|
|
47589
|
+
try {
|
|
47590
|
+
exitCode = await waitForExitImpl(essentialId);
|
|
47591
|
+
} catch (err) {
|
|
47592
|
+
logger.debug(`docker wait failed for replica ${instance.index}: ${err instanceof Error ? err.message : String(err)}`);
|
|
47593
|
+
exitCode = -1;
|
|
47594
|
+
}
|
|
47595
|
+
if (instance.shuttingDown || runState.shuttingDown) return;
|
|
47596
|
+
logger.warn(`Replica ${instance.index} essential container exited with code ${exitCode} (restartCount=${instance.restartCount}).`);
|
|
47597
|
+
if (!shouldRestart(exitCode, options.restartPolicy)) {
|
|
47598
|
+
logger.warn(`Replica ${instance.index} not restarting (policy=${options.restartPolicy}, exit=${exitCode}). Service running in degraded mode.`);
|
|
47599
|
+
instance.shuttingDown = true;
|
|
47600
|
+
return;
|
|
47601
|
+
}
|
|
47602
|
+
const delay = backoffDelayMs(instance.restartCount);
|
|
47603
|
+
logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
|
|
47604
|
+
await sleep(delay);
|
|
47605
|
+
if (instance.shuttingDown || runState.shuttingDown) return;
|
|
47606
|
+
try {
|
|
47607
|
+
await cleanupEcsRun(instance.state, { keepRunning: false });
|
|
47608
|
+
} catch (err) {
|
|
47609
|
+
logger.debug(`Replica ${instance.index} pre-restart cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
47610
|
+
}
|
|
47611
|
+
instance.state = createEcsRunState();
|
|
47612
|
+
instance.restartCount += 1;
|
|
47613
|
+
const bootPromise = bootReplica(service, options, instance);
|
|
47614
|
+
instance.inFlightBoot = bootPromise;
|
|
47615
|
+
try {
|
|
47616
|
+
await bootPromise;
|
|
47617
|
+
} catch (err) {
|
|
47618
|
+
instance.lastError = err instanceof Error ? err : new Error(String(err));
|
|
47619
|
+
logger.error(`Replica ${instance.index} restart failed: ${instance.lastError.message}. Service running in degraded mode.`);
|
|
47620
|
+
instance.shuttingDown = true;
|
|
47621
|
+
return;
|
|
47622
|
+
} finally {
|
|
47623
|
+
instance.inFlightBoot = void 0;
|
|
47624
|
+
}
|
|
47625
|
+
}
|
|
47626
|
+
}
|
|
47627
|
+
function pickEssentialContainerId(instance, service) {
|
|
47628
|
+
if (service) {
|
|
47629
|
+
const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
|
|
47630
|
+
if (essential) {
|
|
47631
|
+
const started = instance.state.startedContainers.find((c) => c.name === essential.name);
|
|
47632
|
+
if (started) return started.id;
|
|
47633
|
+
}
|
|
47634
|
+
}
|
|
47635
|
+
return instance.state.startedContainers[0]?.id;
|
|
47636
|
+
}
|
|
47637
|
+
/**
|
|
47638
|
+
* Production `docker wait <id>` implementation. Captured once so the
|
|
47639
|
+
* test override can restore it without duplicating the body.
|
|
47640
|
+
*/
|
|
47641
|
+
const defaultWaitForExitImpl = async (containerId) => {
|
|
47642
|
+
const { execFile } = await import("node:child_process");
|
|
47643
|
+
const { promisify } = await import("node:util");
|
|
47644
|
+
const { getDockerCmd } = await import("./docker-cmd-EtWSTAje.js").then((n) => n.t);
|
|
47645
|
+
const { stdout } = await promisify(execFile)(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
|
|
47646
|
+
const code = parseInt(stdout.trim(), 10);
|
|
47647
|
+
return Number.isFinite(code) ? code : -1;
|
|
47648
|
+
};
|
|
47649
|
+
/**
|
|
47650
|
+
* `docker wait <id>` returns the exit code on stdout. Extracted as a
|
|
47651
|
+
* test-overridable function so unit tests do not need a real container.
|
|
47652
|
+
*/
|
|
47653
|
+
let waitForExitImpl = defaultWaitForExitImpl;
|
|
47654
|
+
function sleep(ms) {
|
|
47655
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47656
|
+
}
|
|
47657
|
+
|
|
47658
|
+
//#endregion
|
|
47659
|
+
//#region src/cli/commands/local-start-service.ts
|
|
47660
|
+
/**
|
|
47661
|
+
* `cdkd local start-service <Stack/Service>` — Phase 2 of #262. Spins up
|
|
47662
|
+
* `DesiredCount` task replicas locally (clamped by `--max-tasks`) using
|
|
47663
|
+
* the existing `ecs-task-runner` per replica. Long-running; ^C cleans
|
|
47664
|
+
* every replica + sidecar + per-task network.
|
|
47665
|
+
*
|
|
47666
|
+
* Deferred to follow-up PRs (matches the issue's PR-split):
|
|
47667
|
+
* - Local LB emulator (listener + round-robin + target-group health
|
|
47668
|
+
* check) — PR C of #466.
|
|
47669
|
+
* - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
|
|
47670
|
+
* - Service Connect / Cloud Map — tracked separately in #460.
|
|
47671
|
+
*/
|
|
47672
|
+
async function localStartServiceCommand(target, options) {
|
|
47673
|
+
const logger = getLogger();
|
|
47674
|
+
if (options.verbose) logger.setLevel("debug");
|
|
47675
|
+
warnIfDeprecatedRegion(options);
|
|
47676
|
+
const runState = createServiceRunState();
|
|
47677
|
+
let sigintHandler;
|
|
47678
|
+
let sigintCount = 0;
|
|
47679
|
+
let controller;
|
|
47680
|
+
const cleanup = singleFlight(async () => {
|
|
47681
|
+
if (controller) await controller.shutdown();
|
|
47682
|
+
else await Promise.allSettled(runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
|
|
47683
|
+
}, (err) => getLogger().debug(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
47684
|
+
try {
|
|
47685
|
+
await applyRoleArnIfSet({
|
|
47686
|
+
roleArn: options.roleArn,
|
|
47687
|
+
region: options.region
|
|
47688
|
+
});
|
|
47689
|
+
await ensureDockerAvailable();
|
|
47690
|
+
const appCmd = resolveApp(options.app);
|
|
47691
|
+
if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
|
|
47692
|
+
logger.info("Synthesizing CDK app...");
|
|
47693
|
+
const synthesizer = new Synthesizer();
|
|
47694
|
+
const context = parseContextOptions(options.context);
|
|
47695
|
+
const synthOpts = {
|
|
47696
|
+
app: appCmd,
|
|
47697
|
+
output: options.output,
|
|
47698
|
+
...options.region && { region: options.region },
|
|
47699
|
+
...options.profile && { profile: options.profile },
|
|
47700
|
+
...Object.keys(context).length > 0 && { context }
|
|
47701
|
+
};
|
|
47702
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
47703
|
+
const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
|
|
47704
|
+
const service = resolveEcsServiceTarget(target, stacks, imageContext);
|
|
47705
|
+
logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
|
|
47706
|
+
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
|
|
47707
|
+
if (options.fromState && taskNeeds.needsCrossStackResolver) {
|
|
47708
|
+
const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
|
|
47709
|
+
const built = await buildCrossStackResolver(consumerRegion, {
|
|
47710
|
+
...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
|
|
47711
|
+
statePrefix: options.statePrefix,
|
|
47712
|
+
...options.region !== void 0 && { region: options.region },
|
|
47713
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
47714
|
+
});
|
|
47715
|
+
if (built) try {
|
|
47716
|
+
const subContext = {
|
|
47717
|
+
resources: imageContext?.stateResources ?? {},
|
|
47718
|
+
...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
|
|
47719
|
+
consumerRegion,
|
|
47720
|
+
crossStackResolver: built.resolver
|
|
47721
|
+
};
|
|
47722
|
+
await applyCrossStackResolverToTask(service.task, subContext);
|
|
47723
|
+
} finally {
|
|
47724
|
+
built.dispose();
|
|
47725
|
+
}
|
|
47726
|
+
} 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.");
|
|
47727
|
+
sigintHandler = () => {
|
|
47728
|
+
sigintCount += 1;
|
|
47729
|
+
if (sigintCount >= 2) {
|
|
47730
|
+
process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
|
|
47731
|
+
process.exit(130);
|
|
47732
|
+
}
|
|
47733
|
+
logger.info("Stopping service...");
|
|
47734
|
+
cleanup().then(() => process.exit(130));
|
|
47735
|
+
};
|
|
47736
|
+
process.on("SIGINT", sigintHandler);
|
|
47737
|
+
process.on("SIGTERM", sigintHandler);
|
|
47738
|
+
let assumedCredentials;
|
|
47739
|
+
let resolvedRoleArn;
|
|
47740
|
+
if (options.assumeTaskRole === true) {
|
|
47741
|
+
if (!service.task.taskRoleArn) throw new LocalStartServiceError("--assume-task-role passed without an ARN but the task definition has no resolvable TaskRoleArn. Pass the ARN explicitly: --assume-task-role <arn>");
|
|
47742
|
+
resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
|
|
47743
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
47744
|
+
} else if (typeof options.assumeTaskRole === "string") {
|
|
47745
|
+
resolvedRoleArn = options.assumeTaskRole;
|
|
47746
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
47747
|
+
}
|
|
47748
|
+
const envOverrides = readEnvOverridesFile$1(options.envVars);
|
|
47749
|
+
const taskOpts = {
|
|
47750
|
+
cluster: options.cluster,
|
|
47751
|
+
containerHost: options.containerHost,
|
|
47752
|
+
skipPull: options.pull === false,
|
|
47753
|
+
keepRunning: false,
|
|
47754
|
+
detach: true
|
|
47755
|
+
};
|
|
47756
|
+
if (envOverrides) taskOpts.envOverrides = envOverrides;
|
|
47757
|
+
if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
|
|
47758
|
+
if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
|
|
47759
|
+
if (options.platform) taskOpts.platformOverride = options.platform;
|
|
47760
|
+
if (options.region) taskOpts.region = options.region;
|
|
47761
|
+
if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
|
|
47762
|
+
controller = await startEcsService(service, {
|
|
47763
|
+
maxTasks: options.maxTasks,
|
|
47764
|
+
restartPolicy: options.restartPolicy,
|
|
47765
|
+
taskOptions: taskOpts
|
|
47766
|
+
}, runState);
|
|
47767
|
+
logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
|
|
47768
|
+
await controller.waitForShutdown();
|
|
47769
|
+
} finally {
|
|
47770
|
+
if (sigintHandler) {
|
|
47771
|
+
process.off("SIGINT", sigintHandler);
|
|
47772
|
+
process.off("SIGTERM", sigintHandler);
|
|
47773
|
+
}
|
|
47774
|
+
await cleanup();
|
|
47775
|
+
}
|
|
47776
|
+
}
|
|
47777
|
+
async function resolvePlaceholderAccount(arn, region) {
|
|
47778
|
+
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
47779
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
47780
|
+
const sts = new STSClient({ ...region && { region } });
|
|
47781
|
+
try {
|
|
47782
|
+
const account = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
47783
|
+
if (!account) throw new LocalStartServiceError(`--assume-task-role: GetCallerIdentity returned no Account; cannot resolve placeholder ARN '${arn}'.`);
|
|
47784
|
+
return arn.split(TASK_ROLE_ACCOUNT_PLACEHOLDER).join(account);
|
|
47785
|
+
} finally {
|
|
47786
|
+
sts.destroy();
|
|
47787
|
+
}
|
|
47788
|
+
}
|
|
47789
|
+
async function assumeTaskRole(roleArn, region) {
|
|
47790
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
47791
|
+
const sts = new STSClient({ ...region && { region } });
|
|
47792
|
+
try {
|
|
47793
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
47794
|
+
RoleArn: roleArn,
|
|
47795
|
+
RoleSessionName: `cdkd-local-start-service-${Date.now()}`,
|
|
47796
|
+
DurationSeconds: 3600
|
|
47797
|
+
}))).Credentials;
|
|
47798
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalStartServiceError(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
47799
|
+
return {
|
|
47800
|
+
accessKeyId: creds.AccessKeyId,
|
|
47801
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
47802
|
+
sessionToken: creds.SessionToken
|
|
47803
|
+
};
|
|
47804
|
+
} finally {
|
|
47805
|
+
sts.destroy();
|
|
47806
|
+
}
|
|
47807
|
+
}
|
|
47808
|
+
/**
|
|
47809
|
+
* Build the substitution context the ECS resolver consumes. Identical
|
|
47810
|
+
* shape to `local-run-task.ts:buildEcsImageResolutionContext` — only
|
|
47811
|
+
* the candidate stack picker differs because services and tasks share
|
|
47812
|
+
* the same stack-pattern grammar.
|
|
47813
|
+
*/
|
|
47814
|
+
async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
47815
|
+
const logger = getLogger();
|
|
47816
|
+
const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
|
|
47817
|
+
if (!candidate) return void 0;
|
|
47818
|
+
const needs = detectEcsImageResolutionNeeds(candidate);
|
|
47819
|
+
if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
|
|
47820
|
+
const ctx = {};
|
|
47821
|
+
const wantsPseudoForEnvOrSecret = options.fromState && needs.needsEnvOrSecretSubstitution;
|
|
47822
|
+
if (needs.needsPseudoParameters || wantsPseudoForEnvOrSecret) {
|
|
47823
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
|
|
47824
|
+
if (!region) logger.warn("Resolver references ${AWS::Region} but cdkd could not determine the target region. Pass --region, set AWS_REGION, or declare env.region on the CDK stack.");
|
|
47825
|
+
let accountId;
|
|
47826
|
+
try {
|
|
47827
|
+
accountId = await resolveCallerAccountId(region);
|
|
47828
|
+
} catch (err) {
|
|
47829
|
+
logger.warn(`Resolver needs \${AWS::AccountId} but STS GetCallerIdentity failed: ${err instanceof Error ? err.message : String(err)}. Substitution will be skipped; affected env / secret entries will be dropped with per-key warnings.`);
|
|
47830
|
+
}
|
|
47831
|
+
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
47832
|
+
ctx.pseudoParameters = {
|
|
47833
|
+
...accountId !== void 0 && { accountId },
|
|
47834
|
+
...region !== void 0 && { region },
|
|
47835
|
+
...partitionAndSuffix && {
|
|
47836
|
+
partition: partitionAndSuffix.partition,
|
|
47837
|
+
urlSuffix: partitionAndSuffix.urlSuffix
|
|
47838
|
+
}
|
|
47839
|
+
};
|
|
47840
|
+
}
|
|
47841
|
+
const wantsState = needs.needsStateResources || needs.needsEnvOrSecretSubstitution;
|
|
47842
|
+
if (options.fromState && wantsState) {
|
|
47843
|
+
const loaded = await loadStateForStack(candidate.stackName, candidate.region, {
|
|
47844
|
+
...options.stackRegion !== void 0 && { stackRegion: options.stackRegion },
|
|
47845
|
+
...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
|
|
47846
|
+
statePrefix: options.statePrefix,
|
|
47847
|
+
...options.region !== void 0 && { region: options.region },
|
|
47848
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
47849
|
+
});
|
|
47850
|
+
if (loaded) ctx.stateResources = loaded.state.resources;
|
|
47851
|
+
} else if (!options.fromState && needs.needsStateResources) logger.warn("Container Image references a same-stack AWS::ECR::Repository. Pass --from-state to substitute the deployed repository URI.");
|
|
47852
|
+
else if (!options.fromState && needs.needsEnvOrSecretSubstitution) logger.warn("Container Environment / Secrets entries contain CloudFormation intrinsics. Pass --from-state to substitute them against the deployed cdkd state.");
|
|
47853
|
+
return ctx;
|
|
47854
|
+
}
|
|
47855
|
+
function pickCandidateStack(stackPattern, stacks) {
|
|
47856
|
+
if (stackPattern === null) {
|
|
47857
|
+
if (stacks.length === 1) return stacks[0];
|
|
47858
|
+
return;
|
|
47859
|
+
}
|
|
47860
|
+
const matched = matchStacks(stacks, [stackPattern]);
|
|
47861
|
+
if (matched.length === 1) return matched[0];
|
|
47862
|
+
}
|
|
47863
|
+
async function resolveCallerAccountId(region) {
|
|
47864
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
47865
|
+
const sts = new STSClient({ ...region && { region } });
|
|
47866
|
+
try {
|
|
47867
|
+
return (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
47868
|
+
} finally {
|
|
47869
|
+
sts.destroy();
|
|
47870
|
+
}
|
|
47871
|
+
}
|
|
47872
|
+
function readEnvOverridesFile$1(filePath) {
|
|
47873
|
+
if (!filePath) return void 0;
|
|
47874
|
+
let raw;
|
|
47875
|
+
try {
|
|
47876
|
+
raw = readFileSync(filePath, "utf-8");
|
|
47877
|
+
} catch (err) {
|
|
47878
|
+
throw new LocalStartServiceError(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
47879
|
+
}
|
|
47880
|
+
let parsed;
|
|
47881
|
+
try {
|
|
47882
|
+
parsed = JSON.parse(raw);
|
|
47883
|
+
} catch (err) {
|
|
47884
|
+
throw new LocalStartServiceError(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
47885
|
+
}
|
|
47886
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new LocalStartServiceError(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
47887
|
+
return parsed;
|
|
47888
|
+
}
|
|
47889
|
+
function parsePositiveInt(raw, flagName) {
|
|
47890
|
+
const parsed = parseInt(raw, 10);
|
|
47891
|
+
if (!Number.isFinite(parsed) || parsed < 1) throw new LocalStartServiceError(`${flagName} must be a positive integer (got '${raw}').`);
|
|
47892
|
+
return parsed;
|
|
47893
|
+
}
|
|
47894
|
+
/**
|
|
47895
|
+
* Hard cap on `--max-tasks` driven by the per-replica subnet allocator
|
|
47896
|
+
* in `ecs-service-runner.ts:bootReplica` (`170 + (index % 84)`). The
|
|
47897
|
+
* `% 84` modulo wraps at index 84, collapsing replica 84's `/24` onto
|
|
47898
|
+
* replica 0's allocation. Docker rejects the duplicate-subnet network
|
|
47899
|
+
* creation with a cryptic "Pool overlaps with other one on this address
|
|
47900
|
+
* space" error 30s into the boot — by which time some early replicas
|
|
47901
|
+
* may have spent docker-run budget. Reject at parse time so the user
|
|
47902
|
+
* gets an actionable error before any boot work fires.
|
|
47903
|
+
*
|
|
47904
|
+
* 84 is the count of usable link-local /24 octets in the range
|
|
47905
|
+
* `169.254.170.0..169.254.253.0` (255 reserved for broadcast). Raising
|
|
47906
|
+
* this requires extending the allocator to walk a different IP range.
|
|
47907
|
+
*/
|
|
47908
|
+
const MAX_TASKS_SUBNET_RANGE_CAP = 84;
|
|
47909
|
+
function parseMaxTasks(raw) {
|
|
47910
|
+
const parsed = parsePositiveInt(raw, "--max-tasks");
|
|
47911
|
+
if (parsed > 84) throw new LocalStartServiceError(`--max-tasks ${parsed} exceeds the per-replica link-local /24 subnet allocator's range (${84}). The allocator in ecs-service-runner.ts assigns each replica its own 169.254.x.0/24 from the range 169.254.170.0..169.254.253.0; replica indices >= ${84} would collide with earlier replicas via modulo wrap. Lower --max-tasks to <= ${84}, or accept reduced local concurrency for high-DesiredCount services.`);
|
|
47912
|
+
return parsed;
|
|
47913
|
+
}
|
|
47914
|
+
function parseRestartPolicy(raw) {
|
|
47915
|
+
if (raw === "on-failure" || raw === "always" || raw === "none") return raw;
|
|
47916
|
+
throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
|
|
47917
|
+
}
|
|
47918
|
+
function createLocalStartServiceCommand() {
|
|
47919
|
+
const cmd = new Command("start-service").description("Run an AWS::ECS::Service locally as a long-running emulator. Spins up DesiredCount task replicas (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. Target accepts a CDK display path (MyStack/MyService) or stack-qualified logical ID (MyStack:MyServiceXYZ); single-stack apps may omit the stack prefix.").argument("<target>", "CDK display path or stack-qualified logical ID of the AWS::ECS::Service 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));
|
|
47920
|
+
[
|
|
47921
|
+
...commonOptions,
|
|
47922
|
+
...appOptions,
|
|
47923
|
+
...contextOptions,
|
|
47924
|
+
...stateOptions
|
|
47925
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
47926
|
+
cmd.addOption(deprecatedRegionOption);
|
|
47927
|
+
return cmd;
|
|
47928
|
+
}
|
|
47929
|
+
|
|
46994
47930
|
//#endregion
|
|
46995
47931
|
//#region src/cli/commands/local-invoke.ts
|
|
46996
47932
|
/**
|
|
@@ -47722,6 +48658,7 @@ function createLocalCommand() {
|
|
|
47722
48658
|
local.addCommand(invoke);
|
|
47723
48659
|
local.addCommand(createLocalStartApiCommand());
|
|
47724
48660
|
local.addCommand(createLocalRunTaskCommand());
|
|
48661
|
+
local.addCommand(createLocalStartServiceCommand());
|
|
47725
48662
|
return local;
|
|
47726
48663
|
}
|
|
47727
48664
|
|
|
@@ -49094,7 +50031,7 @@ function reorderArgs(argv) {
|
|
|
49094
50031
|
*/
|
|
49095
50032
|
async function main() {
|
|
49096
50033
|
const program = new Command();
|
|
49097
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
50034
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.131.0");
|
|
49098
50035
|
program.addCommand(createBootstrapCommand());
|
|
49099
50036
|
program.addCommand(createSynthCommand());
|
|
49100
50037
|
program.addCommand(createListCommand());
|