vignette/scripts/check-deploy-preflight.py
2026-06-29 17:07:01 +09:00

373 lines
13 KiB
Python

#!/usr/bin/env python3
"""Deployment preflight checks for portable Vignette installs.
This script is intentionally read-only. Owner-run DB migrations/init scripts
must happen before the API starts; this preflight only proves that the runtime
app role and packaged files are ready enough for startup.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
REQUIRED_ENV_KEYS = (
"ENVIRONMENT",
"POSTGRES_PASSWORD",
"APP_DB_PASSWORD",
"OPENAI_API_KEY",
"SESSION_SECRET",
"OAUTH_GOOGLE_CLIENT_ID",
"OAUTH_GOOGLE_CLIENT_SECRET",
)
PLACEHOLDER_VALUES = {
"",
"change-me",
"change-me-app",
"change-me-random",
"dummy",
"dummy-openai-key",
"dummy-session-secret-not-real-12345",
"dummy-client",
"dummy-secret",
}
FORBIDDEN_PROD_TRUE_FLAGS = (
"AUTH_DEV_LOGIN_ENABLED",
"AUTO_SEED_PERSONAS",
"ALLOW_SEED_PERSONA_FALLBACK",
"VIGNETTE_VOICE_POC_SAMPLE_TTS",
)
VALID_ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
REQUIREMENT_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:\[[A-Za-z0-9_,.-]+\])?==[^\s#]+$")
@dataclass(frozen=True)
class Check:
name: str
ok: bool
detail: str
def _rel(path: Path) -> str:
try:
return path.relative_to(REPO_ROOT).as_posix()
except ValueError:
return str(path)
def _iter_requirement_lines(path: Path) -> list[tuple[int, str]]:
rows: list[tuple[int, str]] = []
for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
line = raw.strip()
if not line or line.startswith("#"):
continue
rows.append((lineno, line))
return rows
def check_pinned_requirements() -> Check:
paths = [
REPO_ROOT / "apps" / "api" / "requirements.txt",
REPO_ROOT / "apps" / "api" / "requirements-rag.txt",
]
offenders: list[str] = []
for path in paths:
if not path.exists():
offenders.append(f"{_rel(path)} missing")
continue
for lineno, line in _iter_requirement_lines(path):
if not REQUIREMENT_RE.match(line):
offenders.append(f"{_rel(path)}:{lineno} {line}")
if offenders:
return Check("requirements_pinned", False, "; ".join(offenders))
return Check("requirements_pinned", True, "all active API requirement lines use exact ==")
def _load_json(path: Path, errors: list[str]) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception as exc: # pragma: no cover - detail is reported in CLI output
errors.append(f"{_rel(path)} JSON load failed: {exc}")
return None
def check_live_coach_kb() -> Check:
root = REPO_ROOT / "data" / "kb"
workbook = root / "live_coaching_workbook_0615.json"
source_dir = root / "live_coaching_sources"
errors: list[str] = []
if not workbook.exists():
errors.append(f"{_rel(workbook)} missing")
if not source_dir.is_dir():
errors.append(f"{_rel(source_dir)} missing")
if errors:
return Check("live_coach_kb", False, "; ".join(errors))
payload = _load_json(workbook, errors)
if isinstance(payload, dict):
_validate_source_pack(path=workbook, payload=payload, errors=errors)
source_paths = sorted(source_dir.glob("*.json"))
if not source_paths:
errors.append(f"{_rel(source_dir)} has no json source files")
for path in source_paths:
data = _load_json(path, errors)
if isinstance(data, dict):
_validate_source_pack(path=path, payload=data, errors=errors)
if errors:
return Check("live_coach_kb", False, "; ".join(errors))
return Check(
"live_coach_kb",
True,
f"workbook present and {len(source_paths)} source json files parse",
)
def _validate_source_pack(path: Path, payload: dict[str, Any], errors: list[str]) -> None:
source = payload.get("source")
if not isinstance(source, dict):
errors.append(f"{_rel(path)} missing object source")
source = {}
for key in ("source_id", "title"):
if not str(source.get(key) or "").strip():
errors.append(f"{_rel(path)} source.{key} missing")
chunks = payload.get("chunks")
if not isinstance(chunks, list) or not chunks:
errors.append(f"{_rel(path)} chunks missing or empty")
return
for index, chunk in enumerate(chunks):
if not isinstance(chunk, dict):
errors.append(f"{_rel(path)} chunks[{index}] is not an object")
continue
if not str(chunk.get("summary") or "").strip():
errors.append(f"{_rel(path)} chunks[{index}].summary missing")
def _parse_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def check_env_file(path: Path, *, allow_placeholder_secrets: bool) -> Check:
if not path.exists():
return Check("env_file", False, f"{_rel(path)} missing")
values = _parse_env_file(path)
errors: list[str] = []
for key in REQUIRED_ENV_KEYS:
value = values.get(key, "")
if key not in values:
errors.append(f"{key} missing")
elif not allow_placeholder_secrets and value in PLACEHOLDER_VALUES:
errors.append(f"{key} is placeholder or empty")
engine_mode = values.get("ENGINE_MODE", "claude_cli")
if engine_mode not in VALID_ENGINE_MODES:
errors.append(f"ENGINE_MODE invalid: {engine_mode}")
if values.get("ENVIRONMENT") in {"prod", "staging"}:
for key in FORBIDDEN_PROD_TRUE_FLAGS:
if values.get(key, "").lower() == "true":
errors.append(f"{key}=true is forbidden outside dev")
if errors:
return Check("env_file", False, "; ".join(errors))
return Check("env_file", True, f"{_rel(path)} has required deployment keys")
async def check_database(database_url: str, *, require_app_role: bool) -> Check:
try:
import asyncpg # type: ignore
except Exception as exc:
return Check("database", False, f"asyncpg unavailable: {exc}")
try:
conn = await asyncpg.connect(database_url)
except Exception as exc:
return Check("database", False, f"connect failed: {exc}")
try:
row = await conn.fetchrow(
"""
SELECT
current_user AS current_user,
(SELECT rolbypassrls FROM pg_roles WHERE rolname = current_user) AS bypassrls,
to_regclass('app.app_user') IS NOT NULL AS has_app_user,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
to_regclass('app.session_evaluation') IS NOT NULL AS has_session_evaluation,
to_regclass('app.live_coach_events') IS NOT NULL AS has_live_coach_events,
to_regclass('app.session_share_link') IS NOT NULL AS has_session_share_link,
to_regclass('app.notification_event') IS NOT NULL AS has_notification_event,
to_regclass('app.notification_delivery') IS NOT NULL AS has_notification_delivery,
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
to_regclass('app.turns') IS NOT NULL AS has_turns,
to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'persona_card'
AND column_name = 'triggers'
) AS has_persona_triggers,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'turns'
AND column_name = 'provider_events'
) AS has_turn_provider_events,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'session_review_status'
AND column_name IN (
'worksheet_status',
'worksheet_note',
'worksheet_reviewed_at'
)
GROUP BY table_schema, table_name
HAVING count(*) = 3
) AS has_session_review_worksheet_columns,
EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'app'
AND tablename = 'notification_delivery'
AND indexname = 'idx_notification_delivery_queue'
) AS has_notification_delivery_queue_index,
EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'sessions'
AND policyname IN ('p_sessions_insert','p_sessions_update','p_sessions_delete')
GROUP BY schemaname, tablename
HAVING count(*) = 3
) AS has_session_write_policies,
EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'app'
AND c.relname = 'persona_card'
AND pg_has_role(current_user, c.relowner, 'MEMBER')
) AS owns_persona_card
"""
)
except Exception as exc:
return Check("database", False, f"readiness query failed: {exc}")
finally:
await conn.close()
missing = [
key
for key in (
"has_app_user",
"has_auth_session",
"has_engine_config",
"has_session_evaluation",
"has_live_coach_events",
"has_session_share_link",
"has_notification_event",
"has_notification_delivery",
"has_sessions",
"has_turns",
"has_session_review_status",
"has_persona_triggers",
"has_turn_provider_events",
"has_session_review_worksheet_columns",
"has_notification_delivery_queue_index",
"has_session_write_policies",
)
if not row[key]
]
errors: list[str] = []
if missing:
errors.append("missing DB readiness items: " + ", ".join(missing))
if require_app_role:
if row["bypassrls"]:
errors.append("runtime role has BYPASSRLS")
if row["owns_persona_card"]:
errors.append("runtime role owns app.persona_card; use app role DSN for API")
if errors:
return Check("database", False, "; ".join(errors))
role_note = f"current_user={row['current_user']}"
if require_app_role:
role_note += ", non-owner app role"
return Check("database", True, role_note)
def print_results(results: list[Check]) -> int:
failed = False
for result in results:
status = "OK" if result.ok else "FAIL"
print(f"{status} {result.name}: {result.detail}")
failed = failed or not result.ok
return 1 if failed else 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--env-file",
default=None,
help="Optional deployment env file to validate, for example infra/.env.",
)
parser.add_argument(
"--allow-placeholder-secrets",
action="store_true",
help="Allow example placeholder secrets when validating .env.example.",
)
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL", ""),
help="Optional runtime app-role DATABASE_URL for read-only DB readiness checks.",
)
parser.add_argument(
"--skip-db",
action="store_true",
help="Skip DB readiness checks even when DATABASE_URL is set.",
)
parser.add_argument(
"--require-app-role",
action="store_true",
help="Require the supplied DATABASE_URL to be a non-owner, NOBYPASSRLS runtime app role.",
)
return parser
async def main_async(argv: list[str]) -> int:
args = build_parser().parse_args(argv)
results = [
check_pinned_requirements(),
check_live_coach_kb(),
]
if args.env_file:
results.append(
check_env_file(
Path(args.env_file),
allow_placeholder_secrets=args.allow_placeholder_secrets,
)
)
if args.database_url and not args.skip_db:
results.append(
await check_database(
args.database_url,
require_app_role=args.require_app_role,
)
)
return print_results(results)
def main() -> int:
return asyncio.run(main_async(sys.argv[1:]))
if __name__ == "__main__":
raise SystemExit(main())