8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
480 lines
18 KiB
Python
480 lines
18 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]
|
|
INTERNAL_TOKEN_ENV_KEYS = (
|
|
"VIGNETTE_RUPTURE_INTERNAL_TOKEN",
|
|
"VIGNETTE_PRACTICE_INTERNAL_TOKEN",
|
|
"VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN",
|
|
"VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN",
|
|
"VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN",
|
|
"VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN",
|
|
)
|
|
MIN_INTERNAL_TOKEN_LENGTH = 32
|
|
ENGINE_GATEWAY_SECRET_ENV_KEY = "ENGINE_GATEWAY_SHARED_SECRET"
|
|
COMPOSE_REQUIRED_ENV_KEYS = (
|
|
"ENVIRONMENT",
|
|
"POSTGRES_PASSWORD",
|
|
"APP_DB_PASSWORD",
|
|
"OPENAI_API_KEY",
|
|
"SESSION_SECRET",
|
|
"OAUTH_GOOGLE_CLIENT_ID",
|
|
"OAUTH_GOOGLE_CLIENT_SECRET",
|
|
) + INTERNAL_TOKEN_ENV_KEYS
|
|
DIRECT_RUNTIME_REQUIRED_ENV_KEYS = (
|
|
"ENVIRONMENT",
|
|
"DATABASE_URL",
|
|
"OPENAI_API_KEY",
|
|
"SESSION_SECRET",
|
|
"OAUTH_GOOGLE_CLIENT_ID",
|
|
"OAUTH_GOOGLE_CLIENT_SECRET",
|
|
) + INTERNAL_TOKEN_ENV_KEYS
|
|
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",
|
|
REPO_ROOT / "apps" / "api" / "engine_gateway" / "requirements.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,
|
|
deployment_profile: str = "compose",
|
|
) -> Check:
|
|
if not path.exists():
|
|
return Check("env_file", False, f"{_rel(path)} missing")
|
|
values = _parse_env_file(path)
|
|
errors: list[str] = []
|
|
required_keys = (
|
|
DIRECT_RUNTIME_REQUIRED_ENV_KEYS
|
|
if deployment_profile == "direct-runtime"
|
|
else COMPOSE_REQUIRED_ENV_KEYS
|
|
)
|
|
for key in required_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")
|
|
if not allow_placeholder_secrets:
|
|
gateway_secret = values.get(ENGINE_GATEWAY_SECRET_ENV_KEY, "")
|
|
if gateway_secret:
|
|
if len(gateway_secret) < MIN_INTERNAL_TOKEN_LENGTH:
|
|
errors.append(
|
|
f"{ENGINE_GATEWAY_SECRET_ENV_KEY} must contain at least "
|
|
f"{MIN_INTERNAL_TOKEN_LENGTH} characters"
|
|
)
|
|
if gateway_secret.lower().startswith(
|
|
("change-me", "replace-with", "dummy", "example")
|
|
):
|
|
errors.append(
|
|
f"{ENGINE_GATEWAY_SECRET_ENV_KEY} is placeholder or empty"
|
|
)
|
|
token_values: dict[str, list[str]] = {}
|
|
for key in INTERNAL_TOKEN_ENV_KEYS:
|
|
value = values.get(key, "")
|
|
if value and len(value) < MIN_INTERNAL_TOKEN_LENGTH:
|
|
errors.append(
|
|
f"{key} must contain at least {MIN_INTERNAL_TOKEN_LENGTH} characters"
|
|
)
|
|
if value.lower().startswith(("change-me", "replace-with", "dummy", "example")):
|
|
errors.append(f"{key} is placeholder or empty")
|
|
if value:
|
|
token_values.setdefault(value, []).append(key)
|
|
for duplicate_keys in token_values.values():
|
|
if len(duplicate_keys) > 1:
|
|
errors.append(
|
|
"internal tokens must be independently revocable: "
|
|
+ ", ".join(duplicate_keys)
|
|
+ " share one value"
|
|
)
|
|
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_profile} 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,
|
|
to_regclass('app.measurement_event') IS NOT NULL AS has_measurement_event,
|
|
to_regclass('app.outcome_trajectory_revision') IS NOT NULL AS has_outcome_trajectory,
|
|
to_regclass('app.rupture_episode') IS NOT NULL AS has_rupture_episode,
|
|
to_regclass('app.practice_prescription') IS NOT NULL AS has_practice_prescription,
|
|
to_regclass('app.calibration_prediction_history') IS NOT NULL AS has_calibration_history,
|
|
to_regclass('app.supervision_attention_snapshot') IS NOT NULL AS has_supervision_snapshot,
|
|
to_regclass('app.multimodal_consent_snapshot') IS NOT NULL AS has_multimodal_consent,
|
|
to_regclass('app.ci_content_pipeline') IS NOT NULL AS has_ci_content_pipeline,
|
|
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 IN (
|
|
'audio_ref',
|
|
'silence_ms',
|
|
'speech_rate',
|
|
'barge_in',
|
|
'provider_events'
|
|
)
|
|
GROUP BY table_schema, table_name
|
|
HAVING count(*) = 5
|
|
) AS has_turn_voice_metadata_columns,
|
|
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_measurement_event",
|
|
"has_outcome_trajectory",
|
|
"has_rupture_episode",
|
|
"has_practice_prescription",
|
|
"has_calibration_history",
|
|
"has_supervision_snapshot",
|
|
"has_multimodal_consent",
|
|
"has_ci_content_pipeline",
|
|
"has_persona_triggers",
|
|
"has_turn_voice_metadata_columns",
|
|
"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(
|
|
"--deployment-profile",
|
|
choices=("compose", "direct-runtime"),
|
|
default="compose",
|
|
help=(
|
|
"Validate compose packaging secrets or the direct Windows public "
|
|
"runtime's DATABASE_URL-based configuration."
|
|
),
|
|
)
|
|
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:
|
|
env_path = Path(args.env_file)
|
|
results.append(
|
|
check_env_file(
|
|
env_path,
|
|
allow_placeholder_secrets=args.allow_placeholder_secrets,
|
|
deployment_profile=args.deployment_profile,
|
|
)
|
|
)
|
|
if (
|
|
args.deployment_profile == "direct-runtime"
|
|
and not args.database_url
|
|
and not args.skip_db
|
|
and env_path.exists()
|
|
):
|
|
args.database_url = _parse_env_file(env_path).get("DATABASE_URL", "")
|
|
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())
|