Skip to content

Commit 96c1eef

Browse files
committed
feat(selfhost): host-companion redeploy trigger via MCP admin tool
Closes #7723. A new admin-category MCP tool, loopover_admin_trigger_redeploy, lets an operator trigger a real self-redeploy (pull the published image, restart, wait for health) through an MCP client -- without ever mounting /var/run/docker.sock into an app-facing container. Design decision (proxy-mediated vs. host-companion, per #7720's own framing): host companion. Investigated extending the existing docker-proxy sidecar first -- tecnativa/docker-socket-proxy's ACL model has no way to scope a restart to one named container (only "any container reachable through the socket"), and has no concept of "pull a new image" at all, since the real desired behavior is docker-compose-level orchestration, not a single Docker Engine API call a raw proxy could cleanly allowlist. The companion (scripts/redeploy-companion.ts) runs entirely outside Docker as a systemd service, listens on a Unix domain socket (never a TCP port), authenticates with its own separate REDEPLOY_COMPANION_TOKEN (distinct from LOOPOVER_MCP_ADMIN_TOKEN -- two independent credentials, not one), and shells out to the existing, already-tested scripts/deploy-selfhost-image.sh rather than reimplementing its pull+recreate+health-wait sequence. Entirely opt-in: no companion installed means the tool reports configured: false, every other admin tool is unaffected.
1 parent 80455e1 commit 96c1eef

17 files changed

Lines changed: 1027 additions & 3 deletions

