History

e2e: cli target — supervised daemon in a VM, restart() is a real reboot

Wires the VM substrate into the scenario suite as a `cli` target (one project per guest OS: cli-macos / cli-linux / cli-windows). The globalsetup builds the guest's executor binary, provisions a VM, `executor service install`s the supervised daemon, and forwards its loopback port over a reconnecting SSH tunnel — so target.baseUrl works unchanged for the api surface. restart() reboots the guest OS for REAL and waits for the daemon to auto-start, so the existing restart-persistence scenario now proves the boot-time auto-start path (launchd RunAtLoad / systemd linger / Task Scheduler AtStartup), not a process restart. Because globalsetup (main process) and restart() (test worker) are different processes, restart() re-derives the guest address from env and reboots statelessly, mirroring how selfhost-docker's restart re-derives container ops. Verified: `vitest run --project cli-macos` boots a macOS guest, installs the daemon, addSpec's an integration, reboots the guest, and asserts the data survived — green, with a run-record in the matrix. cli-linux reuses the tart provider; cli-windows awaits the ec2 provider.

Rhys Sullivan committed 5 months ago

Commit e2bff6e1 parent b0363568 files · +161 −0Browse files

Unified · Split

8 files
  1. e2e/setup/cli-linux.globalsetup.ts +3 −0
  2. e2e/setup/cli-macos.globalsetup.ts +3 −0
  3. e2e/setup/cli.globalsetup.ts +65 −0
  4. e2e/src/vm/tart.ts +20 −0
  5. e2e/src/vm/types.ts +2 −0
  6. e2e/targets/cli.ts +47 −0
  7. e2e/targets/registry.ts +6 −0
  8. e2e/vitest.config.ts +15 −0
