From df08938d7ec1c78e2d9553c8338928e6cef4ea97 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Thu, 16 Apr 2026 13:08:53 +0200 Subject: [PATCH] refactor(webinstaller): extract inline payload constants to furtka/assets/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1a of the self-update story. Every HTML/CSS/shell-script/systemd- unit payload that used to live as a triple-quoted string constant inside webinstaller/app.py now lives as a real file under furtka/assets/: furtka/assets/Caddyfile furtka/assets/VERSION (new — matches pyproject.toml) furtka/assets/www/{index.html, settings/index.html, style.css, status.json} furtka/assets/bin/{furtka-status, furtka-welcome} furtka/assets/systemd/furtka-{api,reconcile,status,welcome}.service furtka/assets/systemd/furtka-status.timer The installer now pulls each file from disk via _read_asset(). Byte-for- byte identical output at install time — a fresh-ISO install should land the same files in the same places with the same contents, verified by tests/test_webinstaller_assets.py which reconstructs each base64 blob and asserts equality against the on-disk asset. iso/build.sh also copies furtka/assets/ next to the webinstaller source at /opt/furtka/assets on the live ISO so _resolve_assets_dir() finds them with a "next to me" lookup. In dev the same function walks two levels up to the repo copy, so pytest works without any env vars. furtka-status.sh drops the /etc/furtka/version TODO — it now reads /opt/furtka/VERSION directly, which Slice 1b will upgrade to /opt/furtka/current/VERSION once the symlink layout lands. _FURTKA_WRAPPER_SH (the 5-line /usr/local/bin/furtka shim) stays inline; it's tiny and not asset-shaped. Co-Authored-By: Claude Opus 4.6 (1M context) --- furtka/assets/Caddyfile | 20 + furtka/assets/VERSION | 1 + furtka/assets/bin/furtka-status | 38 + furtka/assets/bin/furtka-welcome | 22 + furtka/assets/systemd/furtka-api.service | 14 + .../assets/systemd/furtka-reconcile.service | 13 + furtka/assets/systemd/furtka-status.service | 7 + furtka/assets/systemd/furtka-status.timer | 10 + furtka/assets/systemd/furtka-welcome.service | 12 + furtka/assets/www/index.html | 136 +++ furtka/assets/www/settings/index.html | 87 ++ furtka/assets/www/status.json | 11 + furtka/assets/www/style.css | 406 ++++++++ iso/build.sh | 3 + tests/test_webinstaller_assets.py | 98 ++ webinstaller/app.py | 916 ++---------------- 16 files changed, 961 insertions(+), 833 deletions(-) create mode 100644 furtka/assets/Caddyfile create mode 100644 furtka/assets/VERSION create mode 100644 furtka/assets/bin/furtka-status create mode 100644 furtka/assets/bin/furtka-welcome create mode 100644 furtka/assets/systemd/furtka-api.service create mode 100644 furtka/assets/systemd/furtka-reconcile.service create mode 100644 furtka/assets/systemd/furtka-status.service create mode 100644 furtka/assets/systemd/furtka-status.timer create mode 100644 furtka/assets/systemd/furtka-welcome.service create mode 100644 furtka/assets/www/index.html create mode 100644 furtka/assets/www/settings/index.html create mode 100644 furtka/assets/www/status.json create mode 100644 furtka/assets/www/style.css create mode 100644 tests/test_webinstaller_assets.py diff --git a/furtka/assets/Caddyfile b/furtka/assets/Caddyfile new file mode 100644 index 0000000..5162a66 --- /dev/null +++ b/furtka/assets/Caddyfile @@ -0,0 +1,20 @@ +# 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. +:80 { + handle /api/* { + reverse_proxy localhost:7000 + } + handle /apps* { + reverse_proxy localhost:7000 + } + handle { + root * /srv/furtka/www + file_server + encode gzip + } + log { + output stdout + } +} diff --git a/furtka/assets/VERSION b/furtka/assets/VERSION new file mode 100644 index 0000000..422a5b4 --- /dev/null +++ b/furtka/assets/VERSION @@ -0,0 +1 @@ +26.0-alpha diff --git a/furtka/assets/bin/furtka-status b/furtka/assets/bin/furtka-status new file mode 100644 index 0000000..5b1e1fd --- /dev/null +++ b/furtka/assets/bin/furtka-status @@ -0,0 +1,38 @@ +#!/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. +set -e + +out=/srv/furtka/www/status.json +tmp=$(mktemp) + +hostname=$(cat /etc/hostname) +uptime=$(uptime -p 2>/dev/null | sed 's/^up //' || echo unknown) +if command -v docker >/dev/null 2>&1; then + docker_version=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || echo unavailable) +else + docker_version=unavailable +fi +disk_free=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free of " $2}' || echo unknown) +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) +updated_at=$(date -Iseconds) + +cat > "$tmp" </dev/null | awk '{print $4}' | cut -d/ -f1 | head -1) + +{ + echo + echo " Furtka is ready." + echo + echo " Open in a browser on another device on your network:" + echo + echo " http://${hostname}.local (easy — try this first)" + if [ -n "$ip" ]; then + echo " http://${ip} (fallback if the first doesn't work)" + fi + echo +} > /etc/issue + +agetty --reload 2>/dev/null || true diff --git a/furtka/assets/systemd/furtka-api.service b/furtka/assets/systemd/furtka-api.service new file mode 100644 index 0000000..613e7fd --- /dev/null +++ b/furtka/assets/systemd/furtka-api.service @@ -0,0 +1,14 @@ +[Unit] +Description=Furtka resource-manager HTTP API + UI +Requires=docker.service +After=docker.service network-online.target furtka-reconcile.service +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/furtka serve --host 127.0.0.1 --port 7000 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/furtka/assets/systemd/furtka-reconcile.service b/furtka/assets/systemd/furtka-reconcile.service new file mode 100644 index 0000000..28f870d --- /dev/null +++ b/furtka/assets/systemd/furtka-reconcile.service @@ -0,0 +1,13 @@ +[Unit] +Description=Furtka app reconciler (boot-scan) +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/furtka reconcile +RemainAfterExit=no + +[Install] +WantedBy=multi-user.target diff --git a/furtka/assets/systemd/furtka-status.service b/furtka/assets/systemd/furtka-status.service new file mode 100644 index 0000000..4dfda2e --- /dev/null +++ b/furtka/assets/systemd/furtka-status.service @@ -0,0 +1,7 @@ +[Unit] +Description=Refresh Furtka system status JSON +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/furtka-status diff --git a/furtka/assets/systemd/furtka-status.timer b/furtka/assets/systemd/furtka-status.timer new file mode 100644 index 0000000..94aba7c --- /dev/null +++ b/furtka/assets/systemd/furtka-status.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Refresh Furtka system status every 30s + +[Timer] +OnBootSec=10s +OnUnitActiveSec=30s +AccuracySec=5s + +[Install] +WantedBy=timers.target diff --git a/furtka/assets/systemd/furtka-welcome.service b/furtka/assets/systemd/furtka-welcome.service new file mode 100644 index 0000000..c8c8708 --- /dev/null +++ b/furtka/assets/systemd/furtka-welcome.service @@ -0,0 +1,12 @@ +[Unit] +Description=Furtka console welcome banner +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/furtka-welcome +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/furtka/assets/www/index.html b/furtka/assets/www/index.html new file mode 100644 index 0000000..2412bf6 --- /dev/null +++ b/furtka/assets/www/index.html @@ -0,0 +1,136 @@ + + + + + Furtka + + + + +
+ +
+

