#11 m5/docker
Patch
.dockerignore+11 −0--- /dev/null+++ b/.dockerignore@@ -0,0 +1,11 @@1+# Keep the docker build context small: sources only.2+# (The Dockerfile copies Cargo.toml, Cargo.lock, src/, static/ explicitly.)3+target/4+dist/5+6+# Runtime / local data: never part of the image context.7+/data/8+*.tmp9+10+# VCS metadata: not needed to build.11+/.git/
Dockerfile+35 −0--- /dev/null+++ b/Dockerfile@@ -0,0 +1,35 @@1+# hu13 — multi-stage build (f0rge issue #11).2+#3+# docker build -t hu13 .4+# docker run --rm -p 39313:39313 \5+# -v "$PWD:/data/repo:ro" -v hu13-inbox:/data/inbox \6+# [-e HU13_BRANCH=main] [-e HU13_CLONE_URL=...] \7+# hu138+#9+# Stage 1: pinned Rust builder. Only the files cargo needs are copied so10+# the layer cache survives unrelated edits. `--locked` keeps Cargo.lock11+# authoritative.12+13+FROM rust:1.98-bookworm AS builder14+WORKDIR /src15+COPY Cargo.toml Cargo.lock ./16+COPY src/ ./src/17+COPY static/ ./static/18+RUN cargo build --locked --release19+20+# Stage 2: minimal runtime. ca-certificates for https clone URLs, git21+# (hu13 shells out to `git -C` for every read — no git, no build),22+# nonroot `hu13` (uid 10000), the static binary + entrypoint only.23+FROM debian:bookworm-slim24+RUN apt-get update \25+ && apt-get install -y --no-install-recommends ca-certificates git \26+ && rm -rf /var/lib/apt/lists \27+ && useradd --uid 10000 --create-home --shell /usr/sbin/nologin hu13 \28+ && mkdir -p /srv/www /data/inbox \29+ && chown -R hu13:hu13 /srv/www /data/inbox30+COPY --from=builder /src/target/release/hu13 /usr/local/bin/hu1331+COPY entrypoint.sh /usr/local/bin/entrypoint.sh32+RUN chmod +x /usr/local/bin/entrypoint.sh33+USER hu1334+EXPOSE 3931335+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
deploy.DOC.md+80 −0--- /dev/null+++ b/deploy.DOC.md@@ -0,0 +1,80 @@1+# Deploy hu13 with Docker2+3+> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the4+> branch stays free of `.forge/` writes. Supervisor: move it to the wiki5+> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md`6+> via the CLI) and delete this file.7+8+## Build the image9+10+From the repo root (build context is sources only — see `.dockerignore`):11+12+```sh13+docker build -t hu13 .14+```15+16+Multi-stage: `rust:1.98-bookworm` compiles with17+`cargo build --locked --release` (only `Cargo.toml`, `Cargo.lock`, `src/`,18+`static/` are copied, so edits elsewhere don't bust the cache);19+`debian:bookworm-slim` runtime adds `ca-certificates`, a nonroot user20+`hu13` (uid 10000), the binary, and `entrypoint.sh`. No toolchain, no21+sources, no `.git` in the final image.22+23+## Run it24+25+```sh26+docker run --rm -p 39313:39313 \27+ -v "$PWD:/data/repo:ro" \28+ -v hu13-inbox:/data/inbox \29+ -e HU13_BRANCH=main \30+ -e HU13_CLONE_URL=https://example.com/mirror.git \31+ hu1332+```33+34+Then open `http://localhost:39313/`.35+36+What happens inside (`entrypoint.sh`):37+38+1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url`39+ only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure —40+ bad branch, unreadable repo — aborts LOUD before serving starts41+ (`set -eu`; serve never runs on a stale/empty site).42+2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox43+ --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`,44+ `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching45+ `HU13_*` env var is set; otherwise the serve defaults apply).46+ `exec` keeps `serve` on PID 1.47+48+## Volumes / env reference49+50+| Mount / var | Default | Notes |51+| ---------------------- | ------------------ | -------------------------------------------------- |52+| `/data/repo` (ro) | — | repo checkout to render (required) |53+| `/data/inbox` | — | inbox storage; MUST sit outside `--www` (serve refuses otherwise) |54+| `HU13_BRANCH` | `main`, else `master` | branch to build; wrong name fails the container |55+| `HU13_CLONE_URL` | git origin remote | clone URL advertised on the index |56+| `HU13_LISTEN` | `0.0.0.0:39313` | serve address |57+| `HU13_MAX_TITLE` | `300` | inbox title cap (chars) |58+| `HU13_MAX_BODY` | `20000` | inbox body/prompt cap (chars) |59+| `HU13_MAX_BYTES` | `32768` | whole-POST cap (bytes) |60+| `HU13_RATE_PER_HOUR` | `2` | per-IP POSTs/hour |61+| `HU13_QUOTA` | `500` | global inbox file quota |62+63+## Notes64+65+- Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.66+ If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`67+ (libyaml) with the hand-rolled frontmatter split already used for68+ parsing shapes, hand-roll the fixed-order `api/*.json` emitter instead69+ of `serde_json`, enable `strip = true` + `opt-level = "z"` + LTO in the70+ release profile. Do not gold-plate before then.71+- `HU13_WWW` / `HU13_INBOX_DIR` also work for bare-metal72+ `hu13 serve` (flags win over env); the container always passes73+ explicit `--www` / `--inbox-dir`.74+- Inbox files are read locally only: `hu13 inbox list|show --inbox-dir75+ /data/inbox` (or `docker exec` into the volume). Never served over HTTP.76+- Mount a plain checkout, not a git worktree: a worktree's `.git` is a77+ pointer file (`gitdir: ...`) whose target lives outside the mount, so78+ git inside the container cannot resolve anything. `git clone` the79+ branch (or copy the repo) and mount that. (Host/container uid mismatch80+ on the mount is already handled via `safe.directory` in entrypoint.sh.)
entrypoint.sh+56 −0--- /dev/null+++ b/entrypoint.sh@@ -0,0 +1,56 @@1+#!/bin/sh2+# hu13 container entrypoint (f0rge issue #11): build once, then serve.3+#4+# Layout: the repo is mounted read-only at /data/repo, the inbox lives at5+# /data/inbox (OUTSIDE the served tree — serve refuses to start otherwise),6+# the built site goes to /srv/www.7+#8+# Env:9+# HU13_BRANCH branch to build (default: main/master auto-detect)10+# HU13_CLONE_URL clone URL to advertise (default: repo origin remote)11+# HU13_LISTEN serve address (default: 0.0.0.0:39313)12+# HU13_MAX_TITLE / HU13_MAX_BODY / HU13_MAX_BYTES /13+# HU13_RATE_PER_HOUR / HU13_QUOTA14+# inbox caps (serve defaults when unset; passed through15+# as flags only when set, env is the fallback otherwise)16+#17+# Fail loud: any build failure (bad branch, bad repo, missing dir)18+# aborts before serve starts. `serve` is exec'd so it keeps PID 1.19+set -eu20+21+# Mounted checkouts belong to the host uid, but we run as uid 10000:22+# without this git refuses every read ("dubious ownership") and the23+# build below would misreport "branch not found".24+git config --global --add safe.directory /data/repo25+26+BUILD_ARGS=""27+if [ -n "${HU13_BRANCH:-}" ]; then28+ BUILD_ARGS="$BUILD_ARGS --branch $HU13_BRANCH"29+fi30+if [ -n "${HU13_CLONE_URL:-}" ]; then31+ BUILD_ARGS="$BUILD_ARGS --clone-url $HU13_CLONE_URL"32+fi33+34+# shellcheck disable=SC208635+hu13 build /data/repo -o /srv/www $BUILD_ARGS36+37+SERVE_ARGS=""38+if [ -n "${HU13_MAX_TITLE:-}" ]; then39+ SERVE_ARGS="$SERVE_ARGS --max-title $HU13_MAX_TITLE"40+fi41+if [ -n "${HU13_MAX_BODY:-}" ]; then42+ SERVE_ARGS="$SERVE_ARGS --max-body $HU13_MAX_BODY"43+fi44+if [ -n "${HU13_MAX_BYTES:-}" ]; then45+ SERVE_ARGS="$SERVE_ARGS --max-bytes $HU13_MAX_BYTES"46+fi47+if [ -n "${HU13_RATE_PER_HOUR:-}" ]; then48+ SERVE_ARGS="$SERVE_ARGS --rate-per-hour $HU13_RATE_PER_HOUR"49+fi50+if [ -n "${HU13_QUOTA:-}" ]; then51+ SERVE_ARGS="$SERVE_ARGS --quota $HU13_QUOTA"52+fi53+54+# shellcheck disable=SC208655+exec hu13 serve --www /srv/www --inbox-dir /data/inbox \56+ --listen "${HU13_LISTEN:-0.0.0.0:39313}" $SERVE_ARGS
src/serve.rs+17 −6--- a/src/serve.rs+++ b/src/serve.rs@@ -309,7 +309,16 @@ fn static_error(stream: &mut TcpStream, code: u16) {309309 405 => "method not allowed",310310 _ => "error",311311 };312- let body = crate::layout::page(msg, "", &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));312+ // Error pages keep the top-level nav so a lost visitor can click out.313+ let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \314+ <a href=\"/issues/index.html\">issues</a> · \315+ <a href=\"/pulls/index.html\">pulls</a> · \316+ <a href=\"/tasks/index.html\">tasks</a> · \317+ <a href=\"/ci/index.html\">ci</a> · \318+ <a href=\"/releases/index.html\">releases</a> · \319+ <a href=\"/wiki/index.html\">wiki</a> · \320+ <a href=\"/agents.html\">agents</a>";321+ let body = crate::layout::page(msg, nav, &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));313322 let extra = if code == 405 {314323 &[("Allow", "GET, HEAD")][..]315324 } else {@@ -460,11 +469,9 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {460469 handle_inbox(&mut stream, &head, rest, kind, shared);461470 return;462471 }463- // Static: GET/HEAD only; never a listing (dirs → 404, files only).464- if head.method != "GET" && head.method != "HEAD" {465- static_error(&mut stream, 405);466- return;467- }472+ // Static: files only, never a listing (dirs → 404). Unknown paths473+ // are 404 for ANY method (including POST); a known file with the474+ // wrong method is 405. Fail closed, echo nothing.468475 let rel = path.strip_prefix('/').unwrap_or(&path);469476 if rel.contains('\0')470477 || rel.split('/').any(|s| s == "..")@@ -490,6 +497,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {490497 return;491498 }492499 };500+ if head.method != "GET" && head.method != "HEAD" {501+ static_error(&mut stream, 405);502+ return;503+ }493504 let ctype = content_type_for(&file_rel);494505 if head.method == "HEAD" {495506 let out = format!(
tests/polish.rs+279 −1--- /dev/null+++ b/tests/polish.rs@@ -0,0 +1,279 @@1+//! Polish suite (m5/docker, f0rge issue #11):2+//! CSS budget, serve 404s, empty-forge build, entrypoint fail-loudness.3+//!4+//! No docker daemon needed: the entrypoint is tested through flag parsing5+//! (`hu13 build --branch <bad>` exits non-zero, naming the branch) plus a6+//! `bash -n` syntax check of `entrypoint.sh` itself.7+8+use std::io::{BufRead, BufReader, Read, Write};9+use std::net::TcpStream;10+use std::path::{Path, PathBuf};11+use std::process::{Child, Command, Stdio};12+use std::time::Duration;13+14+fn bin() -> &'static str {15+ env!("CARGO_BIN_EXE_hu13")16+}17+18+fn manifest() -> PathBuf {19+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))20+}21+22+fn fresh(prefix: &str) -> PathBuf {23+ let dir = std::env::temp_dir().join(format!(24+ "hu13-polish-{prefix}-{}-{}",25+ std::process::id(),26+ std::time::SystemTime::now()27+ .duration_since(std::time::UNIX_EPOCH)28+ .map(|d| d.as_nanos())29+ .unwrap_or(0)30+ ));31+ std::fs::create_dir_all(&dir).unwrap();32+ dir33+}34+35+fn git(repo: &Path, args: &[&str]) {36+ let st = Command::new("git")37+ .arg("-C")38+ .arg(repo)39+ .args(args)40+ .status()41+ .expect("spawn git");42+ assert!(st.success(), "git {args:?} failed");43+}44+45+/// Minimal repo: 1 commit, NO `.forge/` at all.46+fn bare_repo() -> PathBuf {47+ let dir = fresh("repo");48+ git(&dir, &["init", "-b", "main"]);49+ git(&dir, &["config", "user.name", "hu13-test"]);50+ git(&dir, &["config", "user.email", "[email protected]"]);51+ git(&dir, &["config", "commit.gpgsign", "false"]);52+ std::fs::write(dir.join("README.md"), "# polish fixture\n").unwrap();53+ std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();54+ git(&dir, &["add", "-A"]);55+ git(&dir, &["commit", "-m", "init"]);56+ assert!(!dir.join(".forge").exists());57+ dir58+}59+60+struct Server {61+ child: Child,62+ addr: String,63+}64+65+impl Drop for Server {66+ fn drop(&mut self) {67+ self.child.kill().ok();68+ self.child.wait().ok();69+ }70+}71+72+fn start_server(www: &Path, inbox: &Path) -> Server {73+ let mut child = Command::new(bin())74+ .arg("serve")75+ .arg("--www")76+ .arg(www)77+ .arg("--inbox-dir")78+ .arg(inbox)79+ .arg("--listen")80+ .arg("127.0.0.1:0")81+ .stdout(Stdio::piped())82+ .stderr(Stdio::piped())83+ .spawn()84+ .expect("spawn hu13 serve");85+ let stdout = child.stdout.take().expect("piped stdout");86+ let mut lines = BufReader::new(stdout).lines();87+ let mut addr = String::new();88+ for _ in 0..50 {89+ match lines.next() {90+ Some(Ok(line)) if line.contains("listening on ") => {91+ addr = line92+ .rsplit("listening on ")93+ .next()94+ .unwrap_or("")95+ .trim()96+ .to_string();97+ break;98+ }99+ Some(Ok(_)) => continue,100+ _ => break,101+ }102+ }103+ assert!(!addr.is_empty(), "serve printed no `listening on` line");104+ std::thread::sleep(Duration::from_millis(100));105+ Server { child, addr }106+}107+108+fn transact(addr: &str, req: &[u8]) -> String {109+ let mut s = TcpStream::connect(addr).expect("connect");110+ s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();111+ s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();112+ s.write_all(req).expect("write");113+ let mut out = Vec::new();114+ s.read_to_end(&mut out).expect("read");115+ String::from_utf8_lossy(&out).into_owned()116+}117+118+fn status(resp: &str) -> &str {119+ resp.split("\r\n").next().unwrap_or("")120+}121+122+#[test]123+fn css_budget_under_15k() {124+ let css = manifest().join("static").join("style.css");125+ let len = std::fs::metadata(&css).expect("style.css exists").len();126+ assert!(127+ len < 15360,128+ "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"129+ );130+}131+132+#[test]133+fn unknown_path_is_404_html_for_get_and_post() {134+ let www = fresh("www");135+ std::fs::write(www.join("index.html"), "<!DOCTYPE html><p>hi</p>").unwrap();136+ let inbox = fresh("inbox");137+ let srv = start_server(&www, &inbox);138+139+ let get = transact(140+ &srv.addr,141+ b"GET /no-such-page.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",142+ );143+ assert!(144+ status(&get).contains("404"),145+ "GET unknown -> 404, got: {get}"146+ );147+ assert!(get.contains("text/html"), "404 body is html, got: {get}");148+ assert!(149+ get.contains("X-Content-Type-Options: nosniff"),150+ "404 carries nosniff"151+ );152+153+ // POST to an unknown (non-inbox) path is 404, not 405 and not 200.154+ let body = "a=b";155+ let post = transact(156+ &srv.addr,157+ format!(158+ "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}",159+ body.len()160+ )161+ .as_bytes(),162+ );163+ assert!(164+ status(&post).contains("404"),165+ "POST unknown -> 404, got: {post}"166+ );167+ assert!(post.contains("text/html"), "404 body is html");168+169+ // A KNOWN file with the wrong method is still 405 (no regression).170+ let put = transact(171+ &srv.addr,172+ b"PUT /index.html HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",173+ );174+ assert!(175+ status(&put).contains("405"),176+ "wrong method on real file -> 405, got: {put}"177+ );178+}179+180+#[test]181+fn empty_forge_build_all_tabs_none_yet() {182+ let repo = bare_repo();183+ let out = fresh("out");184+ let build = Command::new(bin())185+ .arg("build")186+ .arg(&repo)187+ .arg("-o")188+ .arg(&out)189+ .output()190+ .expect("spawn hu13 build");191+ assert!(192+ build.status.success(),193+ "no-forge build exits 0: {}",194+ String::from_utf8_lossy(&build.stderr)195+ );196+ for (page, marker) in [197+ ("issues/index.html", "No issues yet"),198+ ("pulls/index.html", "No pull requests yet"),199+ ("tasks/index.html", "No tasks yet"),200+ ("ci/index.html", "No CI runs yet"),201+ ("releases/index.html", "No releases yet"),202+ ("wiki/index.html", "No wiki pages yet"),203+ ] {204+ let html = std::fs::read_to_string(out.join(page)).expect(page);205+ assert!(html.contains(marker), "{page} says {marker:?}");206+ }207+}208+209+#[test]210+fn bad_branch_fails_loud_naming_branch() {211+ let repo = bare_repo();212+ let out = fresh("out-bad");213+ // Flag path (what entrypoint.sh passes from $HU13_BRANCH).214+ let build = Command::new(bin())215+ .arg("build")216+ .arg(&repo)217+ .arg("-o")218+ .arg(&out)219+ .arg("--branch")220+ .arg("does-not-exist")221+ .output()222+ .expect("spawn hu13 build");223+ assert!(!build.status.success(), "bad branch exits non-zero");224+ let err = String::from_utf8_lossy(&build.stderr).into_owned();225+ assert!(226+ err.contains("does-not-exist"),227+ "stderr names the bad branch, got: {err}"228+ );229+ // Env path (HU13_BRANCH fallback inside gitx::resolve_branch).230+ let out2 = fresh("out-bad-env");231+ let build2 = Command::new(bin())232+ .arg("build")233+ .arg(&repo)234+ .arg("-o")235+ .arg(&out2)236+ .env("HU13_BRANCH", "also-missing")237+ .output()238+ .expect("spawn hu13 build");239+ assert!(!build2.status.success(), "bad HU13_BRANCH exits non-zero");240+ let err2 = String::from_utf8_lossy(&build2.stderr).into_owned();241+ assert!(242+ err2.contains("also-missing"),243+ "stderr names the bad env branch, got: {err2}"244+ );245+ // Missing repo dir is loud too.246+ let out3 = fresh("out-bad-repo");247+ let build3 = Command::new(bin())248+ .arg("build")249+ .arg("/does/not/exist-at-all")250+ .arg("-o")251+ .arg(&out3)252+ .output()253+ .expect("spawn hu13 build");254+ assert!(!build3.status.success(), "missing repo exits non-zero");255+}256+257+#[test]258+fn entrypoint_syntax_checks() {259+ let ep = manifest().join("entrypoint.sh");260+ assert!(ep.exists(), "entrypoint.sh exists");261+ let st = Command::new("bash")262+ .arg("-n")263+ .arg(&ep)264+ .status()265+ .expect("spawn bash -n");266+ assert!(st.success(), "entrypoint.sh parses");267+ let text = std::fs::read_to_string(&ep).expect("read entrypoint");268+ for needle in [269+ "set -eu",270+ "hu13 build /data/repo -o /srv/www",271+ "HU13_BRANCH",272+ "HU13_CLONE_URL",273+ "exec hu13 serve --www /srv/www --inbox-dir /data/inbox",274+ "0.0.0.0:39313",275+ "safe.directory",276+ ] {277+ assert!(text.contains(needle), "entrypoint mentions {needle}");278+ }279+}0--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.