fix(catalog): retry the boot-time sync and record failures as an error stage

On dual-stack LANs network-online.target is satisfied by the IPv6 SLAAC
address about a second before DHCPv4 lands, so the first
furtka-catalog-sync run died with 'Network is unreachable' and the next
attempt was the daily timer (OnBootSec=10min + up to 6 h jitter). The
state file stayed on 'checking' and the apps page showed 'checking…'
with nothing running — first hardware bench, 2026-08-25.

- furtka-catalog-sync.service: Restart=on-failure every 20 s, up to 8
  times in 15 min
- sync_catalog() writes stage 'error' + message on CatalogError, then
  re-raises (body moved to _sync_locked())
- apps page shows 'last sync failed: <error>' for that stage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-08-25 16:38:06 +02:00
parent 60404ae643
commit 32ba522d59
6 changed files with 104 additions and 41 deletions

View file

@ -9,6 +9,16 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
### 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

View file

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

View file

@ -490,7 +490,11 @@ async function refreshCatalog() {
const updatedAt = (status.state || {}).updated_at || '';
document.getElementById('catalog-last-sync').textContent = updatedAt || 'never';
const stageEl = document.getElementById('catalog-stage');
if (stage && 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.classList.add('pending');
} else {

View file

@ -204,50 +204,63 @@ def _atomic_swap(staging: Path) -> None:
def sync_catalog() -> CatalogCheck:
"""End-to-end sync. Acquires the lock, writes state at each stage, and
leaves the live catalog untouched on any failure before the rename step.
A failure lands as stage ``"error"`` (with the message) instead of leaving
the last in-flight stage behind: the first hardware bench sat on
``"checking"`` for hours after a boot-time network failure, and the UI
showed "checking…" with nothing actually happening.
"""
with acquire_lock():
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:
_rc.extract_tarball(tarball, staging, error_cls=CatalogError)
_validate_staging(staging, check.latest)
except CatalogError:
shutil.rmtree(staging, ignore_errors=True)
return _sync_locked()
except CatalogError as e:
write_state("error", error=str(e))
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)
def _sync_locked() -> CatalogCheck:
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:
_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

View file

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

View file

@ -278,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.
assert parsed["admin"]["hash"] != "test-admin-pw"
assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw")
def test_catalog_sync_service_retries_on_failure():
"""First sync at boot can race DHCPv4 (IPv6 makes network-online fire
early); the unit must retry rather than wait for the daily timer."""
body = (ASSETS / "systemd" / "furtka-catalog-sync.service").read_text()
assert "Restart=on-failure" in body
assert "RestartSec=" in body
assert "StartLimitBurst=" in body