Skip to content
Merged
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: 3 additions & 1 deletion .codex/skills/deploy-qm/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ only once the operator has chosen the broker.

Use the installed `@yc-software/qm` dependency through `npm exec qm -- <command>`. Do
not require or clone the QM source repository. Complete every acceptance check
and return the handoff required by `deployment.md`.
and return the handoff required by `deployment.md`. Treat `qm check --live` and
its private live session canary as the automated release gate; still complete
the administrator's manual sign-in and web acceptance check.
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.5",
"version": "0.1.6",
"license": "MIT",
"description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.",
"type": "module",
Expand Down
73 changes: 73 additions & 0 deletions cli/src/backends/aws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -748,10 +748,75 @@ interface EcsServiceState {
desiredCount?: number;
runningCount?: number;
taskDefinition?: string;
networkConfiguration?: {
awsvpcConfiguration?: {
subnets?: string[];
securityGroups?: string[];
assignPublicIp?: "ENABLED" | "DISABLED";
};
};
deployments?: EcsDeploymentState[];
tags?: Array<{ key?: string; value?: string }>;
}

function awsLiveSession(config: QmConfig, core: EcsServiceState): void {
const aws = requireAws(config);
if (!core.taskDefinition) throw new Error("core service has no live task definition");
if (!core.networkConfiguration?.awsvpcConfiguration) throw new Error("core service has no VPC network configuration");
const started = awsJson<{
tasks?: Array<{ taskArn?: string }>;
failures?: Array<{ arn?: string; reason?: string; detail?: string }>;
}>(aws, [
"ecs",
"run-task",
"--cluster",
aws.cluster,
"--task-definition",
core.taskDefinition,
"--launch-type",
"FARGATE",
"--network-configuration",
JSON.stringify(core.networkConfiguration),
"--overrides",
JSON.stringify({
containerOverrides: [
{
name: "core",
command: [
"node",
"src/deployment/postdeploy-smoke.ts",
"session",
`http://core.${aws.networking.cloudMapNamespace}:8080`,
],
},
],
}),
"--count",
"1",
]);
const taskArn = started.tasks?.[0]?.taskArn;
if (!taskArn) {
const failure = started.failures?.[0];
throw new Error(
`could not start canary task: ${failure?.reason ?? failure?.detail ?? failure?.arn ?? "no task returned"}`,
);
}
awsText(aws, ["ecs", "wait", "tasks-stopped", "--cluster", aws.cluster, "--tasks", taskArn]);
const stopped = awsJson<{
tasks?: Array<{
stoppedReason?: string;
containers?: Array<{ name?: string; exitCode?: number; reason?: string }>;
}>;
}>(aws, ["ecs", "describe-tasks", "--cluster", aws.cluster, "--tasks", taskArn]);
const task = stopped.tasks?.[0];
const coreContainer = task?.containers?.find((container) => container.name === "core");
if (coreContainer?.exitCode !== 0) {
throw new Error(
`canary task exited ${coreContainer?.exitCode ?? "without a code"}: ${coreContainer?.reason ?? task?.stoppedReason ?? "unknown reason"}`,
);
}
}

