Skip to content

fix: report and repair a missing container→host SSH channel - #518

Open
Rish-it wants to merge 4 commits into
oblien:mainfrom
Rish-it:fix/509-host-channel-provisioning
Open

fix: report and repair a missing container→host SSH channel#518
Rish-it wants to merge 4 commits into
oblien:mainfrom
Rish-it:fix/509-host-channel-provisioning

Conversation

@Rish-it

@Rish-it Rish-it commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

A containerized install could come up with no container→host SSH channel, report success, and only fail at the first deploy — This operation targets the HOST machine, but no host channel is configured (OPENSHIP_HOST_SSH_HOST is unset). openship up now says why it couldn't provision the channel, openship doctor reports whether an install has one, and the raw docker compose stack finally has somewhere to mount the key its .env.example tells you to configure.

Motivation

openship up swallowed every reason it couldn't provision. provisionHostSshChannel is one try { … } catch { return null }, and null is also what the DELIBERATE absences return (--no-host-control, a non-Linux box). renderEnv writes the OPENSHIP_HOST_SSH_* keys only if (host), so any failure produced an .env with no channel, an install that printed ✔ Openship is running via Docker Compose, and a deploy that died later with the error above. A missing ssh-keygen was enough to get there.

Worse, the remedy that error names is a no-op for exactly this case: re-running openship up on such a box provisions nothing again, exit 0, no output. Nothing else surfaced it either — openship doctor reported all-green, and probeReachable (ssh-manager.ts:648) calls recordSuccess when the var is unset, so the box shows Online right up to the deploy.

The raw compose stack could never have a channel. docker/docker-compose.yml (README: "Self-host with raw Docker Compose (no CLI)") sets extra_hosts: host.docker.internal:host-gateway but mounts no key, while .env.example tells that install to set OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key — a path nothing ever put a file at. That stack fails every host operation regardless of its .env.

Deploy is where it surfaces because a "This Server" deploy resolves through resolveServerExecutoracquireHostChannel, and the host-port allocation in build-pipeline.ts:1580 goes through sshManager.withHostExecutor — both before any Docker work.

Related issue

Fixes #509

Changes

  • apps/cli/src/lib/compose.tsprovisionHostSshChannel returns { channel, error }: error is null for the deliberate absences and set for everything else (ENOENT is reported as "ssh-keygen isn't installed (package: openssh-client)", not a generic failure). composeUp prints it once the stack is up — after the pull output, not buried in it. The install still succeeds; a box that only manages remote servers is fine without a channel. Adds composeHostChannel(): the same question asked of an install that already exists (.env + the key on disk), shaped as a doctor check.
  • apps/cli/src/lib/repair.tsopenship doctor gains a Host row, compose installs only (a bare install is already on the host). Fails when the channel is missing or its key is gone; passes when it's there or host control was deliberately turned off. Lands in the panel, the one-shot report and --json through the existing ComponentCheck pipeline.
  • docker/docker-compose.yml — mount the host key from OPENSHIP_HOST_KEY_PATH, /dev/null when unset, matching the compose file openship up generates.
  • .env.example — name OPENSHIP_HOST_KEY_PATH alongside the vars that depend on it.
  • packages/adapters/src/system/executor.ts — the error points at openship doctor before openship up, since the run that produced it may already have tried.
  • apps/cli/test/unit/compose-host-channel.test.ts — new; provisioning reported vs. deliberate absence stays quiet, and each state doctor reports.

Verification

Reproduced end to end first, on a real Linux host rather than a mock: a privileged container running its own dockerd and sshd, driving the real CLI through openship up --compose with real image pulls (postgres, redis, api, dashboard, edge).

A clean box provisions correctly — this is not broken in general:

OPENSHIP_HOST_SSH_HOST=host.docker.internal   # in .env AND in openship-api-1
host.docker.internal → 172.18.0.1             # inside the from="172.16.0.0/12,…" grant
ssh -i key root@host.docker.internal          → HOST_CHANNEL_OK

Remove ssh-keygen, re-runmain reports a healthy install with no channel, and the API then returns the reported error verbatim:

✔ Openship is running via Docker Compose.       exit=0
grep -c HOST_SSH ~/.openship/compose/.env   →  0
docker exec openship-api-1 env | grep -c HOST_SSH  →  0

POST /api/system/self-edge/preflight
{"error":"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 — …"}

Same box, this branch:

  ! Host operations are NOT available: ssh-keygen isn't installed (package: openssh-client)
    Deploys to this box, the :80/:443 takeover and the host terminal will fail
    with "no host channel is configured".
    Fix the cause, re-run `openship up`, then confirm with `openship doctor`.
