furtka/tests/test_app.py
Daniel Maksymilian Syrnicki 9f4e514d8a feat(furtka): ship resource manager + fileshare app on the ISO — slice 3
Closes the loop end-to-end. The ISO build now bundles the furtka/
package and the apps/ tree as a tarball; webinstaller hands it to
archinstall via custom_commands; the installed system gets the
`furtka` CLI, a boot-scan systemd unit, and the fileshare app
ready to install.

- iso/build.sh: stages furtka/ + apps/ into a tmpdir, drops
  __pycache__, tarballs into airootfs/opt/furtka-resource-manager.tar.gz.
- webinstaller/app.py: _resource_manager_commands() reads the staged
  payload at request-time, base64-encodes it into a single untar
  command, and writes /usr/local/bin/furtka (PYTHONPATH wrapper, no
  pip needed) + furtka-reconcile.service. Python pacstrapped so the
  wrapper has an interpreter.
- Graceful degradation: dev box / CI without an ISO build has no
  payload tarball, so those commands are skipped (logs a warning).
  Tests cover both branches.
- furtka-reconcile.service is conditionally enabled only if the unit
  file actually landed — keeps the systemctl enable line green when
  the payload was absent.
- apps/fileshare/: first real Furtka app. dperson/samba on host
  network, single named volume, .env.example with placeholder creds.
  Manifest matches the schema locked in slice 1.

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

187 lines
6.7 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):
# 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 + index.html written into the
# target rootfs, and nss-mdns spliced into nsswitch.conf.
import app as app_module
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"])
assert "systemctl enable furtka-welcome.service furtka-status.timer" in joined
for path in (
"/etc/caddy/Caddyfile",
"/srv/furtka/www/index.html",
"/srv/furtka/www/style.css",
"/srv/furtka/www/status.json",
"/usr/local/bin/furtka-status",
"/usr/local/bin/furtka-welcome",
"/etc/systemd/system/furtka-status.service",
"/etc/systemd/system/furtka-status.timer",
"/etc/systemd/system/furtka-welcome.service",
):
assert path in joined, f"expected {path} to be written by custom_commands"
assert "mdns_minimal" in joined
assert "nsswitch.conf" in joined
# The chosen hostname is pinned into the static HTML at install-time.
assert "s/__HOSTNAME__/heimserver/g" 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, drop the `furtka` wrapper, and
# write the reconcile systemd unit. Without the tarball the install still
# succeeds — the resource manager is just absent (covered implicitly by
# the default test environment, which has no payload).
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 expansion happens.
assert "tar -xzf - -C /opt/furtka" in joined
# `furtka` CLI wrapper lands on the target.
assert "/usr/local/bin/furtka" in joined
# systemd unit is written and conditionally enabled.
assert "/etc/systemd/system/furtka-reconcile.service" in joined
assert "furtka-reconcile.service" 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. We should NOT
# emit untar / wrapper / unit commands, but the rest of post-install must
# still be generated normally.
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 - -C /opt/furtka" not in joined
assert "furtka-reconcile.service" in joined # still in the conditional enable line
# The base system bootstrap (caddy etc) is unaffected.
assert "/etc/caddy/Caddyfile" 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": [],
}
]