Files
homelab/hosts/mars/luna-sites-test.nix
T
darmanandClaude Sonnet 5 6f24ab69ad docs: condense comments across the repo
Comments had drifted into multi-paragraph narrative (git commit
lineage, debugging stories, restated code) in several hot spots
(scripts/deploy, hermes-agent.nix, flake.nix, gitea.nix, headscale.nix).
Trim every comment to its load-bearing "why" — gotchas, safety
warnings, and non-obvious rationale survive verbatim in substance,
just tightened to 1-2 sentences; historical narrative and anything
already covered in CLAUDE.md is cut. No code/logic changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UJqEmY1y3AYX3JoX4Y6b21
2026-09-18 21:36:30 +02:00

187 lines
8.4 KiB
Nix

# VM test for luna-sites.nix. Run:
# nix build .#checks.x86_64-linux.luna-sites -L
#
# mars has no VM target, and nearly everything luna-sites does only exists at
# runtime: a rootless podman socket reached through a proxy from another
# container's uid, a path unit, a caddy reload, linger + podman-restart after
# a reboot. So this drives it the way luna does — every podman and registry
# command runs inside a stand-in for the Hermes container, as uid 986 — and
# checks that bad entries are refused without taking good ones down.
{ pkgs }:
let
# `contents` is symlinked into the image root and its closure ships as
# layers, so the app image is self-contained under luna-apps. The stand-in
# is NOT: hermes-agent mounts the host's /nix/store over the image's own,
# which is why the node adds busybox to the VM's store below.
busyboxImage = { name, extraCommands ? "", cmd }: pkgs.dockerTools.buildLayeredImage {
inherit name;
tag = "latest";
contents = [ pkgs.busybox ];
extraCommands = "mkdir -p tmp && chmod 1777 tmp\n" + extraCommands;
config.Cmd = cmd;
};
# Stand-in for docker.io/nousresearch/hermes-agent: a shell and nothing else.
# The podman client comes from the store, mounted by luna-sites.nix exactly
# as on mars.
standin = busyboxImage {
name = "hermes-standin";
cmd = [ "/bin/sleep" "infinity" ];
};
# The "app" luna builds on top of, loaded from the store since the VM has
# no network. Runs under luna-apps, which has no /nix/store mount — hence
# the closure baked into the image.
app = busyboxImage {
name = "testapp";
extraCommands = "mkdir -p www && echo hello > www/index.html";
cmd = [ "/bin/httpd" "-f" "-p" "8080" "-h" "/www" ];
};
in
pkgs.testers.runNixOSTest {
name = "luna-sites";
nodes.mars = {
imports = [ ./luna-sites.nix ];
virtualisation.memorySize = 2048;
virtualisation.diskSize = 4096;
environment.systemPackages = [ pkgs.curl ];
# The stand-in's /bin symlinks point into /nix/store, and the /nix/store
# mount below replaces the image's copy with the VM's, which only holds
# the system closure. Without this: "executable file `/bin/sleep` not
# found". (The real Hermes image is not nix-built, so mars never hits it.)
system.extraDependencies = [ pkgs.busybox ];
# What hermes-agent.nix provides, minus Hermes itself: same uid/gid, host
# networking, hermesHome at /opt/data, /nix/store read-only.
users.groups.hermes.gid = 983;
systemd.tmpfiles.rules = [
"d /var/lib/hermes 0750 root hermes -"
"d /var/lib/hermes/.hermes 0750 986 983 -"
];
virtualisation.oci-containers.containers.hermes-agent = {
image = "hermes-standin:latest";
imageFile = standin;
extraOptions = [ "--network=host" "--user=986:983" ];
volumes = [
"/var/lib/hermes/.hermes:/opt/data"
"/nix/store:/nix/store:ro"
];
environment = {
HERMES_UID = "986";
HERMES_GID = "983";
HOME = "/opt/data";
};
};
};
testScript = /* python */ ''
import shlex
status_file = "/var/lib/hermes/.hermes/sites-status.txt"
def luna(cmd):
"""Run cmd the way luna would: inside her container, as uid 986."""
return mars.succeed("podman exec hermes-agent sh -c " + shlex.quote(cmd))
def code(path):
return mars.succeed(
f"curl -s -o /dev/null -w '%{{http_code}}' http://127.0.0.1{path}"
).strip()
def status_line(entry):
lines = mars.succeed(f"cat {status_file}").splitlines()
found = [l for l in lines if l.split(" ", 1)[0] == entry]
assert len(found) == 1, f"no single status line for {entry}:\n" + "\n".join(lines)
return found[0]
start_all()
mars.wait_for_unit("caddy.service")
mars.wait_for_unit("podman-hermes-agent.service")
with subtest("caddy starts with nothing registered"):
# The import glob matches no file on a fresh box; caddy must still run.
assert code("/") == "404"
with subtest("luna's podman is luna-apps's rootless podman"):
assert luna("id -u").strip() == "986"
assert luna("podman info --format '{{.Host.Security.Rootless}}'").strip() == "true"
readme = luna("cat /opt/data/sites-README.md")
assert "20000" in readme and "@port" not in readme, "README placeholders not substituted"
with subtest("build and run an app, as luna would"):
luna("podman load -i ${app}")
luna(
"mkdir -p /opt/data/apps/notes && "
"printf 'FROM localhost/testapp:latest\\nRUN echo built > /www/built.txt\\n' "
"> /opt/data/apps/notes/Containerfile"
)
luna("podman build -t localhost/notes /opt/data/apps/notes")
luna("podman run -d --name notes --restart=always -p 127.0.0.1:20001:8080 localhost/notes")
mars.wait_until_succeeds("curl -sf http://127.0.0.1:20001/built.txt")
# Container root maps to luna-apps on the host: not root, not uid 986.
mars.succeed("pgrep -u luna-apps -f 'httpd -f -p 8080'")
with subtest("registering routes /notes/ to it"):
luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""")
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built")
# httpd has no /www/notes/, so the 200 above also proves the prefix is stripped.
assert " ok " in status_line("notes.json")
out = mars.succeed(
"curl -s -o /dev/null -w '%{http_code} %{redirect_url}' http://127.0.0.1/notes"
)
assert out.startswith("308 ") and out.endswith("/notes/"), out
mars.succeed("stat -c %U:%a /var/lib/luna-sites/live/notes.caddy | grep -qx root:644")
with subtest("bad entries are rejected one by one"):
luna("""echo '{"port": 9119}' > /opt/data/sites/dash.json""")
luna("echo nope > /opt/data/sites/broken.json")
luna(": > /opt/data/sites/empty.json")
luna("""echo '{"port": 20002}{"port": 20003}' > /opt/data/sites/two.json""")
luna("""echo '{"port": 20003.5}' > /opt/data/sites/frac.json""")
luna("""echo '{"port": "20004"}' > /opt/data/sites/str.json""")
luna("""echo '{"port": 20005}' > /opt/data/sites/Bad_Name.json""")
luna("ln -s /etc/shadow /opt/data/sites/link.json")
mars.wait_until_succeeds(f"grep -q '^link.json ' {status_file}")
for entry, why in [
("dash.json", "port 9119 is outside 20000-20999"),
("broken.json", "not valid JSON"),
("empty.json", "expected exactly one JSON object"),
("two.json", "expected exactly one JSON object"),
("frac.json", "port must be an integer"),
("str.json", "port must be an integer"),
("Bad_Name.json", "name must match"),
("link.json", "not a regular file"),
]:
line = status_line(entry)
assert " rejected " in line and why in line, line
assert " ok " in status_line("notes.json")
# A burst like the one above used to trip systemd's start limit, which
# fails the path unit for good and silently ignores every later entry.
mars.succeed("systemctl is-active luna-sites.path")
assert code("/notes/built.txt") == "200"
assert code("/dash/") == "404"
mars.succeed("test \"$(ls /var/lib/luna-sites/live)\" = notes.caddy")
# The status file is hers, and nothing root-written is left in her tree
# (bar the README's mountpoint, which podman itself creates).
mars.succeed(f"stat -c %u {status_file} | grep -qx 986")
mars.fail("find /var/lib/hermes/.hermes -user root ! -name sites-README.md | grep .")
with subtest("removing the entry removes the route"):
luna("rm /opt/data/sites/notes.json")
mars.wait_until_succeeds("test \"$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1/notes/built.txt)\" = 404")
with subtest("apps and routes come back after a reboot"):
luna("""echo '{"port": 20001}' > /opt/data/sites/notes.json""")
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt")
mars.shutdown()
mars.start()
mars.wait_for_unit("caddy.service")
# Nobody logs in: linger starts luna-apps's manager, podman-restart the container.
mars.wait_until_succeeds("curl -sf http://127.0.0.1/notes/built.txt | grep -qx built", timeout=180)
mars.wait_for_unit("podman-hermes-agent.service")
assert luna("podman ps --format '{{.Names}}'").split() == ["notes"]
'';
}