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 @@ -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

Expand Down
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

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

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion docs/FOUNDRYGATE-ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
50 changes: 46 additions & 4 deletions foundrygate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

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

Expand All @@ -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)))
)
)
)
)
Expand Down
3 changes: 2 additions & 1 deletion foundrygate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1567,7 +1568,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.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>
<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'))}${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'}</div></div>
`;

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

Expand All @@ -120,13 +122,19 @@ 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
self.repository = repository
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,
Expand All @@ -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:
Expand All @@ -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

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