furtka/tests/test_app.py
Daniel Maksymilian Syrnicki 4569c37640 feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
Slice 1b of the self-update story. The installer now sets up a versioned
layout — install extracts the resource-manager tarball to a staging dir,
reads the VERSION it contains, moves the dir to /opt/furtka/versions/<ver>/,
and creates /opt/furtka/current as a symlink pointing at it. All runtime
references (Caddy, wrapper, systemd ExecStart) go through /current, so
Phase 2's self-update just flips the symlink atomically.

Systemd units move from hand-written files in /etc/systemd/system/ to
`systemctl link /opt/furtka/current/assets/systemd/*` — one link per
unit, stable across upgrades because the link target is /current. The
furtka-status + furtka-welcome units now ExecStart the shipped scripts
directly from /opt/furtka/current/assets/bin/, which means we no longer
copy those scripts to /usr/local/bin/ at install time.

Runtime JSON (status.json, furtka.json, update-state.json) moves to
/var/lib/furtka/ so self-updates never clobber it. Caddy serves those
three paths from there; everything else from /opt/furtka/current/assets/www/.

The __HOSTNAME__ sed-template hack is gone. At install time we write
/var/lib/furtka/furtka.json with {hostname, install_date, version}, and
the landing page's JS reads it on load to populate the hostname chip
and to build the SMB deep-link for the fileshare tile. First paint gets
a "—" placeholder and hydrates once fetch completes.

Test updates:
  - test_webinstaller_assets enforces the new command shape (extract-to-
    staging, ln -sfn /opt/furtka/current, systemctl link per unit,
    no writes to /srv/furtka/www/).
  - test_app's legacy "payload present" / "payload absent" tests match
    the new layout too.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00

197 lines
7.3 KiB
Python

