Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/FOUNDRYGATE-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1567,7 +1567,7 @@ def main():
<div class="card"><div class="label">Healthy Providers</div><div class="value">${healthyProviders}/${providers.length}</div><div class="detail">${unhealthyProviders} unhealthy</div></div>
<div class="card"><div class="label">Capability Coverage</div><div class="value">${coverageEntries.length}</div><div class="detail">${coverageEntries.map(([name]) => name).slice(0,3).join(', ') || 'none'}</div></div>
<div class="card"><div class="label">Top Modality</div><div class="value">${esc(topModality)}</div><div class="detail">${modalityRows.length} modality groups</div></div>
<div class="card"><div class="label">Release Status</div><div class="value ${update.update_available ? 'cost' : ''}">${esc(update.latest_version || update.current_version || 'n/a')}</div><div class="detail">${update.enabled ? (update.update_available ? 'Update available' : update.status === 'ok' ? 'Up to date' : 'Update check unavailable') : 'Update checks disabled'}</div></div>
<div class="card"><div class="label">Release Status</div><div class="value ${(update.alert_level === 'critical' || update.alert_level === 'warning') ? 'err' : update.update_available ? 'cost' : ''}">${esc(update.latest_version || update.current_version || 'n/a')}</div><div class="detail">${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'}</div></div>
`;

const providerRows = providers.map(provider => `<tr>
Expand Down
65 changes: 64 additions & 1 deletion foundrygate/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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]:
Expand All @@ -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,
}

Expand Down Expand Up @@ -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

Expand All @@ -132,22 +177,37 @@ 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

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:
Expand All @@ -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
28 changes: 27 additions & 1 deletion tests/test_updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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")


Expand Down Expand Up @@ -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


Expand All @@ -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
Loading