mirror of
https://github.com/andrewyng/openworker.git
synced 2026-11-04 13:27:11 +00:00
374 lines
11 KiB
JavaScript
374 lines
11 KiB
JavaScript
// Local container installer. No cloud calls, Docker socket mounts or kubecontext changes.
|
|
import { spawn } from "node:child_process";
|
|
import {
|
|
readFile,
|
|
writeFile,
|
|
mkdir,
|
|
copyFile,
|
|
chmod,
|
|
stat,
|
|
} from "node:fs/promises";
|
|
import { resolve, dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const repo = resolve(here, "../..");
|
|
export async function run(
|
|
bin,
|
|
args,
|
|
{ input, timeout = 240000, inherit = false } = {},
|
|
) {
|
|
return new Promise((ok, no) => {
|
|
const p = spawn(bin, args, {
|
|
stdio: inherit
|
|
? ["pipe", "inherit", "inherit"]
|
|
: ["pipe", "pipe", "pipe"],
|
|
});
|
|
let out = "",
|
|
err = "";
|
|
if (!inherit) {
|
|
p.stdout.on("data", (b) => {
|
|
if (out.length < 2000000) out += b;
|
|
});
|
|
p.stderr.on("data", (b) => {
|
|
if (err.length < 4000) err += b;
|
|
});
|
|
}
|
|
p.on("error", () => no(Error(`${bin} could not start`)));
|
|
p.stdin.on("error", () => {});
|
|
p.stdin.end(input);
|
|
const timer = setTimeout(() => p.kill("SIGTERM"), timeout);
|
|
p.on("close", (code) => {
|
|
clearTimeout(timer);
|
|
code === 0
|
|
? ok(out)
|
|
: no(
|
|
Error(
|
|
`${bin} failed (${code}); inspect this deployment's logs. Output withheld to protect credentials.`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
async function privateWrite(path, value) {
|
|
await writeFile(path, value, { mode: 0o600, flag: "wx" });
|
|
}
|
|
async function certificate(input, name, cn, san) {
|
|
const ca = join(input, `${name}-ca`),
|
|
cert = join(input, name);
|
|
await run("openssl", [
|
|
"req",
|
|
"-x509",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-nodes",
|
|
"-keyout",
|
|
ca + ".key",
|
|
"-out",
|
|
ca + ".crt",
|
|
"-days",
|
|
"365",
|
|
"-subj",
|
|
`/CN=${cn} development CA`,
|
|
]);
|
|
await run("openssl", [
|
|
"req",
|
|
"-new",
|
|
"-newkey",
|
|
"rsa:2048",
|
|
"-nodes",
|
|
"-keyout",
|
|
cert + ".key",
|
|
"-out",
|
|
cert + ".csr",
|
|
"-subj",
|
|
`/CN=${cn}`,
|
|
]);
|
|
await privateWrite(
|
|
cert + ".ext",
|
|
`subjectAltName=${san}\nextendedKeyUsage=serverAuth\n`,
|
|
);
|
|
await run("openssl", [
|
|
"x509",
|
|
"-req",
|
|
"-in",
|
|
cert + ".csr",
|
|
"-CA",
|
|
ca + ".crt",
|
|
"-CAkey",
|
|
ca + ".key",
|
|
"-CAcreateserial",
|
|
"-out",
|
|
cert + ".crt",
|
|
"-days",
|
|
"30",
|
|
"-extfile",
|
|
cert + ".ext",
|
|
]);
|
|
for (const f of [ca + ".key", cert + ".key"]) await chmod(f, 0o600);
|
|
}
|
|
export function serviceConfig(c, id) {
|
|
const gateway = new URL(c.openshell.endpoint);
|
|
const scripted = c.scripted === true;
|
|
const provider = c.modelProvider ?? "openai";
|
|
return {
|
|
service: {
|
|
id,
|
|
namespace: "local",
|
|
agent: "installed-tools",
|
|
legacy_compatibility: false,
|
|
},
|
|
database_env: "OW_PG_URL",
|
|
schema: "ow_service",
|
|
database_loopback_plaintext: false,
|
|
database_ca: "/run/service/pg-ca.crt",
|
|
state_root: "/state",
|
|
bundle: "/opt/openworker/bundle",
|
|
runner: "/runner/openworker",
|
|
model: scripted ? "fixture" : c.model,
|
|
scripted,
|
|
model_provider: provider,
|
|
model_connections: scripted
|
|
? []
|
|
: [
|
|
{
|
|
id: "primary",
|
|
provider,
|
|
identity: `${id}-model`,
|
|
key_env:
|
|
provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY",
|
|
},
|
|
],
|
|
api_token_file: "/bootstrap/admin-token",
|
|
actor: "local-admin",
|
|
openshell: {
|
|
identity: id,
|
|
endpoint: gateway.toString().replace(/\/$/, ""),
|
|
certificates: "/openshell",
|
|
tls_server_name: c.openshell.tls_server_name,
|
|
workspace: c.openshell.workspace ?? "default",
|
|
image: c.openshell.image,
|
|
},
|
|
};
|
|
}
|
|
export async function init(root, connectionFile, image, port = 58443) {
|
|
root = resolve(root);
|
|
if (/[\r\n$]/.test(root))
|
|
throw Error("State path cannot contain newlines or dollar signs");
|
|
if (!Number.isInteger(port) || port < 1024 || port > 65535)
|
|
throw Error("Invalid local port");
|
|
const c = JSON.parse(await readFile(resolve(connectionFile), "utf8"));
|
|
const gateway = new URL(c.openshell.endpoint);
|
|
if (
|
|
gateway.protocol !== "https:" ||
|
|
gateway.username ||
|
|
gateway.password ||
|
|
gateway.search ||
|
|
gateway.hash
|
|
)
|
|
throw Error("OpenShell requires a verified HTTPS endpoint");
|
|
if (!c.openshell.image.includes("@sha256:"))
|
|
throw Error("Runner image must be pinned by digest");
|
|
const imageId = (
|
|
await run("docker", ["image", "inspect", image, "--format", "{{.Id}}"])
|
|
).trim();
|
|
const id =
|
|
c.serviceId ?? "ows-" + randomUUID().replaceAll("-", "").slice(0, 12);
|
|
if (!/^ows-[a-z0-9]{12}$/.test(id))
|
|
throw Error("Invalid explicit local service identity");
|
|
await mkdir(root, { mode: 0o700 });
|
|
await mkdir(join(root, "input"), { mode: 0o700 });
|
|
const input = join(root, "input");
|
|
await mkdir(join(input, "openshell"), { mode: 0o700 });
|
|
for (const name of ["ca.crt", "tls.crt", "tls.key"]) {
|
|
await copyFile(
|
|
join(resolve(c.openshell.certificates), name),
|
|
join(input, "openshell", name),
|
|
);
|
|
await chmod(join(input, "openshell", name), 0o600);
|
|
}
|
|
await copyFile(resolve(c.runner), join(input, "runner"));
|
|
await chmod(join(input, "runner"), 0o500);
|
|
const password = randomBytes(32).toString("hex"),
|
|
admin = randomBytes(32).toString("hex");
|
|
await privateWrite(join(input, "pg-password"), password);
|
|
await privateWrite(join(input, "admin-token"), admin);
|
|
await certificate(input, "pg", "postgres", "DNS:postgres");
|
|
await certificate(input, "api", "localhost", "DNS:localhost,IP:127.0.0.1");
|
|
await privateWrite(
|
|
join(input, "database-url"),
|
|
`postgresql://openworker:${password}@postgres:5432/openworker`,
|
|
);
|
|
const scripted = c.scripted === true;
|
|
if (!scripted && (!c.model || !c.modelKeyFile))
|
|
throw Error(
|
|
"Live setup requires explicit model and modelKeyFile; use scripted:true for qualification",
|
|
);
|
|
const provider = c.modelProvider ?? "openai";
|
|
if (!["openai", "anthropic"].includes(provider))
|
|
throw Error("Unsupported local model provider");
|
|
if (!scripted) {
|
|
await copyFile(resolve(c.modelKeyFile), join(input, `model-${provider}`));
|
|
await chmod(join(input, `model-${provider}`), 0o600);
|
|
}
|
|
const config = serviceConfig(c, id);
|
|
await privateWrite(
|
|
join(input, "config.json"),
|
|
JSON.stringify(config, null, 2),
|
|
);
|
|
await privateWrite(
|
|
join(input, "nginx.conf"),
|
|
`pid /tmp/nginx.pid;\nerror_log /dev/stderr warn;\nevents {worker_connections 256;}\nhttp {access_log off;client_body_temp_path /tmp/body;proxy_temp_path /tmp/proxy;fastcgi_temp_path /tmp/fastcgi;uwsgi_temp_path /tmp/uwsgi;scgi_temp_path /tmp/scgi;\nserver {listen 8443 ssl;ssl_certificate /tls/tls.crt;ssl_certificate_key /tls/tls.key;ssl_protocols TLSv1.2 TLSv1.3;client_max_body_size 160k;\nlocation ~ ^/(v1/)?sessions/[A-Za-z0-9_-]+/artifacts/[A-Za-z0-9_-]+/content$ {client_max_body_size 64m;client_body_timeout 120s;proxy_pass http://127.0.0.1:8080;proxy_http_version 1.1;proxy_set_header Connection "";proxy_request_buffering off;proxy_buffering off;proxy_read_timeout 120s;proxy_send_timeout 120s;}\nlocation / {proxy_pass http://127.0.0.1:8080;proxy_http_version 1.1;proxy_set_header Connection "";proxy_buffering off;proxy_read_timeout 3600s;}}}\n`,
|
|
);
|
|
const manifest = {
|
|
id,
|
|
image,
|
|
imageId,
|
|
port,
|
|
url: `https://localhost:${port}`,
|
|
root,
|
|
scripted,
|
|
};
|
|
await privateWrite(
|
|
join(root, "deployment.json"),
|
|
JSON.stringify(manifest, null, 2),
|
|
);
|
|
await privateWrite(
|
|
join(root, "cli.json"),
|
|
JSON.stringify(
|
|
{
|
|
selected: "local",
|
|
contexts: {
|
|
local: {
|
|
url: manifest.url,
|
|
namespace: "local",
|
|
tokenFile: join(input, "admin-token"),
|
|
tokenEnv: null,
|
|
caFile: join(input, "api-ca.crt"),
|
|
serviceId: id,
|
|
},
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
console.log(
|
|
JSON.stringify({
|
|
id,
|
|
url: manifest.url,
|
|
cliConfig: join(root, "cli.json"),
|
|
}),
|
|
);
|
|
return manifest;
|
|
}
|
|
export async function load(root) {
|
|
root = resolve(root);
|
|
const m = JSON.parse(await readFile(join(root, "deployment.json"), "utf8"));
|
|
if (m.root !== root || !/^ows-[a-f0-9]{12}$/.test(m.id))
|
|
throw Error("Deployment identity/path mismatch");
|
|
return m;
|
|
}
|
|
export async function compose(m, args, opts = {}) {
|
|
const current = (
|
|
await run("docker", ["image", "inspect", m.image, "--format", "{{.Id}}"])
|
|
).trim();
|
|
if (current !== m.imageId)
|
|
throw Error(
|
|
"Service image changed; create a new deployment or perform an explicit upgrade",
|
|
);
|
|
// env file contains references only; no passwords or access tokens.
|
|
const path = join(m.root, "compose.env");
|
|
await writeFile(
|
|
path,
|
|
`SERVICE_IMAGE=${m.imageId}\nSTATE_DIR=${m.root}\nAPI_PORT=${m.port}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
return run(
|
|
"docker",
|
|
[
|
|
"compose",
|
|
"--project-name",
|
|
m.id,
|
|
"--env-file",
|
|
path,
|
|
"-f",
|
|
join(here, "compose.yaml"),
|
|
...args,
|
|
],
|
|
opts,
|
|
);
|
|
}
|
|
async function main() {
|
|
const [cmd, root, arg, image, port] = process.argv.slice(2);
|
|
if (!cmd || !root)
|
|
throw Error(
|
|
"Usage: node deploy/service/stack.mjs init STATE CONNECTION.json IMAGE [PORT] | up/down/status/destroy STATE [DEPLOYMENT_ID]",
|
|
);
|
|
if (cmd === "init") {
|
|
await init(root, arg, image, port ? Number(port) : 58443);
|
|
return;
|
|
}
|
|
const m = await load(root);
|
|
if (m.destroyed || (m.destroying && cmd !== "destroy"))
|
|
throw Error(
|
|
"Deployment was destroyed or destruction started; use a new state directory and identity",
|
|
);
|
|
if (cmd === "up") {
|
|
await compose(m, ["up", "-d", "--wait"], { inherit: true });
|
|
console.log(
|
|
JSON.stringify({ url: m.url, cliConfig: join(m.root, "cli.json") }),
|
|
);
|
|
} else if (cmd === "down") await compose(m, ["down"], { inherit: true });
|
|
else if (cmd === "status") console.log(await compose(m, ["ps", "--all"]));
|
|
else if (cmd === "restart-api")
|
|
await compose(m, ["restart", "api"], { inherit: true });
|
|
else if (cmd === "destroy") {
|
|
if (arg !== m.id) throw Error("destroy requires the exact deployment ID");
|
|
if (!m.destroying) {
|
|
const remaining = (
|
|
await compose(m, [
|
|
"exec",
|
|
"-T",
|
|
"postgres",
|
|
"psql",
|
|
"-U",
|
|
"openworker",
|
|
"-d",
|
|
"openworker",
|
|
"-Atc",
|
|
"SELECT count(*) FROM ow_service.service_environment_owners o JOIN ow_service.environments e ON e.id=o.environment_id WHERE e.data->>'phase'<>'deleted'",
|
|
])
|
|
).trim();
|
|
if (remaining !== "0")
|
|
throw Error(
|
|
"Close sessions and delete environments with ows before destroying the database. down retains data.",
|
|
);
|
|
await writeFile(
|
|
join(m.root, "deployment.json"),
|
|
JSON.stringify({ ...m, destroying: true }, null, 2),
|
|
{ mode: 0o600 },
|
|
);
|
|
}
|
|
await compose(m, ["down", "--volumes", "--remove-orphans"], {
|
|
inherit: true,
|
|
});
|
|
await writeFile(
|
|
join(m.root, "deployment.json"),
|
|
JSON.stringify({ ...m, destroyed: true }, null, 2),
|
|
{ mode: 0o600 },
|
|
);
|
|
console.log(
|
|
"Deployment containers and volumes removed; local credential files retained for operator disposal.",
|
|
);
|
|
} else throw Error("Unknown command");
|
|
}
|
|
if (
|
|
process.argv[1] &&
|
|
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
)
|
|
main().catch((e) => {
|
|
console.error(e.message);
|
|
process.exitCode = 1;
|
|
});
|