Files
elizaos--eliza/scripts/cloud/mock-stack-up.mjs
T
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

493 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* Eliza cloud — one-command local mock stack.
*
* Boots Hetzner mock, control-plane mock, cloud-api (with MOCK_REDIS + PGlite),
* and cloud-frontend, wired together with auto-picked ports and health-check
* gating. Streams each subprocess to ./.logs/<service>.log and the console
* with a colored prefix. Ctrl+C triggers ordered graceful shutdown.
*/
import { spawn, spawnSync } from "node:child_process";
import { createWriteStream, existsSync, mkdirSync, writeSync } from "node:fs";
import net from "node:net";
import path from "node:path";
import process from "node:process";
const REPO_ROOT = path.resolve(import.meta.dirname, "../..");
const LOG_DIR = path.join(REPO_ROOT, ".logs");
const PGDATA_DIR = path.join(REPO_ROOT, ".eliza/.pgdata");
const RM_RECURSIVE_SCRIPT = path.join(
REPO_ROOT,
"packages",
"scripts",
"rm-path-recursive.mjs",
);
const USAGE = `Usage: bun scripts/cloud/mock-stack-up.mjs [flags]
Boots the local Eliza cloud mock stack (Hetzner mock + control-plane mock +
cloud-api with MOCK_REDIS + PGlite, optionally cloud-frontend) wired together.
Flags:
--no-frontend skip cloud-frontend
--no-cp skip control-plane mock
--no-hetzner skip hetzner mock
--no-migrations skip cloud-shared db:migrate
--reset wipe PGlite data dir before booting
--with-daemon run the autoscale / hot-pool / pool-replenish cron
loops on real intervals against the mock stack, so the
pool is maintained by the daemon (not test-driven ticks).
Interval (ms) overridable via DAEMON_TICK_MS (default 15000).
--port-frontend N override frontend port
--port-api N override cloud-api port
--port-cp N override control-plane port
--port-hetzner N override hetzner port
--help print this usage
`;
function parseFlags(argv) {
const flags = {
noFrontend: false,
noCp: false,
noHetzner: false,
noMigrations: false,
reset: false,
withDaemon: false,
help: false,
portFrontend: undefined,
portApi: undefined,
portCp: undefined,
portHetzner: undefined,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
switch (a) {
case "--no-frontend":
flags.noFrontend = true;
break;
case "--no-cp":
flags.noCp = true;
break;
case "--no-hetzner":
flags.noHetzner = true;
break;
case "--no-migrations":
flags.noMigrations = true;
break;
case "--reset":
flags.reset = true;
break;
case "--with-daemon":
flags.withDaemon = true;
break;
case "--help":
case "-h":
flags.help = true;
break;
case "--port-frontend":
flags.portFrontend = Number.parseInt(argv[++i], 10);
break;
case "--port-api":
flags.portApi = Number.parseInt(argv[++i], 10);
break;
case "--port-cp":
flags.portCp = Number.parseInt(argv[++i], 10);
break;
case "--port-hetzner":
flags.portHetzner = Number.parseInt(argv[++i], 10);
break;
default:
return { error: `Unknown flag: ${a}` };
}
}
return { flags };
}
const COLOR = {
reset: "\x1b[0m",
cyan: "\x1b[36m",
magenta: "\x1b[35m",
yellow: "\x1b[33m",
green: "\x1b[32m",
red: "\x1b[31m",
gray: "\x1b[90m",
};
const color = (name, text) => `${COLOR[name] ?? ""}${text}${COLOR.reset}`;
function pickPort() {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.unref();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
const p = typeof addr === "object" && addr ? addr.port : 0;
srv.close(() => resolve(p));
});
});
}
async function waitForHttp(url, { timeoutMs = 60_000, intervalMs = 500 } = {}) {
const start = Date.now();
let lastErr;
while (Date.now() - start < timeoutMs) {
try {
const res = await fetch(url, { method: "GET" });
if (res.status > 0) return true;
} catch (e) {
lastErr = e;
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(
`Timeout waiting for ${url}: ${lastErr?.message ?? "no response"}`,
);
}
const services = [];
/** Active --with-daemon interval timers, cleared on shutdown. */
const daemonTimers = [];
/**
* The cron loops that, in production, a long-running daemon ticks on its own
* schedule. `--with-daemon` drives them here so the mock stack maintains its
* pool over time without test-enqueued provision jobs (#8921).
*/
const DAEMON_CRONS = [
{ name: "node-autoscale", path: "/v1/cron/node-autoscale" },
{ name: "agent-hot-pool", path: "/v1/cron/agent-hot-pool" },
{ name: "pool-replenish", path: "/v1/cron/pool-replenish" },
];
/**
* Start the autoscale/hot-pool daemon loops against the running mock cloud-api.
* Each cron is POSTed on `tickMs` intervals with the CRON_SECRET bearer the
* routes expect (`verifyCronSecret`). Returns nothing; timers live in
* `daemonTimers` and are cleared by `shutdown`.
*/
function startDaemons(apiBase, cronSecret) {
const tickMs = Number(process.env.DAEMON_TICK_MS) || 15_000;
const headers = { authorization: `Bearer ${cronSecret}` };
for (const cron of DAEMON_CRONS) {
const tick = async () => {
try {
const res = await fetch(`${apiBase}${cron.path}`, {
method: "POST",
headers,
});
process.stdout.write(
`${color("gray", "[daemon]")} ${cron.name}${res.status}\n`,
);
} catch (e) {
process.stdout.write(
`${color("red", "[daemon]")} ${cron.name} tick failed: ${e?.message ?? e}\n`,
);
}
};
void tick(); // fire once immediately so the pool warms without waiting a full interval
const timer = setInterval(tick, tickMs);
timer.unref?.();
daemonTimers.push(timer);
}
process.stdout.write(
`${color("gray", "[stack]")} --with-daemon: ticking ${DAEMON_CRONS.length} cron loop(s) every ${tickMs}ms\n`,
);
}
function streamProcess(name, colorName, proc) {
const logPath = path.join(LOG_DIR, `${name}.log`);
const logStream = createWriteStream(logPath, { flags: "a" });
const prefix = color(colorName, `[${name}]`);
const tail = [];
const pipe = (stream) => {
let buf = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
logStream.write(chunk);
buf += chunk;
let idx;
while ((idx = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
tail.push(line);
if (tail.length > 100) tail.shift();
process.stdout.write(`${prefix} ${line}\n`);
}
});
};
pipe(proc.stdout);
pipe(proc.stderr);
const entry = { name, color: colorName, proc, logPath, tail };
services.push(entry);
return entry;
}
async function shutdown(code = 0) {
process.stdout.write(`${color("gray", "[stack]")} shutting down...\n`);
for (const timer of daemonTimers) clearInterval(timer);
daemonTimers.length = 0;
for (const svc of [...services].reverse()) {
if (svc.proc.exitCode === null && svc.proc.signalCode === null) {
try {
svc.proc.kill("SIGTERM");
} catch {}
}
}
const grace = setTimeout(() => {
for (const svc of services) {
if (svc.proc.exitCode === null && svc.proc.signalCode === null) {
try {
svc.proc.kill("SIGKILL");
} catch {}
}
}
}, 5_000);
await Promise.all(
services.map(
(svc) =>
new Promise((resolve) => {
if (svc.proc.exitCode !== null || svc.proc.signalCode !== null)
return resolve();
svc.proc.once("exit", () => resolve());
}),
),
);
clearTimeout(grace);
process.stdout.write(`${color("gray", "[stack]")} stopped\n`);
process.exit(code);
}
function dumpTail(svcName) {
const svc = services.find((s) => s.name === svcName);
if (!svc) return;
process.stderr.write(`\n--- last 30 lines of ${svcName} ---\n`);
for (const line of svc.tail.slice(-30)) process.stderr.write(`${line}\n`);
process.stderr.write(`--- end ${svcName} ---\n`);
}
function rmRecursive(targetPath) {
const result = spawnSync(
process.execPath,
[RM_RECURSIVE_SCRIPT, targetPath],
{
cwd: REPO_ROOT,
encoding: "utf8",
},
);
if (result.status !== 0) {
const detail = result.stderr || result.stdout || `exit ${result.status}`;
throw new Error(`recursive cleanup failed for ${targetPath}: ${detail}`);
}
}
async function main() {
const { flags, error } = parseFlags(process.argv.slice(2));
if (error) {
writeSync(1, `${error}\n\n${USAGE}`);
process.exit(1);
}
if (flags.help) {
writeSync(1, USAGE);
process.exit(0);
}
mkdirSync(LOG_DIR, { recursive: true });
if (flags.reset && existsSync(PGDATA_DIR)) {
process.stdout.write(
`${color("gray", "[stack]")} --reset: wiping ${PGDATA_DIR}\n`,
);
rmRecursive(PGDATA_DIR);
}
mkdirSync(PGDATA_DIR, { recursive: true });
const pickIfFalsy = async (v) =>
v && Number.isFinite(v) ? v : await pickPort();
const hetznerPort = await pickIfFalsy(
flags.portHetzner ?? Number(process.env.HETZNER_PORT),
);
const cpPort = await pickIfFalsy(flags.portCp ?? Number(process.env.CP_PORT));
const apiPort = await pickIfFalsy(
flags.portApi ?? Number(process.env.API_PORT),
);
const frontendPort = await pickIfFalsy(
flags.portFrontend ?? Number(process.env.FRONTEND_PORT),
);
process.on("SIGINT", () => void shutdown(0));
process.on("SIGTERM", () => void shutdown(0));
if (!flags.noMigrations) {
process.stdout.write(
`${color("gray", "[stack]")} running cloud-shared db:migrate...\n`,
);
const migrate = spawn(
"bun",
["run", "--cwd", "packages/cloud/shared", "db:migrate"],
{
cwd: REPO_ROOT,
env: { ...process.env, DATABASE_URL: `pglite://${PGDATA_DIR}` },
stdio: "inherit",
},
);
const code = await new Promise((res) => migrate.on("exit", (c) => res(c)));
if (code !== 0) {
process.stderr.write(`migrations failed (exit ${code})\n`);
process.exit(1);
}
}
const tHetzner = `http://127.0.0.1:${hetznerPort}`;
const tCp = `http://127.0.0.1:${cpPort}`;
const tApi = `http://127.0.0.1:${apiPort}`;
const tFrontend = `http://127.0.0.1:${frontendPort}`;
const baseEnv = {
...process.env,
MOCK_REDIS: "1",
DATABASE_URL: `pglite://${PGDATA_DIR}`,
HCLOUD_API_BASE_URL: `${tHetzner}/v1`,
HCLOUD_TOKEN: "local-mock-token",
CONTAINER_CONTROL_PLANE_URL: tCp,
CONTAINER_CONTROL_PLANE_TOKEN: "local-mock-token",
CRON_SECRET: "local-cron-secret",
// Fixed dev master key so the mock stack exercises the real secrets
// envelope end-to-end. Since #12229 (M4) the LocalKMSProvider fails closed
// when SECRETS_MASTER_KEY is unset, so any secrets op during a mock session
// (connector-OAuth token storage, provisioning/container-deploy secrets)
// would otherwise throw. This is a throwaway local-dev key, never a real one.
SECRETS_MASTER_KEY:
process.env.SECRETS_MASTER_KEY ?? "0123456789abcdef".repeat(4),
};
try {
if (!flags.noHetzner) {
const proc = spawn(
"bun",
[
"run",
"packages/test/cloud-mocks/bin/hetzner-mock.ts",
"--port",
String(hetznerPort),
],
{ cwd: REPO_ROOT, env: baseEnv },
);
streamProcess("hetzner", "cyan", proc);
try {
await waitForHttp(`${tHetzner}/`, { timeoutMs: 30_000 });
} catch (e) {
dumpTail("hetzner");
throw new Error(`hetzner mock failed to start: ${e.message}`);
}
}
if (!flags.noCp) {
const proc = spawn(
"bun",
["run", "packages/test/cloud-mocks/bin/control-plane-mock.ts"],
{
cwd: REPO_ROOT,
env: {
...baseEnv,
PORT: String(cpPort),
HCLOUD_API_BASE_URL: `${tHetzner}/v1`,
},
},
);
streamProcess("cp", "magenta", proc);
try {
await waitForHttp(`${tCp}/`, { timeoutMs: 30_000 });
} catch (e) {
dumpTail("cp");
throw new Error(`control-plane mock failed to start: ${e.message}`);
}
}
{
const proc = spawn("bun", ["run", "--cwd", "packages/cloud/api", "dev"], {
cwd: REPO_ROOT,
env: { ...baseEnv, API_DEV_PORT: String(apiPort) },
});
streamProcess("api", "yellow", proc);
try {
await waitForHttp(`${tApi}/`, { timeoutMs: 60_000 });
} catch (e) {
dumpTail("api");
throw new Error(`cloud-api failed to start: ${e.message}`);
}
}
// The apex frontend lives in packages/app; there is no standalone
// packages/cloud-frontend (#9093). Skip its boot gracefully when the dir is
// absent so the mock stack still comes up (api + control-plane).
const frontendDir = path.join(REPO_ROOT, "packages/cloud-frontend");
if (!flags.noFrontend && !existsSync(frontendDir)) {
process.stderr.write(
`${color("gray", "[stack]")} cloud-frontend removed (#9093); skipping frontend boot. The apex frontend now lives in packages/app.\n`,
);
} else if (!flags.noFrontend) {
const proc = spawn(
"bun",
[
"run",
"--cwd",
"packages/cloud-frontend",
"dev",
"--",
"--port",
String(frontendPort),
"--strictPort",
],
{ cwd: REPO_ROOT, env: { ...baseEnv, VITE_API_URL: tApi } },
);
streamProcess("fe", "green", proc);
try {
await waitForHttp(`${tFrontend}/`, { timeoutMs: 60_000 });
} catch (e) {
dumpTail("fe");
throw new Error(`cloud-frontend failed to start: ${e.message}`);
}
}
} catch (e) {
process.stderr.write(`\n${color("red", "[stack]")} ${e.message}\n`);
await shutdown(1);
return;
}
for (const svc of services) {
svc.proc.on("exit", (code, signal) => {
if (signal === "SIGTERM" || signal === "SIGKILL") return;
process.stderr.write(
`\n${color("red", "[stack]")} ${svc.name} exited unexpectedly (code=${code}, signal=${signal})\n`,
);
void shutdown(1);
});
}
const pad = (s, n) => (s + " ".repeat(n)).slice(0, n);
const lines = [
"┌────────────────────────────────────────────────────────────┐",
`│ ${pad("Eliza cloud mock stack — ready", 58)} │`,
"├────────────────────────────────────────────────────────────┤",
`│ Frontend ${pad(flags.noFrontend ? "(skipped)" : tFrontend, 41)} │`,
`│ Cloud API ${pad(tApi, 41)} │`,
`│ Control plane ${pad(flags.noCp ? "(skipped)" : tCp, 41)} │`,
`│ Hetzner mock ${pad(flags.noHetzner ? "(skipped)" : tHetzner, 41)} │`,
`│ DB (PGlite) ${pad("./.eliza/.pgdata", 41)} │`,
`│ Redis ${pad("in-memory (MOCK_REDIS=1)", 41)} │`,
"└────────────────────────────────────────────────────────────┘",
`Logs streaming to ${path.relative(REPO_ROOT, LOG_DIR)}/<service>.log`,
"Ctrl+C to stop all.",
];
process.stdout.write(`\n${lines.join("\n")}\n`);
if (flags.withDaemon) {
startDaemons(tApi, baseEnv.CRON_SECRET);
}
}
main().catch((e) => {
process.stderr.write(`fatal: ${e?.stack ?? e}\n`);
void shutdown(1);
});