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>
This commit is contained in:
parent
df08938d7e
commit
4569c37640
8 changed files with 258 additions and 152 deletions
|
|
@ -1,7 +1,10 @@
|
|||
# Serves the Furtka landing page + status.json on :80. Static for the
|
||||
# landing page; /apps and /api are reverse-proxied to the local resource-
|
||||
# manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth come
|
||||
# later when Authentik is wired in.
|
||||
# Serves the Furtka landing page + live JSON on :80. Static pages are read
|
||||
# from the current-version directory under /opt/furtka/current/ — updates
|
||||
# flip the symlink and everything picks up the new content without a Caddy
|
||||
# restart (a `systemctl reload caddy` is still triggered post-swap to flush
|
||||
# the file-server's handle cache). /apps and /api are reverse-proxied to the
|
||||
# resource-manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth
|
||||
# come later when Authentik is wired in.
|
||||
:80 {
|
||||
handle /api/* {
|
||||
reverse_proxy localhost:7000
|
||||
|
|
@ -9,8 +12,22 @@
|
|||
handle /apps* {
|
||||
reverse_proxy localhost:7000
|
||||
}
|
||||
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
|
||||
# (which only swap /opt/furtka/current).
|
||||
handle /status.json {
|
||||
root * /var/lib/furtka
|
||||
file_server
|
||||
}
|
||||
handle /furtka.json {
|
||||
root * /var/lib/furtka
|
||||
file_server
|
||||
}
|
||||
handle /update-state.json {
|
||||
root * /var/lib/furtka
|
||||
file_server
|
||||
}
|
||||
handle {
|
||||
root * /srv/furtka/www
|
||||
root * /opt/furtka/current/assets/www
|
||||
file_server
|
||||
encode gzip
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
#!/bin/bash
|
||||
# Writes /srv/furtka/www/status.json with current system stats. Fired by
|
||||
# furtka-status.timer every 30s; also runs once 10s after boot.
|
||||
# Writes /var/lib/furtka/status.json with current system stats. Fired by
|
||||
# furtka-status.timer every 30s; also runs once 10s after boot. Path is under
|
||||
# /var/lib/ so self-updates (which swap /opt/furtka/current) don't clobber it.
|
||||
set -e
|
||||
|
||||
out=/srv/furtka/www/status.json
|
||||
out=/var/lib/furtka/status.json
|
||||
tmp=$(mktemp)
|
||||
mkdir -p /var/lib/furtka
|
||||
|
||||
hostname=$(cat /etc/hostname)
|
||||
uptime=$(uptime -p 2>/dev/null | sed 's/^up //' || echo unknown)
|
||||
|
|
@ -17,7 +19,7 @@ disk_free=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free of " $2}' || echo
|
|||
ip_primary=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1 || true)
|
||||
kernel=$(uname -r 2>/dev/null || echo unknown)
|
||||
ram_total=$(free -h --si 2>/dev/null | awk '/^Mem:/ {print $2}' || echo unknown)
|
||||
furtka_version=$(cat /opt/furtka/VERSION 2>/dev/null || echo dev)
|
||||
furtka_version=$(cat /opt/furtka/current/VERSION 2>/dev/null || echo dev)
|
||||
updated_at=$(date -Iseconds)
|
||||
|
||||
cat > "$tmp" <<EOF
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ After=network-online.target
|
|||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/furtka-status
|
||||
ExecStart=/opt/furtka/current/assets/bin/furtka-status
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Wants=network-online.target
|
|||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/furtka-welcome
|
||||
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<header>
|
||||
<h1>Welcome to Furtka</h1>
|
||||
<p class="lead">Your home server is ready.</p>
|
||||
<p class="host">Running on <code>__HOSTNAME__</code></p>
|
||||
<p class="host">Running on <code id="hostname">—</code></p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
|
|
@ -67,7 +67,11 @@
|
|||
</main>
|
||||
|
||||
<script>
|
||||
const HOSTNAME = "__HOSTNAME__";
|
||||
// Hostname + install metadata — written once at install time to
|
||||
// /var/lib/furtka/furtka.json (see _furtka_json_cmd in the installer).
|
||||
// Separate from status.json because these facts don't change between
|
||||
// refresh ticks.
|
||||
let HOSTNAME = "";
|
||||
const FALLBACK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
|
||||
|
||||
function esc(s) {
|
||||
|
|
@ -76,11 +80,22 @@
|
|||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function loadFurtkaJson() {
|
||||
try {
|
||||
const r = await fetch('/furtka.json', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const f = await r.json();
|
||||
HOSTNAME = f.hostname || "";
|
||||
const el = document.getElementById('hostname');
|
||||
if (el) el.textContent = HOSTNAME || '—';
|
||||
} catch (e) { /* no-op */ }
|
||||
}
|
||||
|
||||
function primaryAction(app) {
|
||||
// Only fileshare has a direct "open" link today. Future apps with
|
||||
// HTTP endpoints would surface a URL here; everything else falls
|
||||
// back to the /apps manage page.
|
||||
if (app.name === 'fileshare') {
|
||||
if (app.name === 'fileshare' && HOSTNAME) {
|
||||
return { href: `smb://${HOSTNAME}.local/files`, label: 'Open files' };
|
||||
}
|
||||
return { href: '/apps', label: 'Manage →' };
|
||||
|
|
@ -128,7 +143,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
renderApps();
|
||||
// furtka.json must land first so renderApps can build the SMB link
|
||||
// with the real hostname. If it 404s (very early in boot) the
|
||||
// primary-action falls back to "Manage →".
|
||||
loadFurtkaJson().then(renderApps);
|
||||
refresh();
|
||||
setInterval(refresh, 15000);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -63,13 +63,18 @@ def test_build_archinstall_config_uses_selected_locale(monkeypatch):
|
|||
assert cfg["custom_commands"][0] == "gpasswd -a u docker"
|
||||
|
||||
|
||||
def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch):
|
||||
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 + index.html written into the
|
||||
# target rootfs, and nss-mdns spliced into nsswitch.conf.
|
||||
# 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(
|
||||
|
|
@ -93,32 +98,30 @@ def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch):
|
|||
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
|
||||
# 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",
|
||||
"/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",
|
||||
"/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
|
||||
# The chosen hostname is pinned into the static HTML at install-time.
|
||||
assert "s/__HOSTNAME__/heimserver/g" 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, 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).
|
||||
# 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"
|
||||
|
|
@ -137,23 +140,26 @@ def test_resource_manager_payload_landed_when_present(monkeypatch, tmp_path):
|
|||
)
|
||||
joined = "\n".join(cfg["custom_commands"])
|
||||
|
||||
# Tarball expansion happens.
|
||||
assert "tar -xzf - -C /opt/furtka" in joined
|
||||
# `furtka` CLI wrapper lands on the target.
|
||||
# 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
|
||||
# systemd units are written and conditionally enabled.
|
||||
assert "/etc/systemd/system/furtka-reconcile.service" in joined
|
||||
assert "/etc/systemd/system/furtka-api.service" in joined
|
||||
assert "furtka-reconcile.service" in joined
|
||||
assert "furtka-api.service" 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. We should NOT
|
||||
# emit untar / wrapper / unit commands, but the rest of post-install must
|
||||
# still be generated normally.
|
||||
# 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")
|
||||
|
|
@ -170,12 +176,12 @@ def test_resource_manager_absent_without_payload(monkeypatch, tmp_path):
|
|||
)
|
||||
joined = "\n".join(cfg["custom_commands"])
|
||||
|
||||
assert "tar -xzf - -C /opt/furtka" not in joined
|
||||
# The conditional enable line still mentions the units (gated by [ -e ]).
|
||||
assert "furtka-reconcile.service" in joined
|
||||
assert "furtka-api.service" in joined
|
||||
# The base system bootstrap (caddy etc) is unaffected.
|
||||
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():
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
"""Asset sourcing tests for webinstaller.
|
||||
"""Asset sourcing + install-flow tests for webinstaller.
|
||||
|
||||
Slice 1a of the self-update refactor moved every inline HTML/CSS/script/unit
|
||||
file payload out of webinstaller/app.py and into furtka/assets/. These tests
|
||||
lock the new contract: _post_install_commands() and _resource_manager_commands()
|
||||
must write files whose content is byte-equal to the on-disk asset. If the
|
||||
asset tree drifts or a constant sneaks back in, a test breaks loudly.
|
||||
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
|
||||
|
|
@ -21,76 +29,116 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||
ASSETS = REPO_ROOT / "furtka" / "assets"
|
||||
|
||||
|
||||
# (install target path, asset path under furtka/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"),
|
||||
("/srv/furtka/www/index.html", "www/index.html"),
|
||||
("/srv/furtka/www/settings/index.html", "www/settings/index.html"),
|
||||
("/srv/furtka/www/style.css", "www/style.css"),
|
||||
("/srv/furtka/www/status.json", "www/status.json"),
|
||||
("/usr/local/bin/furtka-status", "bin/furtka-status"),
|
||||
("/usr/local/bin/furtka-welcome", "bin/furtka-welcome"),
|
||||
("/etc/systemd/system/furtka-status.service", "systemd/furtka-status.service"),
|
||||
("/etc/systemd/system/furtka-status.timer", "systemd/furtka-status.timer"),
|
||||
("/etc/systemd/system/furtka-welcome.service", "systemd/furtka-welcome.service"),
|
||||
("/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.
|
||||
|
||||
Shape: `mkdir -p <parent> && printf %s <b64> | base64 -d > <target>[ && chmod ...]`.
|
||||
"""
|
||||
"""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():
|
||||
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")
|
||||
# /srv/furtka/www/index.html carries a one-off hostname sed *after* the
|
||||
# write; the bytes written match the asset verbatim.
|
||||
if target == "/srv/furtka/www/index.html":
|
||||
assert "__HOSTNAME__" in expected # slice-1a invariant: sed still applies
|
||||
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
|
||||
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():
|
||||
# The resolved path must point into the repo's furtka/assets — sanity
|
||||
# check that the "two levels up from webinstaller/app.py" math is correct.
|
||||
assert app._ASSETS_DIR == ASSETS
|
||||
|
||||
|
||||
def test_resource_manager_commands_use_asset_units(monkeypatch, tmp_path):
|
||||
# Create a fake payload so _resource_manager_commands doesn't bail out.
|
||||
fake = tmp_path / "payload.tar.gz"
|
||||
fake.write_bytes(b"not a real tarball")
|
||||
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
|
||||
cmds = app._resource_manager_commands()
|
||||
# Expect reconcile + api unit files to be sourced from furtka/assets/systemd/.
|
||||
for unit in ("furtka-reconcile.service", "furtka-api.service"):
|
||||
expected = (ASSETS / "systemd" / unit).read_text(encoding="utf-8")
|
||||
matching = [c for c in cmds if f"/etc/systemd/system/{unit}" in c]
|
||||
assert matching, f"no command writes {unit}"
|
||||
assert _extract_written_content(matching[0], f"/etc/systemd/system/{unit}") == expected
|
||||
|
||||
|
||||
def test_version_asset_matches_pyproject():
|
||||
# Slice 1a ships a VERSION file alongside the assets; it should match the
|
||||
# single source of truth in pyproject.toml.
|
||||
import tomllib
|
||||
|
||||
with open(REPO_ROOT / "pyproject.toml", "rb") as f:
|
||||
|
|
|
|||
|
|
@ -197,9 +197,10 @@ def _read_asset(relpath: str) -> str:
|
|||
_FURTKA_WRAPPER_SH = """\
|
||||
#!/bin/sh
|
||||
# Tiny launcher for the furtka resource-manager CLI. The Python source lives
|
||||
# under /opt/furtka/furtka/ — added to PYTHONPATH so plain `python3 -m` finds
|
||||
# it without needing pip on the target system.
|
||||
PYTHONPATH=/opt/furtka exec python3 -m furtka.cli "$@"
|
||||
# under /opt/furtka/current/furtka/ — /current is a symlink that gets
|
||||
# flipped by self-updates (Phase 2), so this shim stays stable across
|
||||
# upgrades while the underlying code tree is swapped atomically.
|
||||
PYTHONPATH=/opt/furtka/current exec python3 -m furtka.cli "$@"
|
||||
"""
|
||||
|
||||
|
||||
|
|
@ -218,12 +219,23 @@ def _write_file_cmd(path, content, mode=None):
|
|||
return cmd
|
||||
|
||||
|
||||
_FURTKA_UNITS = (
|
||||
"furtka-api.service",
|
||||
"furtka-reconcile.service",
|
||||
"furtka-status.service",
|
||||
"furtka-status.timer",
|
||||
"furtka-welcome.service",
|
||||
)
|
||||
|
||||
|
||||
def _resource_manager_commands():
|
||||
"""Commands to land /opt/furtka/ + the `furtka` CLI + reconcile.service.
|
||||
"""Commands to land /opt/furtka/versions/<ver>/ + symlink /opt/furtka/current
|
||||
+ the `furtka` CLI shim + systemctl-link the unit files.
|
||||
|
||||
Reads the payload tarball staged into the live ISO at build time. If the
|
||||
file isn't present (dev box without an ISO build), returns [] so the rest
|
||||
of the install still works — the resource manager just won't be installed.
|
||||
of the install still works — the resource manager just won't be installed,
|
||||
and nothing else on the system references furtka-* units.
|
||||
"""
|
||||
if not RESOURCE_MANAGER_PAYLOAD.exists():
|
||||
print(
|
||||
|
|
@ -233,23 +245,59 @@ def _resource_manager_commands():
|
|||
)
|
||||
return []
|
||||
payload_b64 = base64.b64encode(RESOURCE_MANAGER_PAYLOAD.read_bytes()).decode()
|
||||
untar_cmd = (
|
||||
f"mkdir -p /opt/furtka && printf %s {payload_b64} | base64 -d | tar -xzf - -C /opt/furtka"
|
||||
# Extract to a staging directory first, then rename to versions/<ver>/.
|
||||
# That way the version-ID lookup is data-driven (reads VERSION from the
|
||||
# tarball) instead of hardcoded at install-time — keeps the installer
|
||||
# version-agnostic so a newer ISO doesn't need a webinstaller change to
|
||||
# ship a new Furtka version.
|
||||
extract_and_link = (
|
||||
"mkdir -p /opt/furtka/versions && "
|
||||
"staging=$(mktemp -d /opt/furtka/versions/staging-XXXXXX) && "
|
||||
f'printf %s {payload_b64} | base64 -d | tar -xzf - -C "$staging" && '
|
||||
'ver=$(cat "$staging/VERSION") && '
|
||||
'mv "$staging" "/opt/furtka/versions/$ver" && '
|
||||
'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current'
|
||||
)
|
||||
systemctl_link = "systemctl link " + " ".join(
|
||||
f"/opt/furtka/current/assets/systemd/{u}" for u in _FURTKA_UNITS
|
||||
)
|
||||
systemctl_enable = "systemctl enable " + " ".join(_FURTKA_UNITS)
|
||||
return [
|
||||
untar_cmd,
|
||||
extract_and_link,
|
||||
_write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"),
|
||||
_write_file_cmd(
|
||||
"/etc/systemd/system/furtka-reconcile.service",
|
||||
_read_asset("systemd/furtka-reconcile.service"),
|
||||
),
|
||||
_write_file_cmd(
|
||||
"/etc/systemd/system/furtka-api.service",
|
||||
_read_asset("systemd/furtka-api.service"),
|
||||
),
|
||||
systemctl_link,
|
||||
systemctl_enable,
|
||||
]
|
||||
|
||||
|
||||
def _furtka_json_cmd(hostname):
|
||||
"""Write /var/lib/furtka/furtka.json with install-time facts.
|
||||
|
||||
Replaces the __HOSTNAME__ sed pass — the landing page reads this file
|
||||
at runtime and renders the hostname chip from it. install_date + version
|
||||
ride along so the settings page can display them without hitting the
|
||||
status timer's refresh cycle.
|
||||
"""
|
||||
# Python-side JSON assembly keeps the shell command free of quote-escape
|
||||
# hazards. Hostname is validated up-front in validate_step1 so it's safe
|
||||
# to interpolate.
|
||||
body = json.dumps(
|
||||
{
|
||||
"hostname": hostname,
|
||||
"install_date": "__INSTALL_DATE__",
|
||||
"version": "__VERSION__",
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
return (
|
||||
"mkdir -p /var/lib/furtka && "
|
||||
+ _write_file_cmd("/var/lib/furtka/furtka.json", body)
|
||||
+ ' && sed -i "s/__INSTALL_DATE__/$(date -Iseconds)/;'
|
||||
's|__VERSION__|$(cat /opt/furtka/current/VERSION 2>/dev/null || echo dev)|"'
|
||||
" /var/lib/furtka/furtka.json"
|
||||
)
|
||||
|
||||
|
||||
def _post_install_commands(hostname):
|
||||
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
|
||||
# the hosts line so `*.local` works from the installed system too. Guarded
|
||||
|
|
@ -260,54 +308,21 @@ def _post_install_commands(hostname):
|
|||
"sed -i '/^hosts:/ s/resolve/mdns_minimal [NOTFOUND=return] resolve/' "
|
||||
"/etc/nsswitch.conf"
|
||||
)
|
||||
# Pin the chosen hostname into the static HTML at install-time so the
|
||||
# landing page doesn't need a server-side template.
|
||||
hostname_sed = f"sed -i 's/__HOSTNAME__/{hostname}/g' /srv/furtka/www/index.html"
|
||||
return [
|
||||
# The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention
|
||||
# (systemd unit points there). Content comes from the shipped asset,
|
||||
# which we copy in at install time so updates that change routing
|
||||
# need a new release to refresh it.
|
||||
_write_file_cmd("/etc/caddy/Caddyfile", _read_asset("Caddyfile")),
|
||||
_write_file_cmd("/srv/furtka/www/index.html", _read_asset("www/index.html")),
|
||||
_write_file_cmd(
|
||||
"/srv/furtka/www/settings/index.html",
|
||||
_read_asset("www/settings/index.html"),
|
||||
),
|
||||
_write_file_cmd("/srv/furtka/www/style.css", _read_asset("www/style.css")),
|
||||
_write_file_cmd("/srv/furtka/www/status.json", _read_asset("www/status.json")),
|
||||
_write_file_cmd(
|
||||
"/usr/local/bin/furtka-status",
|
||||
_read_asset("bin/furtka-status"),
|
||||
mode="755",
|
||||
),
|
||||
_write_file_cmd(
|
||||
"/usr/local/bin/furtka-welcome",
|
||||
_read_asset("bin/furtka-welcome"),
|
||||
mode="755",
|
||||
),
|
||||
_write_file_cmd(
|
||||
"/etc/systemd/system/furtka-status.service",
|
||||
_read_asset("systemd/furtka-status.service"),
|
||||
),
|
||||
_write_file_cmd(
|
||||
"/etc/systemd/system/furtka-status.timer",
|
||||
_read_asset("systemd/furtka-status.timer"),
|
||||
),
|
||||
_write_file_cmd(
|
||||
"/etc/systemd/system/furtka-welcome.service",
|
||||
_read_asset("systemd/furtka-welcome.service"),
|
||||
),
|
||||
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
|
||||
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
||||
nss_sed,
|
||||
hostname_sed,
|
||||
# Resource manager bootstrap: extract tarball → versions/<ver>/,
|
||||
# symlink current, install wrapper, systemctl-link unit files.
|
||||
*_resource_manager_commands(),
|
||||
# archinstall calls `systemctl enable` on `services` *before*
|
||||
# custom_commands runs, so our own unit files aren't on disk yet at
|
||||
# that point. Enable them here, after they exist. caddy /
|
||||
# avahi-daemon stay in the `services` list — those are packaged
|
||||
# units, present right after pacstrap. furtka-reconcile +
|
||||
# furtka-api are enabled only if the resource manager payload was
|
||||
# actually installed above; the conditional keeps systemctl green
|
||||
# on dev / payload-less builds.
|
||||
"systemctl enable furtka-welcome.service furtka-status.timer "
|
||||
"$([ -e /etc/systemd/system/furtka-reconcile.service ] "
|
||||
"&& echo furtka-reconcile.service furtka-api.service)",
|
||||
# furtka.json depends on /opt/furtka/current/VERSION, so it has to
|
||||
# run after the resource-manager commands.
|
||||
_furtka_json_cmd(hostname),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue