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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel
- Added provider-health rollout guardrails so helper-driven auto-updates can block when gateway health is already degraded
- Added `update_check.release_channel` and `auto_update.rollout_ring` so operators can distinguish stable vs preview checks and tighter rollout rings
- Added `auto_update.min_release_age_hours` so helper-driven auto-updates can wait for a release to age before becoming eligible
- Added `auto_update.maintenance_window` so helper-driven auto-updates can stay inside explicit local maintenance hours

## v0.6.0 - 2026-03-12

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ Supported fields in `auto_update`:
- `require_healthy_providers`
- `max_unhealthy_providers`
- `min_release_age_hours`
- `maintenance_window`
- `apply_command`

Example:
Expand All @@ -561,6 +562,12 @@ auto_update:
require_healthy_providers: true
max_unhealthy_providers: 0
min_release_age_hours: 24
maintenance_window:
enabled: true
timezone: "Europe/Berlin"
days: ["sat", "sun"]
start_hour: 2
end_hour: 5
apply_command: "foundrygate-update"
```

Expand All @@ -572,6 +579,7 @@ What the current runtime does with it:
- can block helper-driven rollout when provider health is already degraded
- lets operators separate `stable` vs `preview` release checks and `stable` / `early` / `canary` rollout rings
- can require that a release has aged for a minimum number of hours before helper-driven rollout
- can restrict helper-driven rollout to explicit local maintenance windows

What it still does not do:

Expand Down Expand Up @@ -848,6 +856,7 @@ What it does not do:
- it does not download releases
- it does not modify the checkout
- it does not auto-update the service unless an operator explicitly wires `foundrygate-auto-update --apply` into their own scheduler
- it does not bypass maintenance windows, release-age gates, rollout rings, or provider-health guardrails

Manual check:

Expand Down
6 changes: 6 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,12 @@ auto_update:
require_healthy_providers: true
max_unhealthy_providers: 0
min_release_age_hours: 0
maintenance_window:
enabled: false
timezone: "UTC"
days: ["sat", "sun"]
start_hour: 2
end_hour: 5
apply_command: "foundrygate-update"


Expand Down
1 change: 1 addition & 0 deletions docs/PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ If you want scheduled update application:
- keep `allow_major: false` unless you are ready to absorb breaking changes automatically
- keep `require_healthy_providers: true` unless you are intentionally allowing rollouts while the gateway is degraded
- set `min_release_age_hours` above `0` if you want scheduled rollouts to wait before applying newly published releases
- add `maintenance_window` if scheduled updates should only run in explicit local maintenance hours
- 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)
- use the cron example in [examples/foundrygate-auto-update.cron](./examples/foundrygate-auto-update.cron) only when `systemd` timers are not practical

Expand Down
2 changes: 2 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,6 @@ If `foundrygate-auto-update --apply` refuses to run, inspect the `auto_update` b
- the latest release is a major upgrade while `allow_major: false`
- one or more providers are unhealthy while `require_healthy_providers: true`
- the number of unhealthy providers exceeds `max_unhealthy_providers`
- the current time is outside the configured `maintenance_window.days` or `maintenance_window.start_hour` / `end_hour`
- `maintenance_window.timezone` is invalid for the host runtime
- the release lookup itself is unavailable
54 changes: 54 additions & 0 deletions foundrygate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
_CLIENT_PROFILE_MATCH_KEYS = {"header_contains", "header_present", "any", "all"}
_SUPPORTED_CLIENT_PROFILE_PRESETS = {"openclaw", "n8n", "cli"}
_SUPPORTED_REQUEST_HOOKS = set(get_registered_request_hooks())
_SUPPORTED_WINDOW_DAYS = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"}

_CLIENT_PROFILE_PRESET_SPECS: dict[str, dict[str, Any]] = {
"openclaw": {
Expand Down Expand Up @@ -917,6 +918,45 @@ def _normalize_auto_update(data: dict[str, Any]) -> dict[str, Any]:
if min_release_age_hours < 0:
raise ConfigError("'auto_update.min_release_age_hours' must be non-negative")

maintenance_window = raw.get("maintenance_window", {})
if maintenance_window is None:
maintenance_window = {}
if not isinstance(maintenance_window, dict):
raise ConfigError("'auto_update.maintenance_window' must be a mapping")

window_enabled = maintenance_window.get("enabled", False)
if not isinstance(window_enabled, bool):
raise ConfigError("'auto_update.maintenance_window.enabled' must be a boolean")

timezone = maintenance_window.get("timezone", "UTC")
if not isinstance(timezone, str) or not timezone.strip():
raise ConfigError("'auto_update.maintenance_window.timezone' must be a non-empty string")

days = _normalize_string_list(
maintenance_window.get("days", []),
field_name="days",
rule_name="auto_update.maintenance_window",
allow_empty=True,
)
unknown_days = sorted(set(days) - _SUPPORTED_WINDOW_DAYS)
if unknown_days:
raise ConfigError(
"'auto_update.maintenance_window.days' has unknown weekday values: "
+ ", ".join(unknown_days)
)

start_hour = maintenance_window.get("start_hour", 0)
end_hour = maintenance_window.get("end_hour", 24)
for key, value in {"start_hour": start_hour, "end_hour": end_hour}.items():
if isinstance(value, bool) or not isinstance(value, int):
raise ConfigError(f"'auto_update.maintenance_window.{key}' must be an integer")
if not 0 <= start_hour <= 23:
raise ConfigError("'auto_update.maintenance_window.start_hour' must be between 0 and 23")
if not 1 <= end_hour <= 24:
raise ConfigError("'auto_update.maintenance_window.end_hour' must be between 1 and 24")
if start_hour == end_hour:
raise ConfigError("'auto_update.maintenance_window' must not use the same start/end hour")

apply_command = raw.get("apply_command", "foundrygate-update")
if not isinstance(apply_command, str) or not apply_command.strip():
raise ConfigError("'auto_update.apply_command' must be a non-empty string")
Expand All @@ -929,6 +969,13 @@ def _normalize_auto_update(data: dict[str, Any]) -> dict[str, Any]:
"require_healthy_providers": require_healthy_providers,
"max_unhealthy_providers": max_unhealthy_providers,
"min_release_age_hours": min_release_age_hours,
"maintenance_window": {
"enabled": window_enabled,
"timezone": timezone.strip(),
"days": days,
"start_hour": start_hour,
"end_hour": end_hour,
},
"apply_command": apply_command.strip(),
}
return normalized
Expand Down Expand Up @@ -1023,6 +1070,13 @@ def auto_update(self) -> dict:
"require_healthy_providers": True,
"max_unhealthy_providers": 0,
"min_release_age_hours": 0,
"maintenance_window": {
"enabled": False,
"timezone": "UTC",
"days": [],
"start_hour": 0,
"end_hour": 24,
},
"apply_command": "foundrygate-update",
},
)
Expand Down
7 changes: 6 additions & 1 deletion foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
from .metrics import MetricsStore, calc_cost
from .providers import ProviderBackend, ProviderError
from .router import Router, RoutingDecision
from .updates import UpdateChecker, apply_auto_update_guardrails
from .updates import (
UpdateChecker,
apply_auto_update_guardrails,
apply_maintenance_window_guardrail,
)

logger = logging.getLogger("foundrygate")

Expand Down Expand Up @@ -832,6 +836,7 @@ async def update_status(request: Request, force: bool = False):
providers_healthy=_health_summary()["providers_healthy"],
providers_unhealthy=_health_summary()["providers_unhealthy"],
)
status.auto_update = apply_maintenance_window_guardrail(status.auto_update or {})
operator_action, client_tag = _collect_operator_context(headers)
auto_update = status.auto_update or {}
_metrics.log_operator_event(
Expand Down
64 changes: 62 additions & 2 deletions foundrygate/updates.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@

import time
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import httpx

Expand Down Expand Up @@ -111,7 +112,7 @@ def release_age_hours(published_at: str, *, now: datetime | None = None) -> floa
published = datetime.fromisoformat(published_at.replace("Z", "+00:00"))
except ValueError:
return None
current = now or datetime.now(UTC)
current = now or datetime.now(timezone.utc)
return max(0.0, (current - published).total_seconds() / 3600)


Expand Down Expand Up @@ -175,6 +176,63 @@ def apply_release_age_guardrail(
return result


def apply_maintenance_window_guardrail(
auto_update: dict[str, Any],
*,
now: datetime | None = None,
) -> dict[str, Any]:
"""Apply an optional maintenance-window guardrail to one auto-update status."""
result = dict(auto_update or {})
window = dict(result.get("maintenance_window") or {})
if not result.get("enabled") or not result.get("eligible"):
result["maintenance_window"] = window
return result

if not window.get("enabled", False):
window["open"] = True
result["maintenance_window"] = window
return result

timezone_name = str(window.get("timezone", "UTC"))
try:
zone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
result["eligible"] = False
result["blocked_reason"] = f"Unknown maintenance-window timezone '{timezone_name}'"
window["open"] = False
result["maintenance_window"] = window
return result

current = (now or datetime.now(timezone.utc)).astimezone(zone)
day_name = current.strftime("%a").lower()[:3]
allowed_days = list(window.get("days") or [])
start_hour = int(window.get("start_hour", 0))
end_hour = int(window.get("end_hour", 24))
current_hour = current.hour

day_allowed = not allowed_days or day_name in allowed_days
if start_hour < end_hour:
hour_allowed = start_hour <= current_hour < end_hour
else:
hour_allowed = current_hour >= start_hour or current_hour < end_hour

window["open"] = bool(day_allowed and hour_allowed)
window["current_day"] = day_name
window["current_hour"] = current_hour
result["maintenance_window"] = window

if not day_allowed:
result["eligible"] = False
result["blocked_reason"] = f"Outside maintenance days ({day_name})"
return result
if not hour_allowed:
result["eligible"] = False
result["blocked_reason"] = (
f"Outside maintenance window ({start_hour:02d}:00-{end_hour:02d}:00 {timezone_name})"
)
return result


@dataclass
class UpdateStatus:
"""Structured update-check result."""
Expand Down Expand Up @@ -246,6 +304,7 @@ def __init__(
),
"max_unhealthy_providers": int((auto_update or {}).get("max_unhealthy_providers", 0)),
"min_release_age_hours": int((auto_update or {}).get("min_release_age_hours", 0)),
"maintenance_window": dict((auto_update or {}).get("maintenance_window") or {}),
"apply_command": str((auto_update or {}).get("apply_command", "foundrygate-update")),
}
self._cached = UpdateStatus(
Expand Down Expand Up @@ -306,6 +365,7 @@ def _auto_update_status(
),
"max_unhealthy_providers": int(self.auto_update.get("max_unhealthy_providers", 0)),
"min_release_age_hours": int(self.auto_update.get("min_release_age_hours", 0)),
"maintenance_window": dict(self.auto_update.get("maintenance_window") or {}),
"eligible": eligible,
"blocked_reason": blocked_reason,
"apply_command": apply_command,
Expand Down
5 changes: 5 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ def test_auto_update_defaults_are_exposed():
assert cfg.auto_update["require_healthy_providers"] is True
assert cfg.auto_update["max_unhealthy_providers"] == 0
assert cfg.auto_update["min_release_age_hours"] == 0
assert cfg.auto_update["maintenance_window"]["enabled"] is False
assert cfg.auto_update["maintenance_window"]["timezone"] == "UTC"
assert cfg.auto_update["maintenance_window"]["days"] == ["sat", "sun"]
assert cfg.auto_update["maintenance_window"]["start_hour"] == 2
assert cfg.auto_update["maintenance_window"]["end_hour"] == 5
assert cfg.auto_update["apply_command"] == "foundrygate-update"


Expand Down
Loading
Loading