furtka/tests/test_webinstaller_assets.py
Daniel Maksymilian Syrnicki c080764c7e
All checks were successful
Build ISO / build-iso (push) Successful in 17m5s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 40s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 12s
fix(furtka): move assets/ to repo top level so Caddy + systemd find it
Root cause of today's 403 on a fresh install: assets/ lived inside the
Python package at furtka/assets/, so the resource-manager tarball
extracted to /opt/furtka/versions/<ver>/furtka/assets/. But Caddyfile
has `root * /opt/furtka/current/assets/www`, systemd units point at
/opt/furtka/current/assets/bin/furtka-status, and the install-time
`systemctl link /opt/furtka/current/assets/systemd/*.service` expected
the top-level layout. All three found nothing:

- Caddy → 403 Forbidden (empty/missing document root)
- systemctl link → silent no-op, nothing ever linked into
  /etc/systemd/system/
- furtka-api.service + furtka-reconcile.service → "inactive" because
  they were never registered

Nothing in the Python package ever imported furtka.assets — these are
shell scripts, HTML/CSS, systemd units, and a Caddyfile, which is
config data, not package data. Promoting assets/ to the repo root
matches how it's referenced everywhere downstream and eliminates the
path mismatch.

Changes:
- git mv furtka/assets assets
- iso/build.sh: tarball-staging step now also `cp -a "$REPO_ROOT/assets"`
  so the tarball ships ./assets at its root, and the live-ISO copy
  reads from $REPO_ROOT/assets instead of $REPO_ROOT/furtka/assets.
- scripts/build-release-tarball.sh: same for release tarballs.
- webinstaller/app.py: _resolve_assets_dir's dev fallback walks one
  level up to REPO_ROOT/assets/.
- tests/test_webinstaller_assets.py: ASSETS constant updated.

Tests still green (150/150) because both paths were fs-level — no
code imports changed. Next ISO build will land assets at the path
everything downstream expects.

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

141 lines
5.7 KiB
Python

"""Asset sourcing + install-flow tests for webinstaller.
Phase-2 slice 1a moved every HTML/CSS/script/unit payload out of inline
Python string constants and into real files under furtka/assets/. Slice 1b
then flipped Caddy to serve from /opt/furtka/current/assets/www/, retired
/srv/furtka/www/, and switched systemd units from hand-written files in
/etc/systemd/system/ to `systemctl link` against the shipped asset tree.
These tests lock the new contract:
- the Caddyfile and the status.json placeholder still land via
_write_file_cmd and match the on-disk asset bit-for-bit,
- the resource-manager bootstrap extracts to /opt/furtka/versions/<ver>/,
creates /opt/furtka/current, and systemctl-links every unit,
- furtka.json is written with the installer's hostname,
- no write ever targets /srv/furtka/www/ (that path is retired).
"""
import base64
import re
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "webinstaller"))
import app # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
ASSETS = REPO_ROOT / "assets"
# (install target path, asset path under furtka/assets/) — only the files we
# still copy bit-for-bit at install time. Scripts + unit files are no longer
# copied; they're reached via /opt/furtka/current and `systemctl link`.
ASSET_TARGETS = [
("/etc/caddy/Caddyfile", "Caddyfile"),
("/var/lib/furtka/status.json", "www/status.json"),
]
def _extract_written_content(cmd, target):
"""Pull the base64 payload back out of a _write_file_cmd() shell string."""
match = re.search(r"printf %s (\S+) \| base64 -d > " + re.escape(target), cmd)
assert match, f"cmd didn't look like a write of {target}: {cmd[:100]}"
return base64.b64decode(match.group(1)).decode("utf-8")
@pytest.fixture
def install_cmds(tmp_path, monkeypatch):
# Make _resource_manager_commands return a non-empty list so the full
# command tree is exercised. A fake payload file is enough since the
# test only inspects the generated shell strings.
fake = tmp_path / "payload.tar.gz"
fake.write_bytes(b"not a real tarball")
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
return app._post_install_commands("testhost")
@pytest.mark.parametrize("target,asset_relpath", ASSET_TARGETS)
def test_post_install_writes_asset_from_disk(install_cmds, target, asset_relpath):
expected = (ASSETS / asset_relpath).read_text(encoding="utf-8")
matching = [c for c in install_cmds if f" > {target}" in c]
assert matching, f"no command writes {target}"
assert _extract_written_content(matching[0], target) == expected
def test_no_command_writes_to_retired_srv_path(install_cmds):
for c in install_cmds:
assert "/srv/furtka/www" not in c, f"retired /srv/furtka/www write: {c[:120]}"
def test_resource_manager_extracts_to_versioned_slot(install_cmds):
extract_cmd = next((c for c in install_cmds if "tar -xzf" in c), None)
assert extract_cmd is not None, "no tar extract command"
assert "/opt/furtka/versions" in extract_cmd
assert "staging-" in extract_cmd # mktemp -d pattern
assert 'cat "$staging/VERSION"' in extract_cmd
# An empty VERSION file must abort the install instead of silently
# moving the staging dir into versions/ as a subdir.
assert '[ -n "$ver" ]' in extract_cmd
assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd
def test_resource_manager_systemctl_links_every_unit(install_cmds):
link_cmd = next((c for c in install_cmds if c.startswith("systemctl link ")), None)
assert link_cmd is not None, "no systemctl link command"
for unit in app._FURTKA_UNITS:
assert f"/opt/furtka/current/assets/systemd/{unit}" in link_cmd
def test_resource_manager_enables_all_units(install_cmds):
enable_cmd = next((c for c in install_cmds if c.startswith("systemctl enable ")), None)
assert enable_cmd is not None
for unit in app._FURTKA_UNITS:
assert unit in enable_cmd
def test_furtka_json_written_with_hostname(install_cmds):
furtka_json = next(
(c for c in install_cmds if "/var/lib/furtka/furtka.json" in c and "mkdir" in c),
None,
)
assert furtka_json is not None
# The base64-encoded JSON body should contain the hostname we passed in.
content = _extract_written_content(furtka_json, "/var/lib/furtka/furtka.json")
assert '"hostname": "testhost"' in content
# install_date + version placeholders — the sed below substitutes them at
# install time against /opt/furtka/current/VERSION + `date -Iseconds`.
assert "__INSTALL_DATE__" in content
assert "__VERSION__" in content
assert "date -Iseconds" in furtka_json
assert "/opt/furtka/current/VERSION" in furtka_json
def test_wrapper_script_points_at_current_symlink():
assert "PYTHONPATH=/opt/furtka/current" in app._FURTKA_WRAPPER_SH
def test_caddyfile_asset_serves_from_current():
caddy = (ASSETS / "Caddyfile").read_text()
assert "root * /opt/furtka/current/assets/www" in caddy
assert "/srv/furtka/www" not in caddy
# Runtime JSON paths served from /var/lib/furtka/ so updates don't clobber.
assert "root * /var/lib/furtka" in caddy
def test_systemd_units_reference_current_paths():
for unit in ("furtka-status.service", "furtka-welcome.service"):
body = (ASSETS / "systemd" / unit).read_text()
assert "/opt/furtka/current/assets/bin/" in body, (
f"{unit} still references a /usr/local/bin path"
)
def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html")
def test_assets_dir_resolves_to_repo_tree():
assert app._ASSETS_DIR == ASSETS