From fb9ad933cacb2536f85018eb2832a1668788825b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 28 Jun 2026 11:18:51 -0500 Subject: [PATCH] feat(release): config doctor, Docker assets, and self-hosting smoke for M5 Closes the executable gaps in the M5 self-hosting/release-readiness pass: - config: add `Config::load` + `Config::check` validation surfacing placeholder secrets, missing PEM/coven-code binary, empty/duplicate familiars; covered by unit tests. - server: add `doctor` subcommand (exit-coded preflight) and make `serve` fail fast on config errors instead of crashing mid-request. - docker: real multi-stage Dockerfile (non-root, secret-free), compose.yaml, and .dockerignore replacing the inline doc snippet. - scripts: smoke-webhook.sh exercising unsigned/bad/valid HMAC paths; verified locally against a booted server (401/401/200). - docs: self-hosting guide wired to the committed Docker assets, doctor step, and signed-webhook smoke; README literal-\n fix + doctor/docker steps. - RELEASE.md: gate checklist (fmt/check/clippy/test/docs-smoke green; disposable-repo E2E remains human-gated before tagging). Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 12 ++ Cargo.lock | 1 + Dockerfile | 43 ++++ README.md | 6 + RELEASE.md | 69 +++++++ compose.yaml | 32 +++ crates/config/Cargo.toml | 1 + crates/config/src/lib.rs | 416 +++++++++++++++++++++++++++++++++++++- crates/server/src/main.rs | 91 ++++++++- docs/self-hosting.md | 65 +++++- scripts/smoke-webhook.sh | 59 ++++++ 11 files changed, 779 insertions(+), 16 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 RELEASE.md create mode 100644 compose.yaml create mode 100755 scripts/smoke-webhook.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a7f533 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Keep the Docker build context small and secret-free. +target/ +.git/ +.worktrees/ + +# Never ship secrets or local config into an image. +keys/ +config/local.toml +*.pem + +# Editor / OS noise +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 0081529..03a708b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,6 +341,7 @@ dependencies = [ "anyhow", "serde", "serde_json", + "toml", ] [[package]] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6a30433 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1 +# +# Multi-stage build for the coven-github webhook server. +# +# docker build -t coven-github . +# docker run --rm -p 3000:3000 \ +# -v "$PWD/config:/config:ro" -v "$PWD/keys:/keys:ro" \ +# coven-github serve --config /config/local.toml +# +# See docs/self-hosting.md for the full walkthrough and compose.yaml for a +# ready-to-edit Compose service. + +# ── Builder ───────────────────────────────────────────────────────────────── +FROM rust:1.83-bookworm AS builder +WORKDIR /app + +# Copy the whole workspace and build the release binary. (A cold build pulls the +# full dependency tree; subsequent builds reuse Docker's layer cache.) +COPY . . +RUN cargo build --release --locked -p coven-github + +# ── Runtime ───────────────────────────────────────────────────────────────── +FROM debian:bookworm-slim AS runtime + +# ca-certificates: TLS to api.github.com. git: clone target repos. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +# Run as an unprivileged user. Secrets are mounted read-only at runtime, never +# baked into the image (see the .dockerignore — keys/ and config/local.toml are +# excluded from the build context). +RUN useradd --system --create-home --uid 10001 coven +USER coven +WORKDIR /home/coven + +COPY --from=builder /app/target/release/coven-github /usr/local/bin/coven-github + +EXPOSE 3000 + +# `doctor` exits non-zero on a broken config, so it works as a preflight check. +ENTRYPOINT ["coven-github"] +CMD ["serve", "--config", "/config/local.toml"] diff --git a/README.md b/README.md index 471477d..314a95e 100644 --- a/README.md +++ b/README.md @@ -136,10 +136,16 @@ cp config/example.toml config/local.toml # Then set in config/local.toml: github.app_id, github.private_key_path, # github.webhook_secret, worker.coven_code_bin, and a [[familiars]] block. +# Validate the config (catches placeholder secrets, missing PEM/binary, etc.) +./target/release/coven-github doctor --config config/local.toml + # Run ./target/release/coven-github serve --config config/local.toml ``` +Prefer containers? A multi-stage [`Dockerfile`](Dockerfile) and +[`compose.yaml`](compose.yaml) ship in the repo root. + See [docs/self-hosting.md](docs/self-hosting.md) for full setup including GitHub App registration. For a minimal familiar route, start from [`examples/familiar-github-starter`](examples/familiar-github-starter/). --- diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c12ad6d --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,69 @@ +# Release checklist — coven-github + +The first usable release (`v0.1.0`) ships only after every gate below passes. +Gates 1–5 are runnable from a clean checkout with no GitHub credentials. Gate 6 +needs a disposable repo with the App installed and a live `coven-code` binary — +it is the human-in-the-loop step that authorizes the tag. + +## Gates + +```bash +# 1. Format +cargo fmt --all -- --check + +# 2. Type/borrow check +cargo check --workspace + +# 3. Lint (warnings are errors) +cargo clippy --workspace --all-targets -- -D warnings + +# 4. Tests +cargo test --workspace + +# 5. Docs smoke — config validation + webhook signature path +# (boot the server against a throwaway config, then:) +cargo build --release -p coven-github +./target/release/coven-github doctor --config config/local.toml +scripts/smoke-webhook.sh http://localhost:3000/webhook "$WEBHOOK_SECRET" +``` + +Gate 5 detail: `doctor` must exit `0` on a filled-in config, and +`scripts/smoke-webhook.sh` must report unsigned → 401, bad signature → 401, +valid signature → 200. See [docs/self-hosting.md](docs/self-hosting.md). + +## 6. Disposable-repo end-to-end (human-gated) + +On a throwaway repo with the GitHub App installed and `worker.coven_code_bin` +pointing at a real `coven-code`: + +1. Open an issue and assign it to the configured bot user (or apply a + `trigger_labels` label such as `coven:fix`). +2. Confirm a Check Run appears and the familiar session starts. +3. Confirm a draft PR opens in the familiar's voice and links back to the issue. +4. Confirm the Check Run resolves to success/failure (not stuck). + +Capture the issue/PR links in the release notes as evidence. + +## Cut the tag + +Only after gates 1–6 pass: + +```bash +# Ensure the version in Cargo.toml ([workspace.package].version) is correct, +# the tree is clean, and you are on main. +git tag -s v0.1.0 -m "coven-github v0.1.0" +git push origin v0.1.0 +``` + +> Tags are signed (`-s`). Do not push an unsigned release tag. + +## Status of automatable gates (this branch) + +| Gate | Result | +|---|---| +| 1. `cargo fmt --all -- --check` | ✅ clean | +| 2. `cargo check --workspace` | ✅ clean | +| 3. `cargo clippy … -D warnings` | ✅ clean | +| 4. `cargo test --workspace` | ✅ 18 passing | +| 5. docs smoke (`doctor` + `smoke-webhook.sh`) | ✅ verified locally | +| 6. disposable-repo E2E | ⏳ requires live App creds + `coven-code` | diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..a5fdc1e --- /dev/null +++ b/compose.yaml @@ -0,0 +1,32 @@ +# Docker Compose service for coven-github. +# +# 1. Copy config/example.toml → config/local.toml and fill it in. +# 2. Drop your GitHub App PEM into ./keys/ and point +# github.private_key_path at /keys/.pem in local.toml. +# 3. Validate, then run: +# docker compose run --rm coven-github doctor --config /config/local.toml +# docker compose up +# +# coven-code must be reachable inside the container. Either bake it into a +# derived image and set worker.coven_code_bin to its path, or mount it via an +# extra read-only volume below. +services: + coven-github: + build: . + image: coven-github:local + command: ["serve", "--config", "/config/local.toml"] + ports: + - "3000:3000" + volumes: + # Config and secrets are mounted read-only — never baked into the image. + - ./config:/config:ro + - ./keys:/keys:ro + # Ephemeral per-task workspaces. Keep worker.workspace_root pointed here. + - coven-workspaces:/tmp/coven-github-tasks + environment: + # Adjust log verbosity, e.g. RUST_LOG=coven_github=debug + RUST_LOG: "coven_github=info" + restart: unless-stopped + +volumes: + coven-workspaces: diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 624e4db..8397c97 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,3 +8,4 @@ license.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true +toml.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 250e4e6..3c851a9 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1,7 +1,7 @@ //! Configuration types for coven-github installations. use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// Top-level server configuration (loaded from TOML). #[derive(Debug, Clone, Deserialize, Serialize)] @@ -52,6 +52,247 @@ fn default_retries() -> u32 { 2 } +impl Config { + /// Load and parse a config file from disk (TOML). + /// + /// This only checks that the file is present and structurally valid TOML. + /// Use [`Config::check`] for the semantic, operator-facing validation that + /// powers the `doctor` command. + pub fn load(path: &Path) -> anyhow::Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("failed to read config at {}: {e}", path.display()))?; + let config: Config = toml::from_str(&raw) + .map_err(|e| anyhow::anyhow!("failed to parse config at {}: {e}", path.display()))?; + Ok(config) + } + + /// Run semantic validation over a parsed config and return every diagnostic + /// found. An empty `Error`-severity set means the config is ready to serve. + /// + /// This never touches the network and never reads secret *contents* — it + /// only checks for placeholder values, missing files, and mapping mistakes + /// that would otherwise fail at runtime with an opaque error. + pub fn check(&self) -> Vec { + let mut out = Vec::new(); + + // ── GitHub App ────────────────────────────────────────────────── + if self.github.app_id == 0 { + out.push(Diagnostic::error( + "github.app_id", + "App ID is 0 — set it to the numeric App ID from your GitHub App settings page.", + )); + } + + match path_status(&self.github.private_key_path) { + PathStatus::Missing => out.push(Diagnostic::error( + "github.private_key_path", + format!( + "private key not found at '{}' — download the App's PEM and point this at it.", + self.github.private_key_path.display() + ), + )), + PathStatus::NotAFile => out.push(Diagnostic::error( + "github.private_key_path", + format!( + "'{}' exists but is not a file.", + self.github.private_key_path.display() + ), + )), + PathStatus::Ok => { + if !pem_looks_valid(&self.github.private_key_path) { + out.push(Diagnostic::warning( + "github.private_key_path", + "file does not start with a PEM header ('-----BEGIN') — confirm it is the downloaded private key.", + )); + } + } + } + + let secret = self.github.webhook_secret.trim(); + if secret.is_empty() { + out.push(Diagnostic::error( + "github.webhook_secret", + "webhook secret is empty — set it to the secret configured in the GitHub App.", + )); + } else if PLACEHOLDER_SECRETS + .iter() + .any(|p| secret.eq_ignore_ascii_case(p)) + { + out.push(Diagnostic::error( + "github.webhook_secret", + format!("webhook secret is still the placeholder '{secret}' — replace it with the real secret."), + )); + } else if secret.len() < 16 { + out.push(Diagnostic::warning( + "github.webhook_secret", + "webhook secret is shorter than 16 characters — use a long random string.", + )); + } + + // ── Worker ────────────────────────────────────────────────────── + if self.worker.concurrency == 0 { + out.push(Diagnostic::error( + "worker.concurrency", + "concurrency is 0 — no tasks would ever run; set it to 1 or more.", + )); + } + + if !binary_resolvable(&self.worker.coven_code_bin) { + out.push(Diagnostic::error( + "worker.coven_code_bin", + format!( + "coven-code binary not found at '{}' (and not on PATH) — build/install coven-code or fix the path.", + self.worker.coven_code_bin.display() + ), + )); + } + + // ── Familiars ─────────────────────────────────────────────────── + if self.familiars.is_empty() { + out.push(Diagnostic::error( + "familiars", + "no [[familiars]] configured — add at least one block so webhooks can route to a familiar.", + )); + } + + let mut seen_ids = std::collections::HashSet::new(); + let mut seen_bots = std::collections::HashSet::new(); + for fam in &self.familiars { + let label = if fam.id.is_empty() { + "" + } else { + &fam.id + }; + if fam.id.is_empty() { + out.push(Diagnostic::error( + "familiars[].id", + "a familiar is missing an id.", + )); + } else if !seen_ids.insert(fam.id.as_str()) { + out.push(Diagnostic::error( + "familiars[].id", + format!("duplicate familiar id '{}' — ids must be unique.", fam.id), + )); + } + + if fam.bot_username.trim().is_empty() { + out.push(Diagnostic::error( + "familiars[].bot_username", + format!("familiar '{label}' has no bot_username — assignment and mentions cannot match it."), + )); + } else { + if !seen_bots.insert(fam.bot_username.as_str()) { + out.push(Diagnostic::error( + "familiars[].bot_username", + format!("duplicate bot_username '{}' — two familiars would race the same events.", fam.bot_username), + )); + } + if !fam.bot_username.ends_with("[bot]") { + out.push(Diagnostic::warning( + "familiars[].bot_username", + format!("bot_username '{}' does not end in '[bot]' — GitHub App bot logins normally do.", fam.bot_username), + )); + } + } + + if fam.trigger_labels.is_empty() { + out.push(Diagnostic::warning( + "familiars[].trigger_labels", + format!("familiar '{label}' has no trigger_labels — it will only run on direct bot assignment/mention."), + )); + } + } + + out + } +} + +/// Severity of a [`Diagnostic`] produced by [`Config::check`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Severity { + /// Blocks a usable release; the server should not be started. + Error, + /// Worth fixing but not fatal. + Warning, +} + +/// A single config-validation finding, scoped to a config field. +#[derive(Debug, Clone)] +pub struct Diagnostic { + pub severity: Severity, + pub field: String, + pub message: String, +} + +impl Diagnostic { + fn error(field: &str, message: impl Into) -> Self { + Self { + severity: Severity::Error, + field: field.to_string(), + message: message.into(), + } + } + fn warning(field: &str, message: impl Into) -> Self { + Self { + severity: Severity::Warning, + field: field.to_string(), + message: message.into(), + } + } + pub fn is_error(&self) -> bool { + self.severity == Severity::Error + } +} + +const PLACEHOLDER_SECRETS: &[&str] = + &["CHANGE_ME", "CHANGEME", "changeme", "your-secret", "secret"]; + +enum PathStatus { + Ok, + Missing, + NotAFile, +} + +fn path_status(p: &Path) -> PathStatus { + match std::fs::metadata(p) { + Ok(m) if m.is_file() => PathStatus::Ok, + Ok(_) => PathStatus::NotAFile, + Err(_) => PathStatus::Missing, + } +} + +/// Cheap sniff that a file begins with a PEM header without logging its bytes. +fn pem_looks_valid(p: &Path) -> bool { + use std::io::Read; + let Ok(mut f) = std::fs::File::open(p) else { + return false; + }; + let mut buf = [0u8; 16]; + match f.read(&mut buf) { + Ok(n) => buf[..n].starts_with(b"-----BEGIN"), + Err(_) => false, + } +} + +/// True if `bin` resolves to an executable: either an existing file at the given +/// path, or (for a bare name) a file found on `PATH`. +fn binary_resolvable(bin: &Path) -> bool { + if bin.is_file() { + return true; + } + // Bare command name (no path separator) → search PATH. + if bin.components().count() == 1 { + if let Some(paths) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&paths) { + if dir.join(bin).is_file() { + return true; + } + } + } + } + false +} + /// Per-familiar configuration for task routing. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct FamiliarConfig { @@ -68,3 +309,176 @@ pub struct FamiliarConfig { #[serde(default)] pub trigger_labels: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + fn good_familiar() -> FamiliarConfig { + FamiliarConfig { + id: "cody".into(), + display_name: "Cody".into(), + bot_username: "coven-cody[bot]".into(), + model: None, + skills: vec![], + trigger_labels: vec!["coven:fix".into()], + } + } + + /// A config that points every path at something real so `check` only fires + /// on the field under test. The PEM and binary live next to each other. + fn config_with( + github: GitHubAppConfig, + worker: WorkerConfig, + familiars: Vec, + ) -> Config { + Config { + server: ServerConfig { + bind: "0.0.0.0:3000".into(), + cave_base_url: None, + }, + github, + worker, + familiars, + } + } + + fn write_pem(dir: &Path) -> PathBuf { + let p = dir.join("key.pem"); + std::fs::write( + &p, + b"-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n", + ) + .unwrap(); + p + } + + fn write_bin(dir: &Path) -> PathBuf { + let p = dir.join("coven-code"); + std::fs::write(&p, b"#!/bin/sh\n").unwrap(); + p + } + + fn tmpdir() -> PathBuf { + // Unique-enough temp dir without pulling in an extra dependency. + let base = + std::env::temp_dir().join(format!("coven-github-cfg-test-{}", std::process::id())); + let dir = base.join(format!("{:p}", &base)); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn errors(diags: &[Diagnostic]) -> Vec<&str> { + diags + .iter() + .filter(|d| d.is_error()) + .map(|d| d.field.as_str()) + .collect() + } + + #[test] + fn clean_config_has_no_errors() { + let dir = tmpdir(); + let pem = write_pem(&dir); + let bin = write_bin(&dir); + let cfg = config_with( + GitHubAppConfig { + app_id: 123, + private_key_path: pem, + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 4, + coven_code_bin: bin, + workspace_root: dir.clone(), + timeout_secs: 600, + max_retries: 2, + }, + vec![good_familiar()], + ); + let diags = cfg.check(); + assert!(errors(&diags).is_empty(), "diags: {diags:?}"); + } + + #[test] + fn flags_placeholder_secret_and_zero_app_id_and_missing_pem() { + let dir = tmpdir(); + let bin = write_bin(&dir); + let cfg = config_with( + GitHubAppConfig { + app_id: 0, + private_key_path: dir.join("does-not-exist.pem"), + webhook_secret: "CHANGE_ME".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 4, + coven_code_bin: bin, + workspace_root: dir.clone(), + timeout_secs: 600, + max_retries: 2, + }, + vec![good_familiar()], + ); + let diags = cfg.check(); + let errs = errors(&diags); + assert!(errs.contains(&"github.app_id")); + assert!(errs.contains(&"github.webhook_secret")); + assert!(errs.contains(&"github.private_key_path")); + } + + #[test] + fn flags_missing_binary_and_empty_familiars() { + let dir = tmpdir(); + let pem = write_pem(&dir); + let cfg = config_with( + GitHubAppConfig { + app_id: 1, + private_key_path: pem, + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 0, + coven_code_bin: dir.join("nope-not-here"), + workspace_root: dir.clone(), + timeout_secs: 600, + max_retries: 2, + }, + vec![], + ); + let diags = cfg.check(); + let errs = errors(&diags); + assert!(errs.contains(&"worker.coven_code_bin")); + assert!(errs.contains(&"worker.concurrency")); + assert!(errs.contains(&"familiars")); + } + + #[test] + fn flags_duplicate_familiar_ids_and_bots() { + let dir = tmpdir(); + let pem = write_pem(&dir); + let bin = write_bin(&dir); + let cfg = config_with( + GitHubAppConfig { + app_id: 1, + private_key_path: pem, + webhook_secret: "a-long-random-webhook-secret".into(), + api_base_url: None, + }, + WorkerConfig { + concurrency: 1, + coven_code_bin: bin, + workspace_root: dir.clone(), + timeout_secs: 600, + max_retries: 2, + }, + vec![good_familiar(), good_familiar()], + ); + let diags = cfg.check(); + let errs = errors(&diags); + assert!(errs.contains(&"familiars[].id")); + assert!(errs.contains(&"familiars[].bot_username")); + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 6942e6f..99e39b9 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -30,6 +30,14 @@ enum Command { #[arg(long, default_value = "config/local.toml")] config: PathBuf, }, + /// Validate a config file and report missing or placeholder values. + /// + /// Exits non-zero if any error-severity problem is found, so it doubles as + /// a pre-flight check in CI or a container entrypoint. + Doctor { + #[arg(long, default_value = "config/local.toml")] + config: PathBuf, + }, } #[tokio::main] @@ -42,13 +50,40 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { + Command::Doctor { + config: config_path, + } => { + // Doctor reports problems on stderr and via exit code; it must not + // start the server even if the config is clean. + let exit = run_doctor(&config_path); + std::process::exit(exit); + } Command::Serve { config: config_path, } => { - let config_str = std::fs::read_to_string(&config_path).map_err(|e| { - anyhow::anyhow!("failed to read config at {}: {e}", config_path.display()) - })?; - let config: Config = toml::from_str(&config_str)?; + let config = Config::load(&config_path)?; + + // Fail fast on a broken config instead of crashing later with an + // opaque error mid-request. `doctor` gives the full report. + let diagnostics = config.check(); + let error_count = diagnostics.iter().filter(|d| d.is_error()).count(); + for d in &diagnostics { + match d.severity { + coven_github_config::Severity::Error => { + tracing::error!(field = %d.field, "config error: {}", d.message) + } + coven_github_config::Severity::Warning => { + tracing::warn!(field = %d.field, "config warning: {}", d.message) + } + } + } + if error_count > 0 { + anyhow::bail!( + "config has {error_count} error(s) — run `coven-github doctor --config {}` for details", + config_path.display() + ); + } + let config = Arc::new(config); let (task_tx, task_rx) = mpsc::channel(256); @@ -83,3 +118,51 @@ async fn main() -> Result<()> { Ok(()) } + +/// Load + validate a config and print a human-readable report. +/// +/// Returns the process exit code: `0` if there are no errors (warnings are +/// allowed), `1` if validation found errors, `2` if the file could not be read +/// or parsed at all. +fn run_doctor(config_path: &std::path::Path) -> i32 { + use coven_github_config::Severity; + + let config = match Config::load(config_path) { + Ok(c) => c, + Err(e) => { + eprintln!("✗ {e}"); + return 2; + } + }; + + let diagnostics = config.check(); + let errors = diagnostics.iter().filter(|d| d.is_error()).count(); + let warnings = diagnostics.len() - errors; + + for d in &diagnostics { + let mark = match d.severity { + Severity::Error => "✗ error", + Severity::Warning => "! warn ", + }; + eprintln!("{mark} {:<28} {}", d.field, d.message); + } + + if diagnostics.is_empty() { + println!( + "✓ config at {} looks good — {} familiar(s) configured.", + config_path.display(), + config.familiars.len() + ); + } else { + eprintln!( + "\n{errors} error(s), {warnings} warning(s) in {}", + config_path.display() + ); + } + + if errors > 0 { + 1 + } else { + 0 + } +} diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 1fac6ae..565d8b3 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -104,6 +104,21 @@ Important config fields: | `familiars[].bot_username` | GitHub App bot username that assignment and mentions match. | | `familiars[].trigger_labels` | Labels such as `coven:fix` that create familiar tasks. | +### Validate the config + +Before starting the server, run the built-in doctor. It checks for the mistakes +that otherwise surface as opaque runtime failures — a placeholder webhook +secret, a missing PEM, a `coven_code_bin` that is not on disk or `PATH`, an +empty `[[familiars]]` list, or duplicate familiar ids/bot usernames: + +```bash +./target/release/coven-github doctor --config config/local.toml +``` + +It prints one line per finding and exits non-zero if any **error** remains, so +it works as a CI gate or container preflight. `serve` runs the same checks on +startup and refuses to boot when an error is present. + ## 5. Run ```bash @@ -131,6 +146,17 @@ curl -i \ Expected result: `401 Unauthorized` with `{"error":"missing signature"}`. That confirms the webhook route is reachable and signature enforcement is active. +For the full signature path — unsigned rejected, bad signature rejected, and a +correctly HMAC-signed delivery accepted — run the bundled script against the +running server: + +```bash +scripts/smoke-webhook.sh http://localhost:3000/webhook "$WEBHOOK_SECRET" +``` + +It signs a `ping` payload with the same HMAC-SHA256 scheme GitHub uses, so a +green run proves the receiver end-to-end without a real delivery or `coven-code`. + ## 7. End-to-end test On a repo where the App is installed: @@ -149,19 +175,36 @@ ngrok http 3000 ## Docker -```dockerfile -FROM rust:1.82 AS builder -WORKDIR /app -COPY . . -RUN cargo build --release - -FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates git && rm -rf /var/lib/apt/lists/* -COPY --from=builder /app/target/release/coven-github /usr/local/bin/ -COPY config/example.toml /config/local.toml -CMD ["coven-github", "serve", "--config", "/config/local.toml"] +A production-shaped multi-stage [`Dockerfile`](../Dockerfile) and a +[`compose.yaml`](../compose.yaml) ship in the repo root. The image runs as an +unprivileged user and bakes in no secrets — config and the private key are +mounted read-only at runtime (the [`.dockerignore`](../.dockerignore) keeps +`keys/`, `*.pem`, and `config/local.toml` out of the build context). + +```bash +# Build the image +docker build -t coven-github . + +# Validate the mounted config before serving (exits non-zero on errors) +docker run --rm -v "$PWD/config:/config:ro" -v "$PWD/keys:/keys:ro" \ + coven-github doctor --config /config/local.toml + +# Serve +docker run --rm -p 3000:3000 \ + -v "$PWD/config:/config:ro" -v "$PWD/keys:/keys:ro" \ + coven-github ``` +Or with Compose (create `./keys/` and drop your PEM in first): + +```bash +docker compose run --rm coven-github doctor --config /config/local.toml +docker compose up +``` + +`coven-code` must be reachable inside the container — bake it into a derived +image or mount it, and point `worker.coven_code_bin` at its in-container path. + ## Security notes - The webhook secret is critical — validate it on every request (coven-github does this automatically) diff --git a/scripts/smoke-webhook.sh b/scripts/smoke-webhook.sh new file mode 100755 index 0000000..03b9cd8 --- /dev/null +++ b/scripts/smoke-webhook.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Local webhook smoke test for coven-github. +# +# Verifies, against a running server, that: +# 1. an unsigned request is rejected (401 missing signature) +# 2. a request with a bad signature is rejected (401 invalid signature) +# 3. a correctly HMAC-signed request is accepted (200 ok) +# +# It signs with the same scheme GitHub uses (HMAC-SHA256, `sha256=` prefix), so +# a green run proves the signature path end-to-end without a real delivery. +# +# Usage: +# scripts/smoke-webhook.sh [URL] [SECRET] +# URL webhook endpoint (default: http://localhost:3000/webhook) +# SECRET github.webhook_secret from your config (default: $WEBHOOK_SECRET) +set -euo pipefail + +URL="${1:-http://localhost:3000/webhook}" +SECRET="${2:-${WEBHOOK_SECRET:-}}" + +if [[ -z "$SECRET" ]]; then + echo "error: webhook secret required (arg 2 or \$WEBHOOK_SECRET)" >&2 + exit 64 +fi + +# A minimal, valid 'ping' delivery — accepted and acknowledged without enqueuing +# a worker task, so the smoke test never needs coven-code or a GitHub token. +BODY='{"zen":"Keep it logically awesome.","hook_id":1}' + +status() { # $1=description $2=expected_code $@=curl args + local desc="$1" expected="$2"; shift 2 + local code + code="$(curl -s -o /dev/null -w '%{http_code}' "$@")" + if [[ "$code" == "$expected" ]]; then + echo " ok $desc → $code" + else + echo " FAIL $desc → got $code, expected $expected" >&2 + return 1 + fi +} + +echo "smoke-testing $URL" + +# 1. Unsigned → 401 +status "unsigned request rejected" 401 \ + -X POST -H 'X-GitHub-Event: ping' -d "$BODY" "$URL" + +# 2. Bad signature → 401 +status "bad signature rejected" 401 \ + -X POST -H 'X-GitHub-Event: ping' \ + -H 'X-Hub-Signature-256: sha256=deadbeef' -d "$BODY" "$URL" + +# 3. Valid signature → 200 +SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')" +status "valid signature accepted" 200 \ + -X POST -H 'X-GitHub-Event: ping' \ + -H "X-Hub-Signature-256: $SIG" -d "$BODY" "$URL" + +echo "smoke test passed ✓"