From aee05609f33449bde7bced33636f0ece85d370a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Lange?= Date: Thu, 12 Mar 2026 22:10:17 +0100 Subject: [PATCH] feat(onboarding): add onboarding readiness report --- CHANGELOG.md | 1 + README.md | 2 + docs/ONBOARDING.md | 8 + foundrygate/onboarding.py | 208 ++++++++++++++++++++++++++ scripts/foundrygate-install | 1 + scripts/foundrygate-onboarding-report | 28 ++++ scripts/foundrygate-uninstall | 1 + tests/test_onboarding.py | 102 +++++++++++++ 8 files changed, 351 insertions(+) create mode 100644 foundrygate/onboarding.py create mode 100644 scripts/foundrygate-onboarding-report create mode 100644 tests/test_onboarding.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 403eddb..0514c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is intentionally lightweight and human-readable. Group entries by rel ### Added +- Added `foundrygate-onboarding-report` plus a testable onboarding report module for many-provider and many-client readiness checks - 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 - Added `GET /api/operator-events` plus operator-event metrics for update checks and helper-driven auto-update attempts diff --git a/README.md b/README.md index 5a64a9c..cee6c33 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ If you want the fastest local bootstrap, use the generic helpers first: ./scripts/foundrygate-bootstrap $EDITOR .env ./scripts/foundrygate-doctor +./scripts/foundrygate-onboarding-report ``` If you prefer the Linux service path instead of a manual Python run, jump to [Helper Scripts](#helper-scripts) and use `./scripts/foundrygate-install`. @@ -836,6 +837,7 @@ Running `./scripts/foundrygate-install` also creates symlinks in `/usr/local/bin | --- | --- | | `foundrygate-bootstrap` | Creates `.env` from `.env.example` if needed, creates a local state dir, and appends a safe local `FOUNDRYGATE_DB_PATH` if none is set | | `foundrygate-doctor` | Checks for config/env presence, writable DB path, at least one configured provider key, and optional local health endpoints | +| `foundrygate-onboarding-report` | Summarizes provider readiness, client-profile coverage, routing layers, and onboarding suggestions for many-provider and many-client setups | | `foundrygate-install` | Installs the unit file, creates `/var/lib/foundrygate`, creates helper symlinks, reloads `systemd`, and starts the service | | `foundrygate-start` | Runs `systemctl start foundrygate.service` | | `foundrygate-stop` | Runs `systemctl stop foundrygate.service` | diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 46e15e2..04cb163 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -23,6 +23,7 @@ Run the generic helpers before changing config: ./scripts/foundrygate-bootstrap $EDITOR .env ./scripts/foundrygate-doctor +./scripts/foundrygate-onboarding-report ``` ### 1. Add one provider @@ -47,6 +48,13 @@ $EDITOR .env Repeat the same path before introducing more routing complexity. +For many-provider rollouts, run the onboarding report after every provider change: + +```bash +./scripts/foundrygate-onboarding-report +./scripts/foundrygate-onboarding-report --json +``` + ## Client onboarding sequence ### 1. Keep the client on the common API diff --git a/foundrygate/onboarding.py b/foundrygate/onboarding.py new file mode 100644 index 0000000..dec48ff --- /dev/null +++ b/foundrygate/onboarding.py @@ -0,0 +1,208 @@ +"""Onboarding reporting helpers for many-provider and many-client setups.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from dotenv import dotenv_values, load_dotenv + +from .config import load_config + + +def _env_path(env_file: str | Path | None = None) -> Path: + """Return the effective env file path.""" + if env_file is not None: + return Path(env_file) + return Path.cwd() / ".env" + + +def _provider_ready(provider: dict[str, Any]) -> tuple[bool, str]: + """Return whether one provider looks ready for onboarding.""" + contract = provider.get("contract", "generic") + backend = provider.get("backend", "openai-compat") + api_key = str(provider.get("api_key", "") or "").strip() + base_url = str(provider.get("base_url", "") or "").strip() + + if contract == "local-worker": + if not base_url: + return False, "missing base_url" + return True, "local worker contract" + + if contract == "image-provider" and not base_url: + return False, "missing base_url" + + if backend in {"openai-compat", "google-genai", "anthropic-compat"} and not api_key: + return False, "missing api_key" + + return True, "configured" + + +def build_onboarding_report( + *, + config_path: str | Path | None = None, + env_file: str | Path | None = None, +) -> dict[str, Any]: + """Return a structured onboarding report for providers and clients.""" + resolved_env = _env_path(env_file) + load_dotenv(resolved_env, override=True) + config = load_config(config_path) + env_values = dotenv_values(resolved_env) if resolved_env.exists() else {} + + providers = [] + ready = 0 + local_workers = 0 + image_capable = 0 + missing_api_keys = [] + + for name, provider in sorted(config.providers.items()): + is_ready, readiness_reason = _provider_ready(provider) + if is_ready: + ready += 1 + if provider.get("contract") == "local-worker": + local_workers += 1 + capabilities = provider.get("capabilities") or {} + if capabilities.get("image_generation") or capabilities.get("image_editing"): + image_capable += 1 + if readiness_reason == "missing api_key": + missing_api_keys.append(name) + providers.append( + { + "name": name, + "backend": provider.get("backend", "openai-compat"), + "contract": provider.get("contract", "generic"), + "model": provider.get("model", ""), + "tier": provider.get("tier", ""), + "ready": is_ready, + "readiness_reason": readiness_reason, + "capabilities": capabilities, + } + ) + + client_profiles = config.client_profiles + routing_policies = config.routing_policies + request_hooks = config.request_hooks + update_check = config.update_check + auto_update = config.auto_update + + suggestions = [] + if not providers: + suggestions.append("Add one provider before onboarding clients.") + if providers and ready == 0: + suggestions.append( + "Configure at least one ready provider with a real key or local worker URL." + ) + if not client_profiles.get("enabled"): + suggestions.append("Enable client_profiles when multiple clients share one gateway.") + if not client_profiles.get("presets"): + suggestions.append("Start with client_profiles.presets for openclaw, n8n, or cli.") + if len(config.fallback_chain) == 0: + suggestions.append("Set a fallback_chain before onboarding multiple clients.") + if update_check.get("enabled") and not auto_update.get("enabled"): + suggestions.append("Keep auto_update disabled until the provider and client set is stable.") + + return { + "config_path": str(Path(config_path) if config_path else Path.cwd() / "config.yaml"), + "env_file": str(resolved_env), + "env": { + "exists": resolved_env.exists(), + "provider_keys_present": sorted(key for key, value in env_values.items() if value), + }, + "providers": { + "total": len(providers), + "ready": ready, + "not_ready": len(providers) - ready, + "local_workers": local_workers, + "image_capable": image_capable, + "missing_api_keys": missing_api_keys, + "items": providers, + }, + "clients": { + "profiles_enabled": bool(client_profiles.get("enabled")), + "default_profile": client_profiles.get("default", "generic"), + "presets": list(client_profiles.get("presets", [])), + "profile_count": len(client_profiles.get("profiles", {})), + "rule_count": len(client_profiles.get("rules", [])), + }, + "routing": { + "fallback_chain": list(config.fallback_chain), + "policy_layer_enabled": bool(routing_policies.get("enabled")), + "policy_rule_count": len(routing_policies.get("rules", [])), + "request_hooks_enabled": bool(request_hooks.get("enabled")), + "request_hook_count": len(request_hooks.get("hooks", [])), + }, + "operations": { + "update_checks_enabled": bool(update_check.get("enabled")), + "auto_update_enabled": bool(auto_update.get("enabled")), + "rollout_ring": auto_update.get("rollout_ring", "early"), + }, + "suggestions": suggestions, + } + + +def render_onboarding_report(report: dict[str, Any]) -> str: + """Render the onboarding report as plain text.""" + provider_block = report["providers"] + client_block = report["clients"] + routing_block = report["routing"] + ops_block = report["operations"] + preset_text = ", ".join(client_block["presets"]) if client_block["presets"] else "none" + fallback_text = ( + ", ".join(routing_block["fallback_chain"]) if routing_block["fallback_chain"] else "none" + ) + + lines = [ + "FoundryGate onboarding report", + "", + f"Config: {report['config_path']}", + f"Env : {report['env_file']}", + "", + "Providers", + f"- total: {provider_block['total']}", + f"- ready: {provider_block['ready']}", + f"- not ready: {provider_block['not_ready']}", + f"- local workers: {provider_block['local_workers']}", + f"- image-capable: {provider_block['image_capable']}", + ] + if provider_block["missing_api_keys"]: + lines.append(f"- missing api_key: {', '.join(provider_block['missing_api_keys'])}") + + if provider_block["items"]: + lines.append("") + lines.append("Provider inventory") + for item in provider_block["items"]: + readiness = "ready" if item["ready"] else f"not ready ({item['readiness_reason']})" + lines.append( + f"- {item['name']}: {item['contract']} / {item['backend']} / " + f"{item['tier'] or 'default'} / {readiness}" + ) + + lines.extend( + [ + "", + "Clients", + f"- profiles enabled: {client_block['profiles_enabled']}", + f"- default profile: {client_block['default_profile']}", + f"- presets: {preset_text}", + f"- profiles: {client_block['profile_count']}", + f"- rules: {client_block['rule_count']}", + "", + "Routing", + f"- fallback chain: {fallback_text}", + f"- policy layer: {routing_block['policy_layer_enabled']} " + f"({routing_block['policy_rule_count']} rules)", + f"- request hooks: {routing_block['request_hooks_enabled']} " + f"({routing_block['request_hook_count']} hooks)", + "", + "Operations", + f"- update checks: {ops_block['update_checks_enabled']}", + f"- auto update: {ops_block['auto_update_enabled']}", + f"- rollout ring: {ops_block['rollout_ring']}", + ] + ) + + if report["suggestions"]: + lines.extend(["", "Suggestions"]) + lines.extend(f"- {item}" for item in report["suggestions"]) + + return "\n".join(lines) + "\n" diff --git a/scripts/foundrygate-install b/scripts/foundrygate-install index c857632..3b3b03a 100755 --- a/scripts/foundrygate-install +++ b/scripts/foundrygate-install @@ -5,6 +5,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" helpers=( foundrygate-bootstrap foundrygate-doctor + foundrygate-onboarding-report foundrygate-update-check foundrygate-auto-update foundrygate-install diff --git a/scripts/foundrygate-onboarding-report b/scripts/foundrygate-onboarding-report new file mode 100644 index 0000000..d6bd9aa --- /dev/null +++ b/scripts/foundrygate-onboarding-report @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +config_file="${FOUNDRYGATE_CONFIG_FILE:-$repo_root/config.yaml}" +env_file="${FOUNDRYGATE_ENV_FILE:-$repo_root/.env}" +mode="${1:-text}" + +export FOUNDRYGATE_ONBOARDING_CONFIG="$config_file" +export FOUNDRYGATE_ONBOARDING_ENV="$env_file" +export FOUNDRYGATE_ONBOARDING_MODE="$mode" + +python3 - <<'PY' +import json +import os + +from foundrygate.onboarding import build_onboarding_report, render_onboarding_report + +report = build_onboarding_report( + config_path=os.environ["FOUNDRYGATE_ONBOARDING_CONFIG"], + env_file=os.environ["FOUNDRYGATE_ONBOARDING_ENV"], +) + +if os.environ["FOUNDRYGATE_ONBOARDING_MODE"] == "--json": + print(json.dumps(report, indent=2, sort_keys=True)) +else: + print(render_onboarding_report(report), end="") +PY diff --git a/scripts/foundrygate-uninstall b/scripts/foundrygate-uninstall index 525b190..dd93d7e 100755 --- a/scripts/foundrygate-uninstall +++ b/scripts/foundrygate-uninstall @@ -2,6 +2,7 @@ set -euo pipefail helpers=( foundrygate-install + foundrygate-onboarding-report foundrygate-start foundrygate-stop foundrygate-restart diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py new file mode 100644 index 0000000..8bee856 --- /dev/null +++ b/tests/test_onboarding.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from pathlib import Path + +from foundrygate.onboarding import build_onboarding_report, render_onboarding_report + + +def test_onboarding_report_marks_missing_api_keys_and_presets(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("DEEPSEEK_API_KEY=\n", encoding="utf-8") + + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +fallback_chain: + - deepseek-chat +providers: + deepseek-chat: + backend: openai-compat + base_url: "https://api.deepseek.com/v1" + api_key: "${DEEPSEEK_API_KEY}" + model: "deepseek-chat" + tier: default +client_profiles: + enabled: true + default: generic + presets: ["openclaw"] + profiles: + generic: {} + rules: [] +routing_policies: + enabled: false + rules: [] +request_hooks: + enabled: false + hooks: [] +update_check: + enabled: true + repository: "typelicious/FoundryGate" +auto_update: + enabled: false +""".strip(), + encoding="utf-8", + ) + + report = build_onboarding_report(config_path=config_file, env_file=env_file) + + assert report["providers"]["total"] == 1 + assert report["providers"]["ready"] == 0 + assert report["providers"]["missing_api_keys"] == ["deepseek-chat"] + assert report["clients"]["presets"] == ["openclaw"] + assert ( + "Keep auto_update disabled until the provider and client set is stable." + in report["suggestions"] + ) + + +def test_onboarding_report_marks_local_worker_ready(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("", encoding="utf-8") + + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +fallback_chain: + - local-worker +providers: + local-worker: + contract: local-worker + backend: openai-compat + base_url: "http://127.0.0.1:11434/v1" + api_key: "local" + model: "llama3" + tier: local + capabilities: + local: true + cloud: false +client_profiles: + enabled: false + profiles: + generic: {} + rules: [] +routing_policies: + enabled: false + rules: [] +request_hooks: + enabled: false + hooks: [] +update_check: + enabled: false +auto_update: + enabled: false +""".strip(), + encoding="utf-8", + ) + + report = build_onboarding_report(config_path=config_file, env_file=env_file) + text = render_onboarding_report(report) + + assert report["providers"]["ready"] == 1 + assert report["providers"]["local_workers"] == 1 + assert "local-worker: local-worker / openai-compat / local / ready" in text