diff --git a/CHANGELOG.md b/CHANGELOG.md index 863a3c4..e3d1ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/systemd/furtka-catalog-sync.service b/assets/systemd/furtka-catalog-sync.service index fa0307c..1500ef0 100644 --- a/assets/systemd/furtka-catalog-sync.service +++ b/assets/systemd/furtka-catalog-sync.service @@ -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 diff --git a/furtka/api.py b/furtka/api.py index 7af5209..d052169 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -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 { diff --git a/furtka/catalog.py b/furtka/catalog.py index 5dd8b9d..8286f93 100644 --- a/furtka/catalog.py +++ b/furtka/catalog.py @@ -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 diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 1eccd2f..2113e3f 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -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"] diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index 6c80c48..4577d51 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -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