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

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
1 change: 1 addition & 0 deletions docs/ONBOARDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions foundrygate/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"
1 change: 1 addition & 0 deletions scripts/foundrygate-install
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ helpers=(
foundrygate-bootstrap
foundrygate-doctor
foundrygate-onboarding-report
foundrygate-onboarding-validate
foundrygate-update-check
foundrygate-auto-update
foundrygate-install
Expand Down
36 changes: 36 additions & 0 deletions scripts/foundrygate-onboarding-validate
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions scripts/foundrygate-uninstall
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ set -euo pipefail
helpers=(
foundrygate-install
foundrygate-onboarding-report
foundrygate-onboarding-validate
foundrygate-start
foundrygate-stop
foundrygate-restart
Expand Down
106 changes: 105 additions & 1 deletion tests/test_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"] == []
Loading