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:
parent
7b96a25f5b
commit
9f4e514d8a
7 changed files with 179 additions and 2 deletions
2
apps/fileshare/.env.example
Normal file
2
apps/fileshare/.env.example
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
SMB_USER=furtka
|
||||||
|
SMB_PASSWORD=changeme
|
||||||
26
apps/fileshare/docker-compose.yaml
Normal file
26
apps/fileshare/docker-compose.yaml
Normal 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
4
apps/fileshare/icon.svg
Normal 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 |
9
apps/fileshare/manifest.json
Normal file
9
apps/fileshare/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
13
iso/build.sh
13
iso/build.sh
|
|
@ -76,6 +76,19 @@ mkdir -p "$PROFILE_WORK/airootfs/opt/furtka"
|
||||||
cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/"
|
cp -a "$REPO_ROOT/webinstaller/." "$PROFILE_WORK/airootfs/opt/furtka/"
|
||||||
rm -rf "$PROFILE_WORK/airootfs/opt/furtka/__pycache__"
|
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"
|
mkdir -p "$PROFILE_WORK/airootfs/etc/systemd/system/avahi-daemon.service.d"
|
||||||
ln -sf /usr/lib/systemd/system/avahi-daemon.service \
|
ln -sf /usr/lib/systemd/system/avahi-daemon.service \
|
||||||
"$PROFILE_WORK/airootfs/etc/systemd/system/multi-user.target.wants/avahi-daemon.service"
|
"$PROFILE_WORK/airootfs/etc/systemd/system/multi-user.target.wants/avahi-daemon.service"
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,67 @@ def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch):
|
||||||
assert "s/__HOSTNAME__/heimserver/g" in joined
|
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():
|
def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
|
||||||
creds = build_archinstall_creds({"username": "u", "password": "pw12345678"})
|
creds = build_archinstall_creds({"username": "u", "password": "pw12345678"})
|
||||||
assert creds["!root-password"] == "pw12345678"
|
assert creds["!root-password"] == "pw12345678"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from drives import list_scored_devices
|
from drives import list_scored_devices
|
||||||
|
|
@ -400,6 +401,35 @@ RemainAfterExit=yes
|
||||||
WantedBy=multi-user.target
|
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):
|
def _write_file_cmd(path, content, mode=None):
|
||||||
"""Shell command that recreates `path` with `content` inside the chroot.
|
"""Shell command that recreates `path` with `content` inside the chroot.
|
||||||
|
|
@ -416,6 +446,31 @@ def _write_file_cmd(path, content, mode=None):
|
||||||
return cmd
|
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):
|
def _post_install_commands(hostname):
|
||||||
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
|
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
|
||||||
# the hosts line so `*.local` works from the installed system too. Guarded
|
# 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),
|
_write_file_cmd("/etc/systemd/system/furtka-welcome.service", _FURTKA_WELCOME_SERVICE),
|
||||||
nss_sed,
|
nss_sed,
|
||||||
hostname_sed,
|
hostname_sed,
|
||||||
|
*_resource_manager_commands(),
|
||||||
# archinstall calls `systemctl enable` on `services` *before*
|
# archinstall calls `systemctl enable` on `services` *before*
|
||||||
# custom_commands runs, so our own unit files aren't on disk yet at
|
# custom_commands runs, so our own unit files aren't on disk yet at
|
||||||
# that point. Enable them here, after they exist. caddy /
|
# that point. Enable them here, after they exist. caddy /
|
||||||
# avahi-daemon stay in the `services` list — those are packaged
|
# avahi-daemon stay in the `services` list — those are packaged
|
||||||
# units, present right after pacstrap.
|
# units, present right after pacstrap. furtka-reconcile is enabled
|
||||||
"systemctl enable furtka-welcome.service furtka-status.timer",
|
# 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",
|
"caddy",
|
||||||
"avahi",
|
"avahi",
|
||||||
"nss-mdns",
|
"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"},
|
"profile": {"type": "server"},
|
||||||
"services": [
|
"services": [
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue