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