apps/loopover-ui/content/docs/self-hosting-configuration.mdx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,49 @@ Restart the `loopover` service after changing either (`docker compose up -d --no
345345
Drop `dryRun` (or set it to `false`) to write for real once you're happy with the dry-run result. `scope` is `"global"` (the mount-root default file) or `"repo"` (pass `repoFullName`); `loopover_admin_get_config` additionally accepts `"effective"` to read the exact deep-merged view a real review sees, same as the "Private per-repo config" deep-merge described above. `loopover_admin_list_config_backups` takes the same `scope`/`repoFullName` pair and returns each backup's path and timestamp, newest first.
346346

347347
<Callout variant="warn" title="Boundaries">
348-
These tools only read and write `LOOPOVER_REPO_CONFIG_DIR`. They do not trigger a redeploy, and they do not touch the public dashboard or `/v1/app/*` settings surface — `LOOPOVER_MCP_ADMIN_TOKEN` cannot sign into the control panel or call the routes `ADMIN_GITHUB_LOGINS` gates.
348+
These three tools only read and write `LOOPOVER_REPO_CONFIG_DIR`. Triggering a redeploy is a separate tool with its own separate token — see "MCP redeploy trigger" below. None of the admin tools touch the public dashboard or `/v1/app/*` settings surface — `LOOPOVER_MCP_ADMIN_TOKEN` cannot sign into the control panel or call the routes `ADMIN_GITHUB_LOGINS` gates.
349+
</Callout>
350+
351+
## MCP redeploy trigger
352+
353+
A fourth `admin`-category tool, `loopover_admin_trigger_redeploy` (#7723): pulls the published image, restarts this instance, and waits for it to report healthy — the same sequence `./scripts/deploy-selfhost-image.sh` runs by hand, triggered remotely through an MCP client instead. Same `LOOPOVER_MCP_ADMIN_ENABLED` + `LOOPOVER_MCP_ADMIN_TOKEN` gating as the config tools above, plus one more layer specific to this tool.
354+
355+
<Callout variant="warn" title="Why this needs a whole extra moving part, not just another admin tool">
356+
Every other admin tool runs entirely inside this container. Triggering a redeploy can't: this container has to end up running a *new* image, which means something *outside* this container has to do the actual `docker compose pull && up -d`. The obvious shortcut — mount `/var/run/docker.sock` into this container so it can ask Docker to do it — is explicitly the thing this repo tells contributors never to do (see `docker-proxy`'s own comment in `docker-compose.yml`: a bind-mounted socket, even read-only, is "effectively host root"; `review-enrichment/src/analyzers/iac-misconfig.ts` flags exactly this pattern in the PRs this bot reviews for everyone else). Extending the existing `docker-proxy` sidecar (`tecnativa/docker-socket-proxy`) doesn't avoid the problem either — its access-control model has no way to scope a restart to *one* named container, only "any container reachable through the socket," and it has no concept of "pull a new image" at all (that's compose-level orchestration, not a single Docker Engine API call).
357+
358+
The design here instead runs a small **host companion** — a plain Node process, outside Docker entirely, installed as a systemd service (`systemd/loopover-redeploy-companion.service.example`) — that listens on a Unix domain socket and, on an authenticated request, shells out to the real `deploy-selfhost-image.sh`. This container reaches it via a narrow, purpose-built socket bind-mounted in (never the Docker socket itself), authenticated with its own separate shared secret (`REDEPLOY_COMPANION_TOKEN`) so a leaked `LOOPOVER_MCP_ADMIN_TOKEN` alone still can't trigger a redeploy — two independent credentials have to both be compromised, not one.
359+
</Callout>
360+
361+
**Entirely opt-in.** Skip this whole section and the other three admin tools work exactly as documented above — `loopover_admin_trigger_redeploy` just reports `configured: false` until you set it up.
362+
363+
<CodeBlock
364+
filename="shell"
365+
code={`./scripts/selfhost-init-secrets.sh # generates secrets/redeploy_companion_token.txt
366+
sudo useradd --system --no-create-home loopover-redeploy
367+
sudo usermod -aG docker loopover-redeploy
368+
sudo install -m 600 -o loopover-redeploy /dev/null /etc/loopover-redeploy-companion.env
369+
printf 'REDEPLOY_COMPANION_TOKEN=%s\\n' "$(cat secrets/redeploy_companion_token.txt)" | \\
370+
sudo tee /etc/loopover-redeploy-companion.env >/dev/null
371+
sudo cp systemd/loopover-redeploy-companion.service.example /etc/systemd/system/loopover-redeploy-companion.service
372+
# edit User/Group/WorkingDirectory in that file to match your host, then:
373+
sudo systemctl daemon-reload
374+
sudo systemctl enable --now loopover-redeploy-companion.service`}
375+
/>
376+
377+
The `loopover` service's `docker-compose.yml` entry already bind-mounts the companion's socket and reads `REDEPLOY_COMPANION_TOKEN_FILE` — nothing to add there. Restart the `loopover` service once the companion is running so it picks up the token.
378+
379+
<CodeBlock
380+
lang="json"
381+
filename="example: loopover_admin_trigger_redeploy"
382+
code={`{
383+
"image": "ghcr.io/jsonbored/loopover-selfhost:orb-v0.1.0"
384+
}`}
385+
/>
386+
387+
`image` is optional — omit it to redeploy whatever `LOOPOVER_IMAGE` is already configured (the same default `deploy-selfhost-image.sh` itself uses). The tool call doesn't return until the companion's own health-wait finishes (or times out), and streams back every log line the real script printed along the way, so a failed redeploy comes back with the actual reason, not just a bare non-zero exit code.
388+
389+
<Callout variant="note" title="This restarts the very process serving the tool call">
390+
A successful redeploy means this container gets replaced mid-request. The companion waits for the *new* container to report healthy before it responds, so the tool call itself completes normally either way — but expect the connection to briefly drop if you're also watching logs live.
349391
</Callout>
350392

351393
## Config-as-code blocks with no dashboard equivalent

apps/loopover-ui/content/docs/self-hosting-security.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ eyebrow: Self-hosting
2626
]}
2727
/>
2828

29-
`docker-compose.yml` ships native Docker Compose `secrets:` mounts for the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/MCP-admin/internal-job tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at `/run/secrets/<name>`, never exposed via `docker inspect` or `docker compose config` the way a plain `environment:`/`env_file` value is. This is purely additive: an inline `.env` value always takes priority if you set both, so you can migrate one secret at a time, or not at all. See `secrets/README.md` for the full file list.
29+
`docker-compose.yml` ships native Docker Compose `secrets:` mounts for the highest-value secrets (the GitHub App private key, webhook secret, API/MCP/MCP-admin/redeploy-companion/internal-job tokens, the setup token, the two token-encryption master keys, the Orb enrollment secret, the PagerDuty routing key, and the Claude Code subscription token) — file-mounted at `/run/secrets/<name>`, never exposed via `docker inspect` or `docker compose config` the way a plain `environment:`/`env_file` value is. This is purely additive: an inline `.env` value always takes priority if you set both, so you can migrate one secret at a time, or not at all. See `secrets/README.md` for the full file list.
3030

3131
<CodeBlock
3232
filename="shell"
@@ -50,7 +50,7 @@ category a secret falls into before rotating it.
5050
{
5151
title: "Freely rotatable",
5252
description:
53-
"GITHUB_WEBHOOK_SECRET, LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_MCP_ADMIN_TOKEN, INTERNAL_JOB_TOKEN, SELFHOST_SETUP_TOKEN, REES_SHARED_SECRET. Static bearer comparisons -- nothing is encrypted with them. Generate a new value, restart both sides. Only cost: updating callers holding the old value.",
53+
"GITHUB_WEBHOOK_SECRET, LOOPOVER_API_TOKEN, LOOPOVER_MCP_TOKEN, LOOPOVER_MCP_ADMIN_TOKEN, INTERNAL_JOB_TOKEN, SELFHOST_SETUP_TOKEN, REES_SHARED_SECRET. Static bearer comparisons -- nothing is encrypted with them. Generate a new value, restart both sides. Only cost: updating callers holding the old value. REDEPLOY_COMPANION_TOKEN is the one exception in this list that needs a second manual step: it's shared between this container and the separate host companion process, so rotating it means updating BOTH secrets/redeploy_companion_token.txt and the companion's own /etc/loopover-redeploy-companion.env, not just one file.",
5454
},
5555
{
5656
title: "Externally issued",

apps/loopover-ui/src/lib/selfhost-env-reference.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,14 @@ export const SELFHOST_ENV_REFERENCE_ROWS: SelfHostEnvReferenceRow[] = [
469469
name: "QUEUE_STARTUP_JITTER_MIN_JOBS",
470470
firstReference: "src/selfhost/queue-common.ts",
471471
},
472+
{
473+
name: "REDEPLOY_COMPANION_SOCKET_PATH",
474+
firstReference: "src/server.ts",
475+
},
476+
{
477+
name: "REDEPLOY_COMPANION_TOKEN",
478+
firstReference: "src/server.ts",
479+
},
472480
{
473481
name: "REDIS_URL",
474482
firstReference: "src/selfhost/preflight.ts",
@@ -658,6 +666,8 @@ export const SELFHOST_ENV_REFERENCE_MARKDOWN = [
658666
"| `QUEUE_CONCURRENCY` | `src/selfhost/pg-queue.ts` |",
659667
"| `QUEUE_DEAD_LETTER_AUTO_RETRY_MAX_EXTRA_ATTEMPTS` | `src/selfhost/queue-common.ts` |",
660668
"| `QUEUE_STARTUP_JITTER_MIN_JOBS` | `src/selfhost/queue-common.ts` |",
669+
"| `REDEPLOY_COMPANION_SOCKET_PATH` | `src/server.ts` |",
670+
"| `REDEPLOY_COMPANION_TOKEN` | `src/server.ts` |",
661671
"| `REDIS_URL` | `src/selfhost/preflight.ts` |",
662672
"| `REVIEW_AUDIT_DIR` | `src/server.ts` |",
663673
"| `REVIEW_AUDIT_S3_ACCESS_KEY_ID` | `src/server.ts` |",

docker-compose.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,13 @@ services:
138138
# flipping the config mount below (under `volumes:`) from :ro to :rw yourself -- see its own comment.
139139
LOOPOVER_MCP_ADMIN_ENABLED: "${LOOPOVER_MCP_ADMIN_ENABLED:-}"
140140
LOOPOVER_MCP_ADMIN_TOKEN_FILE: "${LOOPOVER_MCP_ADMIN_TOKEN_FILE:-/run/secrets/loopover_mcp_admin_token}"
141+
# loopover_admin_trigger_redeploy (#7723): a SEPARATE credential from LOOPOVER_MCP_ADMIN_TOKEN, checked
142+
# by the host-side companion itself (systemd/loopover-redeploy-companion.service.example), not by this
143+
# app -- defense in depth across the two independent layers (MCP-tool auth here, host-socket auth
144+
# there). Entirely opt-in: absent the companion running on the host with a matching token, the tool call
145+
# just reports connection-refused -- every other admin tool is unaffected.
146+
REDEPLOY_COMPANION_SOCKET_PATH: "${REDEPLOY_COMPANION_SOCKET_PATH:-/run/loopover-redeploy.sock}"
147+
REDEPLOY_COMPANION_TOKEN_FILE: "${REDEPLOY_COMPANION_TOKEN_FILE:-/run/secrets/redeploy_companion_token}"
141148
INTERNAL_JOB_TOKEN_FILE: "${INTERNAL_JOB_TOKEN_FILE:-/run/secrets/internal_job_token}"
142149
SELFHOST_SETUP_TOKEN_FILE: "${SELFHOST_SETUP_TOKEN_FILE:-/run/secrets/selfhost_setup_token}"
143150
TOKEN_ENCRYPTION_SECRET_FILE: "${TOKEN_ENCRYPTION_SECRET_FILE:-/run/secrets/token_encryption_secret}"
@@ -164,6 +171,13 @@ services:
164171
# anything; the app flag alone never makes this mount secretly writable. Flip it locally:
165172
# - ./loopover-config:/config:rw
166173
- ./loopover-config:/config:ro
174+
# Host redeploy companion socket (#7723, opt-in -- see systemd/loopover-redeploy-companion.service.example
175+
# for the host-side setup). Deliberately a single narrow Unix socket, never /var/run/docker.sock: this
176+
# mount grants the app container reach to exactly one purpose-built request/response protocol on the
177+
# host, not the full Docker Engine API. Absent (companion not installed) ⇒ Docker bind-mounts an empty
178+
# placeholder file here ⇒ the admin tool's connection attempt fails closed with connection-refused, same
179+
# "present but harmless when unconfigured" degradation as the config mount above.
180+
- "${REDEPLOY_COMPANION_SOCKET_PATH:-/run/loopover-redeploy.sock}:${REDEPLOY_COMPANION_SOCKET_PATH:-/run/loopover-redeploy.sock}"
167181
# Mounted read-only at /run/secrets/<name> (Compose's default target). See the top-level `secrets:`
168182
# block above and secrets/README.md.
169183
#
@@ -182,6 +196,7 @@ services:
182196
- loopover_api_token
183197
- loopover_mcp_token
184198
- loopover_mcp_admin_token
199+
- redeploy_companion_token
185200
- internal_job_token
186201
- selfhost_setup_token
187202
- token_encryption_secret
@@ -1339,6 +1354,8 @@ secrets:
13391354
file: ./secrets/loopover_mcp_token.txt
13401355
loopover_mcp_admin_token:
13411356
file: ./secrets/loopover_mcp_admin_token.txt
1357+
redeploy_companion_token:
1358+
file: ./secrets/redeploy_companion_token.txt
13421359
internal_job_token:
13431360
file: ./secrets/internal_job_token.txt
13441361
selfhost_setup_token:

0 commit comments

Comments
 (0)