feat(furtka): ship resource manager + fileshare app on the ISO — slice 3

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) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-15 10:06:01 +02:00
parent 7b96a25f5b
commit 9f4e514d8a
7 changed files with 179 additions and 2 deletions

View file

@ -0,0 +1,2 @@
SMB_USER=furtka
SMB_PASSWORD=changeme

View file

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

4
apps/fileshare/icon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" 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"/>
<path d="M8 13h8M8 17h5"/>
</svg>

After

Width:  |  Height:  |  Size: 314 B

View file

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

View file

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

View file

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

View file

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