Compare commits
5 commits
e1968facef
...
8e281fa4a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e281fa4a6 | |||
| 32ba522d59 | |||
| 60404ae643 | |||
| 5ddda5302a | |||
| 0e05bef667 |
26 changed files with 606 additions and 77 deletions
49
CHANGELOG.md
49
CHANGELOG.md
|
|
@ -7,6 +7,52 @@ 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
|
||||||
|
|
@ -515,7 +561,8 @@ 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.19-alpha...HEAD
|
[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
|
||||||
[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
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,36 @@
|
||||||
#!/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:
|
||||||
set -e
|
# 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
|
||||||
|
|
||||||
hostname=$(cat /etc/hostname)
|
issue=/etc/issue
|
||||||
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 [ -n "$ip" ]; then
|
if ((${#ips[@]})); 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
|
||||||
|
printf '%s\n' "$new" > "$issue"
|
||||||
agetty --reload 2>/dev/null || true
|
agetty --reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,20 @@
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Furtka console welcome banner
|
Description=Furtka console welcome banner
|
||||||
After=network-online.target
|
# No network-online dependency on purpose: paint the banner (with a
|
||||||
Wants=network-online.target
|
# "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.
|
||||||
|
|
||||||
[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
|
||||||
|
|
|
||||||
10
assets/systemd/furtka-welcome.timer
Normal file
10
assets/systemd/furtka-welcome.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Keep the Furtka console welcome (IP fallback) up to date
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3s
|
||||||
|
OnUnitActiveSec=5s
|
||||||
|
AccuracySec=1s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
@ -490,7 +490,11 @@ 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 && stage !== 'done') {
|
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') {
|
||||||
stageEl.textContent = '· ' + stage + '…';
|
stageEl.textContent = '· ' + stage + '…';
|
||||||
stageEl.classList.add('pending');
|
stageEl.classList.add('pending');
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -204,8 +204,21 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,15 @@ 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 also shows the DHCP-assigned IP as a fallback URL |
|
| `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` (runs the banner-updater on network-online), each symlinked into `multi-user.target.wants/` to auto-start on boot |
|
| `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/` |
|
||||||
|
|
||||||
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`
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,15 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
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 will appear below.
|
on your network. The exact URL (and an IP fallback) will
|
||||||
|
appear here in a few seconds.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
After=network-online.target
|
# No network-online dependency on purpose: the first run should paint the
|
||||||
Wants=network-online.target
|
# banner (with a "no IP yet" fallback) as early as possible; the timer
|
||||||
|
# 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
|
||||||
|
|
|
||||||
10
iso/overlay/airootfs/etc/systemd/system/furtka-issue.timer
Normal file
10
iso/overlay/airootfs/etc/systemd/system/furtka-issue.timer
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Keep the Furtka console welcome (IP fallback) up to date
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3s
|
||||||
|
OnUnitActiveSec=5s
|
||||||
|
AccuracySec=1s
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
../furtka-issue.timer
|
||||||
|
|
@ -2,22 +2,40 @@
|
||||||
# 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. Reload at the end nudges agetty to redraw.
|
# doesn't work.
|
||||||
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
|
||||||
|
|
||||||
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
issue=/etc/issue
|
||||||
|
|
||||||
{
|
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 [ -n "$ip" ]; then
|
if ((${#ips[@]})); 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
|
||||||
} > /etc/issue
|
echo " Need a shell? Log in as root (no password)."
|
||||||
|
echo
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$new" != "$(cat "$issue" 2>/dev/null)" ]; then
|
||||||
|
printf '%s\n' "$new" > "$issue"
|
||||||
agetty --reload 2>/dev/null || true
|
agetty --reload 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "furtka"
|
name = "furtka"
|
||||||
version = "26.19-alpha"
|
version = "26.20-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"
|
||||||
|
|
|
||||||
|
|
@ -195,3 +195,34 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -331,3 +331,21 @@ 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"]
|
||||||
|
|
|
||||||
146
tests/test_diskprep.py
Normal file
146
tests/test_diskprep.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""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"]
|
||||||
|
|
@ -227,6 +227,25 @@ 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")
|
||||||
|
|
@ -259,3 +278,12 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ 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
|
||||||
|
|
||||||
|
|
@ -96,6 +97,16 @@ 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
|
||||||
|
|
@ -110,6 +121,10 @@ 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"
|
||||||
|
|
||||||
|
|
@ -264,6 +279,9 @@ _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",
|
||||||
|
|
@ -478,7 +496,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 + furtka-status.timer
|
# custom_commands, so our own furtka-welcome.timer + 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",
|
||||||
|
|
@ -534,7 +552,9 @@ 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):
|
||||||
log_fh = open(log_path, "wb")
|
# 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")
|
||||||
return subprocess.Popen(
|
return subprocess.Popen(
|
||||||
[
|
[
|
||||||
"archinstall",
|
"archinstall",
|
||||||
|
|
@ -606,6 +626,11 @@ 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"))
|
||||||
|
|
||||||
|
|
@ -617,6 +642,7 @@ 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"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
125
webinstaller/diskprep.py
Normal file
125
webinstaller/diskprep.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""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
|
||||||
|
|
@ -421,3 +421,11 @@ 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; }
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,25 @@
|
||||||
{% 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. <strong>Remove the installer USB / eject the ISO</strong>, then click Reboot.
|
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
|
||||||
{% 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('Have you removed the installer USB / ejected the ISO? Click OK to reboot.');">
|
onsubmit="return confirm('USB stick removed? Click OK to restart the computer.');">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="btn btn-primary">Reboot now</button>
|
<button type="submit" class="btn btn-primary">USB stick is out — restart now</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -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.19-alpha</span> — in Arbeit"
|
status: "<span class=\"mono\">26.20-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.
|
||||||
|
|
|
||||||
|
|
@ -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.19-alpha</span> — work in progress"
|
status: "<span class=\"mono\">26.20-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.
|
||||||
|
|
|
||||||
|
|
@ -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.19-alpha"
|
version = "26.20-alpha"
|
||||||
contactEmail = "hallo@furtka.org"
|
contactEmail = "hallo@furtka.org"
|
||||||
|
|
||||||
[markup.goldmark.renderer]
|
[markup.goldmark.renderer]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue