From eb75557d81911f274bf0f6f2b73146b002bb92b8 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Wed, 12 Aug 2026 17:09:59 -0700 Subject: [PATCH 1/2] Fix AWS CloudFront TLS topology --- cli/src/backends/aws.ts | 55 +++++++--------------------------- cli/src/config.ts | 2 +- cli/src/terraform.ts | 1 - cli/templates/aws/main.tf | 6 ++-- cli/templates/aws/variables.tf | 4 --- cli/test/aws.test.ts | 39 +++++++++++------------- cli/test/init.test.ts | 2 +- cli/test/terraform.test.ts | 15 +++++----- 8 files changed, 41 insertions(+), 83 deletions(-) diff --git a/cli/src/backends/aws.ts b/cli/src/backends/aws.ts index 7a1bd86a4..9d051cf3f 100644 --- a/cli/src/backends/aws.ts +++ b/cli/src/backends/aws.ts @@ -1613,9 +1613,7 @@ export async function awsUp(config: QmConfig, _configDir: string, opts: AwsUpOpt const topology = awsTopology(config, _configDir); const { aws } = topology; if (new URL(config.publicUrl).protocol !== "https:") { - throw new CliError( - "AWS deploy requires an HTTPS publicUrl; configure an ACM certificate, update publicUrl, and rerender/apply Terraform before running `qm up`", - ); + throw new CliError("AWS deploy requires an HTTPS publicUrl; use the CloudFront hostname from Terraform output"); } const plugins = new Map(topology.plugins.map((plugin) => [plugin.name, plugin])); const services = opts.only ?? topology.workloads; @@ -2388,45 +2386,25 @@ export function assertGithubDeployTrust(statements: unknown, accountId: string, } } -export function assertAwsPublicListener( - publicUrl: string, - listener: { Protocol?: string; Port?: number; Certificates?: Array<{ CertificateArn?: string }> }, -): void { - const protocol = new URL(publicUrl).protocol; - if (protocol === "https:") { - if (listener.Protocol !== "HTTPS" || listener.Port !== 443) { - throw new Error( - `publicUrl is HTTPS but the ALB listener is ${listener.Protocol ?? "missing"}:${listener.Port ?? "missing"}; configure certificate_arn and apply Terraform`, - ); - } - if (!listener.Certificates?.some((certificate) => Boolean(certificate.CertificateArn))) { - throw new Error( - "publicUrl is HTTPS but the ALB listener has no certificate; configure certificate_arn and apply Terraform", - ); - } - return; +export function assertAwsPublicListener(originUrl: string, listener: { Protocol?: string; Port?: number }): void { + if (new URL(originUrl).protocol !== "http:") { + throw new Error("AWS public origin must use HTTP behind CloudFront"); } - if (protocol === "http:") { - if (listener.Protocol !== "HTTP" || listener.Port !== 80) { - throw new Error( - `publicUrl is HTTP but the ALB listener is ${listener.Protocol ?? "missing"}:${listener.Port ?? "missing"}`, - ); - } - return; + if (listener.Protocol !== "HTTP" || listener.Port !== 80) { + throw new Error( + `AWS public origin listener is ${listener.Protocol ?? "missing"}:${listener.Port ?? "missing"}, expected HTTP:80`, + ); } - throw new Error(`publicUrl must use http or https (got ${protocol})`); } interface AwsPublicListener { ListenerArn?: string; Protocol?: string; Port?: number; - Certificates?: Array<{ CertificateArn?: string }>; DefaultActions?: Array<{ Type?: string; TargetGroupArn?: string; FixedResponseConfig?: { StatusCode?: string }; - RedirectConfig?: { Protocol?: string; Port?: string; StatusCode?: string }; }>; } @@ -2447,12 +2425,6 @@ function awsPublicOrigin(config: QmConfig): URL { } } -function isHttpsRedirectListener(listener: AwsPublicListener): boolean { - if (listener.Protocol !== "HTTP" || listener.Port !== 80) return false; - const actions = listener.DefaultActions ?? []; - return actions.length === 1 && actions[0]?.Type === "redirect" && actions[0].RedirectConfig?.Protocol === "HTTPS"; -} - interface AwsEcsRoutingService { serviceName?: string; status?: string; @@ -2508,21 +2480,16 @@ function awsPublicFrontDoor(config: QmConfig): AwsPublicFrontDoor { loadBalancer.LoadBalancerArn, ]).Listeners ?? []; const origin = awsPublicOrigin(config); - const httpsFrontDoor = origin.protocol === "https:"; - const redirects = httpsFrontDoor ? listeners.filter(isHttpsRedirectListener) : []; - const candidates = listeners.filter((listener) => !redirects.includes(listener)); - if (candidates.length !== 1 || redirects.length > 1) { + if (listeners.length !== 1) { const found = listeners .map( (listener) => `${listener.Protocol ?? "unknown"}:${listener.Port ?? "?"} (default ${listener.DefaultActions?.map((action) => action.Type ?? "unknown").join("+") || "none"})`, ) .join(", "); - throw new Error( - `expected exactly one public listener${httpsFrontDoor ? " plus at most one port-80 HTTPS-redirect listener" : ""}, found ${found || "none"}`, - ); + throw new Error(`expected exactly one public listener, found ${found || "none"}`); } - const listener = candidates[0]; + const listener = listeners[0]; if (!listener?.ListenerArn) throw new Error("public listener is missing"); assertAwsPublicListener(origin.toString(), listener); return { loadBalancerArn: loadBalancer.LoadBalancerArn, dnsName: loadBalancer.DNSName, listener }; diff --git a/cli/src/config.ts b/cli/src/config.ts index 2277e65cf..000a0df29 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -885,7 +885,7 @@ function validateAwsFrontDoor(config: QmConfig, path: string): void { throw new CliError(`${path}: AWS portal requires web-ui`); } if (hasPortal && protocol !== "https:") { - throw new CliError(`${path}: AWS portal requires an HTTPS publicUrl and ACM certificate`); + throw new CliError(`${path}: AWS portal requires an HTTPS publicUrl`); } const harness = config.env.core?.HARNESS?.trim() || "mock"; if (protocol !== "https:" && harness !== "mock") { diff --git a/cli/src/terraform.ts b/cli/src/terraform.ts index 67d35f8ed..75bcfc670 100644 --- a/cli/src/terraform.ts +++ b/cli/src/terraform.ts @@ -28,7 +28,6 @@ const DERIVED_VARS = new Set([ const OPERATOR_DEFAULTS: Record = { github_repository: "replace-me/repository", github_ref: "refs/heads/main", - certificate_arn: "", }; export function declaredVariables(variablesTf: string): string[] { diff --git a/cli/templates/aws/main.tf b/cli/templates/aws/main.tf index 792d950b3..00103c303 100644 --- a/cli/templates/aws/main.tf +++ b/cli/templates/aws/main.tf @@ -701,10 +701,8 @@ resource "aws_lb_target_group" "service" { resource "aws_lb_listener" "public" { load_balancer_arn = aws_lb.this.arn - port = var.certificate_arn == "" ? 80 : 443 - protocol = var.certificate_arn == "" ? "HTTP" : "HTTPS" - certificate_arn = var.certificate_arn == "" ? null : var.certificate_arn - ssl_policy = var.certificate_arn == "" ? null : "ELBSecurityPolicy-TLS13-1-2-2021-06" + port = 80 + protocol = "HTTP" dynamic "default_action" { for_each = local.has_portal ? ["portal"] : [] content { diff --git a/cli/templates/aws/variables.tf b/cli/templates/aws/variables.tf index 2a06202f6..ed1df12dc 100644 --- a/cli/templates/aws/variables.tf +++ b/cli/templates/aws/variables.tf @@ -47,10 +47,6 @@ variable "deploy_microvm_execution_role_arn" { variable "certificate_arn" { type = string default = "" - validation { - condition = var.certificate_arn == "" || can(regex("^arn:(aws|aws-us-gov|aws-cn):acm:[a-z0-9-]+:[0-9]{12}:certificate/[0-9a-f-]+$", var.certificate_arn)) - error_message = "certificate_arn must be an ACM certificate ARN in the configured AWS partition" - } } variable "db_name" { type = string diff --git a/cli/test/aws.test.ts b/cli/test/aws.test.ts index 4074b5130..5f1538537 100644 --- a/cli/test/aws.test.ts +++ b/cli/test/aws.test.ts @@ -95,10 +95,9 @@ else if (a.includes("lambda-microvms get-microvm-image")) { else if (a.includes("lambda-microvms list-microvm-image-versions")) console.log(JSON.stringify({ items: [{ imageVersion: process.env.AWS_FAKE_IMAGE_VERSION || "1", state: process.env.AWS_FAKE_IMAGE_STATE || "SUCCESSFUL", status: process.env.AWS_FAKE_IMAGE_STATUS || "ACTIVE" }] })); else if (a.includes("elbv2 describe-load-balancers")) console.log(JSON.stringify({ LoadBalancers: [{ LoadBalancerArn: "arn:aws:elasticloadbalancing:us-west-2:123456789012:loadbalancer/app/test/1", DNSName: process.env.AWS_FAKE_ALB_DNS || "agent.acme.example", State: { Code: "active" } }] })); else if (a.includes("elbv2 describe-listeners")) { - const protocol = process.env.AWS_FAKE_LISTENER_PROTOCOL || "HTTPS"; + const protocol = process.env.AWS_FAKE_LISTENER_PROTOCOL || "HTTP"; const defaults = process.env.AWS_FAKE_DEFAULT_FORWARD === "1" ? [{ Type: "forward", TargetGroupArn: ${JSON.stringify(targetArn)} }] : ${frontService === "portal" ? JSON.stringify([{ Type: "forward", TargetGroupArn: targetArn }]) : JSON.stringify([{ Type: "fixed-response", FixedResponseConfig: { StatusCode: "404" } }])}; const listeners = [{ ListenerArn: "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener/app/test/1/2", Protocol: protocol, Port: protocol === "HTTPS" ? 443 : 80, Certificates: protocol === "HTTPS" ? [{ CertificateArn: "arn:aws:acm:us-west-2:123456789012:certificate/test" }] : [], DefaultActions: defaults }]; - if (process.env.AWS_FAKE_EXTRA_HTTP_LISTENER === "redirect") listeners.push({ ListenerArn: "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener/app/test/1/3", Protocol: "HTTP", Port: 80, Certificates: [], DefaultActions: [{ Type: "redirect", RedirectConfig: { Protocol: "HTTPS", Port: "443", StatusCode: "HTTP_301" } }] }); if (process.env.AWS_FAKE_EXTRA_HTTP_LISTENER === "forward") listeners.push({ ListenerArn: "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener/app/test/1/3", Protocol: "HTTP", Port: 80, Certificates: [], DefaultActions: [{ Type: "forward", TargetGroupArn: ${JSON.stringify(targetArn)} }] }); console.log(JSON.stringify({ Listeners: listeners })); } @@ -408,7 +407,12 @@ const config: QmConfig = { secretEnv: ["ACME_API_KEY"], }, env: { - core: { HARNESS: "pi", AWS_DEPLOY_IMAGE: "acme-qm-sandbox", AWS_DEPLOY_IMAGE_VERSION: "1" }, + core: { + HARNESS: "pi", + AWS_DEPLOY_IMAGE: "acme-qm-sandbox", + AWS_DEPLOY_IMAGE_VERSION: "1", + AWS_PUBLIC_ORIGIN_URL: "http://agent.acme.example", + }, admin: { ADMIN_BASE_PATH: "/admin" }, portal: { OIDC_CLIENT_ID: "client", PORTAL_EXPECTED_TEAM_ID: "T1" }, }, @@ -766,23 +770,17 @@ test("githubTrustSubject pins the deploy branch, never the working checkout's br } }); -test("AWS doctor requires the public listener transport to match publicUrl", () => { +test("AWS doctor requires an HTTP ALB origin behind CloudFront", () => { const httpsListener = { ListenerArn: "arn:aws:elasticloadbalancing:us-west-2:123456789012:listener/app/acme/123/456", Protocol: "HTTPS", Port: 443, - Certificates: [{ CertificateArn: "arn:aws:acm:us-west-2:123456789012:certificate/abc" }], }; - const httpListener = { ...httpsListener, Protocol: "HTTP", Port: 80, Certificates: [] }; + const httpListener = { ...httpsListener, Protocol: "HTTP", Port: 80 }; - assert.doesNotThrow(() => assertAwsPublicListener("https://agent.acme.example", httpsListener)); - assert.throws(() => assertAwsPublicListener("https://agent.acme.example", httpListener), /configure certificate_arn/); - assert.throws( - () => assertAwsPublicListener("https://agent.acme.example", { ...httpsListener, Certificates: [] }), - /no certificate/, - ); assert.doesNotThrow(() => assertAwsPublicListener("http://agent.acme.example", httpListener)); - assert.throws(() => assertAwsPublicListener("http://agent.acme.example", httpsListener), /publicUrl is HTTP/); + assert.throws(() => assertAwsPublicListener("https://agent.acme.example", httpsListener), /origin must use HTTP/); + assert.throws(() => assertAwsPublicListener("http://agent.acme.example", httpsListener), /expected HTTP:80/); }); test("AWS deploy refuses the HTTP bootstrap before any AWS mutation", async () => { @@ -791,7 +789,7 @@ test("AWS deploy refuses the HTTP bootstrap before any AWS mutation", async () = try { await assert.rejects( () => awsUp({ ...config, publicUrl: "http://agent.acme.example" }, process.cwd(), { yes: true }), - /requires an HTTPS publicUrl.*ACM certificate.*update publicUrl.*rerender\/apply Terraform/, + /requires an HTTPS publicUrl.*CloudFront hostname/, ); assert.equal(readFileSync(fake.log, "utf8"), ""); } finally { @@ -800,15 +798,15 @@ test("AWS deploy refuses the HTTP bootstrap before any AWS mutation", async () = } }); -test("AWS deploy requires the live ALB listener to match the HTTPS public URL", async () => { +test("AWS deploy requires the live ALB listener to remain HTTP behind CloudFront", async () => { const dir = mkdtempSync(join(tmpdir(), "qm-aws-live-listener-")); const fake = fakeAws(dir, `console.log("");`); const prior = process.env.AWS_FAKE_LISTENER_PROTOCOL; - process.env.AWS_FAKE_LISTENER_PROTOCOL = "HTTP"; + process.env.AWS_FAKE_LISTENER_PROTOCOL = "HTTPS"; try { await assert.rejects( () => awsUp(config, process.cwd(), { yes: true }), - /public front door is not ready.*listener is HTTP:80.*configure certificate_arn.*apply Terraform/, + /public front door is not ready.*expected HTTP:80/, ); const calls = readFileSync(fake.log, "utf8"); assert.match(calls, /sts get-caller-identity/); @@ -4111,14 +4109,14 @@ test("an --only deploy that skips core needs no pin source and records the pin b } }); -test("AWS front door tolerates exactly one extra port-80 HTTPS-redirect listener; any other extra listener still fails", async () => { +test("AWS front door requires exactly one HTTP listener", async () => { const dir = mkdtempSync(join(tmpdir(), "qm-aws-front-door-listeners-")); const dockerBin = join(dir, "docker"); writeFileSync(dockerBin, `#!/usr/bin/env node\nconsole.log("Digest: sha256:${"a".repeat(64)}");\n`); chmodSync(dockerBin, 0o755); const priorPath = process.env.PATH; process.env.PATH = `${dir}:${priorPath}`; - const run = async (mode: "redirect" | "forward" | undefined, expected?: RegExp): Promise => { + const run = async (mode: "forward" | undefined, expected?: RegExp): Promise => { const fake = statefulAws(dir, oneServiceConfig()); const prior = process.env.AWS_FAKE_EXTRA_HTTP_LISTENER; if (mode) process.env.AWS_FAKE_EXTRA_HTTP_LISTENER = mode; @@ -4133,10 +4131,9 @@ test("AWS front door tolerates exactly one extra port-80 HTTPS-redirect listener }; try { await run(undefined); - await run("redirect"); await run( "forward", - /expected exactly one public listener plus at most one port-80 HTTPS-redirect listener, found HTTPS:443 \(default fixed-response\), HTTP:80 \(default forward\)/, + /expected exactly one public listener, found HTTP:80 \(default fixed-response\), HTTP:80 \(default forward\)/, ); } finally { process.env.PATH = priorPath; diff --git a/cli/test/init.test.ts b/cli/test/init.test.ts index 1c334e40a..abd821278 100644 --- a/cli/test/init.test.ts +++ b/cli/test/init.test.ts @@ -258,7 +258,7 @@ test("init --target aws scaffolds the full hosted topology, Terraform, and the o assert.match(tfvars, /cluster_name\s*= "acme-qm"/); assert.match(tfvars, /github_repository\s*= "replace-me\/repository"/); assert.match(tfvars, /deploy_microvm_image\s*= "acme-qm-sandbox"/); - assert.match(tfvars, /certificate_arn\s*= ""/); + assert.doesNotMatch(tfvars, /certificate_arn/); assert.match(readFileSync(join(dir, "infra", "main.tf"), "utf8"), /desired_count\s*= 0/); const env = readFileSync(join(dir, ".env.example"), "utf8").split("\n"); for (const name of [ diff --git a/cli/test/terraform.test.ts b/cli/test/terraform.test.ts index 5ff16169d..7d1171e7a 100644 --- a/cli/test/terraform.test.ts +++ b/cli/test/terraform.test.ts @@ -37,7 +37,6 @@ test("declaredVariables reads the scaffolded variables.tf", () => { for (const name of [ "org_id", "account_id", - "certificate_arn", "db_name", "db_username", "github_repository", @@ -166,11 +165,11 @@ test("terraform propagates workload architecture to bootstrap task definitions", test("re-render preserves every declared operator variable, not just the github coordinates", () => { const first = terraformVars(config, "", declared); - assert.match(first, /certificate_arn\s*= ""/, "fresh tfvars select the valid HTTP listener bootstrap"); const edited = - `${first}db_name = "customdb"\ndb_username = "customuser"\ndb_multi_az = true\ndb_skip_final_snapshot = true\necr_force_delete = true\nobject_store_force_destroy = true\nsecret_recovery_window_days = 0\n` - .replace('github_repository = "replace-me/repository"', 'github_repository = "acme/deploy"') - .replace(/certificate_arn\s+= ""/, 'certificate_arn = "arn:aws:acm:us-west-2:123456789012:certificate/abc"'); + `${first}certificate_arn = "arn:aws:acm:us-west-2:123456789012:certificate/abc"\ndb_name = "customdb"\ndb_username = "customuser"\ndb_multi_az = true\ndb_skip_final_snapshot = true\necr_force_delete = true\nobject_store_force_destroy = true\nsecret_recovery_window_days = 0\n`.replace( + 'github_repository = "replace-me/repository"', + 'github_repository = "acme/deploy"', + ); const rerendered = terraformVars({ ...config, publicUrl: "https://new.acme.example" }, edited, declared); assert.match(rerendered, /public_url {2,}= "https:\/\/new\.acme\.example"/, "derived vars re-render from config"); assert.match(rerendered, /github_repository {2,}= "acme\/deploy"/, "operator github coordinate preserved"); @@ -284,7 +283,9 @@ test("AWS module keeps portal as the sole front door and preserves CLI-owned ECS test("AWS module terminates public TLS at CloudFront independently of the ALB origin listener", () => { const listener = mainTf.match(/resource "aws_lb_listener" "public" \{([\s\S]*?)\n\}/)?.[1] ?? ""; const edge = mainTf.match(/resource "aws_cloudfront_distribution" "portal" \{([\s\S]*?)\n\}/)?.[1] ?? ""; - assert.match(listener, /port\s*=\s*var\.certificate_arn == "" \? 80 : 443/); + assert.match(listener, /port\s*=\s*80/); + assert.match(listener, /protocol\s*=\s*"HTTP"/); + assert.doesNotMatch(listener, /certificate_arn|ssl_policy/); assert.match(edge, /domain_name\s*=\s*aws_lb\.this\.dns_name/); assert.match(edge, /cloudfront_default_certificate\s*=\s*true/); }); @@ -403,7 +404,7 @@ test("AWS ECS services wait until target groups are attached to the ALB", () => test("drift check flags wrong derived values but never operator formatting", () => { const rendered = terraformVars(config, "", declared); assert.deepEqual(terraformVarsDrift(config, rendered, declared), []); - const reordered = `certificate_arn = "arn:x"\n# an operator comment\n${rendered}`; + const reordered = `operator_setting = "value"\n# an operator comment\n${rendered}`; assert.deepEqual(terraformVarsDrift(config, reordered, declared), [], "operator additions/formatting are not drift"); const wrongCluster = rendered.replace('"acme-qm"', '"other-cluster"'); assert.deepEqual(terraformVarsDrift(config, wrongCluster, declared), ["cluster_name"]); From 55506ccf5e0888fae52df0116beb22fc722ff33a Mon Sep 17 00:00:00 2001 From: Sinabina Date: Thu, 13 Aug 2026 10:11:59 -0700 Subject: [PATCH 2/2] Fix AWS CLI checks --- cli/package-lock.json | 4 ++-- cli/package.json | 2 +- cli/test/cli-dispatch.test.ts | 10 ++++++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 8b469f6a2..9db291b19 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yc-software/qm", - "version": "0.1.6", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yc-software/qm", - "version": "0.1.6", + "version": "0.1.8", "license": "MIT", "bin": { "qm": "dist/bin/qm.js" diff --git a/cli/package.json b/cli/package.json index 4fbc357d4..2bb3a343b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@yc-software/qm", - "version": "0.1.6", + "version": "0.1.8", "license": "MIT", "description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.", "type": "module", diff --git a/cli/test/cli-dispatch.test.ts b/cli/test/cli-dispatch.test.ts index d5b2bf131..3c6d62df0 100644 --- a/cli/test/cli-dispatch.test.ts +++ b/cli/test/cli-dispatch.test.ts @@ -240,7 +240,13 @@ test("successful check --json --live reports the live-drift clause", async () => publicUrl: "https://acme.example.com", target: "aws", services: ["core"], - env: { core: { AWS_DEPLOY_IMAGE: "acme-microvm-app", AWS_DEPLOY_IMAGE_VERSION: "1" } }, + env: { + core: { + AWS_DEPLOY_IMAGE: "acme-microvm-app", + AWS_DEPLOY_IMAGE_VERSION: "1", + AWS_PUBLIC_ORIGIN_URL: "http://acme.example.com", + }, + }, imageOverrides: { core: `ghcr.io/acme/core@${digest}` }, sandbox: { backend: "sprites", app: "acme-sandboxes", image: PINNED_SANDBOX_IMAGE }, aws: { @@ -289,7 +295,7 @@ else if (args.includes("dynamodb get-item") && args.includes("deployment/current else if (args.includes("dynamodb get-item") && args.includes("deployment/manifest/manifest")) console.log(JSON.stringify({ Item: { manifest: { S: ${JSON.stringify(JSON.stringify(manifest))} } } })); else if (args.includes("s3api get-object")) fs.writeFileSync(argv[argv.indexOf("--key") + 2], ${JSON.stringify(layerBody)}); else if (args.includes("elbv2 describe-load-balancers")) console.log(JSON.stringify({ LoadBalancers: [{ LoadBalancerArn: "lb", DNSName: "acme.example.com", State: { Code: "active" } }] })); -else if (args.includes("elbv2 describe-listeners")) console.log(JSON.stringify({ Listeners: [{ ListenerArn: "listener", Protocol: "HTTPS", Port: 443, Certificates: [{ CertificateArn: "certificate" }], DefaultActions: [{ Type: "fixed-response", FixedResponseConfig: { StatusCode: "404" } }] }] })); +else if (args.includes("elbv2 describe-listeners")) console.log(JSON.stringify({ Listeners: [{ ListenerArn: "listener", Protocol: "HTTP", Port: 80, DefaultActions: [{ Type: "fixed-response", FixedResponseConfig: { StatusCode: "404" } }] }] })); else if (args.includes("elbv2 describe-target-groups")) console.log(JSON.stringify({ TargetGroups: [{ TargetGroupArn: "tg", TargetGroupName: ${JSON.stringify(targetGroupName)} }] })); else if (args.includes("elbv2 describe-rules")) console.log(JSON.stringify({ Rules: [{ IsDefault: false, Actions: [{ Type: "forward", TargetGroupArn: "tg" }], Conditions: [{ Field: "path-pattern", PathPatternConfig: { Values: ["/v1/*"] } }] }] })); else if (args.includes("elbv2 describe-target-health")) console.log(JSON.stringify({ TargetHealthDescriptions: [{ TargetHealth: { State: "healthy" } }] }));