Skip to content

Commit 12be5b6

Browse files
BunsDevCopilot
andauthored
feat: hosted deployment stack — production compose, /healthz, image publish, ops runbook (#71)
* feat: hosted deployment stack — production compose, /healthz, image publish, ops runbook The operational half of the HOSTED.md beta gates (the six code gates are shipped). Single-VM architecture matching what the code wants: single-writer SQLite and a container worker backend that drives the host Docker daemon. - deploy/hosted/: production compose (adapter + Caddy TLS ingress + Litestream SQLite replication to Cloudflare R2), Caddyfile, litestream.yml, .env.example, and an idempotent provision.sh for Ubuntu 24.04 (Docker, firewall 22/80/443, layout, docker-gid + uid-10001 permissions). - /healthz: unauthenticated liveness/readiness route probing the store — compose healthcheck and uptime monitors point at it; leaks no tenant data. - Dockerfile: add curl for the container healthcheck. - .github/workflows/publish.yml: build/push ghcr.io/opencoven/coven-github (latest + SHA on main, semver on tags). - docs/hosted-deploy.md: first-deploy runbook, monitoring signals, backup and restore drill, upgrade and incident basics. Cross-linked from HOSTED.md. Validated: docker compose config, bash -n provision.sh, full CI gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Val Alexander <bunsthedev@gmail.com> * fix(webhook): minimal healthz failure body per review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Val Alexander <bunsthedev@gmail.com> --------- Signed-off-by: Val Alexander <bunsthedev@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9271bf8 commit 12be5b6

13 files changed

Lines changed: 387 additions & 2 deletions

File tree

.github/workflows/publish.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Build and publish the coven-github container image to GHCR.
2+
# Pushes to main publish `latest` + the commit SHA; version tags publish the
3+
# semver tag. deploy/hosted/compose.yaml pins deployments to these images.
4+
name: publish
5+
6+
on:
7+
push:
8+
branches: [main]
9+
tags: ["v*"]
10+
11+
permissions:
12+
contents: read
13+
packages: write
14+
15+
jobs:
16+
image:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: docker/setup-buildx-action@v3
22+
23+
- uses: docker/login-action@v3
24+
with:
25+
registry: ghcr.io
26+
username: ${{ github.actor }}
27+
password: ${{ secrets.GITHUB_TOKEN }}
28+
29+
- id: meta
30+
uses: docker/metadata-action@v5
31+
with:
32+
images: ghcr.io/opencoven/coven-github
33+
tags: |
34+
type=raw,value=latest,enable={{is_default_branch}}
35+
type=sha,format=long
36+
type=semver,pattern={{version}}
37+
38+
- uses: docker/build-push-action@v6
39+
with:
40+
context: .
41+
push: true
42+
tags: ${{ steps.meta.outputs.tags }}
43+
labels: ${{ steps.meta.outputs.labels }}
44+
cache-from: type=gha
45+
cache-to: type=gha,mode=max

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ RUN cargo build --release --locked -p coven-github
2323
FROM debian:bookworm-slim AS runtime
2424

2525
# ca-certificates: TLS to api.github.com. git: clone target repos.
26+
# curl: container healthchecks against /healthz (deploy/hosted/compose.yaml).
2627
RUN apt-get update \
27-
&& apt-get install -y --no-install-recommends ca-certificates git \
28+
&& apt-get install -y --no-install-recommends ca-certificates git curl \
2829
&& rm -rf /var/lib/apt/lists/*
2930

3031
# Run as an unprivileged user. Secrets are mounted read-only at runtime, never

HOSTED.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ Hosted beta should wait for:
8888
5. Usage metering by installation, repo, familiar, and task.
8989
6. Cave oversight dashboard for task history and human intervention.
9090

91+
All six code gates are shipped. The operational side — provisioning, TLS
92+
ingress, continuous store backup, monitoring, restore drills, upgrades — is
93+
the [hosted deployment runbook](docs/hosted-deploy.md) with its stack in
94+
[`deploy/hosted/`](deploy/hosted/).
95+
9196
## Landing Page Copy
9297

9398
Headline:

crates/server/src/main.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use tracing_subscriber::EnvFilter;
1212

1313
use coven_github_config::Config;
1414
use coven_github_webhook::routes::{
15-
audit, handle_webhook, list_memory, list_tasks, revoke_memory, routing, usage, AppState,
15+
audit, handle_webhook, healthz, list_memory, list_tasks, revoke_memory, routing, usage,
16+
AppState,
1617
};
1718
use coven_github_worker as worker;
1819

@@ -172,6 +173,7 @@ async fn main() -> Result<()> {
172173
};
173174

174175
let app = Router::new()
176+
.route("/healthz", get(healthz))
175177
.route("/webhook", post(handle_webhook))
176178
.route("/api/github/tasks", get(list_tasks))
177179
.route("/api/github/memory", get(list_memory))

crates/webhook/src/routes.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,23 @@ fn familiar_names(config: &Config) -> std::collections::HashMap<String, String>
4343
.collect()
4444
}
4545

46+
/// GET /healthz — liveness/readiness for ingress and uptime checks.
47+
/// Unauthenticated by design, so it must never expose tenant data: the body
48+
/// is only `ok` plus whether the durable store answers a trivial read.
49+
pub async fn healthz(State(state): State<AppState>) -> impl IntoResponse {
50+
match state.store.delivery_routing("__healthz__").await {
51+
Ok(_) => (StatusCode::OK, Json(json!({"ok": true}))).into_response(),
52+
Err(e) => {
53+
error!("healthz store probe failed: {e:#}");
54+
(
55+
StatusCode::SERVICE_UNAVAILABLE,
56+
Json(json!({"ok": false})),
57+
)
58+
.into_response()
59+
}
60+
}
61+
}
62+
4663
/// GET /api/github/tasks — task state for CovenCave polling, behind the
4764
/// tenant boundary (issue #3). `token` mode fails closed; a tenant token sees
4865
/// only its own installation (optionally narrowed to repositories); the
@@ -2996,6 +3013,25 @@ mod metering_route_tests {
29963013
}
29973014
}
29983015

3016+
#[cfg(test)]
3017+
mod healthz_tests {
3018+
use super::tests::app_state;
3019+
use super::*;
3020+
use axum::extract::State;
3021+
3022+
#[tokio::test]
3023+
async fn healthz_answers_ok_and_leaks_nothing() {
3024+
let state = app_state();
3025+
let response = healthz(State(state)).await.into_response();
3026+
assert_eq!(response.status(), StatusCode::OK);
3027+
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
3028+
.await
3029+
.expect("body");
3030+
let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
3031+
assert_eq!(json, serde_json::json!({"ok": true}));
3032+
}
3033+
}
3034+
29993035
#[cfg(test)]
30003036
mod billing_route_tests {
30013037
//! Marketplace plan intake: entitlement recording, plan-derived limits,
Binary file not shown.
Binary file not shown.

deploy/hosted/.env.example

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Secrets for deploy/hosted/compose.yaml. Copy to .env (mode 0600) and fill
2+
# in. NEVER commit the filled-in file.
3+
4+
# Public hostname the GitHub App webhook points at.
5+
WEBHOOK_DOMAIN=gh.opencoven.ai
6+
7+
# Image tag to run (a released ghcr.io/opencoven/coven-github tag).
8+
COVEN_GITHUB_TAG=latest
9+
10+
# Cloudflare R2 (S3-compatible) for Litestream SQLite replication.
11+
# Create an R2 API token scoped to one bucket with object read+write.
12+
R2_ACCOUNT_ID=
13+
R2_BUCKET=coven-github-store
14+
R2_ACCESS_KEY_ID=
15+
R2_SECRET_ACCESS_KEY=
16+
17+
# Host docker group id, so the unprivileged adapter user can drive the
18+
# daemon for the container worker backend. provision.sh fills this in
19+
# (getent group docker | cut -d: -f3).
20+
DOCKER_GID=

deploy/hosted/Caddyfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# TLS ingress for hosted coven-github. Caddy provisions and renews
2+
# Let's Encrypt certificates automatically for $WEBHOOK_DOMAIN.
3+
{$WEBHOOK_DOMAIN} {
4+
encode gzip
5+
6+
# GitHub webhook deliveries and the tenant-scoped API.
7+
reverse_proxy coven-github:3000
8+
9+
log {
10+
output stdout
11+
format console
12+
}
13+
}

deploy/hosted/compose.yaml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Production Docker Compose for hosted coven-github: adapter + TLS ingress
2+
# (Caddy) + continuous SQLite replication (Litestream → Cloudflare R2).
3+
#
4+
# Runbook: docs/hosted-deploy.md. Secrets come from ./.env (never committed);
5+
# copy .env.example and fill it in.
6+
#
7+
# Layout on the host (created by provision.sh):
8+
# /opt/coven-github/
9+
# compose.yaml this file
10+
# .env secrets (mode 0600)
11+
# Caddyfile TLS ingress
12+
# litestream.yml replication config
13+
# config/production.toml adapter config
14+
# keys/app.pem GitHub App private key (mode 0600)
15+
# data/ SQLite store (replicated)
16+
17+
services:
18+
coven-github:
19+
image: ghcr.io/opencoven/coven-github:${COVEN_GITHUB_TAG:-latest}
20+
command: ["serve", "--config", "/config/production.toml"]
21+
restart: unless-stopped
22+
expose:
23+
- "3000"
24+
volumes:
25+
- ./config:/config:ro
26+
- ./keys:/keys:ro
27+
- ./data:/data
28+
- ./workspaces:/tmp/coven-github-tasks
29+
# Container worker backend: the adapter launches per-task hardened
30+
# containers through the host Docker daemon (docs/container-isolation.md).
31+
- /var/run/docker.sock:/var/run/docker.sock
32+
# The image runs unprivileged (uid 10001); grant the host docker group so
33+
# the worker can drive the daemon. provision.sh fills DOCKER_GID in .env.
34+
group_add:
35+
- "${DOCKER_GID:?set in .env (getent group docker | cut -d: -f3)}"
36+
environment:
37+
RUST_LOG: "coven_github=info"
38+
healthcheck:
39+
test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"]
40+
interval: 30s
41+
timeout: 5s
42+
retries: 3
43+
start_period: 10s
44+
45+
caddy:
46+
image: caddy:2
47+
restart: unless-stopped
48+
ports:
49+
- "80:80"
50+
- "443:443"
51+
volumes:
52+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
53+
- caddy-data:/data
54+
- caddy-config:/config
55+
environment:
56+
WEBHOOK_DOMAIN: ${WEBHOOK_DOMAIN:?set in .env, e.g. gh.opencoven.ai}
57+
depends_on:
58+
- coven-github
59+
60+
litestream:
61+
image: litestream/litestream:0.3
62+
restart: unless-stopped
63+
command: ["replicate", "-config", "/etc/litestream.yml"]
64+
volumes:
65+
- ./litestream.yml:/etc/litestream.yml:ro
66+
- ./data:/data
67+
environment:
68+
LITESTREAM_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:?set in .env}
69+
LITESTREAM_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:?set in .env}
70+
R2_BUCKET: ${R2_BUCKET:?set in .env}
71+
R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:?set in .env}
72+
depends_on:
73+
- coven-github
74+
75+
volumes:
76+
caddy-data:
77+
caddy-config:

0 commit comments

Comments
 (0)