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:
Daniel Maksymilian Syrnicki 2026-04-16 13:15:59 +02:00
parent df08938d7e
commit 4569c37640
8 changed files with 258 additions and 152 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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>

View file

@ -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():

View file

@ -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:

View file

@ -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),
]