Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
55 changes: 11 additions & 44 deletions cli/src/backends/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}>;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
1 change: 0 additions & 1 deletion cli/src/terraform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ const DERIVED_VARS = new Set([
const OPERATOR_DEFAULTS: Record<string, string> = {
github_repository: "replace-me/repository",
github_ref: "refs/heads/main",
certificate_arn: "",
};

export function declaredVariables(variablesTf: string): string[] {
Expand Down
6 changes: 2 additions & 4 deletions cli/templates/aws/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 0 additions & 4 deletions cli/templates/aws/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 18 additions & 21 deletions cli/test/aws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }));
}
Expand Down Expand Up @@ -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" },
},
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 {
Expand All @@ -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/);
Expand Down Expand Up @@ -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<void> => {
const run = async (mode: "forward" | undefined, expected?: RegExp): Promise<void> => {
const fake = statefulAws(dir, oneServiceConfig());
const prior = process.env.AWS_FAKE_EXTRA_HTTP_LISTENER;
if (mode) process.env.AWS_FAKE_EXTRA_HTTP_LISTENER = mode;
Expand All @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions cli/test/cli-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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" } }] }));
Expand Down
2 changes: 1 addition & 1 deletion cli/test/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand Down
Loading