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:
parent
60404ae643
commit
32ba522d59
6 changed files with 104 additions and 41 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue