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]
## [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
### Fixed
@ -561,8 +515,7 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
- **Containers:** Docker + Compose
- **License:** AGPL-3.0
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.20-alpha...HEAD
[26.20-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.20-alpha
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.19-alpha...HEAD
[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.17-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.17-alpha

View file

@ -1,36 +1,22 @@
#!/bin/bash
# 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:
# runs at boot and every few seconds from furtka-welcome.timer, so the IP
# 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
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern.
set -e
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 " Furtka is ready."
echo
echo " Open in a browser on another device on your network:"
echo
echo " http://${hostname}.local (easy — try this first)"
if ((${#ips[@]})); then
for ip in "${ips[@]}"; do
if [ -n "$ip" ]; then
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
echo
)
} > /etc/issue
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

@ -2,20 +2,11 @@
Description=Furtka apps catalog sync
Requires=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]
Type=oneshot
ExecStart=/usr/local/bin/furtka catalog sync
TimeoutStartSec=5min
Restart=on-failure
RestartSec=20s
[Install]
WantedBy=multi-user.target

View file

@ -1,12 +1,12 @@
[Unit]
Description=Furtka console welcome banner
# No network-online dependency on purpose: paint the banner (with a
# "no IP yet" fallback) as early as possible; furtka-welcome.timer re-runs
# it every few seconds so the address appears as soon as DHCP delivers.
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/opt/furtka/current/assets/bin/furtka-welcome
RemainAfterExit=yes
[Install]
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 || '';
document.getElementById('catalog-last-sync').textContent = updatedAt || 'never';
const stageEl = document.getElementById('catalog-stage');
if (stage === 'error') {
const err = (status.state || {}).error || 'unknown error';
stageEl.textContent = '· last sync failed: ' + err;
stageEl.classList.add('pending');
} else if (stage && stage !== 'done') {
if (stage && stage !== 'done') {
stageEl.textContent = '· ' + stage + '…';
stageEl.classList.add('pending');
} else {

View file

@ -204,21 +204,8 @@ def _atomic_swap(staging: Path) -> None:
def sync_catalog() -> CatalogCheck:
"""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.
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():
try:
return _sync_locked()
except CatalogError as e:
write_state("error", error=str(e))
raise
def _sync_locked() -> CatalogCheck:
write_state("checking")
check = check_catalog()
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/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/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/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/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` (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.
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
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/"
# 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)"
# releng ships menu entries labelled "Arch Linux install medium" across three
# bootloader configs (BIOS syslinux, GRUB, systemd-boot for UEFI). Rewrite to

View file

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

View file

@ -1,12 +1,12 @@
[Unit]
Description=Write Furtka /etc/issue with current IP for the console welcome
# No network-online dependency on purpose: the first run should paint the
# banner (with a "no IP yet" fallback) as early as possible; the timer
# re-runs it until an address shows up.
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/furtka-update-issue
RemainAfterExit=yes
[Install]
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
# 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
# doesn't work.
#
# 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
# doesn't work. Reload at the end nudges agetty to redraw.
set -e
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 " Open Furtka in a browser on another device on your network:"
echo
echo " http://proksi.local:5000 (easy — try this first)"
if ((${#ips[@]})); then
for ip in "${ips[@]}"; do
if [ -n "$ip" ]; then
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
echo
echo " Then follow the wizard to install Furtka on this machine."
echo
echo " Need a shell? Log in as root (no password)."
echo
)
} > /etc/issue
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
printf '%s\n' "$new" > "$issue"
agetty --reload 2>/dev/null || true
fi

View file

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

View file

@ -195,34 +195,3 @@ def test_build_archinstall_creds_uses_archinstall_sentinel_keys():
"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["latest"] == "26.6"
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():
with pytest.raises(FileNotFoundError):
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.
assert 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 pathlib import Path
from diskprep import release_disk
from drives import list_scored_devices
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:")
# 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):
percent = 2
@ -121,10 +110,6 @@ def parse_install_progress(log):
elif any(m in log for m in PROGRESS_ERROR_MARKERS):
status = "error"
phase = "Installation failed — open Show details below"
for marker, hint in PROGRESS_ERROR_HINTS:
if marker in log:
phase = hint
break
else:
status = "running"
@ -279,9 +264,6 @@ _FURTKA_UNITS = (
"furtka-status.service",
"furtka-status.timer",
"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
# is oneshot and also callable ad-hoc via `furtka catalog sync`.
"furtka-catalog-sync.service",
@ -496,7 +478,7 @@ def build_archinstall_config(s):
"docker",
# Base OS post-install services. Only packaged units go here —
# 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.
"caddy",
"avahi-daemon",
@ -552,9 +534,7 @@ def write_install_files(s, state_dir):
def spawn_archinstall(config_path, creds_path, log_path):
# Append: install_run() already truncated the log and may have written
# the disk-release lines we want to keep visible above archinstall's own.
log_fh = open(log_path, "ab")
log_fh = open(log_path, "wb")
return subprocess.Popen(
[
"archinstall",
@ -626,11 +606,6 @@ def install_run():
f"--creds {creds_path} --silent\n"
)
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)
return redirect(url_for("install_log_view"))
@ -642,7 +617,6 @@ def install_log_view():
"install/log.html",
log=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;
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 %}
</h1>
<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.
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
</p>
{% 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') }}"
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">
<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>
</form>
{% endif %}

View file

@ -1,7 +1,7 @@
---
title: "Furtka"
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.
intro: |
**Furtka** ist ein offenes Heimserver-Betriebssystem.

View file

@ -1,7 +1,7 @@
---
title: "Furtka"
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.
intro: |
**Furtka** is an open-source home server OS.

View file

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