245 lines
9.2 KiB
Python
245 lines
9.2 KiB
Python
"""Bring a ModelForge installation from an empty database to a serving control plane.
|
|
|
|
python scripts/bootstrap.py --database-url postgresql+psycopg://...
|
|
|
|
The whole point is that this is safe to run twice. Every step either creates what is missing or
|
|
confirms what is already there, and the report says which of the two happened, so an operator can
|
|
re-run it after a failure without wondering what state they are in.
|
|
|
|
It never invents an operator credential. Secrets are generated by the operator and supplied through
|
|
configuration; bootstrap verifies one is present and refuses to continue in production without it,
|
|
because a platform that mints its own admin secret has no way to tell you it did.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from sqlalchemy import create_engine, inspect, text
|
|
from sqlalchemy.orm import Session
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "backend" / "src"))
|
|
|
|
from modelforge_api.domain.release import ( # noqa: E402
|
|
PRODUCT_NAME,
|
|
PRODUCT_VERSION,
|
|
TARGET_SCHEMA_REVISION,
|
|
Compatibility,
|
|
schema_compatibility,
|
|
)
|
|
from modelforge_api.services.lifecycle import LifecycleService # noqa: E402
|
|
from modelforge_api.services.manifest_registry import ManifestRegistry # noqa: E402
|
|
from modelforge_api.services.migration_engine import MigrationEngineService # noqa: E402
|
|
from modelforge_api.services.observability import ObservabilityService # noqa: E402
|
|
from modelforge_api.services.project_registry import sync_project_registry # noqa: E402
|
|
from modelforge_api.services.recovery import RecoveryService # noqa: E402
|
|
from modelforge_api.services.registry import seed_candidate_registry # noqa: E402
|
|
from modelforge_api.settings import Settings # noqa: E402
|
|
|
|
|
|
@dataclass
|
|
class Step:
|
|
name: str
|
|
outcome: str
|
|
detail: str
|
|
seconds: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class BootstrapReport:
|
|
steps: list[Step] = field(default_factory=list)
|
|
started_at: float = field(default_factory=time.time)
|
|
|
|
def record(self, name: str, outcome: str, detail: str, seconds: float = 0.0) -> None:
|
|
self.steps.append(Step(name, outcome, detail, round(seconds, 3)))
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"product": PRODUCT_NAME,
|
|
"version": PRODUCT_VERSION,
|
|
"total_seconds": round(time.time() - self.started_at, 3),
|
|
"steps": [
|
|
{
|
|
"name": step.name,
|
|
"outcome": step.outcome,
|
|
"detail": step.detail,
|
|
"seconds": step.seconds,
|
|
}
|
|
for step in self.steps
|
|
],
|
|
}
|
|
|
|
|
|
def wait_for_database(url: str, report: BootstrapReport, timeout: float = 120.0) -> None:
|
|
started = time.time()
|
|
engine = create_engine(url, pool_pre_ping=True)
|
|
last: str = ""
|
|
while time.time() - started < timeout:
|
|
try:
|
|
with engine.connect() as connection:
|
|
connection.exec_driver_sql("select 1")
|
|
report.record(
|
|
"database reachable", "OK", "accepted a connection", time.time() - started
|
|
)
|
|
engine.dispose()
|
|
return
|
|
except Exception as error: # noqa: BLE001 - any failure means not ready yet
|
|
last = type(error).__name__
|
|
time.sleep(2)
|
|
engine.dispose()
|
|
raise SystemExit(f"the database never became reachable within {timeout:.0f}s (last: {last})")
|
|
|
|
|
|
def migrate(url: str, report: BootstrapReport) -> str:
|
|
started = time.time()
|
|
engine = create_engine(url)
|
|
with engine.connect() as connection:
|
|
had_schema = inspect(connection).has_table("alembic_version")
|
|
before = (
|
|
connection.execute(text("select version_num from alembic_version")).scalar_one_or_none()
|
|
if had_schema
|
|
else None
|
|
)
|
|
config = Config(str(ROOT / "backend" / "alembic.ini"))
|
|
config.set_main_option("script_location", str(ROOT / "backend" / "alembic"))
|
|
config.set_main_option("sqlalchemy.url", url)
|
|
command.upgrade(config, "head")
|
|
with engine.connect() as connection:
|
|
after = connection.execute(text("select version_num from alembic_version")).scalar_one()
|
|
engine.dispose()
|
|
outcome = "ALREADY_CURRENT" if before == after else ("CREATED" if before is None else "UPGRADED")
|
|
report.record("migrations", outcome, f"{before or '(empty)'} -> {after}", time.time() - started)
|
|
if after != TARGET_SCHEMA_REVISION:
|
|
raise SystemExit(
|
|
f"migrations landed on {after}, but this release targets {TARGET_SCHEMA_REVISION}"
|
|
)
|
|
return str(after)
|
|
|
|
|
|
def seed(url: str, settings: Settings, report: BootstrapReport) -> None:
|
|
"""Every seed is an ensure_*: running it twice must not produce a second copy of anything."""
|
|
|
|
engine = create_engine(url)
|
|
manifests = ManifestRegistry(settings.config_root)
|
|
with Session(engine) as session:
|
|
started = time.time()
|
|
candidates = seed_candidate_registry(session, manifests)
|
|
session.commit()
|
|
report.record(
|
|
"candidate registry",
|
|
"SEEDED" if candidates else "ALREADY_PRESENT",
|
|
f"{candidates} candidate(s) added",
|
|
time.time() - started,
|
|
)
|
|
|
|
started = time.time()
|
|
sync = sync_project_registry(session, manifests)
|
|
session.commit()
|
|
detail = f"{len(sync.unavailable_contracts)} binding(s) waiting for a contract"
|
|
report.record("project registry", "SYNCED", detail, time.time() - started)
|
|
|
|
for name, service in (
|
|
("lifecycle policies", LifecycleService(session)),
|
|
("migration policies", MigrationEngineService(session)),
|
|
("observability rules", ObservabilityService(session, settings)),
|
|
(
|
|
"recovery policies",
|
|
RecoveryService(session, settings, "control_plane", "bootstrap"),
|
|
),
|
|
):
|
|
started = time.time()
|
|
service.ensure_defaults()
|
|
session.commit()
|
|
report.record(name, "ENSURED", "defaults present", time.time() - started)
|
|
engine.dispose()
|
|
|
|
|
|
def verify(url: str, settings: Settings, report: BootstrapReport) -> None:
|
|
engine = create_engine(url)
|
|
with engine.connect() as connection:
|
|
revision = connection.execute(text("select version_num from alembic_version")).scalar_one()
|
|
tables = int(
|
|
connection.execute(
|
|
text(
|
|
"select count(*) from information_schema.tables where table_schema='public'"
|
|
)
|
|
).scalar_one()
|
|
)
|
|
duplicates = connection.execute(
|
|
text(
|
|
"select count(*) from (select capability_contract_id from capability_deployments "
|
|
"where status='stable' group by capability_contract_id having count(*) > 1) as d"
|
|
)
|
|
).scalar_one()
|
|
engine.dispose()
|
|
compatibility = schema_compatibility(revision)
|
|
report.record(
|
|
"schema",
|
|
"OK" if compatibility is Compatibility.COMPATIBLE else "INCOMPATIBLE",
|
|
f"revision {revision}, {tables} tables",
|
|
)
|
|
report.record(
|
|
"no duplicate stable identity",
|
|
"OK" if duplicates == 0 else "VIOLATED",
|
|
f"{duplicates} contract(s) with more than one stable deployment",
|
|
)
|
|
has_operator_key = settings.operator_api_key is not None
|
|
report.record(
|
|
"operator credential",
|
|
"PRESENT" if has_operator_key else "MISSING",
|
|
"supplied through configuration"
|
|
if has_operator_key
|
|
else "set MODELFORGE_OPERATOR_API_KEY before serving",
|
|
)
|
|
if not has_operator_key and settings.env == "production":
|
|
raise SystemExit(
|
|
"refusing to finish: production requires MODELFORGE_OPERATOR_API_KEY. ModelForge "
|
|
"never mints its own admin secret, because it would have no way to tell you it did."
|
|
)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--database-url", default=None)
|
|
parser.add_argument("--env-file", default=None)
|
|
parser.add_argument("--report", default=None)
|
|
parser.add_argument("--skip-seed", action="store_true")
|
|
args = parser.parse_args(argv)
|
|
|
|
settings = (
|
|
Settings(_env_file=args.env_file) # type: ignore[call-arg]
|
|
if args.env_file
|
|
else Settings()
|
|
)
|
|
url = args.database_url or settings.database_url
|
|
|
|
report = BootstrapReport()
|
|
print(f"{PRODUCT_NAME} {PRODUCT_VERSION} — bootstrap", flush=True)
|
|
wait_for_database(url, report)
|
|
migrate(url, report)
|
|
if not args.skip_seed:
|
|
seed(url, settings, report)
|
|
verify(url, settings, report)
|
|
|
|
for step in report.steps:
|
|
print(f" {step.outcome:16} {step.name:28} {step.detail} ({step.seconds:.2f}s)", flush=True)
|
|
print(f" bootstrap completed in {report.as_dict()['total_seconds']:.2f}s", flush=True)
|
|
|
|
if args.report:
|
|
Path(args.report).write_text(json.dumps(report.as_dict(), indent=2), encoding="utf-8")
|
|
print(f" report written to {args.report}", flush=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|