Welcome to Furtka

+

Your home server is ready.

+

Running on __HOSTNAME__

+
+ +
+

Your apps

+ +
+ +
+

System status

+
+
+ Uptime + +
+
+ Docker + +
+
+ Free disk + +
+
+

Updated

+
+ +
+

Coming next

+
+

Features we're building — follow progress on furtka.org.

+ Photos + Smart home + Media streaming + Multiple boxes + Secure link + User accounts +
+
+ + +
+ + + + diff --git a/furtka/assets/www/settings/index.html b/furtka/assets/www/settings/index.html new file mode 100644 index 0000000..e592765 --- /dev/null +++ b/furtka/assets/www/settings/index.html @@ -0,0 +1,87 @@ + + + + + Settings · Furtka + + + + +
+ + +

Settings

+

What this box knows about itself.

+ +
+

About this box

+
+
+
Hostname
+
IP address
+
Furtka version
+
Kernel
+
RAM
+
Docker
+
Uptime
+
+
+
+ +
+

Appearance

+
+
+
Theme
Follows your system setting
+
Language
English
+
+
+
+ +
+

Coming next

+
+

Controls we're building — follow progress on furtka.org.

+ Reboot + Shut down + Change hostname + Backup + User accounts + Remote access +
+
+ + +
+ + + + diff --git a/furtka/assets/www/status.json b/furtka/assets/www/status.json new file mode 100644 index 0000000..e67059d --- /dev/null +++ b/furtka/assets/www/status.json @@ -0,0 +1,11 @@ +{ + "hostname": "", + "uptime": "starting…", + "docker_version": "starting…", + "disk_free": "starting…", + "ip_primary": "", + "kernel": "", + "ram_total": "", + "furtka_version": "", + "updated_at": "" +} diff --git a/furtka/assets/www/style.css b/furtka/assets/www/style.css new file mode 100644 index 0000000..3084f51 --- /dev/null +++ b/furtka/assets/www/style.css @@ -0,0 +1,406 @@ +/* Furtka on-box design system. Served by Caddy at /style.css, + consumed by the landing page AND the resource-manager /apps + page. One source of truth for tokens + components. */ + +:root { + --bg: #0f1115; + --fg: #e8eaed; + --muted: #9aa0a6; + --accent: #6ee7b7; + --accent-soft: rgba(110, 231, 183, 0.12); + --card: #1a1d24; + --card-hover: #222530; + --border: #2a2d34; + --warn: #4a3030; + --warn-fg: #fed; + --danger: #f08080; + + --r-sm: 4px; + --r-md: 8px; + --r-lg: 12px; + --r-pill: 999px; + + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3); + --ring: 0 0 0 2px var(--accent); +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f7f6f3; + --fg: #17181c; + --muted: #5e6066; + --accent: #0f8a5f; + --accent-soft: rgba(15, 138, 95, 0.12); + --card: #ffffff; + --card-hover: #f0efeb; + --border: #e3e1dc; + --warn: #fde2d3; + --warn-fg: #5a2a10; + --danger: #c03a28; + --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); + } +} + +* { box-sizing: border-box; } +body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.5; +} + +/* Shared page container — both landing and /apps wrap content in +
so sizing + padding stay consistent. */ +.wrap { max-width: 780px; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; } + +/* Top nav — persistent across pages (Jakob's Law). */ +.nav { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); + margin-bottom: 2rem; +} +.brand { + font-weight: 700; + letter-spacing: 0.02em; + color: var(--fg); + text-decoration: none; + font-size: 1.05rem; + display: inline-flex; + align-items: center; + gap: 0.55rem; +} +.brand::before { + content: ""; + width: 0.7rem; + height: 0.7rem; + background: var(--accent); + border-radius: 2px; + transform: rotate(45deg); +} +.nav-links { display: flex; gap: 0.25rem; } +.nav-links a { + color: var(--muted); + text-decoration: none; + font-size: 0.9rem; + padding: 0.35rem 0.75rem; + border-radius: var(--r-sm); +} +.nav-links a:hover { color: var(--fg); } +.nav-links a[aria-current="page"] { + color: var(--fg); + background: var(--accent-soft); +} + +/* -- Landing page ---------------------------------------------- */ +header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; } +.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; } +.host { color: var(--muted); margin: 0 0 3rem; } +.host code { + background: var(--card); + padding: 0.15rem 0.5rem; + border-radius: var(--r-sm); + color: var(--accent); +} +section h2 { + font-size: 1.1rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + margin: 2rem 0 1rem; +} +.tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 1rem; +} +.tile { + background: var(--card); + padding: 1.25rem; + border-radius: var(--r-md); + display: flex; + flex-direction: column; +} +.tile .label { + font-size: 0.8rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.tile .value { font-size: 1.25rem; margin-top: 0.5rem; } +.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; } +.soon { + background: var(--card); + padding: 1.5rem; + border-radius: var(--r-md); + margin-top: 2rem; +} +footer { + margin-top: 4rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + color: var(--muted); + font-size: 0.9rem; +} +footer a { color: var(--accent); } + +/* -- Apps page ------------------------------------------------- */ +h1 { font-size: 2rem; margin: 0; } +h2 { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + margin: 2rem 0 0.75rem; +} +.lede { color: var(--muted); margin: 0.25rem 0 1rem; } +.warn { + background: var(--warn); + padding: 1rem; + border-radius: var(--r-md); + margin: 1.5rem 0; + color: var(--warn-fg); + font-size: 0.9rem; +} +.app { + background: var(--card); + padding: 1rem; + border-radius: var(--r-md); + margin: 0.5rem 0; + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + box-shadow: var(--shadow-card); +} +.app .left { + display: flex; + align-items: center; + gap: 1rem; + min-width: 0; + flex: 1; +} +.meta { display: flex; flex-direction: column; min-width: 0; } +.name { font-weight: 600; font-size: 1.05rem; } +.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; } +.desc { + color: var(--muted); + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; +} +.buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + justify-content: flex-end; +} +button { + background: var(--accent); + border: none; + color: var(--bg); + font-weight: 600; + padding: 0.5rem 1rem; + border-radius: var(--r-sm); + cursor: pointer; + white-space: nowrap; + font-size: 0.9rem; + font-family: inherit; +} +button.secondary { + background: var(--card); + color: var(--fg); + border: 1px solid var(--border); +} +button.danger { background: var(--danger); color: #fff; } +button:disabled { opacity: 0.5; cursor: wait; } +button:focus-visible { outline: none; box-shadow: var(--ring); } +.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } +pre { + background: var(--card); + padding: 1rem; + border-radius: var(--r-md); + overflow-x: auto; + font-size: 0.85rem; + white-space: pre-wrap; + word-wrap: break-word; +} +details.log-details { + margin-top: 0.25rem; +} +details.log-details > summary { + cursor: pointer; + color: var(--muted); + font-size: 0.9rem; + padding: 0.25rem 0; + user-select: none; +} +details.log-details[open] > summary { color: var(--fg); } + +/* Modal */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: none; + align-items: flex-start; + justify-content: center; + padding: 2rem 1rem; + overflow-y: auto; + z-index: 10; +} +.modal-backdrop.open { display: flex; } +.modal { + background: var(--card); + border-radius: var(--r-md); + padding: 1.5rem; + max-width: 520px; + width: 100%; +} +.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; } +.modal .long { + color: var(--muted); + font-size: 0.9rem; + margin-bottom: 1.25rem; + white-space: pre-wrap; +} +.field { margin-bottom: 1rem; } +.field label { + display: block; + font-weight: 600; + margin-bottom: 0.25rem; + font-size: 0.95rem; +} +.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; } +.field input { + width: 100%; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 0.5rem 0.6rem; + font-size: 0.95rem; + font-family: inherit; +} +.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; } +.field .req { color: var(--danger); margin-left: 0.25rem; } +.modal .error { + background: var(--warn); + color: var(--warn-fg); + padding: 0.5rem 0.75rem; + border-radius: var(--r-sm); + margin-bottom: 1rem; + font-size: 0.9rem; + display: none; +} +.modal .error.show { display: block; } +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; +} + +/* -- Shared primitives for later slices ------------------------ */ +.chip { + display: inline-block; + background: var(--card); + color: var(--accent); + padding: 0.15rem 0.6rem; + border-radius: var(--r-pill); + font-size: 0.8rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} +.chip-muted { color: var(--muted); } + +.card { + background: var(--card); + padding: 1.25rem; + border-radius: var(--r-md); + box-shadow: var(--shadow-card); +} +.card + .card { margin-top: 1rem; } +.card h3 { margin: 0 0 0.75rem; font-size: 1.05rem; } + +.kv { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 1.25rem; + row-gap: 0.4rem; + font-size: 0.95rem; +} +.kv dt { color: var(--muted); } +.kv dd { margin: 0; color: var(--fg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + +.coming { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} +.coming a { + color: var(--muted); + text-decoration: none; + padding: 0.3rem 0.8rem; + border-radius: var(--r-pill); + border: 1px solid var(--border); + font-size: 0.85rem; +} +.coming a:hover { color: var(--fg); border-color: var(--accent); } +.coming .hint { + color: var(--muted); + font-size: 0.85rem; + width: 100%; + margin: 0 0 0.25rem; +} + +.grid-apps { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.75rem; +} +.app-tile { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; + text-decoration: none; + color: var(--fg); + transition: border-color 120ms, background 120ms; +} +.app-tile:hover { border-color: var(--accent); background: var(--card-hover); } +.app-tile .icon { + width: 40px; + height: 40px; + color: var(--accent); + display: flex; + align-items: center; + justify-content: center; +} +.app-tile .icon svg { width: 100%; height: 100%; } +.app-tile .name { font-weight: 600; font-size: 0.95rem; } +.app-tile .cta { color: var(--accent); font-size: 0.85rem; } + +/* Icon slot inside a /apps row. The app icon inherits currentColor + so a folder path rendered with fill="currentColor" picks up the + accent, while a nested using stroke="var(--accent)" still + gets the brand color. */ +.app-icon { + width: 56px; + height: 56px; + flex-shrink: 0; + background: var(--accent-soft); + border-radius: var(--r-md); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); +} +.app-icon svg { width: 36px; height: 36px; } diff --git a/iso/build.sh b/iso/build.sh index 6c81680..326dab9 100755 --- a/iso/build.sh +++ b/iso/build.sh @@ -74,6 +74,9 @@ sed -i "/--id 'archlinux'/s/menuentry \"Furtka Live Installer/menuentry \"(Recom mkdir -p "$PROFILE_WORK/airootfs/opt/furtka" cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/" +# Ship the post-install asset tree (HTML, CSS, systemd units, scripts, …) +# next to webinstaller/app.py so _resolve_assets_dir() finds it at runtime. +cp -a "$REPO_ROOT/furtka/assets" "$PROFILE_WORK/airootfs/opt/furtka/assets" rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__" # Pack the resource manager (furtka/ Python package + bundled apps/) as a diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py new file mode 100644 index 0000000..75c3292 --- /dev/null +++ b/tests/test_webinstaller_assets.py @@ -0,0 +1,98 @@ +"""Asset sourcing 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. +""" + +import base64 +import re +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "webinstaller")) +import app # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent +ASSETS = REPO_ROOT / "furtka" / "assets" + + +# (install target path, asset path under furtka/assets/) +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"), +] + + +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 ...]`. + """ + 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(): + 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_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: + version = tomllib.load(f)["project"]["version"] + assert (ASSETS / "VERSION").read_text().strip() == version diff --git a/webinstaller/app.py b/webinstaller/app.py index a1d301c..ed8a6ca 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -144,799 +144,56 @@ def build_disk_config(boot_drive): # page + live status tiles on :80, avahi advertises proksi.local, and the # console shows a welcome banner pointing at the URL. # -# Files are shipped inline (base64-encoded) rather than copied from the live -# ISO because archinstall's chroot can't see the live filesystem. Payload is -# small (~200 lines across 9 files) so this is cheaper than a tarball dance. +# Asset files (HTML, CSS, shell scripts, systemd units, Caddyfile) live in +# furtka/assets/ in the repo — at ISO build time they end up on the live ISO +# as part of the webinstaller's source tree AND inside the resource-manager +# payload tarball. The installer reads them from the live-ISO copy, base64- +# encodes them, and hands them to archinstall so the chroot recreates each +# file bit-for-bit. Updates (Phase 2) refresh the tarball, which carries the +# same assets to the target's /opt/furtka/ tree. # --------------------------------------------------------------------------- -_CADDYFILE = """\ -# 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. -:80 { -\thandle /api/* { -\t\treverse_proxy localhost:7000 -\t} -\thandle /apps* { -\t\treverse_proxy localhost:7000 -\t} -\thandle { -\t\troot * /srv/furtka/www -\t\tfile_server -\t\tencode gzip -\t} -\tlog { -\t\toutput stdout -\t} -} -""" - -_INDEX_HTML = """\ - - - - - Furtka - - - - -
- -
-

Welcome to Furtka

-

Your home server is ready.

-

Running on __HOSTNAME__

-
- -
-

Your apps

- -
- -
-

System status

-
-
- Uptime - -
-
- Docker - -
-
- Free disk - -
-
-

Updated

-
- -
-

Coming next

-
-

Features we're building — follow progress on furtka.org.

- Photos - Smart home - Media streaming - Multiple boxes - Secure link - User accounts -
-
- - -
- - - - -""" - -_STYLE_CSS = """\ -/* Furtka on-box design system. Served by Caddy at /style.css, - consumed by the landing page AND the resource-manager /apps - page. One source of truth for tokens + components. */ - -:root { - --bg: #0f1115; - --fg: #e8eaed; - --muted: #9aa0a6; - --accent: #6ee7b7; - --accent-soft: rgba(110, 231, 183, 0.12); - --card: #1a1d24; - --card-hover: #222530; - --border: #2a2d34; - --warn: #4a3030; - --warn-fg: #fed; - --danger: #f08080; - - --r-sm: 4px; - --r-md: 8px; - --r-lg: 12px; - --r-pill: 999px; - - --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3); - --ring: 0 0 0 2px var(--accent); -} - -@media (prefers-color-scheme: light) { - :root { - --bg: #f7f6f3; - --fg: #17181c; - --muted: #5e6066; - --accent: #0f8a5f; - --accent-soft: rgba(15, 138, 95, 0.12); - --card: #ffffff; - --card-hover: #f0efeb; - --border: #e3e1dc; - --warn: #fde2d3; - --warn-fg: #5a2a10; - --danger: #c03a28; - --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08); - } -} - -* { box-sizing: border-box; } -body { - margin: 0; - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - background: var(--bg); - color: var(--fg); - line-height: 1.5; -} - -/* Shared page container — both landing and /apps wrap content in -
so sizing + padding stay consistent. */ -.wrap { max-width: 780px; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; } - -/* Top nav — persistent across pages (Jakob's Law). */ -.nav { - display: flex; - align-items: center; - justify-content: space-between; - padding-bottom: 1.25rem; - border-bottom: 1px solid var(--border); - margin-bottom: 2rem; -} -.brand { - font-weight: 700; - letter-spacing: 0.02em; - color: var(--fg); - text-decoration: none; - font-size: 1.05rem; - display: inline-flex; - align-items: center; - gap: 0.55rem; -} -.brand::before { - content: ""; - width: 0.7rem; - height: 0.7rem; - background: var(--accent); - border-radius: 2px; - transform: rotate(45deg); -} -.nav-links { display: flex; gap: 0.25rem; } -.nav-links a { - color: var(--muted); - text-decoration: none; - font-size: 0.9rem; - padding: 0.35rem 0.75rem; - border-radius: var(--r-sm); -} -.nav-links a:hover { color: var(--fg); } -.nav-links a[aria-current="page"] { - color: var(--fg); - background: var(--accent-soft); -} - -/* -- Landing page ---------------------------------------------- */ -header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; } -.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; } -.host { color: var(--muted); margin: 0 0 3rem; } -.host code { - background: var(--card); - padding: 0.15rem 0.5rem; - border-radius: var(--r-sm); - color: var(--accent); -} -section h2 { - font-size: 1.1rem; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--muted); - margin: 2rem 0 1rem; -} -.tiles { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 1rem; -} -.tile { - background: var(--card); - padding: 1.25rem; - border-radius: var(--r-md); - display: flex; - flex-direction: column; -} -.tile .label { - font-size: 0.8rem; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.08em; -} -.tile .value { font-size: 1.25rem; margin-top: 0.5rem; } -.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; } -.soon { - background: var(--card); - padding: 1.5rem; - border-radius: var(--r-md); - margin-top: 2rem; -} -footer { - margin-top: 4rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border); - color: var(--muted); - font-size: 0.9rem; -} -footer a { color: var(--accent); } - -/* -- Apps page ------------------------------------------------- */ -h1 { font-size: 2rem; margin: 0; } -h2 { - font-size: 1rem; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--muted); - margin: 2rem 0 0.75rem; -} -.lede { color: var(--muted); margin: 0.25rem 0 1rem; } -.warn { - background: var(--warn); - padding: 1rem; - border-radius: var(--r-md); - margin: 1.5rem 0; - color: var(--warn-fg); - font-size: 0.9rem; -} -.app { - background: var(--card); - padding: 1rem; - border-radius: var(--r-md); - margin: 0.5rem 0; - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - box-shadow: var(--shadow-card); -} -.app .left { - display: flex; - align-items: center; - gap: 1rem; - min-width: 0; - flex: 1; -} -.meta { display: flex; flex-direction: column; min-width: 0; } -.name { font-weight: 600; font-size: 1.05rem; } -.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; } -.desc { - color: var(--muted); - font-size: 0.9rem; - overflow: hidden; - text-overflow: ellipsis; -} -.buttons { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - justify-content: flex-end; -} -button { - background: var(--accent); - border: none; - color: var(--bg); - font-weight: 600; - padding: 0.5rem 1rem; - border-radius: var(--r-sm); - cursor: pointer; - white-space: nowrap; - font-size: 0.9rem; - font-family: inherit; -} -button.secondary { - background: var(--card); - color: var(--fg); - border: 1px solid var(--border); -} -button.danger { background: var(--danger); color: #fff; } -button:disabled { opacity: 0.5; cursor: wait; } -button:focus-visible { outline: none; box-shadow: var(--ring); } -.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; } -pre { - background: var(--card); - padding: 1rem; - border-radius: var(--r-md); - overflow-x: auto; - font-size: 0.85rem; - white-space: pre-wrap; - word-wrap: break-word; -} -details.log-details { - margin-top: 0.25rem; -} -details.log-details > summary { - cursor: pointer; - color: var(--muted); - font-size: 0.9rem; - padding: 0.25rem 0; - user-select: none; -} -details.log-details[open] > summary { color: var(--fg); } - -/* Modal */ -.modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.6); - display: none; - align-items: flex-start; - justify-content: center; - padding: 2rem 1rem; - overflow-y: auto; - z-index: 10; -} -.modal-backdrop.open { display: flex; } -.modal { - background: var(--card); - border-radius: var(--r-md); - padding: 1.5rem; - max-width: 520px; - width: 100%; -} -.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; } -.modal .long { - color: var(--muted); - font-size: 0.9rem; - margin-bottom: 1.25rem; - white-space: pre-wrap; -} -.field { margin-bottom: 1rem; } -.field label { - display: block; - font-weight: 600; - margin-bottom: 0.25rem; - font-size: 0.95rem; -} -.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; } -.field input { - width: 100%; - background: var(--bg); - color: var(--fg); - border: 1px solid var(--border); - border-radius: var(--r-sm); - padding: 0.5rem 0.6rem; - font-size: 0.95rem; - font-family: inherit; -} -.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; } -.field .req { color: var(--danger); margin-left: 0.25rem; } -.modal .error { - background: var(--warn); - color: var(--warn-fg); - padding: 0.5rem 0.75rem; - border-radius: var(--r-sm); - margin-bottom: 1rem; - font-size: 0.9rem; - display: none; -} -.modal .error.show { display: block; } -.modal-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; - margin-top: 0.5rem; -} - -/* -- Shared primitives for later slices ------------------------ */ -.chip { - display: inline-block; - background: var(--card); - color: var(--accent); - padding: 0.15rem 0.6rem; - border-radius: var(--r-pill); - font-size: 0.8rem; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; -} -.chip-muted { color: var(--muted); } - -.card { - background: var(--card); - padding: 1.25rem; - border-radius: var(--r-md); - box-shadow: var(--shadow-card); -} -.card + .card { margin-top: 1rem; } -.card h3 { margin: 0 0 0.75rem; font-size: 1.05rem; } - -.kv { - display: grid; - grid-template-columns: max-content 1fr; - column-gap: 1.25rem; - row-gap: 0.4rem; - font-size: 0.95rem; -} -.kv dt { color: var(--muted); } -.kv dd { margin: 0; color: var(--fg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } - -.coming { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.5rem; -} -.coming a { - color: var(--muted); - text-decoration: none; - padding: 0.3rem 0.8rem; - border-radius: var(--r-pill); - border: 1px solid var(--border); - font-size: 0.85rem; -} -.coming a:hover { color: var(--fg); border-color: var(--accent); } -.coming .hint { - color: var(--muted); - font-size: 0.85rem; - width: 100%; - margin: 0 0 0.25rem; -} - -.grid-apps { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 0.75rem; -} -.app-tile { - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--r-md); - padding: 1rem; - display: flex; - flex-direction: column; - gap: 0.5rem; - align-items: flex-start; - text-decoration: none; - color: var(--fg); - transition: border-color 120ms, background 120ms; -} -.app-tile:hover { border-color: var(--accent); background: var(--card-hover); } -.app-tile .icon { - width: 40px; - height: 40px; - color: var(--accent); - display: flex; - align-items: center; - justify-content: center; -} -.app-tile .icon svg { width: 100%; height: 100%; } -.app-tile .name { font-weight: 600; font-size: 0.95rem; } -.app-tile .cta { color: var(--accent); font-size: 0.85rem; } - -/* Icon slot inside a /apps row. The app icon inherits currentColor - so a folder path rendered with fill="currentColor" picks up the - accent, while a nested using stroke="var(--accent)" still - gets the brand color. */ -.app-icon { - width: 56px; - height: 56px; - flex-shrink: 0; - background: var(--accent-soft); - border-radius: var(--r-md); - display: flex; - align-items: center; - justify-content: center; - color: var(--accent); -} -.app-icon svg { width: 36px; height: 36px; } -""" - -_SETTINGS_HTML = """\ - - - - - Settings · Furtka - - - - -
- - -

Settings

-

What this box knows about itself.

- -
-

About this box

-
-
-
Hostname
-
IP address
-
Furtka version
-
Kernel
-
RAM
-
Docker
-
Uptime
-
-
-
- -
-

Appearance

-
-
-
Theme
Follows your system setting
-
Language
English
-
-
-
- -
-

Coming next

-
-

Controls we're building — follow progress on furtka.org.

- Reboot - Shut down - Change hostname - Backup - User accounts - Remote access -
-
- - -
- - - - -""" - -_STATUS_JSON_PLACEHOLDER = """\ -{ - "hostname": "", - "uptime": "starting…", - "docker_version": "starting…", - "disk_free": "starting…", - "ip_primary": "", - "kernel": "", - "ram_total": "", - "furtka_version": "", - "updated_at": "" -} -""" - -_FURTKA_STATUS_SH = """\ -#!/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. -set -e - -out=/srv/furtka/www/status.json -tmp=$(mktemp) - -hostname=$(cat /etc/hostname) -uptime=$(uptime -p 2>/dev/null | sed 's/^up //' || echo unknown) -if command -v docker >/dev/null 2>&1; then - docker_version=$(docker --version 2>/dev/null \ - | awk '{print $3}' | tr -d ',' || echo unavailable) -else - docker_version=unavailable -fi -disk_free=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free of " $2}' || echo unknown) -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) -# TODO(furtka-version): wire into the installer so /etc/furtka/version -# gets pinned at install time. Until then the settings page shows "dev". -furtka_version=$(cat /etc/furtka/version 2>/dev/null || echo dev) -updated_at=$(date -Iseconds) - -cat > "$tmp" </dev/null | awk '{print $4}' | cut -d/ -f1 | head -1) - -{ - echo - echo " Furtka is ready." - echo - echo " Open in a browser on another device on your network:" - echo - echo " http://${hostname}.local (easy — try this first)" - if [ -n "$ip" ]; then - echo " http://${ip} (fallback if the first doesn't work)" - fi - echo -} > /etc/issue - -agetty --reload 2>/dev/null || true -""" - -_FURTKA_WELCOME_SERVICE = """\ -[Unit] -Description=Furtka console welcome banner -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/furtka-welcome -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target -""" - # Tarball built by iso/build.sh containing the furtka/ Python package + the -# bundled apps/ tree. The webinstaller reads it from the live ISO at -# request-time and base64-encodes it into a custom_command for archinstall. +# bundled apps/ tree (plus furtka/assets/). The webinstaller reads it from the +# live ISO at request-time and base64-encodes it into a custom_command for +# archinstall. RESOURCE_MANAGER_PAYLOAD = Path("/opt/furtka-resource-manager.tar.gz") + +# Asset root. Two layouts we have to handle: +# dev / tests — webinstaller/app.py sits at repo_root/webinstaller/ and +# assets live at repo_root/furtka/assets/. +# live ISO — iso/build.sh copies webinstaller/ to /opt/furtka/ AND +# copies furtka/assets/ to /opt/furtka/assets/ right next to +# app.py, so the same "assets next to me" lookup works. +# Probe the sibling path first (ISO case), fall back to the repo layout. +def _resolve_assets_dir() -> Path: + here = Path(__file__).resolve().parent + sibling = here / "assets" + if sibling.is_dir(): + return sibling + repo_copy = here.parent / "furtka" / "assets" + if repo_copy.is_dir(): + return repo_copy + raise FileNotFoundError( + f"furtka assets not found near {here} — looked in {sibling} and {repo_copy}" + ) + + +_ASSETS_DIR = _resolve_assets_dir() + + +def _read_asset(relpath: str) -> str: + """Return the UTF-8 contents of an on-disk asset shipped under furtka/assets/. + + Raises FileNotFoundError if the asset is missing, which is loud by design: + an install that tries to write an asset that isn't there is broken before + the user ever boots the target, not after. + """ + path = _ASSETS_DIR / relpath + return path.read_text(encoding="utf-8") + + _FURTKA_WRAPPER_SH = """\ #!/bin/sh # Tiny launcher for the furtka resource-manager CLI. The Python source lives @@ -945,39 +202,6 @@ _FURTKA_WRAPPER_SH = """\ PYTHONPATH=/opt/furtka exec python3 -m furtka.cli "$@" """ -_FURTKA_RECONCILE_SERVICE = """\ -[Unit] -Description=Furtka app reconciler (boot-scan) -Requires=docker.service -After=docker.service network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -ExecStart=/usr/local/bin/furtka reconcile -RemainAfterExit=no - -[Install] -WantedBy=multi-user.target -""" - -_FURTKA_API_SERVICE = """\ -[Unit] -Description=Furtka resource-manager HTTP API + UI -Requires=docker.service -After=docker.service network-online.target furtka-reconcile.service -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/local/bin/furtka serve --host 127.0.0.1 --port 7000 -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target -""" - def _write_file_cmd(path, content, mode=None): """Shell command that recreates `path` with `content` inside the chroot. @@ -1015,8 +239,14 @@ def _resource_manager_commands(): return [ untar_cmd, _write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"), - _write_file_cmd("/etc/systemd/system/furtka-reconcile.service", _FURTKA_RECONCILE_SERVICE), - _write_file_cmd("/etc/systemd/system/furtka-api.service", _FURTKA_API_SERVICE), + _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"), + ), ] @@ -1034,16 +264,36 @@ def _post_install_commands(hostname): # landing page doesn't need a server-side template. hostname_sed = f"sed -i 's/__HOSTNAME__/{hostname}/g' /srv/furtka/www/index.html" return [ - _write_file_cmd("/etc/caddy/Caddyfile", _CADDYFILE), - _write_file_cmd("/srv/furtka/www/index.html", _INDEX_HTML), - _write_file_cmd("/srv/furtka/www/settings/index.html", _SETTINGS_HTML), - _write_file_cmd("/srv/furtka/www/style.css", _STYLE_CSS), - _write_file_cmd("/srv/furtka/www/status.json", _STATUS_JSON_PLACEHOLDER), - _write_file_cmd("/usr/local/bin/furtka-status", _FURTKA_STATUS_SH, mode="755"), - _write_file_cmd("/usr/local/bin/furtka-welcome", _FURTKA_WELCOME_SH, mode="755"), - _write_file_cmd("/etc/systemd/system/furtka-status.service", _FURTKA_STATUS_SERVICE), - _write_file_cmd("/etc/systemd/system/furtka-status.timer", _FURTKA_STATUS_TIMER), - _write_file_cmd("/etc/systemd/system/furtka-welcome.service", _FURTKA_WELCOME_SERVICE), + _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"), + ), nss_sed, hostname_sed, *_resource_manager_commands(),