# 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"] ''; }