Compare commits
No commits in common. "8e281fa4a64d519fa8d2ddd6b6dcd976f6da0810" and "e1968facef6320d9297918036faacb0505c9915d" have entirely different histories.
8e281fa4a6
...
e1968facef
26 changed files with 77 additions and 606 deletions
49
CHANGELOG.md
49
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -204,63 +204,50 @@ 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():
|
||||||
|
write_state("checking")
|
||||||
|
check = check_catalog()
|
||||||
|
if not check.update_available:
|
||||||
|
write_state("done", version=check.current or check.latest, note="already up to date")
|
||||||
|
return check
|
||||||
|
if not check.tarball_url or not check.sha256_url:
|
||||||
|
raise CatalogError("catalog release is missing tarball or sha256 asset")
|
||||||
|
|
||||||
|
# Downloads land in a sibling of the live catalog so half-finished
|
||||||
|
# artefacts never pollute the live tree, and stay under /var/lib/
|
||||||
|
# furtka/ so a sync interrupted by reboot can resume instead of
|
||||||
|
# starting over from /tmp (which clears).
|
||||||
|
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
|
||||||
|
dl_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
|
||||||
|
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
|
||||||
|
|
||||||
|
write_state("downloading", latest=check.latest)
|
||||||
|
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
|
||||||
|
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
|
||||||
|
|
||||||
|
write_state("verifying", latest=check.latest)
|
||||||
|
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
|
||||||
|
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
|
||||||
|
|
||||||
|
write_state("extracting", latest=check.latest)
|
||||||
|
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
|
||||||
|
if staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
try:
|
try:
|
||||||
return _sync_locked()
|
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
|
||||||
except CatalogError as e:
|
_validate_staging(staging, check.latest)
|
||||||
write_state("error", error=str(e))
|
except CatalogError:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
write_state("swapping", latest=check.latest)
|
||||||
|
try:
|
||||||
|
_atomic_swap(staging)
|
||||||
|
except CatalogError:
|
||||||
|
shutil.rmtree(staging, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
def _sync_locked() -> CatalogCheck:
|
write_state("done", version=check.latest, previous=check.current)
|
||||||
write_state("checking")
|
|
||||||
check = check_catalog()
|
|
||||||
if not check.update_available:
|
|
||||||
write_state("done", version=check.current or check.latest, note="already up to date")
|
|
||||||
return check
|
return check
|
||||||
if not check.tarball_url or not check.sha256_url:
|
|
||||||
raise CatalogError("catalog release is missing tarball or sha256 asset")
|
|
||||||
|
|
||||||
# Downloads land in a sibling of the live catalog so half-finished
|
|
||||||
# artefacts never pollute the live tree, and stay under /var/lib/
|
|
||||||
# furtka/ so a sync interrupted by reboot can resume instead of
|
|
||||||
# starting over from /tmp (which clears).
|
|
||||||
dl_dir = catalog_dir().with_name(catalog_dir().name + _DOWNLOADS_NAME)
|
|
||||||
dl_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
tarball = dl_dir / f"furtka-apps-{check.latest}.tar.gz"
|
|
||||||
sha_file = dl_dir / f"furtka-apps-{check.latest}.tar.gz.sha256"
|
|
||||||
|
|
||||||
write_state("downloading", latest=check.latest)
|
|
||||||
_rc.download(check.tarball_url, tarball, error_cls=CatalogError)
|
|
||||||
_rc.download(check.sha256_url, sha_file, error_cls=CatalogError)
|
|
||||||
|
|
||||||
write_state("verifying", latest=check.latest)
|
|
||||||
expected = _rc.parse_sha256_sidecar(sha_file.read_text(), error_cls=CatalogError)
|
|
||||||
_rc.verify_tarball(tarball, expected, error_cls=CatalogError)
|
|
||||||
|
|
||||||
write_state("extracting", latest=check.latest)
|
|
||||||
staging = catalog_dir().with_name(catalog_dir().name + _STAGING_NAME)
|
|
||||||
if staging.exists():
|
|
||||||
shutil.rmtree(staging)
|
|
||||||
try:
|
|
||||||
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
|
|
||||||
_validate_staging(staging, check.latest)
|
|
||||||
except CatalogError:
|
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
write_state("swapping", latest=check.latest)
|
|
||||||
try:
|
|
||||||
_atomic_swap(staging)
|
|
||||||
except CatalogError:
|
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
write_state("done", version=check.latest, previous=check.current)
|
|
||||||
return check
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
../furtka-issue.timer
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue