import { readFileSync } from 'node:fs'; const jenkinsfile = readFileSync( 'jenkins/Jenkinsfile.preview-deployer', 'utf8', ); const deployer = readFileSync('scripts/jenkins-preview-deployer.sh', 'utf8'); const apiServerDockerfile = readFileSync( 'deploy/container/api-server.Dockerfile', 'utf8', ); const resultWriter = readFileSync( 'scripts/preview-deployment-status.mjs', 'utf8', ); const compose = readFileSync( 'deploy/container/docker-compose.loadtest.yml', 'utf8', ); const webConfig = readFileSync( 'apps/preview-deployer-web/vite.config.ts', 'utf8', ); const systemd = readFileSync( 'deploy/systemd/genarrative-preview-deployer.service', 'utf8', ); const environmentExample = readFileSync( 'deploy/env/preview-deployer.env.example', 'utf8', ); const server = readFileSync( 'server-rs/crates/preview-deployer-server/src/lib.rs', 'utf8', ); const nginx = readFileSync( 'deploy/nginx/genarrative-preview-deployer-lan.conf', 'utf8', ); const jobConfig = readFileSync( 'jenkins/preview-deployer-job-config.xml', 'utf8', ); const failures = []; assertIncludes( jenkinsfile, "choice(name: 'ACTION', choices: ['DEPLOY', 'STATUS', 'UNINSTALL']", 'Jenkins 参数必须固定为 DEPLOY/STATUS/UNINSTALL。', ); assertIncludes( jenkinsfile, "string(name: 'SOURCE_BRANCH'", 'Jenkins 必须暴露 SOURCE_BRANCH。', ); assertIncludes( jenkinsfile, "string(name: 'COMMIT_HASH'", 'Jenkins 必须暴露 COMMIT_HASH。', ); assertIncludes( jenkinsfile, "string(name: 'DEPLOYMENT_ID'", 'Jenkins 必须暴露 DEPLOYMENT_ID。', ); assertIncludes( jenkinsfile, "archiveArtifacts artifacts: 'preview-result.json'", 'Jenkins 必须归档 preview-result.json。', ); assertIncludes( jenkinsfile, "writeFile file: 'preview-result.json'", 'Jenkins 失败路径也必须生成 preview-result.json。', ); assertIncludes( jenkinsfile, 'disableConcurrentBuilds()', 'Jenkins Job 必须禁止并发构建。', ); assertIncludes( jenkinsfile, 'COMMIT_HASH="${COMMIT_HASH:-}"', 'Jenkins Checkout 阶段必须允许 COMMIT_HASH 未导出或为空。', ); assertIncludes( jenkinsfile, 'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"', 'Jenkins Checkout 阶段必须显式使用 bash 执行源码检出脚本。', ); assertExcludes( jenkinsfile, 'COMMIT_HASH="${COMMIT_HASH}"', 'Jenkins Checkout 阶段不得在 set -u 下直接展开未导出的 COMMIT_HASH。', ); assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。'); assertIncludes( deployer, 'git check-ref-format --branch', '部署脚本必须校验分支格式。', ); assertIncludes( deployer, 'genarrative-preview-[0-9a-f]{16}', '卸载必须限制到预览 compose project 白名单。', ); assertIncludes( deployer, 'allocate_port webPort 8400 8499', 'Web 端口必须从 8400-8499 分配。', ); assertIncludes( deployer, 'ports: !reset []', '预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。', ); assertIncludes( deployer, 'spacetimedb:\n mem_limit: 2g\n ports: !reset []', '预览 SpacetimeDB 必须覆盖压测基线内存限制,避免模块首次加载时 OOM。', ); assertIncludes( compose, ' mem_limit: 2g\n ports:', '完整容器 SpacetimeDB 必须允许当前模块首次实例化的内存峰值。', ); assertIncludes( deployer, 'external-generation-worker:\n build:', '预览 compose override 必须配置外部生成 worker。', ); assertIncludes( deployer, 'restart: on-failure', '预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。', ); assertIncludes( deployer, 'GENARRATIVE_PREVIEW_SECRETS_SHA256', '预览构建必须把固定 secrets 文件摘要作为镜像缓存与完整性校验参数。', ); assertIncludes( deployer, 'GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256', '预览构建必须把固定 .env.local 文件摘要作为镜像缓存与完整性校验参数。', ); assertIncludes( jenkinsfile, "GENARRATIVE_PREVIEW_SECRETS_FILE = '/data/jenkins/preview-secrets/.env.secrets.local'", 'Jenkins 必须从受保护的固定宿主路径读取预览 secrets。', ); assertIncludes( jenkinsfile, "GENARRATIVE_PREVIEW_ENV_LOCAL_FILE = '/data/jenkins/preview-secrets/.env.local'", 'Jenkins 必须从受保护的固定宿主路径读取预览 .env.local。', ); assertIncludes( deployer, '[[ "${file_mode}" == "600" ]]', '预览固定输入文件必须拒绝权限过宽。', ); assertIncludes( deployer, '[[ "${dir_mode}" == "700" ]]', '预览固定输入文件所在目录必须拒绝权限过宽。', ); assertIncludes( deployer, '[[ "${file_owner}" == "${EUID}" ]]', '预览固定输入文件必须校验 owner 归 Jenkins 执行用户所有。', ); assertIncludes( deployer, '[[ "${dir_owner}" == "${EUID}" ]]', '预览固定输入文件所在目录必须校验 owner 归 Jenkins 执行用户所有。', ); assertIncludes( deployer, 'genarrative_preview_secrets', '预览 API 与外部生成 worker 构建必须使用固定名称的 BuildKit secret。', ); assertCount( deployer, 'target: genarrative_preview_secrets', 2, '预览 secrets 必须且只能提供给 API 和外部生成 worker 两个构建。', ); assertCount( deployer, 'target: genarrative_preview_env_local', 2, '预览 .env.local 必须且只能提供给 API 和外部生成 worker 两个构建。', ); assertIncludes( apiServerDockerfile, 'ARG GENARRATIVE_PREVIEW_SECRETS_SHA256=', 'API 镜像必须允许普通构建不提供预览 secrets 摘要。', ); assertIncludes( apiServerDockerfile, 'ARG GENARRATIVE_PREVIEW_ENV_LOCAL_SHA256=', 'API 镜像必须允许普通构建不提供预览 .env.local 摘要。', ); assertIncludes( apiServerDockerfile, 'RUN --mount=type=secret,id=genarrative_preview_secrets,required=false', 'API 镜像必须通过可选 BuildKit secret 接收预览 secrets 文件。', ); assertIncludes( apiServerDockerfile, 'test -f /run/secrets/genarrative_preview_secrets;', '提供预览 secrets 摘要时必须要求 BuildKit secret 存在。', ); assertIncludes( apiServerDockerfile, 'sha256sum /run/secrets/genarrative_preview_secrets', 'API 镜像必须在安装前校验预览 secrets 文件摘要。', ); assertIncludes( apiServerDockerfile, 'install -o genarrative -g genarrative -m 0400', '预览 secrets 文件必须只允许 API 运行用户读取。', ); assertIncludes( apiServerDockerfile, '/run/secrets/genarrative_preview_secrets /srv/genarrative/.env.secrets.local;', '预览 secrets 文件必须安装到 API 启动时读取的固定路径。', ); assertIncludes( apiServerDockerfile, '--mount=type=secret,id=genarrative_preview_env_local,required=false', 'API 镜像必须通过可选 BuildKit secret 接收预览 .env.local 文件。', ); assertIncludes( apiServerDockerfile, '/run/secrets/genarrative_preview_env_local /srv/genarrative/.env.local;', '预览 .env.local 文件必须安装到 API 启动时读取的固定路径。', ); assertIncludes( deployer, 'GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true', '预览独立空库必须允许首次密码登录自动创建预览账号。', ); assertIncludes( deployer, "'SMS_AUTH_ENABLED=true',\n 'SMS_AUTH_PROVIDER=mock',\n 'SMS_AUTH_MOCK_VERIFY_CODE=123456'", '预览短信登录必须使用固定 mock 验证码,不得继承生产短信密钥。', ); assertIncludes( deployer, '^preview-[0-9a-f]{16}$', '部署 ID 必须固定为 preview- 加 16 位摘要。', ); assertIncludes( deployer, 'state.active === true && String(state[key]) === expected', '只有活动实例才能占用端口租约。', ); assertIncludes( deployer, '--yes=remote,migrate,break-clients', 'SpacetimeDB 预览发布必须显式确认受控容器网络目标,且不得默认删除实例数据。', ); assertExcludes( deployer, '--delete-data', '预览部署不得使用 SpacetimeDB 删除数据参数。', ); assertIncludes( jenkinsfile, "GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'", 'Jenkins 必须固定输出可供同事访问的内网 Web 主机地址。', ); assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。'); assertIncludes( systemd, 'User=jenkins', '控制服务必须使用 Jenkins 运行用户以访问受控构建状态。', ); assertIncludes( nginx, 'location ^~ /build/', '内网 Nginx 必须提供 /build/ 路由。', ); assertIncludes( server, '.nest_service("/build/assets", ServeDir::new(static_dir.join("assets")))', '控制服务必须原生托管 /build 子路径,不能依赖 Nginx 隐式改写。', ); assertIncludes( server, 'FAILED_RECORD_TTL_SECS', '控制服务必须清理过期且不可卸载的失败/取消记录。', ); assertIncludes( server, 'STOPPED_RECORD_TTL_SECS', '控制服务必须为已卸载记录配置有限审计保留期。', ); assertIncludes( environmentExample, 'GENARRATIVE_PREVIEW_DEPLOYER_JENKINS_PUBLIC_BASE_URL=http://192.168.35.82:8080/jenkins/', '构建详情必须使用局域网 Jenkins 地址而非 loopback。', ); assertIncludes( jobConfig, 'jenkins/Jenkinsfile.preview-deployer', 'Jenkins Job 必须从受保护 master 读取固定流水线。', ); assertIncludes( jobConfig, '', 'Jenkins Job 首次触发前必须已经声明 buildWithParameters 参数。', ); assertIncludes( nginx, 'location ^~ /api/preview-deployer/', '内网 Nginx 必须代理预览控制 API。', ); assertIncludes( resultWriter, 'schemaVersion: 1', '结果文件必须带版本化 schema。', ); assertIncludes(resultWriter, 'healthStatus', '结果文件必须提供健康状态。'); assertIncludes(resultWriter, 'webUrl', '结果文件必须提供 Web URL。'); assertIncludes( resultWriter, 'resolvedCommit', '结果文件必须提供 resolvedCommit。', ); assertIncludes( resultWriter, 'status: publicStatus(phase)', '结果文件必须提供小写 status 契约。', ); assertIncludes( resultWriter, 'health: publicHealth(healthStatus)', '结果文件必须提供小写 health 契约。', ); const normalizedCompose = compose.replaceAll("'", '"'); assertIncludes( normalizedCompose, 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/"]', 'Nginx 容器健康检查必须验证 Web 首页。', ); assertExcludes( normalizedCompose, 'test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]', 'Nginx 容器健康检查不能访问公开拒绝的 /healthz。', ); if (failures.length > 0) { for (const failure of failures) console.error(`[check:preview-deployer] ${failure}`); process.exit(1); } console.log( '[check:preview-deployer] Jenkins 参数、隔离部署、卸载白名单、结果契约和 Nginx 健康检查通过。', ); function assertIncludes(content, expected, message) { if (!content.includes(expected)) failures.push(message); } function assertExcludes(content, expected, message) { if (content.includes(expected)) failures.push(message); } function assertCount(content, expected, count, message) { if (content.split(expected).length - 1 !== count) failures.push(message); }