Compare commits

..

No commits in common. "8e281fa4a64d519fa8d2ddd6b6dcd976f6da0810" and "e1968facef6320d9297918036faacb0505c9915d" have entirely different histories.

26 changed files with 77 additions and 606 deletions

View file

@ -7,52 +7,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
## [26.20-alpha] - 2026-08-25
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
SSD with a previous Proxmox VE on it). All four fixes are things an empty
Proxmox VM never shows.
### Fixed
- **First catalog sync no longer dies silently at boot.** On dual-stack
LANs `network-online.target` is satisfied by the IPv6 SLAAC address a
second before DHCPv4 lands, so `furtka-catalog-sync.service` ran into
`Network is unreachable`, exited, and the next attempt was the daily timer
— up to 6 h of jitter later. Meanwhile `catalog-state.json` stayed on
`checking` and the apps page showed "checking…" with nothing running
(first hardware bench, 2026-08-25). The service now retries on failure
(every 20 s, up to 8 times in 15 min), a failed sync is recorded as stage
`error` with the message, and the apps page shows "last sync failed: …"
instead of a fake in-progress state.
- **Installer frees the target disk before partitioning.** The live ISO
auto-activates whatever an attached drive carries (LVM volume groups, md
arrays, swap) — the first hardware bench's SSD still had a Proxmox VE
`pve` VG on it — and archinstall then failed at 12 % with `unable to
inform the kernel of the change, probably because it/they are in use`.
`install_run` now runs `diskprep.release_disk()` first: swapoff/umount
leaves-first, `dmsetup remove` / `mdadm --stop` for every dm/md node
stacked on the disk, `wipefs -a` on partitions + disk, then
`blockdev --rereadpt`. Every step is written to the top of the install
log. The progress page also names this failure explicitly instead of a
bare "hit a snag" if it does still happen.
- **Console banner always shows the IP fallback.** On both the live ISO and
the installed system the `/etc/issue` welcome was written once, after
`network-online.target`, and simply omitted the `http://<ip>` line when no
address existed yet — so on a slow-DHCP NIC (first hardware bench,
2026-08-24) the screen only ever offered `proksi.local`. The banner script
now runs from a timer (`furtka-issue.timer` on the ISO,
`furtka-welcome.timer` on the installed box, every 5 s) and always prints
a fallback line: every global IPv4 address once there is one, or a
"no IP address yet — check the cable / DHCP" hint until then. It only
rewrites `/etc/issue` and nudges agetty when the text changed, so the
login prompt doesn't flicker. The live ISO no longer auto-logs `root` in
on tty1 (releng default) — that handed the console to a shell before the
banner could update; tty1 now stays at the login prompt with a live
banner, and the banner tells you a shell is `root` + Enter away. Boxes
that self-update get the new timer linked + enabled automatically; the
refresh loop takes effect after their next reboot.
## [26.19-alpha] - 2026-08-23 ## [26.19-alpha] - 2026-08-23
### Fixed ### Fixed
@ -561,8 +515,7 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose - **Containers:** Docker + Compose
- **License:** AGPL-3.0 - **License:** AGPL-3.0
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.20-alpha...HEAD [Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.19-alpha...HEAD
[26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha
[26.19-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.19-alpha [26.19-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.19-alpha
[26.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha [26.18-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.18-alpha
[26.17-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.17-alpha [26.17-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.17-alpha

View file

@ -1,36 +1,22 @@
#!/bin/bash #!/bin/bash
# Regenerates /etc/issue on the installed system so the console tells the # Regenerates /etc/issue on the installed system so the console tells the
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern: # user which URL to open. Mirrors the live-ISO furtka-update-issue pattern.
# runs at boot and every few seconds from furtka-welcome.timer, so the IP set -e
# fallback line is always on screen — with the address once DHCP handed one
# out, or with a "no IP yet" hint before that / after the cable is pulled.
# Only rewrites + nudges agetty when the text changed (no prompt flicker).
set -u
issue=/etc/issue hostname=$(cat /etc/hostname)
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
hostname=$(cat /etc/hostname 2>/dev/null || hostname) {
mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1)
new=$(
echo echo
echo " Furtka is ready." echo " Furtka is ready."
echo echo
echo " Open in a browser on another device on your network:" echo " Open in a browser on another device on your network:"
echo echo
echo " http://${hostname}.local (easy — try this first)" echo " http://${hostname}.local (easy — try this first)"
if ((${#ips[@]})); then if [ -n "$ip" ]; then
for ip in "${ips[@]}"; do
echo " http://${ip} (fallback if the first doesn't work)" echo " http://${ip} (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi fi
echo echo
) } > /etc/issue
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then agetty --reload 2>/dev/null || true
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

@ -2,20 +2,11 @@
Description=Furtka apps catalog sync Description=Furtka apps catalog sync
Requires=network-online.target Requires=network-online.target
After=network-online.target After=network-online.target
# network-online.target is satisfied by *any* routable address. On dual-stack
# LANs that is usually the IPv6 SLAAC address, which lands a second or so
# before DHCPv4 — and the first sync then dies with "Network is unreachable"
# (first hardware bench, 2026-08-25). Retry a few times instead of waiting
# for the daily timer.
StartLimitIntervalSec=15min
StartLimitBurst=8
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/furtka catalog sync ExecStart=/usr/local/bin/furtka catalog sync
TimeoutStartSec=5min TimeoutStartSec=5min
Restart=on-failure
RestartSec=20s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Furtka console welcome banner Description=Furtka console welcome banner
# No network-online dependency on purpose: paint the banner (with a After=network-online.target
# "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs Wants=network-online.target
# it every few seconds so the address appears as soon as DHCP delivers.
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,10 +0,0 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

View file

@ -490,11 +490,7 @@ async function refreshCatalog() {
const updatedAt = (status.state || {}).updated_at || ''; const updatedAt = (status.state || {}).updated_at || '';
document.getElementById('catalog-last-sync').textContent = updatedAt || 'never'; document.getElementById('catalog-last-sync').textContent = updatedAt || 'never';
const stageEl = document.getElementById('catalog-stage'); const stageEl = document.getElementById('catalog-stage');
if (stage === 'error') { if (stage && stage !== 'done') {
const err = (status.state || {}).error || 'unknown error';
stageEl.textContent = '· last sync failed: ' + err;
stageEl.classList.add('pending');
} else if (stage && stage !== 'done') {
stageEl.textContent = '· ' + stage + '…'; stageEl.textContent = '· ' + stage + '…';
stageEl.classList.add('pending'); stageEl.classList.add('pending');
} else { } else {

View file

@ -204,21 +204,8 @@ def _atomic_swap(staging: Path) -> None:
def sync_catalog() -> CatalogCheck: def sync_catalog() -> CatalogCheck:
"""End-to-end sync. Acquires the lock, writes state at each stage, and """End-to-end sync. Acquires the lock, writes state at each stage, and
leaves the live catalog untouched on any failure before the rename step. leaves the live catalog untouched on any failure before the rename step.
A failure lands as stage ``"error"`` (with the message) instead of leaving
the last in-flight stage behind: the first hardware bench sat on
``"checking"`` for hours after a boot-time network failure, and the UI
showed "checking…" with nothing actually happening.
""" """
with acquire_lock(): with acquire_lock():
try:
return _sync_locked()
except CatalogError as e:
write_state("error", error=str(e))
raise
def _sync_locked() -> CatalogCheck:
write_state("checking") write_state("checking")
check = check_catalog() check = check_catalog()
if not check.update_available: if not check.update_available:

View file

@ -27,15 +27,13 @@ The build starts from Arch's stock `releng` profile (the same one used to build
| `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time | | `overlay/airootfs/opt/furtka/` | Directory where `webinstaller/` is copied at build time |
| `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` | | `overlay/airootfs/etc/hostname` | Live-ISO hostname (`proksi`) so mDNS advertises the installer as `proksi.local` |
| `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` | | `overlay/airootfs/etc/issue` | Welcome banner on the TTY pointing users at `http://proksi.local:5000` |
| `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner always carries a fallback line: the DHCP-assigned IP(s), or a "no IP yet — check cable / DHCP" hint until one arrives. Only touches the file (and `agetty --reload`s) when the text changed | | `overlay/airootfs/usr/local/bin/furtka-update-issue` | Rewrites `/etc/issue` at runtime so the banner also shows the DHCP-assigned IP as a fallback URL |
| `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (banner-updater, first run at boot) in `multi-user.target.wants/`, and `furtka-issue.timer` (re-runs the updater every 5 s) in `timers.target.wants/` | | `overlay/airootfs/etc/systemd/system/` | `furtka-webinstaller.service` (Flask on :5000) + `furtka-issue.service` (runs the banner-updater on network-online), each symlinked into `multi-user.target.wants/` to auto-start on boot |
The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN. The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under `/opt/furtka`. The `0.0.0.0` binding is important — the Flask default is localhost-only, which wouldn't be reachable from another machine on the LAN.
mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all. mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all.
`build.sh` also deletes releng's `getty@tty1.service.d/autologin.conf`. With root auto-logged-in, tty1 belongs to a shell and `agetty --reload` can't redraw the banner, so the IP line was a snapshot of whatever DHCP had done by the time getty started. Without autologin tty1 sits at the login prompt showing the live banner; for a shell type `root` + Enter (releng's root has no password) — the banner says so.
## Test flow ## Test flow
1. Build: `./iso/build.sh` 1. Build: `./iso/build.sh`

View file

@ -45,15 +45,6 @@ cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh"
cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/" cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/"
# releng auto-logs root in on tty1. That turns our /etc/issue banner into a
# one-shot snapshot: once agetty has handed tty1 to a shell, `agetty --reload`
# from furtka-update-issue has nothing left to redraw, so the IP fallback only
# shows if DHCP happened to beat getty. Drop the drop-in: tty1 stays at the
# login prompt with a live-refreshed banner (furtka-issue.timer), and a shell
# is one `root` + Enter away (releng's root has no password) — the banner says
# so.
rm -rf "$PROFILE_WORK/airootfs/etc/systemd/system/getty@tty1.service.d"
echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)" echo "==> Rebranding boot menu (GRUB + syslinux + systemd-boot)"
# releng ships menu entries labelled "Arch Linux install medium" across three # releng ships menu entries labelled "Arch Linux install medium" across three
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to # bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to

View file

@ -2,6 +2,5 @@
Furtka Live Installer starting… Furtka Live Installer starting…
Once ready, open http://proksi.local:5000 on another device Once ready, open http://proksi.local:5000 on another device
on your network. The exact URL (and an IP fallback) will on your network. The exact URL will appear below.
appear here in a few seconds.

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Write Furtka /etc/issue with current IP for the console welcome Description=Write Furtka /etc/issue with current IP for the console welcome
# No network-online dependency on purpose: the first run should paint the After=network-online.target
# banner (with a "no IP yet" fallback) as early as possible; the timer Wants=network-online.target
# re-runs it until an address shows up.
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/bin/furtka-update-issue ExecStart=/usr/local/bin/furtka-update-issue
RemainAfterExit=yes
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,10 +0,0 @@
[Unit]
Description=Keep the Furtka console welcome (IP fallback) up to date
[Timer]
OnBootSec=3s
OnUnitActiveSec=5s
AccuracySec=1s
[Install]
WantedBy=timers.target

View file

@ -2,40 +2,22 @@
# Regenerates /etc/issue so the live-ISO console tells the user which URL # Regenerates /etc/issue so the live-ISO console tells the user which URL
# to open in their browser. Shows proksi.local (via avahi/mDNS) as the # to open in their browser. Shows proksi.local (via avahi/mDNS) as the
# preferred URL and the raw IP as a fallback for networks where mDNS # preferred URL and the raw IP as a fallback for networks where mDNS
# doesn't work. # doesn't work. Reload at the end nudges agetty to redraw.
# set -e
# Runs at boot and then every few seconds from furtka-issue.timer, so the
# fallback line is *always* on screen: with the address once DHCP has
# handed one out, or with a "no IP yet" hint before that / when the cable
# is unplugged. Only rewrites + nudges agetty when the text actually
# changed, so the login prompt doesn't flicker on every tick.
set -u
issue=/etc/issue ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
mapfile -t ips < <(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1) {
new=$(
echo echo
echo " Open Furtka in a browser on another device on your network:" echo " Open Furtka in a browser on another device on your network:"
echo echo
echo " http://proksi.local:5000 (easy — try this first)" echo " http://proksi.local:5000 (easy — try this first)"
if ((${#ips[@]})); then if [ -n "$ip" ]; then
for ip in "${ips[@]}"; do
echo " http://${ip}:5000 (fallback if the first doesn't work)" echo " http://${ip}:5000 (fallback if the first doesn't work)"
done
else
echo " (fallback: no IP address yet — waiting for the network."
echo " Check the cable / DHCP. This line updates by itself.)"
fi fi
echo echo
echo " Then follow the wizard to install Furtka on this machine." echo " Then follow the wizard to install Furtka on this machine."
echo echo
echo " Need a shell? Log in as root (no password)." } > /etc/issue
echo
)
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then agetty --reload 2>/dev/null || true
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

@ -1,6 +1,6 @@
[project] [project]
name = "furtka" name = "furtka"
version = "26.20-alpha" version = "26.19-alpha"
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
requires-python = ">=3.11" requires-python = ">=3.11"
readme = "README.md" readme = "README.md"

View file

@ -195,34 +195,3 @@ def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
"groups": [], "groups": [],
} }
] ]
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

View file

@ -331,21 +331,3 @@ def test_write_and_read_state_round_trip(catalog):
assert s["stage"] == "downloading" assert s["stage"] == "downloading"
assert s["latest"] == "26.6" assert s["latest"] == "26.6"
assert "updated_at" in s assert "updated_at" in s
def test_sync_catalog_failure_writes_error_state(catalog, monkeypatch):
"""A boot-time network failure must not leave the state file on
'checking' forever — the UI would show 'checking…' with nothing running."""
from furtka import _release_common as _rc
def offline(host, repo, path, *, error_cls=RuntimeError):
raise error_cls("forgejo api https://x/releases: Network is unreachable")
monkeypatch.setattr(_rc, "forgejo_api", offline)
with pytest.raises(catalog.CatalogError, match="Network is unreachable"):
catalog.sync_catalog()
state = catalog.read_state()
assert state["stage"] == "error"
assert "Network is unreachable" in state["error"]

View file

@ -1,146 +0,0 @@
"""release_disk() against a recorded `lsblk -P` picture of the first
hardware bench: an SSD with an old Proxmox VE `pve` volume group stacked on
sda3 (swap, root, thin pool). Commands are captured through the injected
runner — nothing touches real devices."""
import subprocess
from pathlib import Path
import pytest
from diskprep import release_disk
# What the bench looked like before cleanup (lsblk -lnP -o NAME,TYPE,MOUNTPOINTS /dev/sda).
BENCH_BEFORE = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
NAME="pve-swap" TYPE="lvm" MOUNTPOINTS="[SWAP]"
NAME="pve-root" TYPE="lvm" MOUNTPOINTS="/mnt/old"
NAME="pve-data_tmeta" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data_tdata" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data-tpool" TYPE="lvm" MOUNTPOINTS=""
NAME="pve-data" TYPE="lvm" MOUNTPOINTS=""
"""
BENCH_CLEAN = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
NAME="sda1" TYPE="part" MOUNTPOINTS=""
NAME="sda3" TYPE="part" MOUNTPOINTS=""
"""
EMPTY = """NAME="sda" TYPE="disk" MOUNTPOINTS=""
"""
class FakeRunner:
"""Answers lsblk from a script of snapshots (one per call, last one
sticks) and records everything else."""
def __init__(self, lsblk_outputs):
self.lsblk_outputs = list(lsblk_outputs)
self.calls = []
def __call__(self, cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
if len(self.lsblk_outputs) > 1:
out = self.lsblk_outputs.pop(0)
else:
out = self.lsblk_outputs[0]
return subprocess.CompletedProcess(cmd, 0, out, "")
self.calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0, "", "")
def test_release_tears_down_lvm_stack_leaves_first():
# lsblk #1 (inventory) and #2 (first removal pass) see the full stack;
# everything after that is clean — the dm removals "worked".
runner = FakeRunner([BENCH_BEFORE, BENCH_BEFORE, BENCH_CLEAN])
lines = release_disk("/dev/sda", run=runner)
calls = runner.calls
assert ["swapoff", "/dev/pve-swap"] in calls
assert ["umount", "--all-targets", "/dev/pve-root"] in calls
removed = [c[2] for c in calls if c[:2] == ["dmsetup", "remove"]]
# Thin LV before its pool, pool before tmeta/tdata.
assert removed.index("pve-data") < removed.index("pve-data-tpool")
assert removed.index("pve-data-tpool") < removed.index("pve-data_tmeta")
assert set(removed) == {
"pve-swap",
"pve-root",
"pve-data_tmeta",
"pve-data-tpool",
"pve-data",
"pve-data_tdata",
}
assert ["wipefs", "-a", "/dev/sda3"] in calls
assert ["wipefs", "-a", "/dev/sda1"] in calls
assert ["wipefs", "-a", "/dev/sda"] in calls
assert calls[-1] == ["blockdev", "--rereadpt", "/dev/sda"]
assert not any("WARNING" in line for line in lines)
assert lines[0].startswith("Releasing /dev/sda")
def test_release_warns_when_stack_survives():
runner = FakeRunner([BENCH_BEFORE]) # never becomes clean
lines = release_disk("/dev/sda", run=runner)
assert any("WARNING: still in use" in line for line in lines)
# Still tries to give archinstall a chance.
assert ["blockdev", "--rereadpt", "/dev/sda"] in runner.calls
def test_release_empty_disk_only_wipes_and_rereads():
runner = FakeRunner([EMPTY])
lines = release_disk("sda", run=runner) # bare name is accepted too
assert runner.calls == [["wipefs", "-a", "/dev/sda"], ["blockdev", "--rereadpt", "/dev/sda"]]
assert any("nothing stacked" in line for line in lines)
def test_release_survives_missing_tools():
def runner(cmd, capture_output=True, text=True):
if cmd[0] == "lsblk":
return subprocess.CompletedProcess(cmd, 0, BENCH_BEFORE, "")
raise FileNotFoundError(cmd[0])
lines = release_disk("/dev/sda", run=runner)
assert any("command not found" in line for line in lines)
@pytest.fixture
def app_module(monkeypatch, tmp_path):
import app as app_module
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path / "state")
return app_module
def test_install_run_releases_disk_before_spawning(app_module, monkeypatch):
order = []
def fake_release(disk):
order.append(("release", disk))
return ["Releasing X"]
monkeypatch.setattr(app_module, "release_disk", fake_release)
monkeypatch.setattr(app_module, "spawn_archinstall", lambda *a: order.append(("spawn",)))
monkeypatch.setattr(app_module, "build_archinstall_config", lambda s: {})
monkeypatch.setattr(app_module, "build_archinstall_creds", lambda s: {})
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
app_module.settings.update(boot_drive="/dev/sda", username="u", password="pw12345678")
client = app_module.app.test_client()
resp = client.post("/install/run")
assert resp.status_code == 302
assert order == [("release", "/dev/sda"), ("spawn",)]
assert Path(app_module.INSTALL_LOG).read_text().startswith("Releasing X")
def test_progress_names_disk_in_use_failure(app_module):
log = (
"Traceback (most recent call last):\n"
"_ped.IOException: ... unable to inform the kernel of the change ..."
)
progress = app_module.parse_install_progress(log)
assert progress["status"] == "error"
assert "still in use" in progress["phase"]

View file

@ -227,25 +227,6 @@ def test_systemd_units_reference_current_paths():
) )
def test_welcome_banner_is_timer_driven():
"""The console banner must keep refreshing so the IP fallback is always
on screen. A oneshot with RemainAfterExit=yes never re-runs from a timer
(the unit stays 'active'), and a network-online dependency would delay the
first paint — both regressions the hardware bench hit in 26.19."""
timer = (ASSETS / "systemd" / "furtka-welcome.timer").read_text()
assert "OnUnitActiveSec=" in timer
assert "furtka-welcome.timer" in app._FURTKA_UNITS
service = (ASSETS / "systemd" / "furtka-welcome.service").read_text()
assert "RemainAfterExit" not in service
assert "network-online.target" not in service
def test_welcome_banner_always_prints_fallback_line():
script = (ASSETS / "bin" / "furtka-welcome").read_text()
assert "no IP address yet" in script
assert "fallback" in script
def test_read_asset_raises_for_missing_file(): def test_read_asset_raises_for_missing_file():
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
app._read_asset("does/not/exist.html") app._read_asset("does/not/exist.html")
@ -278,12 +259,3 @@ def test_post_install_writes_users_json_with_hashed_password(install_cmds):
# Hash is a real werkzeug hash, not the plaintext password. # Hash is a real werkzeug hash, not the plaintext password.
assert parsed["admin"]["hash"] != "test-admin-pw" assert parsed["admin"]["hash"] != "test-admin-pw"
assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw") assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw")
def test_catalog_sync_service_retries_on_failure():
"""First sync at boot can race DHCPv4 (IPv6 makes network-online fire
early); the unit must retry rather than wait for the daily timer."""
body = (ASSETS / "systemd" / "furtka-catalog-sync.service").read_text()
assert "Restart=on-failure" in body
assert "RestartSec=" in body
assert "StartLimitBurst=" in body

View file

@ -11,7 +11,6 @@ import sys
from datetime import UTC from datetime import UTC
from pathlib import Path from pathlib import Path
from diskprep import release_disk
from drives import list_scored_devices from drives import list_scored_devices
from flask import Flask, jsonify, redirect, render_template, request, url_for from flask import Flask, jsonify, redirect, render_template, request, url_for
@ -97,16 +96,6 @@ PROGRESS_PHASES = [
PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:") PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:")
# Known failure signatures → a phase label that tells the user what to do
# instead of a bare "open Show details". Checked in order; first hit wins.
PROGRESS_ERROR_HINTS = [
(
"unable to inform the kernel of the change",
"Installation failed — the disk was still in use (old LVM/RAID/swap "
"from a previous system). Reboot the installer and try again",
),
]
def parse_install_progress(log): def parse_install_progress(log):
percent = 2 percent = 2
@ -121,10 +110,6 @@ def parse_install_progress(log):
elif any(m in log for m in PROGRESS_ERROR_MARKERS): elif any(m in log for m in PROGRESS_ERROR_MARKERS):
status = "error" status = "error"
phase = "Installation failed — open Show details below" phase = "Installation failed — open Show details below"
for marker, hint in PROGRESS_ERROR_HINTS:
if marker in log:
phase = hint
break
else: else:
status = "running" status = "running"
@ -279,9 +264,6 @@ _FURTKA_UNITS = (
"furtka-status.service", "furtka-status.service",
"furtka-status.timer", "furtka-status.timer",
"furtka-welcome.service", "furtka-welcome.service",
# Re-runs the welcome banner every few seconds so the console always
# shows the current IP fallback (or a "no IP yet" hint).
"furtka-welcome.timer",
# Daily apps-catalog pull. Timer drives the service; the .service itself # Daily apps-catalog pull. Timer drives the service; the .service itself
# is oneshot and also callable ad-hoc via `furtka catalog sync`. # is oneshot and also callable ad-hoc via `furtka catalog sync`.
"furtka-catalog-sync.service", "furtka-catalog-sync.service",
@ -496,7 +478,7 @@ def build_archinstall_config(s):
"docker", "docker",
# Base OS post-install services. Only packaged units go here — # Base OS post-install services. Only packaged units go here —
# archinstall runs `systemctl enable` on this list *before* # archinstall runs `systemctl enable` on this list *before*
# custom_commands, so our own furtka-welcome.timer + furtka-status.timer # custom_commands, so our own furtka-welcome + furtka-status.timer
# units (written in custom_commands) are enabled there instead. # units (written in custom_commands) are enabled there instead.
"caddy", "caddy",
"avahi-daemon", "avahi-daemon",
@ -552,9 +534,7 @@ def write_install_files(s, state_dir):
def spawn_archinstall(config_path, creds_path, log_path): def spawn_archinstall(config_path, creds_path, log_path):
# Append: install_run() already truncated the log and may have written log_fh = open(log_path, "wb")
# the disk-release lines we want to keep visible above archinstall's own.
log_fh = open(log_path, "ab")
return subprocess.Popen( return subprocess.Popen(
[ [
"archinstall", "archinstall",
@ -626,11 +606,6 @@ def install_run():
f"--creds {creds_path} --silent\n" f"--creds {creds_path} --silent\n"
) )
else: else:
# Free the target disk first: udev auto-activates old LVM/RAID/swap
# on the live ISO, and archinstall can't re-read a partition table
# while those hold the partitions open (first hardware bench, 26.19).
with INSTALL_LOG.open("a") as fh:
fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n")
spawn_archinstall(config_path, creds_path, INSTALL_LOG) spawn_archinstall(config_path, creds_path, INSTALL_LOG)
return redirect(url_for("install_log_view")) return redirect(url_for("install_log_view"))
@ -642,7 +617,6 @@ def install_log_view():
"install/log.html", "install/log.html",
log=log, log=log,
progress=parse_install_progress(log), progress=parse_install_progress(log),
hostname=settings["hostname"],
) )

View file

@ -1,125 +0,0 @@
"""Release a target disk before handing it to archinstall.
The live ISO ships lvm2, mdadm, dmraid and cryptsetup (releng defaults), and
udev happily auto-activates whatever it finds on attached disks at boot. A
drive that used to live in another machine — the first hardware bench had an
old Proxmox VE install on its SSD — therefore comes up with device-mapper
volumes (or swap, or md arrays) stacked on top of its partitions. archinstall
then writes the new partition table fine but `BLKRRPART` fails with
Partition(s) 2, 3 on /dev/sda have been written, but we have been unable
to inform the kernel of the change, probably because it/they are in use.
and the install dies at 12 %. Nothing on a fresh VM ever triggers this,
which is why it survived every smoke run.
`release_disk()` tears that stack down leaves-first (swapoff, umount, dmsetup
remove / mdadm --stop), wipes signatures and asks the kernel to re-read the
partition table. Every step is logged so the install log shows what was
done. All commands are best-effort: the point is to leave archinstall a
disk the kernel can re-partition, not to be a full disk-management tool.
"""
import re
import subprocess
_PAIR_RE = re.compile(r'(\w+)="([^"]*)"')
def _run(cmd, run):
try:
result = run(cmd, capture_output=True, text=True)
except FileNotFoundError:
return 127, f"{cmd[0]}: command not found"
out = ((result.stdout or "") + (result.stderr or "")).strip()
return result.returncode, out
def _parse_pairs(output):
"""Parse `lsblk -P` key="value" lines into dicts."""
rows = []
for line in output.splitlines():
row = {}
for match in _PAIR_RE.finditer(line):
row[match.group(1)] = match.group(2)
if row.get("NAME"):
rows.append(row)
return rows
def _children(disk, run):
"""Everything stacked on `disk`, in lsblk tree order, disk itself excluded."""
rc, out = _run(["lsblk", "-lnP", "-o", "NAME,TYPE,MOUNTPOINTS", disk], run)
if rc != 0:
return []
rows = _parse_pairs(out)
seen = set()
children = []
for row in rows:
if row.get("TYPE") == "disk" or row["NAME"] in seen:
continue
seen.add(row["NAME"])
children.append(row)
return children
def release_disk(disk, run=subprocess.run):
"""Free `disk` (e.g. "/dev/sda") of anything that would block a
partition-table reload. Returns the log lines describing what happened."""
if not disk.startswith("/dev/"):
disk = f"/dev/{disk}"
lines = [f"Releasing {disk} before install…"]
children = _children(disk, run)
if not children:
lines.append(" nothing stacked on the disk — good.")
else:
lines.append(" found: " + ", ".join(f"{c['NAME']} ({c['TYPE']})" for c in children))
# Leaves first: mountpoints and swap sit on the outermost devices.
for child in reversed(children):
dev = f"/dev/{child['NAME']}"
mounts = child.get("MOUNTPOINTS", "")
if "[SWAP]" in mounts:
rc, out = _run(["swapoff", dev], run)
lines.append(f" swapoff {dev}: {'ok' if rc == 0 else out}")
elif mounts:
rc, out = _run(["umount", "--all-targets", dev], run)
lines.append(f" umount {dev}: {'ok' if rc == 0 else out}")
# Device-mapper (LVM, thin pools, LUKS) and md stacks. Thin pools need
# their thin LVs gone before the pool goes, and lsblk lists shared nodes
# more than once, so go leaves-first and retry a couple of passes until
# nothing dm/md-shaped is left.
for _ in range(3):
stacked = [c for c in reversed(_children(disk, run)) if c["TYPE"] != "part"]
if not stacked:
break
for child in stacked:
name, typ = child["NAME"], child["TYPE"]
if typ.startswith("raid") or name.startswith("md"):
cmd = ["mdadm", "--stop", f"/dev/{name}"]
else:
cmd = ["dmsetup", "remove", name]
rc, out = _run(cmd, run)
lines.append(f" {' '.join(cmd)}: {'ok' if rc == 0 else out}")
leftovers = [c["NAME"] for c in _children(disk, run) if c["TYPE"] != "part"]
if leftovers:
lines.append(
" WARNING: still in use after cleanup: "
+ ", ".join(leftovers)
+ " — the install may fail to re-read the partition table."
)
# Old signatures (LVM PV headers, RAID superblocks) would otherwise be
# re-activated by udev the moment the kernel re-reads the table.
for child in reversed(_children(disk, run)):
if child["TYPE"] == "part":
rc, out = _run(["wipefs", "-a", f"/dev/{child['NAME']}"], run)
lines.append(f" wipefs /dev/{child['NAME']}: {'ok' if rc == 0 else out}")
rc, out = _run(["wipefs", "-a", disk], run)
lines.append(f" wipefs {disk}: {'ok' if rc == 0 else out}")
rc, out = _run(["blockdev", "--rereadpt", disk], run)
lines.append(f" re-read partition table: {'ok' if rc == 0 else out}")
return lines

View file

@ -421,11 +421,3 @@ select:focus {
outline-offset: 3px; outline-offset: 3px;
border-radius: 2px; border-radius: 2px;
} }
/* "Furtka is ready" — the two steps before the restart button (log.html). */
.next-steps {
margin: 1.5rem 0 1rem;
padding-left: 1.4rem;
line-height: 1.5;
}
.next-steps li { margin-bottom: 0.6rem; }

View file

@ -17,25 +17,16 @@
{% else %}Installing Furtka{% endif %} {% else %}Installing Furtka{% endif %}
</h1> </h1>
<p class="lede" id="install-lede"> <p class="lede" id="install-lede">
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk. {% if progress.status == "done" %}Installation finished. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot.
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help. {% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %} {% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
</p> </p>
{% if progress.status == "done" %} {% if progress.status == "done" %}
{# The USB-stick step has to be read *before* the click: the machine reboots
3 s after it, and most BIOSes boot the stick before the disk — so the
installer would just come back. Seen on the first hardware bench. #}
<ol class="next-steps">
<li><strong>Pull out the USB stick</strong> (or eject the installer ISO in your VM).
If it stays in, the computer will start this installer again instead of Furtka.</li>
<li>Click the button below. The computer restarts and comes back as
<strong>http://{{ hostname }}.local</strong> in about a minute.</li>
</ol>
<form method="post" action="{{ url_for('install_reboot') }}" <form method="post" action="{{ url_for('install_reboot') }}"
onsubmit="return confirm('USB stick removed? Click OK to restart the computer.');"> onsubmit="return confirm('Have you removed the installer USB / ejected the ISO? Click OK to reboot.');">
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">USB stick is out — restart now</button> <button type="submit" class="btn btn-primary">Reboot now</button>
</div> </div>
</form> </form>
{% endif %} {% endif %}

View file

@ -1,7 +1,7 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle." description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
status: "<span class=\"mono\">26.20-alpha</span> — in Arbeit" status: "<span class=\"mono\">26.19-alpha</span> — in Arbeit"
# features_today / features_next müssen index-parallel zu content/_index.md bleiben. # features_today / features_next müssen index-parallel zu content/_index.md bleiben.
intro: | intro: |
**Furtka** ist ein offenes Heimserver-Betriebssystem. **Furtka** ist ein offenes Heimserver-Betriebssystem.

View file

@ -1,7 +1,7 @@
--- ---
title: "Furtka" title: "Furtka"
description: "Open-source home server OS — simple enough for everyone." description: "Open-source home server OS — simple enough for everyone."
status: "<span class=\"mono\">26.20-alpha</span> — work in progress" status: "<span class=\"mono\">26.19-alpha</span> — work in progress"
# Keep features_today / features_next index-aligned with content/_index.de.md. # Keep features_today / features_next index-aligned with content/_index.de.md.
intro: | intro: |
**Furtka** is an open-source home server OS. **Furtka** is an open-source home server OS.

View file

@ -6,7 +6,7 @@ enableRobotsTXT = true
[params] [params]
description = "Open-source home server OS — simple enough for everyone." description = "Open-source home server OS — simple enough for everyone."
version = "26.20-alpha" version = "26.19-alpha"
contactEmail = "hallo@furtka.org" contactEmail = "hallo@furtka.org"
[markup.goldmark.renderer] [markup.goldmark.renderer]