feat: webinstaller writes archinstall config + execs install, styled
Wires the live-ISO wizard from "shows three screens" to "actually invokes
archinstall on the chosen disk", plus first-pass styling so it stops looking
like raw <h1>/<form>.
Webinstaller flow:
- S1 form gains username/password/password2/language with server-side
validation (hostname/username regex, ≥8 char password, match check).
- /install/run writes user_configuration.json + user_credentials.json
(creds 0600) to FURTKA_STATE_DIR (default /tmp/furtka), then execs
`archinstall --config … --creds … --silent` as a backgrounded subprocess.
- /install/log renders the subprocess output via meta-refresh polling.
- FURTKA_DRY_RUN=1 short-circuits the exec for testing.
- archinstall flag names verified against `archinstall --help` in an
archlinux container before committing.
Drive list:
- drives.py now filters via `lsblk … -o NAME,SIZE,TYPE` keeping TYPE=disk,
so the live ISO's own squashfs (loop) and CD-ROM (rom) stop appearing
as install targets.
Boot menu:
- iso/build.sh sed-rebrands "Arch Linux install medium" →
"Furtka Live Installer" across grub/, syslinux/, and efiboot/loader/
entries. Verified zero leftovers against the current releng profile.
Styling:
- static/style.css adopts the website's design tokens (palette,
typography, gate-mark accent), with light + dark via prefers-color-scheme.
- New base.html with header (gate SVG + FURTKA·INSTALLER wordmark + step
indicator) and footer; all install templates extend it.
- Drive picker uses radio cards with score chip; overview uses a summary
table and a destructive "wipe drive" button.
Tests: 17 pass (4 new in test_app.py covering validation + config builders,
2 new in test_drives.py covering the lsblk filter). Ruff clean.
README roadmap updated to mark these done and explicitly defer the
26.0-alpha release until archinstall actually completes end-to-end on a VM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:54:49 +02:00
|
|
|
from app import (
|
|
|
|
|
build_archinstall_config,
|
|
|
|
|
build_archinstall_creds,
|
|
|
|
|
validate_step1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_step1_accepts_good_input():
|
|
|
|
|
errors, values = validate_step1(
|
|
|
|
|
{
|
|
|
|
|
"hostname": "furtka",
|
|
|
|
|
"username": "daniel",
|
|
|
|
|
"password": "topsecretpw",
|
|
|
|
|
"password2": "topsecretpw",
|
|
|
|
|
"language": "de",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
assert errors == []
|
|
|
|
|
assert values == {
|
|
|
|
|
"hostname": "furtka",
|
|
|
|
|
"username": "daniel",
|
|
|
|
|
"password": "topsecretpw",
|
|
|
|
|
"language": "de",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_step1_collects_all_errors():
|
|
|
|
|
errors, _ = validate_step1(
|
|
|
|
|
{
|
|
|
|
|
"hostname": "BAD!",
|
|
|
|
|
"username": "1bad",
|
|
|
|
|
"password": "short",
|
|
|
|
|
"password2": "mismatch",
|
|
|
|
|
"language": "xx",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
assert len(errors) == 5
|
|
|
|
|
|
|
|
|
|
|
2026-04-14 18:29:42 +02:00
|
|
|
def test_build_archinstall_config_uses_selected_locale(monkeypatch):
|
|
|
|
|
# build_disk_config imports archinstall lazily; archinstall isn't
|
|
|
|
|
# installed in CI (only runs on the live ISO), so stub it out.
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"stubbed_device": d})
|
|
|
|
|
|
feat: webinstaller writes archinstall config + execs install, styled
Wires the live-ISO wizard from "shows three screens" to "actually invokes
archinstall on the chosen disk", plus first-pass styling so it stops looking
like raw <h1>/<form>.
Webinstaller flow:
- S1 form gains username/password/password2/language with server-side
validation (hostname/username regex, ≥8 char password, match check).
- /install/run writes user_configuration.json + user_credentials.json
(creds 0600) to FURTKA_STATE_DIR (default /tmp/furtka), then execs
`archinstall --config … --creds … --silent` as a backgrounded subprocess.
- /install/log renders the subprocess output via meta-refresh polling.
- FURTKA_DRY_RUN=1 short-circuits the exec for testing.
- archinstall flag names verified against `archinstall --help` in an
archlinux container before committing.
Drive list:
- drives.py now filters via `lsblk … -o NAME,SIZE,TYPE` keeping TYPE=disk,
so the live ISO's own squashfs (loop) and CD-ROM (rom) stop appearing
as install targets.
Boot menu:
- iso/build.sh sed-rebrands "Arch Linux install medium" →
"Furtka Live Installer" across grub/, syslinux/, and efiboot/loader/
entries. Verified zero leftovers against the current releng profile.
Styling:
- static/style.css adopts the website's design tokens (palette,
typography, gate-mark accent), with light + dark via prefers-color-scheme.
- New base.html with header (gate SVG + FURTKA·INSTALLER wordmark + step
indicator) and footer; all install templates extend it.
- Drive picker uses radio cards with score chip; overview uses a summary
table and a destructive "wipe drive" button.
Tests: 17 pass (4 new in test_app.py covering validation + config builders,
2 new in test_drives.py covering the lsblk filter). Ruff clean.
README roadmap updated to mark these done and explicitly defer the
26.0-alpha release until archinstall actually completes end-to-end on a VM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:54:49 +02:00
|
|
|
cfg = build_archinstall_config(
|
|
|
|
|
{
|
|
|
|
|
"hostname": "h",
|
|
|
|
|
"username": "u",
|
|
|
|
|
"password": "pw12345678",
|
|
|
|
|
"language": "pl",
|
|
|
|
|
"boot_drive": "/dev/sda",
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-04-14 18:29:42 +02:00
|
|
|
assert cfg["disk_config"] == {"stubbed_device": "/dev/sda"}
|
feat: webinstaller writes archinstall config + execs install, styled
Wires the live-ISO wizard from "shows three screens" to "actually invokes
archinstall on the chosen disk", plus first-pass styling so it stops looking
like raw <h1>/<form>.
Webinstaller flow:
- S1 form gains username/password/password2/language with server-side
validation (hostname/username regex, ≥8 char password, match check).
- /install/run writes user_configuration.json + user_credentials.json
(creds 0600) to FURTKA_STATE_DIR (default /tmp/furtka), then execs
`archinstall --config … --creds … --silent` as a backgrounded subprocess.
- /install/log renders the subprocess output via meta-refresh polling.
- FURTKA_DRY_RUN=1 short-circuits the exec for testing.
- archinstall flag names verified against `archinstall --help` in an
archlinux container before committing.
Drive list:
- drives.py now filters via `lsblk … -o NAME,SIZE,TYPE` keeping TYPE=disk,
so the live ISO's own squashfs (loop) and CD-ROM (rom) stop appearing
as install targets.
Boot menu:
- iso/build.sh sed-rebrands "Arch Linux install medium" →
"Furtka Live Installer" across grub/, syslinux/, and efiboot/loader/
entries. Verified zero leftovers against the current releng profile.
Styling:
- static/style.css adopts the website's design tokens (palette,
typography, gate-mark accent), with light + dark via prefers-color-scheme.
- New base.html with header (gate SVG + FURTKA·INSTALLER wordmark + step
indicator) and footer; all install templates extend it.
- Drive picker uses radio cards with score chip; overview uses a summary
table and a destructive "wipe drive" button.
Tests: 17 pass (4 new in test_app.py covering validation + config builders,
2 new in test_drives.py covering the lsblk filter). Ruff clean.
README roadmap updated to mark these done and explicitly defer the
26.0-alpha release until archinstall actually completes end-to-end on a VM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:54:49 +02:00
|
|
|
assert cfg["hostname"] == "h"
|
|
|
|
|
assert cfg["locale_config"]["locale"] == "pl_PL.UTF-8"
|
2026-04-14 18:29:42 +02:00
|
|
|
# Users moved out of config into creds once we adopted archinstall 4.x's
|
|
|
|
|
# `!password` sentinel; config only carries a gpasswd in custom_commands
|
|
|
|
|
# so the user lands in the docker group after docker is pacstrapped.
|
|
|
|
|
assert "users" not in cfg
|
2026-04-14 19:51:50 +02:00
|
|
|
assert cfg["custom_commands"][0] == "gpasswd -a u docker"
|
|
|
|
|
|
|
|
|
|
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch, tmp_path):
|
2026-04-14 19:51:50 +02:00
|
|
|
# The installed system should come up with a Furtka landing page at
|
|
|
|
|
# http://<hostname>.local. That means caddy + avahi pacstrapped, the
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# 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.
|
2026-04-14 19:51:50 +02:00
|
|
|
import app as app_module
|
|
|
|
|
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# 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)
|
2026-04-14 19:51:50 +02:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for pkg in ("caddy", "avahi", "nss-mdns"):
|
|
|
|
|
assert pkg in cfg["packages"]
|
2026-04-14 20:34:34 +02:00
|
|
|
# Packaged units go in `services` (enabled before custom_commands runs);
|
|
|
|
|
# our own units don't exist at that point, so they must be enabled from
|
|
|
|
|
# within custom_commands after the unit files land on disk.
|
|
|
|
|
for svc in ("caddy", "avahi-daemon"):
|
2026-04-14 19:51:50 +02:00
|
|
|
assert svc in cfg["services"]
|
2026-04-14 20:34:34 +02:00
|
|
|
assert "furtka-welcome" not in cfg["services"]
|
|
|
|
|
assert "furtka-status.timer" not in cfg["services"]
|
2026-04-14 19:51:50 +02:00
|
|
|
|
|
|
|
|
joined = "\n".join(cfg["custom_commands"])
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# 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
|
2026-04-14 19:51:50 +02:00
|
|
|
for path in (
|
|
|
|
|
"/etc/caddy/Caddyfile",
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
"/var/lib/furtka/status.json",
|
|
|
|
|
"/var/lib/furtka/furtka.json",
|
2026-04-14 19:51:50 +02:00
|
|
|
):
|
|
|
|
|
assert path in joined, f"expected {path} to be written by custom_commands"
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# /srv/furtka/www/ is retired — no writes should target it anymore.
|
|
|
|
|
assert "/srv/furtka/www" not in joined
|
2026-04-14 19:51:50 +02:00
|
|
|
|
|
|
|
|
assert "mdns_minimal" in joined
|
|
|
|
|
assert "nsswitch.conf" in joined
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# Hostname is injected via /var/lib/furtka/furtka.json, not via sed.
|
|
|
|
|
assert "__HOSTNAME__" not in joined
|
feat: webinstaller writes archinstall config + execs install, styled
Wires the live-ISO wizard from "shows three screens" to "actually invokes
archinstall on the chosen disk", plus first-pass styling so it stops looking
like raw <h1>/<form>.
Webinstaller flow:
- S1 form gains username/password/password2/language with server-side
validation (hostname/username regex, ≥8 char password, match check).
- /install/run writes user_configuration.json + user_credentials.json
(creds 0600) to FURTKA_STATE_DIR (default /tmp/furtka), then execs
`archinstall --config … --creds … --silent` as a backgrounded subprocess.
- /install/log renders the subprocess output via meta-refresh polling.
- FURTKA_DRY_RUN=1 short-circuits the exec for testing.
- archinstall flag names verified against `archinstall --help` in an
archlinux container before committing.
Drive list:
- drives.py now filters via `lsblk … -o NAME,SIZE,TYPE` keeping TYPE=disk,
so the live ISO's own squashfs (loop) and CD-ROM (rom) stop appearing
as install targets.
Boot menu:
- iso/build.sh sed-rebrands "Arch Linux install medium" →
"Furtka Live Installer" across grub/, syslinux/, and efiboot/loader/
entries. Verified zero leftovers against the current releng profile.
Styling:
- static/style.css adopts the website's design tokens (palette,
typography, gate-mark accent), with light + dark via prefers-color-scheme.
- New base.html with header (gate SVG + FURTKA·INSTALLER wordmark + step
indicator) and footer; all install templates extend it.
- Drive picker uses radio cards with score chip; overview uses a summary
table and a destructive "wipe drive" button.
Tests: 17 pass (4 new in test_app.py covering validation + config builders,
2 new in test_drives.py covering the lsblk filter). Ruff clean.
README roadmap updated to mark these done and explicitly defer the
26.0-alpha release until archinstall actually completes end-to-end on a VM.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:54:49 +02:00
|
|
|
|
|
|
|
|
|
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>
2026-04-15 10:06:01 +02:00
|
|
|
def test_resource_manager_payload_landed_when_present(monkeypatch, tmp_path):
|
|
|
|
|
# When iso/build.sh has staged the resource-manager tarball, the
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# post-install commands should untar it into /opt/furtka/versions/<ver>/,
|
|
|
|
|
# flip the /opt/furtka/current symlink, drop the `furtka` wrapper, and
|
|
|
|
|
# systemctl-link every Furtka unit file.
|
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>
2026-04-15 10:06:01 +02:00
|
|
|
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"])
|
|
|
|
|
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# 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.
|
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>
2026-04-15 10:06:01 +02:00
|
|
|
assert "/usr/local/bin/furtka" in joined
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
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
|
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>
2026-04-15 10:06:01 +02:00
|
|
|
# python is pacstrapped so the wrapper has an interpreter.
|
|
|
|
|
assert "python" in cfg["packages"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resource_manager_absent_without_payload(monkeypatch, tmp_path):
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
# 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.
|
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>
2026-04-15 10:06:01 +02:00
|
|
|
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"])
|
|
|
|
|
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
assert "tar -xzf" not in joined
|
|
|
|
|
assert "systemctl link" not in joined
|
|
|
|
|
# The base system bootstrap (caddy, status.json placeholder, furtka.json)
|
|
|
|
|
# is unaffected.
|
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>
2026-04-15 10:06:01 +02:00
|
|
|
assert "/etc/caddy/Caddyfile" in joined
|
feat(furtka): serve from /opt/furtka/current, retire /srv/furtka/www/
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/<ver>/,
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) <noreply@anthropic.com>
2026-04-16 13:15:59 +02:00
|
|
|
assert "/var/lib/furtka/furtka.json" in joined
|
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>
2026-04-15 10:06:01 +02:00
|
|
|
|
|
|
|
|
|
2026-04-14 18:29:42 +02:00
|
|
|
def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
|
|
|
|
|
creds = build_archinstall_creds({"username": "u", "password": "pw12345678"})
|
|
|
|
|
assert creds["!root-password"] == "pw12345678"
|
|
|
|
|
assert creds["users"] == [
|
|
|
|
|
{
|
|
|
|
|
"username": "u",
|
|
|
|
|
"!password": "pw12345678",
|
|
|
|
|
"sudo": True,
|
|
|
|
|
"groups": [],
|
|
|
|
|
}
|
|
|
|
|
]
|
2026-08-25 16:24:59 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_log_page_done_state_shows_usb_step_before_restart(monkeypatch, tmp_path):
|
|
|
|
|
"""The 'pull the USB stick' step must be visible *before* the restart
|
|
|
|
|
button — after the click the box reboots within seconds (first hardware
|
|
|
|
|
bench booted the stick again)."""
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
log_path = tmp_path / "install.log"
|
|
|
|
|
log_path.write_text("Installation completed without any errors.\n")
|
|
|
|
|
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
|
|
|
|
|
app_module.settings["hostname"] = "bench"
|
|
|
|
|
|
|
|
|
|
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
|
|
|
|
|
|
|
|
|
|
assert '<ol class="next-steps">' in html
|
|
|
|
|
assert "http://bench.local" in html
|
|
|
|
|
assert "USB stick is out" in html
|
|
|
|
|
assert html.index("next-steps") < html.index("USB stick is out")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_log_page_running_state_has_no_restart_button(monkeypatch, tmp_path):
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
log_path = tmp_path / "install.log"
|
|
|
|
|
log_path.write_text("Creating partitions: /dev/sda\n")
|
|
|
|
|
monkeypatch.setattr(app_module, "INSTALL_LOG", log_path)
|
|
|
|
|
|
|
|
|
|
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
|
|
|
|
|
assert "next-steps" not in html
|
|
|
|
|
assert "install_reboot" not in html and "restart now" not in html
|
2026-09-28 12:53:24 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_nonzero_exit_without_traceback_is_an_error():
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
# OOM kill / signal: archinstall dies mid-pacstrap and prints nothing.
|
|
|
|
|
log = "Installing packages: ['base'\n"
|
|
|
|
|
assert app_module.parse_install_progress(log)["status"] == "running"
|
|
|
|
|
assert app_module.parse_install_progress(log, exit_code=-9)["status"] == "error"
|
|
|
|
|
assert app_module.parse_install_progress(log, exit_code=0)["status"] == "running"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeProc:
|
|
|
|
|
def __init__(self, code=None):
|
|
|
|
|
self.code = code
|
|
|
|
|
|
|
|
|
|
def poll(self):
|
|
|
|
|
return self.code
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _install_env(monkeypatch, tmp_path):
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path)
|
|
|
|
|
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
|
|
|
|
|
monkeypatch.setattr(app_module, "_install_proc", None)
|
|
|
|
|
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
|
|
|
|
|
monkeypatch.setitem(app_module.settings, "boot_drive", "/dev/sda")
|
|
|
|
|
monkeypatch.setitem(app_module.settings, "username", "u")
|
|
|
|
|
monkeypatch.setitem(app_module.settings, "password", "pw12345678")
|
|
|
|
|
monkeypatch.setattr(app_module, "release_disk", lambda d: [f"released {d}"])
|
|
|
|
|
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"device": d})
|
|
|
|
|
return app_module
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_run_refuses_second_concurrent_install(monkeypatch, tmp_path):
|
|
|
|
|
app_module = _install_env(monkeypatch, tmp_path)
|
|
|
|
|
spawned = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
app_module, "spawn_archinstall", lambda *a: spawned.append(a) or _FakeProc()
|
|
|
|
|
)
|
|
|
|
|
client = app_module.app.test_client()
|
|
|
|
|
assert client.post("/install/run").status_code == 302
|
|
|
|
|
assert client.post("/install/run").status_code == 302
|
|
|
|
|
assert len(spawned) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_run_disk_probe_failure_lands_in_log(monkeypatch, tmp_path):
|
|
|
|
|
app_module = _install_env(monkeypatch, tmp_path)
|
|
|
|
|
|
|
|
|
|
def boom(_):
|
|
|
|
|
raise RuntimeError("parted could not read /dev/sda")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(app_module, "build_disk_config", boom)
|
|
|
|
|
resp = app_module.app.test_client().post("/install/run")
|
|
|
|
|
assert resp.status_code == 302
|
|
|
|
|
log = (tmp_path / "install.log").read_text()
|
|
|
|
|
assert "Traceback (most recent call last)" in log
|
|
|
|
|
assert app_module.parse_install_progress(log)["status"] == "error"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_log_json_reports_crashed_archinstall(monkeypatch, tmp_path):
|
|
|
|
|
app_module = _install_env(monkeypatch, tmp_path)
|
|
|
|
|
(tmp_path / "install.log").write_text("Installing packages: ['base'\n")
|
|
|
|
|
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=137))
|
|
|
|
|
data = app_module.app.test_client().get("/install/log.json").get_json()
|
|
|
|
|
assert data["progress"]["status"] == "error"
|
|
|
|
|
assert "exit code 137" in data["log"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_step2_rejects_disk_not_offered(monkeypatch):
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
offered = [
|
|
|
|
|
{
|
|
|
|
|
"name": "/dev/sda",
|
|
|
|
|
"usable": True,
|
|
|
|
|
"size": "500 GB",
|
|
|
|
|
"type_label": "SSD",
|
|
|
|
|
"health_label": "Healthy",
|
|
|
|
|
"score": 27,
|
|
|
|
|
"reason": "",
|
|
|
|
|
"warning": "",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "/dev/sdb",
|
|
|
|
|
"usable": False,
|
|
|
|
|
"size": "8 GB",
|
|
|
|
|
"type_label": "USB",
|
|
|
|
|
"health_label": "Status unknown",
|
|
|
|
|
"score": 10,
|
|
|
|
|
"reason": "Too small",
|
|
|
|
|
"warning": "",
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
monkeypatch.setattr(app_module, "list_scored_devices", lambda: offered)
|
|
|
|
|
monkeypatch.setitem(app_module.settings, "boot_drive", "")
|
|
|
|
|
client = app_module.app.test_client()
|
|
|
|
|
for bad in ("/dev/sdb", "/dev/nope", "/etc/passwd"):
|
|
|
|
|
resp = client.post("/install/step2", data={"boot_drive": bad})
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert app_module.settings["boot_drive"] == ""
|
|
|
|
|
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
|
|
|
|
|
assert resp.status_code == 302
|
|
|
|
|
assert app_module.settings["boot_drive"] == "/dev/sda"
|
feat(installer): friendlier wizard, retry after a failed install, no boot stall on multi-NIC boards
- error state gets a 'Try again' button (settings are kept) plus links back
to the disk / settings pages; the page reloads into it instead of only
recolouring the bar
- names are lowercased instead of rejected; system account names (root,
caddy, docker, …) are refused with a clear message
- plain wording: 'Server name' with a live http://<name>.local preview,
'Pick a disk' with an explicit erase warning, a readable confirm table
(language label, disk size/type) and a Back button
- the password hint says it is also the SSH / console password; the
language hint says the wizard itself is English
- wizard pages redirect to the progress page while an install is running
- progress page: aria-live phase, aria-valuenow updates, 'lost connection'
note, 'fine to close this page'
- .local fallback on the reboot page: router device list, IP may change
- live console banner says a network cable with internet is required
- target gets a systemd-networkd-wait-online --any drop-in: boards with a
second unplugged NIC (or Ethernet + Wi-Fi) waited up to 2 min at boot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
2026-09-28 13:21:55 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_step1_lowercases_names():
|
|
|
|
|
errors, values = validate_step1(
|
|
|
|
|
{
|
|
|
|
|
"hostname": "Furtka",
|
|
|
|
|
"username": "Daniel",
|
|
|
|
|
"password": "topsecretpw",
|
|
|
|
|
"password2": "topsecretpw",
|
|
|
|
|
"language": "de",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
assert errors == []
|
|
|
|
|
assert values["hostname"] == "furtka"
|
|
|
|
|
assert values["username"] == "daniel"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_validate_step1_rejects_reserved_usernames():
|
|
|
|
|
for name in ("root", "caddy", "docker", "nobody"):
|
|
|
|
|
errors, _ = validate_step1(
|
|
|
|
|
{
|
|
|
|
|
"hostname": "furtka",
|
|
|
|
|
"username": name,
|
|
|
|
|
"password": "topsecretpw",
|
|
|
|
|
"password2": "topsecretpw",
|
|
|
|
|
"language": "en",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
assert len(errors) == 1 and "reserved" in errors[0], name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_post_install_sets_wait_online_any(monkeypatch):
|
|
|
|
|
import base64
|
|
|
|
|
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
cmds = app_module._post_install_commands("h", "u", "pw12345678")
|
|
|
|
|
hits = [c for c in cmds if "systemd-networkd-wait-online.service.d" in c]
|
|
|
|
|
assert len(hits) == 1
|
|
|
|
|
payload = hits[0].split("printf %s ")[1].split(" |")[0]
|
|
|
|
|
assert "--any" in base64.b64decode(payload).decode()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_wizard_pages_redirect_to_progress_while_installing(monkeypatch, tmp_path):
|
|
|
|
|
app_module = _install_env(monkeypatch, tmp_path)
|
|
|
|
|
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=None))
|
|
|
|
|
client = app_module.app.test_client()
|
|
|
|
|
for path in ("/install/step1", "/install/step2", "/install/overview"):
|
|
|
|
|
resp = client.get(path)
|
|
|
|
|
assert resp.status_code == 302
|
|
|
|
|
assert resp.headers["Location"].endswith("/install/log")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_error_page_offers_retry(monkeypatch, tmp_path):
|
|
|
|
|
app_module = _install_env(monkeypatch, tmp_path)
|
|
|
|
|
(tmp_path / "install.log").write_text("Traceback (most recent call last)\nboom\n")
|
|
|
|
|
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
|
|
|
|
|
assert "Try again" in html
|
|
|
|
|
assert "next-steps" not in html
|
2026-09-28 14:21:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detect_bootloader_by_firmware(tmp_path):
|
|
|
|
|
import app as app_module
|
|
|
|
|
|
|
|
|
|
efi = tmp_path / "efi"
|
|
|
|
|
assert app_module._detect_bootloader(efi) == "Grub" # BIOS / legacy
|
|
|
|
|
efi.mkdir()
|
|
|
|
|
assert app_module._detect_bootloader(efi) == "Systemd-boot" # no bitness file
|
|
|
|
|
(efi / "fw_platform_size").write_text("64\n")
|
|
|
|
|
assert app_module._detect_bootloader(efi) == "Systemd-boot"
|
|
|
|
|
(efi / "fw_platform_size").write_text("32\n")
|
|
|
|
|
assert app_module._detect_bootloader(efi) == "Limine"
|