diff --git a/.env.example b/.env.example index 9ef5b7b21..520e7dcc5 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # UltiCode local development environment # -# Do not copy this file and reuse the placeholders. Generate a private `.env` -# containing random infrastructure credentials and documented local app users: +# Credentials are deliberately empty in this tracked template. Generate a +# private `.env` containing random infrastructure and local app credentials: # # ./scripts/dev/init-env.sh # @@ -27,7 +27,7 @@ MANAGEMENT_PORT=9003 DB_HOST=localhost DB_PORT=23306 DB_USER=ulticode -DB_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +DB_PASSWORD= DB_NAME=ulticode # Owner runtime schemas/accounts. Owners may share this MySQL instance, but # each runtime account is scoped to its own schema. @@ -35,32 +35,32 @@ AUTH_DB_HOST=localhost AUTH_DB_PORT=23306 AUTH_DB_NAME=auth AUTH_DB_USER=auth_rw -AUTH_DB_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +AUTH_DB_PASSWORD= ADMIN_DB_HOST=localhost ADMIN_DB_PORT=23306 ADMIN_DB_NAME=admin ADMIN_DB_USER=admin_rw -ADMIN_DB_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +ADMIN_DB_PASSWORD= APP_DB_HOST=localhost APP_DB_PORT=23306 APP_DB_NAME=app APP_DB_USER=app_rw -APP_DB_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +APP_DB_PASSWORD= NOTIFICATION_DB_HOST=localhost NOTIFICATION_DB_PORT=23306 NOTIFICATION_DB_NAME=notification NOTIFICATION_DB_USER=notification_rw -NOTIFICATION_DB_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +NOTIFICATION_DB_PASSWORD= # Submission owner runtime. SUBMISSION_DB_HOST=localhost SUBMISSION_DB_PORT=23306 SUBMISSION_DB_NAME=submission SUBMISSION_DB_USER=submission_rw -SUBMISSION_DB_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +SUBMISSION_DB_PASSWORD= # Separate privileged identity for Submission owner migrations. SUBMISSION_MIGRATION_DB_USER=migration_submission -SUBMISSION_MIGRATION_DB_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +SUBMISSION_MIGRATION_DB_PASSWORD= APP_SUBMISSION_ROUTING_MODE=remote # One-time cutover input (do not persist in `.env`): # `SUBMISSION_CUTOVER_QUIESCE_CONFIRM=I_UNDERSTAND_SUBMISSION_QUIESCE_ALL_WRITERS` @@ -68,8 +68,8 @@ SUBMISSION_CUTOVER_COMPLETE=false SUBMISSION_APP_DB_USER=app_rw SUBMISSION_APP_DB_HOST=% NOTIFICATION_APP_DB_USER= -MYSQL_ROOT_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh -DB_ROOT_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +MYSQL_ROOT_PASSWORD= +DB_ROOT_PASSWORD= # Explicit privileged owner migration job input. Required when # MIGRATION_SCHEMA is auth/admin/app/notification/submission; never reuse a # runtime *_DB_USER as the migration account. @@ -94,20 +94,22 @@ REDIS_ACL_DIR=.local/redis # Per-owner Redis ACL credentials (review 2026-08-25). The shared # REDIS_PASSWORD is gone: each security domain authenticates as its own ACL # user in the runtime REDIS_ACL_DIR (default `.local/redis`; default user -# disabled). The values below are DEV-ONLY placeholders; init-env.sh generates -# the runtime ACL outside Git. Production must rotate all of them with -# scripts/runbooks/redis-acl-rotation.sh. -AUTH_REDIS_PASSWORD=dev-only-auth-redis-password -ADMIN_REDIS_PASSWORD=dev-only-admin-redis-password -APP_REDIS_PASSWORD=dev-only-app-redis-password -SUBMISSION_REDIS_PASSWORD=dev-only-submission-redis-password -SEARCH_REDIS_PASSWORD=dev-only-search-redis-password -NOTIFICATION_REDIS_PASSWORD=dev-only-notification-redis-password -JUDGE_REDIS_PASSWORD=dev-only-judge-redis-password -OPS_REDIS_PASSWORD=dev-only-ops-redis-password -HEALTH_REDIS_PASSWORD=dev-only-health-redis-password -REDIS_REPLICATION_PASSWORD=dev-only-redis-replication-password -REDIS_SENTINEL_PASSWORD=dev-only-redis-sentinel-password +# disabled). Leave all passwords empty in this tracked template. For local +# development, init-env.sh generates separate random passwords in the ignored +# .env file and renders the runtime ACL outside Git. Deployed environments must +# inject operator-managed secrets. Existing credentials are rotated separately +# with scripts/runbooks/redis-acl-rotation.sh; never reuse committed defaults. +AUTH_REDIS_PASSWORD= +ADMIN_REDIS_PASSWORD= +APP_REDIS_PASSWORD= +SUBMISSION_REDIS_PASSWORD= +SEARCH_REDIS_PASSWORD= +NOTIFICATION_REDIS_PASSWORD= +JUDGE_REDIS_PASSWORD= +OPS_REDIS_PASSWORD= +HEALTH_REDIS_PASSWORD= +REDIS_REPLICATION_PASSWORD= +REDIS_SENTINEL_PASSWORD= # Production only: mount a secret-managed directory containing the certificate # and private key; do not put PEM material in this repository. # TLS_CERT_DIR=/run/secrets/ulticode-tls @@ -125,7 +127,7 @@ REDIS_SENTINEL_PASSWORD=dev-only-redis-sentinel-password # REDIS_USERNAME=ulticode-auth REDIS_PASSWORD="$AUTH_REDIS_PASSWORD" # JWT and browser security -JWT_SECRET=GENERATED_BY_scripts_dev_init_env_sh_minimum_32_chars +JWT_SECRET= JWT_COOKIE_SECURE=false CORS_ALLOWED_ORIGINS=http://localhost:9002,http://localhost:9003,http://127.0.0.1:9002,http://127.0.0.1:9003 FRONTEND_URL=http://localhost:9002 @@ -139,23 +141,23 @@ NACOS_NAMESPACE=dev NACOS_GROUP=DEFAULT_GROUP DUBBO_NAMESPACE=dev NACOS_USERNAME=ulticode_dev_admin -NACOS_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +NACOS_PASSWORD= # Dev-only per-service Nacos registry identities generated by init-env.sh. AUTH_NACOS_USERNAME=ulticode-auth -AUTH_NACOS_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +AUTH_NACOS_PASSWORD= ADMIN_NACOS_USERNAME=ulticode-admin -ADMIN_NACOS_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +ADMIN_NACOS_PASSWORD= APP_NACOS_USERNAME=ulticode-app -APP_NACOS_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +APP_NACOS_PASSWORD= SUBMISSION_NACOS_USERNAME=ulticode-submission -SUBMISSION_NACOS_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +SUBMISSION_NACOS_PASSWORD= NOTIFICATION_NACOS_USERNAME=ulticode-notification -NOTIFICATION_NACOS_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh +NOTIFICATION_NACOS_PASSWORD= JUDGE_NACOS_USERNAME=ulticode-judge -JUDGE_NACOS_PASSWORD=GENERATED_BY_scripts_dev_init_env_sh -NACOS_AUTH_TOKEN=GENERATED_BY_scripts_dev_init_env_sh -NACOS_AUTH_IDENTITY_KEY=GENERATED_BY_scripts_dev_init_env_sh -NACOS_AUTH_IDENTITY_VALUE=GENERATED_BY_scripts_dev_init_env_sh +JUDGE_NACOS_PASSWORD= +NACOS_AUTH_TOKEN= +NACOS_AUTH_IDENTITY_KEY= +NACOS_AUTH_IDENTITY_VALUE= # Production-only trust controls; set these in the deployment secret store. #NACOS_DB_USER=nacos_rw #NACOS_DB_PASSWORD=GENERATED_IN_DEPLOYMENT_SECRET_STORE @@ -237,7 +239,7 @@ APP_STORAGE_S3_UPLOAD_TIMEOUT_MS=1800000 #MANAGED_OTLP_AUTHORIZATION= # Generated by init-env.sh; startup/test fall back to an in-memory disposable # value for older env files and never persist it automatically. -MEILI_MASTER_KEY=GENERATED_BY_scripts_dev_init_env_sh +MEILI_MASTER_KEY= # Optional Judge0 async Adapter. Keep disabled for private/hidden tests. # Enable only with an operator-managed HTTPS endpoint and secret-file API key. #JUDGE0_ENABLED=false @@ -330,11 +332,12 @@ APP_BOOTSTRAP_ADMIN_USERNAME= APP_BOOTSTRAP_ADMIN_EMAIL= APP_BOOTSTRAP_ADMIN_PASSWORD= -# Fixed credentials for the disposable local development database only. +# Per-checkout credentials for the disposable local development database only. +# init-env.sh generates the administrator password and matching browser input. DEV_SEED_USERS_ENABLED=true DEV_SEED_ADMIN_USERNAME=admin DEV_SEED_ADMIN_EMAIL=admin@localhost.test -DEV_SEED_ADMIN_PASSWORD=admin123 +DEV_SEED_ADMIN_PASSWORD= DEV_SEED_ADMIN_ROLE=ADMIN # Optional integrations are disabled until real local credentials are supplied. @@ -355,4 +358,4 @@ EMAIL_FROM_NAME=UltiCode Dev VITE_API_BASE_URL=/api VITE_ADMIN_BASE_URL=http://localhost:9003 VITE_TEST_USERNAME=admin -VITE_TEST_PASSWORD=admin123 +VITE_TEST_PASSWORD= diff --git a/.github/workflows/_backend.yml b/.github/workflows/_backend.yml index b55e01284..a73c40524 100644 --- a/.github/workflows/_backend.yml +++ b/.github/workflows/_backend.yml @@ -315,6 +315,15 @@ jobs: } trap cleanup EXIT cp .env.example "$env_file" + # Synthetic values for config-only validation; runtime templates stay empty. + printf '%s\n' \ + "MYSQL_ROOT_PASSWORD=config-only-synthetic-mysql-root-password" \ + "DB_PASSWORD=config-only-synthetic-db-password" \ + "HEALTH_REDIS_PASSWORD=config-only-synthetic-health-redis-password" \ + "NACOS_AUTH_TOKEN=config-only-synthetic-nacos-auth-token" \ + "NACOS_AUTH_IDENTITY_KEY=config-only-synthetic-nacos-auth-identity-key" \ + "NACOS_AUTH_IDENTITY_VALUE=config-only-synthetic-nacos-auth-identity-value" \ + "MEILI_MASTER_KEY=config-only-synthetic-meili-master-key" >> "$env_file" replication_password="$(openssl rand -hex 16)" sentinel_password="$(openssl rand -hex 16)" printf '\nNACOS_DB_USER=nacos_rw\nNACOS_DB_PASSWORD=ci-ha-config\nNACOS_SERVERS=nacos:8848 nacos-2:8848 nacos-3:8848\nREDIS_HA_CONFIG_DIR=%s\nREDIS_REPLICATION_PASSWORD=%s\nREDIS_SENTINEL_PASSWORD=%s\n' \ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d253ce62f..65f5c0f50 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -195,6 +195,8 @@ App 还单独读取现有 application child 的目录,以及已有 listener cookies、源码、PM2 state 或原始服务日志。退出时仅停止该测试 project/home,保留本机卷供 诊断;GitHub hosted runner 生命周期负责回收其临时磁盘。 +启动失败报告仅输出固定诊断词表、固定源码帧白名单及受限覆盖计数,不回显异常原文或 URL。每个 Owner 保留日志缺失、读取失败和尾部截断信息;信号为 false 只表示在受限日志尾部未观察到,不能据此认定服务未启动。Spring context 启动与投影 providers discovered 分别记录;历史成功与失败信号可以同时出现,不代表当前健康或因果关系。源码帧白名单随相关代码位置维护。 + 本机重现需在新 checkout 安装锁定依赖、PM2 与 Playwright Chromium,然后运行: ```bash @@ -730,6 +732,8 @@ enabled-owner wiring 或业务 journey。 `.env` 是本地部署密管输入,由 `./scripts/dev/init-env.sh` 生成并被 Git 忽略;`.env.example` 与 `.env.test.example` 是可提交模板,不包含可用凭据。实现配置以各服务 `application.yml`、Compose 和 `ecosystem.config.cjs` 为准。 +`.env.example` 的数据库、Redis ACL、Nacos、JWT、Meili 和开发用户凭据全部留空。首次本地初始化使用 `./scripts/dev/init-env.sh`,由初始化流程生成各角色独立的随机凭据并写入 ignored `.env`;开发管理员密码与 `VITE_TEST_PASSWORD` 使用同一份本次初始化值,验收入口继续从环境读取。启用开发管理员 bootstrap 且启动 Auth 时,`up.sh` 要求显式提供非空白 `DEV_SEED_ADMIN_PASSWORD`,缺失、空值或仅含 Java 空白字符的值会在基础设施变更前拒绝启动,不再回退到固定口令;`--skip-bootstrap`、`--prepare-submission-owner`、禁用开发用户或不启动 Auth 时不强求该密码。部署环境从密钥系统注入。不要直接使用模板启动,也不要重用曾提交到 Git 的默认密码。Redis ACL 生成器在任一角色密码缺失时拒绝生成配置;已部署凭据的撤销或轮换需要单独执行相应运维流程。 现有 `.env` 会原样保留,不自动更改或轮换已有密码。`init-env.sh --force` 会同时重新生成其他基础设施凭据,不能作为仅更改管理员口令的捷径。 + ### 常用变量 | 变量 | 用途 | 规则 | @@ -831,3 +835,7 @@ Codex 前缀规则只能识别已列出的参数排列,无法覆盖任意脚 - [部署与回滚](OPERATIONS.md#部署发布与回滚) - [Services 问题注册表](../services/docs/SERVICES_ISSUES.md) - [Worker SLO Runbook](../services/docs/WORKER_SLO_RUNBOOK.md) + +### 有界启动失败诊断 + +启动超时不放宽就绪门禁。失败报告记录最后一轮所选服务的固定 readiness 结果(HTTP 状态枚举或 PM2/banner 分类),而不是用历史启动成功行判断当前状态。额外依赖诊断只读取固定 IPv4 loopback 健康端点,限制 8 KiB 响应、每 socket 5 秒,并以 25 秒总时限和 2 秒强制终止上限约束整个子进程;不跟随跳转、不传认证、不输出原始响应或地址。只发布固定 db/redis/storage/problemOwnerRpc 的 up/down/unknown 枚举。额外探测发生在最终门禁轮次之后,不代表同一时刻;缺失探测或未选服务保留 unknown,不能当作健康或失败。日志 startup_signals 仍只表示有界日志尾部曾观察到的信号,成功与失败可同时存在。 diff --git a/scripts/ci/acceptance_report.py b/scripts/ci/acceptance_report.py index 9fdeaccf3..f0a8c1eb5 100644 --- a/scripts/ci/acceptance_report.py +++ b/scripts/ci/acceptance_report.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Publish only bounded public exception types/source coordinates from test logs.""" +"""Publish fixed diagnostic vocabulary, bounded observations and coverage, never raw logs.""" import json import os import re @@ -13,6 +13,24 @@ LIMIT = 1_000_000 KINDS = re.compile(r"\b(?:org|com|java|javax|jakarta|io)\.(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*[A-Za-z_$][A-Za-z0-9_$]*(?:Exception|Error)\b") FRAMES = re.compile(r"\bat (com\.ulticode\.[A-Za-z0-9_.$]+)\(([A-Za-z0-9_$]+\.java:[0-9]+)\)") +# Regexes detect candidate tokens only; output is intersected with this fixed +# vocabulary. A Java-looking secret or forged source frame is never serialized. +ALLOWED_KINDS = frozenset({ + "java.io.IOException", "java.io.InterruptedIOException", + "java.lang.IllegalStateException", "java.lang.OutOfMemoryError", + "java.net.ConnectException", "java.net.SocketTimeoutException", + "java.util.concurrent.TimeoutException", "javax.net.ssl.SSLHandshakeException", + "jakarta.validation.NoProviderFoundException", + "org.apache.dubbo.remoting.RemotingException", "org.apache.dubbo.rpc.RpcException", + "org.springframework.beans.factory.BeanCreationException", +}) +ALLOWED_FRAMES = frozenset({ + ("com.ulticode.submission.config.SubmissionDiscoveryStartupGate.await", "SubmissionDiscoveryStartupGate.java:40"), + ("com.ulticode.submission.config.SubmissionDiscoveryStartupGate.run", "SubmissionDiscoveryStartupGate.java:32"), + ("com.ulticode.submission.BackendSubmissionApplication.main", "BackendSubmissionApplication.java:38"), +}) +OWNERS = ("app", "submission", "admin", "auth", "judge", "notification", "search") +PRIVATE_LOGS = ("environment", "sandbox", "prepare", "cutover", "launch") PHASES = {"environment", "sandbox", "fresh-migrations", "backfill-and-cutover", "launch", "auth-environment", "fixtures", "browser-base", "browser-business", "complete"} @@ -186,26 +204,80 @@ def _discovery_observations(path, marker, pattern): return observations[-16:] -def summary(paths): - kinds, frames, messages = set(), [], [] +FINAL_APPS = tuple("ulticode-" + owner for owner in OWNERS) + ("ulticode-core", "ulticode-9002", "ulticode-9003", "ulticode-9004") +HTTP_DETAILS = ("http_200", "http_401", "http_403", "http_404", "http_500", "http_502", "http_503", "http_504", "http_unreachable", "http_other") +PM2_DETAILS = ("pm2_not_online", "pm2_online_no_banner_required", "pm2_log_missing", "pm2_banner_missing", "pm2_banner_observed", "unknown_probe_kind") +HEALTH_COMPONENTS = { + "ulticode-auth": ("db", "redis"), + "ulticode-admin": ("db", "redis", "problemOwnerRpc", "storage"), + "ulticode-app": ("db", "redis", "storage"), + "ulticode-notification": ("db", "redis"), +} + + +def final_probes(text): + """Last explicit final-cycle markers only; all serialized values are fixed.""" + details = set(HTTP_DETAILS) | set(PM2_DETAILS) | {a + "/" + b for a in HTTP_DETAILS for b in HTTP_DETAILS} + readiness = {app: {"observed": False, "state": "unknown", "detail": "unknown"} for app in FINAL_APPS} + dependencies = {app: {"observed": False, "probe": "unknown", "components": dict.fromkeys(keys, "unknown")} + for app, keys in HEALTH_COMPONENTS.items()} + for line in text.splitlines(): + match = re.fullmatch(r"ULTICODE_FINAL_READINESS app=([^ ]+) state=(ready|failed) detail=([^ ]+)", line) + if match: + app, state, detail = match.groups() + if app in readiness and detail in details: + readiness[app] = {"observed": True, "state": state, "detail": detail} + match = re.fullmatch(r"ULTICODE_FINAL_HEALTH app=([^ ]+) state=(http_200|http_503|unreachable|http_other|invalid_response|oversized_response)", line) + if match: + app, state = match.groups() + if app in dependencies: + dependencies[app]["observed"] = True + dependencies[app]["probe"] = state + match = re.fullmatch(r"ULTICODE_FINAL_COMPONENT app=([^ ]+) component=([^ ]+) state=(up|down|unknown)", line) + if match: + app, key, state = match.groups() + if app in dependencies and key in dependencies[app]["components"]: + dependencies[app]["components"][key] = state + return {"final_readiness": readiness, "final_dependency_probes": dependencies} + + +def summary(paths, include_final_probes=False): + paths = tuple(paths) + kinds, frames, messages, observed = set(), set(), [], [] + coverage = {"files_expected": len(paths), "files_seen": 0, "files_missing": 0, + "files_excluded": 0, "read_errors": 0, "tail_truncated": False} + unknown_kinds = unknown_frames = False for path in paths: - if path.is_symlink() or not path.is_file(): - continue try: + if path.is_symlink(): + coverage["files_excluded"] += 1 + continue + if not path.exists(): + coverage["files_missing"] += 1 + continue + if not path.is_file(): + coverage["files_excluded"] += 1 + continue with path.open("rb") as stream: - stream.seek(max(0, path.stat().st_size - LIMIT)) + size = os.fstat(stream.fileno()).st_size + coverage["tail_truncated"] |= size > LIMIT + stream.seek(max(0, size - LIMIT)) text = stream.read(LIMIT).decode("utf-8", errors="replace") + coverage["files_seen"] += 1 except OSError: + coverage["read_errors"] += 1 continue + observed.append(text) lines = text.splitlines() for index, line in enumerate(lines): if any(marker in line for marker in ("RpcException", "Caused by:", "Last error", "last error", "Exception:", "Error:")): messages.extend(lines[index:index + 3]) - kinds.update(KINDS.findall(text)) - for symbol, location in FRAMES.findall(text): - frame = {"symbol": symbol, "location": location} - if frame not in frames: - frames.append(frame) + candidates = set(KINDS.findall(text)) + kinds.update(candidates & ALLOWED_KINDS) + unknown_kinds |= bool(candidates - ALLOWED_KINDS) + candidates = set(FRAMES.findall(text)) + frames.update(candidates & ALLOWED_FRAMES) + unknown_frames |= bool(candidates - ALLOWED_FRAMES) context = "\n".join(messages) patterns = { "timeout": r"\b(?:timeout waiting|timed out|deadline exceeded|TimeoutException)\b", @@ -216,24 +288,56 @@ def summary(paths): "tls_identity": r"\b(?:SSLHandshakeException|certificate_unknown|untrusted peer|peer identity mismatch|tls handshake)\b", "validation_provider": r"\bNoProviderFoundException\b", } - return {"exception_classes": sorted(kinds)[:20], "public_source_frames": frames[-16:], + # Only booleans under fixed keys are emitted; never include matched text, + # exception messages, endpoints, process environment or arbitrary timestamps. + startup_patterns = { + "spring_context_started": r"\bStarted (?:Backend(?:App|Submission|Admin|Auth|Judge|Notification)Application|SearchWorkerApplication) in ", + "application_failed": r"APPLICATION FAILED TO START", + "submission_projection_providers_discovered": r"Submission projection providers discovered", + "submission_projection_discovery_deadline": r"Submission projection providers were not discovered before readiness deadline", + "nacos_client_starting": r"Client not connected, current status:STARTING|Client not connected, current status: STARTING", + "nacos_client_unhealthy": r"Client not connected, current status:UNHEALTHY|Client not connected, current status: UNHEALTHY", + "connection_refused": r"\bConnection refused\b", + "reactor_build_failed": r"Error: reactor install failed; fix compilation errors before starting the stack\.", + "auth_readiness_failed": r"ulticode-auth did not become healthy; cannot run dev-admin bootstrap\.|backend-auth did not become healthy; not starting App or its deferred consumers\.", + "app_readiness_failed": r"backend-app did not become healthy; (?:starting ulticode-judge anyway|not starting deferred Submission/Judge services\.)", + "stack_readiness_timed_out": r"Application readiness check timed out for:", + } + observed_text = "\n".join(observed) + coverage["complete"] = (bool(paths) and coverage["files_seen"] == len(paths) + and not coverage["tail_truncated"]) + # False signals mean "not observed in the inspected tails", not proof of + # absence. Coverage makes missing/unreadable/truncated evidence explicit. + result = {"coverage": coverage, + "unlisted_exception_type_observed": unknown_kinds, + "unlisted_source_frame_observed": unknown_frames, + "startup_signals": {name: bool(re.search(pattern, observed_text)) for name, pattern in startup_patterns.items()}, + "exception_classes": sorted(kinds)[:20], "public_source_frames": [{"symbol": symbol, "location": location} for symbol, location in sorted(frames)], "rpc_failure_categories": {name: bool(re.search(pattern, context, re.I)) for name, pattern in patterns.items()}} + if include_final_probes: + result.update(final_probes(observed_text)) + return result def build_report(root, phase, exit_code, project): - private = summary((root / ".local/acceptance").glob("private-*.log")) + private = summary((root / ".local/acceptance" / f"private-{stage}.log" for stage in PRIVATE_LOGS), include_final_probes=True) services = {} - for owner in ("app", "submission", "admin", "auth", "judge"): + for owner in OWNERS: item = summary([root / "logs" / f"backend-{owner}.{kind}.log" for kind in ("out", "err")]) - if item["exception_classes"] or item["public_source_frames"] or any(item["rpc_failure_categories"].values()): - services[owner] = item + # Keep every selected owner visible even when both log files are absent. + services[owner] = item return { "evidence": "disposable-real-services", "phase": phase if phase in PHASES else "unknown", "passed": exit_code == 0, - "project": project if re.fullmatch(r"ulticode-acceptance-[0-9]{1,20}-[0-9]{1,20}", project) else "unknown", + "project": "disposable" if re.fullmatch(r"ulticode-acceptance-[0-9]{1,20}-[0-9]{1,20}", project) else "unknown", "raw_logs_published": False, + "signals_semantics": "observed_in_bounded_log_tails_not_current_health", "exception_classes": private["exception_classes"], + "launcher_signals": private["startup_signals"], + "launcher_coverage": private["coverage"], + "final_readiness": private["final_readiness"], + "final_dependency_probes": private["final_dependency_probes"], "service_diagnostics": services, "service_diagnostics_scope": "historical-log-observations-not-root-cause", "launch_readiness": read_snapshot(root, project), @@ -248,6 +352,7 @@ def build_report(root, phase, exit_code, project): } + def main(): try: root = Path.cwd() diff --git a/scripts/dev/init-env.sh b/scripts/dev/init-env.sh index e1eb1bc49..be9d7d553 100755 --- a/scripts/dev/init-env.sh +++ b/scripts/dev/init-env.sh @@ -12,7 +12,8 @@ usage() { Usage: ./scripts/dev/init-env.sh [--output PATH] [--force] Generates a private local development environment. Infrastructure credentials are -random; the documented application login is fixed for developer convenience. +random. The application username is admin; its random password is shared with +the browser test configuration in the generated private environment file. Existing files are preserved unless --force is supplied. EOF } @@ -123,6 +124,7 @@ app_db_password="app_$(random_hex 18)" notification_db_password="notification_$(random_hex 18)" submission_db_password="submission_$(random_hex 18)" submission_migration_password="migration_submission_$(random_hex 24)" +dev_seed_admin_password="local_admin_$(random_hex 24)" mkdir -p "$(dirname "$OUTPUT_FILE")" umask 077 @@ -311,12 +313,12 @@ APP_BOOTSTRAP_ADMIN_USERNAME= APP_BOOTSTRAP_ADMIN_EMAIL= APP_BOOTSTRAP_ADMIN_PASSWORD= -# Fixed credentials for the disposable local development database only. +# Per-checkout credentials for the disposable local development database only. DEV_SEED_USERS_ENABLED=true DEV_SEED_DATA_ENABLED=true DEV_SEED_ADMIN_USERNAME=admin DEV_SEED_ADMIN_EMAIL=admin@localhost.test -DEV_SEED_ADMIN_PASSWORD=admin123 +DEV_SEED_ADMIN_PASSWORD=$dev_seed_admin_password DEV_SEED_ADMIN_ROLE=ADMIN GITHUB_CLIENT_ID= @@ -335,7 +337,7 @@ EMAIL_FROM_NAME="UltiCode Dev" VITE_API_BASE_URL=/api VITE_ADMIN_BASE_URL=http://localhost:9003 VITE_TEST_USERNAME=admin -VITE_TEST_PASSWORD=admin123 +VITE_TEST_PASSWORD=$dev_seed_admin_password EOF chmod 600 "$OUTPUT_FILE" diff --git a/scripts/dev/readiness-diagnostics.py b/scripts/dev/readiness-diagnostics.py new file mode 100644 index 000000000..92ffb10ae --- /dev/null +++ b/scripts/dev/readiness-diagnostics.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Bounded loopback-only diagnostics; emit fixed enums, never response/log text.""" +import http.client +import json +import sys + +LIMIT = 8192 +TARGETS = { + "ulticode-auth": (9101, "/api/v1/auth/health/ready", ("db", "redis")), + "ulticode-admin": (9102, "/api/v1/admin/health/ready", ("db", "redis", "problemOwnerRpc", "storage")), + "ulticode-app": (9103, "/api/v1/app/health/ready", ("db", "redis", "storage")), + "ulticode-notification": (9105, "/api/v1/notification/health/ready", ("db", "redis")), +} + + +def classify(status, body, keys): + components = dict.fromkeys(keys, "unknown") + if len(body) > LIMIT: + return "oversized_response", components + if status not in (200, 503): + return "http_other", components + try: + document = json.loads(body) + except (ValueError, UnicodeError): + return "invalid_response", components + if not isinstance(document, dict) or not isinstance(document.get("components"), dict): + return "invalid_response", components + for key in keys: + value = document["components"].get(key) + if value == "UP": + components[key] = "up" + elif value == "DOWN": + components[key] = "down" + return "http_200" if status == 200 else "http_503", components + + +def probe(app, connection_factory=http.client.HTTPConnection): + port, path, keys = TARGETS[app] + result, components = "unreachable", dict.fromkeys(keys, "unknown") + connection = None + try: + # No ambient proxy, redirects, authentication or user-controlled URL. + connection = connection_factory("127.0.0.1", port, timeout=5) + connection.request("GET", path) + response = connection.getresponse() + result, components = classify(response.status, response.read(LIMIT + 1), keys) + except (OSError, http.client.HTTPException, ValueError): + pass + finally: + if connection is not None: + try: + connection.close() + except OSError: + pass + return result, components + + +def main(argv): + selected = set(argv[0].split(",")) if len(argv) == 1 else set() + for app in TARGETS: + if app not in selected: + continue + result, components = probe(app) + print(f"ULTICODE_FINAL_HEALTH app={app} state={result}") + for key, value in components.items(): + print(f"ULTICODE_FINAL_COMPONENT app={app} component={key} state={value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/dev/up.sh b/scripts/dev/up.sh index e12531cf7..2183469bc 100755 --- a/scripts/dev/up.sh +++ b/scripts/dev/up.sh @@ -38,6 +38,32 @@ capture_env_vars MIGRATION_DB_HOST MIGRATION_DB_PORT MIGRATION_DB_NAME MIGRATION DEV_SEED_USERS_ENABLED DEV_SEED_DATA_ENABLED \ DEV_SEED_ADMIN_USERNAME DEV_SEED_ADMIN_EMAIL DEV_SEED_ADMIN_PASSWORD DEV_SEED_ADMIN_ROLE +# Administrator bootstrap policy: share the predicate between fail-closed +# validation and execution; freeze it before sourcing a developer environment. +dev_admin_bootstrap_enabled() { + [[ "$SKIP_BOOTSTRAP" != true && "$PREPARE_SUBMISSION_OWNER" != true \ + && "$DEV_SEED_USERS_ENABLED" == true \ + && ",$PM2_APPS," == *,ulticode-auth,* ]] +} + +require_dev_admin_password() { + # Match Java String.isBlank/Character.isWhitespace without altering the value. + # Non-breaking spaces U+00A0, U+2007 and U+202F are deliberately excluded. + local password="${DEV_SEED_ADMIN_PASSWORD:-}" whitespace + # Remove exact character sequences, not a bracket class: in the C locale a + # multibyte bracket class can accidentally match bytes of a nonblank character. + for whitespace in $'\t' $'\n' $'\v' $'\f' $'\r' $'\x1c' $'\x1d' $'\x1e' $'\x1f' ' ' \ + $'\xe1\x9a\x80' $'\xe2\x80\x80' $'\xe2\x80\x81' $'\xe2\x80\x82' $'\xe2\x80\x83' $'\xe2\x80\x84' $'\xe2\x80\x85' $'\xe2\x80\x86' \ + $'\xe2\x80\x88' $'\xe2\x80\x89' $'\xe2\x80\x8a' $'\xe2\x80\xa8' $'\xe2\x80\xa9' $'\xe2\x81\x9f' $'\xe3\x80\x80'; do + password="${password//"$whitespace"/}" + done + if dev_admin_bootstrap_enabled && [[ -z "$password" ]]; then + echo "DEV_SEED_ADMIN_PASSWORD is required when development administrator bootstrap is enabled; provide it in the private environment or disable bootstrap." >&2 + return 1 + fi +} +readonly -f dev_admin_bootstrap_enabled require_dev_admin_password + # ===== 参数解析 ===== SKIP_INSTALL=false SKIP_INFRA=false @@ -287,8 +313,8 @@ devstack_apply_scope "$DEV_SCOPE" : "${DEV_SEED_DATA_ENABLED:=true}" : "${DEV_SEED_ADMIN_USERNAME:=admin}" : "${DEV_SEED_ADMIN_EMAIL:=admin@localhost.test}" -: "${DEV_SEED_ADMIN_PASSWORD:=admin123}" : "${DEV_SEED_ADMIN_ROLE:=ADMIN}" +require_dev_admin_password || exit $? mapfile -t required_vars < <(devstack_required_vars "$DEV_MODE" "$FRONTEND_ONLY") for var in "${required_vars[@]}"; do @@ -641,8 +667,7 @@ fi # ===== 步骤 4: dev-admin bootstrap (依赖 auth 的 Dubbo provider, 故先启动 auth) ===== AUTH_STARTED_FOR_BOOTSTRAP=false -if [[ "$SKIP_BOOTSTRAP" != true && "$DEV_SEED_USERS_ENABLED" == "true" \ - && ",$PM2_APPS," == *,ulticode-auth,* ]]; then +if dev_admin_bootstrap_enabled; then # UserProvisioningAdapter 通过 Dubbo RPC 调用 backend-auth 的 # AccountManagementService 创建/恢复 admin (check=false: 容器可启动, 调用期才需要 provider)。 # 所以 bootstrap 前必须先拉起 ulticode-auth 并等待其 Dubbo provider 注册到 Nacos。 @@ -944,17 +969,36 @@ check_url() { } # 同时尝试 IPv4 与 IPv6 回环, 任一返回 200 即就绪, 避免绑定地址族导致的假性失败。 +readiness_http_detail() { + case "$1" in + 200|401|403|404|500|502|503|504) printf 'http_%s' "$1" ;; + 000|000000|'') printf 'http_unreachable' ;; + *) printf 'http_other' ;; + esac +} + check_port() { - local port="$1" path="${2:-/}" - [[ "$(check_url "http://127.0.0.1:${port}${path}")" == "200" ]] && return 0 - [[ "$(check_url "http://[::1]:${port}${path}")" == "200" ]] && return 0 + local port="$1" path="${2:-/}" ipv4 ipv6 + ipv4="$(check_url "http://127.0.0.1:${port}${path}")" + if [[ "$ipv4" == "200" ]]; then + READINESS_DETAIL=http_200 + return 0 + fi + ipv6="$(check_url "http://[::1]:${port}${path}")" + if [[ "$ipv6" == "200" ]]; then + READINESS_DETAIL=http_200 + return 0 + fi + READINESS_DETAIL="$(readiness_http_detail "$ipv4")/$(readiness_http_detail "$ipv6")" return 1 } check_pm2_online() { local app="$1" log + READINESS_DETAIL=pm2_not_online pm2_load_records pm2_record_is_online "$app" || return 1 + READINESS_DETAIL=pm2_online_no_banner_required local banner banner="$(devstack_readiness_banner "$app")" @@ -974,25 +1018,56 @@ check_pm2_online() { fi # 不用 grep -q: 它在首个匹配即提前退出, tail 收到 SIGPIPE(141), # 在 set -o pipefail 下管道非零 → 假阴性。改为读完全部输入并丢弃输出。 - tail -c +$((offset + 1)) "$log" 2>/dev/null \ - | grep "$banner" >/dev/null 2>&1 + READINESS_DETAIL=pm2_log_missing + [[ -f "$log" ]] || return 1 + READINESS_DETAIL=pm2_banner_missing + if tail -c +$((offset + 1)) "$log" 2>/dev/null \ + | grep "$banner" >/dev/null 2>&1; then + READINESS_DETAIL=pm2_banner_observed + return 0 + fi + return 1 } -for _ in $(seq 1 "$DEVSTACK_SERVICE_READINESS_ATTEMPTS"); do - all_ok=true +# Record only the latest completed probe cycle, not historical startup banners. +# Values are fixed categories; no URLs, response bodies or environment values. +declare -A READINESS_RESULTS=() +check_selected_readiness() { + local readiness app readiness_kind readiness_port readiness_path state all_ok=true + READINESS_RESULTS=() for readiness in "${SELECTED_READINESS[@]}"; do IFS='|' read -r app readiness_kind readiness_port readiness_path <<< "$readiness" - observation=ready + state=ready + READINESS_DETAIL=unknown_probe_kind case "$readiness_kind" in - http) check_port "$readiness_port" "$readiness_path" || observation=not_ready ;; - pm2) check_pm2_online "$app" || observation=not_ready ;; + http) check_port "$readiness_port" "$readiness_path" || state=failed ;; + pm2) check_pm2_online "$app" || state=failed ;; + *) state=failed ;; esac - [[ "$observation" == ready ]] || all_ok=false + READINESS_RESULTS["$app"]="$state $READINESS_DETAIL" if [[ "${ACCEPTANCE_DISPOSABLE:-}" == true ]]; then - ACCEPTANCE_LAST_READINESS["$app"]="$observation" + if [[ "$state" == ready ]]; then + ACCEPTANCE_LAST_READINESS["$app"]=ready + else + ACCEPTANCE_LAST_READINESS["$app"]=not_ready + fi fi + [[ "$state" == ready ]] || all_ok=false done - if [[ "$all_ok" == true ]]; then + [[ "$all_ok" == true ]] +} + +emit_final_readiness() { + local readiness app state detail + for readiness in "${SELECTED_READINESS[@]}"; do + IFS='|' read -r app _ <<< "$readiness" + read -r state detail <<< "${READINESS_RESULTS[$app]:-failed unknown_probe_kind}" + printf 'ULTICODE_FINAL_READINESS app=%s state=%s detail=%s\n' "$app" "$state" "$detail" >&2 + done +} + +for _ in $(seq 1 "$DEVSTACK_SERVICE_READINESS_ATTEMPTS"); do + if check_selected_readiness; then printf 'Development stack is ready (scope: %s; mode: %s; services: %s).\n' \ "$DEV_SCOPE" "$DEV_MODE" "$PM2_APPS" echo "Owner migrations: $(devstack_apps_csv "${DEVSTACK_OWNER_MIGRATION_ORDER[@]}")" @@ -1023,6 +1098,9 @@ for _ in $(seq 1 "$DEVSTACK_SERVICE_READINESS_ATTEMPTS"); do sleep "$DEVSTACK_READINESS_INTERVAL_SECONDS" done +emit_final_readiness +# Failure-only read probes: fixed loopback endpoints and bounded enum output. +timeout --kill-after=2s 25s python3 -u "$ROOT_DIR/scripts/dev/readiness-diagnostics.py" "$PM2_APPS" 2>/dev/null || true echo "Application readiness check timed out for: $PM2_APPS" >&2 pm2 status >&2 || true echo >&2 diff --git a/scripts/test/cicd-contract.sh b/scripts/test/cicd-contract.sh index 1de14d2ec..5d1910726 100644 --- a/scripts/test/cicd-contract.sh +++ b/scripts/test/cicd-contract.sh @@ -19,3 +19,8 @@ python3 "$ROOT_DIR/scripts/test/verified-release-package-contract.py" -v python3 "$ROOT_DIR/scripts/test/test_acceptance_report.py" -v python3 "$ROOT_DIR/scripts/test/test_acceptance_readiness.py" -v node "$ROOT_DIR/scripts/test/browser-auth-diagnostics.test.mjs" + +bash "$ROOT_DIR/scripts/test/init-env-contract.sh" +python3 "$ROOT_DIR/scripts/test/test_dev_admin_bootstrap.py" -v + +python3 "$ROOT_DIR/scripts/test/test_dev_readiness.py" -v diff --git a/scripts/test/init-env-contract.sh b/scripts/test/init-env-contract.sh new file mode 100644 index 000000000..2c428fc69 --- /dev/null +++ b/scripts/test/init-env-contract.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Exercise the real generator in a disposable repository fixture; no Docker or network. +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FIXTURE="$(mktemp -d)" +trap 'rm -rf "$FIXTURE"' EXIT +mkdir -p "$FIXTURE/scripts" "$FIXTURE/docker" +cp -R "$ROOT_DIR/scripts/dev" "$FIXTURE/scripts/" +cp -R "$ROOT_DIR/docker/redis" "$FIXTURE/docker/" +chmod +x "$FIXTURE/docker/redis/generate-users-acl.sh" +GENERATOR="$FIXTURE/scripts/dev/init-env.sh" +bash "$GENERATOR" --output "$FIXTURE/first.env" >"$FIXTURE/first.log" 2>&1 +bash "$GENERATOR" --output "$FIXTURE/second.env" >"$FIXTURE/second.log" 2>&1 +python3 - "$FIXTURE" <<'PY' +import pathlib, stat, sys +root = pathlib.Path(sys.argv[1]) +def values(name): + path = root / name + assert stat.S_IMODE(path.stat().st_mode) == 0o600, 'Environment must be private (0600)' + return dict(line.split('=', 1) for line in path.read_text().splitlines() + if '=' in line and not line.startswith('#')) +a, b = values('first.env'), values('second.env') +for env in (a,b): + assert env['DEV_SEED_ADMIN_USERNAME'] == env['VITE_TEST_USERNAME'] == 'admin' + assert len(env['DEV_SEED_ADMIN_PASSWORD']) >= 32, 'Expected strong generated password' + assert env['DEV_SEED_ADMIN_PASSWORD'] == env['VITE_TEST_PASSWORD'], 'Browser password out of sync' +assert a['DEV_SEED_ADMIN_PASSWORD'] != b['DEV_SEED_ADMIN_PASSWORD'], 'Password reused across runs' +for log in root.glob('*.log'): + for env in (a,b): + assert env['DEV_SEED_ADMIN_PASSWORD'] not in log.read_text(), 'Password leaked to output' +PY +cp "$FIXTURE/first.env" "$FIXTURE/saved.env" +bash "$GENERATOR" --output "$FIXTURE/first.env" >"$FIXTURE/preserve.log" 2>&1 +cmp -s "$FIXTURE/saved.env" "$FIXTURE/first.env" || { echo 'Existing environment changed without --force' >&2; exit 1; } +bash "$GENERATOR" --output "$FIXTURE/first.env" --force >"$FIXTURE/force.log" 2>&1 +python3 - "$FIXTURE" <<'PY' +import pathlib, stat, sys +root=pathlib.Path(sys.argv[1]) +def values(name): + return dict(line.split('=',1) for line in (root/name).read_text().splitlines() + if '=' in line and not line.startswith('#')) +a,b=values('saved.env'),values('first.env') +assert a['DEV_SEED_ADMIN_PASSWORD'] != b['DEV_SEED_ADMIN_PASSWORD'], '--force did not rotate generated password' +assert b['DEV_SEED_ADMIN_PASSWORD'] == b['VITE_TEST_PASSWORD'], '--force broke browser sync' +assert stat.S_IMODE((root/'first.env').stat().st_mode)==0o600 +assert b['DEV_SEED_ADMIN_PASSWORD'] not in (root/'force.log').read_text() +PY +printf '%s\n' 'PASS: init-env randomness, browser sync, private mode, safe output, preservation and --force' diff --git a/scripts/test/supply-chain-contract.sh b/scripts/test/supply-chain-contract.sh index 448827b24..e48b95241 100755 --- a/scripts/test/supply-chain-contract.sh +++ b/scripts/test/supply-chain-contract.sh @@ -177,7 +177,7 @@ for service in owner_services: owner_pom = repo / 'services' / service['module'].split('/', 1)[0] / 'pom.xml' owner_properties = ET.parse(owner_pom).getroot().find('m:properties', ns) owner_version = owner_properties.findtext(f'm:{prop}', namespaces=ns) - expected_version = '1.0.6' + expected_version = '1.0.7' assert root_version == owner_version == expected_version, f'{service["name"]}: release version mismatch (root={root_version!r}, owner={owner_version!r}, expected={expected_version!r})' assert 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' in workflow print('digest-only candidates, common normalized repo and all-service promote gate: PASS') diff --git a/scripts/test/test_acceptance_readiness.py b/scripts/test/test_acceptance_readiness.py index 766f4d3ee..6be5a5c37 100644 --- a/scripts/test/test_acceptance_readiness.py +++ b/scripts/test/test_acceptance_readiness.py @@ -133,7 +133,7 @@ def test_actual_launcher_loop_preserves_probes_exit_and_prepare_isolation(self): source = (ROOT / 'scripts/dev/up.sh').read_text() hook = source.split('declare -A ACCEPTANCE_LAST_READINESS=()', 1)[1].split('resolve_mysql_container()', 1)[0] hook = 'declare -A ACCEPTANCE_LAST_READINESS=()' + hook - loop = source[source.rindex('for _ in $(seq 1 "$DEVSTACK_SERVICE_READINESS_ATTEMPTS"); do'):] + loop = source[source.index('declare -A READINESS_RESULTS=()'):] for failed, prepare, broken_writer in ((False, False, False), (True, False, False), (True, True, False), (True, False, True)): with self.subTest(failed=failed, prepare=prepare, broken_writer=broken_writer), tempfile.TemporaryDirectory() as directory: @@ -153,12 +153,13 @@ def test_actual_launcher_loop_preserves_probes_exit_and_prepare_isolation(self): DEVSTACK_READINESS_INTERVAL_SECONDS=0 DEV_SCOPE=full-stack DEV_MODE=dev-full INFRA_TARGETS='' DEVSTACK_OWNER_MIGRATION_ORDER=() DEV_SEED_USERS_ENABLED=false -check_port() { echo "$1" >> "$ROOT_DIR/probes"; [[ "$FAILED" != true || "$1" != 9002 ]]; } -check_pm2_online() { echo search >> "$ROOT_DIR/probes"; ACCEPTANCE_PM2_JSON='[]'; ACCEPTANCE_PM2_COLLECTION=ok; } +check_port() { echo "$1" >> "$ROOT_DIR/probes"; READINESS_DETAIL=http_200; if [[ "$FAILED" == true && "$1" == 9002 ]]; then READINESS_DETAIL=http_unreachable; return 1; fi; } +check_pm2_online() { echo search >> "$ROOT_DIR/probes"; ACCEPTANCE_PM2_JSON='[]'; ACCEPTANCE_PM2_COLLECTION=ok; READINESS_DETAIL=pm2_banner_observed; } devstack_apps_csv() { :; } devstack_app_label() { :; } pm2() { :; } sleep() { :; } +timeout() { :; } ''' if broken_writer: setup += 'python3() { return 42; }\n' diff --git a/scripts/test/test_acceptance_report.py b/scripts/test/test_acceptance_report.py index 346931b00..3edd26293 100644 --- a/scripts/test/test_acceptance_report.py +++ b/scripts/test/test_acceptance_report.py @@ -7,6 +7,9 @@ import tempfile import sys import unittest +import contextlib +import io +from unittest.mock import patch from unittest import mock from pathlib import Path @@ -40,8 +43,9 @@ def test_only_public_types_and_source_coordinates_are_serialized(self): self.assertTrue(report["service_diagnostics"]["app"]["rpc_failure_categories"]["serialization"]) self.assertFalse(report["raw_logs_published"]) self.assertIn("org.apache.dubbo.rpc.RpcException", report["exception_classes"]) - self.assertEqual(report["service_diagnostics"]["app"]["public_source_frames"], - [{"symbol": "com.ulticode.app.Probe.submit", "location": "Probe.java:42"}]) + self.assertEqual(report["service_diagnostics"]["app"]["public_source_frames"], []) + self.assertTrue(report["service_diagnostics"]["app"]["unlisted_source_frame_observed"]) + def test_symlinks_unrelated_files_and_untrusted_metadata_are_excluded(self): with tempfile.TemporaryDirectory() as directory: @@ -52,11 +56,13 @@ def test_symlinks_unrelated_files_and_untrusted_metadata_are_excluded(self): (root / "logs/backend-app.err.log").symlink_to(outside) (root / "logs/unrelated.log").write_text(outside.read_text()) report = module.build_report(root, "private-canary", 1, "private-canary") - self.assertEqual(report["service_diagnostics"], {}) + self.assertEqual(report["service_diagnostics"]["app"]["coverage"]["files_excluded"], 1) + self.assertEqual(report["service_diagnostics"]["app"]["exception_classes"], []) self.assertEqual(report["phase"], "unknown") self.assertEqual(report["project"], "unknown") self.assertNotIn("private-canary", json.dumps(report)) + def test_discovery_observations_are_strict_bounded_and_do_not_expose_messages(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -90,6 +96,7 @@ def test_discovery_observations_are_strict_bounded_and_do_not_expose_messages(se (root / "logs/backend-submission.out.log").write_text("\n".join(invalid)) self.assertEqual(module.discovery_observations(root), []) + def test_discovery_observations_exclude_symlinks_and_truncated_records(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -114,6 +121,7 @@ def test_discovery_observations_exclude_symlinks_and_truncated_records(self): log.write_text("x" * module.LIMIT + payload) self.assertEqual(module.discovery_observations(root), []) + def test_app_discovery_is_strict_bounded_and_redacted(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -149,6 +157,7 @@ def test_app_discovery_is_strict_bounded_and_redacted(self): self.assertNotIn("canary", json.dumps(report)) self.assertNotIn("https://", json.dumps(report)) + def test_app_discovery_excludes_symlinks_missing_and_truncated_records(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -178,6 +187,207 @@ def test_app_discovery_excludes_symlinks_missing_and_truncated_records(self): self.assertEqual(module.app_discovery_observations(root), []) + def test_startup_diagnostics_are_fixed_booleans_not_exception_messages(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "logs").mkdir() + (root / ".local/acceptance").mkdir(parents=True) + canaries = ["secret-value-canary", "https://private-user:private-password@private-host.invalid/path?token=private-token", + "Authorization: Bearer private-bearer", "DB_PASSWORD=private-db", "private-stack-body"] + untrusted = "\n".join(canaries) + (root / "logs/backend-submission.err.log").write_text( + "java.lang.IllegalStateException: Submission projection providers were not discovered before readiness deadline\n" + "jakarta.validation.NoProviderFoundException: " + untrusted) + (root / "logs/backend-app.out.log").write_text( + "Started BackendAppApplication in 12.0 seconds\n" + untrusted) + (root / ".local/acceptance/private-launch.log").write_text( + "Application readiness check timed out for: " + untrusted) + report = module.build_report(root, "launch", 1, "ulticode-acceptance-123-456") + self.assertTrue(report["launcher_signals"]["stack_readiness_timed_out"]) + submission = report["service_diagnostics"]["submission"] + self.assertTrue(submission["startup_signals"]["submission_projection_discovery_deadline"]) + self.assertTrue(submission["rpc_failure_categories"]["validation_provider"]) + self.assertTrue(report["service_diagnostics"]["app"]["startup_signals"]["spring_context_started"]) + for owner in report["service_diagnostics"].values(): + self.assertTrue(all(type(value) is bool for value in owner["startup_signals"].values())) + encoded = json.dumps(report) + for canary in canaries: + self.assertNotIn(canary, encoded) + self.assertNotIn("private-host", encoded) + self.assertNotIn("private-password", encoded) + self.assertFalse(report["passed"]) + + + def test_generic_validation_exception_does_not_prove_discovery_deadline(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "logs").mkdir() + (root / "logs/backend-submission.err.log").write_text( + "jakarta.validation.NoProviderFoundException: optional validation provider absent") + report = module.build_report(root, "launch", 1, "ulticode-acceptance-123-456") + self.assertFalse(report["service_diagnostics"]["submission"]["startup_signals"]["submission_projection_discovery_deadline"]) + self.assertFalse(report["launcher_signals"]["stack_readiness_timed_out"]) + + + def test_forged_java_types_and_frames_never_escape_fixed_vocabulary(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "logs").mkdir() + canaries = ["java.syntheticPrivateCanaryException", "com.ulticode.privateCanary.PasswordError", + "com.ulticode.secretCanary.method", "SecretCanary.java:123", + "SubmissionDiscoveryStartupGate.java:987654321"] + (root / "logs/backend-submission.err.log").write_text( + canaries[0] + ": password=secret-canary\n" + canaries[1] + + "\n at " + canaries[2] + "(" + canaries[3] + ")" + + "\n at com.ulticode.submission.config.SubmissionDiscoveryStartupGate.await(" + canaries[4] + ")" + + "\n java.lang.IllegalStateException: secret-canary" + + "\n at com.ulticode.submission.config.SubmissionDiscoveryStartupGate.await(SubmissionDiscoveryStartupGate.java:40)") + report = module.build_report(root, "launch", 1, "private-project-canary") + item = report["service_diagnostics"]["submission"] + self.assertEqual(item["exception_classes"], ["java.lang.IllegalStateException"]) + self.assertEqual(item["public_source_frames"], [{ + "symbol": "com.ulticode.submission.config.SubmissionDiscoveryStartupGate.await", + "location": "SubmissionDiscoveryStartupGate.java:40"}]) + self.assertTrue(item["unlisted_exception_type_observed"]) + self.assertTrue(item["unlisted_source_frame_observed"]) + for canary in canaries + ["secret-canary", "private-project-canary"]: + self.assertNotIn(canary, json.dumps(report)) + + + def test_missing_logs_are_unknown_coverage_not_omitted_services(self): + with tempfile.TemporaryDirectory() as directory: + report = module.build_report(Path(directory), "launch", 1, "ulticode-acceptance-1-2") + self.assertEqual(set(report["service_diagnostics"]), set(module.OWNERS)) + for item in report["service_diagnostics"].values(): + self.assertEqual(item["coverage"]["files_expected"], 2) + self.assertEqual(item["coverage"]["files_seen"], 0) + self.assertEqual(item["coverage"]["files_missing"], 2) + self.assertFalse(item["coverage"]["complete"]) + self.assertEqual(report["launcher_coverage"]["files_missing"], len(module.PRIVATE_LOGS)) + + + def test_truncated_prefix_does_not_claim_absence_or_complete_coverage(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "log" + path.write_text("Submission projection providers discovered\n" + "x" * 256) + with patch.object(module, "LIMIT", 128): + item = module.summary([path]) + self.assertTrue(item["coverage"]["tail_truncated"]) + self.assertFalse(item["coverage"]["complete"]) + self.assertFalse(item["startup_signals"]["submission_projection_providers_discovered"]) + path.write_text("x" * 256 + "\nSubmission projection providers discovered\n") + with patch.object(module, "LIMIT", 128): + item = module.summary([path]) + self.assertTrue(item["coverage"]["tail_truncated"]) + self.assertTrue(item["startup_signals"]["submission_projection_providers_discovered"]) + + + def test_read_error_has_bounded_count_without_exception_detail(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "log" + path.write_text("unused") + with patch.object(Path, "open", side_effect=PermissionError("private-file-canary")): + item = module.summary([path]) + self.assertEqual(item["coverage"]["read_errors"], 1) + self.assertEqual(item["coverage"]["files_seen"], 0) + self.assertFalse(item["coverage"]["complete"]) + self.assertNotIn("private-file-canary", json.dumps(item)) + + + def test_notification_search_and_positive_submission_discovery(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "logs").mkdir() + for owner, name in (("notification", "Notification"), ("search", "Search"), ("submission", "Submission")): + (root / f"logs/backend-{owner}.out.log").write_text( + f"Started {'SearchWorkerApplication' if owner == 'search' else 'Backend' + name + 'Application'} in 1.0 seconds\n" + + ("Submission projection providers discovered\n" if owner == "submission" else "")) + (root / f"logs/backend-{owner}.err.log").write_text("") + report = module.build_report(root, "complete", 0, "ulticode-acceptance-1-2") + self.assertTrue(report["passed"]) + for owner in ("notification", "search", "submission"): + self.assertTrue(report["service_diagnostics"][owner]["coverage"]["complete"]) + self.assertTrue(report["service_diagnostics"][owner]["startup_signals"]["spring_context_started"]) + self.assertTrue(report["service_diagnostics"]["submission"]["startup_signals"]["submission_projection_providers_discovered"]) + self.assertFalse(report["service_diagnostics"]["submission"]["startup_signals"]["submission_projection_discovery_deadline"]) + + + def test_mixed_restart_success_and_failure_are_observations_not_current_health(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "logs").mkdir() + (root / "logs/backend-submission.out.log").write_text( + "Started BackendSubmissionApplication in 1 second\n" + "Submission projection providers discovered\n" + "APPLICATION FAILED TO START\n" + "Submission projection providers were not discovered before readiness deadline\n") + report = module.build_report(root, "launch", 1, "ulticode-acceptance-1-2") + item = report["service_diagnostics"]["submission"]["startup_signals"] + self.assertTrue(item["spring_context_started"]) + self.assertTrue(item["submission_projection_providers_discovered"]) + self.assertTrue(item["submission_projection_discovery_deadline"]) + self.assertFalse(report["passed"]) + self.assertEqual(report["signals_semantics"], "observed_in_bounded_log_tails_not_current_health") + + + def test_each_startup_signal_has_positive_and_negative_coverage(self): + examples = { + "spring_context_started": "Started BackendAppApplication in 1 second", + "application_failed": "APPLICATION FAILED TO START", + "submission_projection_providers_discovered": "Submission projection providers discovered", + "submission_projection_discovery_deadline": "Submission projection providers were not discovered before readiness deadline", + "nacos_client_starting": "Client not connected, current status: STARTING", + "nacos_client_unhealthy": "Client not connected, current status:UNHEALTHY", + "connection_refused": "Connection refused", + "reactor_build_failed": "Error: reactor install failed; fix compilation errors before starting the stack.", + "auth_readiness_failed": "ulticode-auth did not become healthy; cannot run dev-admin bootstrap.", + "app_readiness_failed": "backend-app did not become healthy; starting ulticode-judge anyway", + "stack_readiness_timed_out": "Application readiness check timed out for:", + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "log" + path.write_text("ordinary non-diagnostic output") + self.assertFalse(any(module.summary([path])["startup_signals"].values())) + for key, example in examples.items(): + with self.subTest(key=key): + path.write_text(example + "\nhttps://secret-user:secret-password@private.invalid/token") + item = module.summary([path]) + self.assertTrue(item["startup_signals"][key]) + self.assertNotIn("secret-user", json.dumps(item)) + self.assertNotIn("secret-password", json.dumps(item)) + self.assertEqual(set(examples), set(item["startup_signals"])) + + + def test_current_fail_closed_dependency_messages_are_observations(self): + messages = { + "auth_readiness_failed": "backend-auth did not become healthy; not starting App or its deferred consumers. Fix Auth readiness and re-run up.sh.", + "app_readiness_failed": "backend-app did not become healthy; not starting deferred Submission/Judge services. Fix App readiness and re-run up.sh.", + } + source = (ROOT / "scripts/dev/up.sh").read_text() + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "log" + for key, message in messages.items(): + self.assertIn(message, source) + path.write_text(message + "\nprivate-message-canary") + item = module.summary([path]) + self.assertTrue(item["startup_signals"][key]) + self.assertNotIn("private-message-canary", json.dumps(item)) + + def test_success_main_writes_bounded_report_without_failure_output(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch.object(Path, "cwd", return_value=root), patch.dict(os.environ, { + "ACCEPTANCE_PHASE": "complete", "ACCEPTANCE_EXIT": "0", "COMPOSE_PROJECT_NAME": "ulticode-acceptance-1-2"}), contextlib.redirect_stdout(io.StringIO()) as output: + self.assertEqual(module.main(), 0) + self.assertEqual(output.getvalue(), "") + report = json.loads((root / ".local/acceptance/installation.json").read_text()) + self.assertTrue(report["passed"]) + self.assertFalse(report["raw_logs_published"]) + self.assertEqual(report["project"], "disposable") + + + + class AdditionalObservationTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() diff --git a/scripts/test/test_dev_admin_bootstrap.py b/scripts/test/test_dev_admin_bootstrap.py new file mode 100644 index 000000000..7bc6b9cf6 --- /dev/null +++ b/scripts/test/test_dev_admin_bootstrap.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Run the launcher's actual frozen bootstrap policy without starting services.""" +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[2] +UP = ROOT / "scripts/dev/up.sh" +POLICY = re.compile(r"^dev_admin_bootstrap_enabled\(\) \{\n.*?^readonly -f dev_admin_bootstrap_enabled require_dev_admin_password\n", re.M | re.S) + + +class BootstrapPasswordTests(unittest.TestCase): + def run_policy(self, password=None, **overrides): + source = UP.read_text() + match = POLICY.search(source) + self.assertIsNotNone(match, "Actual bootstrap policy must remain directly testable") + environment = dict(os.environ) + environment.pop("DEV_SEED_ADMIN_PASSWORD", None) + environment.update(SKIP_BOOTSTRAP="false", PREPARE_SUBMISSION_OWNER="false", + DEV_SEED_USERS_ENABLED="true", PM2_APPS="ulticode-auth,ulticode-app") + environment.update(overrides) + if password is not None: + environment["DEV_SEED_ADMIN_PASSWORD"] = password + with tempfile.TemporaryDirectory() as directory: + policy = Path(directory) / "policy.sh" + policy.write_text(match.group()) + result = subprocess.run(["bash", "-c", '''set -euo pipefail +source "$1" +before="${DEV_SEED_ADMIN_PASSWORD-}" +before_set="${DEV_SEED_ADMIN_PASSWORD+x}" +require_dev_admin_password +[[ "${DEV_SEED_ADMIN_PASSWORD-}" == "$before" ]] +[[ "${DEV_SEED_ADMIN_PASSWORD+x}" == "$before_set" ]] +''', "bootstrap-contract", str(policy)], env=environment, text=True, capture_output=True) + self.assertEqual(result.stdout, "") + if password and password.strip(): + self.assertNotIn(password, result.stderr) + return result + + def test_enabled_bootstrap_rejects_missing_password(self): + result = self.run_policy() + self.assertNotEqual(result.returncode, 0) + self.assertIn("DEV_SEED_ADMIN_PASSWORD is required", result.stderr) + + def test_enabled_bootstrap_rejects_empty_password(self): + self.assertNotEqual(self.run_policy("").returncode, 0) + + def test_enabled_bootstrap_rejects_whitespace_only_password(self): + for value in (" ", "\t", "\n", "\r\n", " \t\r\n "): + with self.subTest(kind=repr(value)): + self.assertNotEqual(self.run_policy(value).returncode, 0) + + def test_java_unicode_whitespace_only_password_is_rejected(self): + codepoints = ( + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, + 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, + 0x2006, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x205F, 0x3000, + ) + values = [chr(codepoint) for codepoint in codepoints] + for locale in ("C", "C.UTF-8"): + for value in values + ["".join(values)]: + with self.subTest(locale=locale, codepoints=[hex(ord(char)) for char in value]): + self.assertNotEqual(self.run_policy(value, LC_ALL=locale).returncode, 0) + + def test_java_nonbreaking_spaces_are_preserved_as_nonblank(self): + for locale in ("C", "C.UTF-8"): + for value in ("\u00a0", "\u2007", "\u202f", "测试用非ASCII值", " \u1680有效值\u3000 "): + with self.subTest(locale=locale, codepoints=[hex(ord(char)) for char in value]): + result = self.run_policy(value, LC_ALL=locale) + self.assertEqual(result.returncode, 0) + self.assertEqual(result.stderr, "") + + def test_explicit_password_is_preserved_without_printing(self): + for value in ("test-only-explicit-value=$with punctuation", " test-only-value \t"): + with self.subTest(): + self.assertEqual(self.run_policy(value).returncode, 0) + + def test_skip_bootstrap_allows_missing_password(self): + self.assertEqual(self.run_policy(SKIP_BOOTSTRAP="true").returncode, 0) + + def test_prepare_owner_without_bootstrap_allows_missing_password(self): + self.assertEqual(self.run_policy(PREPARE_SUBMISSION_OWNER="true").returncode, 0) + + def test_disabled_dev_users_allow_empty_password(self): + self.assertEqual(self.run_policy("", DEV_SEED_USERS_ENABLED="false").returncode, 0) + + def test_no_auth_selection_allows_missing_password(self): + self.assertEqual(self.run_policy(PM2_APPS="ulticode-app,ulticode-judge").returncode, 0) + + def test_similarly_named_service_is_not_auth(self): + self.assertEqual(self.run_policy(PM2_APPS="ulticode-auth-other").returncode, 0) + + def test_policy_is_frozen_before_env_and_checked_before_side_effects(self): + source = UP.read_text() + self.assertNotRegex(source, r"DEV_SEED_ADMIN_PASSWORD:=") + frozen = source.index("readonly -f dev_admin_bootstrap_enabled require_dev_admin_password") + self.assertLess(frozen, source.index("\nload_env_file\n")) + check = source.index("require_dev_admin_password || exit $?") + for marker in ("\nmaterialize_redis_acl ", "\nprovision_submission_migration_principal()", "\nif dev_admin_bootstrap_enabled; then", "\npreflight_service_ports\n"): + self.assertLess(check, source.index(marker), marker) + self.assertEqual(source.count("if dev_admin_bootstrap_enabled; then"), 1) + self.assertEqual(source.count("require_dev_admin_password || exit $?"), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test/test_dev_readiness.py b/scripts/test/test_dev_readiness.py new file mode 100644 index 000000000..104e263d1 --- /dev/null +++ b/scripts/test/test_dev_readiness.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Offline contracts: no live services, raw responses or relaxed readiness.""" +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / 'scripts/ci')) +def load(name, path): + spec = importlib.util.spec_from_file_location(name, ROOT / path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module +health = load('health', 'scripts/dev/readiness-diagnostics.py') +report = load('report', 'scripts/ci/acceptance_report.py') +SOURCE = (ROOT / 'scripts/dev/up.sh').read_text() +FUNCTIONS = SOURCE[SOURCE.index('check_url() {'):SOURCE.index('for _ in $(seq 1 "$DEVSTACK_SERVICE_READINESS_ATTEMPTS"); do', SOURCE.index('check_url() {'))] + +class ReadinessTests(unittest.TestCase): + def shell(self, body): + with tempfile.TemporaryDirectory() as root: + return subprocess.run(['bash', '-c', 'set -euo pipefail\nROOT_DIR="$1"\n' + FUNCTIONS + '\n' + body, 'test', root], text=True, capture_output=True, check=True).stdout + + def test_ipv4_ipv6_success_and_failure_enums(self): + result = self.shell(''' +check_url() { case "$1" in *127.0.0.1*) echo 503;; *) echo 200;; esac; } +check_port 9103 /fixed +[[ "$READINESS_DETAIL" == http_200 ]] +check_url() { echo 'private-value'; } +if check_port 9103 /fixed; then exit 1; fi +[[ "$READINESS_DETAIL" == http_other/http_other ]] +check_url() { echo 503; } +if check_port 9103 /fixed; then exit 1; fi +[[ "$READINESS_DETAIL" == http_503/http_503 ]] +echo PASS +''') + self.assertEqual(result.strip(), 'PASS') + + def test_pm2_requires_online_and_current_banner(self): + self.shell(''' +pm2_load_records() { :; } +pm2_record_is_online() { false; } +devstack_readiness_banner() { echo 'Started Worker'; } +if check_pm2_online ulticode-submission; then exit 1; fi +[[ "$READINESS_DETAIL" == pm2_not_online ]] +pm2_record_is_online() { true; } +if check_pm2_online ulticode-submission; then exit 1; fi +[[ "$READINESS_DETAIL" == pm2_log_missing ]] +mkdir -p "$ROOT_DIR/logs" +echo 'Started Worker' > "$ROOT_DIR/logs/backend-submission.out.log" +wc -c < "$ROOT_DIR/logs/backend-submission.out.log" > "$ROOT_DIR/logs/.submission-ready-offset" +if check_pm2_online ulticode-submission; then exit 1; fi +[[ "$READINESS_DETAIL" == pm2_banner_missing ]] +echo 'Started Worker' >> "$ROOT_DIR/logs/backend-submission.out.log" +check_pm2_online ulticode-submission +[[ "$READINESS_DETAIL" == pm2_banner_observed ]] +''') + + def test_last_cycle_replaces_earlier_failure(self): + self.shell(''' +SELECTED_READINESS=('ulticode-app|http|9103|/fixed' 'ulticode-search|pm2|0|') +check_port() { READINESS_DETAIL=http_503/http_503; return 1; } +check_pm2_online() { READINESS_DETAIL=pm2_banner_observed; return 0; } +if check_selected_readiness; then exit 1; fi +[[ "${READINESS_RESULTS[ulticode-app]}" == 'failed http_503/http_503' ]] +check_port() { READINESS_DETAIL=http_200; return 0; } +check_selected_readiness +[[ "${READINESS_RESULTS[ulticode-app]}" == 'ready http_200' ]] +SELECTED_READINESS=('ulticode-app|unknown|0|') +if check_selected_readiness; then exit 1; fi +[[ "${READINESS_RESULTS[ulticode-app]}" == 'failed unknown_probe_kind' ]] +''') + + def test_acceptance_snapshot_maps_each_completed_cycle(self): + self.shell(''' +ACCEPTANCE_DISPOSABLE=true +declare -A ACCEPTANCE_LAST_READINESS=() +SELECTED_READINESS=('ulticode-app|http|9103|/fixed' 'ulticode-submission|pm2|0|') +check_port() { READINESS_DETAIL=http_503/http_503; return 1; } +check_pm2_online() { READINESS_DETAIL=pm2_banner_observed; return 0; } +if check_selected_readiness; then exit 1; fi +[[ "${ACCEPTANCE_LAST_READINESS[ulticode-app]}" == not_ready ]] +[[ "${ACCEPTANCE_LAST_READINESS[ulticode-submission]}" == ready ]] +check_port() { READINESS_DETAIL=http_200; return 0; } +check_selected_readiness +[[ "${ACCEPTANCE_LAST_READINESS[ulticode-app]}" == ready ]] +SELECTED_READINESS=('ulticode-submission|unknown|0|') +if check_selected_readiness; then exit 1; fi +[[ "${ACCEPTANCE_LAST_READINESS[ulticode-submission]}" == not_ready ]] +''') + + def test_regular_readiness_does_not_require_acceptance_state(self): + self.shell(''' +unset ACCEPTANCE_DISPOSABLE ACCEPTANCE_LAST_READINESS +SELECTED_READINESS=('ulticode-app|http|9103|/fixed') +check_port() { READINESS_DETAIL=http_200; return 0; } +check_selected_readiness +! declare -p ACCEPTANCE_LAST_READINESS >/dev/null 2>&1 +''') + + def test_diagnostics_remain_after_success_exit_and_keep_snapshot_hook(self): + self.assertEqual(SOURCE.count('trap acceptance_launch_snapshot EXIT'), 1) + self.assertEqual(SOURCE.count('timeout --kill-after=2s 25s python3 -u'), 1) + tail = SOURCE[SOURCE.index('check_url() {'):] + self.assertLess(tail.index(' exit 0'), tail.index('\nemit_final_readiness\n')) + self.assertTrue(tail.rstrip().endswith('exit 1')) + + def test_hung_diagnostic_is_killed_without_changing_failure_exit(self): + # Exercise the production TERM/KILL wrapper with shorter test durations. + wrapper = next(line for line in SOURCE.splitlines() if line.startswith('timeout --kill-after=')) + self.assertIn('--kill-after=2s 25s python3 -u', wrapper) + with tempfile.TemporaryDirectory() as root: + script = Path(root) / 'scripts/dev/readiness-diagnostics.py' + script.parent.mkdir(parents=True) + script.write_text('import signal,time\nsignal.signal(signal.SIGTERM, signal.SIG_IGN)\nwhile True: time.sleep(0.01)\n') + command = wrapper.replace('--kill-after=2s 25s', '--kill-after=0.1s 0.2s') + started = time.monotonic() + result = subprocess.run(['bash', '-c', 'ROOT_DIR="$1"; PM2_APPS=ulticode-app; ' + command + '; echo cleanup-ran; exit 1', 'test', root], capture_output=True, text=True, timeout=3) + self.assertEqual(result.returncode, 1) + self.assertIn('cleanup-ran', result.stdout) + self.assertLess(time.monotonic() - started, 2) + + def test_known_components_only(self): + body = json.dumps({'components': {'db':'DOWN', 'redis':'UP', 'private-key':'private-value', 'storage':'private-value'}}).encode() + status, components = health.classify(503, body, ('db','redis','storage')) + self.assertEqual((status, components), ('http_503', {'db':'down','redis':'up','storage':'unknown'})) + self.assertNotIn('private', json.dumps(components)) + + def test_invalid_oversize_and_redirect(self): + for code, body, expected in [(200,b'bad','invalid_response'),(200,b'[]','invalid_response'),(200,b'x'*(health.LIMIT+1),'oversized_response'),(302,b'private-url','http_other')]: + self.assertEqual(health.classify(code, body, ('db',)), (expected, {'db':'unknown'})) + + def test_probe_has_fixed_destination_limit_and_no_redirect(self): + seen = [] + class Connection: + def __init__(self, *args, **kwargs): seen.append((args, kwargs)) + def request(self, *args): seen.append(args) + def getresponse(self): return self + status = 302 + def read(self, count): seen.append(count); return b'private-url' + def close(self): seen.append('closed') + result = health.probe('ulticode-notification', Connection) + self.assertEqual(result[0], 'http_other') + self.assertEqual(seen, [(('127.0.0.1',9105),{'timeout':5}),('GET','/api/v1/notification/health/ready'),health.LIMIT+1,'closed']) + + def test_exception_text_never_emitted(self): + def fail(*args, **kwargs): raise OSError('https://user:secret@private.example') + with patch.object(health, 'probe', side_effect=lambda app: health.probe_original(app, fail)), contextlib.redirect_stdout(io.StringIO()) as out: + health.main(['ulticode-app,private-app']) + self.assertNotIn('secret', out.getvalue()) + self.assertNotIn('private', out.getvalue()) + self.assertIn('state=unreachable', out.getvalue()) + + def test_parser_last_markers_and_fixed_vocabulary(self): + text = '\n'.join(['ULTICODE_FINAL_READINESS app=ulticode-app state=failed detail=http_503/http_503', 'ULTICODE_FINAL_READINESS app=ulticode-app state=ready detail=http_200', 'ULTICODE_FINAL_READINESS app=private-app state=failed detail=http_503', 'ULTICODE_FINAL_READINESS app=ulticode-app state=failed detail=private-value', 'ULTICODE_FINAL_HEALTH app=ulticode-admin state=http_503', 'ULTICODE_FINAL_COMPONENT app=ulticode-admin component=problemOwnerRpc state=down', 'ULTICODE_FINAL_COMPONENT app=ulticode-admin component=private-key state=up']) + data = report.final_probes(text) + self.assertEqual(data['final_readiness']['ulticode-app']['state'], 'ready') + self.assertEqual(data['final_dependency_probes']['ulticode-admin']['components']['problemOwnerRpc'], 'down') + self.assertFalse(data['final_readiness']['ulticode-search']['observed']) + self.assertNotIn('private', json.dumps(data)) + + def test_search_actual_entrypoint(self): + with tempfile.TemporaryDirectory() as root: + log = Path(root)/'search.log' + log.write_text('Started SearchWorkerApplication in 1 seconds') + self.assertTrue(report.summary([log])['startup_signals']['spring_context_started']) + +health.probe_original = health.probe +if __name__ == '__main__': unittest.main() diff --git a/scripts/test/test_pm2_startup_order.py b/scripts/test/test_pm2_startup_order.py index b4aaaaed3..ad1582574 100644 --- a/scripts/test/test_pm2_startup_order.py +++ b/scripts/test/test_pm2_startup_order.py @@ -9,7 +9,9 @@ SOURCE = (ROOT / 'scripts/dev/up.sh').read_text() STARTUP = SOURCE.split('# ===== 步骤 6: PM2 服务 =====', 1)[1].split('# ===== 步骤 7:', 1)[0] BOOTSTRAP = SOURCE.split('# ===== 步骤 4:', 1)[1].split('# ===== 步骤 5:', 1)[0] -BOOTSTRAP = '# ===== 步骤 4:' + BOOTSTRAP +POLICY = SOURCE.split('dev_admin_bootstrap_enabled() {', 1)[1].split('# ===== 参数解析 =====', 1)[0] +POLICY = 'dev_admin_bootstrap_enabled() {' + POLICY +BOOTSTRAP = POLICY + '# ===== 步骤 4:' + BOOTSTRAP CHECK = SOURCE.split('check_pm2_online() {', 1)[1].split('\nfor _ in ', 1)[0] CHECK = 'check_pm2_online() {' + CHECK @@ -89,6 +91,7 @@ def run_stage(self, apps, scope='admin', failed=False, fresh=True, old_logs=True ''' bootstrap_setup = r''' DEV_SEED_USERS_ENABLED=true +PREPARE_SUBMISSION_OWNER=false DEVSTACK_DUBBO_REGISTRATION_DELAY_SECONDS=0 DEVSTACK_BOOTSTRAP_KILL_AFTER_SECONDS=1 DEVSTACK_BOOTSTRAP_TIMEOUT_SECONDS=1 diff --git a/services/admin/pom.xml b/services/admin/pom.xml index 41285429d..f9c4f11ac 100644 --- a/services/admin/pom.xml +++ b/services/admin/pom.xml @@ -13,7 +13,7 @@ - 1.0.6 + 1.0.7 backend-admin ${service.version.admin} diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index 4e669d9c0..b38e18671 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-55eb004505226f20e823f0b18192a463d37fdc8cd9aca5ef1c344cf4a3230069", - "chunk_id": "repository-development:sha256-55eb004505226f20e823f0b18192a463d37fdc8cd9aca5ef1c344cf4a3230069:1", + "version": "sha256-b82d7a1f1b140850d8cc7ea6364bfe705876b20be69ba7013a3c499fd69b654f", + "chunk_id": "repository-development:sha256-b82d7a1f1b140850d8cc7ea6364bfe705876b20be69ba7013a3c499fd69b654f:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", diff --git a/services/app/pom.xml b/services/app/pom.xml index 8dd224140..5fa18ef2f 100644 --- a/services/app/pom.xml +++ b/services/app/pom.xml @@ -13,7 +13,7 @@ - 1.0.6 + 1.0.7 backend-app ${service.version.app} diff --git a/services/auth/pom.xml b/services/auth/pom.xml index 2e0e9d283..c95429cab 100644 --- a/services/auth/pom.xml +++ b/services/auth/pom.xml @@ -13,7 +13,7 @@ - 1.0.6 + 1.0.7 backend-auth ${service.version.auth} diff --git a/services/judge/pom.xml b/services/judge/pom.xml index 8b969ac89..f69064c0f 100644 --- a/services/judge/pom.xml +++ b/services/judge/pom.xml @@ -15,7 +15,7 @@ - 1.0.6 + 1.0.7 backend-judge diff --git a/services/notification/pom.xml b/services/notification/pom.xml index 70bdb9e69..bf9bb6735 100644 --- a/services/notification/pom.xml +++ b/services/notification/pom.xml @@ -14,7 +14,7 @@ - 1.0.6 + 1.0.7 backend-notification ${service.version.notification} diff --git a/services/pom.xml b/services/pom.xml index 7c276570d..1ac5c5cda 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -52,13 +52,13 @@ - 1.0.6 - 1.0.6 - 1.0.6 - 1.0.6 - 1.0.6 - 1.0.6 - 1.0.6 + 1.0.7 + 1.0.7 + 1.0.7 + 1.0.7 + 1.0.7 + 1.0.7 + 1.0.7 17 2.21.7 diff --git a/services/search/pom.xml b/services/search/pom.xml index f20034628..8c87cc5fb 100644 --- a/services/search/pom.xml +++ b/services/search/pom.xml @@ -14,7 +14,7 @@ - 1.0.6 + 1.0.7 backend-search diff --git a/services/submission/pom.xml b/services/submission/pom.xml index 1272cc6fa..4d310e10f 100644 --- a/services/submission/pom.xml +++ b/services/submission/pom.xml @@ -15,7 +15,7 @@ - 1.0.6 + 1.0.7 backend-submission