From 4569c376400582504c90b492488192c6647e7cf1 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Thu, 16 Apr 2026 13:15:59 +0200 Subject: [PATCH] feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//, 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) --- furtka/assets/Caddyfile | 27 +++- furtka/assets/bin/furtka-status | 10 +- furtka/assets/systemd/furtka-status.service | 2 +- furtka/assets/systemd/furtka-welcome.service | 2 +- furtka/assets/www/index.html | 26 +++- tests/test_app.py | 74 +++++----- tests/test_webinstaller_assets.py | 134 ++++++++++++------ webinstaller/app.py | 135 ++++++++++--------- 8 files changed, 258 insertions(+), 152 deletions(-) diff --git a/furtka/assets/Caddyfile b/furtka/assets/Caddyfile index 5162a66..7896a37 100644 --- a/furtka/assets/Caddyfile +++ b/furtka/assets/Caddyfile @@ -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 } diff --git a/furtka/assets/bin/furtka-status b/furtka/assets/bin/furtka-status index 5b1e1fd..af5fcb0 100644 --- a/furtka/assets/bin/furtka-status +++ b/furtka/assets/bin/furtka-status @@ -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" <

Welcome to Furtka

Your home server is ready.

-

Running on __HOSTNAME__

+

Running on

@@ -67,7 +67,11 @@ diff --git a/tests/test_app.py b/tests/test_app.py index 863093d..ca301e3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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://.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//, + # 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(): diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index 75c3292..3520879 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -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//, + 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 && printf %s | base64 -d > [ && 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: diff --git a/webinstaller/app.py b/webinstaller/app.py index ed8a6ca..38ab52f 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -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// + 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//. + # 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//, + # 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), ]