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
6 changes: 6 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ computed secret names, tools, skills, and plugins without network access; `up`,
`sandbox build` run the same checks first. `doctor` verifies external prerequisites read-only.
`plan` renders the deployment; AWS mutation requires `up --yes`.

For a single-host Docker deployment, `sandbox.backend: "local"` runs each agent
computer in its own container. `qm up` builds the local runtime from the CLI's
pinned sandbox base, mounts the host Docker socket into trusted core, and connects
core to each sandbox's private network. An explicit `sandbox.image` uses that
runnable local image instead.

On AWS, `up` snapshots the RDS instance under the deploy lease before its first
mutation, names the snapshot after the deployment manifest it precedes, and
records it in that manifest. `rollback` restores code and configuration only,
Expand Down
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.7",
"version": "0.1.8",
"license": "MIT",
"description": "Control-plane CLI for portable QM deployments on Docker, Fly, and AWS.",
"type": "module",
Expand Down
99 changes: 94 additions & 5 deletions cli/src/backends/docker.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { httpDeploymentLayerTransport, type DeploymentLayerTransport } from "../deployment-layer.ts";

import { randomBytes } from "node:crypto";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { CliError, bold, die, dim, errMessage, header, note, ok, step, warn } from "../log.ts";
Expand All @@ -18,7 +18,7 @@ import {
tailString,
which,
} from "../util.ts";
import { manifestRef } from "../manifest.ts";
import { manifestRef, sandboxBaseRef } from "../manifest.ts";
import {
brokerWiring,
ordered,
Expand All @@ -31,7 +31,7 @@ import {
type LogOpts,
type ServiceName,
} from "../services.ts";
import { dockerBasePort, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts";
import { dockerBasePort, localSandboxActive, sandboxCoreEnv, securityScreenEnv, type QmConfig } from "../config.ts";
import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts";
import { computedSecrets, runtimeSecretNames, secretsForService } from "../secrets.ts";
import { readDeploymentState, withDeploymentLock, writeDeploymentState, type DeploymentState } from "../state.ts";
Expand Down Expand Up @@ -66,6 +66,69 @@ const dockerPrefix = (config: QmConfig): string => `qm-${safe(config.orgId)}`;
const cname = (ctx: DockerCtx, name: string): string => `${ctx.prefix}-${name}`;
const pgVolume = (ctx: DockerCtx): string => `${ctx.prefix}-pgdata`;

const localSandboxImage = (config: QmConfig): string =>
config.sandbox?.image ?? `${dockerPrefix(config).toLowerCase()}-sandbox-local:latest`;

function localAgentSource(): Buffer {
const source = new URL("../../templates/aws/microvm-agent/agent.mjs", import.meta.url);
const packaged = new URL("../../../templates/aws/microvm-agent/agent.mjs", import.meta.url);
return readFileSync(existsSync(source) ? source : packaged);
}

function ensureLocalSandboxImage(config: QmConfig): string {
const image = localSandboxImage(config);
if (config.sandbox?.image) return image;
const base = sandboxBaseRef();
try {
const labeled = capture("docker", [
"image",
"inspect",
"-f",
'{{index .Config.Labels "qm.local-sandbox-base"}}',
image,
]).trim();
if (labeled === base) return image;
} catch {
// Build the local wrapper when it is absent or stale.
}
const dir = mkdtempSync(join(tmpdir(), "qm-local-sandbox-"));
try {
writeFileSync(join(dir, "agent.mjs"), localAgentSource());
writeFileSync(
join(dir, "Dockerfile"),
`ARG BASE\nFROM \${BASE}\nCOPY agent.mjs /opt/qm/agent.mjs\nENV HOME=/root\nWORKDIR /root\nEXPOSE 8080\nCMD ["node", "/opt/qm/agent.mjs"]\n`,
);
dockerInherit([
"build",
"--build-arg",
`BASE=${base}`,
"--label",
`qm.local-sandbox-base=${base}`,
"-t",
image,
dir,
]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
return image;
}

function hostDockerSocket(): { path: string; gid?: string } {
const configured = process.env.DOCKER_HOST?.trim();
if (configured && !configured.startsWith("unix://")) {
throw new CliError('sandbox.backend "local" requires a Unix Docker socket');
}
const path = configured?.slice("unix://".length) || "/var/run/docker.sock";
let gid: string | undefined;
try {
gid = capture("stat", ["-c", "%g", path]).trim() || undefined;
} catch {
throw new CliError(`sandbox.backend "local" cannot read the Docker socket at ${path}`);
}
return { path, ...(gid ? { gid } : {}) };
}

function requireDocker(): void {
if (!which("docker")) die("docker not found on PATH (the docker target needs a running Docker daemon).");
try {
Expand Down Expand Up @@ -290,6 +353,10 @@ export function dockerServiceEnv(config: QmConfig, service: ServiceName): Record
CORE_API_URL: "http://core:8080",
...orgEnv(service, config.orgId, config.publicUrl, config.services.includes("portal"), brandEnvOf(config)),
};
if (service === "core" && localSandboxActive(config)) {
out.DOCKER_HOST = "unix:///var/run/docker.sock";
out.QM_CORE_CONTAINER = `${dockerPrefix(config)}-core`;
}
if (service === "portal") {
if (config.services.includes("web-ui")) out.WEB_UI_UPSTREAM = "http://web-ui:8080";
if (config.services.includes("admin")) out.ADMIN_UPSTREAM = "http://admin:8080";
Expand Down Expand Up @@ -328,6 +395,10 @@ function serviceEnv(ctx: DockerCtx, service: ServiceName): Record<string, string
const layerSubs = existingLayerSubdirs(ctx);
if (layerSubs.length) out.DEPLOYMENT_LAYER = "/layer";
Object.assign(out, ctx.sandboxEnv);
if (localSandboxActive(config)) {
out.DOCKER_HOST = "unix:///var/run/docker.sock";
out.QM_CORE_CONTAINER = `${ctx.prefix}-core`;
}
} else {
Object.assign(out, dockerServiceEnv(config, service));
}
Expand Down Expand Up @@ -414,6 +485,11 @@ function runArgs(ctx: DockerCtx, service: ServiceName, image: string): { args: s
args.push("-v", `${ctx.prefix}-coredata:/data`);
for (const m of layerMounts(ctx)) args.push("-v", m);
for (const m of skillMounts(ctx)) args.push("-v", m);
if (localSandboxActive(ctx.config)) {
const socket = hostDockerSocket();
args.push("-v", `${socket.path}:/var/run/docker.sock`);
if (socket.gid) args.push("--group-add", socket.gid);
}
}
if (def.docker.hostPortOffset !== undefined) {
args.push("-p", `${baseHostPort(ctx) + def.docker.hostPortOffset}:${def.docker.internalPort}`);
Expand Down Expand Up @@ -471,7 +547,13 @@ async function waitPluginUp(name: string): Promise<void> {
function buildCtx(
config: QmConfig,
configDir: string,
opts: { sandboxDir?: string; buildFrom: boolean; buildFromPath?: string; envFile?: string },
opts: {
sandboxDir?: string;
buildFrom: boolean;
buildFromPath?: string;
envFile?: string;
localSandboxImage?: string;
},
): DockerCtx {
const prefix = dockerPrefix(config);
const envFile = opts.envFile ? resolve(opts.envFile) : join(configDir, ".env");
Expand All @@ -493,7 +575,10 @@ function buildCtx(
if (signingSecret) ctx.signingSecret = signingSecret;
const lookup = (name: string): string | undefined => deploymentSecretValue(name, readEnvValue(ctx.envFile, name));
const sb = sandboxCoreEnv(config, lookup);
ctx.sandboxEnv = sb.env;
ctx.sandboxEnv = {
...sb.env,
...(opts.localSandboxImage ? { LOCAL_SANDBOX_IMAGE: opts.localSandboxImage } : {}),
};
ctx.missingSandboxSecrets = sb.missingSecrets;
if (opts.buildFrom) ctx.repoRoot = resolveBuildRepoRoot(opts.buildFromPath, runnableServices(config.services));
return ctx;
Expand Down Expand Up @@ -528,11 +613,13 @@ export async function dockerUp(
opts: { sandboxDir?: string; buildFrom?: boolean; buildFromPath?: string; envFile?: string; dryRun?: boolean } = {},
): Promise<void> {
if (!opts.dryRun) requireDocker();
const resolvedLocalImage = localSandboxActive(config) ? localSandboxImage(config) : undefined;
const ctx = buildCtx(config, configDir, {
sandboxDir: opts.sandboxDir,
buildFrom: opts.buildFrom ?? false,
buildFromPath: opts.buildFromPath,
envFile: opts.envFile,
...(resolvedLocalImage ? { localSandboxImage: resolvedLocalImage } : {}),
});
const plugins = discoverPlugins(configDir, config).plugins;

Expand All @@ -550,6 +637,7 @@ export async function dockerUp(
if (opts.dryRun) {
ctx.databaseUrl = ensurePostgres(ctx, true);
step(`network: ${ctx.network}`);
if (resolvedLocalImage) step(`sandbox: local image ${resolvedLocalImage}`);
for (const def of ordered(runnableServices(config.services))) {
const ports =
def.docker.hostPortOffset !== undefined ? ` (host :${baseHostPort(ctx) + def.docker.hostPortOffset})` : "";
Expand Down Expand Up @@ -582,6 +670,7 @@ export async function dockerUp(
);
}

if (localSandboxActive(config)) ensureLocalSandboxImage(config);
ensureNetwork(ctx);
ctx.databaseUrl = ensurePostgres(ctx, false);
if (!externalDatabaseUrl(ctx)) await waitPostgres(ctx);
Expand Down
5 changes: 4 additions & 1 deletion cli/src/backends/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import {
MODEL_PROVIDER_KEYS,
localSandboxActive,
mockHarnessWarning,
validatePortalTrust,
type ModelProvider,
Expand Down Expand Up @@ -143,7 +144,9 @@ export async function doctorCommon(
);
step("required local secret values: ok");
}
if (config.target === "aws") {
if (localSandboxActive(config)) {
step("local Docker sandbox: configured");
} else if (config.target === "aws") {
step("AWS Lambda MicroVM sandbox: configured");
} else if (config.sandbox?.app) {
requireFlyAuth();
Expand Down
4 changes: 2 additions & 2 deletions cli/src/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { readEnvFile } from "../util.ts";
import { CliError, errMessage, header, note, ok, step, warn } from "../log.ts";
import { validateSandboxLayer, type SandboxValidation } from "../sandbox-layer.ts";
import { discoverPlugins, type ResolvedPlugin } from "../plugins.ts";
import { mockHarnessWarning, sandboxPinPending, type QmConfig } from "../config.ts";
import { localSandboxActive, mockHarnessWarning, sandboxPinPending, type QmConfig } from "../config.ts";
import { computedSecrets, runtimeSecretNames, type ComputedSecret } from "../secrets.ts";
import { isVirtualService, runnableServices } from "../services.ts";
import { serviceEnvironment } from "../backends/aws.ts";
Expand All @@ -30,7 +30,7 @@ export function runChecks(
const configError = (message: string, clause = "config.v1"): void => void configErrors.push({ clause, message });
const provider = hostingProvider(config.target);
configErrors.push(...provider.validateConfig(config, plugins));
if (provider.requiresSandboxApp && !config.sandbox?.app?.trim()) {
if (provider.requiresSandboxApp && !localSandboxActive(config) && !config.sandbox?.app?.trim()) {
configError("contract sandbox.app: a Fly agent-computer app is required for docker and fly targets");
}
for (const skill of config.skills) {
Expand Down
29 changes: 23 additions & 6 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface PluginEntry {
}

export interface SandboxConfig {
backend?: "sprites" | "aws";
backend?: "local" | "sprites" | "aws";
app?: string;
image?: string;
baseImage?: string;
Expand Down Expand Up @@ -205,10 +205,14 @@ export const isDigestPinned = (ref: string): boolean => /@sha256:[0-9a-f]{64}$/.

const SANDBOX_PIN_PENDING = `"sandbox.app" is set but no sandbox layer image is pinned; run \`qm sandbox publish\` to build and record the digest-pinned "sandbox.image" agents boot from`;

export const localSandboxActive = (config: QmConfig): boolean =>
config.target === "docker" && config.sandbox?.backend === "local";

export const sandboxPinPending = (config: QmConfig): boolean =>
config.target !== "aws" && Boolean(config.sandbox?.app && !config.sandbox.image);
config.target !== "aws" && !localSandboxActive(config) && Boolean(config.sandbox?.app && !config.sandbox.image);

export function sandboxImagePinErrors(config: QmConfig): Array<{ clause: string; message: string }> {
if (localSandboxActive(config)) return [];
const sb = config.sandbox;
if (!sb?.app || !sb.image || isDigestPinned(sb.image)) return [];
return [
Expand All @@ -227,6 +231,11 @@ export function sandboxCoreEnv(
const missingSecrets: string[] = [];
const sb = config.sandbox;
if (!sb) return { env, missingSecrets };
if (localSandboxActive(config)) {
env.SANDBOX_BACKEND = "local";
if (sb.image) env.LOCAL_SANDBOX_IMAGE = sb.image;
return { env, missingSecrets };
}
if (sb.app) {
if (!sb.image) throw new CliError(SANDBOX_PIN_PENDING, { clause: "config.v1" });
const violation = sandboxImagePinErrors(config)[0];
Expand Down Expand Up @@ -1326,9 +1335,9 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon
};
const out: SandboxConfig = {};
if (o["backend"] !== undefined) {
if (o["backend"] !== "sprites" && o["backend"] !== "aws") {
if (o["backend"] !== "local" && o["backend"] !== "sprites" && o["backend"] !== "aws") {
throw new CliError(
`${path}: "sandbox.backend" must be "sprites" (Fly Sprites, booting the operator-published layer image from the Fly app in "sandbox.app") or "aws" (Lambda MicroVM sandboxes)`,
`${path}: "sandbox.backend" must be "local" (Docker containers on the deployment host), "sprites" (Fly Sprites), or "aws" (Lambda MicroVM sandboxes)`,
);
}
out.backend = o["backend"];
Expand Down Expand Up @@ -1370,6 +1379,14 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon
const label = out.backend === "aws" ? " (Lambda MicroVM sandboxes)" : "";
throw new CliError(`${path}: "sandbox.backend": ${JSON.stringify(out.backend)}${label} requires target ${targets}`);
}
if (out.backend === "local") {
const stray = (["app", "baseImage", "env", "secretEnv"] as const).filter((key) => out[key] !== undefined);
if (stray.length) {
throw new CliError(
`${path}: "sandbox.backend": "local" ignores ${stray.map((key) => `"sandbox.${key}"`).join(", ")} — remove them; use "sandbox.image" for the runnable local sandbox image`,
);
}
}
if (out.backend === "aws") {
const stray = (["app", "image", "baseImage", "env", "secretEnv"] as const).filter((key) => out[key] !== undefined);
if (stray.length) {
Expand All @@ -1378,8 +1395,8 @@ function validateSandbox(raw: unknown, path: string, target: Target): SandboxCon
);
}
}
if (out.image && !out.app) {
throw new CliError(`${path}: "sandbox.image" requires "sandbox.app" (the app the microVMs run in)`);
if (out.image && !out.app && out.backend !== "local") {
throw new CliError(`${path}: "sandbox.image" requires "sandbox.app" unless "sandbox.backend" is "local"`);
}
if (out.backend === "sprites" && !out.app) {
throw new CliError(
Expand Down
4 changes: 2 additions & 2 deletions cli/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export type Target = (typeof HOSTING_PROVIDER_IDS)[number];
export const isTarget = (value: unknown): value is Target =>
typeof value === "string" && (HOSTING_PROVIDER_IDS as readonly string[]).includes(value);

export type SandboxBackendId = "sprites" | "aws";
export type SandboxBackendId = "local" | "sprites" | "aws";

export interface SandboxBackendPolicy {
/** Sandbox backends this hosting target can run. */
Expand All @@ -16,7 +16,7 @@ export interface SandboxBackendPolicy {

/** Keyed by hosting target so adding a target forces a sandbox-backend decision. */
export const SANDBOX_BACKEND_POLICY: Record<Target, SandboxBackendPolicy> = {
docker: { allowed: ["sprites"], requireExplicit: false },
docker: { allowed: ["local", "sprites"], requireExplicit: false },
fly: { allowed: ["sprites"], requireExplicit: false },
aws: { allowed: ["sprites", "aws"], requireExplicit: true },
};
Expand Down
12 changes: 12 additions & 0 deletions cli/test/auth-broker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ test("docker and AWS wire the broker with parity", () => {
assert.equal(serviceEnvironment(aws, "auth").PORT, "8080");
});

test("docker local wires the host daemon coordinates only into core", () => {
const local = configWith(
configText().replace(
'"plugins": [],',
'"sandbox": { "backend": "local", "image": "qm-sandbox-local:latest" }, "plugins": [],',
),
);
assert.equal(dockerServiceEnv(local, "core").DOCKER_HOST, "unix:///var/run/docker.sock");
assert.equal(dockerServiceEnv(local, "core").QM_CORE_CONTAINER, "qm-acme-core");
assert.equal(dockerServiceEnv(local, "portal").DOCKER_HOST, undefined);
});

test("the broker's generated secrets reach both sides under the right names", () => {
const config = brokerConfig();
const secrets = computedSecrets(config);
Expand Down
9 changes: 9 additions & 0 deletions cli/test/check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ test("a bare deployment (no sandbox/, no plugins) passes", () => {
}
});

test("docker with sandbox.backend local passes without a Fly sandbox app", () => {
const d = deployment(() => {}, { sandbox: { backend: "local", image: "qm-sandbox-local:latest" } });
try {
assert.doesNotThrow(() => check(d));
} finally {
rmSync(d.dir, { recursive: true, force: true });
}
});

test("AWS requires exact ECS/ECR coordinates for discovered plugins", () => {
const plugin = { name: "linear", image: "ghcr.io/acme/linear:1" };
const aws = {
Expand Down
Loading