Back out quire-ci webserver: remove server, db, migrations
The webserver approach turned out to be unnecessary. Remove:
- server.rs (axum HTTP server with webhook handler)
- db.rs (SQLite wrapper for quire-ci runs)
- migrations/0001_initial.sql
- Commands::Serve from main.rs
- All webserver dependencies from Cargo.toml
The mod quire is retained (pre-webserver version): config loading
without the webhook_secret or port fields added for the server.
Also delete the design and deployment docs for the split architecture.
https://claude.ai/code/session_0171wuJy2GRJZuj2oVYZ2iKe
diff --git a/Cargo.lock b/Cargo.lock
index 87263bb..49bd370 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -485,15 +485,6 @@ dependencies = [
"generic-array",
]
-[[package]]
-name = "block-buffer"
-version = "0.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
-dependencies = [
- "hybrid-array",
-]
-
[[package]]
name = "block2"
version = "0.6.2"
@@ -650,12 +641,6 @@ dependencies = [
"cc",
]
-[[package]]
-name = "cmov"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
-
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -678,12 +663,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
-[[package]]
-name = "const-oid"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
-
[[package]]
name = "convert_case"
version = "0.10.0"
@@ -718,15 +697,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "cpufeatures"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -752,24 +722,6 @@ dependencies = [
"typenum",
]
-[[package]]
-name = "crypto-common"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
-dependencies = [
- "hybrid-array",
-]
-
-[[package]]
-name = "ctutils"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
-dependencies = [
- "cmov",
-]
-
[[package]]
name = "debugid"
version = "0.8.0"
@@ -824,20 +776,8 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
- "block-buffer 0.10.4",
- "crypto-common 0.1.7",
-]
-
-[[package]]
-name = "digest"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
-dependencies = [
- "block-buffer 0.12.0",
- "const-oid",
- "crypto-common 0.2.2",
- "ctutils",
+ "block-buffer",
+ "crypto-common",
]
[[package]]
@@ -1467,15 +1407,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-[[package]]
-name = "hmac"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
-dependencies = [
- "digest 0.11.3",
-]
-
[[package]]
name = "hostname"
version = "0.4.2"
@@ -1543,15 +1474,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
-[[package]]
-name = "hybrid-array"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
-dependencies = [
- "typenum",
-]
-
[[package]]
name = "hyper"
version = "1.9.0"
@@ -2626,14 +2548,9 @@ dependencies = [
name = "quire-ci"
version = "0.1.0"
dependencies = [
- "axum",
- "axum-extra",
"facet",
"figue",
"fs-err",
- "hex",
- "hmac",
- "http-body-util",
"jiff",
"miette",
"mlua",
@@ -2641,22 +2558,15 @@ dependencies = [
"opentelemetry_sdk",
"quire-core",
"reqwest",
- "rusqlite",
- "rusqlite_migration",
"sentry",
"serde",
"serde_json",
- "sha2",
- "subtle",
"tempfile",
"thiserror",
"tokio",
- "tower",
- "tower-http",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
- "uuid",
]
[[package]]
@@ -3323,19 +3233,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
- "cpufeatures 0.2.17",
- "digest 0.10.7",
-]
-
-[[package]]
-name = "sha2"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
-dependencies = [
- "cfg-if",
- "cpufeatures 0.3.0",
- "digest 0.11.3",
+ "cpufeatures",
+ "digest",
]
[[package]]
diff --git a/docs/plans/2026-05-23-quire-ci-split-design.md b/docs/plans/2026-05-23-quire-ci-split-design.md
deleted file mode 100644
index c5aa2ef..0000000
--- a/docs/plans/2026-05-23-quire-ci-split-design.md
+++ /dev/null
@@ -1,372 +0,0 @@
-# Split quire-ci from quire-server
-
-Make CI a standalone service. quire-server keeps the git wire protocol,
-repo browsing, and push detection. A new `quire-ci serve` mode owns the
-webhook receiver, SQLite, runner, run/log web UI, and per-run workspace
-materialization.
-
-## Scope
-
-In scope:
-
-- A standalone `quire-ci serve` long-running process.
-- A webhook from quire-server to quire-ci on push.
-- A fresh `0001` migration for quire-ci's SQLite, post-split shape.
-- One Dockerfile with two final-stage targets (`server`, `ci`).
-- A staged cutover with quire-server intact through phases 1 and 2.
-
-Out of scope:
-
-- Schema or vocabulary reshape beyond what the split forces (deferred to
- a separate pass once quire-ci is standing on its own).
-- A `repos` or `pushes` table. Add when a concrete need lands.
-- Per-repo webhook secrets. One shared secret is the right grain.
-- The in-pipeline `quire-ci run` subprocess dispatch path. Collapses to
- in-thread execution.
-
-## Service shape
-
-```
- push webhook POST
-client (jj/git) ────────► quire-server ────────────────────► quire-ci
- │ │
- │ git wire, repo browse UI, │ webhook endpoint,
- │ post-receive hook, │ SQLite, runner,
- │ internal git-http endpoint │ run/log web UI
- │ │
- ▼ ▼
- /var/quire/repos git clone over HTTP from quire-server,
- (bare repos) materialize workspace under /var/quire-ci
- │
- ▼
- runner task
- (in-process Fennel VM)
- │
- ┌────────────────────┴────────────────────┐
- ▼ ▼
- (sh ...) → child process (sh ...) → docker exec
- (subprocess phase, now) (per-run container, later)
-```
-
-Push lifecycle:
-
-1. Client pushes. `git-receive-pack` accepts. The `post-receive` hook fires.
-2. The hook POSTs JSON to `ci_webhook_url`. Auth via shared secret HMAC
- header. The active span's `traceparent` is propagated as a header.
-3. quire-ci's webhook handler inserts a `runs` row, extracts the
- traceparent, and notifies the runner via `tokio::sync::Notify`.
-4. The runner picks up the run, clones the repo from quire-server's
- internal git-http endpoint at the SHA in the payload, and materializes
- a workspace under `/var/quire-ci/runs/<id>/workspace/`.
-5. The runner calls into the pipeline runtime via
- `tokio::task::spawn_blocking` (Lua is not `Send`-friendly across
- `.await` points).
-6. The runtime evaluates `ci.fnl`, walks the DAG, dispatches each
- `(sh ...)`:
- - Subprocess phase (now): spawn a child process directly.
- - Docker phase (later): `docker exec <per-run-container> sh -c "..."`.
-7. Events flow back over an in-process `mpsc` channel. The runner
- persists them to SQLite (`jobs`, `sh`) and rebroadcasts via
- `tokio::sync::broadcast` to any web subscribers tailing logs.
-8. Secrets stay in-process. The runtime resolves them against quire-ci's
- `SecretRegistry` directly.
-
-What goes away from the current shape:
-
-- `quire-ci run` subprocess dispatch and its JSONL event stream.
-- The `/api/run/bootstrap` and `/api/run/secrets/:name` endpoints.
-- `run_token` minting and verification.
-- `QUIRE__SERVER_URL` / `QUIRE__RUN_TOKEN` env plumbing.
-- The musl-static build target. quire-ci is dynamic against glibc.
-
-What stays as a thin CLI:
-
-- `quire-ci validate <path>` and `quire-ci run --local <path>` wrap the
- same in-process pipeline runtime for debugging `ci.fnl` outside the
- orchestrator. One subcommand each, no orchestrator state.
-
-## Repository access
-
-quire-ci clones from quire-server per run. Per-run clone is wasteful but
-buys isolation; the clone-and-discard cost is acceptable for v1.
-
-quire-server gains an **internal git-over-HTTP endpoint** for this.
-Smart HTTP via `git http-backend`, bound to a port reachable only by
-quire-ci. Auth: a shared token in an Authorization header, or network
-isolation (docker network, loopback), or both.
-
-Clone URL: configured on quire-ci's side, not in the webhook payload.
-One quire-ci binds to one quire-server.
-
-Future optimization (when measured): cache a bare clone at
-`/var/quire-ci/repos/<name>.git`, update via `git fetch` on each
-webhook, reify per-run workspaces via `git worktree add` or
-`jj workspace add`. Avoids the full re-clone per push without coupling
-through a shared mount.
-
-## Schema (new quire-ci/migrations/0001)
-
-Lifecycle is derived from column population, not stored. No `state`
-column. A row's stage is read off the timestamps and outcome:
-
-| `dispatched_at` | `outcome` | Stage |
-|---|---|---|
-| NULL | NULL | queued |
-| set | NULL | active |
-| set | set | resolved (specific kind in `outcome`) |
-
-```sql
-CREATE TABLE runs (
- id TEXT PRIMARY KEY,
- repo TEXT NOT NULL,
- ref_name TEXT NOT NULL,
- sha TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- dispatched_at INTEGER,
- resolved_at INTEGER,
- outcome TEXT,
- traceparent TEXT,
-
- -- timestamps move forward
- CHECK (dispatched_at IS NULL OR dispatched_at >= created_at),
- CHECK (resolved_at IS NULL OR resolved_at >= created_at),
- CHECK (resolved_at IS NULL OR dispatched_at IS NULL
- OR resolved_at >= dispatched_at),
-
- -- resolved_at and outcome travel together
- CHECK ((resolved_at IS NULL) = (outcome IS NULL)),
-
- -- outcome enum
- CHECK (outcome IS NULL OR outcome IN (
- 'succeeded',
- 'failed-pipeline', 'failed-orphaned', 'failed-internal',
- 'superseded'
- ))
-);
-
--- Pending work: queue scans only touch unresolved rows.
-CREATE INDEX runs_pending ON runs(created_at) WHERE outcome IS NULL;
-
--- Listing runs per repo, most recent first.
-CREATE INDEX runs_repo_created_at ON runs(repo, created_at DESC);
-```
-
-A Rust-side `enum RunStage { Queued, Active, Resolved(Outcome) }` is
-computed from the columns rather than stored. The mapping is the table
-above.
-
-`jobs` and `sh` follow the same convention (drop state column, encode
-lifecycle through timestamp population, add an `outcome` enum where
-relevant). Spelling out their CHECK constraints can wait for the
-migration itself.
-
-Outcome values:
-
-| Value | Meaning |
-|---|---|
-| `succeeded` | Pipeline ran, all jobs passed. |
-| `failed-pipeline` | Pipeline ran, a job or `(sh ...)` reported failure. |
-| `failed-orphaned` | Runner restart found an unresolved row with no live runner — `reconcile_orphans` marked it. |
-| `failed-internal` | Runner task panicked or hit an unexpected error before the pipeline could report. |
-| `superseded` | A later push for the same `(repo, ref)` displaced this one. |
-
-Dropped from the current schema:
-
-- `runs.state` (derived from timestamps + `outcome` now).
-- `runs.failure_kind` (folded into `outcome` as `failed-*` variants).
-- `runs.run_token` (no API callback to authenticate).
-- `runs.git_dir` (derivable from `repo` plus a known base, and the base
- is per-process anyway).
-- `runs.pushed_at_ms` (the receive time on quire-ci's clock is the
- honest field; renamed to `created_at`).
-- `runs.started_at_ms` / `finished_at_ms` (renamed to `dispatched_at` /
- `resolved_at` — neutral terms that don't overclaim like "finished"
- does for a superseded run).
-
-Kept: `runs.traceparent`, populated from the webhook header rather than
-env vars.
-
-## Vocabulary
-
-Leave `runs`, `jobs`, `sh` as they are. `sh` is honest about being the
-host-effect chokepoint and avoids overclaiming "step" when a job's
-Fennel logic between `(sh ...)` calls is not recorded as a row.
-Generalize the name when a second host primitive lands.
-
-## Webhook contract
-
-`POST {ci_webhook_url}` with body:
-
-```json
-{
- "repo": "foo",
- "refs": [
- { "ref_name": "refs/heads/main",
- "old_sha": "...",
- "new_sha": "..." }
- ]
-}
-```
-
-Headers:
-
-- `Authorization: HMAC-SHA256 <hex>` — signature over the raw body
- using `ci_webhook_secret`.
-- `traceparent: <w3c>` — propagated from the post-receive span.
-
-One webhook per push. A push can touch multiple refs; the receiver
-inserts one `runs` row per ref.
-
-## Binary
-
-One `quire-ci` binary with three subcommands:
-
-- `serve` — the orchestrator (webhook + DB + runner + web UI).
-- `validate <path>` — compile-only check of a `ci.fnl`.
-- `run --local <path>` — execute a `ci.fnl` against a local checkout
- for debugging, no orchestrator state involved.
-
-The server-dispatched, subprocess form of `quire-ci run` goes away.
-
-## Dockerfile
-
-One Dockerfile with two final-stage targets, sharing the cargo-chef
-cook stage:
-
-```dockerfile
-FROM debian:trixie-slim AS git-builder
-# builds git 2.54 from source; only the server target consumes it
-...
-
-FROM rust:1.95-trixie AS chef
-RUN cargo install --locked cargo-chef
-WORKDIR /build
-
-FROM chef AS planner
-COPY . .
-RUN cargo chef prepare --recipe-path recipe.json
-
-FROM chef AS builder
-COPY --from=planner /build/recipe.json recipe.json
-RUN --mount=type=cache,target=/usr/local/cargo/registry \
- --mount=type=cache,target=/build/target \
- cargo chef cook --release --recipe-path recipe.json
-COPY . .
-RUN --mount=type=cache,target=/usr/local/cargo/registry \
- --mount=type=cache,target=/build/target \
- cargo build --release --bin quire --bin quire-ci && \
- mkdir -p /build/bin && \
- cp target/release/quire /build/bin/quire && \
- cp target/release/quire-ci /build/bin/quire-ci
-
-FROM debian:trixie-slim AS server
-RUN apt-get update && apt-get install -y --no-install-recommends \
- ca-certificates libcurl4 libexpat1 \
- && rm -rf /var/lib/apt/lists/*
-COPY --from=git-builder /usr/local/bin/git /usr/local/bin/git
-COPY --from=git-builder /usr/local/libexec/git-core/ /usr/local/libexec/git-core/
-COPY --from=builder /build/bin/quire /usr/local/bin/quire
-RUN git config --system hook.quire.event "post-receive" \
- && git config --system hook.quire.command "quire hook post-receive"
-RUN mkdir -p /var/quire/repos
-WORKDIR /var/quire
-EXPOSE 3000
-ENTRYPOINT ["quire"]
-CMD ["serve"]
-
-FROM debian:trixie-slim AS ci
-RUN apt-get update && apt-get install -y --no-install-recommends \
- ca-certificates git \
- && rm -rf /var/lib/apt/lists/*
-COPY --from=builder /build/bin/quire-ci /usr/local/bin/quire-ci
-RUN mkdir -p /var/quire-ci
-WORKDIR /var/quire-ci
-EXPOSE 3001
-ENTRYPOINT ["quire-ci"]
-CMD ["serve"]
-```
-
-Build:
-
-```
-docker build --target server -t quire-server:$VER .
-docker build --target ci -t quire-ci:$VER .
-```
-
-Notes:
-
-- The `builder` stage builds both binaries on every target. The cook
- stage dominates; per-bin compilation is cheap. Split into per-bin
- builder stages only if it shows up in CI time.
-- No `docker:cli` copy. The subprocess-first phase does not need it.
- Add one apt line to the ci target when the docker-exec phase lands.
-- `Dockerfile.gitweb` is unrelated and stays as-is.
-
-## Code moves
-
-### Phase 1 — grow quire-ci in parallel
-
-quire-server is **not touched** in this phase. quire-ci grows from a
-stub into a working orchestrator alongside the existing in-process
-runner.
-
-- `quire-ci/migrations/0001_initial.sql` — the schema above.
-- `quire-ci/src/orchestrator/` — webhook handler, `Runs`/`Run`/`Executor`,
- runner task. Crib from `quire-server/src/ci/`, then trim.
-- `quire-ci/src/web/` — run/log views. Port templates and Askama setup
- from quire-server.
-- `quire-ci/src/db.rs` — DB plumbing, cribbed from quire-server.
-- `quire-ci/src/server.rs` (existing stub) — fleshed out into the real
- axum router: `/webhook`, `/runs/*`, `/runs/:id/jobs/:id/logs/stream`,
- static assets.
-- `quire-ci/src/quire.rs` — `QuireCi` grows to own the DB pool,
- `SecretRegistry`, and runner handle.
-- `quire-core::PushEvent` / `PushRef` — lifted out of
- `quire-server/src/event.rs`, since both processes need them.
-- `quire-server`: add a new `webhook_client` module (signs and POSTs the
- event) with tests, but **do not wire it into the hook yet**.
-- `quire-server`: add the internal git-http endpoint. quire-ci needs
- something to clone from during parallel development.
-
-At the end of phase 1, both processes work. quire-server still handles
-pushes via the in-process runner. quire-ci runs standalone, exercised
-via direct webhook calls.
-
-### Phase 2 — cutover
-
-A single small change:
-
-- Switch `quire hook post-receive` from "notify in-process listener" to
- "POST to `ci_webhook_url`".
-- Add `ci_webhook_url` and `ci_webhook_secret` to quire-server's config.
-- Deploy quire-ci into production alongside quire-server.
-
-### Phase 3 — server cleanup
-
-A separate commit, once production has been on quire-ci for long enough
-to trust it:
-
-- Delete `quire-server/src/ci/`, `src/quire/web/api.rs`,
- `src/quire/web/db.rs`, and the run/log handlers and templates.
-- Delete all of `quire-server/migrations/` and the `rusqlite*` deps.
-- Drop `quire-core::api::SecretResponse`, `ci::bootstrap`,
- `ci::run::{ApiSession, RunMeta}`. They have no consumers after this.
-
-quire-server ends the phase stateless with respect to CI.
-
-## Open questions
-
-- HMAC scheme: SHA-256 is the obvious default; revisit if anything
- pushes against it.
-- Whether to fold the existing `/api` mount path into quire-ci's web
- router under the same prefix, or pick a fresh layout. Worth a look
- when porting the templates.
-- Health-check shape for quire-ci. The current stub has `/health`; keep
- that and add `/ready` if any deployment infra needs the distinction.
-
-## Sequencing
-
-1. Phase 1 lands as one or more PRs. Tests at the webhook boundary
- (post a synthesized push to quire-ci, assert a `runs` row appears).
-2. Phase 2 is one small PR plus a deploy.
-3. Phase 3 is the cleanup PR.
diff --git a/docs/quire-ci-deployment.md b/docs/quire-ci-deployment.md
deleted file mode 100644
index 7bf737e..0000000
--- a/docs/quire-ci-deployment.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# quire-ci deployment
-
-`quire-ci` is the webhook receiver / CI dispatcher. It runs as a separate
-container from `quire`, sharing the same image but with a different entrypoint.
-
-## Data layout
-
-```
-/var/quire-ci/
- config.fnl operator-created; required before first start
- quire-ci.db SQLite database; created automatically on first start
-```
-
-## Config
-
-Create `/var/quire-ci/config.fnl` on the host before starting the container.
-Minimal config:
-
-```fennel
-{:webhook-secret "change-me"}
-```
-
-| Key | Required | Default | Purpose |
-|-------------------|----------|---------|----------------------------------------------|
-| `:webhook-secret` | yes | — | Shared HMAC-SHA256 secret with quire-server. |
-| `:port` | no | `3000` | TCP port to listen on. |
-| `:sentry :dsn` | no | — | Sentry DSN for error reporting. |
-
-`:webhook-secret` accepts a plain string or a Docker secret reference:
-
-```fennel
-{:webhook-secret {:file "/run/secrets/webhook_secret"}}
-```
-
-## Docker Compose
-
-```yaml
-services:
- quire-ci:
- image: quire
- entrypoint: quire-ci
- command: serve
- volumes:
- - quire-ci-data:/var/quire-ci
- ports:
- - "3001:3000"
- restart: unless-stopped
- secrets:
- - webhook_secret # optional; only if using {:file ...} in config.fnl
-
-volumes:
- quire-ci-data:
-
-secrets:
- webhook_secret:
- file: ./secrets/webhook_secret
-```
-
-## Wiring to quire-server
-
-`quire-server` POSTs push events to `quire-ci` over HTTP. Set
-`:quire-ci-url` in `/var/quire/config.fnl` to point at the `quire-ci`
-container:
-
-```fennel
-{:quire-ci-url "http://quire-ci:3000/webhook"
- :webhook-secret "change-me"}
-```
-
-Both sides must share the same `:webhook-secret`.
-
-## Health check
-
-```
-GET /health → 200 "ok"
-```
-
-Suitable for a Docker healthcheck or reverse-proxy probe.
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 4281481..27580fe 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -4,36 +4,22 @@ version = "0.1.0"
edition = "2024"
[dependencies]
-axum = { workspace = true }
-axum-extra = { workspace = true }
-tower-http = { workspace = true }
facet = { workspace = true }
figue = "*"
fs-err = { workspace = true }
-hmac = "*"
jiff = { workspace = true }
miette = { workspace = true, features = ["fancy"] }
mlua = { workspace = true }
quire-core = { path = "../quire-core" }
reqwest = { version = "*", features = ["blocking", "json", "rustls"], default-features = false }
-rusqlite = { version = "*", features = ["bundled"] }
-rusqlite_migration = "*"
sentry = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
-sha2 = "*"
-subtle = "*"
tempfile = { workspace = true }
thiserror = { workspace = true }
-tokio = { workspace = true, features = ["full"] }
+tokio = { workspace = true, features = ["rt-multi-thread"] }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
tracing-subscriber = { workspace = true }
-uuid = { version = "*", features = ["v7"] }
-hex = "*"
-
-[dev-dependencies]
-http-body-util = "*"
-tower = { version = "*", features = ["util"] }
diff --git a/quire-ci/migrations/0001_initial.sql b/quire-ci/migrations/0001_initial.sql
deleted file mode 100644
index f72449c..0000000
--- a/quire-ci/migrations/0001_initial.sql
+++ /dev/null
@@ -1,33 +0,0 @@
-CREATE TABLE runs (
- id TEXT PRIMARY KEY,
- repo TEXT NOT NULL,
- ref TEXT NOT NULL,
- sha TEXT NOT NULL,
- created_at INTEGER NOT NULL,
- dispatched_at INTEGER,
- resolved_at INTEGER,
- outcome TEXT,
- traceparent TEXT,
-
- -- timestamps move forward
- CHECK (dispatched_at IS NULL OR dispatched_at >= created_at),
- CHECK (resolved_at IS NULL OR resolved_at >= created_at),
- CHECK (resolved_at IS NULL OR dispatched_at IS NULL
- OR resolved_at >= dispatched_at),
-
- -- resolved_at and outcome travel together
- CHECK ((resolved_at IS NULL) = (outcome IS NULL)),
-
- -- outcome enum
- CHECK (outcome IS NULL OR outcome IN (
- 'succeeded',
- 'failed-pipeline', 'failed-orphaned', 'failed-internal',
- 'superseded'
- ))
-);
-
--- Pending work: queue scans only touch unresolved rows.
-CREATE INDEX runs_pending ON runs(created_at) WHERE outcome IS NULL;
-
--- Listing runs per repo, most recent first.
-CREATE INDEX runs_repo_created_at ON runs(repo, created_at DESC);
diff --git a/quire-ci/src/db.rs b/quire-ci/src/db.rs
deleted file mode 100644
index abc5a4a..0000000
--- a/quire-ci/src/db.rs
+++ /dev/null
@@ -1,94 +0,0 @@
-use std::path::Path;
-use std::sync::{Arc, Mutex, MutexGuard};
-
-use rusqlite::Connection;
-use rusqlite_migration::{M, Migrations};
-
-static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLock::new(|| {
- Migrations::new(vec![M::up(include_str!("../migrations/0001_initial.sql"))])
-});
-
-#[derive(Debug, thiserror::Error)]
-pub enum Error {
- #[error(transparent)]
- Sqlite(#[from] rusqlite::Error),
- #[error("migration error: {0}")]
- Migration(#[from] rusqlite_migration::Error),
-}
-
-/// An opened, migrated SQLite database.
-#[derive(Clone)]
-pub struct Db {
- conn: Arc<Mutex<Connection>>,
-}
-
-impl Db {
- pub fn open(path: &Path) -> Result<Self, Error> {
- tracing::debug!(path = %path.display(), "opening database");
- let mut conn = Connection::open(path)?;
- conn.execute_batch(
- "PRAGMA journal_mode = WAL;
- PRAGMA foreign_keys = ON;
- PRAGMA busy_timeout = 5000;",
- )?;
- MIGRATIONS.to_latest(&mut conn)?;
- Ok(Self {
- conn: Arc::new(Mutex::new(conn)),
- })
- }
-
- pub fn lock(&self) -> MutexGuard<'_, Connection> {
- self.conn.lock().unwrap()
- }
-}
-
-#[cfg(test)]
-pub fn open_in_memory() -> Result<Db, Error> {
- let mut conn = Connection::open_in_memory()?;
- conn.execute_batch("PRAGMA foreign_keys = ON;")?;
- MIGRATIONS.to_latest(&mut conn)?;
- Ok(Db {
- conn: Arc::new(Mutex::new(conn)),
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn migrations_apply_without_panicking() {
- open_in_memory().expect("migrations should apply cleanly");
- }
-
- #[test]
- fn runs_table_has_expected_columns() {
- let db = open_in_memory().expect("open_in_memory");
- let conn = db.lock();
- let mut stmt = conn
- .prepare("PRAGMA table_info(runs)")
- .expect("prepare pragma");
- let columns: Vec<String> = stmt
- .query_map([], |row| row.get(1))
- .expect("query")
- .map(|r| r.expect("column name"))
- .collect();
-
- for expected in &[
- "id",
- "repo",
- "ref",
- "sha",
- "created_at",
- "dispatched_at",
- "resolved_at",
- "outcome",
- "traceparent",
- ] {
- assert!(
- columns.contains(&expected.to_string()),
- "missing column: {expected}"
- );
- }
- }
-}
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 61cce60..9bf86bf 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -1,6 +1,4 @@
-mod db;
mod quire;
-mod server;
mod sink;
use std::cell::RefCell;
@@ -127,9 +125,6 @@ enum Commands {
#[facet(args::named, default)]
git_dir: Option<PathBuf>,
},
-
- /// Start the HTTP server.
- Serve,
}
/// RAII wrapper around a tempdir holding captured sh logs. On drop,
@@ -298,14 +293,6 @@ fn main() -> Result<()> {
match cli.command {
Commands::Validate => validate(workspace),
- Commands::Serve => {
- let quire = quire::QuireCi::new(cli.base_dir)?;
- let rt = tokio::runtime::Builder::new_multi_thread()
- .enable_all()
- .build()
- .into_diagnostic()?;
- rt.block_on(server::run(quire)).into_diagnostic()
- }
Commands::Run {
events,
out_dir,
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
index 2521b4d..83b27f1 100644
--- a/quire-ci/src/quire.rs
+++ b/quire-ci/src/quire.rs
@@ -1,11 +1,8 @@
use std::path::PathBuf;
-use miette::{IntoDiagnostic, Result, bail};
+use miette::{IntoDiagnostic, Result};
use quire_core::fennel::Fennel;
-use quire_core::secret::SecretString;
-
-use crate::db::Db;
/// Parsed global configuration (`<base-dir>/config.fnl`).
#[derive(serde::Deserialize, Debug, Clone)]
@@ -16,7 +13,6 @@ pub struct GlobalConfig {
/// TCP port the HTTP server binds to on all interfaces (`0.0.0.0`).
#[serde(default = "default_port")]
pub port: u16,
- pub webhook_secret: SecretString,
}
fn default_port() -> u16 {
@@ -26,45 +22,37 @@ fn default_port() -> u16 {
pub use quire_core::telemetry::SentryConfig;
/// Application runtime context.
+///
+/// Loads config at construction time so callers don't have to thread
+/// Results around.
#[derive(Clone)]
pub struct QuireCi {
config: GlobalConfig,
- db: Db,
- hmac_key: Vec<u8>,
}
impl QuireCi {
pub fn new(base_dir: PathBuf) -> Result<Self> {
let config_path = base_dir.join("config.fnl");
- if !config_path.exists() {
- bail!("config file not found: {}", config_path.display());
- }
- let fennel = Fennel::new().into_diagnostic()?;
- let config: GlobalConfig = fennel.load_file(&config_path).into_diagnostic()?;
- let hmac_key = config
- .webhook_secret
- .reveal()
- .into_diagnostic()?
- .as_bytes()
- .to_vec();
- let db = Db::open(&base_dir.join("quire-ci.db")).into_diagnostic()?;
- Ok(Self {
- config,
- db,
- hmac_key,
- })
+ let config = if config_path.exists() {
+ let fennel = Fennel::new().into_diagnostic()?;
+ fennel.load_file(&config_path).into_diagnostic()?
+ } else {
+ GlobalConfig::default()
+ };
+ Ok(Self { config })
}
pub fn config(&self) -> &GlobalConfig {
&self.config
}
+}
- pub fn db(&self) -> &Db {
- &self.db
- }
-
- pub fn hmac_key(&self) -> &[u8] {
- &self.hmac_key
+impl Default for GlobalConfig {
+ fn default() -> Self {
+ Self {
+ sentry: None,
+ port: default_port(),
+ }
}
}
@@ -72,18 +60,11 @@ impl QuireCi {
mod tests {
use super::*;
- fn write_config(dir: &tempfile::TempDir, content: &str) {
- fs_err::write(dir.path().join("config.fnl"), content).expect("write config");
- }
-
- fn minimal_config(secret: &str) -> String {
- format!(r#"{{:webhook-secret "{secret}"}}"#)
- }
-
#[test]
fn global_config_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
- write_config(&dir, &minimal_config("s3cret"));
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(&config_path, "{}").expect("write");
let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
assert_eq!(q.config().port, 3000);
@@ -92,26 +73,31 @@ mod tests {
#[test]
fn global_config_parses_custom_port() {
let dir = tempfile::tempdir().expect("tempdir");
- write_config(&dir, r#"{:webhook-secret "s3cret" :port 4000}"#);
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(&config_path, r#"{:port 4000}"#).expect("write");
let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
assert_eq!(q.config().port, 4000);
}
#[test]
- fn global_config_missing_file_errors() {
+ fn global_config_missing_file_uses_defaults() {
let dir = tempfile::tempdir().expect("tempdir");
- let result = QuireCi::new(dir.path().to_path_buf());
- assert!(result.is_err(), "should error when config file is missing");
+
+ let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
+ assert_eq!(q.config().port, 3000);
+ assert!(q.config().sentry.is_none());
}
#[test]
fn global_config_loads_sentry() {
let dir = tempfile::tempdir().expect("tempdir");
- write_config(
- &dir,
- r#"{:webhook-secret "s3cret" :sentry {:dsn "https://key@sentry.io/123"}}"#,
- );
+ let config_path = dir.path().join("config.fnl");
+ fs_err::write(
+ &config_path,
+ r#"{:sentry {:dsn "https://key@sentry.io/123"}}"#,
+ )
+ .expect("write");
let q = QuireCi::new(dir.path().to_path_buf()).expect("should load");
let sentry = q
@@ -121,15 +107,4 @@ mod tests {
.expect("sentry should be present");
assert_eq!(sentry.dsn.reveal().unwrap(), "https://key@sentry.io/123");
}
-
- #[test]
- fn db_is_created_at_expected_path() {
- let dir = tempfile::tempdir().expect("tempdir");
- write_config(&dir, &minimal_config("s3cret"));
- QuireCi::new(dir.path().to_path_buf()).expect("should load");
- assert!(
- dir.path().join("quire-ci.db").exists(),
- "db file should exist after new()"
- );
- }
}
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
deleted file mode 100644
index d072f95..0000000
--- a/quire-ci/src/server.rs
+++ /dev/null
@@ -1,306 +0,0 @@
-use std::net::SocketAddr;
-use std::time::Duration;
-
-use axum::Router;
-use axum::extract::{MatchedPath, State};
-use axum::http::{HeaderMap, Request, StatusCode};
-use axum::response::{IntoResponse, Response};
-use axum::routing::{get, post};
-use axum_extra::TypedHeader;
-use axum_extra::headers::Authorization;
-use axum_extra::headers::authorization::Credentials;
-use hmac::{Hmac, KeyInit, Mac};
-use quire_core::event::PushEvent;
-use quire_core::telemetry::{self, FmtMode};
-use sha2::Sha256;
-use tower_http::trace::TraceLayer;
-use tracing::info_span;
-
-use crate::quire::QuireCi;
-
-const VERSION: &str = env!("QUIRE_VERSION");
-
-async fn health() -> &'static str {
- "ok"
-}
-
-async fn index() -> String {
- format!("quire-ci {VERSION}\n")
-}
-
-#[derive(Debug, thiserror::Error)]
-enum WebhookError {
- #[error("missing or malformed Authorization header")]
- MissingSignature,
- #[error("signature mismatch")]
- InvalidSignature(#[from] hmac::digest::MacError),
- #[error(transparent)]
- InvalidPayload(#[from] serde_json::Error),
- #[error(transparent)]
- Db(#[from] rusqlite::Error),
-}
-
-/// Carries an error message through response extensions so TraceLayer can log it.
-#[derive(Clone)]
-struct RequestError(String);
-
-impl IntoResponse for WebhookError {
- fn into_response(self) -> Response {
- let status = match &self {
- WebhookError::MissingSignature | WebhookError::InvalidSignature(_) => {
- StatusCode::UNAUTHORIZED
- }
- WebhookError::InvalidPayload(_) => StatusCode::BAD_REQUEST,
- WebhookError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
- };
- // Auth failures are intentionally quiet; other errors get logged by TraceLayer.
- if matches!(
- self,
- WebhookError::MissingSignature | WebhookError::InvalidSignature(_)
- ) {
- return status.into_response();
- }
- let mut response = status.into_response();
- response
- .extensions_mut()
- .insert(RequestError(self.to_string()));
- response
- }
-}
-
-struct HmacSha256Sig(Vec<u8>);
-
-impl Credentials for HmacSha256Sig {
- const SCHEME: &'static str = "HMAC-SHA256";
-
- fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
- let hex_str = value.to_str().ok()?.strip_prefix("HMAC-SHA256 ")?;
- hex::decode(hex_str).ok().map(Self)
- }
-
- fn encode(&self) -> axum::http::HeaderValue {
- axum::http::HeaderValue::from_str(&format!("HMAC-SHA256 {}", hex::encode(&self.0)))
- .expect("hex is always a valid header value")
- }
-}
-
-struct HmacSha256Auth(Vec<u8>);
-
-impl<S: Send + Sync> axum::extract::FromRequestParts<S> for HmacSha256Auth {
- type Rejection = WebhookError;
-
- async fn from_request_parts(
- parts: &mut axum::http::request::Parts,
- state: &S,
- ) -> std::result::Result<Self, WebhookError> {
- use axum::extract::FromRequestParts;
-
- let Some(TypedHeader(Authorization(sig))) =
- <TypedHeader<Authorization<HmacSha256Sig>> as FromRequestParts<S>>::from_request_parts(
- parts, state,
- )
- .await
- .ok()
- else {
- return Err(WebhookError::MissingSignature);
- };
- Ok(Self(sig.0))
- }
-}
-
-async fn webhook(
- State(quire): State<QuireCi>,
- HmacSha256Auth(provided_bytes): HmacSha256Auth,
- headers: HeaderMap,
- body: axum::body::Bytes,
-) -> std::result::Result<StatusCode, WebhookError> {
- let mut mac =
- Hmac::<Sha256>::new_from_slice(quire.hmac_key()).expect("HMAC accepts any key length");
- mac.update(&body);
- mac.verify_slice(&provided_bytes)?;
-
- let event: PushEvent = serde_json::from_slice(&body)?;
-
- let traceparent = headers
- .get("traceparent")
- .and_then(|v| v.to_str().ok())
- .map(|s| s.to_string());
-
- let conn = quire.db().lock();
- let now_ms = jiff::Timestamp::now().as_millisecond();
-
- for push_ref in event.updated_refs() {
- let id = uuid::Uuid::now_v7().to_string();
- conn.execute(
- r#"INSERT INTO runs (id, repo, "ref", sha, created_at, traceparent)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
- rusqlite::params![
- id,
- event.repo,
- push_ref.ref_name,
- push_ref.new_sha,
- now_ms,
- traceparent,
- ],
- )?;
- }
-
- Ok(StatusCode::NO_CONTENT)
-}
-
-#[derive(Debug, thiserror::Error, miette::Diagnostic)]
-pub enum Error {
- #[error("io error: {0}")]
- Io(#[from] std::io::Error),
-
- #[error(transparent)]
- Secret(#[from] quire_core::secret::Error),
-
- #[error(transparent)]
- Telemetry(#[from] quire_core::telemetry::Error),
-}
-
-pub type Result<T> = std::result::Result<T, Error>;
-
-pub async fn run(quire: QuireCi) -> Result<()> {
- let port = quire.config().port;
-
- let miette_layer = telemetry::MietteLayer::new();
- let _guard = telemetry::init_telemetry(
- miette_layer,
- FmtMode::AutoJson,
- quire.config().sentry.as_ref(),
- VERSION,
- )?;
-
- let app = Router::new()
- .route("/health", get(health))
- .route("/", get(index))
- .route("/webhook", post(webhook))
- .layer(
- TraceLayer::new_for_http()
- .make_span_with(|request: &Request<_>| {
- let matched_path = request
- .extensions()
- .get::<MatchedPath>()
- .map(MatchedPath::as_str);
- info_span!("http_request", method = ?request.method(), matched_path)
- })
- .on_response(|response: &Response, _: Duration, _: &tracing::Span| {
- if let Some(RequestError(error)) = response.extensions().get::<RequestError>() {
- if response.status().is_server_error() {
- tracing::error!(%error);
- } else {
- tracing::warn!(%error);
- }
- }
- }),
- )
- .with_state(quire);
-
- let addr = SocketAddr::from(([0, 0, 0, 0], port));
- tracing::info!(%addr, "starting HTTP server");
-
- let listener = tokio::net::TcpListener::bind(addr).await?;
-
- axum::serve(listener, app).await?;
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use axum::body::Body;
- use axum::http::{Request, StatusCode};
- use hmac::{Hmac, KeyInit, Mac};
- use sha2::Sha256;
- use tower::ServiceExt;
-
- use crate::quire::QuireCi;
-
- fn make_app(quire: QuireCi) -> axum::Router {
- axum::Router::new()
- .route("/webhook", axum::routing::post(super::webhook))
- .with_state(quire)
- }
-
- fn quire_with_secret(secret: &str) -> (tempfile::TempDir, QuireCi) {
- let dir = tempfile::tempdir().expect("tempdir");
- let config_path = dir.path().join("config.fnl");
- fs_err::write(&config_path, format!(r#"{{:webhook-secret "{secret}"}}"#))
- .expect("write config");
- let quire = QuireCi::new(dir.path().to_path_buf()).expect("QuireCi::new");
- (dir, quire)
- }
-
- fn push_event_body() -> Vec<u8> {
- serde_json::to_vec(&serde_json::json!({
- "type": "push",
- "repo": "test/repo.git",
- "pushed_at": "2026-05-01T00:00:00Z",
- "refs": [
- {
- "ref": "refs/heads/main",
- "old_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "new_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
- }
- ]
- }))
- .expect("serialize")
- }
-
- fn hmac_header(secret: &str, body: &[u8]) -> String {
- let mut mac =
- Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
- mac.update(body);
- let result = mac.finalize();
- format!("HMAC-SHA256 {}", hex::encode(result.into_bytes()))
- }
-
- #[tokio::test]
- async fn valid_hmac_creates_run_row() {
- let secret = "test-secret-key";
- let (_dir, quire) = quire_with_secret(secret);
- let db = quire.db().clone();
- let app = make_app(quire);
-
- let body = push_event_body();
- let auth = hmac_header(secret, &body);
-
- let req = Request::builder()
- .method("POST")
- .uri("/webhook")
- .header("Authorization", auth)
- .header("content-type", "application/json")
- .body(Body::from(body))
- .unwrap();
-
- let resp = app.oneshot(req).await.unwrap();
- assert_eq!(resp.status(), StatusCode::NO_CONTENT);
-
- let count: i64 = db
- .lock()
- .query_row("SELECT COUNT(*) FROM runs", [], |row| row.get(0))
- .expect("count");
- assert_eq!(count, 1);
- }
-
- #[tokio::test]
- async fn wrong_hmac_returns_401() {
- let (_dir, quire) = quire_with_secret("correct-secret");
- let app = make_app(quire);
-
- let body = push_event_body();
-
- let req = Request::builder()
- .method("POST")
- .uri("/webhook")
- .header("Authorization", "HMAC-SHA256 deadbeefdeadbeef")
- .header("content-type", "application/json")
- .body(Body::from(body))
- .unwrap();
-
- let resp = app.oneshot(req).await.unwrap();
- assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
- }
-}