#11 m5/docker

mergedpassopened
details
branches
master ← m5/docker
updated
id
71c03ee0-ba11-40ea-b705-dfd5de88cbbf

Patch

.dockerignore+11 −0
diff --git a/.dockerignore b/.dockerignorenew file mode 100644index 0000000..e005087--- /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
diff --git a/Dockerfile b/Dockerfilenew file mode 100644index 0000000..31b427c--- /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
diff --git a/deploy.DOC.md b/deploy.DOC.mdnew file mode 100644index 0000000..15b953e--- /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
diff --git a/entrypoint.sh b/entrypoint.shnew file mode 100755index 0000000..3cc51e5--- /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
diff --git a/src/serve.rs b/src/serve.rsindex bd878f0..ed60894 100644--- 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
diff --git a/tests/polish.rs b/tests/polish.rsnew file mode 100644index 0000000..e71a09f--- /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-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls