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 ### 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 - **Installer frees the target disk before partitioning.** The live ISO
auto-activates whatever an attached drive carries (LVM volume groups, md auto-activates whatever an attached drive carries (LVM volume groups, md
arrays, swap) — the first hardware bench's SSD still had a Proxmox VE arrays, swap) — the first hardware bench's SSD still had a Proxmox VE

View file

@ -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

View file

@ -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 {

View file

@ -204,50 +204,63 @@ 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:
_rc.extract_tarball(tarball, staging, error_cls=CatalogError) return _sync_locked()
_validate_staging(staging, check.latest) except CatalogError as e:
except CatalogError: write_state("error", error=str(e))
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
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 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["stage"] == "downloading"
assert s["latest"] == "26.6" assert s["latest"] == "26.6"
assert "updated_at" in s assert "updated_at" in s
def test_sync_catalog_failure_writes_error_state(catalog, monkeypatch):
"""A boot-time network failure must not leave the state file on
'checking' forever the UI would show 'checking…' with nothing running."""
from furtka import _release_common as _rc
def offline(host, repo, path, *, error_cls=RuntimeError):
raise error_cls("forgejo api https://x/releases: Network is unreachable")
monkeypatch.setattr(_rc, "forgejo_api", offline)
with pytest.raises(catalog.CatalogError, match="Network is unreachable"):
catalog.sync_catalog()
state = catalog.read_state()
assert state["stage"] == "error"
assert "Network is unreachable" in state["error"]

View file

@ -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. # 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