1 From ee8c2ea4ec64ec0625a2de400c10f4c1e897893b Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 06:09:02 +01004 Subject: [PATCH] m5/docker: Dockerfile, entrypoint, 404, empty states, polish5 (106 tests)6 7 ---8 .dockerignore | 11 ++9 Dockerfile | 35 ++++++10 deploy.DOC.md | 80 ++++++++++++++11 entrypoint.sh | 56 ++++++++++12 src/serve.rs | 23 ++--13 tests/polish.rs | 279 ++++++++++++++++++++++++++++++++++++++++++++++++14 6 files changed, 478 insertions(+), 6 deletions(-)15 create mode 100644 .dockerignore16 create mode 100644 Dockerfile17 create mode 100644 deploy.DOC.md18 create mode 100755 entrypoint.sh19 create mode 100644 tests/polish.rs20 21 diff --git a/.dockerignore b/.dockerignore22 new file mode 10064423 index 0000000..e00508724 --- /dev/null25 +++ b/.dockerignore26 @@ -0,0 +1,11 @@27 +# Keep the docker build context small: sources only.28 +# (The Dockerfile copies Cargo.toml, Cargo.lock, src/, static/ explicitly.)29 +target/30 +dist/31 +32 +# Runtime / local data: never part of the image context.33 +/data/34 +*.tmp35 +36 +# VCS metadata: not needed to build.37 +/.git/38 diff --git a/Dockerfile b/Dockerfile39 new file mode 10064440 index 0000000..31b427c41 --- /dev/null42 +++ b/Dockerfile43 @@ -0,0 +1,35 @@44 +# hu13 — multi-stage build (f0rge issue #11).45 +#46 +# docker build -t hu13 .47 +# docker run --rm -p 39313:39313 \48 +# -v "$PWD:/data/repo:ro" -v hu13-inbox:/data/inbox \49 +# [-e HU13_BRANCH=main] [-e HU13_CLONE_URL=...] \50 +# hu1351 +#52 +# Stage 1: pinned Rust builder. Only the files cargo needs are copied so53 +# the layer cache survives unrelated edits. `--locked` keeps Cargo.lock54 +# authoritative.55 +56 +FROM rust:1.98-bookworm AS builder57 +WORKDIR /src58 +COPY Cargo.toml Cargo.lock ./59 +COPY src/ ./src/60 +COPY static/ ./static/61 +RUN cargo build --locked --release62 +63 +# Stage 2: minimal runtime. ca-certificates for https clone URLs, git64 +# (hu13 shells out to `git -C` for every read — no git, no build),65 +# nonroot `hu13` (uid 10000), the static binary + entrypoint only.66 +FROM debian:bookworm-slim67 +RUN apt-get update \68 + && apt-get install -y --no-install-recommends ca-certificates git \69 + && rm -rf /var/lib/apt/lists \70 + && useradd --uid 10000 --create-home --shell /usr/sbin/nologin hu13 \71 + && mkdir -p /srv/www /data/inbox \72 + && chown -R hu13:hu13 /srv/www /data/inbox73 +COPY --from=builder /src/target/release/hu13 /usr/local/bin/hu1374 +COPY entrypoint.sh /usr/local/bin/entrypoint.sh75 +RUN chmod +x /usr/local/bin/entrypoint.sh76 +USER hu1377 +EXPOSE 3931378 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]79 diff --git a/deploy.DOC.md b/deploy.DOC.md80 new file mode 10064481 index 0000000..15b953e82 --- /dev/null83 +++ b/deploy.DOC.md84 @@ -0,0 +1,80 @@85 +# Deploy hu13 with Docker86 +87 +> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the88 +> branch stays free of `.forge/` writes. Supervisor: move it to the wiki89 +> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md`90 +> via the CLI) and delete this file.91 +92 +## Build the image93 +94 +From the repo root (build context is sources only — see `.dockerignore`):95 +96 +```sh97 +docker build -t hu13 .98 +```99 +100 +Multi-stage: `rust:1.98-bookworm` compiles with101 +`cargo build --locked --release` (only `Cargo.toml`, `Cargo.lock`, `src/`,102 +`static/` are copied, so edits elsewhere don't bust the cache);103 +`debian:bookworm-slim` runtime adds `ca-certificates`, a nonroot user104 +`hu13` (uid 10000), the binary, and `entrypoint.sh`. No toolchain, no105 +sources, no `.git` in the final image.106 +107 +## Run it108 +109 +```sh110 +docker run --rm -p 39313:39313 \111 + -v "$PWD:/data/repo:ro" \112 + -v hu13-inbox:/data/inbox \113 + -e HU13_BRANCH=main \114 + -e HU13_CLONE_URL=https://example.com/mirror.git \115 + hu13116 +```117 +118 +Then open `http://localhost:39313/`.119 +120 +What happens inside (`entrypoint.sh`):121 +122 +1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url`123 + only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure —124 + bad branch, unreadable repo — aborts LOUD before serving starts125 + (`set -eu`; serve never runs on a stale/empty site).126 +2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox127 + --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`,128 + `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching129 + `HU13_*` env var is set; otherwise the serve defaults apply).130 + `exec` keeps `serve` on PID 1.131 +132 +## Volumes / env reference133 +134 +| Mount / var | Default | Notes |135 +| ---------------------- | ------------------ | -------------------------------------------------- |136 +| `/data/repo` (ro) | — | repo checkout to render (required) |137 +| `/data/inbox` | — | inbox storage; MUST sit outside `--www` (serve refuses otherwise) |138 +| `HU13_BRANCH` | `main`, else `master` | branch to build; wrong name fails the container |139 +| `HU13_CLONE_URL` | git origin remote | clone URL advertised on the index |140 +| `HU13_LISTEN` | `0.0.0.0:39313` | serve address |141 +| `HU13_MAX_TITLE` | `300` | inbox title cap (chars) |142 +| `HU13_MAX_BODY` | `20000` | inbox body/prompt cap (chars) |143 +| `HU13_MAX_BYTES` | `32768` | whole-POST cap (bytes) |144 +| `HU13_RATE_PER_HOUR` | `2` | per-IP POSTs/hour |145 +| `HU13_QUOTA` | `500` | global inbox file quota |146 +147 +## Notes148 +149 +- Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.150 + If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`151 + (libyaml) with the hand-rolled frontmatter split already used for152 + parsing shapes, hand-roll the fixed-order `api/*.json` emitter instead153 + of `serde_json`, enable `strip = true` + `opt-level = "z"` + LTO in the154 + release profile. Do not gold-plate before then.155 +- `HU13_WWW` / `HU13_INBOX_DIR` also work for bare-metal156 + `hu13 serve` (flags win over env); the container always passes157 + explicit `--www` / `--inbox-dir`.158 +- Inbox files are read locally only: `hu13 inbox list|show --inbox-dir159 + /data/inbox` (or `docker exec` into the volume). Never served over HTTP.160 +- Mount a plain checkout, not a git worktree: a worktree's `.git` is a161 + pointer file (`gitdir: ...`) whose target lives outside the mount, so162 + git inside the container cannot resolve anything. `git clone` the163 + branch (or copy the repo) and mount that. (Host/container uid mismatch164 + on the mount is already handled via `safe.directory` in entrypoint.sh.)165 diff --git a/entrypoint.sh b/entrypoint.sh166 new file mode 100755167 index 0000000..3cc51e5168 --- /dev/null169 +++ b/entrypoint.sh170 @@ -0,0 +1,56 @@171 +#!/bin/sh172 +# hu13 container entrypoint (f0rge issue #11): build once, then serve.173 +#174 +# Layout: the repo is mounted read-only at /data/repo, the inbox lives at175 +# /data/inbox (OUTSIDE the served tree — serve refuses to start otherwise),176 +# the built site goes to /srv/www.177 +#178 +# Env:179 +# HU13_BRANCH branch to build (default: main/master auto-detect)180 +# HU13_CLONE_URL clone URL to advertise (default: repo origin remote)181 +# HU13_LISTEN serve address (default: 0.0.0.0:39313)182 +# HU13_MAX_TITLE / HU13_MAX_BODY / HU13_MAX_BYTES /183 +# HU13_RATE_PER_HOUR / HU13_QUOTA184 +# inbox caps (serve defaults when unset; passed through185 +# as flags only when set, env is the fallback otherwise)186 +#187 +# Fail loud: any build failure (bad branch, bad repo, missing dir)188 +# aborts before serve starts. `serve` is exec'd so it keeps PID 1.189 +set -eu190 +191 +# Mounted checkouts belong to the host uid, but we run as uid 10000:192 +# without this git refuses every read ("dubious ownership") and the193 +# build below would misreport "branch not found".194 +git config --global --add safe.directory /data/repo195 +196 +BUILD_ARGS=""197 +if [ -n "${HU13_BRANCH:-}" ]; then198 + BUILD_ARGS="$BUILD_ARGS --branch $HU13_BRANCH"199 +fi200 +if [ -n "${HU13_CLONE_URL:-}" ]; then201 + BUILD_ARGS="$BUILD_ARGS --clone-url $HU13_CLONE_URL"202 +fi203 +204 +# shellcheck disable=SC2086205 +hu13 build /data/repo -o /srv/www $BUILD_ARGS206 +207 +SERVE_ARGS=""208 +if [ -n "${HU13_MAX_TITLE:-}" ]; then209 + SERVE_ARGS="$SERVE_ARGS --max-title $HU13_MAX_TITLE"210 +fi211 +if [ -n "${HU13_MAX_BODY:-}" ]; then212 + SERVE_ARGS="$SERVE_ARGS --max-body $HU13_MAX_BODY"213 +fi214 +if [ -n "${HU13_MAX_BYTES:-}" ]; then215 + SERVE_ARGS="$SERVE_ARGS --max-bytes $HU13_MAX_BYTES"216 +fi217 +if [ -n "${HU13_RATE_PER_HOUR:-}" ]; then218 + SERVE_ARGS="$SERVE_ARGS --rate-per-hour $HU13_RATE_PER_HOUR"219 +fi220 +if [ -n "${HU13_QUOTA:-}" ]; then221 + SERVE_ARGS="$SERVE_ARGS --quota $HU13_QUOTA"222 +fi223 +224 +# shellcheck disable=SC2086225 +exec hu13 serve --www /srv/www --inbox-dir /data/inbox \226 + --listen "${HU13_LISTEN:-0.0.0.0:39313}" $SERVE_ARGS227 diff --git a/src/serve.rs b/src/serve.rs228 index bd878f0..ed60894 100644229 --- a/src/serve.rs230 +++ b/src/serve.rs231 @@ -309,7 +309,16 @@ fn static_error(stream: &mut TcpStream, code: u16) {232 405 => "method not allowed",233 _ => "error",234 };235 - let body = crate::layout::page(msg, "", &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));236 + // Error pages keep the top-level nav so a lost visitor can click out.237 + let nav = "<a href=\"/\">code</a> · <a href=\"/commits.html\">commits</a> · \238 + <a href=\"/issues/index.html\">issues</a> · \239 + <a href=\"/pulls/index.html\">pulls</a> · \240 + <a href=\"/tasks/index.html\">tasks</a> · \241 + <a href=\"/ci/index.html\">ci</a> · \242 + <a href=\"/releases/index.html\">releases</a> · \243 + <a href=\"/wiki/index.html\">wiki</a> · \244 + <a href=\"/agents.html\">agents</a>";245 + let body = crate::layout::page(msg, nav, &format!("<h1>{msg}</h1>\n<p>Nothing here.</p>\n"));246 let extra = if code == 405 {247 &[("Allow", "GET, HEAD")][..]248 } else {249 @@ -460,11 +469,9 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {250 handle_inbox(&mut stream, &head, rest, kind, shared);251 return;252 }253 - // Static: GET/HEAD only; never a listing (dirs → 404, files only).254 - if head.method != "GET" && head.method != "HEAD" {255 - static_error(&mut stream, 405);256 - return;257 - }258 + // Static: files only, never a listing (dirs → 404). Unknown paths259 + // are 404 for ANY method (including POST); a known file with the260 + // wrong method is 405. Fail closed, echo nothing.261 let rel = path.strip_prefix('/').unwrap_or(&path);262 if rel.contains('\0')263 || rel.split('/').any(|s| s == "..")264 @@ -490,6 +497,10 @@ fn handle_conn(mut stream: TcpStream, shared: &Shared) {265 return;266 }267 };268 + if head.method != "GET" && head.method != "HEAD" {269 + static_error(&mut stream, 405);270 + return;271 + }272 let ctype = content_type_for(&file_rel);273 if head.method == "HEAD" {274 let out = format!(275 diff --git a/tests/polish.rs b/tests/polish.rs276 new file mode 100644277 index 0000000..e71a09f278 --- /dev/null279 +++ b/tests/polish.rs280 @@ -0,0 +1,279 @@281 +//! Polish suite (m5/docker, f0rge issue #11):282 +//! CSS budget, serve 404s, empty-forge build, entrypoint fail-loudness.283 +//!284 +//! No docker daemon needed: the entrypoint is tested through flag parsing285 +//! (`hu13 build --branch <bad>` exits non-zero, naming the branch) plus a286 +//! `bash -n` syntax check of `entrypoint.sh` itself.287 +288 +use std::io::{BufRead, BufReader, Read, Write};289 +use std::net::TcpStream;290 +use std::path::{Path, PathBuf};291 +use std::process::{Child, Command, Stdio};292 +use std::time::Duration;293 +294 +fn bin() -> &'static str {295 + env!("CARGO_BIN_EXE_hu13")296 +}297 +298 +fn manifest() -> PathBuf {299 + PathBuf::from(env!("CARGO_MANIFEST_DIR"))300 +}301 +302 +fn fresh(prefix: &str) -> PathBuf {303 + let dir = std::env::temp_dir().join(format!(304 + "hu13-polish-{prefix}-{}-{}",305 + std::process::id(),306 + std::time::SystemTime::now()307 + .duration_since(std::time::UNIX_EPOCH)308 + .map(|d| d.as_nanos())309 + .unwrap_or(0)310 + ));311 + std::fs::create_dir_all(&dir).unwrap();312 + dir313 +}314 +315 +fn git(repo: &Path, args: &[&str]) {316 + let st = Command::new("git")317 + .arg("-C")318 + .arg(repo)319 + .args(args)320 + .status()321 + .expect("spawn git");322 + assert!(st.success(), "git {args:?} failed");323 +}324 +325 +/// Minimal repo: 1 commit, NO `.forge/` at all.326 +fn bare_repo() -> PathBuf {327 + let dir = fresh("repo");328 + git(&dir, &["init", "-b", "main"]);329 + git(&dir, &["config", "user.name", "hu13-test"]);330 + git(&dir, &["config", "user.email", "[email protected]"]);331 + git(&dir, &["config", "commit.gpgsign", "false"]);332 + std::fs::write(dir.join("README.md"), "# polish fixture\n").unwrap();333 + std::fs::write(dir.join("hello.txt"), "hello\n").unwrap();334 + git(&dir, &["add", "-A"]);335 + git(&dir, &["commit", "-m", "init"]);336 + assert!(!dir.join(".forge").exists());337 + dir338 +}339 +340 +struct Server {341 + child: Child,342 + addr: String,343 +}344 +345 +impl Drop for Server {346 + fn drop(&mut self) {347 + self.child.kill().ok();348 + self.child.wait().ok();349 + }350 +}351 +352 +fn start_server(www: &Path, inbox: &Path) -> Server {353 + let mut child = Command::new(bin())354 + .arg("serve")355 + .arg("--www")356 + .arg(www)357 + .arg("--inbox-dir")358 + .arg(inbox)359 + .arg("--listen")360 + .arg("127.0.0.1:0")361 + .stdout(Stdio::piped())362 + .stderr(Stdio::piped())363 + .spawn()364 + .expect("spawn hu13 serve");365 + let stdout = child.stdout.take().expect("piped stdout");366 + let mut lines = BufReader::new(stdout).lines();367 + let mut addr = String::new();368 + for _ in 0..50 {369 + match lines.next() {370 + Some(Ok(line)) if line.contains("listening on ") => {371 + addr = line372 + .rsplit("listening on ")373 + .next()374 + .unwrap_or("")375 + .trim()376 + .to_string();377 + break;378 + }379 + Some(Ok(_)) => continue,380 + _ => break,381 + }382 + }383 + assert!(!addr.is_empty(), "serve printed no `listening on` line");384 + std::thread::sleep(Duration::from_millis(100));385 + Server { child, addr }386 +}387 +388 +fn transact(addr: &str, req: &[u8]) -> String {389 + let mut s = TcpStream::connect(addr).expect("connect");390 + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();391 + s.set_write_timeout(Some(Duration::from_secs(10))).unwrap();392 + s.write_all(req).expect("write");393 + let mut out = Vec::new();394 + s.read_to_end(&mut out).expect("read");395 + String::from_utf8_lossy(&out).into_owned()396 +}397 +398 +fn status(resp: &str) -> &str {399 + resp.split("\r\n").next().unwrap_or("")400 +}401 +402 +#[test]403 +fn css_budget_under_15k() {404 + let css = manifest().join("static").join("style.css");405 + let len = std::fs::metadata(&css).expect("style.css exists").len();406 + assert!(407 + len < 15360,408 + "static/style.css is {len} bytes, budget is 15360 (see gl0w.md)"409 + );410 +}411 +412 +#[test]413 +fn unknown_path_is_404_html_for_get_and_post() {414 + let www = fresh("www");415 + std::fs::write(www.join("index.html"), "<!DOCTYPE html><p>hi</p>").unwrap();416 + let inbox = fresh("inbox");417 + let srv = start_server(&www, &inbox);418 +419 + let get = transact(420 + &srv.addr,421 + b"GET /no-such-page.html HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n",422 + );423 + assert!(424 + status(&get).contains("404"),425 + "GET unknown -> 404, got: {get}"426 + );427 + assert!(get.contains("text/html"), "404 body is html, got: {get}");428 + assert!(429 + get.contains("X-Content-Type-Options: nosniff"),430 + "404 carries nosniff"431 + );432 +433 + // POST to an unknown (non-inbox) path is 404, not 405 and not 200.434 + let body = "a=b";435 + let post = transact(436 + &srv.addr,437 + format!(438 + "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}",439 + body.len()440 + )441 + .as_bytes(),442 + );443 + assert!(444 + status(&post).contains("404"),445 + "POST unknown -> 404, got: {post}"446 + );447 + assert!(post.contains("text/html"), "404 body is html");448 +449 + // A KNOWN file with the wrong method is still 405 (no regression).450 + let put = transact(451 + &srv.addr,452 + b"PUT /index.html HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",453 + );454 + assert!(455 + status(&put).contains("405"),456 + "wrong method on real file -> 405, got: {put}"457 + );458 +}459 +460 +#[test]461 +fn empty_forge_build_all_tabs_none_yet() {462 + let repo = bare_repo();463 + let out = fresh("out");464 + let build = Command::new(bin())465 + .arg("build")466 + .arg(&repo)467 + .arg("-o")468 + .arg(&out)469 + .output()470 + .expect("spawn hu13 build");471 + assert!(472 + build.status.success(),473 + "no-forge build exits 0: {}",474 + String::from_utf8_lossy(&build.stderr)475 + );476 + for (page, marker) in [477 + ("issues/index.html", "No issues yet"),478 + ("pulls/index.html", "No pull requests yet"),479 + ("tasks/index.html", "No tasks yet"),480 + ("ci/index.html", "No CI runs yet"),481 + ("releases/index.html", "No releases yet"),482 + ("wiki/index.html", "No wiki pages yet"),483 + ] {484 + let html = std::fs::read_to_string(out.join(page)).expect(page);485 + assert!(html.contains(marker), "{page} says {marker:?}");486 + }487 +}488 +489 +#[test]490 +fn bad_branch_fails_loud_naming_branch() {491 + let repo = bare_repo();492 + let out = fresh("out-bad");493 + // Flag path (what entrypoint.sh passes from $HU13_BRANCH).494 + let build = Command::new(bin())495 + .arg("build")496 + .arg(&repo)497 + .arg("-o")498 + .arg(&out)499 + .arg("--branch")500 + .arg("does-not-exist")501 + .output()502 + .expect("spawn hu13 build");503 + assert!(!build.status.success(), "bad branch exits non-zero");504 + let err = String::from_utf8_lossy(&build.stderr).into_owned();505 + assert!(506 + err.contains("does-not-exist"),507 + "stderr names the bad branch, got: {err}"508 + );509 + // Env path (HU13_BRANCH fallback inside gitx::resolve_branch).510 + let out2 = fresh("out-bad-env");511 + let build2 = Command::new(bin())512 + .arg("build")513 + .arg(&repo)514 + .arg("-o")515 + .arg(&out2)516 + .env("HU13_BRANCH", "also-missing")517 + .output()518 + .expect("spawn hu13 build");519 + assert!(!build2.status.success(), "bad HU13_BRANCH exits non-zero");520 + let err2 = String::from_utf8_lossy(&build2.stderr).into_owned();521 + assert!(522 + err2.contains("also-missing"),523 + "stderr names the bad env branch, got: {err2}"524 + );525 + // Missing repo dir is loud too.526 + let out3 = fresh("out-bad-repo");527 + let build3 = Command::new(bin())528 + .arg("build")529 + .arg("/does/not/exist-at-all")530 + .arg("-o")531 + .arg(&out3)532 + .output()533 + .expect("spawn hu13 build");534 + assert!(!build3.status.success(), "missing repo exits non-zero");535 +}536 +537 +#[test]538 +fn entrypoint_syntax_checks() {539 + let ep = manifest().join("entrypoint.sh");540 + assert!(ep.exists(), "entrypoint.sh exists");541 + let st = Command::new("bash")542 + .arg("-n")543 + .arg(&ep)544 + .status()545 + .expect("spawn bash -n");546 + assert!(st.success(), "entrypoint.sh parses");547 + let text = std::fs::read_to_string(&ep).expect("read entrypoint");548 + for needle in [549 + "set -eu",550 + "hu13 build /data/repo -o /srv/www",551 + "HU13_BRANCH",552 + "HU13_CLONE_URL",553 + "exec hu13 serve --www /srv/www --inbox-dir /data/inbox",554 + "0.0.0.0:39313",555 + "safe.directory",556 + ] {557 + assert!(text.contains(needle), "entrypoint mentions {needle}");558 + }559 +}560 -- 561 2.55.0562