diff --git a/.env.example b/.env.example index 6013de065..60d68dba4 100644 --- a/.env.example +++ b/.env.example @@ -114,10 +114,13 @@ INTERNAL_TOKEN=change-me-32-byte-random-hex # the host over SSH via host.docker.internal (internal bridge, not the public IP). # `openship up` provisions the key + these vars automatically; set them by hand # only for a raw `docker compose` install that needs host ops. +# OPENSHIP_HOST_KEY_PATH is the key on the HOST (mounted to OPENSHIP_HOST_SSH_KEY +# in the container); its public half goes in that user's ~/.ssh/authorized_keys. # OPENSHIP_HOST_SSH_HOST=host.docker.internal # OPENSHIP_HOST_SSH_USER=root # OPENSHIP_HOST_SSH_PORT=22 # OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key +# OPENSHIP_HOST_KEY_PATH=/root/.openship/compose/host-ssh/id_ed25519 # ─── OAuth login (optional) ─────────────────────────────── # GITHUB_CLIENT_ID= diff --git a/apps/cli/src/lib/compose.ts b/apps/cli/src/lib/compose.ts index 241f0b7e8..63c2afa27 100644 --- a/apps/cli/src/lib/compose.ts +++ b/apps/cli/src/lib/compose.ts @@ -543,9 +543,21 @@ function plannedHostChannel(hostControl: boolean): { user: string; keyPath: stri return { user, keyPath: join(COMPOSE_DIR, "host-ssh", "id_ed25519") }; } -function provisionHostSshChannel(hostControl: boolean): { user: string; keyPath: string } | null { +/** + * Provision the channel `plannedHostChannel` names, or say why it couldn't. + * + * `error` is null for the DELIBERATE absences (`--no-host-control`, a non-Linux + * box) and set for everything else. Collapsing the two into a bare `null` is what + * let `renderEnv` write an `.env` with no OPENSHIP_HOST_SSH_* keys, report a + * healthy install, and leave the first deploy to fail with "no host channel is + * configured" (#509). + */ +function provisionHostSshChannel(hostControl: boolean): { + channel: { user: string; keyPath: string } | null; + error: string | null; +} { const target = plannedHostChannel(hostControl); - if (!target) return null; + if (!target) return { channel: null, error: null }; const { user, keyPath } = target; try { mkdirSync(join(COMPOSE_DIR, "host-ssh"), { recursive: true, mode: 0o700 }); @@ -555,10 +567,19 @@ function provisionHostSshChannel(hostControl: boolean): { user: string; keyPath: ["-t", "ed25519", "-N", "", "-q", "-f", keyPath, "-C", HOST_KEY_COMMENT], { stdio: "ignore" }, ); - if (g.status !== 0) return null; + // `g.error` is ENOENT — a box with no openssh-client, which is a package + // away rather than a broken install. + if (g.error || g.status !== 0) { + return { + channel: null, + error: g.error + ? "ssh-keygen isn't installed (package: openssh-client)" + : `ssh-keygen failed (exit ${g.status})`, + }; + } } const pub = readFileSync(`${keyPath}.pub`, "utf8").trim(); - if (!pub) return null; + if (!pub) return { channel: null, error: `${keyPath}.pub is empty — delete the key pair and re-run` }; // Authorize the key for the host user the container SSHes in as (see // plannedHostChannel for why that user comes from the passwd entry). @@ -572,10 +593,35 @@ function provisionHostSshChannel(hostControl: boolean): { user: string; keyPath: // already existed keeps its old permissions — set them explicitly. chmodSync(authKeys, 0o600); - return { user, keyPath }; - } catch { - return null; + return { channel: { user, keyPath }, error: null }; + } catch (err) { + return { channel: null, error: (err as Error).message }; + } +} + +/** + * The host channel this install HAS — read back from the generated `.env`, as + * opposed to `plannedHostChannel`'s "what a run would create". Shaped as a + * `doctor` check (repair.ts `ComponentCheck`), which is its only caller: an + * install whose channel failed to provision reports healthy everywhere else, right + * up to the deploy that needs it (#509). + */ +export function composeHostChannel(): { state: "pass" | "fail"; detail: string } { + const env = readEnvFile(); + if (env.OPENSHIP_HOST_CONTROL === "false") { + return { state: "pass", detail: "host control off (--no-host-control)" }; + } + const host = env.OPENSHIP_HOST_SSH_HOST; + if (!host) { + return { state: "fail", detail: "not provisioned — deploys to this box will fail; re-run `openship up`" }; + } + // The key outlives the `.env` that points at it: a wiped `~/.openship` leaves + // the vars behind and the API then fails at connect time instead of here. + const keyPath = env.OPENSHIP_HOST_KEY_PATH; + if (!keyPath || !existsSync(keyPath)) { + return { state: "fail", detail: `key missing at ${keyPath || "(unset)"} — re-run \`openship up\`` }; } + return { state: "pass", detail: `SSH to ${host} as ${env.OPENSHIP_HOST_SSH_USER || "root"}` }; } /** @@ -1182,6 +1228,8 @@ function materialize(opts: ComposeUpOpts): { * environment. So this decides whether they get recreated. */ envChanged: boolean; + /** Why the host channel isn't there, when it was meant to be (see composeUp). */ + hostChannelError: string | null; } { mkdirSync(COMPOSE_DIR, { recursive: true, mode: 0o700 }); const prev = readEnvFile(); @@ -1189,7 +1237,7 @@ function materialize(opts: ComposeUpOpts): { // --no-host-control: never generate/authorize a host key in the first place. // Not just "don't use it" — there is nothing on disk to steal. Resolved through // cfg so a plain re-run keeps the install's original choice. - const host = provisionHostSshChannel(cfg.hostControl); + const { channel: host, error: hostChannelError } = provisionHostSshChannel(cfg.hostControl); let before = ""; try { before = readFileSync(ENV_FILE, "utf8"); @@ -1204,7 +1252,7 @@ function materialize(opts: ComposeUpOpts): { if (buildDir) writeFileSync(BUILD_FILE, renderBuildOverride(buildDir)); // `before === ""` is a first install: the containers don't exist yet and will be // created with this env, so there is nothing to force. - return { buildDir, cfg, envChanged: before !== "" && rendered !== before }; + return { buildDir, cfg, envChanged: before !== "" && rendered !== before, hostChannelError }; } /** `.env` keys whose VALUE must never be printed. Suffix-matched so a key added @@ -1365,7 +1413,7 @@ function isRootlessDocker(): boolean { export async function composeUp( opts: ComposeUpOpts, ): Promise<{ ok: boolean; apiPort: string; dashPort: string }> { - const { buildDir, cfg, envChanged } = materialize(opts); + const { buildDir, cfg, envChanged, hostChannelError } = materialize(opts); // The EFFECTIVE ports, not the flags: a re-run with no flags keeps the ports the // install was configured with, so the summary must report those. const apiPort = cfg.apiPort; @@ -1455,6 +1503,7 @@ export async function composeUp( } onEdgeContainerChanged(); writeInstallMethod("compose"); + warnHostChannel(hostChannelError); return { ok: true, apiPort, dashPort }; } @@ -1464,9 +1513,26 @@ export async function composeUp( if (up() !== 0) return { ok: false, apiPort, dashPort }; onEdgeContainerChanged(); writeInstallMethod("compose"); + warnHostChannel(hostChannelError); return { ok: true, apiPort, dashPort }; } +/** + * A stack that came up WITHOUT the host channel it was supposed to have. Printed + * here rather than where provisioning happens — hundreds of lines of pull output + * sit between the two. Doesn't fail the install: a box that only manages remote + * servers is fine without a channel. + */ +function warnHostChannel(error: string | null): void { + if (!error) return; + console.error( + `\n ! Host operations are NOT available: ${error}\n` + + ` Deploys to this box, the :80/:443 takeover and the host terminal will fail\n` + + ` with "no host channel is configured".\n` + + ` Fix the cause, re-run \`openship up\`, then confirm with \`openship doctor\`.\n`, + ); +} + /** * Compose just created (or replaced) `openship-edge` behind the detector's back — * `ensureContainerEdge` isn't in this path, so nothing else invalidates the diff --git a/apps/cli/src/lib/repair.ts b/apps/cli/src/lib/repair.ts index c9199e6f5..197306658 100644 --- a/apps/cli/src/lib/repair.ts +++ b/apps/cli/src/lib/repair.ts @@ -24,6 +24,7 @@ import { storedDashboardPort as dashboardPort, } from "./ports"; import { startService, ensureInternalToken } from "../commands/up"; +import { composeHostChannel, readInstallMethod } from "./compose"; import { resolveDataDir, dataDirExists, @@ -229,6 +230,13 @@ export async function componentChecks(apiUp: boolean): Promise : { name: "Edge", state: "warn", detail: "not installed (fine for a local box)" }, ); + // Host channel — compose only: that stack's API is containerized, so every + // operation on THIS machine (deploys to this box, the :80/:443 takeover, the + // host terminal) goes over SSH to the host. A bare install is already there. + if (readInstallMethod() === "compose") { + checks.push({ name: "Host", ...composeHostChannel() }); + } + return checks; } diff --git a/apps/cli/test/unit/compose-host-channel.test.ts b/apps/cli/test/unit/compose-host-channel.test.ts new file mode 100644 index 000000000..5618536f7 --- /dev/null +++ b/apps/cli/test/unit/compose-host-channel.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +/** + * The container→host SSH channel is what every "this machine" operation runs + * through once the API is containerized: deploys to this box, the :80/:443 + * takeover, the host terminal. `openship up` provisions it — and used to swallow + * every reason it couldn't, writing an `.env` with no OPENSHIP_HOST_SSH_* keys, + * reporting a healthy install, and leaving the first deploy to fail with "no host + * channel is configured (OPENSHIP_HOST_SSH_HOST is unset)" (#509). One missing + * `ssh-keygen` was enough, and re-running `openship up` — what that error tells + * you to do — silently did the same thing again. + * + * These pin: a failure to provision is REPORTED with its cause, a deliberate + * absence is not, and `doctor` can tell an install that has a channel from one + * that only thinks it does. + */ + +const h = vi.hoisted(() => ({ + existing: new Set(), + written: new Map(), + /** `ssh-keygen` is absent from this box (no openssh-client). */ + noKeygen: false, + /** `ssh-keygen` runs but fails (full disk, unwritable key dir, …). */ + keygenFails: false, +})); + +vi.mock("node:child_process", () => ({ + execFile: (_c: unknown, _a: unknown, cb: (e: null, o: { stdout: string }) => void) => + cb(null, { stdout: "" }), + spawnSync: (cmd: string, args: string[] = []) => { + if (cmd === "ssh-keygen") { + if (h.noKeygen) + return { + status: null, + stdout: "", + stderr: "", + error: new Error("spawnSync ssh-keygen ENOENT"), + }; + if (h.keygenFails) return { status: 1, stdout: "", stderr: "", error: undefined }; + // A real keygen writes both halves; the provisioner reads the public one. + const keyPath = String(args[args.indexOf("-f") + 1]); + h.written.set(keyPath, "PRIVATE"); + h.written.set( + `${keyPath}.pub`, + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAItest openship-host-executor", + ); + h.existing.add(keyPath); + h.existing.add(`${keyPath}.pub`); + return { status: 0, stdout: "", stderr: "", error: undefined }; + } + // `docker compose pull/up` succeed; everything else (volume inspect, ps) comes + // back empty — no pre-existing db volume, no published ports, no orphaned stack. + if (cmd === "docker" && args[0] === "compose") + return { status: 0, stdout: "", stderr: "", error: undefined }; + return { status: 1, stdout: "", stderr: "", error: undefined }; + }, +})); + +vi.mock("node:fs", () => ({ + existsSync: (p: string) => h.existing.has(String(p)), + mkdirSync: () => undefined, + chmodSync: () => undefined, + readFileSync: (p: string) => { + const v = h.written.get(String(p)); + if (v === undefined) throw new Error(`ENOENT: ${p}`); + return v; + }, + writeFileSync: (p: string, data: string) => { + h.written.set(String(p), String(data)); + h.existing.add(String(p)); + }, +})); + +vi.mock("node:os", () => ({ + homedir: () => "/home/op", + userInfo: () => ({ username: "op" }), +})); + +vi.mock("../../src/lib/source-install", () => ({ readSourceInstall: () => null })); + +vi.mock("@repo/adapters/proxy", () => ({ sanitizeEdgeVhosts: async () => {} })); + +vi.mock("@repo/adapters", async () => { + const lua = await import("../../../../packages/adapters/src/infra/openresty-lua"); + return { + systemCatalog: { installs: { docker: () => ({ supported: false }) } }, + EDGE_HOST_STATE_DIR: lua.EDGE_HOST_STATE_DIR, + EDGE_CONTAINER_MOUNTS: lua.EDGE_CONTAINER_MOUNTS, + invalidateEdgeContainer: () => {}, + LocalExecutor: class {}, + }; +}); + +import { composeHostChannel, composePaths, composeUp } from "../../src/lib/compose"; + +const realPlatform = process.platform; +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} + +/** The `.env` this run wrote, parsed back into key → value. */ +function writtenEnv(): Record { + const out: Record = {}; + for (const line of (h.written.get(composePaths.env) ?? "").split("\n")) { + const m = line.match(/^([A-Z0-9_]+)=(.*)$/); + if (m) out[m[1]] = m[2]; + } + return out; +} + +let stderr: string[] = []; + +beforeEach(() => { + h.existing = new Set(); + h.written = new Map(); + h.noKeygen = false; + h.keygenFails = false; + stderr = []; + // The compose install is Linux-only, so provisioning never runs on the platform + // these tests happen to be executed on. + setPlatform("linux"); + vi.spyOn(console, "error").mockImplementation((...a: unknown[]) => { + stderr.push(a.map(String).join(" ")); + }); + vi.spyOn(console, "log").mockImplementation(() => {}); +}); + +afterEach(() => { + setPlatform(realPlatform); + vi.restoreAllMocks(); +}); + +describe("openship up — host channel provisioning", () => { + it("reports the reason when the channel can't be provisioned", async () => { + h.noKeygen = true; + await composeUp({}); + // The install genuinely has no channel… + expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBeUndefined(); + // …so it must say so, naming the cause and what it costs. Silence here is + // what shipped a box that reports success and cannot deploy. + const said = stderr.join("\n"); + expect(said).toMatch(/ssh-keygen/); + expect(said).toMatch(/host/i); + expect(said).toMatch(/openship doctor/); + }); + + it("reports a keygen that runs but fails, too", async () => { + h.keygenFails = true; + await composeUp({}); + expect(stderr.join("\n")).toMatch(/ssh-keygen/); + }); + + it("says nothing when the channel is provisioned", async () => { + await composeUp({}); + expect(writtenEnv().OPENSHIP_HOST_SSH_HOST).toBe("host.docker.internal"); + expect(stderr.join("\n")).not.toMatch(/host channel/i); + }); + + it("says nothing when the operator asked for no host control", async () => { + h.noKeygen = true; + await composeUp({ noHostControl: true }); + expect(stderr.join("\n")).not.toMatch(/host channel/i); + }); +}); + +describe("composeHostChannel — what doctor reports", () => { + it("passes an install that has a channel", async () => { + await composeUp({}); + expect(composeHostChannel()).toEqual({ + state: "pass", + detail: "SSH to host.docker.internal as op", + }); + }); + + it("fails an install whose `.env` has no channel", async () => { + h.noKeygen = true; + await composeUp({}); + const c = composeHostChannel(); + expect(c.state).toBe("fail"); + expect(c.detail).toMatch(/deploys to this box will fail/i); + }); + + it("fails a channel whose key has gone missing", async () => { + await composeUp({}); + h.existing.delete(writtenEnv().OPENSHIP_HOST_KEY_PATH); + expect(composeHostChannel().state).toBe("fail"); + }); + + it("treats an opted-out install as fine, not broken", async () => { + await composeUp({ noHostControl: true }); + expect(composeHostChannel().state).toBe("pass"); + }); +}); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 096f9f727..edfb46658 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -130,6 +130,10 @@ services: - /etc/letsencrypt:/etc/letsencrypt:z - /var/lib/openship/edge/acme:/var/www/acme:z - /opt/openship/static:/opt/openship/static:z + # Host-op SSH key at the path OPENSHIP_HOST_SSH_KEY names in .env.example — + # /dev/null when OPENSHIP_HOST_KEY_PATH is unset. Without this mount there is + # nowhere for the key to be, so host ops fail whatever the .env says (#509). + - ${OPENSHIP_HOST_KEY_PATH:-/dev/null}:/run/secrets/openship_host_key:ro # Reach the HOST over the internal docker bridge (host.docker.internal → # host-gateway) for host-OS ops when configured (OPENSHIP_HOST_SSH_*, set up # by `openship up`). Harmless when unset. Linux needs the explicit mapping. diff --git a/packages/adapters/src/system/executor.ts b/packages/adapters/src/system/executor.ts index d55288bab..036c3ed18 100644 --- a/packages/adapters/src/system/executor.ts +++ b/packages/adapters/src/system/executor.ts @@ -109,7 +109,8 @@ export function createHostExecutor(): CommandExecutor { "This operation targets the HOST machine, but no host channel is configured " + "(OPENSHIP_HOST_SSH_HOST is unset) and Openship is running in a container — " + "so it would have run inside the container instead, against the wrong " + - "filesystem. Re-run `openship up` to provision the host channel.", + "filesystem. Run `openship doctor` on the host to see why the channel is " + + "missing, then re-run `openship up` to provision it.", ); } return localExecutor;