From 9f4e514d8a2d908f0b588f28bfe4846c81969ba4 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Wed, 15 Apr 2026 10:06:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(furtka):=20ship=20resource=20manager=20+?= =?UTF-8?q?=20fileshare=20app=20on=20the=20ISO=20=E2=80=94=20slice=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the loop end-to-end. The ISO build now bundles the furtka/ package and the apps/ tree as a tarball; webinstaller hands it to archinstall via custom_commands; the installed system gets the `furtka` CLI, a boot-scan systemd unit, and the fileshare app ready to install. - iso/build.sh: stages furtka/ + apps/ into a tmpdir, drops __pycache__, tarballs into airootfs/opt/furtka-resource-manager.tar.gz. - webinstaller/app.py: _resource_manager_commands() reads the staged payload at request-time, base64-encodes it into a single untar command, and writes /usr/local/bin/furtka (PYTHONPATH wrapper, no pip needed) + furtka-reconcile.service. Python pacstrapped so the wrapper has an interpreter. - Graceful degradation: dev box / CI without an ISO build has no payload tarball, so those commands are skipped (logs a warning). Tests cover both branches. - furtka-reconcile.service is conditionally enabled only if the unit file actually landed — keeps the systemctl enable line green when the payload was absent. - apps/fileshare/: first real Furtka app. dperson/samba on host network, single named volume, .env.example with placeholder creds. Manifest matches the schema locked in slice 1. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/fileshare/.env.example | 2 + apps/fileshare/docker-compose.yaml | 26 ++++++++++++ apps/fileshare/icon.svg | 4 ++ apps/fileshare/manifest.json | 9 ++++ iso/build.sh | 13 ++++++ tests/test_app.py | 61 +++++++++++++++++++++++++++ webinstaller/app.py | 66 +++++++++++++++++++++++++++++- 7 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 apps/fileshare/.env.example create mode 100644 apps/fileshare/docker-compose.yaml create mode 100644 apps/fileshare/icon.svg create mode 100644 apps/fileshare/manifest.json diff --git a/apps/fileshare/.env.example b/apps/fileshare/.env.example new file mode 100644 index 0000000..4189cbb --- /dev/null +++ b/apps/fileshare/.env.example @@ -0,0 +1,2 @@ +SMB_USER=furtka +SMB_PASSWORD=changeme diff --git a/apps/fileshare/docker-compose.yaml b/apps/fileshare/docker-compose.yaml new file mode 100644 index 0000000..54c2a34 --- /dev/null +++ b/apps/fileshare/docker-compose.yaml @@ -0,0 +1,26 @@ +# Furtka fileshare — SMB share via dperson/samba. +# +# The volume `furtka_fileshare_files` is created by the Furtka reconciler +# from the manifest's "volumes" list before this compose file is brought up; +# it's referenced as `external: true` here so docker compose doesn't try +# to manage its lifecycle. + +services: + smbd: + image: dperson/samba:latest + restart: unless-stopped + network_mode: host + environment: + - USERID=1000 + - GROUPID=1000 + - TZ=Europe/Berlin + command: > + -u "${SMB_USER};${SMB_PASSWORD}" + -s "files;/mount;yes;no;no;${SMB_USER}" + -p + volumes: + - furtka_fileshare_files:/mount + +volumes: + furtka_fileshare_files: + external: true diff --git a/apps/fileshare/icon.svg b/apps/fileshare/icon.svg new file mode 100644 index 0000000..ded4152 --- /dev/null +++ b/apps/fileshare/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/fileshare/manifest.json b/apps/fileshare/manifest.json new file mode 100644 index 0000000..ae37601 --- /dev/null +++ b/apps/fileshare/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fileshare", + "display_name": "Network Files", + "version": "0.1.0", + "description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.", + "volumes": ["files"], + "ports": [445, 139], + "icon": "icon.svg" +} diff --git a/iso/build.sh b/iso/build.sh index 79b34a9..6c81680 100755 --- a/iso/build.sh +++ b/iso/build.sh @@ -76,6 +76,19 @@ mkdir -p "$PROFILE_WORK/airootfs/opt/furtka" cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/" rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__" +# Pack the resource manager (furtka/ Python package + bundled apps/) as a +# tarball that webinstaller hands to archinstall via custom_commands. Lives at +# a fixed path in the live ISO; the installed system reads it back, untars +# into /opt/furtka/, and gets a working `furtka` CLI + the fileshare app. +echo "==> Bundling resource manager payload" +PAYLOAD_STAGE="$(mktemp -d)" +cp -a "$REPO_ROOT/furtka" "$PAYLOAD_STAGE/" +cp -a "$REPO_ROOT/apps" "$PAYLOAD_STAGE/" +find "$PAYLOAD_STAGE" -type d -name __pycache__ -exec rm -rf {} + +tar -czf "$PROFILE_WORK/airootfs/opt/furtka-resource-manager.tar.gz" \ + -C "$PAYLOAD_STAGE" . +rm -rf "$PAYLOAD_STAGE" + mkdir -p "$PROFILE_WORK/airootfs/etc/systemd/system/avahi-daemon.service.d" ln -sf /usr/lib/systemd/system/avahi-daemon.service \ "$PROFILE_WORK/airootfs/etc/systemd/system/multi-user.target.wants/avahi-daemon.service" diff --git a/tests/test_app.py b/tests/test_app.py index 948ec2e..fd88cb2 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -113,6 +113,67 @@ def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch): assert "s/__HOSTNAME__/heimserver/g" 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). + import app as app_module + + fake_payload = tmp_path / "furtka-resource-manager.tar.gz" + fake_payload.write_bytes(b"\x1f\x8b\x08\x00fake-tarball-bytes") + 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( + { + "hostname": "heimserver", + "username": "u", + "password": "pw12345678", + "language": "en", + "boot_drive": "/dev/sda", + } + ) + joined = "\n".join(cfg["custom_commands"]) + + # Tarball expansion happens. + assert "tar -xzf - -C /opt/furtka" in joined + # `furtka` CLI wrapper lands on the target. + assert "/usr/local/bin/furtka" in joined + # systemd unit is written and conditionally enabled. + assert "/etc/systemd/system/furtka-reconcile.service" in joined + assert "furtka-reconcile.service" 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. + import app as app_module + + monkeypatch.setattr(app_module, "RESOURCE_MANAGER_PAYLOAD", tmp_path / "does-not-exist.tar.gz") + monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"stubbed_device": d}) + + cfg = build_archinstall_config( + { + "hostname": "heimserver", + "username": "u", + "password": "pw12345678", + "language": "en", + "boot_drive": "/dev/sda", + } + ) + joined = "\n".join(cfg["custom_commands"]) + + assert "tar -xzf - -C /opt/furtka" not in joined + assert "furtka-reconcile.service" in joined # still in the conditional enable line + # The base system bootstrap (caddy etc) is unaffected. + assert "/etc/caddy/Caddyfile" in joined + + def test_build_archinstall_creds_uses_archinstall_sentinel_keys(): creds = build_archinstall_creds({"username": "u", "password": "pw12345678"}) assert creds["!root-password"] == "pw12345678" diff --git a/webinstaller/app.py b/webinstaller/app.py index b6d8ad6..59651c6 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -3,6 +3,7 @@ import json import os import re import subprocess +import sys from pathlib import Path from drives import list_scored_devices @@ -400,6 +401,35 @@ RemainAfterExit=yes 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. +RESOURCE_MANAGER_PAYLOAD = Path("/opt/furtka-resource-manager.tar.gz") + +_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 "$@" +""" + +_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 +""" + def _write_file_cmd(path, content, mode=None): """Shell command that recreates `path` with `content` inside the chroot. @@ -416,6 +446,31 @@ def _write_file_cmd(path, content, mode=None): return cmd +def _resource_manager_commands(): + """Commands to land /opt/furtka/ + the `furtka` CLI + reconcile.service. + + 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. + """ + if not RESOURCE_MANAGER_PAYLOAD.exists(): + print( + f"warning: {RESOURCE_MANAGER_PAYLOAD} missing, " + "resource manager will NOT be installed on target", + file=sys.stderr, + ) + 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" + ) + 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), + ] + + 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 @@ -441,12 +496,16 @@ def _post_install_commands(hostname): _write_file_cmd("/etc/systemd/system/furtka-welcome.service", _FURTKA_WELCOME_SERVICE), nss_sed, hostname_sed, + *_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. - "systemctl enable furtka-welcome.service furtka-status.timer", + # units, present right after pacstrap. furtka-reconcile is enabled + # only if the resource manager payload was actually installed above. + "systemctl enable furtka-welcome.service furtka-status.timer " + "$([ -e /etc/systemd/system/furtka-reconcile.service ] " + "&& echo furtka-reconcile.service)", ] @@ -476,6 +535,9 @@ def build_archinstall_config(s): "caddy", "avahi", "nss-mdns", + # Resource manager runtime — pure-stdlib Python, no pip needed + # because we expose the package via PYTHONPATH in /usr/local/bin/furtka. + "python", ], "profile": {"type": "server"}, "services": [