e2e/setup/cli-linux.globalsetup.ts+3−0
@@ -0,0 +1,3 @@
1+import { setupCliTarget } from "./cli.globalsetup";
2+
3+export default (): Promise<(() => Promise<void>) | void> => setupCliTarget("linux");
e2e/setup/cli-macos.globalsetup.ts+3−0
@@ -0,0 +1,3 @@
1+import { setupCliTarget } from "./cli.globalsetup";
2+
3+export default (): Promise<(() => Promise<void>) | void> => setupCliTarget("macos");
e2e/setup/cli.globalsetup.ts+65−0
@@ -0,0 +1,65 @@
1+// Boot the `cli` target: build the guest's `executor` binary, provision a VM,
2+// install it as a supervised OS service, and forward its loopback HTTP port to
3+// the host over a reconnecting SSH tunnel. Publishes connection + reboot info
4+// via env (inherited by the test workers spawned afterward). Per-OS entrypoints
5+// (cli-macos.globalsetup.ts, …) call setupCliTarget with their OS.
6+import { buildGuestBinary } from "../src/vm/build-binary";
7+import { tartVm } from "../src/vm/tart";
8+import type { VmArch, VmOs } from "../src/vm/types";
9+import { waitForHttp } from "./boot";
10+
11+const PORT = 4789;
12+const GUEST_DIR = "~/ed";
13+
14+export async function setupCliTarget(os: VmOs): Promise<(() => Promise<void>) | void> {
15+ process.env.E2E_VM_OS = os; // so the worker-side target resolves the same OS
16+ if (os === "windows") {
17+ throw new Error("cli-windows is pending the ec2 provider; run cli-macos / cli-linux for now");
18+ }
19+
20+ const arch: VmArch = "arm64"; // tart guests on an Apple-Silicon host
21+ const binDir = await buildGuestBinary(os, arch);
22+ const vm = await tartVm(os, arch).provision();
23+
24+ let tunnelClose: (() => void) | undefined;
25+ try {
26+ await vm.ssh(`rm -rf ${GUEST_DIR} && mkdir -p ${GUEST_DIR}`);
27+ await vm.push(`${binDir}/.`, `${GUEST_DIR}/`);
28+ // macOS quarantines scp'd executables; clear it so the binary can run.
29+ await vm.ssh(
30+ os === "macos"
31+ ? `chmod +x ${GUEST_DIR}/executor; xattr -dr com.apple.quarantine ${GUEST_DIR} 2>/dev/null || true`
32+ : `chmod +x ${GUEST_DIR}/executor`,
33+ );
34+
35+ const install = await vm.ssh(`${GUEST_DIR}/executor service install --port ${PORT}`);
36+ if (install.code !== 0) {
37+ throw new Error(`service install failed: ${install.stderr.trim() || install.stdout.trim()}`);
38+ }
39+
40+ const keyRaw = (await vm.ssh("cat ~/.executor/server-control/service.key")).stdout.trim();
41+ const password = (JSON.parse(keyRaw) as { password: string }).password;
42+
43+ const tunnel = await vm.tunnel(PORT);
44+ tunnelClose = tunnel.close;
45+ const baseUrl = `http://127.0.0.1:${tunnel.localPort}`;
46+ await waitForHttp(`${baseUrl}/`, { timeoutMs: 60_000 });
47+
48+ process.env.E2E_CLI_BASE_URL = baseUrl;
49+ process.env.E2E_CLI_AUTH_PASSWORD = password;
50+ process.env.E2E_CLI_VM_HOST = vm.host;
51+ process.env.E2E_CLI_TUNNEL_PORT = String(tunnel.localPort);
52+ process.env.E2E_CLI_BIN_DIR = GUEST_DIR;
53+ } catch (error) {
54+ tunnelClose?.();
55+ await vm.ssh(`${GUEST_DIR}/executor service uninstall`).catch(() => undefined);
56+ await vm.discard();
57+ throw error;
58+ }
59+
60+ return async () => {
61+ tunnelClose?.();
62+ await vm.ssh(`${GUEST_DIR}/executor service uninstall`).catch(() => undefined);
63+ await vm.discard();
64+ };
65+}
e2e/src/vm/tart.ts+20−0
@@ -34,6 +34,23 @@ const SSH_OPTS = [ · 33 unmodified lines
3434 const GUEST_USER = "admin";
3535 const GUEST_PASS = "admin";
3636
37+/**
38+ * Reboot a tart guest by address, with no live handle. `restart()` runs in a
39+ * vitest worker (separate process from the globalsetup that owns the VM), so it
40+ * re-derives the guest address from env and triggers the reboot statelessly —
41+ * the reconnecting tunnel and a health poll confirm recovery.
42+ */
43+export const sshRebootGuest = async (ip: string): Promise<void> => {
44+ await execFileP(SSHPASS, [
45+ "-p",
46+ GUEST_PASS,
47+ "ssh",
48+ ...SSH_OPTS,
49+ `${GUEST_USER}@${ip}`,
50+ "sudo reboot",
51+ ]).catch(() => undefined); // the connection drops mid-call
52+};
53+
3754 const baseImage = (os: "macos" | "linux"): string =>
3855 os === "macos"
3956 ? (process.env.E2E_TART_MACOS_BASE ?? "executor-macos-base")
@@ -129,6 +146,9 @@ export const tartVm = (os: "macos" | "linux", arch: VmArch = "arm64"): VmProvide · 89 unmodified lines
129146 const handle: VmHandle = {
130147 os,
131148 arch,
149+ get host() {
150+ return ip;
151+ },
132152 ssh,
133153 push: async (localPath, remotePath) => {
134154 await execFileP(SSHPASS, [
e2e/src/vm/types.ts+2−0
@@ -22,6 +22,8 @@ export interface Tunnel { · 21 unmodified lines
2222 export interface VmHandle {
2323 readonly os: VmOs;
2424 readonly arch: VmArch;
25+ /** Current reachable address of the guest (re-resolved across reboots). */
26+ readonly host: string;
2527 /** Run a command in the guest over SSH (shell on Unix, PowerShell on Windows). */
2628 ssh(command: string): Promise<SshResult>;
2729 /** Copy a local file or directory into the guest (recursive for directories). */
e2e/targets/cli.ts+47−0
@@ -0,0 +1,47 @@
1+// The supervised local CLI daemon as a target. `executor service install` boots
2+// an OS-managed daemon (launchd / systemd / Task Scheduler) inside a guest VM;
3+// `restart()` reboots that guest for REAL, so restart-persistence proves the
4+// boot-time auto-start path (RunAtLoad / linger / AtStartup), not just a process
5+// restart. The guest daemon binds loopback, so globalsetup forwards it to a
6+// local port over a reconnecting SSH tunnel — making target.baseUrl work
7+// unchanged for the api surface. Boot + tunnel live in setup/cli.globalsetup.ts;
8+// this target only reads what that published via env.
9+import { Effect } from "effect";
10+
11+import { waitForHttp } from "../setup/boot";
12+import { sshRebootGuest } from "../src/vm/tart";
13+import type { VmOs } from "../src/vm/types";
14+import type { Capability, Identity, Target } from "../src/target";
15+
16+const env = (key: string): string => {
17+ const value = process.env[key];
18+ if (!value) throw new Error(`cli target: ${key} not set — did cli.globalsetup run?`);
19+ return value;
20+};
21+
22+export const cliTarget = (): Target => {
23+ const baseUrl = env("E2E_CLI_BASE_URL");
24+ const os = (process.env.E2E_VM_OS ?? "macos") as VmOs;
25+ const username = process.env.E2E_CLI_AUTH_USER ?? "executor";
26+
27+ return {
28+ name: process.env.E2E_TARGET ?? "cli",
29+ baseUrl,
30+ mcpUrl: `${baseUrl}/mcp`,
31+ capabilities: new Set<Capability>(["api"]),
32+ newIdentity: () =>
33+ Effect.sync((): Identity => {
34+ const basic = Buffer.from(`${username}:${env("E2E_CLI_AUTH_PASSWORD")}`).toString("base64");
35+ return { label: "cli-daemon", headers: { Authorization: `Basic ${basic}` } };
36+ }),
37+ // A genuine machine reboot, not a service kick: reboot the guest OS and wait
38+ // for the supervised daemon to auto-start and serve again (401 = up). The
39+ // reconnecting tunnel re-establishes the forward, so the same baseUrl works.
40+ restart: () =>
41+ Effect.promise(async () => {
42+ if (os === "windows") throw new Error("cli-windows restart pending the ec2 provider");
43+ await sshRebootGuest(env("E2E_CLI_VM_HOST"));
44+ await waitForHttp(`${baseUrl}/`, { timeoutMs: 240_000 });
45+ }),
46+ };
47+};
e2e/targets/registry.ts+6−0
@@ -2,6 +2,7 @@ · 1 unmodified line
22 // once per worker. Adding a target = one factory entry here + a project in
33 // vitest.config.ts + a globalsetup that boots (or attaches to) the instance.
44 import type { Target } from "../src/target";
5+import { cliTarget } from "./cli";
56 import { cloudTarget } from "./cloud";
67 import { cloudflareTarget } from "./cloudflare";
78 import { desktopTarget } from "./desktop";
@@ -16,6 +17,11 @@ const factories: Record<string, () => Target> = { · 8 unmodified lines
1617 cloudflare: cloudflareTarget,
1718 desktop: desktopTarget,
1819 local: localTarget,
20+ // The supervised CLI daemon inside a VM, one project per guest OS — restart()
21+ // is a real reboot. See setup/cli.globalsetup.ts.
22+ "cli-macos": cliTarget,
23+ "cli-linux": cliTarget,
24+ "cli-windows": cliTarget,
1925 };
2026
2127 let current: Target | undefined;
e2e/vitest.config.ts+15−0
@@ -68,6 +68,21 @@ export default defineConfig({ · 67 unmodified lines
6868 fileParallelism: true,
6969 testTimeout: 180_000,
7070 }),
71+ // The supervised CLI daemon inside a guest VM, one project per OS. The
72+ // globalsetup provisions a VM, `executor service install`s the daemon, and
73+ // tunnels it; restart() reboots the guest for REAL, so restart-persistence
74+ // proves the boot-time auto-start path. Needs tart (macOS/Linux) or an EC2
75+ // credential (Windows); not part of the default `npm run test` chain — run
76+ // with `vitest run --project cli-macos` (etc.) on the Mini.
77+ ...(["macos", "linux", "windows"] as const).map((os) =>
78+ project(`cli-${os}`, {
79+ include: ["scenarios/restart-persistence.test.ts", "cli/**/*.test.ts"],
80+ env: { E2E_TARGET: `cli-${os}`, E2E_VM_OS: os },
81+ fileParallelism: false,
82+ testTimeout: 300_000,
83+ hookTimeout: 900_000,
84+ }),
85+ ),
7186 ],
7287 },
7388 });