From badcd195dc345918ad937ef0928d0e817cacf9a1 Mon Sep 17 00:00:00 2001 From: Rishit Sharma Date: Sat, 8 Aug 2026 22:56:26 +0530 Subject: [PATCH 1/4] fix(cli): report why the host channel couldn't be provisioned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provisionHostSshChannel` collapsed every failure to `null`, which is also what the deliberate absences return (`--no-host-control`, a non-Linux box). `renderEnv` then wrote an `.env` with no OPENSHIP_HOST_SSH_* keys, `openship up` reported a healthy install, and the first deploy to that box failed with "no host channel is configured (OPENSHIP_HOST_SSH_HOST is unset)". A missing `ssh-keygen` was enough, and re-running `openship up` — the remedy that error names — silently did the same thing again. It now returns the reason alongside the channel, and `composeUp` prints it once the stack is up: what broke, what it costs, and how to confirm the fix. The install still succeeds — a box that only manages remote servers needs no channel — it just stops being silent about it. Adds `composeHostChannel()`, the same question asked of an install that already exists (`.env` + the key on disk), for `openship doctor`. --- apps/cli/src/lib/compose.ts | 86 +++++++- .../test/unit/compose-host-channel.test.ts | 193 ++++++++++++++++++ 2 files changed, 269 insertions(+), 10 deletions(-) create mode 100644 apps/cli/test/unit/compose-host-channel.test.ts 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/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"); + }); +}); From ce4d2d7d822badcb5bc9f62227caf6e80f3cfb41 Mon Sep 17 00:00:00 2001 From: Rishit Sharma Date: Sat, 8 Aug 2026 22:56:36 +0530 Subject: [PATCH 2/4] feat(cli): check the host channel in `openship doctor` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose only: that stack's API is containerized, so every operation on the box it runs on goes over SSH to the host. An install whose channel never got provisioned reported all-green here — service, database, API, dashboard, edge — and only failed at the first deploy. Fails the check when the channel is missing or its key is gone, passes when it is there or host control was deliberately turned off. --- apps/cli/src/lib/repair.ts | 8 ++++++++ 1 file changed, 8 insertions(+) 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; } From a5dd63c5202542c6d66bdc2050f5bc0997c7781d Mon Sep 17 00:00:00 2001 From: Rishit Sharma Date: Sat, 8 Aug 2026 22:56:36 +0530 Subject: [PATCH 3/4] fix(docker): mount the host-op SSH key in the raw compose stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.env.example` tells a raw `docker compose` install to set OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key, but nothing ever mounted a key there — so that stack could never do a host operation no matter what its `.env` said. Mounts it from OPENSHIP_HOST_KEY_PATH, /dev/null when unset, matching the compose file `openship up` generates. --- .env.example | 3 +++ docker/docker-compose.yml | 4 ++++ 2 files changed, 7 insertions(+) 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/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. From cc0e0f1b2dc8b3aae7d3d59ba8267ad2a2a5e434 Mon Sep 17 00:00:00 2001 From: Rishit Sharma Date: Sat, 8 Aug 2026 22:56:36 +0530 Subject: [PATCH 4/4] fix(adapters): point the host-channel error at a way to diagnose it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Re-run `openship up`" is a no-op for the case that produces this error most often — a run that already tried and couldn't. Name `openship doctor` first, which now reports the channel and why it is missing. --- packages/adapters/src/system/executor.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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;