from app import (
build_archinstall_config,
build_archinstall_creds,
validate_step1,
)
def test_validate_step1_accepts_good_input():
errors, values = validate_step1(
{
"hostname": "furtka",
"username": "daniel",
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "de",
}
)
assert errors == []
assert values == {
"hostname": "furtka",
"username": "daniel",
"password": "topsecretpw",
"language": "de",
}
def test_validate_step1_collects_all_errors():
errors, _ = validate_step1(
{
"hostname": "BAD!",
"username": "1bad",
"password": "short",
"password2": "mismatch",
"language": "xx",
}
)
assert len(errors) == 5
def test_build_archinstall_config_uses_selected_locale(monkeypatch):
# build_disk_config imports archinstall lazily; archinstall isn't
# installed in CI (only runs on the live ISO), so stub it out.
import app as app_module
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"stubbed_device": d})
cfg = build_archinstall_config(
{
"hostname": "h",
"username": "u",
"password": "pw12345678",
"language": "pl",
"boot_drive": "/dev/sda",
}
)
assert cfg["disk_config"] == {"stubbed_device": "/dev/sda"}
assert cfg["hostname"] == "h"
assert cfg["locale_config"]["locale"] == "pl_PL.UTF-8"
# Users moved out of config into creds once we adopted archinstall 4.x's
# `!password` sentinel; config only carries a gpasswd in custom_commands
# so the user lands in the docker group after docker is pacstrapped.
assert "users" not in cfg
assert cfg["custom_commands"][0] == "gpasswd -a u docker"
def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch, tmp_path):
# The installed system should come up with a Furtka landing page at
# http://<hostname>.local. That means caddy + avahi pacstrapped, the
# matching services enabled, a Caddyfile written into the target rootfs,
# nss-mdns spliced into nsswitch.conf, and the resource-manager tarball
# extracted into the versioned /opt/furtka/current layout.
import app as app_module
# Fake payload so _resource_manager_commands emits its full cmd tree.
fake_payload = tmp_path / "payload.tar.gz"
fake_payload.write_bytes(b"\x1f\x8b\x08\x00fake")
monkeypatch.setattr(app_module, "RESOURCE_MANAGER_PAYLOAD", fake_payload)
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"stubbed_device": d})
cfg = build_archinstall_config(
{
"hostname": "heimserver",
"username": "u",
"password": "pw12345678",
"language": "en",
"boot_drive": "/dev/sda",
}
)
for pkg in ("caddy", "avahi", "nss-mdns"):
assert pkg in cfg["packages"]
# Packaged units go in `services` (enabled before custom_commands runs);
# our own units don't exist at that point, so they must be enabled from
# within custom_commands after the unit files land on disk.
for svc in ("caddy", "avahi-daemon"):
assert svc in cfg["services"]
assert "furtka-welcome" not in cfg["services"]
assert "furtka-status.timer" not in cfg["services"]
joined = "\n".join(cfg["custom_commands"])
# Every furtka-* unit is systemctl-linked from the shipped asset tree and
# then enabled — no hand-written files under /etc/systemd/system/.
assert "systemctl link /opt/furtka/current/assets/systemd/" in joined
assert "systemctl enable furtka-api.service" in joined
for path in (
"/etc/caddy/Caddyfile",
"/var/lib/furtka/status.json",
"/var/lib/furtka/furtka.json",
):
assert path in joined, f"expected {path} to be written by custom_commands"
# /srv/furtka/www/ is retired — no writes should target it anymore.
assert "/srv/furtka/www" not in joined
assert "mdns_minimal" in joined
assert "nsswitch.conf" in joined
# Hostname is injected via /var/lib/furtka/furtka.json, not via sed.
assert "__HOSTNAME__" not in joined
def test_resource_manager_payload_landed_when_present(monkeypatch, tmp_path):
# When iso/build.sh has staged the resource-manager tarball, the
# post-install commands should untar it into /opt/furtka/versions/<ver>/,
# flip the /opt/furtka/current symlink, drop the `furtka` wrapper, and
# systemctl-link every Furtka unit file.
import app as app_module
fake_payload = tmp_path / "furtka-resource-manager.tar.gz"
fake_payload.write_bytes(b"\x1f\x8b\x08\x00fake-tarball-bytes")
monkeypatch.setattr(app_module, "RESOURCE_MANAGER_PAYLOAD", fake_payload)
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"stubbed_device": d})
cfg = build_archinstall_config(
{
"hostname": "heimserver",
"username": "u",
"password": "pw12345678",
"language": "en",
"boot_drive": "/dev/sda",
}
)
joined = "\n".join(cfg["custom_commands"])
# Tarball lands in the versioned slot, not flat /opt/furtka/.
assert 'tar -xzf - -C "$staging"' in joined
assert "/opt/furtka/versions/$ver" in joined
assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in joined
# CLI wrapper lands and references /opt/furtka/current. The wrapper body
# rides the base64 payload, so assert on the constant directly.
assert "/usr/local/bin/furtka" in joined
assert "PYTHONPATH=/opt/furtka/current" in app_module._FURTKA_WRAPPER_SH
# Every unit is linked + enabled.
for unit in app_module._FURTKA_UNITS:
assert f"/opt/furtka/current/assets/systemd/{unit}" in joined
assert unit in joined
# python is pacstrapped so the wrapper has an interpreter.
assert "python" in cfg["packages"]
def test_resource_manager_absent_without_payload(monkeypatch, tmp_path):
# Dev box / CI without an ISO build: payload doesn't exist. No tarball
# extract, no unit link, no enable — and no stale references to furtka-*
# units in custom_commands.
import app as app_module
monkeypatch.setattr(app_module, "RESOURCE_MANAGER_PAYLOAD", tmp_path / "does-not-exist.tar.gz")
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"stubbed_device": d})
cfg = build_archinstall_config(
{
"hostname": "heimserver",
"username": "u",
"password": "pw12345678",
"language": "en",
"boot_drive": "/dev/sda",
}
)
joined = "\n".join(cfg["custom_commands"])
assert "tar -xzf" not in joined
assert "systemctl link" not in joined
# The base system bootstrap (caddy, status.json placeholder, furtka.json)
# is unaffected.
assert "/etc/caddy/Caddyfile" in joined
assert "/var/lib/furtka/furtka.json" in joined
def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
creds = build_archinstall_creds({"username": "u", "password": "pw12345678"})
assert creds["!root-password"] == "pw12345678"
assert creds["users"] == [
{
"username": "u",
"!password": "pw12345678",
"sudo": True,
"groups": [],
}
]