Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
86 changes: 76 additions & 10 deletions apps/cli/src/lib/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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})`,
};
}
Comment on lines +572 to +579
}
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).
Expand All @@ -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"}` };
}

/**
Expand Down Expand Up @@ -1182,14 +1228,16 @@ 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();
const cfg = resolveEnvConfig(prev, opts);
// --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");
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1455,6 +1503,7 @@ export async function composeUp(
}
onEdgeContainerChanged();
writeInstallMethod("compose");
warnHostChannel(hostChannelError);
return { ok: true, apiPort, dashPort };
}

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/cli/src/lib/repair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
storedDashboardPort as dashboardPort,
} from "./ports";
import { startService, ensureInternalToken } from "../commands/up";
import { composeHostChannel, readInstallMethod } from "./compose";
import {
resolveDataDir,
dataDirExists,
Expand Down Expand Up @@ -229,6 +230,13 @@ export async function componentChecks(apiUp: boolean): Promise<ComponentCheck[]>
: { 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;
}

Expand Down
193 changes: 193 additions & 0 deletions apps/cli/test/unit/compose-host-channel.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>(),
written: new Map<string, string>(),
/** `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<string, string> {
const out: Record<string, string> = {};
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");
});
});
4 changes: 4 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading