#!/usr/bin/env python3 """Environment-neutral technical acceptance for offline runtime deployments.""" from __future__ import annotations import json from pathlib import Path from time import time from typing import Any from urllib.parse import urlencode from urllib.request import Request, urlopen from common import ( DeployError, bool_env, db_query_scalar, log, resolve_frontend_dist_target, verify_frontend_dist, wait_for_health, wait_for_http_ok, ) from deployment_runtime_state import append_stage_summary PUBLIC_OAUTH_PKCE_ENV_KEY = "NEXUSCRM_PUBLIC_OAUTH_PKCE_ENABLED" PUBLIC_OAUTH_SCHEMA_CONTRACT = { "_System_ConnectedApp": ( "ConsumerSecretHash", "RedirectUris", "ClientType", "RequirePkce", "TokenEndpointAuthMethod", "AllowedGrantTypes", "ConsentPolicy", "ConsentVersion", "IssueRefreshTokens", "PublisherName", "SupportUrl", "SessionIssuerClass", ), "_System_ConnectedAppAccess": ( "ConnectedAppId", "ProfileId", "PermissionSetId", ), "_System_OAuthCustomScope": ( "Name", "CustomPermissionId", "AllowedClientTypes", "AllowedMethods", "RoutePatterns", "ResourceLinks", "IsActive", ), "_System_OAuthCode": ( "CodeHash", "CodeChallenge", "CodeChallengeMethod", "AuthTime", "Issuer", "ConsentId", "ClientTypeAtIssue", ), "_System_OAuthConsent": ( "UserId", "ConnectedAppId", "Scope", "ConsentVersion", "ApprovedAt", "LastUsedAt", "RevokedAt", "RevokedBy", ), } def _sql_string_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" def information_schema_table_exists( envv: dict[str, str], table_name: str, ) -> bool: database = envv["TIDB_DATABASE"] count = db_query_scalar( envv, ( "SELECT COUNT(*) " "FROM information_schema.tables " f"WHERE table_schema={_sql_string_literal(database)} " f"AND table_name={_sql_string_literal(table_name)};" ), database="information_schema", ) return count == "1" def information_schema_existing_columns( envv: dict[str, str], table_name: str, columns: tuple[str, ...], ) -> set[str]: database = envv["TIDB_DATABASE"] column_literals = ", ".join(_sql_string_literal(column) for column in columns) raw = db_query_scalar( envv, ( "SELECT column_name " "FROM information_schema.columns " f"WHERE table_schema={_sql_string_literal(database)} " f"AND table_name={_sql_string_literal(table_name)} " f"AND column_name IN ({column_literals}) " "ORDER BY column_name;" ), database="information_schema", ) return {line.strip() for line in raw.splitlines() if line.strip()} def verify_public_oauth_readiness( envv: dict[str, str], source_stage: str, ) -> dict[str, Any]: started_at = time() missing_tables: list[str] = [] missing_columns: dict[str, list[str]] = {} for table_name, required_columns in PUBLIC_OAUTH_SCHEMA_CONTRACT.items(): if not information_schema_table_exists(envv, table_name): missing_tables.append(table_name) missing_columns[table_name] = list(required_columns) continue existing_columns = information_schema_existing_columns( envv, table_name, required_columns, ) table_missing = [ column for column in required_columns if column not in existing_columns ] if table_missing: missing_columns[table_name] = table_missing pkce_enabled = bool_env(envv, PUBLIC_OAUTH_PKCE_ENV_KEY) summary = { "database": envv["TIDB_DATABASE"], "source_stage": source_stage, "pkce_env_key": PUBLIC_OAUTH_PKCE_ENV_KEY, "pkce_enabled": pkce_enabled, "required_tables": sorted(PUBLIC_OAUTH_SCHEMA_CONTRACT), "missing_tables": missing_tables, "missing_columns": missing_columns, "duration_seconds": round(time() - started_at, 3), "status": "failed" if missing_tables or missing_columns or not pkce_enabled else "passed", } append_stage_summary(envv, "public-oauth-readiness", summary) errors: list[str] = [] if not pkce_enabled: errors.append( f"{PUBLIC_OAUTH_PKCE_ENV_KEY} must be true for public PKCE rollout" ) if missing_tables: errors.append(f"missing tables: {', '.join(missing_tables)}") for table_name, columns in missing_columns.items(): if table_name in missing_tables: continue errors.append(f"{table_name} missing columns: {', '.join(columns)}") if errors: raise DeployError("public OAuth readiness failed: " + "; ".join(errors)) log("public OAuth PKCE schema readiness passed") return summary def verify_public_contract(envv: dict[str, str]) -> None: base_url = envv["PUBLIC_BASE_URL"].rstrip("/") wait_for_health(f"{base_url}/health") wait_for_http_ok(f"{base_url}/") wait_for_http_ok(f"{base_url}/index.html") wait_for_http_ok(f"{base_url}/version.json") def verify_live_frontend_contract(envv: dict[str, str]) -> None: frontend_target = resolve_frontend_dist_target( Path(envv["ENV_ROOT"]).expanduser().resolve() / "frontend-dist" ) verify_frontend_dist(frontend_target) headers = {"Host": envv["SERVER_NAME"]} local_base_url = f"http://127.0.0.1:{envv['NGINX_LISTEN_PORT']}" public_base_url = envv["PUBLIC_BASE_URL"].rstrip("/") wait_for_http_ok(f"{local_base_url}/", headers=headers) wait_for_http_ok(f"{local_base_url}/index.html", headers=headers) wait_for_http_ok(f"{local_base_url}/version.json", headers=headers) payload = urlencode( { "grant_type": "password", "client_id": "nexuscrm-ui", "client_secret": "nexuscrm-ui-internal", "username": envv["VERIFY_USERNAME"], "password": envv["NEXUS_ADMIN_PASSWORD"], } ).encode() request = Request( f"{local_base_url}/services/oauth2/token", data=payload, headers={ "Content-Type": "application/x-www-form-urlencoded", "Host": envv["SERVER_NAME"], }, method="POST", ) with urlopen(request, timeout=30) as response: token_json = json.loads(response.read().decode()) access_token = str(token_json.get("access_token", "")).strip() if not access_token: raise DeployError("staging live frontend oauth token missing access_token") instance_url = str(token_json.get("instance_url", "")).strip() identity_url = str(token_json.get("id", "")).strip() if instance_url != public_base_url: raise DeployError(f"unexpected token instance_url: {instance_url}") if identity_url and not identity_url.startswith(f"{public_base_url}/"): raise DeployError(f"unexpected token id: {identity_url}") def request_localhost_access_token(envv: dict[str, str]) -> str: base_url = f"http://127.0.0.1:{envv['BACKEND_PORT']}" payload = urlencode( { "grant_type": "password", "client_id": "nexuscrm-ui", "client_secret": "nexuscrm-ui-internal", "username": envv["VERIFY_USERNAME"], "password": envv["NEXUS_ADMIN_PASSWORD"], } ).encode() token_request = Request( f"{base_url}/services/oauth2/token", data=payload, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST", ) with urlopen(token_request, timeout=30) as response: token_json = json.loads(response.read().decode()) access_token = token_json.get("access_token", "") if not access_token: raise DeployError("standby smoke missing access_token from localhost oauth response") return access_token def verify_standby_startup_smoke(envv: dict[str, str]) -> None: # startup-only path skips apply, so keep the probe on auth + mounted API surface. base_url = f"http://127.0.0.1:{envv['BACKEND_PORT']}" wait_for_health(f"{base_url}/health") access_token = request_localhost_access_token(envv) versions_request = Request( f"{base_url}/services/data", headers={"Authorization": f"Bearer {access_token}"}, method="GET", ) with urlopen(versions_request, timeout=30) as response: if response.status != 200: raise DeployError( "startup smoke data versions returned unexpected status: " f"{response.status}" ) versions_json = json.loads(response.read().decode()) if not isinstance(versions_json, list) or not versions_json: raise DeployError("startup smoke data versions response is empty or malformed") if not any( isinstance(item, dict) and item.get("url") == "/services/data/v63.0" for item in versions_json ): raise DeployError("startup smoke missing advertised v63.0 data API") userinfo_request = Request( f"{base_url}/services/oauth2/userinfo", headers={"Authorization": f"Bearer {access_token}"}, method="GET", ) with urlopen(userinfo_request, timeout=30) as response: if response.status != 200: raise DeployError( f"startup smoke userinfo returned unexpected status: {response.status}" ) userinfo_json = json.loads(response.read().decode()) if not isinstance(userinfo_json, dict) or not userinfo_json.get("user_id"): raise DeployError("startup smoke userinfo missing user_id") def verify_standby_runtime_smoke(envv: dict[str, str]) -> None: base_url = f"http://127.0.0.1:{envv['BACKEND_PORT']}" # Deploy apply only gates on backend liveness; functional verification runs later in dedicated cases. wait_for_health(f"{base_url}/health")