@go-to-k/cdkd 0.130.0 → 0.132.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 +48 -31
- 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 +2452 -56
- package/dist/cli.js.map +1 -1
- package/dist/{deploy-engine-B-w4C_7O.js → deploy-engine-Dn7oV5rA.js} +19 -989
- 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-B-w4C_7O.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;
|
|
@@ -40432,20 +40433,74 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
|
|
|
40432
40433
|
};
|
|
40433
40434
|
const integrationType = integration["Type"];
|
|
40434
40435
|
if (integrationType === "MOCK") {
|
|
40435
|
-
|
|
40436
|
-
|
|
40436
|
+
if (httpMethod === "OPTIONS") {
|
|
40437
|
+
const preflight = extractRestV1MockCorsConfig(integration);
|
|
40438
|
+
if (preflight) return [{
|
|
40439
|
+
...baseRoute,
|
|
40440
|
+
method: "OPTIONS",
|
|
40441
|
+
pathPattern: path,
|
|
40442
|
+
lambdaLogicalId: "",
|
|
40443
|
+
mockCors: preflight
|
|
40444
|
+
}];
|
|
40445
|
+
}
|
|
40446
|
+
const config = buildMockIntegrationConfig(integration);
|
|
40447
|
+
return [{
|
|
40437
40448
|
...baseRoute,
|
|
40438
|
-
method:
|
|
40449
|
+
method: httpMethod,
|
|
40439
40450
|
pathPattern: path,
|
|
40440
40451
|
lambdaLogicalId: "",
|
|
40441
|
-
|
|
40452
|
+
restV1Integration: config
|
|
40453
|
+
}];
|
|
40454
|
+
}
|
|
40455
|
+
if (integrationType === "HTTP_PROXY") {
|
|
40456
|
+
const config = buildHttpProxyIntegrationConfig(integration, stackName, logicalId);
|
|
40457
|
+
if (config.kind === "unsupported") return [{
|
|
40458
|
+
...baseRoute,
|
|
40459
|
+
method: httpMethod,
|
|
40460
|
+
pathPattern: path,
|
|
40461
|
+
lambdaLogicalId: "",
|
|
40462
|
+
unsupported: { reason: config.reason }
|
|
40463
|
+
}];
|
|
40464
|
+
return [{
|
|
40465
|
+
...baseRoute,
|
|
40466
|
+
method: httpMethod,
|
|
40467
|
+
pathPattern: path,
|
|
40468
|
+
lambdaLogicalId: "",
|
|
40469
|
+
restV1Integration: config.config
|
|
40470
|
+
}];
|
|
40471
|
+
}
|
|
40472
|
+
if (integrationType === "HTTP") {
|
|
40473
|
+
const config = buildHttpIntegrationConfig(integration, stackName, logicalId);
|
|
40474
|
+
if (config.kind === "unsupported") return [{
|
|
40475
|
+
...baseRoute,
|
|
40476
|
+
method: httpMethod,
|
|
40477
|
+
pathPattern: path,
|
|
40478
|
+
lambdaLogicalId: "",
|
|
40479
|
+
unsupported: { reason: config.reason }
|
|
40442
40480
|
}];
|
|
40443
40481
|
return [{
|
|
40444
40482
|
...baseRoute,
|
|
40445
40483
|
method: httpMethod,
|
|
40446
40484
|
pathPattern: path,
|
|
40447
40485
|
lambdaLogicalId: "",
|
|
40448
|
-
|
|
40486
|
+
restV1Integration: config.config
|
|
40487
|
+
}];
|
|
40488
|
+
}
|
|
40489
|
+
if (integrationType === "AWS") {
|
|
40490
|
+
const config = buildAwsIntegrationConfig(integration, stackName, logicalId);
|
|
40491
|
+
if (config.kind === "unsupported") return [{
|
|
40492
|
+
...baseRoute,
|
|
40493
|
+
method: httpMethod,
|
|
40494
|
+
pathPattern: path,
|
|
40495
|
+
lambdaLogicalId: "",
|
|
40496
|
+
unsupported: { reason: config.reason }
|
|
40497
|
+
}];
|
|
40498
|
+
return [{
|
|
40499
|
+
...baseRoute,
|
|
40500
|
+
method: httpMethod,
|
|
40501
|
+
pathPattern: path,
|
|
40502
|
+
lambdaLogicalId: config.config.lambdaLogicalId,
|
|
40503
|
+
restV1Integration: config.config
|
|
40449
40504
|
}];
|
|
40450
40505
|
}
|
|
40451
40506
|
if (integrationType !== "AWS_PROXY") return [{
|
|
@@ -40453,7 +40508,7 @@ function discoverRestV1Method(logicalId, resource, template, stackName) {
|
|
|
40453
40508
|
method: httpMethod,
|
|
40454
40509
|
pathPattern: path,
|
|
40455
40510
|
lambdaLogicalId: "",
|
|
40456
|
-
unsupported: { reason: `${stackName}/${logicalId}: REST v1 integration type '${String(integrationType)}'
|
|
40511
|
+
unsupported: { reason: `${stackName}/${logicalId}: unknown REST v1 integration type '${String(integrationType)}' (expected AWS_PROXY / AWS / HTTP / HTTP_PROXY / MOCK).` }
|
|
40457
40512
|
}];
|
|
40458
40513
|
const integrationUri = integration["Uri"];
|
|
40459
40514
|
const arnOutcome = resolveLambdaArnOutcome(integrationUri);
|
|
@@ -40523,6 +40578,188 @@ function extractRestV1MockCorsConfig(integration) {
|
|
|
40523
40578
|
};
|
|
40524
40579
|
}
|
|
40525
40580
|
/**
|
|
40581
|
+
* Marker sequence on a Lambda invoke ARN — used to tell apart REST v1
|
|
40582
|
+
* `AWS` integrations whose backend is Lambda (`functions/<arn>/invocations`)
|
|
40583
|
+
* from other AWS-service integrations (`:s3:path/...`, `:sqs:path/...`).
|
|
40584
|
+
* Closes #457's AWS-vs-Lambda discrimination.
|
|
40585
|
+
*/
|
|
40586
|
+
const LAMBDA_INVOKE_PATH = ":lambda:path/2015-03-31/functions/";
|
|
40587
|
+
/**
|
|
40588
|
+
* Build a MOCK integration config for `cdkd local start-api` dispatch.
|
|
40589
|
+
*
|
|
40590
|
+
* Pulls `Integration.RequestTemplates['application/json']` (drives MOCK
|
|
40591
|
+
* status-code selection — AWS reads `{"statusCode": N}` from the rendered
|
|
40592
|
+
* template) and `Integration.IntegrationResponses[]` (drives the shaped
|
|
40593
|
+
* response).
|
|
40594
|
+
*/
|
|
40595
|
+
function buildMockIntegrationConfig(integration) {
|
|
40596
|
+
const requestTemplate = pickStringFromRecord(integration["RequestTemplates"], "application/json");
|
|
40597
|
+
const responses = readIntegrationResponses(integration);
|
|
40598
|
+
return {
|
|
40599
|
+
kind: "mock",
|
|
40600
|
+
requestTemplate: requestTemplate ?? void 0,
|
|
40601
|
+
responses
|
|
40602
|
+
};
|
|
40603
|
+
}
|
|
40604
|
+
/**
|
|
40605
|
+
* Build a HTTP_PROXY integration config. The Uri must be a literal
|
|
40606
|
+
* string at template-author time — `Fn::Sub` shapes with literal
|
|
40607
|
+
* placeholders are rare and unsupported in v1 (surfaces as a 501 with
|
|
40608
|
+
* a clear reason).
|
|
40609
|
+
*/
|
|
40610
|
+
function buildHttpProxyIntegrationConfig(integration, stackName, logicalId) {
|
|
40611
|
+
const uri = integration["Uri"];
|
|
40612
|
+
if (typeof uri !== "string" || uri.length === 0) return {
|
|
40613
|
+
kind: "unsupported",
|
|
40614
|
+
reason: `${stackName}/${logicalId}: HTTP_PROXY Integration.Uri must be a literal string in v1 (cdkd local start-api does not resolve Fn::Sub / Fn::Join in HTTP_PROXY Uris); got ${shortJson$1(uri)}.`
|
|
40615
|
+
};
|
|
40616
|
+
const integrationHttpMethod = pickStringField(integration, "IntegrationHttpMethod");
|
|
40617
|
+
const requestParameters = pickStringRecord(integration["RequestParameters"]);
|
|
40618
|
+
const responses = readIntegrationResponses(integration);
|
|
40619
|
+
return {
|
|
40620
|
+
kind: "config",
|
|
40621
|
+
config: {
|
|
40622
|
+
kind: "http-proxy",
|
|
40623
|
+
uri,
|
|
40624
|
+
...integrationHttpMethod !== void 0 && { integrationHttpMethod },
|
|
40625
|
+
...requestParameters !== void 0 && { requestParameters },
|
|
40626
|
+
responses
|
|
40627
|
+
}
|
|
40628
|
+
};
|
|
40629
|
+
}
|
|
40630
|
+
/**
|
|
40631
|
+
* Build an HTTP (non-proxy) integration config. Like HTTP_PROXY but with
|
|
40632
|
+
* `RequestTemplates` for VTL transformation.
|
|
40633
|
+
*/
|
|
40634
|
+
function buildHttpIntegrationConfig(integration, stackName, logicalId) {
|
|
40635
|
+
const uri = integration["Uri"];
|
|
40636
|
+
if (typeof uri !== "string" || uri.length === 0) return {
|
|
40637
|
+
kind: "unsupported",
|
|
40638
|
+
reason: `${stackName}/${logicalId}: HTTP Integration.Uri must be a literal string in v1 (cdkd local start-api does not resolve Fn::Sub / Fn::Join in HTTP Uris); got ${shortJson$1(uri)}.`
|
|
40639
|
+
};
|
|
40640
|
+
const integrationHttpMethod = pickStringField(integration, "IntegrationHttpMethod");
|
|
40641
|
+
const requestParameters = pickStringRecord(integration["RequestParameters"]);
|
|
40642
|
+
const requestTemplates = pickStringRecord(integration["RequestTemplates"]);
|
|
40643
|
+
const responses = readIntegrationResponses(integration);
|
|
40644
|
+
return {
|
|
40645
|
+
kind: "config",
|
|
40646
|
+
config: {
|
|
40647
|
+
kind: "http",
|
|
40648
|
+
uri,
|
|
40649
|
+
...integrationHttpMethod !== void 0 && { integrationHttpMethod },
|
|
40650
|
+
...requestParameters !== void 0 && { requestParameters },
|
|
40651
|
+
...requestTemplates !== void 0 && { requestTemplates },
|
|
40652
|
+
responses
|
|
40653
|
+
}
|
|
40654
|
+
};
|
|
40655
|
+
}
|
|
40656
|
+
/**
|
|
40657
|
+
* Build an AWS integration config. Branches on whether the integration
|
|
40658
|
+
* targets a Lambda (`:lambda:path/2015-03-31/functions/<arn>/invocations`)
|
|
40659
|
+
* or a non-Lambda AWS service (`:s3:path/...` / `:sqs:action/...` etc.).
|
|
40660
|
+
*
|
|
40661
|
+
* cdkd v1 supports Lambda non-proxy AWS integrations end-to-end. Non-
|
|
40662
|
+
* Lambda AWS service integrations surface as deferred-501 unsupported
|
|
40663
|
+
* routes — they would require an AWS SDK client per service, IAM
|
|
40664
|
+
* credential threading, and a sizable per-service unit-test matrix.
|
|
40665
|
+
* See [docs/local-emulation.md](docs/local-emulation.md) for the deferred
|
|
40666
|
+
* AWS-service-action list.
|
|
40667
|
+
*/
|
|
40668
|
+
function buildAwsIntegrationConfig(integration, stackName, logicalId) {
|
|
40669
|
+
const uri = integration["Uri"];
|
|
40670
|
+
if (!uriContainsLambdaMarker(uri)) return {
|
|
40671
|
+
kind: "unsupported",
|
|
40672
|
+
reason: `${stackName}/${logicalId}: REST v1 AWS integration targeting a non-Lambda service (Uri ${shortJson$1(uri)}) is not emulated locally in cdkd v1. Lambda non-proxy AWS integrations are supported; direct AWS service integrations (S3 / SQS / SNS / DynamoDB) require deploying to AWS. See docs/local-emulation.md.`
|
|
40673
|
+
};
|
|
40674
|
+
const arnOutcome = resolveLambdaArnOutcome(uri);
|
|
40675
|
+
if (arnOutcome.kind === "unsupported") return {
|
|
40676
|
+
kind: "unsupported",
|
|
40677
|
+
reason: `${stackName}/${logicalId}.Integration.Uri: ${arnOutcome.detail} (got ${shortJson$1(uri)}). Lambda Arn intrinsics on cross-stack / imported references are not resolvable locally.`
|
|
40678
|
+
};
|
|
40679
|
+
const requestTemplates = pickStringRecord(integration["RequestTemplates"]);
|
|
40680
|
+
const responses = readIntegrationResponses(integration);
|
|
40681
|
+
return {
|
|
40682
|
+
kind: "config",
|
|
40683
|
+
config: {
|
|
40684
|
+
kind: "aws-lambda",
|
|
40685
|
+
lambdaLogicalId: arnOutcome.logicalId,
|
|
40686
|
+
...requestTemplates !== void 0 && { requestTemplates },
|
|
40687
|
+
responses
|
|
40688
|
+
}
|
|
40689
|
+
};
|
|
40690
|
+
}
|
|
40691
|
+
/**
|
|
40692
|
+
* Determine whether an `Integration.Uri` references a Lambda invoke path.
|
|
40693
|
+
* Recognises the canonical Lambda invoke ARN shape across the same
|
|
40694
|
+
* intrinsic forms `intrinsic-lambda-arn.ts` accepts (the shared resolver
|
|
40695
|
+
* never produces this Boolean directly, so we walk the shape here).
|
|
40696
|
+
*/
|
|
40697
|
+
function uriContainsLambdaMarker(uri) {
|
|
40698
|
+
if (typeof uri === "string") return uri.includes(LAMBDA_INVOKE_PATH);
|
|
40699
|
+
if (uri && typeof uri === "object" && !Array.isArray(uri)) {
|
|
40700
|
+
const obj = uri;
|
|
40701
|
+
if ("Fn::Sub" in obj) {
|
|
40702
|
+
const v = obj["Fn::Sub"];
|
|
40703
|
+
if (typeof v === "string") return v.includes(LAMBDA_INVOKE_PATH);
|
|
40704
|
+
if (Array.isArray(v) && typeof v[0] === "string") return v[0].includes(LAMBDA_INVOKE_PATH);
|
|
40705
|
+
}
|
|
40706
|
+
if ("Fn::Join" in obj) {
|
|
40707
|
+
const join = obj["Fn::Join"];
|
|
40708
|
+
if (Array.isArray(join) && join.length === 2 && Array.isArray(join[1])) {
|
|
40709
|
+
for (const piece of join[1]) if (typeof piece === "string" && piece.includes(LAMBDA_INVOKE_PATH)) return true;
|
|
40710
|
+
}
|
|
40711
|
+
}
|
|
40712
|
+
if ("Ref" in obj || "Fn::GetAtt" in obj) return true;
|
|
40713
|
+
}
|
|
40714
|
+
return false;
|
|
40715
|
+
}
|
|
40716
|
+
/**
|
|
40717
|
+
* Read `Integration.IntegrationResponses[]` from a Method's Integration
|
|
40718
|
+
* sub-object and return the entries cdkd's dispatchers consume.
|
|
40719
|
+
*
|
|
40720
|
+
* Defensive: rejects non-object entries with a clear inline warning.
|
|
40721
|
+
*/
|
|
40722
|
+
function readIntegrationResponses(integration) {
|
|
40723
|
+
const raw = integration["IntegrationResponses"];
|
|
40724
|
+
if (!Array.isArray(raw)) return [];
|
|
40725
|
+
const out = [];
|
|
40726
|
+
for (const entry of raw) {
|
|
40727
|
+
if (!entry || typeof entry !== "object") continue;
|
|
40728
|
+
const obj = entry;
|
|
40729
|
+
const statusCode = obj["StatusCode"];
|
|
40730
|
+
if (statusCode === void 0) continue;
|
|
40731
|
+
if (typeof statusCode !== "string" && typeof statusCode !== "number") continue;
|
|
40732
|
+
const e = { StatusCode: String(statusCode) };
|
|
40733
|
+
if (typeof obj["SelectionPattern"] === "string") e.SelectionPattern = obj["SelectionPattern"];
|
|
40734
|
+
const responseParameters = pickStringRecord(obj["ResponseParameters"]);
|
|
40735
|
+
if (responseParameters !== void 0) e.ResponseParameters = responseParameters;
|
|
40736
|
+
const responseTemplates = pickStringRecord(obj["ResponseTemplates"]);
|
|
40737
|
+
if (responseTemplates !== void 0) e.ResponseTemplates = responseTemplates;
|
|
40738
|
+
if (typeof obj["ContentHandling"] === "string") e.ContentHandling = obj["ContentHandling"];
|
|
40739
|
+
out.push(e);
|
|
40740
|
+
}
|
|
40741
|
+
return out;
|
|
40742
|
+
}
|
|
40743
|
+
function pickStringField(props, key) {
|
|
40744
|
+
const v = props[key];
|
|
40745
|
+
return typeof v === "string" ? v : void 0;
|
|
40746
|
+
}
|
|
40747
|
+
function pickStringRecord(value) {
|
|
40748
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
40749
|
+
const out = {};
|
|
40750
|
+
let any = false;
|
|
40751
|
+
for (const [k, v] of Object.entries(value)) if (typeof v === "string") {
|
|
40752
|
+
out[k] = v;
|
|
40753
|
+
any = true;
|
|
40754
|
+
}
|
|
40755
|
+
return any ? out : void 0;
|
|
40756
|
+
}
|
|
40757
|
+
function pickStringFromRecord(value, key) {
|
|
40758
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
40759
|
+
const v = value[key];
|
|
40760
|
+
return typeof v === "string" ? v : void 0;
|
|
40761
|
+
}
|
|
40762
|
+
/**
|
|
40526
40763
|
* Walk a chain of `AWS::ApiGateway::Resource` parent pointers up to the
|
|
40527
40764
|
* `RestApi` root to build the full path. Each `Resource` contributes a
|
|
40528
40765
|
* `PathPart` segment; the `RestApi` itself contributes the leading `/`.
|
|
@@ -40890,6 +41127,1354 @@ function shortJson$1(value) {
|
|
|
40890
41127
|
}
|
|
40891
41128
|
}
|
|
40892
41129
|
|
|
41130
|
+
//#endregion
|
|
41131
|
+
//#region src/local/vtl-engine.ts
|
|
41132
|
+
/** Error thrown when a template references an unsupported VTL feature. */
|
|
41133
|
+
var VtlEvaluationError = class VtlEvaluationError extends Error {
|
|
41134
|
+
constructor(message) {
|
|
41135
|
+
super(message);
|
|
41136
|
+
this.name = "VtlEvaluationError";
|
|
41137
|
+
Object.setPrototypeOf(this, VtlEvaluationError.prototype);
|
|
41138
|
+
}
|
|
41139
|
+
};
|
|
41140
|
+
/** Built-in `$util` implementation. */
|
|
41141
|
+
function buildDefaultUtil() {
|
|
41142
|
+
const coerce = (v) => {
|
|
41143
|
+
if (v == null) return "";
|
|
41144
|
+
if (typeof v === "string") return v;
|
|
41145
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
41146
|
+
try {
|
|
41147
|
+
return JSON.stringify(v);
|
|
41148
|
+
} catch {
|
|
41149
|
+
return "";
|
|
41150
|
+
}
|
|
41151
|
+
};
|
|
41152
|
+
return {
|
|
41153
|
+
escapeJavaScript(input) {
|
|
41154
|
+
return coerce(input).replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
|
|
41155
|
+
},
|
|
41156
|
+
base64Encode(input) {
|
|
41157
|
+
return Buffer.from(coerce(input), "utf-8").toString("base64");
|
|
41158
|
+
},
|
|
41159
|
+
base64Decode(input) {
|
|
41160
|
+
return Buffer.from(coerce(input), "base64").toString("utf-8");
|
|
41161
|
+
},
|
|
41162
|
+
urlEncode(input) {
|
|
41163
|
+
return encodeURIComponent(coerce(input));
|
|
41164
|
+
},
|
|
41165
|
+
urlDecode(input) {
|
|
41166
|
+
try {
|
|
41167
|
+
return decodeURIComponent(coerce(input));
|
|
41168
|
+
} catch {
|
|
41169
|
+
return coerce(input);
|
|
41170
|
+
}
|
|
41171
|
+
},
|
|
41172
|
+
parseJson(input) {
|
|
41173
|
+
const s = coerce(input);
|
|
41174
|
+
try {
|
|
41175
|
+
return JSON.parse(s);
|
|
41176
|
+
} catch (err) {
|
|
41177
|
+
throw new VtlEvaluationError(`$util.parseJson: invalid JSON input: ${err instanceof Error ? err.message : String(err)}`);
|
|
41178
|
+
}
|
|
41179
|
+
}
|
|
41180
|
+
};
|
|
41181
|
+
}
|
|
41182
|
+
/**
|
|
41183
|
+
* Public entry point — evaluate a VTL template against a context and
|
|
41184
|
+
* return the rendered string. Throws {@link VtlEvaluationError} on any
|
|
41185
|
+
* unsupported syntax or runtime failure.
|
|
41186
|
+
*
|
|
41187
|
+
* Empty / undefined templates short-circuit to an empty string, matching
|
|
41188
|
+
* AWS API Gateway behavior when `RequestTemplates` / `ResponseTemplates`
|
|
41189
|
+
* is absent for the selected content type.
|
|
41190
|
+
*/
|
|
41191
|
+
function evaluateVtl(template, ctx) {
|
|
41192
|
+
if (template === void 0 || template.length === 0) return "";
|
|
41193
|
+
return new VtlEvaluator(ctx).evaluate(template);
|
|
41194
|
+
}
|
|
41195
|
+
/**
|
|
41196
|
+
* Stateful evaluator. Tokenizes + parses + renders in one pass — minimal
|
|
41197
|
+
* subset, so a recursive-descent walk over the template suffices. Tracks
|
|
41198
|
+
* a per-template scope chain for `#set` and `#foreach` bindings.
|
|
41199
|
+
*/
|
|
41200
|
+
var VtlEvaluator = class {
|
|
41201
|
+
ctx;
|
|
41202
|
+
scopes;
|
|
41203
|
+
output = [];
|
|
41204
|
+
constructor(ctx) {
|
|
41205
|
+
this.ctx = ctx;
|
|
41206
|
+
this.scopes = [/* @__PURE__ */ new Map()];
|
|
41207
|
+
}
|
|
41208
|
+
evaluate(template) {
|
|
41209
|
+
this.renderBlock(template);
|
|
41210
|
+
return this.output.join("");
|
|
41211
|
+
}
|
|
41212
|
+
/**
|
|
41213
|
+
* Render a block — walks the template, interpolating `${var}` /
|
|
41214
|
+
* `$var.field.method(args)` and handling `#set` / `#if` / `#foreach`
|
|
41215
|
+
* directives.
|
|
41216
|
+
*
|
|
41217
|
+
* The walk is line-aware for directives: every `#directive` MUST start
|
|
41218
|
+
* a line (after whitespace) per Velocity convention, but for ergonomics
|
|
41219
|
+
* we also accept directives at the start of the template. Inline `$var`
|
|
41220
|
+
* references are handled anywhere.
|
|
41221
|
+
*/
|
|
41222
|
+
renderBlock(block) {
|
|
41223
|
+
let i = 0;
|
|
41224
|
+
while (i < block.length) {
|
|
41225
|
+
const ch = block[i];
|
|
41226
|
+
if (ch === "#" && this.isDirectiveStart(block, i)) {
|
|
41227
|
+
i = this.handleDirective(block, i);
|
|
41228
|
+
continue;
|
|
41229
|
+
}
|
|
41230
|
+
if (ch === "$") {
|
|
41231
|
+
const consumed = this.handleVariable(block, i);
|
|
41232
|
+
if (consumed > 0) {
|
|
41233
|
+
i += consumed;
|
|
41234
|
+
continue;
|
|
41235
|
+
}
|
|
41236
|
+
}
|
|
41237
|
+
if (ch === "\\" && i + 1 < block.length && block[i + 1] === "$") {
|
|
41238
|
+
this.output.push("$");
|
|
41239
|
+
i += 2;
|
|
41240
|
+
continue;
|
|
41241
|
+
}
|
|
41242
|
+
this.output.push(ch ?? "");
|
|
41243
|
+
i++;
|
|
41244
|
+
}
|
|
41245
|
+
}
|
|
41246
|
+
isDirectiveStart(block, i) {
|
|
41247
|
+
if (i + 1 >= block.length) return false;
|
|
41248
|
+
const next = block[i + 1];
|
|
41249
|
+
if (next === "#") return true;
|
|
41250
|
+
return next !== void 0 && /[a-zA-Z]/.test(next);
|
|
41251
|
+
}
|
|
41252
|
+
/**
|
|
41253
|
+
* Handle one directive (`#set`, `#if`, `#foreach`, etc.) — returns the
|
|
41254
|
+
* NEW index in `block` (i.e. how far we consumed past the directive).
|
|
41255
|
+
*/
|
|
41256
|
+
handleDirective(block, start) {
|
|
41257
|
+
if (block[start + 1] === "#") {
|
|
41258
|
+
const eol = block.indexOf("\n", start);
|
|
41259
|
+
return eol === -1 ? block.length : eol + 1;
|
|
41260
|
+
}
|
|
41261
|
+
const directiveMatch = /^#([a-zA-Z]+)/.exec(block.slice(start));
|
|
41262
|
+
if (!directiveMatch) {
|
|
41263
|
+
this.output.push("#");
|
|
41264
|
+
return start + 1;
|
|
41265
|
+
}
|
|
41266
|
+
const name = directiveMatch[1];
|
|
41267
|
+
const afterDirective = start + 1 + name.length;
|
|
41268
|
+
switch (name) {
|
|
41269
|
+
case "set": return this.handleSetDirective(block, afterDirective);
|
|
41270
|
+
case "if": return this.handleIfDirective(block, afterDirective);
|
|
41271
|
+
case "foreach": return this.handleForeachDirective(block, afterDirective);
|
|
41272
|
+
case "else":
|
|
41273
|
+
case "elseif":
|
|
41274
|
+
case "end": throw new VtlEvaluationError(`Unexpected #${name} outside of a #if / #foreach block`);
|
|
41275
|
+
default: throw new VtlEvaluationError(`Unsupported VTL directive #${name} (cdkd local start-api supports #set / #if / #elseif / #else / #foreach / #end / ##)`);
|
|
41276
|
+
}
|
|
41277
|
+
}
|
|
41278
|
+
/**
|
|
41279
|
+
* `#set($var = expression)` — assigns to the innermost scope.
|
|
41280
|
+
*/
|
|
41281
|
+
handleSetDirective(block, after) {
|
|
41282
|
+
const { args, end } = this.readParenArgs(block, after);
|
|
41283
|
+
const eq = args.indexOf("=");
|
|
41284
|
+
if (eq === -1) throw new VtlEvaluationError(`#set requires '=': got #set(${args})`);
|
|
41285
|
+
const left = args.slice(0, eq).trim();
|
|
41286
|
+
const right = args.slice(eq + 1).trim();
|
|
41287
|
+
if (!left.startsWith("$")) throw new VtlEvaluationError(`#set left side must be a $var reference (got '${left}')`);
|
|
41288
|
+
const varName = left.slice(1).replace(/^\{/, "").replace(/\}$/, "");
|
|
41289
|
+
const value = this.evaluateExpression(right);
|
|
41290
|
+
this.scopes[this.scopes.length - 1].set(varName, value);
|
|
41291
|
+
return this.skipDirectiveTrailingNewline(block, end);
|
|
41292
|
+
}
|
|
41293
|
+
/**
|
|
41294
|
+
* `#if (cond) ... #elseif (cond) ... #else ... #end`. Renders the
|
|
41295
|
+
* first true branch only; the rest are skipped (their text NOT emitted).
|
|
41296
|
+
*/
|
|
41297
|
+
handleIfDirective(block, after) {
|
|
41298
|
+
const { args: condExpr, end } = this.readParenArgs(block, after);
|
|
41299
|
+
let rendered = false;
|
|
41300
|
+
let renderedAny = false;
|
|
41301
|
+
const branches = [{
|
|
41302
|
+
condition: condExpr,
|
|
41303
|
+
bodyStart: this.skipDirectiveTrailingNewline(block, end),
|
|
41304
|
+
bodyEnd: -1
|
|
41305
|
+
}];
|
|
41306
|
+
let cursor = branches[0].bodyStart;
|
|
41307
|
+
let depth = 1;
|
|
41308
|
+
while (cursor < block.length && depth > 0) {
|
|
41309
|
+
if (block[cursor] !== "#") {
|
|
41310
|
+
cursor++;
|
|
41311
|
+
continue;
|
|
41312
|
+
}
|
|
41313
|
+
const m = /^#([a-zA-Z]+)/.exec(block.slice(cursor));
|
|
41314
|
+
if (!m) {
|
|
41315
|
+
cursor++;
|
|
41316
|
+
continue;
|
|
41317
|
+
}
|
|
41318
|
+
const tag = m[1];
|
|
41319
|
+
const tagAfter = cursor + 1 + tag.length;
|
|
41320
|
+
if (tag === "if" || tag === "foreach") {
|
|
41321
|
+
depth++;
|
|
41322
|
+
cursor = tagAfter;
|
|
41323
|
+
continue;
|
|
41324
|
+
}
|
|
41325
|
+
if (tag === "end") {
|
|
41326
|
+
depth--;
|
|
41327
|
+
if (depth === 0) {
|
|
41328
|
+
branches[branches.length - 1].bodyEnd = cursor;
|
|
41329
|
+
const endIdx = this.skipDirectiveTrailingNewline(block, tagAfter);
|
|
41330
|
+
for (const branch of branches) {
|
|
41331
|
+
if (rendered) break;
|
|
41332
|
+
const truthy = branch.condition === null ? !renderedAny : this.evaluateCondition(branch.condition);
|
|
41333
|
+
if (truthy) {
|
|
41334
|
+
this.renderBlock(block.slice(branch.bodyStart, branch.bodyEnd));
|
|
41335
|
+
rendered = true;
|
|
41336
|
+
}
|
|
41337
|
+
renderedAny = renderedAny || truthy;
|
|
41338
|
+
}
|
|
41339
|
+
return endIdx;
|
|
41340
|
+
}
|
|
41341
|
+
cursor = tagAfter;
|
|
41342
|
+
continue;
|
|
41343
|
+
}
|
|
41344
|
+
if (depth === 1 && (tag === "elseif" || tag === "else")) {
|
|
41345
|
+
branches[branches.length - 1].bodyEnd = cursor;
|
|
41346
|
+
if (tag === "elseif") {
|
|
41347
|
+
const { args, end: elseifEnd } = this.readParenArgs(block, tagAfter);
|
|
41348
|
+
branches.push({
|
|
41349
|
+
condition: args,
|
|
41350
|
+
bodyStart: this.skipDirectiveTrailingNewline(block, elseifEnd),
|
|
41351
|
+
bodyEnd: -1
|
|
41352
|
+
});
|
|
41353
|
+
cursor = branches[branches.length - 1].bodyStart;
|
|
41354
|
+
} else {
|
|
41355
|
+
branches.push({
|
|
41356
|
+
condition: null,
|
|
41357
|
+
bodyStart: this.skipDirectiveTrailingNewline(block, tagAfter),
|
|
41358
|
+
bodyEnd: -1
|
|
41359
|
+
});
|
|
41360
|
+
cursor = branches[branches.length - 1].bodyStart;
|
|
41361
|
+
}
|
|
41362
|
+
continue;
|
|
41363
|
+
}
|
|
41364
|
+
cursor = tagAfter;
|
|
41365
|
+
}
|
|
41366
|
+
throw new VtlEvaluationError("#if without matching #end");
|
|
41367
|
+
}
|
|
41368
|
+
/**
|
|
41369
|
+
* `#foreach($x in $list) ... #end` — iterates a list / object's values.
|
|
41370
|
+
*/
|
|
41371
|
+
handleForeachDirective(block, after) {
|
|
41372
|
+
const { args, end } = this.readParenArgs(block, after);
|
|
41373
|
+
const m = /^\s*\$([a-zA-Z_][a-zA-Z_0-9]*)\s+in\s+(.+)$/.exec(args);
|
|
41374
|
+
if (!m) throw new VtlEvaluationError(`Invalid #foreach syntax: ${args}`);
|
|
41375
|
+
const varName = m[1];
|
|
41376
|
+
const listExpr = m[2];
|
|
41377
|
+
const listValue = this.evaluateExpression(listExpr);
|
|
41378
|
+
let depth = 1;
|
|
41379
|
+
let cursor = this.skipDirectiveTrailingNewline(block, end);
|
|
41380
|
+
const bodyStart = cursor;
|
|
41381
|
+
while (cursor < block.length && depth > 0) {
|
|
41382
|
+
if (block[cursor] !== "#") {
|
|
41383
|
+
cursor++;
|
|
41384
|
+
continue;
|
|
41385
|
+
}
|
|
41386
|
+
const tm = /^#([a-zA-Z]+)/.exec(block.slice(cursor));
|
|
41387
|
+
if (!tm) {
|
|
41388
|
+
cursor++;
|
|
41389
|
+
continue;
|
|
41390
|
+
}
|
|
41391
|
+
const tag = tm[1];
|
|
41392
|
+
if (tag === "if" || tag === "foreach") {
|
|
41393
|
+
depth++;
|
|
41394
|
+
cursor += 1 + tag.length;
|
|
41395
|
+
continue;
|
|
41396
|
+
}
|
|
41397
|
+
if (tag === "end") {
|
|
41398
|
+
depth--;
|
|
41399
|
+
if (depth === 0) {
|
|
41400
|
+
const bodyEnd = cursor;
|
|
41401
|
+
const endIdx = this.skipDirectiveTrailingNewline(block, cursor + 1 + tag.length);
|
|
41402
|
+
const items = this.coerceToIterable(listValue);
|
|
41403
|
+
for (const item of items) {
|
|
41404
|
+
this.scopes.push(new Map([[varName, item]]));
|
|
41405
|
+
try {
|
|
41406
|
+
this.renderBlock(block.slice(bodyStart, bodyEnd));
|
|
41407
|
+
} finally {
|
|
41408
|
+
this.scopes.pop();
|
|
41409
|
+
}
|
|
41410
|
+
}
|
|
41411
|
+
return endIdx;
|
|
41412
|
+
}
|
|
41413
|
+
}
|
|
41414
|
+
cursor += 1 + tag.length;
|
|
41415
|
+
}
|
|
41416
|
+
throw new VtlEvaluationError("#foreach without matching #end");
|
|
41417
|
+
}
|
|
41418
|
+
/** Convert a value into an iterable sequence for `#foreach`. */
|
|
41419
|
+
coerceToIterable(value) {
|
|
41420
|
+
if (Array.isArray(value)) return value;
|
|
41421
|
+
if (value && typeof value === "object") return Object.values(value);
|
|
41422
|
+
if (value == null) return [];
|
|
41423
|
+
return [value];
|
|
41424
|
+
}
|
|
41425
|
+
/**
|
|
41426
|
+
* Skip whitespace and a single trailing newline immediately after a
|
|
41427
|
+
* directive — matches Velocity's "directive eats its own newline"
|
|
41428
|
+
* convention. Without this rule, every `#set(...)` line in a template
|
|
41429
|
+
* would leave a blank line in the output.
|
|
41430
|
+
*/
|
|
41431
|
+
skipDirectiveTrailingNewline(block, after) {
|
|
41432
|
+
let i = after;
|
|
41433
|
+
while (i < block.length && (block[i] === " " || block[i] === " ")) i++;
|
|
41434
|
+
if (block[i] === "\r") i++;
|
|
41435
|
+
if (block[i] === "\n") i++;
|
|
41436
|
+
return i;
|
|
41437
|
+
}
|
|
41438
|
+
/**
|
|
41439
|
+
* Read `(...)` arguments after a directive name. Returns the inner
|
|
41440
|
+
* string + the index AFTER the closing paren. Handles nested parens
|
|
41441
|
+
* inside string literals / method calls.
|
|
41442
|
+
*/
|
|
41443
|
+
readParenArgs(block, after) {
|
|
41444
|
+
let i = after;
|
|
41445
|
+
while (i < block.length && (block[i] === " " || block[i] === " ")) i++;
|
|
41446
|
+
if (block[i] !== "(") throw new VtlEvaluationError(`Expected '(' after directive at offset ${after}`);
|
|
41447
|
+
i++;
|
|
41448
|
+
let depth = 1;
|
|
41449
|
+
const start = i;
|
|
41450
|
+
let inString = null;
|
|
41451
|
+
while (i < block.length && depth > 0) {
|
|
41452
|
+
const c = block[i];
|
|
41453
|
+
if (inString) {
|
|
41454
|
+
if (c === "\\" && i + 1 < block.length) {
|
|
41455
|
+
i += 2;
|
|
41456
|
+
continue;
|
|
41457
|
+
}
|
|
41458
|
+
if (c === inString) inString = null;
|
|
41459
|
+
i++;
|
|
41460
|
+
continue;
|
|
41461
|
+
}
|
|
41462
|
+
if (c === "\"" || c === "'") {
|
|
41463
|
+
inString = c;
|
|
41464
|
+
i++;
|
|
41465
|
+
continue;
|
|
41466
|
+
}
|
|
41467
|
+
if (c === "(") depth++;
|
|
41468
|
+
else if (c === ")") depth--;
|
|
41469
|
+
if (depth === 0) break;
|
|
41470
|
+
i++;
|
|
41471
|
+
}
|
|
41472
|
+
if (depth !== 0) throw new VtlEvaluationError(`Unterminated parenthesised argument at offset ${after}`);
|
|
41473
|
+
return {
|
|
41474
|
+
args: block.slice(start, i),
|
|
41475
|
+
end: i + 1
|
|
41476
|
+
};
|
|
41477
|
+
}
|
|
41478
|
+
/**
|
|
41479
|
+
* Handle a `$var` / `${var}` / `$obj.field.method(args)` reference.
|
|
41480
|
+
* Returns the number of characters consumed (0 if not a reference —
|
|
41481
|
+
* caller emits the literal `$`).
|
|
41482
|
+
*/
|
|
41483
|
+
handleVariable(block, start) {
|
|
41484
|
+
const m = /^\$(\{[^}]+\}|[a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)/.exec(block.slice(start));
|
|
41485
|
+
if (!m) return 0;
|
|
41486
|
+
const ref = m[1];
|
|
41487
|
+
const refStr = ref.startsWith("{") ? ref.slice(1, -1) : ref;
|
|
41488
|
+
let consumed = m[0].length;
|
|
41489
|
+
let value = this.resolveReference(refStr);
|
|
41490
|
+
let pos = start + consumed;
|
|
41491
|
+
while (pos < block.length) {
|
|
41492
|
+
if (block[pos] === "(") {
|
|
41493
|
+
const { args, end } = this.readParenArgs(block, pos);
|
|
41494
|
+
value = this.callValueAsMethod(value, args, refStr);
|
|
41495
|
+
consumed = end - start;
|
|
41496
|
+
pos = end;
|
|
41497
|
+
if (pos < block.length && block[pos] === ".") {
|
|
41498
|
+
const tailMatch = /^\.([a-zA-Z_][a-zA-Z_0-9]*)/.exec(block.slice(pos));
|
|
41499
|
+
if (tailMatch) {
|
|
41500
|
+
const field = tailMatch[1];
|
|
41501
|
+
value = lookupField(value, field);
|
|
41502
|
+
consumed += tailMatch[0].length;
|
|
41503
|
+
pos += tailMatch[0].length;
|
|
41504
|
+
continue;
|
|
41505
|
+
}
|
|
41506
|
+
}
|
|
41507
|
+
break;
|
|
41508
|
+
}
|
|
41509
|
+
break;
|
|
41510
|
+
}
|
|
41511
|
+
this.output.push(this.stringifyForOutput(value));
|
|
41512
|
+
return consumed;
|
|
41513
|
+
}
|
|
41514
|
+
/**
|
|
41515
|
+
* Resolve a dotted reference path against context + scopes. The first
|
|
41516
|
+
* segment is matched against built-in roots (`input` / `context` / `util`
|
|
41517
|
+
* / `inputRoot`) and the scope chain in order.
|
|
41518
|
+
*/
|
|
41519
|
+
resolveReference(path) {
|
|
41520
|
+
const parts = path.split(".");
|
|
41521
|
+
const first = parts[0];
|
|
41522
|
+
const rest = parts.slice(1);
|
|
41523
|
+
let base;
|
|
41524
|
+
if (first === "input") base = this.ctx.input;
|
|
41525
|
+
else if (first === "context") base = this.ctx.context;
|
|
41526
|
+
else if (first === "util") base = this.ctx.util;
|
|
41527
|
+
else if (first === "inputRoot") base = this.ctx.inputRoot;
|
|
41528
|
+
else {
|
|
41529
|
+
let found = false;
|
|
41530
|
+
for (let i = this.scopes.length - 1; i >= 0; i--) {
|
|
41531
|
+
const scope = this.scopes[i];
|
|
41532
|
+
if (scope.has(first)) {
|
|
41533
|
+
base = scope.get(first);
|
|
41534
|
+
found = true;
|
|
41535
|
+
break;
|
|
41536
|
+
}
|
|
41537
|
+
}
|
|
41538
|
+
if (!found) return null;
|
|
41539
|
+
}
|
|
41540
|
+
return rest.reduce((acc, seg) => lookupField(acc, seg), base);
|
|
41541
|
+
}
|
|
41542
|
+
/**
|
|
41543
|
+
* Invoke a value as a method — used after a `$ref(args)` shape. The
|
|
41544
|
+
* value must be a function or a special-cased built-in.
|
|
41545
|
+
*/
|
|
41546
|
+
callValueAsMethod(value, argsRaw, refPath) {
|
|
41547
|
+
if (typeof value !== "function") throw new VtlEvaluationError(`Reference '$${refPath}' is not callable (got ${typeof value}). cdkd supports calling $input / $util / $context method-style references only.`);
|
|
41548
|
+
return value(...this.parseArgList(argsRaw));
|
|
41549
|
+
}
|
|
41550
|
+
/**
|
|
41551
|
+
* Parse a comma-separated argument list — recursively evaluates each
|
|
41552
|
+
* expression. Handles string literals, numbers, booleans, and nested
|
|
41553
|
+
* `$var` refs.
|
|
41554
|
+
*/
|
|
41555
|
+
parseArgList(raw) {
|
|
41556
|
+
const trimmed = raw.trim();
|
|
41557
|
+
if (trimmed.length === 0) return [];
|
|
41558
|
+
const parts = [];
|
|
41559
|
+
let depth = 0;
|
|
41560
|
+
let inString = null;
|
|
41561
|
+
let start = 0;
|
|
41562
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
41563
|
+
const c = trimmed[i];
|
|
41564
|
+
if (inString) {
|
|
41565
|
+
if (c === "\\" && i + 1 < trimmed.length) {
|
|
41566
|
+
i++;
|
|
41567
|
+
continue;
|
|
41568
|
+
}
|
|
41569
|
+
if (c === inString) inString = null;
|
|
41570
|
+
continue;
|
|
41571
|
+
}
|
|
41572
|
+
if (c === "\"" || c === "'") {
|
|
41573
|
+
inString = c;
|
|
41574
|
+
continue;
|
|
41575
|
+
}
|
|
41576
|
+
if (c === "(" || c === "[") depth++;
|
|
41577
|
+
else if (c === ")" || c === "]") depth--;
|
|
41578
|
+
else if (c === "," && depth === 0) {
|
|
41579
|
+
parts.push(trimmed.slice(start, i));
|
|
41580
|
+
start = i + 1;
|
|
41581
|
+
}
|
|
41582
|
+
}
|
|
41583
|
+
parts.push(trimmed.slice(start));
|
|
41584
|
+
return parts.map((p) => this.evaluateExpression(p.trim()));
|
|
41585
|
+
}
|
|
41586
|
+
/**
|
|
41587
|
+
* Evaluate a sub-expression (string literal / number / boolean / null /
|
|
41588
|
+
* `$ref` / `$ref.field`). Tiny grammar — no arithmetic operators.
|
|
41589
|
+
*/
|
|
41590
|
+
evaluateExpression(expr) {
|
|
41591
|
+
const trimmed = expr.trim();
|
|
41592
|
+
if (trimmed.length === 0) return null;
|
|
41593
|
+
if (trimmed === "true") return true;
|
|
41594
|
+
if (trimmed === "false") return false;
|
|
41595
|
+
if (trimmed === "null") return null;
|
|
41596
|
+
if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return this.unescapeStringLiteral(trimmed.slice(1, -1));
|
|
41597
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
41598
|
+
if (trimmed.startsWith("$")) {
|
|
41599
|
+
const refMatch = /^\$(\{[^}]+\}|[a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)$/.exec(trimmed);
|
|
41600
|
+
if (refMatch) {
|
|
41601
|
+
const refStr = refMatch[1];
|
|
41602
|
+
const refPath = refStr.startsWith("{") ? refStr.slice(1, -1) : refStr;
|
|
41603
|
+
return this.resolveReference(refPath);
|
|
41604
|
+
}
|
|
41605
|
+
const callMatch = /^\$([a-zA-Z_][a-zA-Z_0-9]*(?:\.[a-zA-Z_][a-zA-Z_0-9]*)*)\((.*)\)$/.exec(trimmed);
|
|
41606
|
+
if (callMatch) {
|
|
41607
|
+
const refPath = callMatch[1];
|
|
41608
|
+
const argsRaw = callMatch[2];
|
|
41609
|
+
const value = this.resolveReference(refPath);
|
|
41610
|
+
return this.callValueAsMethod(value, argsRaw, refPath);
|
|
41611
|
+
}
|
|
41612
|
+
}
|
|
41613
|
+
throw new VtlEvaluationError(`Could not evaluate VTL sub-expression: '${trimmed}'`);
|
|
41614
|
+
}
|
|
41615
|
+
unescapeStringLiteral(s) {
|
|
41616
|
+
return s.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\t/g, " ").replace(/\\\\/g, "\\").replace(/\\"/g, "\"").replace(/\\'/g, "'");
|
|
41617
|
+
}
|
|
41618
|
+
/**
|
|
41619
|
+
* Evaluate a `#if` / `#elseif` condition expression. Supports `&&`,
|
|
41620
|
+
* `||`, `!`, comparison ops, and bare value tests (truthy/falsy).
|
|
41621
|
+
*/
|
|
41622
|
+
evaluateCondition(expr) {
|
|
41623
|
+
const trimmed = expr.trim();
|
|
41624
|
+
const orParts = splitTopLevel(trimmed, "||");
|
|
41625
|
+
if (orParts.length > 1) return orParts.some((p) => this.evaluateCondition(p));
|
|
41626
|
+
const andParts = splitTopLevel(trimmed, "&&");
|
|
41627
|
+
if (andParts.length > 1) return andParts.every((p) => this.evaluateCondition(p));
|
|
41628
|
+
if (trimmed.startsWith("!")) return !this.evaluateCondition(trimmed.slice(1).trim());
|
|
41629
|
+
if (trimmed.startsWith("(") && trimmed.endsWith(")")) return this.evaluateCondition(trimmed.slice(1, -1));
|
|
41630
|
+
for (const op of [
|
|
41631
|
+
"==",
|
|
41632
|
+
"!=",
|
|
41633
|
+
"<=",
|
|
41634
|
+
">=",
|
|
41635
|
+
"<",
|
|
41636
|
+
">"
|
|
41637
|
+
]) {
|
|
41638
|
+
const parts = splitTopLevel(trimmed, op);
|
|
41639
|
+
if (parts.length === 2) return compareValues(this.evaluateExpression(parts[0]), this.evaluateExpression(parts[1]), op);
|
|
41640
|
+
}
|
|
41641
|
+
return isTruthy(this.evaluateExpression(trimmed));
|
|
41642
|
+
}
|
|
41643
|
+
/**
|
|
41644
|
+
* Convert a value to its template output form. Mirrors Velocity's
|
|
41645
|
+
* `toString` convention: `null` → empty string; objects → JSON; numbers
|
|
41646
|
+
* / booleans → standard.
|
|
41647
|
+
*/
|
|
41648
|
+
stringifyForOutput(value) {
|
|
41649
|
+
if (value === null || value === void 0) return "";
|
|
41650
|
+
if (typeof value === "string") return value;
|
|
41651
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
41652
|
+
return JSON.stringify(value);
|
|
41653
|
+
}
|
|
41654
|
+
};
|
|
41655
|
+
/**
|
|
41656
|
+
* Look up `field` on `obj`. Returns `null` for missing fields (Velocity
|
|
41657
|
+
* silent-undefined convention).
|
|
41658
|
+
*/
|
|
41659
|
+
function lookupField(obj, field) {
|
|
41660
|
+
if (obj == null) return null;
|
|
41661
|
+
if (typeof obj === "object") {
|
|
41662
|
+
const rec = obj;
|
|
41663
|
+
if (Object.prototype.hasOwnProperty.call(rec, field)) return rec[field];
|
|
41664
|
+
return null;
|
|
41665
|
+
}
|
|
41666
|
+
return null;
|
|
41667
|
+
}
|
|
41668
|
+
function splitTopLevel(s, sep) {
|
|
41669
|
+
const out = [];
|
|
41670
|
+
let depth = 0;
|
|
41671
|
+
let inString = null;
|
|
41672
|
+
let start = 0;
|
|
41673
|
+
for (let i = 0; i < s.length; i++) {
|
|
41674
|
+
const c = s[i];
|
|
41675
|
+
if (inString) {
|
|
41676
|
+
if (c === "\\" && i + 1 < s.length) {
|
|
41677
|
+
i++;
|
|
41678
|
+
continue;
|
|
41679
|
+
}
|
|
41680
|
+
if (c === inString) inString = null;
|
|
41681
|
+
continue;
|
|
41682
|
+
}
|
|
41683
|
+
if (c === "\"" || c === "'") {
|
|
41684
|
+
inString = c;
|
|
41685
|
+
continue;
|
|
41686
|
+
}
|
|
41687
|
+
if (c === "(" || c === "[") depth++;
|
|
41688
|
+
else if (c === ")" || c === "]") depth--;
|
|
41689
|
+
else if (depth === 0 && s.startsWith(sep, i)) {
|
|
41690
|
+
out.push(s.slice(start, i));
|
|
41691
|
+
start = i + sep.length;
|
|
41692
|
+
i += sep.length - 1;
|
|
41693
|
+
}
|
|
41694
|
+
}
|
|
41695
|
+
out.push(s.slice(start));
|
|
41696
|
+
return out;
|
|
41697
|
+
}
|
|
41698
|
+
function compareValues(lhs, rhs, op) {
|
|
41699
|
+
if (op === "==") return looseEqual(lhs, rhs);
|
|
41700
|
+
if (op === "!=") return !looseEqual(lhs, rhs);
|
|
41701
|
+
const a = typeof lhs === "number" ? lhs : Number(lhs);
|
|
41702
|
+
const b = typeof rhs === "number" ? rhs : Number(rhs);
|
|
41703
|
+
if (Number.isFinite(a) && Number.isFinite(b)) switch (op) {
|
|
41704
|
+
case "<": return a < b;
|
|
41705
|
+
case "<=": return a <= b;
|
|
41706
|
+
case ">": return a > b;
|
|
41707
|
+
case ">=": return a >= b;
|
|
41708
|
+
}
|
|
41709
|
+
const sa = String(lhs);
|
|
41710
|
+
const sb = String(rhs);
|
|
41711
|
+
switch (op) {
|
|
41712
|
+
case "<": return sa < sb;
|
|
41713
|
+
case "<=": return sa <= sb;
|
|
41714
|
+
case ">": return sa > sb;
|
|
41715
|
+
case ">=": return sa >= sb;
|
|
41716
|
+
}
|
|
41717
|
+
}
|
|
41718
|
+
function looseEqual(a, b) {
|
|
41719
|
+
if (a === b) return true;
|
|
41720
|
+
if (a == null || b == null) return a == null && b == null;
|
|
41721
|
+
if (typeof a === typeof b) {
|
|
41722
|
+
if (typeof a === "object") return JSON.stringify(a) === JSON.stringify(b);
|
|
41723
|
+
return false;
|
|
41724
|
+
}
|
|
41725
|
+
return safeStringify(a) === safeStringify(b);
|
|
41726
|
+
}
|
|
41727
|
+
function safeStringify(v) {
|
|
41728
|
+
if (v == null) return "";
|
|
41729
|
+
if (typeof v === "string") return v;
|
|
41730
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
41731
|
+
try {
|
|
41732
|
+
return JSON.stringify(v);
|
|
41733
|
+
} catch {
|
|
41734
|
+
return "";
|
|
41735
|
+
}
|
|
41736
|
+
}
|
|
41737
|
+
function isTruthy(v) {
|
|
41738
|
+
if (v == null) return false;
|
|
41739
|
+
if (typeof v === "boolean") return v;
|
|
41740
|
+
if (typeof v === "number") return v !== 0;
|
|
41741
|
+
if (typeof v === "string") return v.length > 0;
|
|
41742
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
41743
|
+
if (typeof v === "object") return Object.keys(v).length > 0;
|
|
41744
|
+
return true;
|
|
41745
|
+
}
|
|
41746
|
+
/**
|
|
41747
|
+
* Build a `VtlInput` binding from an HTTP request snapshot + matched
|
|
41748
|
+
* route context. `$input` exposes the body + parameter accessors used by
|
|
41749
|
+
* AWS API Gateway's VTL templates.
|
|
41750
|
+
*
|
|
41751
|
+
* `params()` returns the union of header / querystring / path maps;
|
|
41752
|
+
* `params(name)` resolves first against path, then querystring, then
|
|
41753
|
+
* header (matches AWS-deployed precedence).
|
|
41754
|
+
*
|
|
41755
|
+
* `json(jsonPath)` returns a JSON-stringified slice of the parsed body;
|
|
41756
|
+
* `path(jsonPath)` returns the raw native value (primitives unquoted).
|
|
41757
|
+
*
|
|
41758
|
+
* JSONPath support is minimal: supports `$` (root), `$.field`,
|
|
41759
|
+
* `$.field.subField`, `$.array[0]`. AWS supports more (filter
|
|
41760
|
+
* expressions, recursive descent); cdkd surfaces a clear error on
|
|
41761
|
+
* unsupported expressions rather than silently producing wrong output.
|
|
41762
|
+
*/
|
|
41763
|
+
function buildVtlInput(body, headers, querystring, pathParams) {
|
|
41764
|
+
let jsonBodyCache;
|
|
41765
|
+
let jsonBodyParsed = false;
|
|
41766
|
+
function lazyJson() {
|
|
41767
|
+
if (!jsonBodyParsed) {
|
|
41768
|
+
jsonBodyParsed = true;
|
|
41769
|
+
try {
|
|
41770
|
+
jsonBodyCache = body.length === 0 ? null : JSON.parse(body);
|
|
41771
|
+
} catch {
|
|
41772
|
+
jsonBodyCache = null;
|
|
41773
|
+
}
|
|
41774
|
+
}
|
|
41775
|
+
return jsonBodyCache;
|
|
41776
|
+
}
|
|
41777
|
+
function jsonFn(...args) {
|
|
41778
|
+
const expr = args.length > 0 ? String(args[0]) : "$";
|
|
41779
|
+
const val = applyJsonPath(lazyJson(), expr);
|
|
41780
|
+
return JSON.stringify(val ?? null);
|
|
41781
|
+
}
|
|
41782
|
+
function pathFn(...args) {
|
|
41783
|
+
const expr = args.length > 0 ? String(args[0]) : "$";
|
|
41784
|
+
return applyJsonPath(lazyJson(), expr);
|
|
41785
|
+
}
|
|
41786
|
+
function paramsFn(...args) {
|
|
41787
|
+
if (args.length === 0) return {
|
|
41788
|
+
header: headers,
|
|
41789
|
+
querystring,
|
|
41790
|
+
path: pathParams
|
|
41791
|
+
};
|
|
41792
|
+
const arg = String(args[0]);
|
|
41793
|
+
if (arg === "header") return headers;
|
|
41794
|
+
if (arg === "querystring") return querystring;
|
|
41795
|
+
if (arg === "path") return pathParams;
|
|
41796
|
+
if (Object.prototype.hasOwnProperty.call(pathParams, arg)) return pathParams[arg];
|
|
41797
|
+
if (Object.prototype.hasOwnProperty.call(querystring, arg)) return querystring[arg];
|
|
41798
|
+
if (Object.prototype.hasOwnProperty.call(headers, arg)) return headers[arg];
|
|
41799
|
+
const lowerArg = arg.toLowerCase();
|
|
41800
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === lowerArg) return v;
|
|
41801
|
+
return null;
|
|
41802
|
+
}
|
|
41803
|
+
return {
|
|
41804
|
+
body,
|
|
41805
|
+
get jsonBody() {
|
|
41806
|
+
return lazyJson();
|
|
41807
|
+
},
|
|
41808
|
+
headers,
|
|
41809
|
+
querystring,
|
|
41810
|
+
path: pathParams,
|
|
41811
|
+
json: jsonFn,
|
|
41812
|
+
path: pathFn,
|
|
41813
|
+
params: paramsFn
|
|
41814
|
+
};
|
|
41815
|
+
}
|
|
41816
|
+
/**
|
|
41817
|
+
* Minimal JSONPath evaluator. Supports `$`, `$.field`, `$.field.sub`,
|
|
41818
|
+
* `$.array[index]`. Unsupported syntax throws so the user sees a clear
|
|
41819
|
+
* pointer to the gap.
|
|
41820
|
+
*/
|
|
41821
|
+
function applyJsonPath(root, expr) {
|
|
41822
|
+
const trimmed = expr.trim();
|
|
41823
|
+
if (trimmed === "$" || trimmed.length === 0) return root;
|
|
41824
|
+
if (!trimmed.startsWith("$")) throw new VtlEvaluationError(`JSONPath must start with '$': got '${trimmed}'`);
|
|
41825
|
+
let cursor = root;
|
|
41826
|
+
let i = 1;
|
|
41827
|
+
while (i < trimmed.length) {
|
|
41828
|
+
const c = trimmed[i];
|
|
41829
|
+
if (c === ".") {
|
|
41830
|
+
i++;
|
|
41831
|
+
const m = /^[a-zA-Z_][a-zA-Z_0-9]*/.exec(trimmed.slice(i));
|
|
41832
|
+
if (!m) throw new VtlEvaluationError(`Unsupported JSONPath syntax at position ${i}: '${trimmed}' (cdkd supports $, $.field, $.field.sub, $.array[index] only).`);
|
|
41833
|
+
cursor = lookupField(cursor, m[0]);
|
|
41834
|
+
i += m[0].length;
|
|
41835
|
+
continue;
|
|
41836
|
+
}
|
|
41837
|
+
if (c === "[") {
|
|
41838
|
+
const close = trimmed.indexOf("]", i);
|
|
41839
|
+
if (close === -1) throw new VtlEvaluationError(`Unterminated [ in JSONPath: '${trimmed}'`);
|
|
41840
|
+
const inside = trimmed.slice(i + 1, close).trim();
|
|
41841
|
+
if (/^-?\d+$/.test(inside)) {
|
|
41842
|
+
const idx = Number(inside);
|
|
41843
|
+
if (Array.isArray(cursor)) cursor = cursor[idx];
|
|
41844
|
+
else cursor = null;
|
|
41845
|
+
} else if (inside.startsWith("\"") && inside.endsWith("\"") || inside.startsWith("'") && inside.endsWith("'")) cursor = lookupField(cursor, inside.slice(1, -1));
|
|
41846
|
+
else throw new VtlEvaluationError(`Unsupported JSONPath bracket expression: '${inside}' (cdkd supports integer indices and quoted string keys only).`);
|
|
41847
|
+
i = close + 1;
|
|
41848
|
+
continue;
|
|
41849
|
+
}
|
|
41850
|
+
throw new VtlEvaluationError(`Unexpected character in JSONPath at position ${i}: '${trimmed}'`);
|
|
41851
|
+
}
|
|
41852
|
+
return cursor;
|
|
41853
|
+
}
|
|
41854
|
+
/**
|
|
41855
|
+
* Build a `$context` binding from a request snapshot + matched route.
|
|
41856
|
+
* The mapping mirrors what AWS API Gateway exposes (see AWS docs:
|
|
41857
|
+
* https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html).
|
|
41858
|
+
*/
|
|
41859
|
+
function buildVtlRequestContext(args) {
|
|
41860
|
+
return {
|
|
41861
|
+
requestId: args.requestId,
|
|
41862
|
+
httpMethod: args.httpMethod,
|
|
41863
|
+
resourcePath: args.resourcePath,
|
|
41864
|
+
stage: args.stage,
|
|
41865
|
+
identity: {
|
|
41866
|
+
sourceIp: args.sourceIp,
|
|
41867
|
+
userAgent: args.userAgent
|
|
41868
|
+
}
|
|
41869
|
+
};
|
|
41870
|
+
}
|
|
41871
|
+
|
|
41872
|
+
//#endregion
|
|
41873
|
+
//#region src/local/integration-response-selector.ts
|
|
41874
|
+
/**
|
|
41875
|
+
* Pick the right `IntegrationResponses[]` entry for the given outcome.
|
|
41876
|
+
*
|
|
41877
|
+
* Per AWS docs, `SelectionPattern` is matched against the backend
|
|
41878
|
+
* outcome regardless of whether the backend returned success or error —
|
|
41879
|
+
* a `SelectionPattern: '200'` entry IS expected to match an HTTP 200
|
|
41880
|
+
* upstream response. cdkd ALWAYS runs the regex loop first and only
|
|
41881
|
+
* falls to the default entry when no pattern matches; pre-#505-review
|
|
41882
|
+
* the success branch short-circuited to the default entry without
|
|
41883
|
+
* running the regex loop, which silently dropped success-side selection.
|
|
41884
|
+
*
|
|
41885
|
+
* @param entries - The `IntegrationResponses[]` array from the template
|
|
41886
|
+
* (already extracted from the route's Integration property).
|
|
41887
|
+
* @param matchTarget - The string AWS would match `SelectionPattern`
|
|
41888
|
+
* against. For HTTP / HTTP_PROXY this is `String(upstream.status)`;
|
|
41889
|
+
* for Lambda this is the `errorMessage` field on the parsed payload,
|
|
41890
|
+
* or the sentinel `'success'` when the payload has no `errorMessage`.
|
|
41891
|
+
* For MOCK this is unused (MOCK dispatch picks by `StatusCode`).
|
|
41892
|
+
* @param fallbackStatusCode - Status code to use when `entries` is empty
|
|
41893
|
+
* or no entry matches AND no default entry exists. HTTP / HTTP_PROXY
|
|
41894
|
+
* pass the upstream status; Lambda passes 200 on success / 500 on
|
|
41895
|
+
* error.
|
|
41896
|
+
*/
|
|
41897
|
+
function selectIntegrationResponse(entries, matchTarget, fallbackStatusCode = 200) {
|
|
41898
|
+
if (!entries || entries.length === 0) return {
|
|
41899
|
+
entry: null,
|
|
41900
|
+
statusCode: fallbackStatusCode
|
|
41901
|
+
};
|
|
41902
|
+
const defaultEntry = entries.find((e) => e.SelectionPattern === void 0 || e.SelectionPattern === "");
|
|
41903
|
+
for (const entry of entries) {
|
|
41904
|
+
if (entry.SelectionPattern === void 0 || entry.SelectionPattern === "") continue;
|
|
41905
|
+
try {
|
|
41906
|
+
if (new RegExp(`^${entry.SelectionPattern}$`).test(matchTarget)) return {
|
|
41907
|
+
entry,
|
|
41908
|
+
statusCode: parseStatus$1(entry.StatusCode, fallbackStatusCode)
|
|
41909
|
+
};
|
|
41910
|
+
} catch {}
|
|
41911
|
+
}
|
|
41912
|
+
const entry = defaultEntry ?? null;
|
|
41913
|
+
return {
|
|
41914
|
+
entry,
|
|
41915
|
+
statusCode: entry !== null ? parseStatus$1(entry.StatusCode, fallbackStatusCode) : fallbackStatusCode
|
|
41916
|
+
};
|
|
41917
|
+
}
|
|
41918
|
+
function parseStatus$1(raw, fallback) {
|
|
41919
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
41920
|
+
if (typeof raw === "string") {
|
|
41921
|
+
const parsed = Number.parseInt(raw, 10);
|
|
41922
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
41923
|
+
}
|
|
41924
|
+
return fallback;
|
|
41925
|
+
}
|
|
41926
|
+
/**
|
|
41927
|
+
* Evaluate `IntegrationResponse.ResponseParameters` — header literals
|
|
41928
|
+
* mapped onto the HTTP response. Returns `{name: value}` for every entry
|
|
41929
|
+
* we could resolve; unresolvable entries (non-literal / mapping
|
|
41930
|
+
* expression) get a warning via `onUnsupported` and are skipped.
|
|
41931
|
+
*
|
|
41932
|
+
* AWS format: keys are `method.response.header.<HeaderName>`; values
|
|
41933
|
+
* are `'literal'` (with single quotes) or mapping expressions
|
|
41934
|
+
* (`integration.response.body.X` / `integration.response.header.X` /
|
|
41935
|
+
* `context.X`). cdkd v1 supports the literal form only.
|
|
41936
|
+
*/
|
|
41937
|
+
function evaluateResponseParameters(responseParameters, opts = {}) {
|
|
41938
|
+
if (!responseParameters) return {};
|
|
41939
|
+
const out = {};
|
|
41940
|
+
for (const [key, value] of Object.entries(responseParameters)) {
|
|
41941
|
+
const headerMatch = /^method\.response\.header\.(.+)$/.exec(key);
|
|
41942
|
+
if (!headerMatch) {
|
|
41943
|
+
opts.onUnsupported?.(key, value, `Only method.response.header.<name> keys are supported on REST v1 ResponseParameters; cdkd cannot map ${key}.`);
|
|
41944
|
+
continue;
|
|
41945
|
+
}
|
|
41946
|
+
const headerName = headerMatch[1];
|
|
41947
|
+
if (typeof value !== "string") {
|
|
41948
|
+
opts.onUnsupported?.(key, String(value), `non-string ResponseParameter value`);
|
|
41949
|
+
continue;
|
|
41950
|
+
}
|
|
41951
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
|
|
41952
|
+
out[headerName] = value.slice(1, -1);
|
|
41953
|
+
continue;
|
|
41954
|
+
}
|
|
41955
|
+
opts.onUnsupported?.(key, value, `ResponseParameter value '${value}' is a mapping expression (integration.response.* / context.*) which cdkd local start-api does not emulate. Only single-quoted literals are honored.`);
|
|
41956
|
+
}
|
|
41957
|
+
return out;
|
|
41958
|
+
}
|
|
41959
|
+
/**
|
|
41960
|
+
* Pick the response template AWS would render for the given Accept
|
|
41961
|
+
* header. AWS uses content negotiation; cdkd picks `application/json`
|
|
41962
|
+
* first, then any other entry. Returns `undefined` when no template is
|
|
41963
|
+
* configured (caller emits the backend body verbatim).
|
|
41964
|
+
*
|
|
41965
|
+
* The chosen template's content-type is also returned so the dispatcher
|
|
41966
|
+
* can emit a matching `Content-Type` header (matches AWS-deployed
|
|
41967
|
+
* behavior).
|
|
41968
|
+
*/
|
|
41969
|
+
function pickResponseTemplate(responseTemplates, accept) {
|
|
41970
|
+
if (!responseTemplates) return void 0;
|
|
41971
|
+
const entries = Object.entries(responseTemplates);
|
|
41972
|
+
if (entries.length === 0) return void 0;
|
|
41973
|
+
if (accept) {
|
|
41974
|
+
const acceptTypes = accept.split(",").map((s) => s.split(";")[0].trim()).filter(Boolean);
|
|
41975
|
+
for (const acceptType of acceptTypes) for (const [ct, template] of entries) if (ct === acceptType) return {
|
|
41976
|
+
template,
|
|
41977
|
+
contentType: ct
|
|
41978
|
+
};
|
|
41979
|
+
}
|
|
41980
|
+
const jsonEntry = responseTemplates["application/json"];
|
|
41981
|
+
if (jsonEntry !== void 0) return {
|
|
41982
|
+
template: jsonEntry,
|
|
41983
|
+
contentType: "application/json"
|
|
41984
|
+
};
|
|
41985
|
+
const first = entries[0];
|
|
41986
|
+
return {
|
|
41987
|
+
template: first[1],
|
|
41988
|
+
contentType: first[0]
|
|
41989
|
+
};
|
|
41990
|
+
}
|
|
41991
|
+
|
|
41992
|
+
//#endregion
|
|
41993
|
+
//#region src/local/rest-v1-integrations.ts
|
|
41994
|
+
/**
|
|
41995
|
+
* Dispatch a MOCK integration. AWS MOCK semantics:
|
|
41996
|
+
*
|
|
41997
|
+
* 1. Render `RequestTemplates['application/json']` (VTL) against the
|
|
41998
|
+
* request — yields a JSON object like `{"statusCode": 200}`.
|
|
41999
|
+
* 2. Parse the rendered JSON; pick the `IntegrationResponses[]` entry
|
|
42000
|
+
* whose `StatusCode` equals the parsed `statusCode` (string compare,
|
|
42001
|
+
* mirroring AWS).
|
|
42002
|
+
* 3. Render the picked entry's `ResponseTemplates[<content-type>]`
|
|
42003
|
+
* against an empty body context and emit it.
|
|
42004
|
+
* 4. Apply `ResponseParameters` header literals.
|
|
42005
|
+
*
|
|
42006
|
+
* When no request template is configured AWS defaults to picking the
|
|
42007
|
+
* `IntegrationResponses[]` entry with `SelectionPattern === ''` (or the
|
|
42008
|
+
* first entry).
|
|
42009
|
+
*/
|
|
42010
|
+
function dispatchMockIntegration(config, req) {
|
|
42011
|
+
const logger = getLogger().child("start-api");
|
|
42012
|
+
const ctx = buildVtlContextFromRequest(req, "");
|
|
42013
|
+
let pickedStatus;
|
|
42014
|
+
if (config.requestTemplate !== void 0 && config.requestTemplate.trim().length > 0) try {
|
|
42015
|
+
pickedStatus = extractStatusCodeFromRendered(evaluateVtl(config.requestTemplate, ctx));
|
|
42016
|
+
} catch (err) {
|
|
42017
|
+
return vtlFailure("request", err, config.requestTemplate);
|
|
42018
|
+
}
|
|
42019
|
+
let entry = null;
|
|
42020
|
+
if (pickedStatus !== void 0) entry = config.responses.find((e) => parseStatus(e.StatusCode) === pickedStatus) ?? defaultResponseEntry(config.responses);
|
|
42021
|
+
else entry = defaultResponseEntry(config.responses);
|
|
42022
|
+
if (!entry) return {
|
|
42023
|
+
statusCode: pickedStatus ?? 200,
|
|
42024
|
+
headers: { "content-type": "application/json" },
|
|
42025
|
+
body: ""
|
|
42026
|
+
};
|
|
42027
|
+
const accept = req.headers["accept"];
|
|
42028
|
+
const picked = pickResponseTemplate(entry.ResponseTemplates, accept);
|
|
42029
|
+
const respCtx = buildVtlContextFromRequest(req, "", null);
|
|
42030
|
+
let body = "";
|
|
42031
|
+
let contentType = "application/json";
|
|
42032
|
+
if (picked) {
|
|
42033
|
+
try {
|
|
42034
|
+
body = evaluateVtl(picked.template, respCtx);
|
|
42035
|
+
} catch (err) {
|
|
42036
|
+
return vtlFailure("response", err, picked.template);
|
|
42037
|
+
}
|
|
42038
|
+
contentType = picked.contentType;
|
|
42039
|
+
}
|
|
42040
|
+
const headers = { "content-type": contentType };
|
|
42041
|
+
Object.assign(headers, evaluateResponseParameters(entry.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`MOCK response: ${reason}`) }));
|
|
42042
|
+
return {
|
|
42043
|
+
statusCode: parseStatus(entry.StatusCode) ?? 200,
|
|
42044
|
+
headers,
|
|
42045
|
+
body
|
|
42046
|
+
};
|
|
42047
|
+
}
|
|
42048
|
+
/**
|
|
42049
|
+
* Dispatch an HTTP_PROXY integration. The request is forwarded verbatim
|
|
42050
|
+
* with `RequestParameters` mappings applied; the response is also
|
|
42051
|
+
* forwarded verbatim (AWS does NOT apply ResponseTemplates on HTTP_PROXY,
|
|
42052
|
+
* only IntegrationResponses[].SelectionPattern routes the status code).
|
|
42053
|
+
*/
|
|
42054
|
+
async function dispatchHttpProxyIntegration(config, req, deps) {
|
|
42055
|
+
const url = substituteUriPlaceholders(config.uri, req);
|
|
42056
|
+
const method = config.integrationHttpMethod ?? req.method;
|
|
42057
|
+
const outHeaders = { ...req.headers };
|
|
42058
|
+
for (const drop of [
|
|
42059
|
+
"host",
|
|
42060
|
+
"connection",
|
|
42061
|
+
"content-length",
|
|
42062
|
+
"transfer-encoding"
|
|
42063
|
+
]) delete outHeaders[drop];
|
|
42064
|
+
applyRequestParameters(config.requestParameters, req, {
|
|
42065
|
+
headers: outHeaders,
|
|
42066
|
+
urlObj: void 0
|
|
42067
|
+
});
|
|
42068
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
42069
|
+
const fetchInit = {
|
|
42070
|
+
method,
|
|
42071
|
+
headers: outHeaders
|
|
42072
|
+
};
|
|
42073
|
+
if (req.body.length > 0) fetchInit.body = new Uint8Array(req.body);
|
|
42074
|
+
let upstream;
|
|
42075
|
+
try {
|
|
42076
|
+
upstream = await fetchImpl(url, fetchInit);
|
|
42077
|
+
} catch (err) {
|
|
42078
|
+
return {
|
|
42079
|
+
statusCode: 502,
|
|
42080
|
+
headers: { "content-type": "application/json" },
|
|
42081
|
+
body: JSON.stringify({
|
|
42082
|
+
message: "HTTP_PROXY upstream unreachable",
|
|
42083
|
+
url,
|
|
42084
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42085
|
+
})
|
|
42086
|
+
};
|
|
42087
|
+
}
|
|
42088
|
+
const upstreamBody = Buffer.from(await upstream.arrayBuffer());
|
|
42089
|
+
const selected = selectIntegrationResponse(config.responses, String(upstream.status), upstream.status);
|
|
42090
|
+
const headers = {};
|
|
42091
|
+
upstream.headers.forEach((value, name) => {
|
|
42092
|
+
headers[name.toLowerCase()] = value;
|
|
42093
|
+
});
|
|
42094
|
+
delete headers["content-encoding"];
|
|
42095
|
+
delete headers["content-length"];
|
|
42096
|
+
const logger = getLogger().child("start-api");
|
|
42097
|
+
Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`HTTP_PROXY response: ${reason}`) }));
|
|
42098
|
+
return {
|
|
42099
|
+
statusCode: selected.entry ? selected.statusCode : upstream.status,
|
|
42100
|
+
headers,
|
|
42101
|
+
body: upstreamBody
|
|
42102
|
+
};
|
|
42103
|
+
}
|
|
42104
|
+
/**
|
|
42105
|
+
* Dispatch an HTTP (non-proxy) integration: HTTP_PROXY + VTL on both
|
|
42106
|
+
* directions. Same upstream-call shape; the request body is transformed
|
|
42107
|
+
* via VTL, and the response body is transformed via VTL too.
|
|
42108
|
+
*/
|
|
42109
|
+
async function dispatchHttpIntegration(config, req, deps) {
|
|
42110
|
+
const logger = getLogger().child("start-api");
|
|
42111
|
+
const url = substituteUriPlaceholders(config.uri, req);
|
|
42112
|
+
const method = config.integrationHttpMethod ?? req.method;
|
|
42113
|
+
const ctx = buildVtlContextFromRequest(req, req.body.toString("utf-8"));
|
|
42114
|
+
const reqTemplate = pickRequestTemplate(config.requestTemplates, req.headers["content-type"]);
|
|
42115
|
+
let outBody;
|
|
42116
|
+
let outContentType = req.headers["content-type"] ?? "application/json";
|
|
42117
|
+
if (reqTemplate) {
|
|
42118
|
+
try {
|
|
42119
|
+
outBody = evaluateVtl(reqTemplate.template, ctx);
|
|
42120
|
+
} catch (err) {
|
|
42121
|
+
return vtlFailure("request", err, reqTemplate.template);
|
|
42122
|
+
}
|
|
42123
|
+
outContentType = reqTemplate.contentType;
|
|
42124
|
+
} else outBody = req.body.toString("utf-8");
|
|
42125
|
+
const outHeaders = {
|
|
42126
|
+
...req.headers,
|
|
42127
|
+
"content-type": outContentType
|
|
42128
|
+
};
|
|
42129
|
+
for (const drop of [
|
|
42130
|
+
"host",
|
|
42131
|
+
"connection",
|
|
42132
|
+
"content-length",
|
|
42133
|
+
"transfer-encoding"
|
|
42134
|
+
]) delete outHeaders[drop];
|
|
42135
|
+
applyRequestParameters(config.requestParameters, req, {
|
|
42136
|
+
headers: outHeaders,
|
|
42137
|
+
urlObj: void 0
|
|
42138
|
+
});
|
|
42139
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch;
|
|
42140
|
+
const fetchInit = {
|
|
42141
|
+
method,
|
|
42142
|
+
headers: outHeaders
|
|
42143
|
+
};
|
|
42144
|
+
if (outBody !== void 0 && outBody.length > 0) fetchInit.body = outBody;
|
|
42145
|
+
let upstream;
|
|
42146
|
+
try {
|
|
42147
|
+
upstream = await fetchImpl(url, fetchInit);
|
|
42148
|
+
} catch (err) {
|
|
42149
|
+
return {
|
|
42150
|
+
statusCode: 502,
|
|
42151
|
+
headers: { "content-type": "application/json" },
|
|
42152
|
+
body: JSON.stringify({
|
|
42153
|
+
message: "HTTP upstream unreachable",
|
|
42154
|
+
url,
|
|
42155
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42156
|
+
})
|
|
42157
|
+
};
|
|
42158
|
+
}
|
|
42159
|
+
const upstreamContentType = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
42160
|
+
const isUpstreamTextLike = isTextLikeContentType(upstreamContentType);
|
|
42161
|
+
let upstreamText;
|
|
42162
|
+
let upstreamBinary;
|
|
42163
|
+
if (isUpstreamTextLike) upstreamText = await upstream.text();
|
|
42164
|
+
else upstreamBinary = Buffer.from(await upstream.arrayBuffer());
|
|
42165
|
+
const selected = selectIntegrationResponse(config.responses, String(upstream.status), upstream.status);
|
|
42166
|
+
let body;
|
|
42167
|
+
let contentType = upstreamContentType;
|
|
42168
|
+
if (selected.entry) {
|
|
42169
|
+
const picked = pickResponseTemplate(selected.entry.ResponseTemplates, req.headers["accept"]);
|
|
42170
|
+
if (picked) if (upstreamText === void 0) {
|
|
42171
|
+
logger.warn(`HTTP response: ResponseTemplates set but upstream Content-Type '${upstreamContentType}' is binary; passing body through unchanged.`);
|
|
42172
|
+
body = upstreamBinary;
|
|
42173
|
+
} else {
|
|
42174
|
+
const respCtx = buildVtlContextFromRequest(req, upstreamText, safeJsonParse(upstreamText));
|
|
42175
|
+
try {
|
|
42176
|
+
body = evaluateVtl(picked.template, respCtx);
|
|
42177
|
+
} catch (err) {
|
|
42178
|
+
return vtlFailure("response", err, picked.template);
|
|
42179
|
+
}
|
|
42180
|
+
contentType = picked.contentType;
|
|
42181
|
+
}
|
|
42182
|
+
else body = upstreamText ?? upstreamBinary;
|
|
42183
|
+
} else body = upstreamText ?? upstreamBinary;
|
|
42184
|
+
const headers = { "content-type": contentType };
|
|
42185
|
+
Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`HTTP response: ${reason}`) }));
|
|
42186
|
+
return {
|
|
42187
|
+
statusCode: selected.statusCode,
|
|
42188
|
+
headers,
|
|
42189
|
+
body
|
|
42190
|
+
};
|
|
42191
|
+
}
|
|
42192
|
+
/**
|
|
42193
|
+
* Dispatch an AWS (Lambda non-proxy) integration. The request body is
|
|
42194
|
+
* transformed via VTL into the Lambda event; the Lambda is invoked via
|
|
42195
|
+
* RIE; the return value is transformed via ResponseTemplates.
|
|
42196
|
+
*
|
|
42197
|
+
* AWS error routing: when the Lambda returns an object with an
|
|
42198
|
+
* `errorMessage` field (Node Lambda runtime convention), AWS treats it
|
|
42199
|
+
* as an error and matches `SelectionPattern` against the
|
|
42200
|
+
* `errorMessage`. Otherwise success.
|
|
42201
|
+
*/
|
|
42202
|
+
async function dispatchAwsLambdaIntegration(config, req, deps) {
|
|
42203
|
+
const logger = getLogger().child("start-api");
|
|
42204
|
+
const ctx = buildVtlContextFromRequest(req, req.body.toString("utf-8"));
|
|
42205
|
+
const template = pickRequestTemplate(config.requestTemplates, req.headers["content-type"]);
|
|
42206
|
+
let eventPayload;
|
|
42207
|
+
if (template) {
|
|
42208
|
+
let rendered;
|
|
42209
|
+
try {
|
|
42210
|
+
rendered = evaluateVtl(template.template, ctx);
|
|
42211
|
+
} catch (err) {
|
|
42212
|
+
return vtlFailure("request", err, template.template);
|
|
42213
|
+
}
|
|
42214
|
+
try {
|
|
42215
|
+
eventPayload = JSON.parse(rendered);
|
|
42216
|
+
} catch {
|
|
42217
|
+
eventPayload = rendered;
|
|
42218
|
+
}
|
|
42219
|
+
} else eventPayload = safeJsonParse(req.body.toString("utf-8")) ?? req.body.toString("utf-8");
|
|
42220
|
+
let handle;
|
|
42221
|
+
try {
|
|
42222
|
+
handle = await deps.pool.acquire(config.lambdaLogicalId);
|
|
42223
|
+
} catch (err) {
|
|
42224
|
+
return {
|
|
42225
|
+
statusCode: 502,
|
|
42226
|
+
headers: { "content-type": "application/json" },
|
|
42227
|
+
body: JSON.stringify({
|
|
42228
|
+
message: "Failed to acquire RIE container for AWS Lambda non-proxy integration",
|
|
42229
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42230
|
+
})
|
|
42231
|
+
};
|
|
42232
|
+
}
|
|
42233
|
+
let invokeOutcome;
|
|
42234
|
+
try {
|
|
42235
|
+
invokeOutcome = await invokeRie(handle.containerHost, handle.hostPort, eventPayload, deps.rieTimeoutMs);
|
|
42236
|
+
} catch (err) {
|
|
42237
|
+
deps.pool.release(handle);
|
|
42238
|
+
return {
|
|
42239
|
+
statusCode: 502,
|
|
42240
|
+
headers: { "content-type": "application/json" },
|
|
42241
|
+
body: JSON.stringify({
|
|
42242
|
+
message: "AWS Lambda non-proxy invocation failed",
|
|
42243
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
42244
|
+
})
|
|
42245
|
+
};
|
|
42246
|
+
}
|
|
42247
|
+
deps.pool.release(handle);
|
|
42248
|
+
const payload = invokeOutcome.payload;
|
|
42249
|
+
const isError = payload !== null && typeof payload === "object" && "errorMessage" in payload;
|
|
42250
|
+
const matchTarget = isError ? String(payload["errorMessage"]) : "success";
|
|
42251
|
+
const selected = selectIntegrationResponse(config.responses, matchTarget, isError ? 500 : 200);
|
|
42252
|
+
const respCtx = buildVtlContextFromRequest(req, JSON.stringify(payload ?? null), payload);
|
|
42253
|
+
let body = "";
|
|
42254
|
+
let contentType = "application/json";
|
|
42255
|
+
if (selected.entry) {
|
|
42256
|
+
const picked = pickResponseTemplate(selected.entry.ResponseTemplates, req.headers["accept"]);
|
|
42257
|
+
if (picked) {
|
|
42258
|
+
try {
|
|
42259
|
+
body = evaluateVtl(picked.template, respCtx);
|
|
42260
|
+
} catch (err) {
|
|
42261
|
+
return vtlFailure("response", err, picked.template);
|
|
42262
|
+
}
|
|
42263
|
+
contentType = picked.contentType;
|
|
42264
|
+
} else body = JSON.stringify(payload ?? null);
|
|
42265
|
+
} else body = JSON.stringify(payload ?? null);
|
|
42266
|
+
const headers = { "content-type": contentType };
|
|
42267
|
+
Object.assign(headers, evaluateResponseParameters(selected.entry?.ResponseParameters, { onUnsupported: (_k, _v, reason) => logger.warn(`AWS Lambda non-proxy response: ${reason}`) }));
|
|
42268
|
+
return {
|
|
42269
|
+
statusCode: selected.statusCode,
|
|
42270
|
+
headers,
|
|
42271
|
+
body
|
|
42272
|
+
};
|
|
42273
|
+
}
|
|
42274
|
+
function buildVtlContextFromRequest(req, body, inputRoot) {
|
|
42275
|
+
return {
|
|
42276
|
+
input: buildVtlInput(body, req.headers, req.querystring, req.pathParameters),
|
|
42277
|
+
context: buildVtlRequestContext({
|
|
42278
|
+
requestId: req.requestId,
|
|
42279
|
+
httpMethod: req.method,
|
|
42280
|
+
resourcePath: req.resourcePath,
|
|
42281
|
+
stage: req.stage,
|
|
42282
|
+
sourceIp: req.sourceIp,
|
|
42283
|
+
userAgent: req.userAgent
|
|
42284
|
+
}),
|
|
42285
|
+
util: buildDefaultUtil(),
|
|
42286
|
+
...inputRoot !== void 0 && { inputRoot }
|
|
42287
|
+
};
|
|
42288
|
+
}
|
|
42289
|
+
function pickRequestTemplate(requestTemplates, contentType) {
|
|
42290
|
+
if (!requestTemplates) return void 0;
|
|
42291
|
+
const entries = Object.entries(requestTemplates);
|
|
42292
|
+
if (entries.length === 0) return void 0;
|
|
42293
|
+
if (contentType) {
|
|
42294
|
+
const primary = contentType.split(";")[0].trim();
|
|
42295
|
+
if (requestTemplates[primary] !== void 0) return {
|
|
42296
|
+
template: requestTemplates[primary],
|
|
42297
|
+
contentType: primary
|
|
42298
|
+
};
|
|
42299
|
+
}
|
|
42300
|
+
if (requestTemplates["application/json"] !== void 0) return {
|
|
42301
|
+
template: requestTemplates["application/json"],
|
|
42302
|
+
contentType: "application/json"
|
|
42303
|
+
};
|
|
42304
|
+
const first = entries[0];
|
|
42305
|
+
return {
|
|
42306
|
+
template: first[1],
|
|
42307
|
+
contentType: first[0]
|
|
42308
|
+
};
|
|
42309
|
+
}
|
|
42310
|
+
/**
|
|
42311
|
+
* Extract `{"statusCode": <N>}` from a rendered MOCK request template.
|
|
42312
|
+
* AWS uses this single key to drive `IntegrationResponses[]` selection.
|
|
42313
|
+
*/
|
|
42314
|
+
function extractStatusCodeFromRendered(rendered) {
|
|
42315
|
+
try {
|
|
42316
|
+
const parsed = JSON.parse(rendered);
|
|
42317
|
+
if (parsed && typeof parsed === "object" && "statusCode" in parsed) {
|
|
42318
|
+
const val = parsed["statusCode"];
|
|
42319
|
+
if (typeof val === "number") return val;
|
|
42320
|
+
if (typeof val === "string") {
|
|
42321
|
+
const n = Number.parseInt(val, 10);
|
|
42322
|
+
if (Number.isFinite(n)) return n;
|
|
42323
|
+
}
|
|
42324
|
+
}
|
|
42325
|
+
} catch {}
|
|
42326
|
+
}
|
|
42327
|
+
function defaultResponseEntry(entries) {
|
|
42328
|
+
return entries.find((e) => e.SelectionPattern === void 0 || e.SelectionPattern === "") ?? null;
|
|
42329
|
+
}
|
|
42330
|
+
function parseStatus(raw) {
|
|
42331
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
42332
|
+
if (typeof raw === "string") {
|
|
42333
|
+
const n = Number.parseInt(raw, 10);
|
|
42334
|
+
if (Number.isFinite(n)) return n;
|
|
42335
|
+
}
|
|
42336
|
+
}
|
|
42337
|
+
/**
|
|
42338
|
+
* Heuristic: is the given HTTP `Content-Type` header value likely to
|
|
42339
|
+
* carry text content that VTL ResponseTemplates can safely render
|
|
42340
|
+
* against? Used by `dispatchHttpIntegration` to branch the upstream
|
|
42341
|
+
* body read between `.text()` (text-like) and `.arrayBuffer()` (binary
|
|
42342
|
+
* pass-through). Charset parameters are stripped before matching.
|
|
42343
|
+
*
|
|
42344
|
+
* Exported for unit testing.
|
|
42345
|
+
*/
|
|
42346
|
+
function isTextLikeContentType(contentType) {
|
|
42347
|
+
const primary = contentType.split(";")[0].trim().toLowerCase();
|
|
42348
|
+
if (primary.startsWith("text/")) return true;
|
|
42349
|
+
if (primary === "application/json" || primary === "application/xml" || primary === "application/x-www-form-urlencoded" || primary === "application/javascript" || primary === "application/ld+json") return true;
|
|
42350
|
+
if (primary.startsWith("application/") && (primary.endsWith("+json") || primary.endsWith("+xml"))) return true;
|
|
42351
|
+
return false;
|
|
42352
|
+
}
|
|
42353
|
+
/**
|
|
42354
|
+
* Classify a hostname or IP literal against well-known internal address
|
|
42355
|
+
* spaces. Used by `warnSsrfRiskyUri` at server boot to surface a warn
|
|
42356
|
+
* line per HTTP / HTTP_PROXY integration whose URI points at a
|
|
42357
|
+
* potentially-sensitive destination. Best-effort; does NOT do DNS
|
|
42358
|
+
* resolution — only matches hostname literals that are already an IP.
|
|
42359
|
+
*
|
|
42360
|
+
* Returns `undefined` when the host appears safe (public DNS name) OR
|
|
42361
|
+
* cannot be classified (DNS name that may resolve to an internal IP
|
|
42362
|
+
* the helper cannot see without async DNS).
|
|
42363
|
+
*
|
|
42364
|
+
* Exported for unit testing.
|
|
42365
|
+
*/
|
|
42366
|
+
function classifyInternalHost(host) {
|
|
42367
|
+
const h = host.replace(/^\[|\]$/g, "");
|
|
42368
|
+
if (h === "169.254.169.254" || h === "[fd00:ec2::254]" || h === "fd00:ec2::254") return "AWS IMDS (169.254.169.254) — credentials exfiltration risk";
|
|
42369
|
+
if (/^127\.\d+\.\d+\.\d+$/.test(h)) return "IPv4 loopback (127.0.0.0/8)";
|
|
42370
|
+
if (h === "::1") return "IPv6 loopback (::1)";
|
|
42371
|
+
if (/^169\.254\.\d+\.\d+$/.test(h)) return "IPv4 link-local (169.254.0.0/16)";
|
|
42372
|
+
if (/^fe[89ab][0-9a-f]?:/i.test(h)) return "IPv6 link-local (fe80::/10)";
|
|
42373
|
+
if (/^10\.\d+\.\d+\.\d+$/.test(h)) return "RFC1918 private (10.0.0.0/8)";
|
|
42374
|
+
if (/^192\.168\.\d+\.\d+$/.test(h)) return "RFC1918 private (192.168.0.0/16)";
|
|
42375
|
+
const m = /^172\.(\d+)\.\d+\.\d+$/.exec(h);
|
|
42376
|
+
if (m && Number(m[1]) >= 16 && Number(m[1]) <= 31) return "RFC1918 private (172.16.0.0/12)";
|
|
42377
|
+
}
|
|
42378
|
+
/**
|
|
42379
|
+
* Emit a `logger.warn` line for each HTTP / HTTP_PROXY integration
|
|
42380
|
+
* whose `Integration.Uri` parses to a hostname classified as internal
|
|
42381
|
+
* by `classifyInternalHost`. Called once at server boot from
|
|
42382
|
+
* `cdkd local start-api`'s discovery pass; per-route deduplicated.
|
|
42383
|
+
*
|
|
42384
|
+
* cdkd does NOT block the URI — this is a developer-loop tool, not a
|
|
42385
|
+
* security boundary, and warn-and-proceed matches the precedent set by
|
|
42386
|
+
* the cognito JWKS pass-through fallback. The right v2 follow-up is an
|
|
42387
|
+
* `--allow-internal-uri` flag (and an opposite default block) once the
|
|
42388
|
+
* surface is well-understood.
|
|
42389
|
+
*/
|
|
42390
|
+
function warnSsrfRiskyUri(uri, routeLabel, warn) {
|
|
42391
|
+
let host;
|
|
42392
|
+
try {
|
|
42393
|
+
const sanitized = uri.replace(/\{[^/{}]+\}/g, "x");
|
|
42394
|
+
host = new URL(sanitized).hostname;
|
|
42395
|
+
} catch {
|
|
42396
|
+
return;
|
|
42397
|
+
}
|
|
42398
|
+
const classification = classifyInternalHost(host);
|
|
42399
|
+
if (classification !== void 0) warn(`Integration URI for ${routeLabel} points at ${host} — ${classification}. cdkd does NOT block this; ensure the upstream is intentional.`);
|
|
42400
|
+
}
|
|
42401
|
+
function safeJsonParse(s) {
|
|
42402
|
+
try {
|
|
42403
|
+
return JSON.parse(s);
|
|
42404
|
+
} catch {
|
|
42405
|
+
return null;
|
|
42406
|
+
}
|
|
42407
|
+
}
|
|
42408
|
+
/**
|
|
42409
|
+
* Apply `Integration.RequestParameters` mappings — header / query / path
|
|
42410
|
+
* rewrites that copy from `method.request.X` to `integration.request.Y`.
|
|
42411
|
+
*
|
|
42412
|
+
* Supported key shapes:
|
|
42413
|
+
* - `integration.request.header.<name>` → outgoing header
|
|
42414
|
+
* - `integration.request.querystring.<name>` → query string param
|
|
42415
|
+
* - `integration.request.path.<name>` → path placeholder substitution
|
|
42416
|
+
*
|
|
42417
|
+
* Supported value shapes:
|
|
42418
|
+
* - `method.request.header.<name>` → read incoming header
|
|
42419
|
+
* - `method.request.querystring.<name>` → read incoming query param
|
|
42420
|
+
* - `method.request.path.<name>` → read path parameter
|
|
42421
|
+
* - `'literal'` → single-quoted literal
|
|
42422
|
+
*
|
|
42423
|
+
* Unsupported mapping expressions are logged at warn and skipped (matches
|
|
42424
|
+
* the ResponseParameters handling in `integration-response-selector.ts`).
|
|
42425
|
+
*/
|
|
42426
|
+
function applyRequestParameters(requestParameters, req, out) {
|
|
42427
|
+
if (!requestParameters) return;
|
|
42428
|
+
const logger = getLogger().child("start-api");
|
|
42429
|
+
for (const [key, value] of Object.entries(requestParameters)) {
|
|
42430
|
+
const resolved = resolveRequestParameterValue(value, req);
|
|
42431
|
+
if (resolved === void 0) {
|
|
42432
|
+
logger.warn(`RequestParameter '${key}' value '${value}' is not a recognized mapping; skipping.`);
|
|
42433
|
+
continue;
|
|
42434
|
+
}
|
|
42435
|
+
const headerMatch = /^integration\.request\.header\.(.+)$/.exec(key);
|
|
42436
|
+
const queryMatch = /^integration\.request\.querystring\.(.+)$/.exec(key);
|
|
42437
|
+
const pathMatch = /^integration\.request\.path\.(.+)$/.exec(key);
|
|
42438
|
+
if (headerMatch) out.headers[headerMatch[1].toLowerCase()] = resolved;
|
|
42439
|
+
else if (queryMatch) logger.warn(`RequestParameter '${key}' (querystring rewrite) is recognized but cdkd applies querystring rewrites only via URI placeholder substitution; ignoring.`);
|
|
42440
|
+
else if (pathMatch) logger.warn(`RequestParameter '${key}' (path rewrite) is recognized but cdkd substitutes path placeholders via {param} in the URI; ignoring.`);
|
|
42441
|
+
else logger.warn(`Unsupported RequestParameter key '${key}'; skipping.`);
|
|
42442
|
+
}
|
|
42443
|
+
}
|
|
42444
|
+
function resolveRequestParameterValue(raw, req) {
|
|
42445
|
+
if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1);
|
|
42446
|
+
const headerMatch = /^method\.request\.header\.(.+)$/.exec(raw);
|
|
42447
|
+
if (headerMatch) return req.headers[headerMatch[1].toLowerCase()];
|
|
42448
|
+
const queryMatch = /^method\.request\.querystring\.(.+)$/.exec(raw);
|
|
42449
|
+
if (queryMatch) return req.querystring[queryMatch[1]];
|
|
42450
|
+
const pathMatch = /^method\.request\.path\.(.+)$/.exec(raw);
|
|
42451
|
+
if (pathMatch) return req.pathParameters[pathMatch[1]];
|
|
42452
|
+
}
|
|
42453
|
+
/**
|
|
42454
|
+
* Substitute `{paramName}` placeholders in a URI string with the value
|
|
42455
|
+
* of the matching path parameter on the request. Used by HTTP_PROXY /
|
|
42456
|
+
* HTTP integrations whose `Integration.Uri` may contain such
|
|
42457
|
+
* placeholders (e.g. `https://upstream.example.com/users/{userId}`).
|
|
42458
|
+
*/
|
|
42459
|
+
function substituteUriPlaceholders(uri, req) {
|
|
42460
|
+
return uri.replace(/\{([^/{}]+)\}/g, (_, name) => {
|
|
42461
|
+
const val = req.pathParameters[name];
|
|
42462
|
+
return val !== void 0 ? encodeURIComponent(val) : "";
|
|
42463
|
+
});
|
|
42464
|
+
}
|
|
42465
|
+
function vtlFailure(direction, err, template) {
|
|
42466
|
+
const reason = err instanceof VtlEvaluationError ? err.message : err instanceof Error ? err.message : String(err);
|
|
42467
|
+
return {
|
|
42468
|
+
statusCode: 502,
|
|
42469
|
+
headers: { "content-type": "application/json" },
|
|
42470
|
+
body: JSON.stringify({
|
|
42471
|
+
message: `VTL ${direction}-template evaluation failed`,
|
|
42472
|
+
reason,
|
|
42473
|
+
template: template.length > 200 ? template.slice(0, 200) + "..." : template
|
|
42474
|
+
})
|
|
42475
|
+
};
|
|
42476
|
+
}
|
|
42477
|
+
|
|
40893
42478
|
//#endregion
|
|
40894
42479
|
//#region src/local/container-pool.ts
|
|
40895
42480
|
const DEFAULT_IDLE_MS = 6e4;
|
|
@@ -43870,6 +45455,16 @@ async function handleRequest(req, res, state, opts) {
|
|
|
43870
45455
|
const overlay = buildOverlay(authorizer, authResult);
|
|
43871
45456
|
if (overlay) baseEvent = applyAuthorizerOverlay(baseEvent, overlay);
|
|
43872
45457
|
}
|
|
45458
|
+
if (match.route.restV1Integration) {
|
|
45459
|
+
try {
|
|
45460
|
+
writeIntegrationOutcome(res, await dispatchRestV1Integration(match.route.restV1Integration, snapshot, matchCtx, state, opts));
|
|
45461
|
+
} catch (err) {
|
|
45462
|
+
logger.error(`REST v1 ${match.route.restV1Integration.kind} dispatch failed for ${match.route.declaredAt}: ${err instanceof Error ? err.message : String(err)}`);
|
|
45463
|
+
if (!res.headersSent) writeError(res, 502);
|
|
45464
|
+
else res.end();
|
|
45465
|
+
}
|
|
45466
|
+
return;
|
|
45467
|
+
}
|
|
43873
45468
|
let handle;
|
|
43874
45469
|
try {
|
|
43875
45470
|
handle = await state.pool.acquire(match.route.lambdaLogicalId);
|
|
@@ -43949,6 +45544,50 @@ function writeStreamingResponse(res, result, releasePool) {
|
|
|
43949
45544
|
body.pipe(res);
|
|
43950
45545
|
}
|
|
43951
45546
|
/**
|
|
45547
|
+
* Dispatch a REST v1 non-AWS_PROXY integration to the matching handler.
|
|
45548
|
+
* Built once per request from the matched route + request snapshot.
|
|
45549
|
+
*
|
|
45550
|
+
* Returns a {@link RestV1IntegrationOutcome} — the caller writes it
|
|
45551
|
+
* onto the `ServerResponse` via {@link writeIntegrationOutcome}.
|
|
45552
|
+
*/
|
|
45553
|
+
async function dispatchRestV1Integration(integration, snapshot, matchCtx, state, opts) {
|
|
45554
|
+
const headers = lowercaseSingularHeaders(snapshot.headers);
|
|
45555
|
+
const querystring = parseQueryStringSingular(snapshot.rawUrl);
|
|
45556
|
+
const sourceIp = snapshot.sourceIp ?? "127.0.0.1";
|
|
45557
|
+
const userAgent = headers["user-agent"] ?? "";
|
|
45558
|
+
const req = {
|
|
45559
|
+
method: snapshot.method.toUpperCase(),
|
|
45560
|
+
matchedPath: matchCtx.matchedPath,
|
|
45561
|
+
pathParameters: matchCtx.pathParameters,
|
|
45562
|
+
querystring,
|
|
45563
|
+
headers,
|
|
45564
|
+
body: snapshot.body,
|
|
45565
|
+
sourceIp,
|
|
45566
|
+
userAgent,
|
|
45567
|
+
stage: matchCtx.route.stage,
|
|
45568
|
+
resourcePath: matchCtx.route.pathPattern,
|
|
45569
|
+
requestId: randomUUID()
|
|
45570
|
+
};
|
|
45571
|
+
const deps = {
|
|
45572
|
+
pool: state.pool,
|
|
45573
|
+
rieTimeoutMs: opts.rieTimeoutMs
|
|
45574
|
+
};
|
|
45575
|
+
switch (integration.kind) {
|
|
45576
|
+
case "mock": return dispatchMockIntegration(integration, req);
|
|
45577
|
+
case "http-proxy": return await dispatchHttpProxyIntegration(integration, req, deps);
|
|
45578
|
+
case "http": return await dispatchHttpIntegration(integration, req, deps);
|
|
45579
|
+
case "aws-lambda": return await dispatchAwsLambdaIntegration(integration, req, deps);
|
|
45580
|
+
}
|
|
45581
|
+
}
|
|
45582
|
+
/**
|
|
45583
|
+
* Write a {@link RestV1IntegrationOutcome} to the HTTP response.
|
|
45584
|
+
*/
|
|
45585
|
+
function writeIntegrationOutcome(res, outcome) {
|
|
45586
|
+
res.statusCode = outcome.statusCode;
|
|
45587
|
+
for (const [name, value] of Object.entries(outcome.headers)) res.setHeader(name, value);
|
|
45588
|
+
res.end(outcome.body);
|
|
45589
|
+
}
|
|
45590
|
+
/**
|
|
43952
45591
|
* Attempt CORS preflight interception. Returns `true` when the
|
|
43953
45592
|
* preflight response was written (caller must NOT continue to route
|
|
43954
45593
|
* dispatch); `false` when no preflight match (caller falls through to
|
|
@@ -45074,7 +46713,7 @@ async function localStartApiCommand(target, options) {
|
|
|
45074
46713
|
await ensureDockerAvailable();
|
|
45075
46714
|
const appCmd = resolveApp(options.app);
|
|
45076
46715
|
if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
|
|
45077
|
-
const overrides = readEnvOverridesFile$
|
|
46716
|
+
const overrides = readEnvOverridesFile$3(options.envVars);
|
|
45078
46717
|
const debugPortBase = options.debugPortBase ? parseDebugPort(options.debugPortBase) : void 0;
|
|
45079
46718
|
const perLambdaConcurrency = parsePerLambdaConcurrency(options.perLambdaConcurrency);
|
|
45080
46719
|
const inlineTmpDirs = /* @__PURE__ */ new Set();
|
|
@@ -45264,7 +46903,9 @@ async function localStartApiCommand(target, options) {
|
|
|
45264
46903
|
if (basePort !== 0) nextPort += 1;
|
|
45265
46904
|
}
|
|
45266
46905
|
printPerServerRouteTables(servers);
|
|
45267
|
-
|
|
46906
|
+
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
46907
|
+
warnUnsupportedRoutes(allRoutes, logger);
|
|
46908
|
+
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
45268
46909
|
logger.info(`Per-Lambda concurrency: ${perLambdaConcurrency} (override with --per-lambda-concurrency)`);
|
|
45269
46910
|
for (const { group, server } of servers) process.stdout.write(`Server listening on ${server.scheme}://${server.host}:${server.port} (${group.displayName})\n`);
|
|
45270
46911
|
process.stdout.write("^C to stop and clean up containers.\n");
|
|
@@ -45813,12 +47454,26 @@ function printRouteTable(routes) {
|
|
|
45813
47454
|
process.stdout.write("Discovered routes:\n");
|
|
45814
47455
|
for (const r of sorted) {
|
|
45815
47456
|
const sourceLabel = r.source === "http-api" ? "HTTP API" : r.source === "rest-v1" ? `REST v1, stage '${r.stage}'` : "Function URL";
|
|
45816
|
-
const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.lambdaLogicalId;
|
|
47457
|
+
const target = r.mockCors ? "[MOCK CORS preflight]" : r.unsupported ? "[501 Not Implemented]" : r.serviceIntegration ? `[${r.serviceIntegration.subtype}]` : r.restV1Integration ? formatRestV1IntegrationLabel(r.restV1Integration) : r.lambdaLogicalId;
|
|
45817
47458
|
process.stdout.write(` ${r.method.padEnd(methodWidth)} ${r.pathPattern.padEnd(pathWidth)} -> ${target} (${sourceLabel})\n`);
|
|
45818
47459
|
}
|
|
45819
47460
|
process.stdout.write("\n");
|
|
45820
47461
|
}
|
|
45821
47462
|
/**
|
|
47463
|
+
* Format the route-table label for a REST v1 non-AWS_PROXY integration.
|
|
47464
|
+
* `MOCK` / `HTTP` / `HTTP_PROXY` show their integration kind directly;
|
|
47465
|
+
* `AWS` (Lambda non-proxy) shows the Lambda logical id with an `[AWS]`
|
|
47466
|
+
* suffix so it's distinguishable from AWS_PROXY rows. Closes #457.
|
|
47467
|
+
*/
|
|
47468
|
+
function formatRestV1IntegrationLabel(integration) {
|
|
47469
|
+
switch (integration.kind) {
|
|
47470
|
+
case "mock": return "[MOCK]";
|
|
47471
|
+
case "http-proxy": return `[HTTP_PROXY ${integration.uri}]`;
|
|
47472
|
+
case "http": return `[HTTP ${integration.uri}]`;
|
|
47473
|
+
case "aws-lambda": return `${integration.lambdaLogicalId} [AWS]`;
|
|
47474
|
+
}
|
|
47475
|
+
}
|
|
47476
|
+
/**
|
|
45822
47477
|
* Materialize an inline Lambda body (`Code.ZipFile`) to a tmpdir and
|
|
45823
47478
|
* return the directory the container should mount at /var/task.
|
|
45824
47479
|
* Mirrors `cdkd local invoke`'s implementation; the only divergence is
|
|
@@ -45847,7 +47502,7 @@ function getTemplateEnv$1(resource) {
|
|
|
45847
47502
|
return vars;
|
|
45848
47503
|
}
|
|
45849
47504
|
/** Read the SAM-shape `--env-vars` JSON file. */
|
|
45850
|
-
function readEnvOverridesFile$
|
|
47505
|
+
function readEnvOverridesFile$3(filePath) {
|
|
45851
47506
|
if (!filePath) return void 0;
|
|
45852
47507
|
let raw;
|
|
45853
47508
|
try {
|
|
@@ -45972,6 +47627,26 @@ function warnUnsupportedRoutes(routes, logger) {
|
|
|
45972
47627
|
return unsupported.length;
|
|
45973
47628
|
}
|
|
45974
47629
|
/**
|
|
47630
|
+
* Surface a one-line warn per HTTP / HTTP_PROXY integration whose
|
|
47631
|
+
* `Integration.Uri` points at a well-known internal address space
|
|
47632
|
+
* (AWS IMDS, loopback, link-local, RFC1918). PR #505 / issue #457
|
|
47633
|
+
* follow-up: cdkd does NOT block these — warn-and-proceed matches the
|
|
47634
|
+
* cognito JWKS pass-through pattern — but the user should see the
|
|
47635
|
+
* destination at boot so a malicious / typo'd template Uri does not
|
|
47636
|
+
* silently exfiltrate credentials in CI. Deduplicated per-Uri.
|
|
47637
|
+
*/
|
|
47638
|
+
function warnSsrfRiskyIntegrations(routes, logger) {
|
|
47639
|
+
const seen = /* @__PURE__ */ new Set();
|
|
47640
|
+
for (const r of routes) {
|
|
47641
|
+
const integ = r.restV1Integration;
|
|
47642
|
+
if (!integ) continue;
|
|
47643
|
+
if (integ.kind !== "http" && integ.kind !== "http-proxy") continue;
|
|
47644
|
+
if (seen.has(integ.uri)) continue;
|
|
47645
|
+
seen.add(integ.uri);
|
|
47646
|
+
warnSsrfRiskyUri(integ.uri, `${r.method} ${r.pathPattern}`, (msg) => logger.warn(msg));
|
|
47647
|
+
}
|
|
47648
|
+
}
|
|
47649
|
+
/**
|
|
45975
47650
|
* One reload cycle for the multi-server topology (issue #260). The
|
|
45976
47651
|
* watcher serializes calls via a chain promise; this function:
|
|
45977
47652
|
*
|
|
@@ -46020,7 +47695,9 @@ async function reloadAllServers(args) {
|
|
|
46020
47695
|
lastAssetPaths.value = computeAssetPaths(material.specs);
|
|
46021
47696
|
if (watcher) watcher.update([output, ...lastAssetPaths.value]);
|
|
46022
47697
|
printPerServerRouteTables(servers);
|
|
46023
|
-
|
|
47698
|
+
const allRoutes = servers.flatMap((s) => s.group.routes.map((r) => r.route));
|
|
47699
|
+
warnUnsupportedRoutes(allRoutes, logger);
|
|
47700
|
+
warnSsrfRiskyIntegrations(allRoutes, logger);
|
|
46024
47701
|
}
|
|
46025
47702
|
/**
|
|
46026
47703
|
* Returns true when any value in the function's template env map is a
|
|
@@ -46196,14 +47873,38 @@ const execFileAsync$1 = promisify(execFile);
|
|
|
46196
47873
|
/** AWS-published sidecar image (latest tag). amd64 is the only image AWS ships. */
|
|
46197
47874
|
const METADATA_ENDPOINT_IMAGE = "amazon/amazon-ecs-local-container-endpoints:latest-amd64";
|
|
46198
47875
|
/**
|
|
46199
|
-
*
|
|
46200
|
-
* the documented AWS task-metadata endpoint address. Containers
|
|
46201
|
-
* `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<
|
|
46202
|
-
* to reach it.
|
|
47876
|
+
* Default well-known IP for the ECS local-container-endpoints sidecar —
|
|
47877
|
+
* matches the documented AWS task-metadata endpoint address. Containers
|
|
47878
|
+
* inject `ECS_CONTAINER_METADATA_URI_V4=http://169.254.170.2/v4/<id>`
|
|
47879
|
+
* to reach it. `cdkd local run-task` keeps this verbatim; `cdkd local
|
|
47880
|
+
* start-service` allocates a per-replica subnet (via `subnetOctet`) so
|
|
47881
|
+
* concurrent replicas don't collide on a single docker network range.
|
|
46203
47882
|
*/
|
|
46204
47883
|
const METADATA_ENDPOINT_IP = "169.254.170.2";
|
|
46205
|
-
/**
|
|
46206
|
-
const
|
|
47884
|
+
/** Default subnet — used when no `subnetOctet` override is supplied. */
|
|
47885
|
+
const DEFAULT_METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
|
|
47886
|
+
/**
|
|
47887
|
+
* Pure-functional subnet allocator. `cdkd local run-task` uses the
|
|
47888
|
+
* default subnet; `cdkd local start-service` walks `subnetOctet=170,
|
|
47889
|
+
* 171, 172, ...` (one per replica) to keep parallel docker networks
|
|
47890
|
+
* from clashing. The link-local 169.254.0.0/16 space is reserved AWS-
|
|
47891
|
+
* wide for cloud metadata so collisions with user workloads are
|
|
47892
|
+
* unlikely, but each replica still gets its own /24 to ensure
|
|
47893
|
+
* docker's `--subnet` allocator does not reject "Pool overlaps".
|
|
47894
|
+
*
|
|
47895
|
+
* `subnetOctet` is the second-from-last byte of the network: 170 →
|
|
47896
|
+
* 169.254.170.0/24 (default), 171 → 169.254.171.0/24, etc. Valid
|
|
47897
|
+
* range is 1..254; the runner clamps to `(170 + replicaIndex) % 84`
|
|
47898
|
+
* + 170 in practice (rolling window) — exported here so the runner
|
|
47899
|
+
* keeps the allocation logic in one place.
|
|
47900
|
+
*/
|
|
47901
|
+
function buildEndpointSubnet(subnetOctet) {
|
|
47902
|
+
if (subnetOctet < 1 || subnetOctet > 254 || !Number.isInteger(subnetOctet)) throw new Error(`buildEndpointSubnet: subnetOctet must be an integer in 1..254 (got ${subnetOctet}).`);
|
|
47903
|
+
return {
|
|
47904
|
+
cidr: `169.254.${subnetOctet}.0/24`,
|
|
47905
|
+
sidecarIp: `169.254.${subnetOctet}.2`
|
|
47906
|
+
};
|
|
47907
|
+
}
|
|
46207
47908
|
/**
|
|
46208
47909
|
* Create the per-task docker network + start the metadata-endpoints
|
|
46209
47910
|
* sidecar. The sidecar must come up at the well-known address BEFORE any
|
|
@@ -46213,8 +47914,13 @@ const METADATA_ENDPOINT_SUBNET = "169.254.170.0/24";
|
|
|
46213
47914
|
async function createTaskNetwork(options = {}) {
|
|
46214
47915
|
const logger = getLogger().child("ecs-network");
|
|
46215
47916
|
const networkName = `${options.prefix ?? "cdkd-local"}-task-${randomBytes(4).toString("hex")}`;
|
|
47917
|
+
const subnetOctet = options.subnetOctet ?? 170;
|
|
47918
|
+
const { cidr, sidecarIp } = options.subnetOctet === void 0 ? {
|
|
47919
|
+
cidr: DEFAULT_METADATA_ENDPOINT_SUBNET,
|
|
47920
|
+
sidecarIp: METADATA_ENDPOINT_IP
|
|
47921
|
+
} : buildEndpointSubnet(subnetOctet);
|
|
46216
47922
|
await pullImage(METADATA_ENDPOINT_IMAGE, options.skipPull ?? false);
|
|
46217
|
-
logger.info(`Creating docker network ${networkName} (subnet ${
|
|
47923
|
+
logger.info(`Creating docker network ${networkName} (subnet ${cidr})...`);
|
|
46218
47924
|
try {
|
|
46219
47925
|
await execFileAsync$1(getDockerCmd(), [
|
|
46220
47926
|
"network",
|
|
@@ -46222,12 +47928,12 @@ async function createTaskNetwork(options = {}) {
|
|
|
46222
47928
|
"--driver",
|
|
46223
47929
|
"bridge",
|
|
46224
47930
|
"--subnet",
|
|
46225
|
-
|
|
47931
|
+
cidr,
|
|
46226
47932
|
networkName
|
|
46227
47933
|
]);
|
|
46228
47934
|
} catch (err) {
|
|
46229
47935
|
const e = err;
|
|
46230
|
-
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 ${
|
|
47936
|
+
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.`);
|
|
46231
47937
|
}
|
|
46232
47938
|
const sidecarArgs = [
|
|
46233
47939
|
"run",
|
|
@@ -46238,7 +47944,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
46238
47944
|
"--network",
|
|
46239
47945
|
networkName,
|
|
46240
47946
|
"--ip",
|
|
46241
|
-
|
|
47947
|
+
sidecarIp
|
|
46242
47948
|
];
|
|
46243
47949
|
const sidecarEnv = {};
|
|
46244
47950
|
if (options.credentials) {
|
|
@@ -46249,7 +47955,7 @@ async function createTaskNetwork(options = {}) {
|
|
|
46249
47955
|
if (options.cluster) sidecarEnv["CLUSTER"] = options.cluster;
|
|
46250
47956
|
for (const [k, v] of Object.entries(sidecarEnv)) sidecarArgs.push("-e", `${k}=${v}`);
|
|
46251
47957
|
sidecarArgs.push(METADATA_ENDPOINT_IMAGE);
|
|
46252
|
-
logger.info(
|
|
47958
|
+
logger.info(`Starting ECS local-container-endpoints sidecar at ${sidecarIp}...`);
|
|
46253
47959
|
let sidecarContainerId;
|
|
46254
47960
|
try {
|
|
46255
47961
|
const { stdout } = await execFileAsync$1(getDockerCmd(), sidecarArgs, { maxBuffer: 10 * 1024 * 1024 });
|
|
@@ -46261,7 +47967,8 @@ async function createTaskNetwork(options = {}) {
|
|
|
46261
47967
|
}
|
|
46262
47968
|
return {
|
|
46263
47969
|
networkName,
|
|
46264
|
-
sidecarContainerId
|
|
47970
|
+
sidecarContainerId,
|
|
47971
|
+
sidecarIp
|
|
46265
47972
|
};
|
|
46266
47973
|
}
|
|
46267
47974
|
/**
|
|
@@ -46276,9 +47983,10 @@ async function createTaskNetwork(options = {}) {
|
|
|
46276
47983
|
* to whichever credentials AWS SDK chains find).
|
|
46277
47984
|
*/
|
|
46278
47985
|
function buildMetadataEnv(opts) {
|
|
47986
|
+
const ip = opts.sidecarIp ?? "169.254.170.2";
|
|
46279
47987
|
const env = {
|
|
46280
|
-
ECS_CONTAINER_METADATA_URI_V4: `http://${
|
|
46281
|
-
ECS_CONTAINER_METADATA_URI: `http://${
|
|
47988
|
+
ECS_CONTAINER_METADATA_URI_V4: `http://${ip}/v4/${opts.containerName}`,
|
|
47989
|
+
ECS_CONTAINER_METADATA_URI: `http://${ip}/v3/${opts.containerName}`
|
|
46282
47990
|
};
|
|
46283
47991
|
if (opts.roleArn) env["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] = `/role/${encodeURIComponent(opts.roleArn)}`;
|
|
46284
47992
|
if (opts.region) env["AWS_REGION"] = opts.region;
|
|
@@ -46527,6 +48235,7 @@ async function runEcsTask(task, options, state) {
|
|
|
46527
48235
|
};
|
|
46528
48236
|
if (options.taskCredentials) netCreateOpts.credentials = options.taskCredentials;
|
|
46529
48237
|
if (options.cluster) netCreateOpts.cluster = options.cluster;
|
|
48238
|
+
if (options.subnetOctet !== void 0) netCreateOpts.subnetOctet = options.subnetOctet;
|
|
46530
48239
|
state.network = await createTaskNetwork(netCreateOpts);
|
|
46531
48240
|
const volumeByName = await realizeDockerVolumes(task.volumes, state);
|
|
46532
48241
|
const dockerCmds = /* @__PURE__ */ new Map();
|
|
@@ -46544,7 +48253,8 @@ async function runEcsTask(task, options, state) {
|
|
|
46544
48253
|
containerHost: options.containerHost,
|
|
46545
48254
|
roleArn: options.taskRoleArn,
|
|
46546
48255
|
platformOverride: options.platformOverride,
|
|
46547
|
-
region: options.region
|
|
48256
|
+
region: options.region,
|
|
48257
|
+
sidecarIp: state.network.sidecarIp
|
|
46548
48258
|
}));
|
|
46549
48259
|
}
|
|
46550
48260
|
const startedByName = /* @__PURE__ */ new Map();
|
|
@@ -46681,7 +48391,7 @@ async function waitForContainerHealthy(containerId, displayName) {
|
|
|
46681
48391
|
if (err instanceof EcsTaskRunnerError) throw err;
|
|
46682
48392
|
logger.debug(`docker inspect on '${displayName}' failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
46683
48393
|
}
|
|
46684
|
-
await sleep(1e3);
|
|
48394
|
+
await sleep$1(1e3);
|
|
46685
48395
|
}
|
|
46686
48396
|
throw new EcsTaskRunnerError(`Container '${displayName}' did not become healthy within 5 minutes.`);
|
|
46687
48397
|
}
|
|
@@ -46705,7 +48415,7 @@ async function stopContainer(containerId, graceSeconds) {
|
|
|
46705
48415
|
]);
|
|
46706
48416
|
} catch {}
|
|
46707
48417
|
}
|
|
46708
|
-
function sleep(ms) {
|
|
48418
|
+
function sleep$1(ms) {
|
|
46709
48419
|
return new Promise((res) => setTimeout(res, ms));
|
|
46710
48420
|
}
|
|
46711
48421
|
/**
|
|
@@ -46885,7 +48595,8 @@ function buildDockerRunArgs(opts) {
|
|
|
46885
48595
|
const metaEnv = buildMetadataEnv({
|
|
46886
48596
|
containerName: container.name,
|
|
46887
48597
|
...roleArn !== void 0 && { roleArn },
|
|
46888
|
-
...opts.region !== void 0 && { region: opts.region }
|
|
48598
|
+
...opts.region !== void 0 && { region: opts.region },
|
|
48599
|
+
...opts.sidecarIp !== void 0 && { sidecarIp: opts.sidecarIp }
|
|
46889
48600
|
});
|
|
46890
48601
|
Object.assign(finalEnv, metaEnv);
|
|
46891
48602
|
Object.assign(finalEnv, container.environment);
|
|
@@ -46986,7 +48697,7 @@ async function localRunTaskCommand(target, options) {
|
|
|
46986
48697
|
...Object.keys(context).length > 0 && { context }
|
|
46987
48698
|
};
|
|
46988
48699
|
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
46989
|
-
const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
|
|
48700
|
+
const imageContext = await buildEcsImageResolutionContext$1(target, stacks, options);
|
|
46990
48701
|
const task = resolveEcsTaskTarget(target, stacks, imageContext);
|
|
46991
48702
|
logger.info(`Target: ${task.stack.stackName}/${task.taskDefinitionLogicalId} (family=${task.family}, containers=${task.containers.length})`);
|
|
46992
48703
|
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === task.stack.stackName) ?? task.stack);
|
|
@@ -47028,13 +48739,13 @@ async function localRunTaskCommand(target, options) {
|
|
|
47028
48739
|
let resolvedRoleArn;
|
|
47029
48740
|
if (options.assumeTaskRole === true) {
|
|
47030
48741
|
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>");
|
|
47031
|
-
resolvedRoleArn = await resolvePlaceholderAccount(task.taskRoleArn, options.region);
|
|
47032
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
48742
|
+
resolvedRoleArn = await resolvePlaceholderAccount$1(task.taskRoleArn, options.region);
|
|
48743
|
+
assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
|
|
47033
48744
|
} else if (typeof options.assumeTaskRole === "string") {
|
|
47034
48745
|
resolvedRoleArn = options.assumeTaskRole;
|
|
47035
|
-
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
48746
|
+
assumedCredentials = await assumeTaskRole$1(resolvedRoleArn, options.region);
|
|
47036
48747
|
}
|
|
47037
|
-
const envOverrides = readEnvOverridesFile$
|
|
48748
|
+
const envOverrides = readEnvOverridesFile$2(options.envVars);
|
|
47038
48749
|
const runOpts = {
|
|
47039
48750
|
cluster: options.cluster,
|
|
47040
48751
|
containerHost: options.containerHost,
|
|
@@ -47069,7 +48780,7 @@ async function localRunTaskCommand(target, options) {
|
|
|
47069
48780
|
* Lazy: callers should only invoke this when the resolved ARN is actually
|
|
47070
48781
|
* going to be used (i.e. on the bare `--assume-task-role` path).
|
|
47071
48782
|
*/
|
|
47072
|
-
async function resolvePlaceholderAccount(arn, region) {
|
|
48783
|
+
async function resolvePlaceholderAccount$1(arn, region) {
|
|
47073
48784
|
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
47074
48785
|
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
47075
48786
|
const sts = new STSClient({ ...region && { region } });
|
|
@@ -47085,7 +48796,7 @@ async function resolvePlaceholderAccount(arn, region) {
|
|
|
47085
48796
|
* Assume `roleArn` and return temp credentials. Mirrors the same flow
|
|
47086
48797
|
* `cdkd local invoke --assume-role` uses.
|
|
47087
48798
|
*/
|
|
47088
|
-
async function assumeTaskRole(roleArn, region) {
|
|
48799
|
+
async function assumeTaskRole$1(roleArn, region) {
|
|
47089
48800
|
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
47090
48801
|
const sts = new STSClient({ ...region && { region } });
|
|
47091
48802
|
try {
|
|
@@ -47116,9 +48827,9 @@ async function assumeTaskRole(roleArn, region) {
|
|
|
47116
48827
|
* for the candidate stack — same warn-and-fall-back error policy as
|
|
47117
48828
|
* `cdkd local invoke --from-state`.
|
|
47118
48829
|
*/
|
|
47119
|
-
async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
48830
|
+
async function buildEcsImageResolutionContext$1(target, stacks, options) {
|
|
47120
48831
|
const logger = getLogger();
|
|
47121
|
-
const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
|
|
48832
|
+
const candidate = pickCandidateStack$1(parseEcsTarget(target).stackPattern, stacks);
|
|
47122
48833
|
if (!candidate) return void 0;
|
|
47123
48834
|
const needs = detectEcsImageResolutionNeeds(candidate);
|
|
47124
48835
|
if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
|
|
@@ -47129,7 +48840,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
|
47129
48840
|
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.");
|
|
47130
48841
|
let accountId;
|
|
47131
48842
|
try {
|
|
47132
|
-
accountId = await resolveCallerAccountId(region);
|
|
48843
|
+
accountId = await resolveCallerAccountId$1(region);
|
|
47133
48844
|
} catch (err) {
|
|
47134
48845
|
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.`);
|
|
47135
48846
|
}
|
|
@@ -47157,7 +48868,7 @@ async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
|
47157
48868
|
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).");
|
|
47158
48869
|
return ctx;
|
|
47159
48870
|
}
|
|
47160
|
-
function pickCandidateStack(stackPattern, stacks) {
|
|
48871
|
+
function pickCandidateStack$1(stackPattern, stacks) {
|
|
47161
48872
|
if (stackPattern === null) {
|
|
47162
48873
|
if (stacks.length === 1) return stacks[0];
|
|
47163
48874
|
return;
|
|
@@ -47165,7 +48876,7 @@ function pickCandidateStack(stackPattern, stacks) {
|
|
|
47165
48876
|
const matched = matchStacks(stacks, [stackPattern]);
|
|
47166
48877
|
if (matched.length === 1) return matched[0];
|
|
47167
48878
|
}
|
|
47168
|
-
async function resolveCallerAccountId(region) {
|
|
48879
|
+
async function resolveCallerAccountId$1(region) {
|
|
47169
48880
|
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
47170
48881
|
const sts = new STSClient({ ...region && { region } });
|
|
47171
48882
|
try {
|
|
@@ -47179,7 +48890,7 @@ async function resolveCallerAccountId(region) {
|
|
|
47179
48890
|
* `cdkd local invoke --env-vars`: top-level keys are container names, with
|
|
47180
48891
|
* `Parameters` reserved for global entries.
|
|
47181
48892
|
*/
|
|
47182
|
-
function readEnvOverridesFile$
|
|
48893
|
+
function readEnvOverridesFile$2(filePath) {
|
|
47183
48894
|
if (!filePath) return void 0;
|
|
47184
48895
|
let raw;
|
|
47185
48896
|
try {
|
|
@@ -47208,6 +48919,690 @@ function createLocalRunTaskCommand() {
|
|
|
47208
48919
|
return cmd;
|
|
47209
48920
|
}
|
|
47210
48921
|
|
|
48922
|
+
//#endregion
|
|
48923
|
+
//#region src/local/ecs-service-resolver.ts
|
|
48924
|
+
/**
|
|
48925
|
+
* Walk the synth template to locate an `AWS::ECS::Service` by display
|
|
48926
|
+
* path or stack-qualified logical id, resolve its `TaskDefinition`
|
|
48927
|
+
* reference, and chain into the existing `resolveEcsTaskTarget` machinery
|
|
48928
|
+
* to produce a `ResolvedEcsService` carrying both the service knobs and
|
|
48929
|
+
* the underlying task descriptor.
|
|
48930
|
+
*
|
|
48931
|
+
* Target shape mirrors `cdkd local run-task`: `<Stack>/<DisplayPath>` or
|
|
48932
|
+
* `<Stack>:<LogicalId>`; single-stack apps may omit the stack prefix.
|
|
48933
|
+
*
|
|
48934
|
+
* Optional `context` (same as the task resolver) carries the ECR image
|
|
48935
|
+
* substitution data — pseudo parameters (Tier 1) + state-recorded
|
|
48936
|
+
* resources (Tier 2). The CLI builds it lazily when the candidate
|
|
48937
|
+
* service's task definition actually needs substitution.
|
|
48938
|
+
*/
|
|
48939
|
+
function resolveEcsServiceTarget(target, stacks, context) {
|
|
48940
|
+
if (stacks.length === 0) throw new EcsTaskResolutionError("No stacks found in the synthesized assembly.");
|
|
48941
|
+
const parsed = parseEcsTarget(target);
|
|
48942
|
+
const stack = pickStack(parsed, stacks);
|
|
48943
|
+
const resources = stack.template.Resources ?? {};
|
|
48944
|
+
let serviceLogicalId;
|
|
48945
|
+
let serviceResource;
|
|
48946
|
+
if (parsed.isPath) {
|
|
48947
|
+
const index = buildCdkPathIndex(stack.template);
|
|
48948
|
+
const services = resolveCdkPathToLogicalIds(parsed.pathOrId, index).filter(({ logicalId: l }) => resources[l]?.Type === "AWS::ECS::Service");
|
|
48949
|
+
if (services.length === 0) throw notFoundError(target, stack, resources);
|
|
48950
|
+
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.");
|
|
48951
|
+
serviceLogicalId = services[0].logicalId;
|
|
48952
|
+
serviceResource = resources[serviceLogicalId];
|
|
48953
|
+
} else {
|
|
48954
|
+
serviceResource = resources[parsed.pathOrId];
|
|
48955
|
+
if (!serviceResource) throw notFoundError(target, stack, resources);
|
|
48956
|
+
serviceLogicalId = parsed.pathOrId;
|
|
48957
|
+
}
|
|
48958
|
+
if (!serviceLogicalId || !serviceResource) throw notFoundError(target, stack, resources);
|
|
48959
|
+
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.`);
|
|
48960
|
+
if (serviceResource.Type !== "AWS::ECS::Service") throw new EcsTaskResolutionError(`Resource '${serviceLogicalId}' in ${stack.stackName} is ${serviceResource.Type}, not an AWS::ECS::Service.`);
|
|
48961
|
+
return extractServiceProperties(stack, serviceLogicalId, serviceResource, stacks, context);
|
|
48962
|
+
}
|
|
48963
|
+
/**
|
|
48964
|
+
* Pure-functional extraction from the synth resource. Exposed for unit
|
|
48965
|
+
* testing the per-field resolution rules (DesiredCount default, missing
|
|
48966
|
+
* TaskDefinition, intrinsic shapes).
|
|
48967
|
+
*/
|
|
48968
|
+
function extractServiceProperties(stack, serviceLogicalId, resource, stacks, context) {
|
|
48969
|
+
const props = resource.Properties ?? {};
|
|
48970
|
+
const warnings = [];
|
|
48971
|
+
const taskDefRef = props["TaskDefinition"];
|
|
48972
|
+
if (taskDefRef === void 0 || taskDefRef === null) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' in ${stack.stackName} has no TaskDefinition property.`);
|
|
48973
|
+
const taskDefLogicalId = resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId);
|
|
48974
|
+
const task = resolveEcsTaskTarget(`${stack.stackName}:${taskDefLogicalId}`, stacks, context);
|
|
48975
|
+
const desiredCount = parseDesiredCount(props["DesiredCount"], serviceLogicalId);
|
|
48976
|
+
const healthCheckGracePeriodSeconds = parseHealthCheckGrace(props["HealthCheckGracePeriodSeconds"], serviceLogicalId);
|
|
48977
|
+
const serviceName = parseServiceName(props["ServiceName"], serviceLogicalId);
|
|
48978
|
+
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.`);
|
|
48979
|
+
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.`);
|
|
48980
|
+
return {
|
|
48981
|
+
stack,
|
|
48982
|
+
serviceLogicalId,
|
|
48983
|
+
resource,
|
|
48984
|
+
serviceName,
|
|
48985
|
+
desiredCount,
|
|
48986
|
+
healthCheckGracePeriodSeconds,
|
|
48987
|
+
task,
|
|
48988
|
+
warnings
|
|
48989
|
+
};
|
|
48990
|
+
}
|
|
48991
|
+
/**
|
|
48992
|
+
* Resolve `Properties.TaskDefinition` to a logical id in the same stack.
|
|
48993
|
+
* Accepted shapes — verified against real CDK 2.x `cdk synth` output on
|
|
48994
|
+
* 2026-05-22 (per `feedback_verify_cdk_synth_shape_before_resolver.md`):
|
|
48995
|
+
* - `{Ref: '<TaskDefLogicalId>'}` — the CDK-canonical shape emitted by
|
|
48996
|
+
* `new ecs.FargateService({ taskDefinition })`.
|
|
48997
|
+
* - flat string `'<TaskDefLogicalId>'` — accepted defensively but CDK
|
|
48998
|
+
* rarely emits this for cross-resource refs.
|
|
48999
|
+
* Other intrinsic shapes (`Fn::ImportValue` / `Fn::GetAtt` / etc.) are
|
|
49000
|
+
* rejected — cross-stack task definitions and `Fn::GetAtt` shapes have
|
|
49001
|
+
* no clean local resolution and would land here only as user errors.
|
|
49002
|
+
*/
|
|
49003
|
+
function resolveTaskDefinitionReference(taskDefRef, stack, serviceLogicalId) {
|
|
49004
|
+
if (typeof taskDefRef === "string") return taskDefRef;
|
|
49005
|
+
if (taskDefRef && typeof taskDefRef === "object" && !Array.isArray(taskDefRef)) {
|
|
49006
|
+
const refValue = taskDefRef["Ref"];
|
|
49007
|
+
if (typeof refValue === "string") {
|
|
49008
|
+
const target = (stack.template.Resources ?? {})[refValue];
|
|
49009
|
+
if (!target) throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' references TaskDefinition '${refValue}' but no such resource exists in ${stack.stackName}.`);
|
|
49010
|
+
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.`);
|
|
49011
|
+
return refValue;
|
|
49012
|
+
}
|
|
49013
|
+
}
|
|
49014
|
+
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.`);
|
|
49015
|
+
}
|
|
49016
|
+
function parseDesiredCount(raw, serviceLogicalId) {
|
|
49017
|
+
if (raw === void 0 || raw === null) return 1;
|
|
49018
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
|
|
49019
|
+
if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
|
|
49020
|
+
throw new EcsTaskResolutionError(`ECS Service '${serviceLogicalId}' has an unsupported DesiredCount value: ${JSON.stringify(raw)}. Must be a non-negative integer.`);
|
|
49021
|
+
}
|
|
49022
|
+
function parseHealthCheckGrace(raw, _serviceLogicalId) {
|
|
49023
|
+
if (raw === void 0 || raw === null) return 30;
|
|
49024
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
|
|
49025
|
+
if (typeof raw === "string" && /^\d+$/.test(raw)) return parseInt(raw, 10);
|
|
49026
|
+
return 30;
|
|
49027
|
+
}
|
|
49028
|
+
function parseServiceName(raw, serviceLogicalId) {
|
|
49029
|
+
if (typeof raw === "string" && raw.length > 0) return raw;
|
|
49030
|
+
return serviceLogicalId;
|
|
49031
|
+
}
|
|
49032
|
+
/**
|
|
49033
|
+
* Local copy of the same `pickStack` helper used by the task resolver.
|
|
49034
|
+
* Kept in-file rather than exported from `ecs-task-resolver.ts` so future
|
|
49035
|
+
* service-specific extensions (e.g. cross-stack service-to-task refs)
|
|
49036
|
+
* can diverge without breaking the run-task code path.
|
|
49037
|
+
*/
|
|
49038
|
+
function pickStack(parsed, stacks) {
|
|
49039
|
+
if (parsed.stackPattern === null) {
|
|
49040
|
+
if (stacks.length === 1) return stacks[0];
|
|
49041
|
+
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'.`);
|
|
49042
|
+
}
|
|
49043
|
+
const matched = matchStacks(stacks, [parsed.stackPattern]);
|
|
49044
|
+
if (matched.length === 0) throw new EcsTaskResolutionError(`No stack matches '${parsed.stackPattern}'. Available stacks: ${stacks.map((s) => s.stackName).join(", ")}.`);
|
|
49045
|
+
if (matched.length > 1) throw new EcsTaskResolutionError(`Multiple stacks match '${parsed.stackPattern}': ${matched.map((s) => s.stackName).join(", ")}. Refine the pattern.`);
|
|
49046
|
+
return matched[0];
|
|
49047
|
+
}
|
|
49048
|
+
function notFoundError(target, stack, resources) {
|
|
49049
|
+
const services = Object.entries(resources).filter(([, r]) => r.Type === "AWS::ECS::Service").map(([id]) => id);
|
|
49050
|
+
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.`);
|
|
49051
|
+
return new EcsTaskResolutionError(`Target '${target}' did not match any ECS Service in ${stack.stackName}. Available services: ${services.join(", ")}.`);
|
|
49052
|
+
}
|
|
49053
|
+
|
|
49054
|
+
//#endregion
|
|
49055
|
+
//#region src/local/ecs-service-runner.ts
|
|
49056
|
+
/**
|
|
49057
|
+
* Phase 2 of #262 — long-running ECS Service emulator. Wraps the existing
|
|
49058
|
+
* `ecs-task-runner` machinery in a replica pool: N concurrent task
|
|
49059
|
+
* instances per `DesiredCount`, each with its own docker network +
|
|
49060
|
+
* metadata sidecar + container set. Tasks that exit non-zero AFTER the
|
|
49061
|
+
* health-check grace period are restarted with exponential backoff so a
|
|
49062
|
+
* crash-looping container does not hammer docker.
|
|
49063
|
+
*
|
|
49064
|
+
* v1 scope (per the issue's PR-split recommendation):
|
|
49065
|
+
* - Replica pool sizing via `DesiredCount` clamped by `--max-tasks`.
|
|
49066
|
+
* - Restart-on-exit with exponential backoff (1s → 30s, capped) +
|
|
49067
|
+
* a per-instance retry counter so a permanently-broken container
|
|
49068
|
+
* stops compounding cleanup work.
|
|
49069
|
+
* - Long-running lifecycle (returns only on shutdown).
|
|
49070
|
+
*
|
|
49071
|
+
* Deferred to follow-up PRs:
|
|
49072
|
+
* - Local load-balancer emulation (LB listener + target-group health
|
|
49073
|
+
* check + round-robin) — separate PR per the issue's PR-split.
|
|
49074
|
+
* - Service Connect / Cloud Map (tracked in #460).
|
|
49075
|
+
* - Rolling deployment (`--reload` / `--watch`).
|
|
49076
|
+
*/
|
|
49077
|
+
var EcsServiceRunnerError = class EcsServiceRunnerError extends Error {
|
|
49078
|
+
constructor(message) {
|
|
49079
|
+
super(message);
|
|
49080
|
+
this.name = "EcsServiceRunnerError";
|
|
49081
|
+
Object.setPrototypeOf(this, EcsServiceRunnerError.prototype);
|
|
49082
|
+
}
|
|
49083
|
+
};
|
|
49084
|
+
function createServiceRunState() {
|
|
49085
|
+
return {
|
|
49086
|
+
replicas: [],
|
|
49087
|
+
shuttingDown: false
|
|
49088
|
+
};
|
|
49089
|
+
}
|
|
49090
|
+
/**
|
|
49091
|
+
* Compute the effective replica count for a service: the smaller of
|
|
49092
|
+
* `service.desiredCount` and `--max-tasks`, floored at 1. Pure-
|
|
49093
|
+
* functional so the CLI can show the user what cdkd is about to do
|
|
49094
|
+
* before any docker calls fire.
|
|
49095
|
+
*/
|
|
49096
|
+
function computeReplicaCount(desiredCount, maxTasks) {
|
|
49097
|
+
if (maxTasks < 1) throw new EcsServiceRunnerError(`--max-tasks must be >= 1 (got ${maxTasks}); local dev needs at least one running replica.`);
|
|
49098
|
+
if (desiredCount <= 0) return 1;
|
|
49099
|
+
return Math.min(desiredCount, maxTasks);
|
|
49100
|
+
}
|
|
49101
|
+
/**
|
|
49102
|
+
* Exponential backoff schedule: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... Used
|
|
49103
|
+
* between restarts of a crash-looping replica so docker is not hammered
|
|
49104
|
+
* by the watcher loop. Exposed for unit testing.
|
|
49105
|
+
*/
|
|
49106
|
+
function backoffDelayMs(restartCount) {
|
|
49107
|
+
return Math.min(1e3 * Math.pow(2, Math.min(restartCount, 10)), 3e4);
|
|
49108
|
+
}
|
|
49109
|
+
/**
|
|
49110
|
+
* Decide whether a replica that just exited should restart. Pure-
|
|
49111
|
+
* functional so the watcher loop's policy is easy to unit-test.
|
|
49112
|
+
*/
|
|
49113
|
+
function shouldRestart(exitCode, policy) {
|
|
49114
|
+
if (policy === "none") return false;
|
|
49115
|
+
if (policy === "always") return true;
|
|
49116
|
+
return exitCode !== 0;
|
|
49117
|
+
}
|
|
49118
|
+
/**
|
|
49119
|
+
* Long-running entry point. Boots `replicaCount` instances of the
|
|
49120
|
+
* service's task descriptor, returns a controller object the CLI uses
|
|
49121
|
+
* to (1) wait for the first failure that gives up restarting and (2)
|
|
49122
|
+
* shut every replica down on SIGINT / SIGTERM.
|
|
49123
|
+
*
|
|
49124
|
+
* The returned `shutdown()` is idempotent and safe to call from
|
|
49125
|
+
* multiple SIGINT handlers (CLI's single-flight pattern wraps it
|
|
49126
|
+
* anyway).
|
|
49127
|
+
*/
|
|
49128
|
+
async function startEcsService(service, options, runState) {
|
|
49129
|
+
const logger = getLogger().child("ecs-service");
|
|
49130
|
+
for (const w of service.warnings) logger.warn(w);
|
|
49131
|
+
const replicaCount = computeReplicaCount(service.desiredCount, options.maxTasks);
|
|
49132
|
+
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.`);
|
|
49133
|
+
logger.info(`Starting ECS service '${service.serviceName}' with ${replicaCount} replica(s) (restartPolicy=${options.restartPolicy})`);
|
|
49134
|
+
for (let i = 0; i < replicaCount; i++) {
|
|
49135
|
+
const instance = {
|
|
49136
|
+
index: i,
|
|
49137
|
+
state: createEcsRunState(),
|
|
49138
|
+
restartCount: 0,
|
|
49139
|
+
shuttingDown: false,
|
|
49140
|
+
inFlightBoot: void 0
|
|
49141
|
+
};
|
|
49142
|
+
runState.replicas.push(instance);
|
|
49143
|
+
const bootPromise = bootReplica(service, options, instance);
|
|
49144
|
+
instance.inFlightBoot = bootPromise;
|
|
49145
|
+
try {
|
|
49146
|
+
await bootPromise;
|
|
49147
|
+
} catch (err) {
|
|
49148
|
+
instance.lastError = err instanceof Error ? err : new Error(String(err));
|
|
49149
|
+
throw new EcsServiceRunnerError(`Failed to boot replica ${i} of service '${service.serviceName}': ${instance.lastError.message}`);
|
|
49150
|
+
} finally {
|
|
49151
|
+
instance.inFlightBoot = void 0;
|
|
49152
|
+
}
|
|
49153
|
+
}
|
|
49154
|
+
for (const instance of runState.replicas) watchReplica(service, options, instance, runState);
|
|
49155
|
+
return new ServiceController(service, runState, options);
|
|
49156
|
+
}
|
|
49157
|
+
/**
|
|
49158
|
+
* Public controller surface. The CLI awaits `controller.waitForShutdown()`
|
|
49159
|
+
* to block until the user ^Cs. `controller.shutdown()` is wired into the
|
|
49160
|
+
* SIGINT / SIGTERM handlers.
|
|
49161
|
+
*/
|
|
49162
|
+
var ServiceController = class {
|
|
49163
|
+
service;
|
|
49164
|
+
runState;
|
|
49165
|
+
options;
|
|
49166
|
+
shutdownResolve;
|
|
49167
|
+
shutdownPromise;
|
|
49168
|
+
/**
|
|
49169
|
+
* Single-flight wrapper for `shutdown()` so the fan-out cleanup runs
|
|
49170
|
+
* exactly once even when SIGINT and the CLI's outer `finally` both
|
|
49171
|
+
* fire (the canonical pattern documented in
|
|
49172
|
+
* `feedback_sigint_finally_cleanup_singleflight.md`). Built in the
|
|
49173
|
+
* constructor so every call to `shutdown()` resolves against the same
|
|
49174
|
+
* underlying promise.
|
|
49175
|
+
*/
|
|
49176
|
+
runShutdown;
|
|
49177
|
+
constructor(service, runState, options) {
|
|
49178
|
+
this.service = service;
|
|
49179
|
+
this.runState = runState;
|
|
49180
|
+
this.options = options;
|
|
49181
|
+
this.shutdownPromise = new Promise((resolve) => {
|
|
49182
|
+
this.shutdownResolve = resolve;
|
|
49183
|
+
});
|
|
49184
|
+
this.runShutdown = singleFlight(() => this.doShutdown());
|
|
49185
|
+
}
|
|
49186
|
+
/**
|
|
49187
|
+
* Returns the count of currently-active (non-shutting-down) replicas.
|
|
49188
|
+
* Exposed so the CLI can surface a one-line "service is degraded"
|
|
49189
|
+
* banner when restarts stop firing.
|
|
49190
|
+
*/
|
|
49191
|
+
activeReplicaCount() {
|
|
49192
|
+
return this.runState.replicas.filter((r) => !r.shuttingDown).length;
|
|
49193
|
+
}
|
|
49194
|
+
/**
|
|
49195
|
+
* Block until `shutdown()` is called. Used by the CLI as the
|
|
49196
|
+
* long-running blocking point — the SIGINT handler resolves it.
|
|
49197
|
+
*/
|
|
49198
|
+
waitForShutdown() {
|
|
49199
|
+
return this.shutdownPromise;
|
|
49200
|
+
}
|
|
49201
|
+
/**
|
|
49202
|
+
* Idempotent fan-out shutdown across every active replica. Wired into
|
|
49203
|
+
* both SIGINT and the outer `finally` of the CLI command; the
|
|
49204
|
+
* `singleFlight`-wrapped `runShutdown` collapses concurrent / repeated
|
|
49205
|
+
* callers to one underlying invocation.
|
|
49206
|
+
*/
|
|
49207
|
+
async shutdown() {
|
|
49208
|
+
await this.runShutdown();
|
|
49209
|
+
return this.shutdownPromise;
|
|
49210
|
+
}
|
|
49211
|
+
async doShutdown() {
|
|
49212
|
+
this.runState.shuttingDown = true;
|
|
49213
|
+
const logger = getLogger().child("ecs-service");
|
|
49214
|
+
logger.info(`Shutting down service '${this.service.serviceName}'...`);
|
|
49215
|
+
for (const r of this.runState.replicas) r.shuttingDown = true;
|
|
49216
|
+
const inFlightBoots = this.runState.replicas.map((r) => r.inFlightBoot).filter((p) => p !== void 0);
|
|
49217
|
+
if (inFlightBoots.length > 0) {
|
|
49218
|
+
logger.debug(`Awaiting ${inFlightBoots.length} in-flight bootReplica() call(s) before cleanup...`);
|
|
49219
|
+
await Promise.allSettled(inFlightBoots);
|
|
49220
|
+
}
|
|
49221
|
+
await Promise.allSettled(this.runState.replicas.map(async (instance) => {
|
|
49222
|
+
try {
|
|
49223
|
+
await cleanupEcsRun(instance.state, { keepRunning: this.options.taskOptions.keepRunning });
|
|
49224
|
+
} catch (err) {
|
|
49225
|
+
logger.debug(`Replica ${instance.index} cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
49226
|
+
}
|
|
49227
|
+
}));
|
|
49228
|
+
this.shutdownResolve?.();
|
|
49229
|
+
}
|
|
49230
|
+
};
|
|
49231
|
+
/**
|
|
49232
|
+
* Boot a single replica. Mutates the supplied `instance.state` so the
|
|
49233
|
+
* shutdown path's `cleanupEcsRun(instance.state)` covers every partial
|
|
49234
|
+
* side effect. Network names are suffixed with the replica index so
|
|
49235
|
+
* docker doesn't collide on shared per-task network names when N > 1.
|
|
49236
|
+
*/
|
|
49237
|
+
async function bootReplica(service, options, instance) {
|
|
49238
|
+
const logger = getLogger().child("ecs-service");
|
|
49239
|
+
const perReplicaCluster = `${options.taskOptions.cluster}-svc-${service.serviceLogicalId.toLowerCase()}-r${instance.index}`;
|
|
49240
|
+
const perReplicaSubnetOctet = 170 + instance.index % 84;
|
|
49241
|
+
const perReplicaTaskOptions = {
|
|
49242
|
+
...options.taskOptions,
|
|
49243
|
+
cluster: perReplicaCluster,
|
|
49244
|
+
subnetOctet: perReplicaSubnetOctet,
|
|
49245
|
+
detach: true
|
|
49246
|
+
};
|
|
49247
|
+
logger.info(`Booting replica ${instance.index} (${perReplicaCluster})`);
|
|
49248
|
+
await runEcsTask(service.task, perReplicaTaskOptions, instance.state);
|
|
49249
|
+
}
|
|
49250
|
+
/**
|
|
49251
|
+
* Long-running watcher loop for one replica. Polls the essential
|
|
49252
|
+
* container's exit code via `docker wait`; on exit, decides whether to
|
|
49253
|
+
* restart per `restartPolicy` + applies exponential backoff. The loop
|
|
49254
|
+
* exits only when the replica's `shuttingDown` flag is set.
|
|
49255
|
+
*/
|
|
49256
|
+
async function watchReplica(service, options, instance, runState) {
|
|
49257
|
+
const logger = getLogger().child("ecs-service");
|
|
49258
|
+
while (!instance.shuttingDown && !runState.shuttingDown) {
|
|
49259
|
+
const essentialId = pickEssentialContainerId(instance, service);
|
|
49260
|
+
if (!essentialId) {
|
|
49261
|
+
await sleep(500);
|
|
49262
|
+
continue;
|
|
49263
|
+
}
|
|
49264
|
+
let exitCode;
|
|
49265
|
+
try {
|
|
49266
|
+
exitCode = await waitForExitImpl(essentialId);
|
|
49267
|
+
} catch (err) {
|
|
49268
|
+
logger.debug(`docker wait failed for replica ${instance.index}: ${err instanceof Error ? err.message : String(err)}`);
|
|
49269
|
+
exitCode = -1;
|
|
49270
|
+
}
|
|
49271
|
+
if (instance.shuttingDown || runState.shuttingDown) return;
|
|
49272
|
+
logger.warn(`Replica ${instance.index} essential container exited with code ${exitCode} (restartCount=${instance.restartCount}).`);
|
|
49273
|
+
if (!shouldRestart(exitCode, options.restartPolicy)) {
|
|
49274
|
+
logger.warn(`Replica ${instance.index} not restarting (policy=${options.restartPolicy}, exit=${exitCode}). Service running in degraded mode.`);
|
|
49275
|
+
instance.shuttingDown = true;
|
|
49276
|
+
return;
|
|
49277
|
+
}
|
|
49278
|
+
const delay = backoffDelayMs(instance.restartCount);
|
|
49279
|
+
logger.info(`Restarting replica ${instance.index} in ${delay}ms...`);
|
|
49280
|
+
await sleep(delay);
|
|
49281
|
+
if (instance.shuttingDown || runState.shuttingDown) return;
|
|
49282
|
+
try {
|
|
49283
|
+
await cleanupEcsRun(instance.state, { keepRunning: false });
|
|
49284
|
+
} catch (err) {
|
|
49285
|
+
logger.debug(`Replica ${instance.index} pre-restart cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
49286
|
+
}
|
|
49287
|
+
instance.state = createEcsRunState();
|
|
49288
|
+
instance.restartCount += 1;
|
|
49289
|
+
const bootPromise = bootReplica(service, options, instance);
|
|
49290
|
+
instance.inFlightBoot = bootPromise;
|
|
49291
|
+
try {
|
|
49292
|
+
await bootPromise;
|
|
49293
|
+
} catch (err) {
|
|
49294
|
+
instance.lastError = err instanceof Error ? err : new Error(String(err));
|
|
49295
|
+
logger.error(`Replica ${instance.index} restart failed: ${instance.lastError.message}. Service running in degraded mode.`);
|
|
49296
|
+
instance.shuttingDown = true;
|
|
49297
|
+
return;
|
|
49298
|
+
} finally {
|
|
49299
|
+
instance.inFlightBoot = void 0;
|
|
49300
|
+
}
|
|
49301
|
+
}
|
|
49302
|
+
}
|
|
49303
|
+
function pickEssentialContainerId(instance, service) {
|
|
49304
|
+
if (service) {
|
|
49305
|
+
const essential = service.task.containers.find((c) => c.essential) ?? service.task.containers[0];
|
|
49306
|
+
if (essential) {
|
|
49307
|
+
const started = instance.state.startedContainers.find((c) => c.name === essential.name);
|
|
49308
|
+
if (started) return started.id;
|
|
49309
|
+
}
|
|
49310
|
+
}
|
|
49311
|
+
return instance.state.startedContainers[0]?.id;
|
|
49312
|
+
}
|
|
49313
|
+
/**
|
|
49314
|
+
* Production `docker wait <id>` implementation. Captured once so the
|
|
49315
|
+
* test override can restore it without duplicating the body.
|
|
49316
|
+
*/
|
|
49317
|
+
const defaultWaitForExitImpl = async (containerId) => {
|
|
49318
|
+
const { execFile } = await import("node:child_process");
|
|
49319
|
+
const { promisify } = await import("node:util");
|
|
49320
|
+
const { getDockerCmd } = await import("./docker-cmd-EtWSTAje.js").then((n) => n.t);
|
|
49321
|
+
const { stdout } = await promisify(execFile)(getDockerCmd(), ["wait", containerId], { maxBuffer: 1024 * 1024 });
|
|
49322
|
+
const code = parseInt(stdout.trim(), 10);
|
|
49323
|
+
return Number.isFinite(code) ? code : -1;
|
|
49324
|
+
};
|
|
49325
|
+
/**
|
|
49326
|
+
* `docker wait <id>` returns the exit code on stdout. Extracted as a
|
|
49327
|
+
* test-overridable function so unit tests do not need a real container.
|
|
49328
|
+
*/
|
|
49329
|
+
let waitForExitImpl = defaultWaitForExitImpl;
|
|
49330
|
+
function sleep(ms) {
|
|
49331
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
49332
|
+
}
|
|
49333
|
+
|
|
49334
|
+
//#endregion
|
|
49335
|
+
//#region src/cli/commands/local-start-service.ts
|
|
49336
|
+
/**
|
|
49337
|
+
* `cdkd local start-service <Stack/Service>` — Phase 2 of #262. Spins up
|
|
49338
|
+
* `DesiredCount` task replicas locally (clamped by `--max-tasks`) using
|
|
49339
|
+
* the existing `ecs-task-runner` per replica. Long-running; ^C cleans
|
|
49340
|
+
* every replica + sidecar + per-task network.
|
|
49341
|
+
*
|
|
49342
|
+
* Deferred to follow-up PRs (matches the issue's PR-split):
|
|
49343
|
+
* - Local LB emulator (listener + round-robin + target-group health
|
|
49344
|
+
* check) — PR C of #466.
|
|
49345
|
+
* - Rolling deployment (`--watch` / `--reload`) — PR D of #466.
|
|
49346
|
+
* - Service Connect / Cloud Map — tracked separately in #460.
|
|
49347
|
+
*/
|
|
49348
|
+
async function localStartServiceCommand(target, options) {
|
|
49349
|
+
const logger = getLogger();
|
|
49350
|
+
if (options.verbose) logger.setLevel("debug");
|
|
49351
|
+
warnIfDeprecatedRegion(options);
|
|
49352
|
+
const runState = createServiceRunState();
|
|
49353
|
+
let sigintHandler;
|
|
49354
|
+
let sigintCount = 0;
|
|
49355
|
+
let controller;
|
|
49356
|
+
const cleanup = singleFlight(async () => {
|
|
49357
|
+
if (controller) await controller.shutdown();
|
|
49358
|
+
else await Promise.allSettled(runState.replicas.map((r) => cleanupEcsRun(r.state, { keepRunning: false }).catch(() => void 0)));
|
|
49359
|
+
}, (err) => getLogger().debug(`service cleanup failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
49360
|
+
try {
|
|
49361
|
+
await applyRoleArnIfSet({
|
|
49362
|
+
roleArn: options.roleArn,
|
|
49363
|
+
region: options.region
|
|
49364
|
+
});
|
|
49365
|
+
await ensureDockerAvailable();
|
|
49366
|
+
const appCmd = resolveApp(options.app);
|
|
49367
|
+
if (!appCmd) throw new Error("No CDK app specified. Pass --app, set CDKD_APP, or add \"app\" to cdk.json.");
|
|
49368
|
+
logger.info("Synthesizing CDK app...");
|
|
49369
|
+
const synthesizer = new Synthesizer();
|
|
49370
|
+
const context = parseContextOptions(options.context);
|
|
49371
|
+
const synthOpts = {
|
|
49372
|
+
app: appCmd,
|
|
49373
|
+
output: options.output,
|
|
49374
|
+
...options.region && { region: options.region },
|
|
49375
|
+
...options.profile && { profile: options.profile },
|
|
49376
|
+
...Object.keys(context).length > 0 && { context }
|
|
49377
|
+
};
|
|
49378
|
+
const { stacks } = await synthesizer.synthesize(synthOpts);
|
|
49379
|
+
const imageContext = await buildEcsImageResolutionContext(target, stacks, options);
|
|
49380
|
+
const service = resolveEcsServiceTarget(target, stacks, imageContext);
|
|
49381
|
+
logger.info(`Target: ${service.stack.stackName}/${service.serviceLogicalId} (service=${service.serviceName}, desiredCount=${service.desiredCount}, task=${service.task.taskDefinitionLogicalId})`);
|
|
49382
|
+
const taskNeeds = detectEcsImageResolutionNeeds(stacks.find((s) => s.stackName === service.stack.stackName) ?? service.stack);
|
|
49383
|
+
if (options.fromState && taskNeeds.needsCrossStackResolver) {
|
|
49384
|
+
const consumerRegion = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? service.stack.region ?? "us-east-1";
|
|
49385
|
+
const built = await buildCrossStackResolver(consumerRegion, {
|
|
49386
|
+
...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
|
|
49387
|
+
statePrefix: options.statePrefix,
|
|
49388
|
+
...options.region !== void 0 && { region: options.region },
|
|
49389
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
49390
|
+
});
|
|
49391
|
+
if (built) try {
|
|
49392
|
+
const subContext = {
|
|
49393
|
+
resources: imageContext?.stateResources ?? {},
|
|
49394
|
+
...imageContext?.pseudoParameters && { pseudoParameters: imageContext.pseudoParameters },
|
|
49395
|
+
consumerRegion,
|
|
49396
|
+
crossStackResolver: built.resolver
|
|
49397
|
+
};
|
|
49398
|
+
await applyCrossStackResolverToTask(service.task, subContext);
|
|
49399
|
+
} finally {
|
|
49400
|
+
built.dispose();
|
|
49401
|
+
}
|
|
49402
|
+
} 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.");
|
|
49403
|
+
sigintHandler = () => {
|
|
49404
|
+
sigintCount += 1;
|
|
49405
|
+
if (sigintCount >= 2) {
|
|
49406
|
+
process.stderr.write("Force-exit on second ^C; container cleanup skipped.\n");
|
|
49407
|
+
process.exit(130);
|
|
49408
|
+
}
|
|
49409
|
+
logger.info("Stopping service...");
|
|
49410
|
+
cleanup().then(() => process.exit(130));
|
|
49411
|
+
};
|
|
49412
|
+
process.on("SIGINT", sigintHandler);
|
|
49413
|
+
process.on("SIGTERM", sigintHandler);
|
|
49414
|
+
let assumedCredentials;
|
|
49415
|
+
let resolvedRoleArn;
|
|
49416
|
+
if (options.assumeTaskRole === true) {
|
|
49417
|
+
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>");
|
|
49418
|
+
resolvedRoleArn = await resolvePlaceholderAccount(service.task.taskRoleArn, options.region);
|
|
49419
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
49420
|
+
} else if (typeof options.assumeTaskRole === "string") {
|
|
49421
|
+
resolvedRoleArn = options.assumeTaskRole;
|
|
49422
|
+
assumedCredentials = await assumeTaskRole(resolvedRoleArn, options.region);
|
|
49423
|
+
}
|
|
49424
|
+
const envOverrides = readEnvOverridesFile$1(options.envVars);
|
|
49425
|
+
const taskOpts = {
|
|
49426
|
+
cluster: options.cluster,
|
|
49427
|
+
containerHost: options.containerHost,
|
|
49428
|
+
skipPull: options.pull === false,
|
|
49429
|
+
keepRunning: false,
|
|
49430
|
+
detach: true
|
|
49431
|
+
};
|
|
49432
|
+
if (envOverrides) taskOpts.envOverrides = envOverrides;
|
|
49433
|
+
if (assumedCredentials) taskOpts.taskCredentials = assumedCredentials;
|
|
49434
|
+
if (resolvedRoleArn) taskOpts.taskRoleArn = resolvedRoleArn;
|
|
49435
|
+
if (options.platform) taskOpts.platformOverride = options.platform;
|
|
49436
|
+
if (options.region) taskOpts.region = options.region;
|
|
49437
|
+
if (options.ecrRoleArn) taskOpts.ecrRoleArn = options.ecrRoleArn;
|
|
49438
|
+
controller = await startEcsService(service, {
|
|
49439
|
+
maxTasks: options.maxTasks,
|
|
49440
|
+
restartPolicy: options.restartPolicy,
|
|
49441
|
+
taskOptions: taskOpts
|
|
49442
|
+
}, runState);
|
|
49443
|
+
logger.info(`Service '${service.serviceName}' running with ${controller.activeReplicaCount()} active replica(s). Press ^C to shut down.`);
|
|
49444
|
+
await controller.waitForShutdown();
|
|
49445
|
+
} finally {
|
|
49446
|
+
if (sigintHandler) {
|
|
49447
|
+
process.off("SIGINT", sigintHandler);
|
|
49448
|
+
process.off("SIGTERM", sigintHandler);
|
|
49449
|
+
}
|
|
49450
|
+
await cleanup();
|
|
49451
|
+
}
|
|
49452
|
+
}
|
|
49453
|
+
async function resolvePlaceholderAccount(arn, region) {
|
|
49454
|
+
if (!arn.includes("${AWS::AccountId}")) return arn;
|
|
49455
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
49456
|
+
const sts = new STSClient({ ...region && { region } });
|
|
49457
|
+
try {
|
|
49458
|
+
const account = (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
49459
|
+
if (!account) throw new LocalStartServiceError(`--assume-task-role: GetCallerIdentity returned no Account; cannot resolve placeholder ARN '${arn}'.`);
|
|
49460
|
+
return arn.split(TASK_ROLE_ACCOUNT_PLACEHOLDER).join(account);
|
|
49461
|
+
} finally {
|
|
49462
|
+
sts.destroy();
|
|
49463
|
+
}
|
|
49464
|
+
}
|
|
49465
|
+
async function assumeTaskRole(roleArn, region) {
|
|
49466
|
+
const { STSClient, AssumeRoleCommand } = await import("@aws-sdk/client-sts");
|
|
49467
|
+
const sts = new STSClient({ ...region && { region } });
|
|
49468
|
+
try {
|
|
49469
|
+
const creds = (await sts.send(new AssumeRoleCommand({
|
|
49470
|
+
RoleArn: roleArn,
|
|
49471
|
+
RoleSessionName: `cdkd-local-start-service-${Date.now()}`,
|
|
49472
|
+
DurationSeconds: 3600
|
|
49473
|
+
}))).Credentials;
|
|
49474
|
+
if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) throw new LocalStartServiceError(`AssumeRole(${roleArn}) returned no usable credentials.`);
|
|
49475
|
+
return {
|
|
49476
|
+
accessKeyId: creds.AccessKeyId,
|
|
49477
|
+
secretAccessKey: creds.SecretAccessKey,
|
|
49478
|
+
sessionToken: creds.SessionToken
|
|
49479
|
+
};
|
|
49480
|
+
} finally {
|
|
49481
|
+
sts.destroy();
|
|
49482
|
+
}
|
|
49483
|
+
}
|
|
49484
|
+
/**
|
|
49485
|
+
* Build the substitution context the ECS resolver consumes. Identical
|
|
49486
|
+
* shape to `local-run-task.ts:buildEcsImageResolutionContext` — only
|
|
49487
|
+
* the candidate stack picker differs because services and tasks share
|
|
49488
|
+
* the same stack-pattern grammar.
|
|
49489
|
+
*/
|
|
49490
|
+
async function buildEcsImageResolutionContext(target, stacks, options) {
|
|
49491
|
+
const logger = getLogger();
|
|
49492
|
+
const candidate = pickCandidateStack(parseEcsTarget(target).stackPattern, stacks);
|
|
49493
|
+
if (!candidate) return void 0;
|
|
49494
|
+
const needs = detectEcsImageResolutionNeeds(candidate);
|
|
49495
|
+
if (!needs.needsPseudoParameters && !needs.needsStateResources && !needs.needsEnvOrSecretSubstitution) return;
|
|
49496
|
+
const ctx = {};
|
|
49497
|
+
const wantsPseudoForEnvOrSecret = options.fromState && needs.needsEnvOrSecretSubstitution;
|
|
49498
|
+
if (needs.needsPseudoParameters || wantsPseudoForEnvOrSecret) {
|
|
49499
|
+
const region = options.region ?? process.env["AWS_REGION"] ?? process.env["AWS_DEFAULT_REGION"] ?? candidate.region;
|
|
49500
|
+
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.");
|
|
49501
|
+
let accountId;
|
|
49502
|
+
try {
|
|
49503
|
+
accountId = await resolveCallerAccountId(region);
|
|
49504
|
+
} catch (err) {
|
|
49505
|
+
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.`);
|
|
49506
|
+
}
|
|
49507
|
+
const partitionAndSuffix = region ? derivePartitionAndUrlSuffix(region) : void 0;
|
|
49508
|
+
ctx.pseudoParameters = {
|
|
49509
|
+
...accountId !== void 0 && { accountId },
|
|
49510
|
+
...region !== void 0 && { region },
|
|
49511
|
+
...partitionAndSuffix && {
|
|
49512
|
+
partition: partitionAndSuffix.partition,
|
|
49513
|
+
urlSuffix: partitionAndSuffix.urlSuffix
|
|
49514
|
+
}
|
|
49515
|
+
};
|
|
49516
|
+
}
|
|
49517
|
+
const wantsState = needs.needsStateResources || needs.needsEnvOrSecretSubstitution;
|
|
49518
|
+
if (options.fromState && wantsState) {
|
|
49519
|
+
const loaded = await loadStateForStack(candidate.stackName, candidate.region, {
|
|
49520
|
+
...options.stackRegion !== void 0 && { stackRegion: options.stackRegion },
|
|
49521
|
+
...options.stateBucket !== void 0 && { stateBucket: options.stateBucket },
|
|
49522
|
+
statePrefix: options.statePrefix,
|
|
49523
|
+
...options.region !== void 0 && { region: options.region },
|
|
49524
|
+
...options.profile !== void 0 && { profile: options.profile }
|
|
49525
|
+
});
|
|
49526
|
+
if (loaded) ctx.stateResources = loaded.state.resources;
|
|
49527
|
+
} 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.");
|
|
49528
|
+
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.");
|
|
49529
|
+
return ctx;
|
|
49530
|
+
}
|
|
49531
|
+
function pickCandidateStack(stackPattern, stacks) {
|
|
49532
|
+
if (stackPattern === null) {
|
|
49533
|
+
if (stacks.length === 1) return stacks[0];
|
|
49534
|
+
return;
|
|
49535
|
+
}
|
|
49536
|
+
const matched = matchStacks(stacks, [stackPattern]);
|
|
49537
|
+
if (matched.length === 1) return matched[0];
|
|
49538
|
+
}
|
|
49539
|
+
async function resolveCallerAccountId(region) {
|
|
49540
|
+
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
|
|
49541
|
+
const sts = new STSClient({ ...region && { region } });
|
|
49542
|
+
try {
|
|
49543
|
+
return (await sts.send(new GetCallerIdentityCommand({}))).Account;
|
|
49544
|
+
} finally {
|
|
49545
|
+
sts.destroy();
|
|
49546
|
+
}
|
|
49547
|
+
}
|
|
49548
|
+
function readEnvOverridesFile$1(filePath) {
|
|
49549
|
+
if (!filePath) return void 0;
|
|
49550
|
+
let raw;
|
|
49551
|
+
try {
|
|
49552
|
+
raw = readFileSync(filePath, "utf-8");
|
|
49553
|
+
} catch (err) {
|
|
49554
|
+
throw new LocalStartServiceError(`Failed to read --env-vars file '${filePath}': ${err instanceof Error ? err.message : String(err)}`);
|
|
49555
|
+
}
|
|
49556
|
+
let parsed;
|
|
49557
|
+
try {
|
|
49558
|
+
parsed = JSON.parse(raw);
|
|
49559
|
+
} catch (err) {
|
|
49560
|
+
throw new LocalStartServiceError(`Failed to parse --env-vars file '${filePath}' as JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
49561
|
+
}
|
|
49562
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new LocalStartServiceError(`--env-vars file '${filePath}' must contain a JSON object at the top level.`);
|
|
49563
|
+
return parsed;
|
|
49564
|
+
}
|
|
49565
|
+
function parsePositiveInt(raw, flagName) {
|
|
49566
|
+
const parsed = parseInt(raw, 10);
|
|
49567
|
+
if (!Number.isFinite(parsed) || parsed < 1) throw new LocalStartServiceError(`${flagName} must be a positive integer (got '${raw}').`);
|
|
49568
|
+
return parsed;
|
|
49569
|
+
}
|
|
49570
|
+
/**
|
|
49571
|
+
* Hard cap on `--max-tasks` driven by the per-replica subnet allocator
|
|
49572
|
+
* in `ecs-service-runner.ts:bootReplica` (`170 + (index % 84)`). The
|
|
49573
|
+
* `% 84` modulo wraps at index 84, collapsing replica 84's `/24` onto
|
|
49574
|
+
* replica 0's allocation. Docker rejects the duplicate-subnet network
|
|
49575
|
+
* creation with a cryptic "Pool overlaps with other one on this address
|
|
49576
|
+
* space" error 30s into the boot — by which time some early replicas
|
|
49577
|
+
* may have spent docker-run budget. Reject at parse time so the user
|
|
49578
|
+
* gets an actionable error before any boot work fires.
|
|
49579
|
+
*
|
|
49580
|
+
* 84 is the count of usable link-local /24 octets in the range
|
|
49581
|
+
* `169.254.170.0..169.254.253.0` (255 reserved for broadcast). Raising
|
|
49582
|
+
* this requires extending the allocator to walk a different IP range.
|
|
49583
|
+
*/
|
|
49584
|
+
const MAX_TASKS_SUBNET_RANGE_CAP = 84;
|
|
49585
|
+
function parseMaxTasks(raw) {
|
|
49586
|
+
const parsed = parsePositiveInt(raw, "--max-tasks");
|
|
49587
|
+
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.`);
|
|
49588
|
+
return parsed;
|
|
49589
|
+
}
|
|
49590
|
+
function parseRestartPolicy(raw) {
|
|
49591
|
+
if (raw === "on-failure" || raw === "always" || raw === "none") return raw;
|
|
49592
|
+
throw new LocalStartServiceError(`--restart-policy must be one of 'on-failure', 'always', or 'none' (got '${raw}').`);
|
|
49593
|
+
}
|
|
49594
|
+
function createLocalStartServiceCommand() {
|
|
49595
|
+
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));
|
|
49596
|
+
[
|
|
49597
|
+
...commonOptions,
|
|
49598
|
+
...appOptions,
|
|
49599
|
+
...contextOptions,
|
|
49600
|
+
...stateOptions
|
|
49601
|
+
].forEach((opt) => cmd.addOption(opt));
|
|
49602
|
+
cmd.addOption(deprecatedRegionOption);
|
|
49603
|
+
return cmd;
|
|
49604
|
+
}
|
|
49605
|
+
|
|
47211
49606
|
//#endregion
|
|
47212
49607
|
//#region src/cli/commands/local-invoke.ts
|
|
47213
49608
|
/**
|
|
@@ -47939,6 +50334,7 @@ function createLocalCommand() {
|
|
|
47939
50334
|
local.addCommand(invoke);
|
|
47940
50335
|
local.addCommand(createLocalStartApiCommand());
|
|
47941
50336
|
local.addCommand(createLocalRunTaskCommand());
|
|
50337
|
+
local.addCommand(createLocalStartServiceCommand());
|
|
47942
50338
|
return local;
|
|
47943
50339
|
}
|
|
47944
50340
|
|
|
@@ -49311,7 +51707,7 @@ function reorderArgs(argv) {
|
|
|
49311
51707
|
*/
|
|
49312
51708
|
async function main() {
|
|
49313
51709
|
const program = new Command();
|
|
49314
|
-
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.
|
|
51710
|
+
program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.132.0");
|
|
49315
51711
|
program.addCommand(createBootstrapCommand());
|
|
49316
51712
|
program.addCommand(createSynthCommand());
|
|
49317
51713
|
program.addCommand(createListCommand());
|