diff --git a/CHANGELOG.md b/CHANGELOG.md index 572cce2..338b94e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel ### Added - Added stronger update-alert metadata to `GET /api/update`, including update type, alert level, and recommended action for operators and dashboard consumers +- 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 ## v0.6.0 - 2026-03-12 diff --git a/README.md b/README.md index af5c770..453f00e 100644 --- a/README.md +++ b/README.md @@ -509,7 +509,7 @@ Timeouts and connection errors still participate in fallback behavior and health ### Update Check Settings -FoundryGate can also cache release-update metadata for operators. This is read-only runtime behavior; it does not perform automatic upgrades. +FoundryGate can also cache release-update metadata for operators. The runtime surface stays read-only; optional helper-driven updates remain explicit and opt-in. Supported fields in `update_check`: @@ -532,6 +532,35 @@ update_check: The status is exposed through `GET /api/update`, the dashboard, and the helper script `foundrygate-update-check`. +FoundryGate also supports an optional `auto_update` policy block for controlled environments. This stays strictly opt-in and only marks whether the current release state is eligible for a helper-driven update command. + +Supported fields in `auto_update`: + +- `enabled` +- `allow_major` +- `apply_command` + +Example: + +```yaml +auto_update: + enabled: true + allow_major: false + apply_command: "foundrygate-update" +``` + +What the current runtime does with it: + +- exposes eligibility in `GET /api/update` under `auto_update` +- shows the same state in the dashboard +- lets `foundrygate-auto-update --apply` run only when the current release state is eligible + +What it still does not do: + +- it does not self-update over HTTP +- it does not schedule itself +- it does not apply major upgrades unless you opt in with `allow_major: true` + ### Provider Capability Schema Each provider can expose normalized capability metadata under `capabilities:` in `config.yaml`. This is the first building block for policy-aware routing and future local-worker support. @@ -777,6 +806,7 @@ Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin | `foundrygate-logs` | Tails `journalctl -u foundrygate.service` | | `foundrygate-health` | Calls `GET /health` locally with `curl` | | `foundrygate-update-check` | Calls `GET /api/update` locally and prints the cached release-check status | +| `foundrygate-auto-update` | Evaluates the cached update status and, with `--apply`, only runs the configured update command when the release is eligible | | `foundrygate-update` | Fetches from Git, hard-resets to `origin/main`, cleans untracked files, reinstalls the unit, restarts, and retries health checks | | `foundrygate-uninstall` | Stops and disables the service, removes the unit file, and removes helper symlinks | @@ -792,18 +822,21 @@ What it does: - caches the result for `update_check.check_interval_seconds` - exposes the cached status in `GET /api/update` - surfaces the same status in the dashboard and `foundrygate-update-check` +- exposes opt-in auto-update eligibility and the configured apply command What it does not do: - it does not download releases - it does not modify the checkout -- it does not auto-update the service +- it does not auto-update the service unless an operator explicitly wires `foundrygate-auto-update --apply` into their own scheduler Manual check: ```bash curl -fsS http://127.0.0.1:8090/api/update ./scripts/foundrygate-update-check +./scripts/foundrygate-auto-update +./scripts/foundrygate-auto-update --apply ``` ## Community And Security diff --git a/config.yaml b/config.yaml index c9703e9..b9bbe69 100644 --- a/config.yaml +++ b/config.yaml @@ -882,6 +882,14 @@ update_check: timeout_seconds: 5 check_interval_seconds: 21600 +# ── Optional Auto-Update Enabler ──────────────────────────────────────────── +# This does not make the API mutate the checkout. It only marks whether the +# current release status is eligible for a helper-driven update command. +auto_update: + enabled: false + allow_major: false + apply_command: "foundrygate-update" + # ── Prompt Caching ───────────────────────────────────────────────────────── # DeepSeek: Automatisch server-seitig (prefix-basiert, 64-token Granularität) diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md index 1c58df5..9d47a8f 100644 --- a/docs/FOUNDRYGATE-ROADMAP.md +++ b/docs/FOUNDRYGATE-ROADMAP.md @@ -212,6 +212,13 @@ 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. +The next small slice is to keep auto-update conservative: + +- disabled by default +- no checkout mutation over HTTP +- helper-driven and operator-triggered only +- major upgrades still manual unless explicitly allowed + ### `v0.8.x`: many-provider and many-client onboarding Primary goals: @@ -352,8 +359,9 @@ Current baseline: - cached release checks via `GET /api/update` - dashboard visibility for current vs latest known release - local helper access via `foundrygate-update-check` +- opt-in eligibility reporting and helper-driven apply flow via `foundrygate-auto-update` -This should remain opt-in and operationally conservative as it expands toward stronger alerts and optional controlled auto-update flows. +This should remain opt-in and operationally conservative as it expands toward scheduled helper use, stronger rollout controls, and clearer operator approval boundaries. ### 7. Distribution channels diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index b10dff3..13f2295 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -103,12 +103,12 @@ Current state: - manual updates via Git or `foundrygate-update` - cached release update checks via `GET /api/update` and `foundrygate-update-check` +- optional eligibility reporting and helper-driven apply flow via `foundrygate-auto-update` - tag-driven release artifacts for Python distributions and container images - publish dry-run workflow for Python packaging and GHCR container builds Planned state: -- stronger update alerts -- optional auto-update enablers for controlled environments +- scheduled use of `foundrygate-auto-update --apply` in controlled environments -These are roadmap items. They are not implemented as automatic runtime behavior today. +This remains opt-in. FoundryGate does not self-schedule or mutate the checkout over HTTP. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0293bc2..99c6e3c 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -149,6 +149,7 @@ Check the cached runtime view first: ```bash curl -fsS http://127.0.0.1:8090/api/update ./scripts/foundrygate-update-check +./scripts/foundrygate-auto-update ``` Common causes: @@ -174,3 +175,9 @@ Use `force=true` when you need an immediate refresh instead of the cached result ```bash curl -fsS 'http://127.0.0.1:8090/api/update?force=true' ``` + +If `foundrygate-auto-update --apply` refuses to run, inspect the `auto_update` block in the JSON response. Common blockers are: + +- `auto_update.enabled: false` +- the latest release is a major upgrade while `allow_major: false` +- the release lookup itself is unavailable diff --git a/foundrygate/config.py b/foundrygate/config.py index bf7368e..222d483 100644 --- a/foundrygate/config.py +++ b/foundrygate/config.py @@ -876,6 +876,35 @@ def _normalize_update_check(data: dict[str, Any]) -> dict[str, Any]: return normalized +def _normalize_auto_update(data: dict[str, Any]) -> dict[str, Any]: + """Validate optional auto-update helper configuration.""" + raw = data.get("auto_update", {}) + if raw is None: + raw = {} + if not isinstance(raw, dict): + raise ConfigError("'auto_update' must be a mapping") + + enabled = raw.get("enabled", False) + if not isinstance(enabled, bool): + raise ConfigError("'auto_update.enabled' must be a boolean") + + allow_major = raw.get("allow_major", False) + if not isinstance(allow_major, bool): + raise ConfigError("'auto_update.allow_major' must be a boolean") + + 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") + + normalized = dict(data) + normalized["auto_update"] = { + "enabled": enabled, + "allow_major": allow_major, + "apply_command": apply_command.strip(), + } + return normalized + + class Config: """Holds the parsed and expanded configuration.""" @@ -953,6 +982,17 @@ def update_check(self) -> dict: }, ) + @property + def auto_update(self) -> dict: + return self._data.get( + "auto_update", + { + "enabled": False, + "allow_major": False, + "apply_command": "foundrygate-update", + }, + ) + def provider(self, name: str) -> dict | None: return self.providers.get(name) @@ -978,10 +1018,12 @@ def load_config(path: str | Path | None = None) -> Config: with path.open() as f: raw = yaml.safe_load(f) - expanded = _normalize_update_check( - _normalize_request_hooks( - _normalize_client_profiles( - _normalize_routing_policies(_normalize_providers(_walk_expand(raw))) + expanded = _normalize_auto_update( + _normalize_update_check( + _normalize_request_hooks( + _normalize_client_profiles( + _normalize_routing_policies(_normalize_providers(_walk_expand(raw))) + ) ) ) ) diff --git a/foundrygate/main.py b/foundrygate/main.py index e4432b0..7abae91 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -614,6 +614,7 @@ async def lifespan(app: FastAPI): api_base=str(_config.update_check.get("api_base", "https://api.github.com")), check_interval_seconds=int(_config.update_check.get("check_interval_seconds", 21600)), timeout_seconds=float(_config.update_check.get("timeout_seconds", 5.0)), + auto_update=_config.auto_update, ) # Metrics @@ -1567,7 +1568,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.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'}
+
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'))}${update.auto_update && update.auto_update.enabled ? ` / auto: ${esc(update.auto_update.eligible ? 'eligible' : (update.auto_update.blocked_reason || 'blocked'))}` : ''}` : 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 3c22c10..fd5ed8a 100644 --- a/foundrygate/updates.py +++ b/foundrygate/updates.py @@ -89,6 +89,7 @@ class UpdateStatus: update_type: str = "current" alert_level: str = "disabled" recommended_action: str = "" + auto_update: dict[str, Any] | None = None error: str = "" def to_dict(self) -> dict[str, Any]: @@ -104,6 +105,7 @@ def to_dict(self) -> dict[str, Any]: "update_type": self.update_type, "alert_level": self.alert_level, "recommended_action": self.recommended_action, + "auto_update": self.auto_update or {}, "error": self.error, } @@ -120,6 +122,7 @@ def __init__( api_base: str = "https://api.github.com", check_interval_seconds: int = 21600, timeout_seconds: float = 5.0, + auto_update: dict[str, Any] | None = None, ): self.current_version = current_version self.enabled = enabled @@ -127,6 +130,11 @@ def __init__( self.api_base = api_base.rstrip("/") self.check_interval_seconds = check_interval_seconds self.timeout_seconds = timeout_seconds + self.auto_update = { + "enabled": bool((auto_update or {}).get("enabled", False)), + "allow_major": bool((auto_update or {}).get("allow_major", False)), + "apply_command": str((auto_update or {}).get("apply_command", "foundrygate-update")), + } self._cached = UpdateStatus( enabled=enabled, current_version=current_version, @@ -143,6 +151,49 @@ def __init__( async def close(self) -> None: await self._client.aclose() + def _auto_update_status( + self, + *, + status: str, + update_available: bool, + update_type: str, + latest_version: str = "", + ) -> dict[str, Any]: + """Return opt-in auto-update eligibility for operator tooling.""" + enabled = bool(self.auto_update.get("enabled", False)) + allow_major = bool(self.auto_update.get("allow_major", False)) + apply_command = str(self.auto_update.get("apply_command", "foundrygate-update")) + allowed_types = ["patch", "minor"] + if allow_major: + allowed_types.append("major") + + blocked_reason = "" + eligible = False + if not enabled: + blocked_reason = "Auto-update is disabled" + elif status == "disabled": + blocked_reason = "Update checks are disabled" + elif status != "ok": + blocked_reason = "Release status is unavailable" + elif not update_available: + blocked_reason = "Already on the latest release" + elif update_type not in allowed_types: + blocked_reason = f"{update_type.capitalize()} updates require manual approval" + else: + eligible = True + + return { + "enabled": enabled, + "strategy": "script", + "allowed_update_types": allowed_types, + "allow_major": allow_major, + "eligible": eligible, + "blocked_reason": blocked_reason, + "apply_command": apply_command, + "target_version": latest_version, + "requires_operator_trigger": True, + } + async def get_status(self, *, force: bool = False) -> UpdateStatus: """Return cached or freshly fetched update status.""" if not self.enabled: @@ -155,6 +206,11 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: update_type="current", alert_level="disabled", recommended_action="Update checks are disabled", + auto_update=self._auto_update_status( + status="disabled", + update_available=False, + update_type="current", + ), ) return self._cached @@ -180,6 +236,11 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: update_type="unknown", alert_level="warning", recommended_action="Inspect release connectivity and retry later", + auto_update=self._auto_update_status( + status="unavailable", + update_available=False, + update_type="unknown", + ), error=f"Release lookup returned HTTP {response.status_code}", ) return self._cached @@ -208,6 +269,12 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: recommended_action=( "Upgrade to the latest release" if update_available else "No action needed" ), + auto_update=self._auto_update_status( + status="ok", + update_available=update_available, + update_type=update_type, + latest_version=latest_version, + ), ) return self._cached except Exception as exc: @@ -220,6 +287,11 @@ async def get_status(self, *, force: bool = False) -> UpdateStatus: update_type="unknown", alert_level="warning", recommended_action="Inspect release connectivity and retry later", + auto_update=self._auto_update_status( + status="unavailable", + update_available=False, + update_type="unknown", + ), error=str(exc), ) return self._cached diff --git a/scripts/foundrygate-auto-update b/scripts/foundrygate-auto-update new file mode 100755 index 0000000..9720127 --- /dev/null +++ b/scripts/foundrygate-auto-update @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +api_url="${FOUNDRYGATE_UPDATE_API_URL:-http://127.0.0.1:8090/api/update?force=true}" +mode="${1:-}" + +payload="$(curl -fsS "$api_url")" + +if [ "$mode" = "--json" ]; then + printf '%s\n' "$payload" + exit 0 +fi + +export FOUNDRYGATE_UPDATE_PAYLOAD="$payload" + +mapfile -t parsed < <( +python3 - <<'PY' +import json +import os + +payload = json.loads(os.environ["FOUNDRYGATE_UPDATE_PAYLOAD"]) +auto = payload.get("auto_update") or {} + +current = payload.get("current_version") or "unknown" +latest = payload.get("latest_version") or current +update_type = payload.get("update_type") or "unknown" +recommended = payload.get("recommended_action") or "" +enabled = bool(auto.get("enabled")) +eligible = bool(auto.get("eligible")) +blocked = auto.get("blocked_reason") or "" +apply_command = auto.get("apply_command") or "foundrygate-update" + +for value in ( + current, + latest, + payload.get("status") or "unknown", + update_type, + recommended, + "true" if enabled else "false", + "true" if eligible else "false", + blocked, + apply_command, +): + print(value) +PY +) +current="${parsed[0]}" +latest="${parsed[1]}" +status="${parsed[2]}" +update_type="${parsed[3]}" +recommended_action="${parsed[4]}" +auto_enabled="${parsed[5]}" +auto_eligible="${parsed[6]}" +auto_blocked_reason="${parsed[7]}" +apply_command="${parsed[8]}" + +if [ "$mode" = "--apply" ]; then + if [ "$auto_enabled" != "true" ]; then + echo "auto-update is disabled" >&2 + exit 1 + fi + if [ "$auto_eligible" != "true" ]; then + echo "auto-update not eligible: ${auto_blocked_reason:-blocked}" >&2 + exit 1 + fi + + echo "Applying ${update_type} update to ${latest} via: ${apply_command}" + exec /bin/sh -lc "$apply_command" +fi + +printf 'Current: %s\nLatest: %s\nStatus: %s\nUpdate type: %s\nAction: %s\n' \ + "$current" "$latest" "$status" "$update_type" "$recommended_action" + +if [ "$auto_enabled" = "true" ]; then + if [ "$auto_eligible" = "true" ]; then + printf 'Auto-update: eligible via %s\n' "$apply_command" + else + printf 'Auto-update: blocked (%s)\n' "${auto_blocked_reason:-blocked}" + fi +else + printf 'Auto-update: disabled\n' +fi diff --git a/scripts/foundrygate-install b/scripts/foundrygate-install index c55635c..c857632 100755 --- a/scripts/foundrygate-install +++ b/scripts/foundrygate-install @@ -6,6 +6,7 @@ helpers=( foundrygate-bootstrap foundrygate-doctor foundrygate-update-check + foundrygate-auto-update foundrygate-install foundrygate-start foundrygate-stop diff --git a/scripts/foundrygate-uninstall b/scripts/foundrygate-uninstall index 7698e33..525b190 100755 --- a/scripts/foundrygate-uninstall +++ b/scripts/foundrygate-uninstall @@ -9,6 +9,7 @@ helpers=( foundrygate-logs foundrygate-health foundrygate-update + foundrygate-auto-update foundrygate-uninstall ) diff --git a/tests/test_config.py b/tests/test_config.py index 87e4b8d..09b00fc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -81,3 +81,10 @@ def test_metrics_db_path_never_dot_slash(monkeypatch): db_path = cfg.metrics["db_path"] assert not db_path.startswith("./"), f"unsafe db_path in metrics: {db_path}" assert db_path.startswith("/"), f"expected absolute path, got: {db_path}" + + +def test_auto_update_defaults_are_exposed(): + cfg = load_config(Path(__file__).parent.parent / "config.yaml") + assert cfg.auto_update["enabled"] is False + assert cfg.auto_update["allow_major"] is False + assert cfg.auto_update["apply_command"] == "foundrygate-update" diff --git a/tests/test_updates.py b/tests/test_updates.py index 42faa83..0dd4da6 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -63,6 +63,7 @@ async def test_update_checker_reports_latest_release(): current_version="0.4.0", enabled=True, repository="typelicious/FoundryGate", + auto_update={"enabled": True, "allow_major": False}, ) checker._client = _FakeClient( _FakeResponse( @@ -82,6 +83,9 @@ async def test_update_checker_reports_latest_release(): assert status.update_type == "minor" assert status.alert_level == "warning" assert status.recommended_action == "Upgrade to the latest release" + assert status.auto_update["enabled"] is True + assert status.auto_update["eligible"] is True + assert status.auto_update["allowed_update_types"] == ["patch", "minor"] assert status.release_url.endswith("/v0.5.0") @@ -128,4 +132,49 @@ async def test_update_checker_handles_remote_errors(): assert status.update_available is False assert status.alert_level == "warning" assert status.recommended_action == "Inspect release connectivity and retry later" + assert status.auto_update["eligible"] is False + assert status.auto_update["blocked_reason"] == "Auto-update is disabled" assert "network unavailable" in status.error + + +@pytest.mark.asyncio +async def test_major_updates_are_blocked_when_auto_update_disallows_them(): + checker = UpdateChecker( + current_version="0.6.0", + enabled=True, + repository="typelicious/FoundryGate", + auto_update={"enabled": True, "allow_major": False}, + ) + checker._client = _FakeClient( + _FakeResponse( + 200, + { + "tag_name": "v1.0.0", + "html_url": "https://github.com/typelicious/FoundryGate/releases/tag/v1.0.0", + }, + ) + ) + + status = await checker.get_status(force=True) + + assert status.update_type == "major" + assert status.auto_update["enabled"] is True + assert status.auto_update["eligible"] is False + assert status.auto_update["blocked_reason"] == "Major updates require manual approval" + + +@pytest.mark.asyncio +async def test_auto_update_disabled_status_is_reported_cleanly(): + checker = UpdateChecker( + current_version="0.6.0", + enabled=False, + repository="typelicious/FoundryGate", + auto_update={"enabled": False}, + ) + + status = await checker.get_status(force=False) + + assert status.status == "disabled" + assert status.auto_update["enabled"] is False + assert status.auto_update["eligible"] is False + assert status.auto_update["blocked_reason"] == "Auto-update is disabled"