type DeploymentImageProvenance =
| { kind: "configured"; source: string }
| { kind: "source-build"; source?: "plugin" | "checkout"; gitCommit?: string; dirty?: boolean };
Expand Down Expand Up @@ -3154,6 +3219,14 @@ async function checkLive(
failures.push(`deployment layer drift: ${errMessage(error)}`);
}
}
if (!failures.length) {
try {
awsLiveSession(config, states.get("core")!);
if (opts.report ?? true) step("core: private live session smoke passed");
} catch (error) {
failures.push(`core: private live session smoke failed: ${errMessage(error)}`);
}
}
if (failures.length)
throw new CliError(`live drift detected:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`, {
clause: "aws.live-drift",
Expand Down
20 changes: 20 additions & 0 deletions cli/src/backends/fly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,14 @@ function flyS3RoundTrip(app: string, machineId: string): void {
fly(["ssh", "console", "-a", app, "--machine", machineId, "--command", flyS3ProbeCommand(), "--quiet"]);
}

export function flyLiveSessionCommand(): string {
return "node src/deployment/postdeploy-smoke.ts session http://127.0.0.1:8080";
}

function flyLiveSession(app: string, machineId: string): void {
fly(["ssh", "console", "-a", app, "--machine", machineId, "--command", flyLiveSessionCommand(), "--quiet"]);
}

function flyOrgApps(flyOrg: string): Set<string> {
const raw = fly(["apps", "list", "--org", flyOrg, "--json"]);
let parsed: unknown;
Expand Down Expand Up @@ -1615,6 +1623,18 @@ export async function flyCheckLive(
} catch (error) {
failures.push(`${healthUrl}: ${errMessage(error)}`);
}
if (!failures.length) {
if (!coreMachineId) {
failures.push(`${ctx.appPrefix}-core: cannot run the live session smoke without an identified machine`);
} else {
try {
flyLiveSession(`${ctx.appPrefix}-core`, coreMachineId);
if (report) step(`${ctx.appPrefix}-core: private live session smoke passed`);
} catch (error) {
failures.push(`${ctx.appPrefix}-core: private live session smoke failed: ${errMessage(error)}`);
}
}
}
if (failures.length) {
throw new CliError(`live check failed:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`, {
clause: "fly.live-readiness",
Expand Down
17 changes: 16 additions & 1 deletion cli/templates/aws/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,21 @@ resource "aws_iam_role_policy" "github_deploy" {
Action = ["ecs:DescribeServices", "ecs:ListTagsForResource", "ecs:UpdateService"]
Resource = ["arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:service/${var.cluster_name}/*"]
},
{
Sid = "RunDeploymentCanaries"
Effect = "Allow"
Action = ["ecs:RunTask"]
Resource = ["arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task-definition/${var.services["core"].ecs_service}:*"]
Condition = {
ArnEquals = { "ecs:cluster" = aws_ecs_cluster.this.arn }
}
},
{
Sid = "InspectDeploymentCanaries"
Effect = "Allow"
Action = ["ecs:DescribeTasks"]
Resource = ["arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task/${var.cluster_name}/*"]
},
{
Sid = "PassTaskRolesToEcs"
Effect = "Allow"
Expand Down Expand Up @@ -606,7 +621,7 @@ resource "aws_iam_role_policy" "task_objects" {
Resource = aws_s3_bucket.objects.arn
},
{
Effect = "Allow"
Effect = "Allow"
# AbortMultipartUpload is its own action — PutObject covers Create/UploadPart/Complete but
# not the abort, and without it a failed staging upload strands parts that bill silently.
Action = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"]
Expand Down
3 changes: 2 additions & 1 deletion cli/templates/deployment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ only once the operator has chosen the broker.
Use the repository's installed `@yc-software/qm` dependency through
`npm exec qm -- <command>`. Do not require or clone the QM source repository.
Do not stop at infrastructure health: complete the acceptance checks and return
the handoff required by `deployment.md`.
the handoff required by `deployment.md`. A web response without a generated
sidebar title is not a completed deployment.
26 changes: 19 additions & 7 deletions cli/templates/deployment/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ This repository defines one QM deployment. The `@yc-software/qm` dependency supp
the deployment engine; this repository owns the organization-specific config,
sandbox layer, provider coordinates, and generated Slack manifests.

The task is complete only after the administrator can sign in, receive a real
web response, and, when Slack is requested, mention the bot in a test channel
and receive a response.
The automated release gate is `qm check --live`, including its private live
session canary. The task is complete only after that gate passes, the
administrator can sign in and receive a real web response, and, when Slack is
requested, the bot replies in a test channel.

## 1. Collect choices and authorization

Expand Down Expand Up @@ -212,6 +213,14 @@ npm exec qm -- conformance
npm exec qm -- outputs --json
```

`check --live` verifies provider infrastructure, private storage, public
health, and a private end-to-end web session. The session canary runs one real
agent turn plus auxiliary title generation, verifies the exact reply and
persisted transcript, requires a generated title, checks the session-scoped
error log, and archives itself. It does not recall or capture administrator
memory. Fly runs it inside the core machine; AWS runs it as a one-off task on
the core service's private network. It does not add a public session endpoint.

Open `adminOnboardingUrl` from the JSON output and confirm Model provider
reports the chosen vendor as configured, sourced from the environment. It does
when `modelProvider` is set: the key travelled with the rest of the deployment
Expand All @@ -227,7 +236,10 @@ place a deployment key belongs, and `qm secrets push` moves it without printing
it.

Open `webUiUrl`, sign in as the seeded administrator, send a message, and
receive a real model response. Ask the agent to create a fresh UUID in
receive a real model response. Use a specific request rather than a greeting,
then confirm its generated sidebar title replaces the `Web chat` fallback. A
missing title is one failed runtime assertion; inspect the core error log and
rerun `check --live` before continuing. Ask the agent to create a fresh UUID in
`/root/workspace/qm-computer-proof.txt`, then use the provider reference's
independent proof to verify that UUID outside the model transcript.

Expand Down Expand Up @@ -267,9 +279,9 @@ Return:
- provider, account or organization, and region;
- the base model provider and where its key lives — the deployment `.env` or the
Admin page — so the operator knows what to rotate and where;
- pass/fail for health, sign-in, web chat, agent-computer proof, connector
visibility, user OAuth, Slack reply, live check, conformance, and an
idempotent deployment rerun;
- pass/fail for health, the private live session canary, sign-in, manual web
chat and generated title, agent-computer proof, connector visibility, user
OAuth, Slack reply, conformance, and an idempotent deployment rerun;
- `npm exec qm -- status`, logs, rollback, and teardown commands;
- recurring cost or manual work still owned by the operator, including model
usage billed directly by the provider.
Expand Down
6 changes: 6 additions & 0 deletions cli/templates/deployment/references/aws.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ npm exec qm -- up --yes
npm exec qm -- check --live
```

Existing deployments created before private session canaries must rerun
`npm exec qm -- infra render`, review the Terraform plan, and apply it with
infrastructure-administrator credentials before enabling `check --live`. This
adds the deploy role's stack-scoped permission to run and inspect the one-off
core canary task.

The package image manifest supplies first-party control-plane images. The AWS
backend transfers them into deployment-owned ECR and records immutable digests.
After the first successful deployment, rerun `npm exec qm -- up --yes` and
Expand Down
19 changes: 18 additions & 1 deletion cli/test/aws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,12 @@ else if (a.includes("ecs describe-services")) {
: ${JSON.stringify(opts.primaryFailedTasks ?? false)} || transientlyFailing
? [{ id: service.deploymentId, status: "PRIMARY", taskDefinition: service.taskDefinition, rolloutState: "IN_PROGRESS", runningCount: 0, failedTasks: 1 }]
: [{ id: service.deploymentId, status: "PRIMARY", taskDefinition: service.taskDefinition, rolloutState: "COMPLETED", runningCount: service.desiredCount, failedTasks: transientFailedTaskPolls && s.updated ? 1 : 0 }];
return [{ serviceName: name, status: "ACTIVE", desiredCount: service.desiredCount, runningCount: ${JSON.stringify(opts.drainRollout ?? false)} ? service.desiredCount + 1 : service.desiredCount, taskDefinition: service.taskDefinition, deployments, loadBalancers: service.workload === ${JSON.stringify(frontService)} ? [{ targetGroupArn: ${JSON.stringify(frontTargetArn)} }] : (service.workload === "core" && ${JSON.stringify(coreHosts.length > 0)} ? [{ targetGroupArn: ${JSON.stringify(coreTargetArn)} }] : []), tags: [{ key: "Deployment", value: ${JSON.stringify(opts.foreignServiceTags ? "other" : configured.orgId)} }, { key: "ManagedBy", value: "terraform" }] }];
return [{ serviceName: name, status: "ACTIVE", desiredCount: service.desiredCount, runningCount: ${JSON.stringify(opts.drainRollout ?? false)} ? service.desiredCount + 1 : service.desiredCount, taskDefinition: service.taskDefinition, networkConfiguration: { awsvpcConfiguration: { subnets: ["subnet-test"], securityGroups: ["sg-test"], assignPublicIp: "DISABLED" } }, deployments, loadBalancers: service.workload === ${JSON.stringify(frontService)} ? [{ targetGroupArn: ${JSON.stringify(frontTargetArn)} }] : (service.workload === "core" && ${JSON.stringify(coreHosts.length > 0)} ? [{ targetGroupArn: ${JSON.stringify(coreTargetArn)} }] : []), tags: [{ key: "Deployment", value: ${JSON.stringify(opts.foreignServiceTags ? "other" : configured.orgId)} }, { key: "ManagedBy", value: "terraform" }] }];
}), failures: names.filter((name) => !s.services[name]).map((name) => ({ arn: name, reason: "MISSING" })) }));
}
else if (a.includes("ecs run-task")) console.log(JSON.stringify({ tasks: [{ taskArn: "arn:aws:ecs:us-west-2:123456789012:task/canary" }] }));
else if (a.includes("ecs wait tasks-stopped")) console.log("");
else if (a.includes("ecs describe-tasks")) console.log(JSON.stringify({ tasks: [{ stoppedReason: "Essential container exited", containers: [{ name: "core", exitCode: Number(process.env.AWS_FAKE_CANARY_EXIT || "0"), reason: process.env.AWS_FAKE_CANARY_REASON }] }] }));
else if (a.includes("ecs describe-task-definition")) {
const id = after("--task-definition");
console.log(JSON.stringify({ taskDefinition: s.definitions[id] }));
Expand Down Expand Up @@ -1010,20 +1013,34 @@ test("AWS up scales services to the configured desired count and live check flag
};
const fake = statefulAws(dir, scaled());
const priorPath = process.env.PATH;
const priorCanaryExit = process.env.AWS_FAKE_CANARY_EXIT;
process.env.PATH = `${dir}:${priorPath}`;
try {
await awsUp(scaled(), dir, { yes: true });
const state = JSON.parse(readFileSync(fake.state, "utf8"));
assert.equal(state.services["acme-core"].desiredCount, 2);
assert.match(readFileSync(fake.log, "utf8"), /ecs update-service .*--desired-count 2/);
await assert.doesNotReject(() => awsCheckLive(scaled(), { report: false }));
assert.match(
readFileSync(fake.log, "utf8"),
/ecs run-task .*postdeploy-smoke\.ts.*session.*http:\/\/core\.acme\.internal:8080/,
);
process.env.AWS_FAKE_CANARY_EXIT = "1";
await assert.rejects(
() => awsCheckLive(scaled(), { report: false }),
/core: private live session smoke failed: canary task exited 1/,
);
if (priorCanaryExit === undefined) delete process.env.AWS_FAKE_CANARY_EXIT;
else process.env.AWS_FAKE_CANARY_EXIT = priorCanaryExit;
state.services["acme-core"].desiredCount = 1;
writeFileSync(fake.state, JSON.stringify(state));
await assert.rejects(
() => awsCheckLive(scaled(), { report: false }),
/core: runtime is ACTIVE with 1\/1 running, expected 2/,
);
} finally {
if (priorCanaryExit === undefined) delete process.env.AWS_FAKE_CANARY_EXIT;
else process.env.AWS_FAKE_CANARY_EXIT = priorCanaryExit;
process.env.PATH = priorPath;
fake.restore();
rmSync(dir, { recursive: true, force: true });
Expand Down
4 changes: 3 additions & 1 deletion cli/test/cli-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,10 @@ else if (args.includes("lambda-microvms get-microvm-image")) console.log(JSON.st
else if (args.includes("lambda-microvms list-microvm-image-versions")) console.log(JSON.stringify({ items: [{ imageVersion: "1", state: "SUCCESSFUL", status: "ACTIVE" }] }));
else if (args.includes("get-secret-value") && args.includes("--query SecretString")) console.log("signing-secret".repeat(3));
else if (args.includes("get-secret-value")) console.log(JSON.stringify({ ARN: "arn", SecretString: "secret-value".repeat(3) }));
else if (args.includes("describe-services")) console.log(JSON.stringify({ services: [{ serviceName: "s", status: "ACTIVE", desiredCount: 1, runningCount: 1, taskDefinition: "task", deployments: [{ status: "PRIMARY", rolloutState: "COMPLETED", taskDefinition: "task" }], loadBalancers: [{ targetGroupArn: "tg" }] }] }));
else if (args.includes("describe-services")) console.log(JSON.stringify({ services: [{ serviceName: "s", status: "ACTIVE", desiredCount: 1, runningCount: 1, taskDefinition: "task", networkConfiguration: { awsvpcConfiguration: { subnets: ["subnet"], securityGroups: ["sg"], assignPublicIp: "DISABLED" } }, deployments: [{ status: "PRIMARY", rolloutState: "COMPLETED", taskDefinition: "task" }], loadBalancers: [{ targetGroupArn: "tg" }] }] }));
else if (args.includes("describe-task-definition")) console.log(${JSON.stringify(JSON.stringify({ taskDefinition: task }))});
else if (args.includes("run-task")) console.log(JSON.stringify({ tasks: [{ taskArn: "canary" }] }));
else if (args.includes("describe-tasks")) console.log(JSON.stringify({ tasks: [{ containers: [{ name: "core", exitCode: 0 }] }] }));
else if (args.includes("dynamodb get-item") && args.includes("deployment/current")) console.log(JSON.stringify({ Item: { manifestId: { S: "manifest" } } }));
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)});
Expand Down
13 changes: 12 additions & 1 deletion cli/test/fly-sandbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfigAt, type QmConfig } from "../src/config.ts";
import { derivedTomlFor, derivedPluginTomlFor, flyCheckLive, flyS3ProbeCommand, flyUp } from "../src/backends/fly.ts";
import {
derivedTomlFor,
derivedPluginTomlFor,
flyCheckLive,
flyLiveSessionCommand,
flyS3ProbeCommand,
flyUp,
} from "../src/backends/fly.ts";
import type { ResolvedPlugin } from "../src/plugins.ts";

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");

test("Fly runs the live session smoke inside the private core machine", () => {
assert.equal(flyLiveSessionCommand(), "node src/deployment/postdeploy-smoke.ts session http://127.0.0.1:8080");
});

test("a fly config's sandbox block rewrites the core fly.toml [env] (app, image, env literals)", () => {
const { config } = loadConfigAt(join(repoRoot, "deploy", "stacks", "acme", "qm.config.jsonc"));
const cfg = {
Expand Down
Loading