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 @@
1import { setupCliTarget } from "./cli.globalsetup";
2
3export default (): Promise<(() => Promise<void>) | void> => setupCliTarget("linux");
e2e/setup/cli-macos.globalsetup.ts+3−0
@@ -0,0 +1,3 @@
1import { setupCliTarget } from "./cli.globalsetup";
2
3export 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.
6import { buildGuestBinary } from "../src/vm/build-binary";
7import { tartVm } from "../src/vm/tart";
8import type { VmArch, VmOs } from "../src/vm/types";
9import { waitForHttp } from "./boot";
10
11const PORT = 4789;
12const GUEST_DIR = "~/ed";
13
14export 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
34const GUEST_USER = "admin";34const GUEST_USER = "admin";
35const GUEST_PASS = "admin";35const GUEST_PASS = "admin";
36 36
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 */
43export 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
37const baseImage = (os: "macos" | "linux"): string =>54const baseImage = (os: "macos" | "linux"): string =>
38 os === "macos"55 os === "macos"
39 ? (process.env.E2E_TART_MACOS_BASE ?? "executor-macos-base")56 ? (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
129 const handle: VmHandle = {146 const handle: VmHandle = {
130 os,147 os,
131 arch,148 arch,
149 get host() {
150 return ip;
151 },
132 ssh,152 ssh,
133 push: async (localPath, remotePath) => {153 push: async (localPath, remotePath) => {
134 await execFileP(SSHPASS, [154 await execFileP(SSHPASS, [
e2e/src/vm/types.ts+2−0
@@ -22,6 +22,8 @@ export interface Tunnel { · 21 unmodified lines
22export interface VmHandle {22export interface VmHandle {
23 readonly os: VmOs;23 readonly os: VmOs;
24 readonly arch: VmArch;24 readonly arch: VmArch;
25 /** Current reachable address of the guest (re-resolved across reboots). */
26 readonly host: string;
25 /** Run a command in the guest over SSH (shell on Unix, PowerShell on Windows). */27 /** Run a command in the guest over SSH (shell on Unix, PowerShell on Windows). */
26 ssh(command: string): Promise<SshResult>;28 ssh(command: string): Promise<SshResult>;
27 /** Copy a local file or directory into the guest (recursive for directories). */29 /** 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.
9import { Effect } from "effect";
10
11import { waitForHttp } from "../setup/boot";
12import { sshRebootGuest } from "../src/vm/tart";
13import type { VmOs } from "../src/vm/types";
14import type { Capability, Identity, Target } from "../src/target";
15
16const 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
22export 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
2// once per worker. Adding a target = one factory entry here + a project in2// once per worker. Adding a target = one factory entry here + a project in
3// vitest.config.ts + a globalsetup that boots (or attaches to) the instance.3// vitest.config.ts + a globalsetup that boots (or attaches to) the instance.
4import type { Target } from "../src/target";4import type { Target } from "../src/target";
5import { cliTarget } from "./cli";
5import { cloudTarget } from "./cloud";6import { cloudTarget } from "./cloud";
6import { cloudflareTarget } from "./cloudflare";7import { cloudflareTarget } from "./cloudflare";
7import { desktopTarget } from "./desktop";8import { desktopTarget } from "./desktop";
@@ -16,6 +17,11 @@ const factories: Record<string, () => Target> = { · 8 unmodified lines
16 cloudflare: cloudflareTarget,17 cloudflare: cloudflareTarget,
17 desktop: desktopTarget,18 desktop: desktopTarget,
18 local: localTarget,19 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,
19};25};
20 26
21let current: Target | undefined;27let current: Target | undefined;
e2e/vitest.config.ts+15−0
@@ -68,6 +68,21 @@ export default defineConfig({ · 67 unmodified lines
68 fileParallelism: true,68 fileParallelism: true,
69 testTimeout: 180_000,69 testTimeout: 180_000,
70 }),70 }),
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 ),
71 ],86 ],
72 },87 },
73});88});