Skip to content

Commit cbf445e

Browse files
authored
Merge pull request #47 from typelicious/codex/feature/v0.7-rollout-guardrails-r2-2026-03-12
feat(ops): add rollout guardrails
2 parents 509b9b9 + 667f1a6 commit cbf445e

10 files changed

Lines changed: 136 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel
1212
- Added an opt-in `auto_update` policy block plus `foundrygate-auto-update` so controlled deployments can gate helper-driven updates without enabling silent self-updates
1313
- Added `GET /api/operator-events` plus operator-event metrics for update checks and helper-driven auto-update attempts
1414
- Added dashboard cards and tables for operator-side update checks and apply attempts
15+
- Added provider-health rollout guardrails so helper-driven auto-updates can block when gateway health is already degraded
1516

1617
## v0.6.0 - 2026-03-12
1718

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,8 @@ Supported fields in `auto_update`:
543543

544544
- `enabled`
545545
- `allow_major`
546+
- `require_healthy_providers`
547+
- `max_unhealthy_providers`
546548
- `apply_command`
547549

548550
Example:
@@ -551,6 +553,8 @@ Example:
551553
auto_update:
552554
enabled: true
553555
allow_major: false
556+
require_healthy_providers: true
557+
max_unhealthy_providers: 0
554558
apply_command: "foundrygate-update"
555559
```
556560

@@ -559,6 +563,7 @@ What the current runtime does with it:
559563
- exposes eligibility in `GET /api/update` under `auto_update`
560564
- shows the same state in the dashboard
561565
- lets `foundrygate-auto-update --apply` run only when the current release state is eligible
566+
- can block helper-driven rollout when provider health is already degraded
562567

563568
What it still does not do:
564569

config.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,8 @@ update_check:
888888
auto_update:
889889
enabled: false
890890
allow_major: false
891+
require_healthy_providers: true
892+
max_unhealthy_providers: 0
891893
apply_command: "foundrygate-update"
892894

893895

docs/PUBLISHING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ If you want scheduled update application:
6262

6363
- keep `auto_update.enabled: true` explicit in `config.yaml`
6464
- keep `allow_major: false` unless you are ready to absorb breaking changes automatically
65+
- keep `require_healthy_providers: true` unless you are intentionally allowing rollouts while the gateway is degraded
6566
- prefer the reviewed examples in [examples/foundrygate-auto-update.service](./examples/foundrygate-auto-update.service) and [examples/foundrygate-auto-update.timer](./examples/foundrygate-auto-update.timer)
6667
- use the cron example in [examples/foundrygate-auto-update.cron](./examples/foundrygate-auto-update.cron) only when `systemd` timers are not practical
6768

docs/TROUBLESHOOTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,6 @@ If `foundrygate-auto-update --apply` refuses to run, inspect the `auto_update` b
181181

182182
- `auto_update.enabled: false`
183183
- the latest release is a major upgrade while `allow_major: false`
184+
- one or more providers are unhealthy while `require_healthy_providers: true`
185+
- the number of unhealthy providers exceeds `max_unhealthy_providers`
184186
- the release lookup itself is unavailable

foundrygate/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,16 @@ def _normalize_auto_update(data: dict[str, Any]) -> dict[str, Any]:
892892
if not isinstance(allow_major, bool):
893893
raise ConfigError("'auto_update.allow_major' must be a boolean")
894894

895+
require_healthy_providers = raw.get("require_healthy_providers", True)
896+
if not isinstance(require_healthy_providers, bool):
897+
raise ConfigError("'auto_update.require_healthy_providers' must be a boolean")
898+
899+
max_unhealthy_providers = raw.get("max_unhealthy_providers", 0)
900+
if isinstance(max_unhealthy_providers, bool) or not isinstance(max_unhealthy_providers, int):
901+
raise ConfigError("'auto_update.max_unhealthy_providers' must be a non-negative integer")
902+
if max_unhealthy_providers < 0:
903+
raise ConfigError("'auto_update.max_unhealthy_providers' must be non-negative")
904+
895905
apply_command = raw.get("apply_command", "foundrygate-update")
896906
if not isinstance(apply_command, str) or not apply_command.strip():
897907
raise ConfigError("'auto_update.apply_command' must be a non-empty string")
@@ -900,6 +910,8 @@ def _normalize_auto_update(data: dict[str, Any]) -> dict[str, Any]:
900910
normalized["auto_update"] = {
901911
"enabled": enabled,
902912
"allow_major": allow_major,
913+
"require_healthy_providers": require_healthy_providers,
914+
"max_unhealthy_providers": max_unhealthy_providers,
903915
"apply_command": apply_command.strip(),
904916
}
905917
return normalized
@@ -989,6 +1001,8 @@ def auto_update(self) -> dict:
9891001
{
9901002
"enabled": False,
9911003
"allow_major": False,
1004+
"require_healthy_providers": True,
1005+
"max_unhealthy_providers": 0,
9921006
"apply_command": "foundrygate-update",
9931007
},
9941008
)

foundrygate/main.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from .metrics import MetricsStore, calc_cost
2424
from .providers import ProviderBackend, ProviderError
2525
from .router import Router, RoutingDecision
26-
from .updates import UpdateChecker
26+
from .updates import UpdateChecker, apply_auto_update_guardrails
2727

2828
logger = logging.getLogger("foundrygate")
2929

@@ -248,6 +248,17 @@ def _build_capability_coverage() -> dict[str, dict[str, Any]]:
248248
return dict(sorted(coverage.items()))
249249

250250

251+
def _health_summary() -> dict[str, int]:
252+
"""Return a compact provider-health summary for operator guardrails."""
253+
providers_healthy = sum(1 for provider in _providers.values() if provider.health.healthy)
254+
providers_unhealthy = sum(1 for provider in _providers.values() if not provider.health.healthy)
255+
return {
256+
"providers_total": len(_providers),
257+
"providers_healthy": providers_healthy,
258+
"providers_unhealthy": providers_unhealthy,
259+
}
260+
261+
251262
def _estimate_request_dimensions(body: dict[str, Any]) -> dict[str, int | str]:
252263
"""Return lightweight request-dimension estimates for debugging and routing preview."""
253264
messages = body.get("messages", [])
@@ -675,13 +686,7 @@ async def health():
675686
}
676687
return {
677688
"status": "ok",
678-
"summary": {
679-
"providers_total": len(providers),
680-
"providers_healthy": sum(1 for provider in providers.values() if provider["healthy"]),
681-
"providers_unhealthy": sum(
682-
1 for provider in providers.values() if not provider["healthy"]
683-
),
684-
},
689+
"summary": _health_summary(),
685690
"coverage": _build_capability_coverage(),
686691
"providers": providers,
687692
}
@@ -821,6 +826,11 @@ async def update_status(request: Request, force: bool = False):
821826
"""Return cached or fresh release update metadata."""
822827
headers = _collect_routing_headers(request)
823828
status = await _update_checker.get_status(force=force)
829+
status.auto_update = apply_auto_update_guardrails(
830+
status.auto_update or {},
831+
providers_healthy=_health_summary()["providers_healthy"],
832+
providers_unhealthy=_health_summary()["providers_unhealthy"],
833+
)
824834
operator_action, client_tag = _collect_operator_context(headers)
825835
auto_update = status.auto_update or {}
826836
_metrics.log_operator_event(

foundrygate/updates.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,38 @@ def alert_level_for_update(update_type: str, *, available: bool, status: str) ->
7474
return "warning"
7575

7676

77+
def apply_auto_update_guardrails(
78+
auto_update: dict[str, Any],
79+
*,
80+
providers_healthy: int,
81+
providers_unhealthy: int,
82+
) -> dict[str, Any]:
83+
"""Apply provider-health guardrails to one auto-update eligibility result."""
84+
result = dict(auto_update or {})
85+
if not result.get("enabled") or not result.get("eligible"):
86+
return result
87+
88+
require_healthy_providers = bool(result.get("require_healthy_providers", True))
89+
max_unhealthy_providers = int(result.get("max_unhealthy_providers", 0))
90+
91+
if not require_healthy_providers:
92+
return result
93+
94+
if providers_healthy <= 0:
95+
result["eligible"] = False
96+
result["blocked_reason"] = "No healthy providers available"
97+
return result
98+
99+
if providers_unhealthy > max_unhealthy_providers:
100+
result["eligible"] = False
101+
result["blocked_reason"] = (
102+
f"Too many unhealthy providers ({providers_unhealthy} > {max_unhealthy_providers})"
103+
)
104+
return result
105+
106+
return result
107+
108+
77109
@dataclass
78110
class UpdateStatus:
79111
"""Structured update-check result."""
@@ -133,6 +165,10 @@ def __init__(
133165
self.auto_update = {
134166
"enabled": bool((auto_update or {}).get("enabled", False)),
135167
"allow_major": bool((auto_update or {}).get("allow_major", False)),
168+
"require_healthy_providers": bool(
169+
(auto_update or {}).get("require_healthy_providers", True)
170+
),
171+
"max_unhealthy_providers": int((auto_update or {}).get("max_unhealthy_providers", 0)),
136172
"apply_command": str((auto_update or {}).get("apply_command", "foundrygate-update")),
137173
}
138174
self._cached = UpdateStatus(
@@ -187,6 +223,10 @@ def _auto_update_status(
187223
"strategy": "script",
188224
"allowed_update_types": allowed_types,
189225
"allow_major": allow_major,
226+
"require_healthy_providers": bool(
227+
self.auto_update.get("require_healthy_providers", True)
228+
),
229+
"max_unhealthy_providers": int(self.auto_update.get("max_unhealthy_providers", 0)),
190230
"eligible": eligible,
191231
"blocked_reason": blocked_reason,
192232
"apply_command": apply_command,

tests/test_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,6 @@ def test_auto_update_defaults_are_exposed():
8787
cfg = load_config(Path(__file__).parent.parent / "config.yaml")
8888
assert cfg.auto_update["enabled"] is False
8989
assert cfg.auto_update["allow_major"] is False
90+
assert cfg.auto_update["require_healthy_providers"] is True
91+
assert cfg.auto_update["max_unhealthy_providers"] == 0
9092
assert cfg.auto_update["apply_command"] == "foundrygate-update"

tests/test_updates.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from foundrygate.updates import (
88
UpdateChecker,
99
alert_level_for_update,
10+
apply_auto_update_guardrails,
1011
classify_update,
1112
is_update_available,
1213
)
@@ -57,6 +58,56 @@ def test_alert_level_maps_update_type_and_status():
5758
assert alert_level_for_update("unknown", available=False, status="unavailable") == "warning"
5859

5960

61+
def test_auto_update_guardrails_block_when_too_many_providers_are_unhealthy():
62+
guarded = apply_auto_update_guardrails(
63+
{
64+
"enabled": True,
65+
"eligible": True,
66+
"require_healthy_providers": True,
67+
"max_unhealthy_providers": 0,
68+
"blocked_reason": "",
69+
},
70+
providers_healthy=1,
71+
providers_unhealthy=1,
72+
)
73+
74+
assert guarded["eligible"] is False
75+
assert guarded["blocked_reason"] == "Too many unhealthy providers (1 > 0)"
76+
77+
78+
def test_auto_update_guardrails_allow_updates_when_health_budget_is_met():
79+
guarded = apply_auto_update_guardrails(
80+
{
81+
"enabled": True,
82+
"eligible": True,
83+
"require_healthy_providers": True,
84+
"max_unhealthy_providers": 1,
85+
"blocked_reason": "",
86+
},
87+
providers_healthy=2,
88+
providers_unhealthy=1,
89+
)
90+
91+
assert guarded["eligible"] is True
92+
93+
94+
def test_auto_update_guardrails_block_when_no_provider_is_healthy():
95+
guarded = apply_auto_update_guardrails(
96+
{
97+
"enabled": True,
98+
"eligible": True,
99+
"require_healthy_providers": True,
100+
"max_unhealthy_providers": 2,
101+
"blocked_reason": "",
102+
},
103+
providers_healthy=0,
104+
providers_unhealthy=2,
105+
)
106+
107+
assert guarded["eligible"] is False
108+
assert guarded["blocked_reason"] == "No healthy providers available"
109+
110+
60111
@pytest.mark.asyncio
61112
async def test_update_checker_reports_latest_release():
62113
checker = UpdateChecker(

0 commit comments

Comments
 (0)