diff --git a/CHANGELOG.md b/CHANGELOG.md index 65c159b..e103f45 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 `foundrygate-onboarding-report` plus a testable onboarding report module for many-provider and many-client readiness checks +- Added `foundrygate-onboarding-validate` so onboarding blockers can fail fast in local setup and CI-style validation flows ## v0.7.0 - 2026-03-12 diff --git a/README.md b/README.md index cee6c33..43b36f6 100644 --- a/README.md +++ b/README.md @@ -838,6 +838,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-onboarding-validate` | Exits non-zero when onboarding blockers exist and prints warnings for common multi-provider and multi-client misconfigurations | | `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 04cb163..9191f02 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -53,6 +53,7 @@ For many-provider rollouts, run the onboarding report after every provider chang ```bash ./scripts/foundrygate-onboarding-report ./scripts/foundrygate-onboarding-report --json +./scripts/foundrygate-onboarding-validate ``` ## Client onboarding sequence diff --git a/foundrygate/onboarding.py b/foundrygate/onboarding.py index dec48ff..8612d74 100644 --- a/foundrygate/onboarding.py +++ b/foundrygate/onboarding.py @@ -17,12 +17,22 @@ def _env_path(env_file: str | Path | None = None) -> Path: return Path.cwd() / ".env" +def _is_unresolved_env(value: str) -> bool: + """Return whether a config value still looks like an unresolved env placeholder.""" + stripped = value.strip() + return stripped.startswith("${") and stripped.endswith("}") + + 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 _is_unresolved_env(api_key): + api_key = "" + if _is_unresolved_env(base_url): + base_url = "" if contract == "local-worker": if not base_url: @@ -140,6 +150,46 @@ def build_onboarding_report( } +def build_onboarding_validation(report: dict[str, Any]) -> dict[str, Any]: + """Return onboarding blockers and warnings for one report.""" + providers = report["providers"] + clients = report["clients"] + routing = report["routing"] + env = report["env"] + + blockers: list[str] = [] + warnings: list[str] = [] + + if not env.get("exists", False): + blockers.append("Environment file is missing.") + if providers["total"] == 0: + blockers.append("No providers are configured.") + elif providers["ready"] == 0: + blockers.append("No configured provider is ready.") + + if providers["total"] > 1 and not routing["fallback_chain"]: + blockers.append("Fallback chain is empty for a multi-provider setup.") + + if providers["not_ready"] > 0: + warnings.append( + f"{providers['not_ready']} provider(s) are not ready: " + + ", ".join(item["name"] for item in providers["items"] if not item["ready"]) + ) + + if not clients["profiles_enabled"]: + warnings.append("Client profiles are disabled.") + if clients["profiles_enabled"] and not clients["presets"]: + warnings.append("No built-in client presets are enabled.") + if routing["request_hooks_enabled"] and routing["request_hook_count"] == 0: + warnings.append("Request hooks are enabled but no hooks are configured.") + + return { + "ok": not blockers, + "blockers": blockers, + "warnings": warnings, + } + + def render_onboarding_report(report: dict[str, Any]) -> str: """Render the onboarding report as plain text.""" provider_block = report["providers"] @@ -206,3 +256,19 @@ def render_onboarding_report(report: dict[str, Any]) -> str: lines.extend(f"- {item}" for item in report["suggestions"]) return "\n".join(lines) + "\n" + + +def render_onboarding_validation(validation: dict[str, Any]) -> str: + """Render onboarding validation results as plain text.""" + lines = [ + "FoundryGate onboarding validation", + "", + f"Status: {'ok' if validation['ok'] else 'blocked'}", + ] + if validation["blockers"]: + lines.extend(["", "Blockers"]) + lines.extend(f"- {item}" for item in validation["blockers"]) + if validation["warnings"]: + lines.extend(["", "Warnings"]) + lines.extend(f"- {item}" for item in validation["warnings"]) + return "\n".join(lines) + "\n" diff --git a/scripts/foundrygate-install b/scripts/foundrygate-install index 3b3b03a..abd45cf 100755 --- a/scripts/foundrygate-install +++ b/scripts/foundrygate-install @@ -6,6 +6,7 @@ helpers=( foundrygate-bootstrap foundrygate-doctor foundrygate-onboarding-report + foundrygate-onboarding-validate foundrygate-update-check foundrygate-auto-update foundrygate-install diff --git a/scripts/foundrygate-onboarding-validate b/scripts/foundrygate-onboarding-validate new file mode 100644 index 0000000..f1ab83b --- /dev/null +++ b/scripts/foundrygate-onboarding-validate @@ -0,0 +1,36 @@ +#!/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 +import sys + +from foundrygate.onboarding import ( + build_onboarding_report, + build_onboarding_validation, + render_onboarding_validation, +) + +report = build_onboarding_report( + config_path=os.environ["FOUNDRYGATE_ONBOARDING_CONFIG"], + env_file=os.environ["FOUNDRYGATE_ONBOARDING_ENV"], +) +validation = build_onboarding_validation(report) + +if os.environ["FOUNDRYGATE_ONBOARDING_MODE"] == "--json": + print(json.dumps(validation, indent=2, sort_keys=True)) +else: + print(render_onboarding_validation(validation), end="") + +sys.exit(0 if validation["ok"] else 1) +PY diff --git a/scripts/foundrygate-uninstall b/scripts/foundrygate-uninstall index dd93d7e..b646b89 100755 --- a/scripts/foundrygate-uninstall +++ b/scripts/foundrygate-uninstall @@ -3,6 +3,7 @@ set -euo pipefail helpers=( foundrygate-install foundrygate-onboarding-report + foundrygate-onboarding-validate foundrygate-start foundrygate-stop foundrygate-restart diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 8bee856..950b1a5 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -2,7 +2,12 @@ from pathlib import Path -from foundrygate.onboarding import build_onboarding_report, render_onboarding_report +from foundrygate.onboarding import ( + build_onboarding_report, + build_onboarding_validation, + render_onboarding_report, + render_onboarding_validation, +) def test_onboarding_report_marks_missing_api_keys_and_presets(tmp_path: Path): @@ -100,3 +105,102 @@ def test_onboarding_report_marks_local_worker_ready(tmp_path: Path): assert report["providers"]["ready"] == 1 assert report["providers"]["local_workers"] == 1 assert "local-worker: local-worker / openai-compat / local / ready" in text + + +def test_onboarding_validation_blocks_missing_env_and_unready_providers( + tmp_path: Path, monkeypatch +): + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +fallback_chain: [] +providers: + deepseek-chat: + backend: openai-compat + base_url: "https://api.deepseek.com/v1" + api_key: "${DEEPSEEK_API_KEY}" + model: "deepseek-chat" + tier: default + gemini-flash: + backend: google-genai + base_url: "https://generativelanguage.googleapis.com/v1beta" + api_key: "${GEMINI_API_KEY}" + model: "gemini-2.5-flash" + tier: mid +client_profiles: + enabled: false + profiles: + generic: {} + rules: [] +routing_policies: + enabled: false + rules: [] +request_hooks: + enabled: true + hooks: [] +update_check: + enabled: false +auto_update: + enabled: false +""".strip(), + encoding="utf-8", + ) + + report = build_onboarding_report(config_path=config_file, env_file=tmp_path / ".env") + validation = build_onboarding_validation(report) + text = render_onboarding_validation(validation) + + assert validation["ok"] is False + assert "Environment file is missing." in validation["blockers"] + assert "No configured provider is ready." in validation["blockers"] + assert "Fallback chain is empty for a multi-provider setup." in validation["blockers"] + assert "Client profiles are disabled." in validation["warnings"] + assert "Request hooks are enabled but no hooks are configured." in validation["warnings"] + assert "Status: blocked" in text + + +def test_onboarding_validation_passes_for_ready_multi_provider_setup(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("DEEPSEEK_API_KEY=sk-demo\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", "cli"] + profiles: + generic: {} + rules: [] +routing_policies: + enabled: false + rules: [] +request_hooks: + enabled: false + hooks: [] +update_check: + enabled: true +auto_update: + enabled: false +""".strip(), + encoding="utf-8", + ) + + report = build_onboarding_report(config_path=config_file, env_file=env_file) + validation = build_onboarding_validation(report) + + assert validation["ok"] is True + assert validation["blockers"] == []