From ee8c2ea4ec64ec0625a2de400c10f4c1e897893b Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 06:09:02 +0100 Subject: [PATCH] m5/docker: Dockerfile, entrypoint, 404, empty states, polish (106 tests) --- .dockerignore | 11 ++ Dockerfile | 35 ++++++ deploy.DOC.md | 80 ++++++++++++++ entrypoint.sh | 56 ++++++++++ src/serve.rs | 23 ++-- tests/polish.rs | 279 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 478 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 deploy.DOC.md create mode 100755 entrypoint.sh create mode 100644 tests/polish.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e005087 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Keep the docker build context small: sources only. +# (The Dockerfile copies Cargo.toml, Cargo.lock, src/, static/ explicitly.) +target/ +dist/ + +# Runtime / local data: never part of the image context. +/data/ +*.tmp + +# VCS metadata: not needed to build. +/.git/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..31b427c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# hu13 — multi-stage build (f0rge issue #11). +# +# docker build -t hu13 . +# docker run --rm -p 39313:39313 \ +# -v "$PWD:/data/repo:ro" -v hu13-inbox:/data/inbox \ +# [-e HU13_BRANCH=main] [-e HU13_CLONE_URL=...] \ +# hu13 +# +# Stage 1: pinned Rust builder. Only the files cargo needs are copied so +# the layer cache survives unrelated edits. `--locked` keeps Cargo.lock +# authoritative. + +FROM rust:1.98-bookworm AS builder +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY src/ ./src/ +COPY static/ ./static/ +RUN cargo build --locked --release + +# Stage 2: minimal runtime. ca-certificates for https clone URLs, git +# (hu13 shells out to `git -C` for every read — no git, no build), +# nonroot `hu13` (uid 10000), the static binary + entrypoint only. +FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists \ + && useradd --uid 10000 --create-home --shell /usr/sbin/nologin hu13 \ + && mkdir -p /srv/www /data/inbox \ + && chown -R hu13:hu13 /srv/www /data/inbox +COPY --from=builder /src/target/release/hu13 /usr/local/bin/hu13 +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +USER hu13 +EXPOSE 39313 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/deploy.DOC.md b/deploy.DOC.md new file mode 100644 index 0000000..15b953e --- /dev/null +++ b/deploy.DOC.md @@ -0,0 +1,80 @@ +# Deploy hu13 with Docker + +> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the +> branch stays free of `.forge/` writes. Supervisor: move it to the wiki +> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md` +> via the CLI) and delete this file. + +## Build the image + +From the repo root (build context is sources only — see `.dockerignore`): + +```sh +docker build -t hu13 . +``` + +Multi-stage: `rust:1.98-bookworm` compiles with +`cargo build --locked --release` (only `Cargo.toml`, `Cargo.lock`, `src/`, +`static/` are copied, so edits elsewhere don't bust the cache); +`debian:bookworm-slim` runtime adds `ca-certificates`, a nonroot user +`hu13` (uid 10000), the binary, and `entrypoint.sh`. No toolchain, no +sources, no `.git` in the final image. + +## Run it + +```sh +docker run --rm -p 39313:39313 \ + -v "$PWD:/data/repo:ro" \ + -v hu13-inbox:/data/inbox \ + -e HU13_BRANCH=main \ + -e HU13_CLONE_URL=https://example.com/mirror.git \ + hu13 +``` + +Then open `http://localhost:39313/`. + +What happens inside (`entrypoint.sh`): + +1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url` + only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure — + bad branch, unreadable repo — aborts LOUD before serving starts + (`set -eu`; serve never runs on a stale/empty site). +2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox + --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`, + `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching + `HU13_*` env var is set; otherwise the serve defaults apply). + `exec` keeps `serve` on PID 1. + +## Volumes / env reference + +| Mount / var | Default | Notes | +| ---------------------- | ------------------ | -------------------------------------------------- | +| `/data/repo` (ro) | — | repo checkout to render (required) | +| `/data/inbox` | — | inbox storage; MUST sit outside `--www` (serve refuses otherwise) | +| `HU13_BRANCH` | `main`, else `master` | branch to build; wrong name fails the container | +| `HU13_CLONE_URL` | git origin remote | clone URL advertised on the index | +| `HU13_LISTEN` | `0.0.0.0:39313` | serve address | +| `HU13_MAX_TITLE` | `300` | inbox title cap (chars) | +| `HU13_MAX_BODY` | `20000` | inbox body/prompt cap (chars) | +| `HU13_MAX_BYTES` | `32768` | whole-POST cap (bytes) | +| `HU13_RATE_PER_HOUR` | `2` | per-IP POSTs/hour | +| `HU13_QUOTA` | `500` | global inbox file quota | + +## Notes + +- Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped. + If it ever tops 2MB stripped, the cut list is: replace `yaml_serde` + (libyaml) with the hand-rolled frontmatter split already used for + parsing shapes, hand-roll the fixed-order `api/*.json` emitter instead + of `serde_json`, enable `strip = true` + `opt-level = "z"` + LTO in the + release profile. Do not gold-plate before then. +- `HU13_WWW` / `HU13_INBOX_DIR` also work for bare-metal + `hu13 serve` (flags win over env); the container always passes + explicit `--www` / `--inbox-dir`. +- Inbox files are read locally only: `hu13 inbox list|show --inbox-dir + /data/inbox` (or `docker exec` into the volume). Never served over HTTP. +- Mount a plain checkout, not a git worktree: a worktree's `.git` is a + pointer file (`gitdir: ...`) whose target lives outside the mount, so + git inside the container cannot resolve anything. `git clone` the + branch (or copy the repo) and mount that. (Host/container uid mismatch + on the mount is already handled via `safe.directory` in entrypoint.sh.) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..3cc51e5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,56 @@ +#!/bin/sh +# hu13 container entrypoint (f0rge issue #11): build once, then serve. +# +# Layout: the repo is mounted read-only at /data/repo, the inbox lives at +# /data/inbox (OUTSIDE the served tree — serve refuses to start otherwise), +# the built site goes to /srv/www. +# +# Env: +# HU13_BRANCH branch to build (default: main/master auto-detect) +# HU13_CLONE_URL clone URL to advertise (default: repo origin remote) +# HU13_LISTEN serve address (default: 0.0.0.0:39313) +# HU13_MAX_TITLE / HU13_MAX_BODY / HU13_MAX_BYTES / +# HU13_RATE_PER_HOUR / HU13_QUOTA +# inbox caps (serve defaults when unset; passed through +# as flags only when set, env is the fallback otherwise) +# +# Fail loud: any build failure (bad branch, bad repo, missing dir) +# aborts before serve starts. `serve` is exec'd so it keeps PID 1. +set -eu + +# Mounted checkouts belong to the host uid, but we run as uid 10000: +# without this git refuses every read ("dubious ownership") and the +# build below would misreport "branch not found". +git config --global --add safe.directory /data/repo + +BUILD_ARGS="" +if [ -n "${HU13_BRANCH:-}" ]; then + BUILD_ARGS="$BUILD_ARGS --branch $HU13_BRANCH" +fi +if [ -n "${HU13_CLONE_URL:-}" ]; then + BUILD_ARGS="$BUILD_ARGS --clone-url $HU13_CLONE_URL" +fi + +# shellcheck disable=SC2086 +hu13 build /data/repo -o /srv/www $BUILD_ARGS + +SERVE_ARGS="" +if [ -n "${HU13_MAX_TITLE:-}" ]; then + SERVE_ARGS="$SERVE_ARGS --max-title $HU13_MAX_TITLE" +fi +if [ -n "${HU13_MAX_BODY:-}" ]; then + SERVE_ARGS="$SERVE_ARGS --max-body $HU13_MAX_BODY" +fi +if [ -n "${HU13_MAX_BYTES:-}" ]; then + SERVE_ARGS="$SERVE_ARGS --max-bytes $HU13_MAX_BYTES" +fi +if [ -n "${HU13_RATE_PER_HOUR:-}" ]; then + SERVE_ARGS="$SERVE_ARGS --rate-per-hour $HU13_RATE_PER_HOUR" +fi +if [ -n "${HU13_QUOTA:-}" ]; then + SERVE_ARGS="$SERVE_ARGS --quota $HU13_QUOTA" +fi + +# shellcheck disable=SC2086 +exec hu13 serve --www /srv/www --inbox-dir /data/inbox \ + --listen "${HU13_LISTEN:-0.0.0.0:39313}" $SERVE_ARGS diff --git a/src/serve.rs b/src/serve.rs index bd878f0..ed60894 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -309,7 +309,16 @@ fn static_error(stream: &mut TcpStream, code: u16) { 405 => "method not allowed", _ => "error", }; - let body = crate::layout::page(msg, "", &format!("

{msg}

\n

Nothing here.

\n")); + // Error pages keep the top-level nav so a lost visitor can click out. + let nav = "code · commits · \ + issues · \ + pulls · \ + tasks · \ + ci · \ + releases · \ + wiki · \ + agents"; + let body = crate::layout::page(msg, nav, &format!("

{msg}

\n

Nothing here.

\n")); let extra = if code == 405 { &[("Allow", "GET, HEAD")][..] } else { @@ -460,11 +469,9 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) { handle_inbox(&mut stream, &head, rest, kind, shared); return; } - // Static: GET/HEAD only; never a listing (dirs → 404, files only). - if head.method != "GET" && head.method != "HEAD" { - static_error(&mut stream, 405); - return; - } + // Static: files only, never a listing (dirs → 404). Unknown paths + // are 404 for ANY method (including POST); a known file with the + // wrong method is 405. Fail closed, echo nothing. let rel = path.strip_prefix('/').unwrap_or(&path); if rel.contains('\0') || rel.split('/').any(|s| s == "..") @@ -490,6 +497,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) { return; } }; + if head.method != "GET" && head.method != "HEAD" { + static_error(&mut stream, 405); + return; + } let ctype = content_type_for(&file_rel); if head.method == "HEAD" { let out = format!( diff --git a/tests/polish.rs b/tests/polish.rs new file mode 100644 index 0000000..e71a09f --- /dev/null +++ b/tests/polish.rs @@ -0,0 +1,279 @@ +//! Polish suite (m5/docker, f0rge issue #11): +//! CSS budget, serve 404s, empty-forge build, entrypoint fail-loudness. +//! +//! No docker daemon needed: the entrypoint is tested through flag parsing +//! (`hu13 build --branch ` exits non-zero, naming the branch) plus a +//! `bash -n` syntax check of `entrypoint.sh` itself. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +fn bin() -> &'static str { + env!("CARGO_BIN_EXE_hu13") +} + +fn manifest() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-polish-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) { + let st = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .status() + .expect("spawn git"); + assert!(st.success(), "git {args:?} failed"); +} + +/// Minimal repo: 1 commit, NO `.forge/` at all. +fn bare_repo() -> PathBuf { + let dir = fresh("repo"); + git(&dir, &["init", "-b", "main"]); + git(&dir, &["config", "user.name", "hu13-test"]); + git(&dir, &["config", "user.email", "hu13@test.local"]); + git(&dir, &["config", "commit.gpgsign", "false"]); + std::fs::write(dir.join("README.md"), "# polish fixture\n").unwrap(); + std::fs::write(dir.join("hello.txt"), "hello\n").unwrap(); + git(&dir, &["add", "-A"]); + git(&dir, &["commit", "-m", "init"]); + assert!(!dir.join(".forge").exists()); + dir +} + +struct Server { + child: Child, + addr: String, +} + +impl Drop for Server { + fn drop(&mut self) { + self.child.kill().ok(); + self.child.wait().ok(); + } +} + +fn start_server(www: &Path, inbox: &Path) -> Server { + let mut child = Command::new(bin()) + .arg("serve") + .arg("--www") + .arg(www) + .arg("--inbox-dir") + .arg(inbox) + .arg("--listen") + .arg("127.0.0.1:0") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn hu13 serve"); + let stdout = child.stdout.take().expect("piped stdout"); + let mut lines = BufReader::new(stdout).lines(); + let mut addr = String::new(); + for _ in 0..50 { + match lines.next() { + Some(Ok(line)) if line.contains("listening on ") => { + addr = line + .rsplit("listening on ") + .next() + .unwrap_or("") + .trim() + .to_string(); + break; + } + Some(Ok(_)) => continue, + _ => break, + } + } + assert!(!addr.is_empty(), "serve printed no `listening on` line"); + std::thread::sleep(Duration::from_millis(100)); + Server { child, addr } +} + +fn transact(addr: &str, req: &[u8]) -> String { + let mut s = TcpStream::connect(addr).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + s.set_write_timeout(Some(Duration::from_secs(10))).unwrap(); + s.write_all(req).expect("write"); + let mut out = Vec::new(); + s.read_to_end(&mut out).expect("read"); + String::from_utf8_lossy(&out).into_owned() +} + +fn status(resp: &str) -> &str { + resp.split("\r\n").next().unwrap_or("") +} + +#[test] +fn css_budget_under_15k() { + let css = manifest().join("static").join("style.css"); + let len = std::fs::metadata(&css).expect("style.css exists").len(); + assert!( + len < 15360, + "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)" + ); +} + +#[test] +fn unknown_path_is_404_html_for_get_and_post() { + let www = fresh("www"); + std::fs::write(www.join("index.html"), "

hi

").unwrap(); + let inbox = fresh("inbox"); + let srv = start_server(&www, &inbox); + + let get = transact( + &srv.addr, + b"GET /no-such-page.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + ); + assert!( + status(&get).contains("404"), + "GET unknown -> 404, got: {get}" + ); + assert!(get.contains("text/html"), "404 body is html, got: {get}"); + assert!( + get.contains("X-Content-Type-Options: nosniff"), + "404 carries nosniff" + ); + + // POST to an unknown (non-inbox) path is 404, not 405 and not 200. + let body = "a=b"; + let post = transact( + &srv.addr, + format!( + "POST /no-such-page.html HTTP/1.1\r\nHost: x\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ); + assert!( + status(&post).contains("404"), + "POST unknown -> 404, got: {post}" + ); + assert!(post.contains("text/html"), "404 body is html"); + + // A KNOWN file with the wrong method is still 405 (no regression). + let put = transact( + &srv.addr, + b"PUT /index.html HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + assert!( + status(&put).contains("405"), + "wrong method on real file -> 405, got: {put}" + ); +} + +#[test] +fn empty_forge_build_all_tabs_none_yet() { + let repo = bare_repo(); + let out = fresh("out"); + let build = Command::new(bin()) + .arg("build") + .arg(&repo) + .arg("-o") + .arg(&out) + .output() + .expect("spawn hu13 build"); + assert!( + build.status.success(), + "no-forge build exits 0: {}", + String::from_utf8_lossy(&build.stderr) + ); + for (page, marker) in [ + ("issues/index.html", "No issues yet"), + ("pulls/index.html", "No pull requests yet"), + ("tasks/index.html", "No tasks yet"), + ("ci/index.html", "No CI runs yet"), + ("releases/index.html", "No releases yet"), + ("wiki/index.html", "No wiki pages yet"), + ] { + let html = std::fs::read_to_string(out.join(page)).expect(page); + assert!(html.contains(marker), "{page} says {marker:?}"); + } +} + +#[test] +fn bad_branch_fails_loud_naming_branch() { + let repo = bare_repo(); + let out = fresh("out-bad"); + // Flag path (what entrypoint.sh passes from $HU13_BRANCH). + let build = Command::new(bin()) + .arg("build") + .arg(&repo) + .arg("-o") + .arg(&out) + .arg("--branch") + .arg("does-not-exist") + .output() + .expect("spawn hu13 build"); + assert!(!build.status.success(), "bad branch exits non-zero"); + let err = String::from_utf8_lossy(&build.stderr).into_owned(); + assert!( + err.contains("does-not-exist"), + "stderr names the bad branch, got: {err}" + ); + // Env path (HU13_BRANCH fallback inside gitx::resolve_branch). + let out2 = fresh("out-bad-env"); + let build2 = Command::new(bin()) + .arg("build") + .arg(&repo) + .arg("-o") + .arg(&out2) + .env("HU13_BRANCH", "also-missing") + .output() + .expect("spawn hu13 build"); + assert!(!build2.status.success(), "bad HU13_BRANCH exits non-zero"); + let err2 = String::from_utf8_lossy(&build2.stderr).into_owned(); + assert!( + err2.contains("also-missing"), + "stderr names the bad env branch, got: {err2}" + ); + // Missing repo dir is loud too. + let out3 = fresh("out-bad-repo"); + let build3 = Command::new(bin()) + .arg("build") + .arg("/does/not/exist-at-all") + .arg("-o") + .arg(&out3) + .output() + .expect("spawn hu13 build"); + assert!(!build3.status.success(), "missing repo exits non-zero"); +} + +#[test] +fn entrypoint_syntax_checks() { + let ep = manifest().join("entrypoint.sh"); + assert!(ep.exists(), "entrypoint.sh exists"); + let st = Command::new("bash") + .arg("-n") + .arg(&ep) + .status() + .expect("spawn bash -n"); + assert!(st.success(), "entrypoint.sh parses"); + let text = std::fs::read_to_string(&ep).expect("read entrypoint"); + for needle in [ + "set -eu", + "hu13 build /data/repo -o /srv/www", + "HU13_BRANCH", + "HU13_CLONE_URL", + "exec hu13 serve --www /srv/www --inbox-dir /data/inbox", + "0.0.0.0:39313", + "safe.directory", + ] { + assert!(text.contains(needle), "entrypoint mentions {needle}"); + } +} -- 2.55.0