From 01f3a2ddc1e1ca753647569d620b6d8e94406275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Thu, 12 Mar 2026 17:24:47 +0100 Subject: [PATCH] feat(ops): add update alert severity --- CHANGELOG.md | 4 +++ README.md | 2 +- docs/FOUNDRYGATE-ROADMAP.md | 2 ++ foundrygate/main.py | 2 +- foundrygate/updates.py | 65 ++++++++++++++++++++++++++++++++++++- tests/test_updates.py | 28 +++++++++++++++- 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 951cfdb..572cce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is intentionally lightweight and human-readable. Group entries by rel ## Unreleased +### Added + +- Added stronger update-alert metadata to `GET /api/update`, including update type, alert level, and recommended action for operators and dashboard consumers + ## v0.6.0 - 2026-03-12 ### Added diff --git a/README.md b/README.md index eefd4b2..af5c770 100644 --- a/README.md +++ b/README.md @@ -304,7 +304,7 @@ For image-capable providers, `image.policy_tags` can be used as lightweight pres `GET /api/traces` returns recent enriched routing records from the metrics store, including requested model, modality, resolved client profile, client tag, decision reason, confidence, and attempt order. -`GET /api/update` returns the cached release-check result for the running service, including the current version, latest known tag, update availability, and the release URL when GitHub lookups succeed. +`GET /api/update` returns the cached release-check result for the running service, including the current version, latest known tag, update availability, update type (`patch`, `minor`, `major`), alert level, recommended action, and the release URL when GitHub lookups succeed. ## Model Aliases And Routing diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md index 81904e1..1c58df5 100644 --- a/docs/FOUNDRYGATE-ROADMAP.md +++ b/docs/FOUNDRYGATE-ROADMAP.md @@ -210,6 +210,8 @@ Primary goals: This release line is about day-2 operations rather than new routing concepts. +The first small slice in this line is to turn `GET /api/update` from a plain boolean check into an operator-facing alert surface with update type, alert level, and recommended action. + ### `v0.8.x`: many-provider and many-client onboarding Primary goals: diff --git a/foundrygate/main.py b/foundrygate/main.py index eb12cda..e4432b0 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -1567,7 +1567,7 @@ def main():
Healthy Providers
${healthyProviders}/${providers.length}
${unhealthyProviders} unhealthy
Capability Coverage
${coverageEntries.length}
${coverageEntries.map(([name]) => name).slice(0,3).join(', ') || 'none'}
Top Modality
${esc(topModality)}
${modalityRows.length} modality groups
-
Release Status
${esc(update.latest_version || update.current_version || 'n/a')}
${update.enabled ? (update.update_available ? 'Update available' : update.status === 'ok' ? 'Up to date' : 'Update check unavailable') : 'Update checks disabled'}
+
Release Status
${esc(update.latest_version || update.current_version || 'n/a')}
${update.enabled ? (update.status === 'ok' ? `${esc(update.update_type || 'current')} / ${esc(update.recommended_action || (update.update_available ? 'Upgrade recommended' : 'No action needed'))}` : esc(update.recommended_action || 'Update check unavailable')) : 'Update checks disabled'}
`; const providerRows = providers.map(provider => ` diff --git a/foundrygate/updates.py b/foundrygate/updates.py index 05f7d99..3c22c10 100644 --- a/foundrygate/updates.py +++ b/foundrygate/updates.py @@ -38,6 +38,42 @@ def is_update_available(current_version: str, latest_version: str) -> bool: return latest > current +def classify_update(current_version: str, latest_version: str) -> str: + """Classify a newer release as patch, minor, major, or current.""" + current = _normalize_version(current_version) + latest = _normalize_version(latest_version) + if not current or not latest: + return "unknown" + + width = max(3, len(current), len(latest)) + current += (0,) * (width - len(current)) + latest += (0,) * (width - len(latest)) + if latest <= current: + return "current" + if latest[0] > current[0]: + return "major" + if latest[1] > current[1]: + return "minor" + return "patch" + + +def alert_level_for_update(update_type: str, *, available: bool, status: str) -> str: + """Return an operator-facing alert level for one update status.""" + if status in {"unavailable"}: + return "warning" + if status in {"disabled"}: + return "disabled" + if not available: + return "ok" + if update_type == "major": + return "critical" + if update_type == "minor": + return "warning" + if update_type == "patch": + return "info" + return "warning" + + @dataclass class UpdateStatus: """Structured update-check result.""" @@ -50,6 +86,9 @@ class UpdateStatus: release_url: str = "" checked_at: float = 0.0 status: str = "disabled" + update_type: str = "current" + alert_level: str = "disabled" + recommended_action: str = "" error: str = "" def to_dict(self) -> dict[str, Any]: @@ -62,6 +101,9 @@ def to_dict(self) -> dict[str, Any]: "release_url": self.release_url, "checked_at": self.checked_at, "status": self.status, + "update_type": self.update_type, + "alert_level": self.alert_level, + "recommended_action": self.recommended_action, "error": self.error, } @@ -110,6 +152,9 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: repository=self.repository, checked_at=time.time(), status="disabled", + update_type="current", + alert_level="disabled", + recommended_action="Update checks are disabled", ) return self._cached @@ -132,6 +177,9 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: repository=self.repository, checked_at=now, status="unavailable", + update_type="unknown", + alert_level="warning", + recommended_action="Inspect release connectivity and retry later", error=f"Release lookup returned HTTP {response.status_code}", ) return self._cached @@ -139,15 +187,27 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: payload = response.json() latest_version = str(payload.get("tag_name") or "").strip() release_url = str(payload.get("html_url") or "").strip() + update_available = is_update_available(self.current_version, latest_version) + update_type = classify_update(self.current_version, latest_version) + alert_level = alert_level_for_update( + update_type, + available=update_available, + status="ok", + ) self._cached = UpdateStatus( enabled=True, current_version=self.current_version, latest_version=latest_version, - update_available=is_update_available(self.current_version, latest_version), + update_available=update_available, repository=self.repository, release_url=release_url, checked_at=now, status="ok", + update_type=update_type, + alert_level=alert_level, + recommended_action=( + "Upgrade to the latest release" if update_available else "No action needed" + ), ) return self._cached except Exception as exc: @@ -157,6 +217,9 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: repository=self.repository, checked_at=now, status="unavailable", + update_type="unknown", + alert_level="warning", + recommended_action="Inspect release connectivity and retry later", error=str(exc), ) return self._cached diff --git a/tests/test_updates.py b/tests/test_updates.py index 8e84648..42faa83 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -4,7 +4,12 @@ import pytest -from foundrygate.updates import UpdateChecker, is_update_available +from foundrygate.updates import ( + UpdateChecker, + alert_level_for_update, + classify_update, + is_update_available, +) class _FakeResponse: @@ -37,6 +42,21 @@ def test_version_comparison_detects_newer_release(): assert is_update_available("0.5.1", "v0.5.0") is False +def test_classify_update_distinguishes_patch_minor_and_major(): + assert classify_update("0.6.0", "v0.6.1") == "patch" + assert classify_update("0.6.0", "v0.7.0") == "minor" + assert classify_update("0.6.0", "v1.0.0") == "major" + assert classify_update("0.6.0", "v0.6.0") == "current" + + +def test_alert_level_maps_update_type_and_status(): + assert alert_level_for_update("patch", available=True, status="ok") == "info" + assert alert_level_for_update("minor", available=True, status="ok") == "warning" + assert alert_level_for_update("major", available=True, status="ok") == "critical" + assert alert_level_for_update("current", available=False, status="ok") == "ok" + assert alert_level_for_update("unknown", available=False, status="unavailable") == "warning" + + @pytest.mark.asyncio async def test_update_checker_reports_latest_release(): checker = UpdateChecker( @@ -59,6 +79,9 @@ async def test_update_checker_reports_latest_release(): assert status.status == "ok" assert status.latest_version == "v0.5.0" assert status.update_available is True + assert status.update_type == "minor" + assert status.alert_level == "warning" + assert status.recommended_action == "Upgrade to the latest release" assert status.release_url.endswith("/v0.5.0") @@ -86,6 +109,7 @@ async def test_update_checker_uses_cache_until_forced(): assert first.status == "ok" assert second.status == "ok" + assert second.alert_level == "ok" assert fake_client.calls == 1 @@ -102,4 +126,6 @@ async def test_update_checker_handles_remote_errors(): assert status.status == "unavailable" assert status.update_available is False + assert status.alert_level == "warning" + assert status.recommended_action == "Inspect release connectivity and retry later" assert "network unavailable" in status.error