✔ Openship is running via Docker Compose.

$ openship doctor   →  ✗ Host   not provisioned — deploys to this box will fail; re-run `openship up`
$ echo $?           →  1

Restore ssh-keygen, re-run openship up: no warning, ✓ Host SSH to host.docker.internal as root, and the call that returned the error now returns {"status":{"classification":"ours",…}}.

The raw compose stack, brought up for real from docker/docker-compose.yml (postgres + redis + api) with the key the CLI provisioned:

mount:  /root/.openship/compose/host-ssh/id_ed25519 -> /run/secrets/openship_host_key
POST /api/system/self-edge/preflight  →  {"status":{"classification":"free",…}}

Negative control on the same stack with OPENSHIP_HOST_KEY_PATH unset, so the mount falls back to /dev/null (the pre-fix state — key named in .env, nothing mounted): the api still boots healthy, and the host operation fails with SSH requires one of privateKey, sshAgent, or password. So the mount is load-bearing and its default is harmless.

The regression test fails with only compose.ts reverted — 6 of 8, with the two negative controls staying green, so the warning isn't unconditional:

$ npx vitest run test/unit/compose-host-channel.test.ts   # fix reverted, tests kept
      Tests  6 failed | 2 passed (8)
$ npx vitest run test/unit/compose-host-channel.test.ts   # fix restored
      Tests  8 passed (8)

Suites, run twice each and green both times:

$ bun run test --force
 Tasks:    7 successful, 7 total          # 0 cached, both runs
$ cd apps/cli && npx tsc --noEmit                              # clean
$ cd packages/adapters && npx tsc --noEmit -p tsconfig.json    # clean

Every commit on the branch was checked individually — 233 CLI tests passing at each, none red.

Two pre-existing failures on main that this branch neither touches nor fixes: @repo/dashboard#lint (stale .next generated types against the installed Next — fails identically on a clean main), and prettier --check on compose.ts, repair.ts and docker-compose.yml, all three of which are already unformatted on main. Running bun format would rewrite unrelated lines in them; the one new file is prettier-clean.

Deliberately out of scope, both worth their own change: up --dry-run still previews a channel it may be unable to create (composePlan calls the pure planner, never the provisioner), and a local Docker deploy still requires the channel for host-port allocation even though the build and run go over the mounted socket.

Checklist

  • One change per PR — one bug, or one agreed feature, with nothing unrelated bundled in
  • The diff is scoped — no reformatting or lint fixes on lines I wasn't otherwise changing
  • A test fails without this change and passes with it (or I explained above why there isn't one)
  • bun run test, bun run --cwd <workspace> lint, and bun format all pass locally — bun run test passes; @repo/dashboard#lint and bun format are pre-existing failures on main, see Verification
  • I understand every line of this diff and can explain it in review

Rish-it added 4 commits August 8, 2026 23:11
`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`.
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.
`.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.
"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.
Copilot AI lite review requested due to automatic review settings August 8, 2026 17:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens containerized (Docker Compose) installs by making missing container→host SSH provisioning visible at install time, diagnosable via openship doctor, and supportable in the “raw docker compose” stack by mounting the configured host key into the API container.

Changes:

  • Update compose install provisioning to distinguish “deliberate absence” from “provisioning failure”, surface failures after up, and add a composeHostChannel() doctor check.
  • Extend openship doctor to report host-channel health for compose installs.
  • Align the raw docker/docker-compose.yml stack and .env.example with the host-key mount/config needed for host operations; improve the runtime error guidance to point users at openship doctor.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/adapters/src/system/executor.ts Improves the “missing host channel” error to direct users to openship doctor for diagnosis.
docker/docker-compose.yml Adds a bind mount for the host SSH key into the API container for raw compose installs.
apps/cli/test/unit/compose-host-channel.test.ts Adds unit coverage for provisioning warnings vs deliberate absence, and doctor reporting for channel states.
apps/cli/src/lib/repair.ts Adds a compose-only “Host” component check to openship doctor.
apps/cli/src/lib/compose.ts Returns { channel, error } from provisioning, adds composeHostChannel(), threads warning text to the end of composeUp().
.env.example Documents OPENSHIP_HOST_KEY_PATH alongside host-channel SSH variables for raw compose setups.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +572 to +579
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})`,
};
}
return null;
return { channel: { user, keyPath }, error: null };
} catch (err) {
return { channel: null, error: (err as Error).message };
Comment thread docker/docker-compose.yml
- /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 —
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docker app deployment fails because the host channel is not provisioned

2 participants