diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml index 273558cf..9b17b945 100644 --- a/config/fixtures/pikaoa-test-target.yaml +++ b/config/fixtures/pikaoa-test-target.yaml @@ -13,10 +13,9 @@ testRuntime: TEST01: enabled: true validationOnly: true - dedicated: true node: TEST01 route: TEST01:k3s - namespacePrefix: pikaoa-test + namespace: pikaoa-test ttlSeconds: 7200 waitTimeoutSeconds: 180 fieldManager: unidesk-pikaoa-test-target @@ -34,27 +33,32 @@ testRuntime: images: api: registry.invalid/pikaoa/api:${commit} worker: registry.invalid/pikaoa/worker:${commit} + web: registry.invalid/pikaoa/web:${commit} migration: registry.invalid/pikaoa/migration:${commit} pullPolicy: IfNotPresent database: - image: postgres:16-alpine + configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url name: pikaoa_test username: pikaoa_test schema: pikaoa - port: 5432 - password: - sourceRef: ./secrets/pikaoa-test-database-password.txt - targetKey: PIKAOA_DATABASE_PASSWORD + connection: + sourceRef: ./secrets/pikaoa-test.env + sourceKey: DATABASE_URL + targetKey: DATABASE_URL runtime: secretName: pikaoa-test-runtime + apiServiceName: pikaoa-api apiPort: 8080 workerMetricsPort: 8081 + webPort: 8080 + webApiUpstreamEnv: PIKAOA_API_UPSTREAM attachment: storageRoot: /var/lib/pikaoa/attachments maxBytes: 104857600 claimName: pikaoa-attachments storageRequest: 1Gi storageClassName: null + fsGroup: 65532 accessModes: - ReadWriteOnce workerInterval: 2s @@ -75,7 +79,8 @@ testRuntime: sourceRef: ./secrets/pikaoa-test-employee-password.txt targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD sessionSecret: - sourceRef: ./secrets/pikaoa-test-session-secret.txt + sourceRef: ./secrets/pikaoa-test.env + sourceKey: PIKAOA_SESSION_SECRET targetKey: PIKAOA_SESSION_SECRET migration: jobName: pikaoa-migrate @@ -86,6 +91,11 @@ testRuntime: - up - --config - /etc/pikaoa/pikaoa.yaml + exposure: + serviceName: pikaoa-web + serviceType: NodePort + hostIP: 192.0.2.10 + port: 32080 observability: otlpEndpoint: http://otel-collector.observability.svc.cluster.local:4318 prometheus: @@ -102,6 +112,9 @@ testRuntime: worker: livenessPath: /healthz readinessPath: /readyz + web: + livenessPath: /healthz + readinessPath: /healthz delivery: targets: diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index a95d7998..e3a4a12a 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -27,9 +27,114 @@ modules: workers: named-consumer testRuntime: - # 专用测试集群 target 由用户明确声明后才能启用;空对象表示当前不允许远端测试运行。 - defaultTargetId: null - targets: {} + defaultTargetId: NC01 + targets: + NC01: + enabled: true + validationOnly: false + node: NC01 + route: NC01:k3s + namespace: pikaoa-test + ttlSeconds: 86400 + waitTimeoutSeconds: 300 + fieldManager: unidesk-pikaoa-test-target + taskState: + rootPath: /var/lib/unidesk/pikaoa-test-target + statusRequestTimeoutSeconds: 10 + logTailLines: 80 + cleanup: + mode: delete-namespace + requireOwnershipLabels: true + source: + mode: prebuilt-images + repository: pikainc/pikaoa + commitPolicy: cli-required + images: + api: 127.0.0.1:5000/pikaoa/api:${commit} + worker: 127.0.0.1:5000/pikaoa/worker:${commit} + web: 127.0.0.1:5000/pikaoa/web:${commit} + migration: 127.0.0.1:5000/pikaoa/api:${commit} + pullPolicy: IfNotPresent + database: + configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url + name: pikaoa_test + username: pikaoa_test + schema: pikaoa + connection: + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env + sourceKey: DATABASE_URL + targetKey: DATABASE_URL + runtime: + secretName: pikaoa-test-runtime + apiServiceName: pikaoa-api + apiPort: 8080 + workerMetricsPort: 8081 + webPort: 8080 + webApiUpstreamEnv: PIKAOA_API_UPSTREAM + attachment: + storageRoot: /var/lib/pikaoa/attachments + maxBytes: 104857600 + claimName: pikaoa-attachments + storageRequest: 10Gi + storageClassName: null + fsGroup: 65532 + accessModes: + - ReadWriteOnce + workerInterval: 2s + outbox: + batchSize: 50 + maxAttempts: 4 + retryDelay: 2s + sessionTTL: 8h + shutdownTimeout: 10s + administrator: + username: admin + password: + sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD + employee: + username: employee + password: + sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD + sessionSecret: + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env + sourceKey: PIKAOA_SESSION_SECRET + targetKey: PIKAOA_SESSION_SECRET + migration: + jobName: pikaoa-migrate + backoffLimit: 2 + command: + - /usr/local/bin/pikaoa + - migrate + - up + - --config + - /etc/pikaoa/pikaoa.yaml + exposure: + serviceName: pikaoa-web + serviceType: NodePort + hostIP: 152.53.229.148 + port: 32080 + observability: + configRef: config/platform-infra/observability.yaml + otlpEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318 + prometheus: + scrape: true + apiPath: /metrics + workerPath: /metrics + exporterFailure: + warning: true + blocking: false + probes: + api: + livenessPath: /healthz + readinessPath: /readyz + worker: + livenessPath: /healthz + readinessPath: /readyz + web: + livenessPath: /healthz + readinessPath: /healthz delivery: targets: diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index e041409e..37a9a8b6 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -99,6 +99,17 @@ sources: PIKAOA_SESSION_SECRET: bytes: 32 prefix: poa_ + - sourceRef: platform-infra/pikaoa-test.env + type: env + requiredKeys: + - DATABASE_URL + - PIKAOA_SESSION_SECRET + createIfMissing: + enabled: true + randomBase64Url: + PIKAOA_SESSION_SECRET: + bytes: 32 + prefix: poa_ - sourceRef: hwlab/jd01-v03-opencode.env type: env requiredKeys: @@ -234,6 +245,11 @@ targets: namespace: pikaoa scope: pikaoa enabled: true + - id: pikaoa-test-nc01 + route: NC01:k3s + namespace: pikaoa-test + scope: pikaoa-test + enabled: true kubernetesSecrets: - name: pikaoa-runtime @@ -256,6 +272,23 @@ kubernetesSecrets: - sourceRef: platform-infra/pk01-frp.env sourceKey: FRP_TOKEN targetKey: FRP_TOKEN + - name: pikaoa-test-runtime + targetId: pikaoa-test-nc01 + secretName: pikaoa-test-runtime + type: Opaque + data: + - sourceRef: platform-infra/pikaoa-test.env + sourceKey: DATABASE_URL + targetKey: DATABASE_URL + - sourceRef: platform-infra/pikaoa-test.env + sourceKey: PIKAOA_SESSION_SECRET + targetKey: PIKAOA_SESSION_SECRET + - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + sourceKey: contents + targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD + - sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + sourceKey: contents + targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD - name: selfmedia-access targetId: selfmedia-nc01 secretName: selfmedia-access diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.5_Task_Report.md new file mode 100644 index 00000000..a0643a46 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.5_Task_Report.md @@ -0,0 +1,78 @@ +# R1.5.9.5 任务报告 + +## 结论 + +已在 PR #2084 完成 NC01 固定 `pikaoa-test` 测试运行面的: + +- YAML-first Secret 初始化闭环; +- 单步 CLI; +- 可重跑异步状态机。 + +测试运行面边界: + +- 使用 PK01 host PostgreSQL 独立 `pikaoa_test` role/database; +- 固定使用 NodePort `32080`; +- 不创建专用测试集群、动态 namespace 或 namespace-local PostgreSQL; +- 不恢复 dedicated 门禁。 + +## 实现 + +- `config/secrets-distribution.yaml`: + - 声明 `platform-infra/pikaoa-test.env`,必需 key 为 `DATABASE_URL` 与 `PIKAOA_SESSION_SECRET`; + - `createIfMissing` 只生成 `PIKAOA_SESSION_SECRET`,保留 platform-db export 写入的 `DATABASE_URL`; + - 增加 `pikaoa-test-nc01` target,固定 `NC01:k3s`、`pikaoa-test` namespace 和 `pikaoa-test` scope; + - 增加 `pikaoa-test-runtime` Secret 映射,继续消费既有管理员和员工 external raw sources。 +- `config/pikaoa.yaml`: + - `testRuntime.runtime.sessionSecret` 改读 `platform-infra/pikaoa-test.env#PIKAOA_SESSION_SECRET`; + - 保留 PK01 `pikaoa_test` 数据库、固定 namespace、附件 PVC、NodePort、OTel/Prometheus 和单步工作负载声明。 +- `pikaoa test-target`: + - 新增 `--step foundation`; + - foundation 不要求 `--commit` 或业务 Secret,只提交带所有权标签的固定 Namespace/PVC; + - migration、api、worker、web 和 all 继续要求 commit 与 Secret source; + - plan 只投影当前 step 所需对象,并保持 Secret 脱敏; + - request identity 包含 target、instance、commit、step 和当前 step 稳定 structural manifest fingerprint; + - request fingerprint 继续脱敏 Secret `stringData`,并排除每次渲染变化的 `pikaoa.unidesk.io/expires-at`; + - 完全相同 request 返回已有任务; + - 不同 request 遇 queued/running 且 worker 存活时返回 `start-instance-conflict`; + - 不同 request 遇终态或 worker-missing 时,原子退役旧有界状态目录并提交新任务; + - `status` 继续读取同一 `target/instance/step` 的最新任务,不引入第二状态库、控制器或租约。 +- `docs/reference/pikaoa.md`: + - 删除动态 namespace 和临时 PostgreSQL 旧设计; + - 写明 foundation、Secret sync、migration、api、worker、web 顺序; + - 写明终态可重跑、运行中去重和稳定 request fingerprint; + - 写明先稳定 CLI,再建设独立 test CI/CD,且不得成为业务阻塞门禁。 + +## 验证 + +- `bun --check scripts/src/pikaoa-test-target.ts`:通过。 +- `bun test scripts/src/pikaoa-test-target-async.test.ts`:2 pass,0 fail。 +- 异步回归覆盖: + - 同 request 在任务终态后仍返回 `start-already-succeeded`; + - 不同 commit 在旧任务运行中返回 `start-instance-conflict`; + - 不同 commit 在旧任务终态后重置并重新提交; + - 同 commit 但探针 manifest 变化时重置并重新提交; + - 仅 `expires-at` 渲染时间变化时仍识别为同 request; + - worker-missing 时允许安全重置并重新提交; + - foundation 终态后可用新 request 重跑。 +- `bun scripts/cli.ts check --syntax-only`:11 pass,0 fail。 +- `pikaoa test-target plan --step foundation`: + - `readyForMutation=true`; + - `missingFields=[]`; + - `blockers=[]`; + - 仅投影 Namespace 与 PersistentVolumeClaim; + - `secretRequired=false`; + - `valuesPrinted=false`。 +- `secrets plan --scope pikaoa-test`: + - target 为 `pikaoa-test-nc01`; + - Secret 为 `pikaoa-test-runtime`; + - 投影四个声明 key; + - `mutation=false`; + - `valuesPrinted=false`。 +- `git diff --check`:通过。 + +## 边界 + +- 本任务只修改代码、YAML、测试和文档。 +- 未执行 foundation、Secret sync、migration、API、Worker、Web 或任何部署。 +- 未读取、打印或从 Kubernetes 反解 Secret 值。 +- 原入口真实部署与 NodePort 验收由主代理在合并后按文档顺序执行。 diff --git a/docs/MDTODO/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md index 9d6945d3..5561fa93 100644 --- a/docs/MDTODO/pikaoa-enterprise-platform.md +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -105,7 +105,7 @@ ##### R1.5.9.4 [completed] 在 PK01 host PostgreSQL 声明独立 pikaoa_test database/role、Secret export 与 NC01 HBA,执行记录见 [pikasTech/unidesk#2078](https://github.com/pikasTech/unidesk/issues/2078),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.4_Task_Report.md)。 -##### R1.5.9.5 [in_progress] +##### R1.5.9.5 [completed] 在 NC01 pikaoa-test namespace 实现外部 PK01 测试 DSN、API/Worker/Web/迁移/附件 PVC、OTel/Prometheus、YAML hostIP 端口和受控单步 CLI,执行记录见 [pikasTech/unidesk#2079](https://github.com/pikasTech/unidesk/issues/2079),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.9.5_Task_Report.md)。 ##### R1.5.9.6 [completed] diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index 89c78cd5..c2f71fa0 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -1,99 +1,135 @@ -# PikaOA 开发与测试运行面 +# PikaOA ## 配置真相 - PikaOA 平台配置唯一真相是 `config/pikaoa.yaml`。 -- 正式交付与临时测试运行面职责分离: - - `delivery.targets` 只描述正式 CI/CD、GitOps、运行 namespace、数据库和公网暴露; - - `testRuntime.targets` 只描述专用测试集群上的临时后端运行面。 -- 禁止把现有节点推断成 PikaOA 专用测试集群。 -- `testRuntime.targets` 为空时表示当前没有获准的测试运行面: - - `plan` 和 `status` 返回 `configured=false`、`mutation=false`; - - `start` 和 `stop` 在任何远端调用前失败。 +- 测试运行面固定使用: + - Target:`NC01`; + - route:`NC01:k3s`; + - namespace:`pikaoa-test`; + - NodePort:`32080`。 +- 测试 namespace 不根据 commit、instance 或任务动态生成。 +- `pikaoa` 与 `pikaoa-ci` 是正式交付保护 namespace。 +- 测试入口不得渲染、覆盖或删除保护 namespace 中的对象。 -## 受控入口 +## 数据库边界 -```bash -bun scripts/cli.ts pikaoa test-target plan -bun scripts/cli.ts pikaoa test-target status -bun scripts/cli.ts pikaoa test-target start --target --instance --commit --confirm -bun scripts/cli.ts pikaoa test-target stop --target --instance --confirm -``` +- 测试数据库固定使用 PK01 host PostgreSQL。 +- `config/platform-db/postgres-pk01.yaml` 声明独立的: + - role:`pikaoa_test`; + - database:`pikaoa_test`; + - schema:`pikaoa`; + - `DATABASE_URL` export:`platform-infra/pikaoa-test.env`。 +- `pikaoa-test` namespace 不创建 PostgreSQL StatefulSet、Service 或临时数据库。 +- 数据库生命周期独立于 namespace 和业务 workload。 +- 测试数据清理由 PK01 PostgreSQL 受控入口负责,不依赖删除 namespace。 -- `plan` 是本地只读渲染入口,不接受 `--dry-run`。 -- `start` 和 `stop` 只在显式 `--confirm` 后执行。 -- `--config ` 可用于 fixture 或本地 override;`validationOnly=true` 的 target 永远不连接 route。 -- 默认输出是紧凑文本;`--output json` 或 `--json` 输出单一 JSON 文档。 -- 长启动使用提交与短轮询分离的远程任务: - - `start --confirm` 只提交由 `target/instance` 唯一定位的启动任务,返回 `start-submitted`、`jobId`、namespace 和 `mutation=true`; - - 相同 `target/instance/commit` 重复提交只返回已有任务状态,不再启动并行任务; - - 相同 `target/instance` 改用其他 commit 时返回 `start-instance-conflict`,操作者应使用新 instance; - - `status` 返回 `running/succeeded/failed/canceled`、当前阶段、时间、终态 code、exit code 和有界事件尾部; - - `status` 的运行面查询只使用 YAML 声明的短 request timeout,不进入启动等待循环。 -- 远程任务事实只保存在 target YAML `taskState.rootPath` 对应的单一状态目录: - - `taskState.statusRequestTimeoutSeconds` 控制短查询预算; - - `taskState.logTailLines` 控制事件尾部上限; - - 不建立第二数据库、控制器、租约或独立锁服务。 +## Secret 初始化 -## 临时运行面 +- `config/secrets-distribution.yaml` 声明: + - source:`platform-infra/pikaoa-test.env`; + - target:`pikaoa-test-nc01`; + - scope:`pikaoa-test`; + - Kubernetes Secret:`pikaoa-test-runtime`。 +- `platform-infra/pikaoa-test.env` 必须包含: + - `DATABASE_URL`; + - `PIKAOA_SESSION_SECRET`。 +- `DATABASE_URL` 由 `platform-db postgres export-secrets` 写入并保留。 +- `createIfMissing` 只为 `PIKAOA_SESSION_SECRET` 生成值,不覆盖已有 `DATABASE_URL`。 +- 管理员和员工密码继续使用既有 external raw sources: + - `~/.unidesk/.env/pikaoa-admin-password.txt`; + - `~/.unidesk/.env/pikaoa-employee-password.txt`。 +- Secret 输出只披露对象、key、presence、fingerprint 和摘要,不读取或打印值。 -- namespace 固定由 YAML `namespacePrefix` 和 CLI `--instance` 组成。 -- namespace、API、Worker、临时 PostgreSQL、Service 和 Secret 都带以下可审计标签: +## Test Target CLI + +- 受控入口是 `bun scripts/cli.ts pikaoa test-target`。 +- `config/pikaoa.yaml#testRuntime` 声明固定 namespace、镜像、PVC、Secret 引用、迁移、工作负载、探针和暴露端口。 +- 所有创建对象都带以下所有权标签: - `app.kubernetes.io/managed-by=unidesk-pikaoa-test-target`; - `pikaoa.unidesk.io/test-runtime=true`; - `pikaoa.unidesk.io/target=`; - - `pikaoa.unidesk.io/instance=`。 -- `stop` 删除前重新校验 target、instance 和 managed-by 标签,只删除精确实例 namespace。 -- `stop` 发现同一实例的启动任务仍在运行时,先写入精确任务的取消请求,再删除 namespace;返回 `startTaskDisposition` 说明是 `cancel-requested`、`cancel-already-requested`、`already-terminal` 或 `not-found`。 -- 正式 namespace 保护集合来自以下 YAML 字段: - - `delivery.targets.*.namespace`; - - `delivery.targets.*.ci.namespace`。 -- 测试入口不得渲染或删除保护集合中的任何对象。 -- PostgreSQL 使用 namespace 内的临时存储,namespace 删除后测试数据随之清理。 -- 临时数据库连接只由 target YAML 渲染: - - `database.name`、`database.username` 和 `database.schema` 都是必填 PostgreSQL identifier; - - renderer 使用结构化 URL 参数写入 `sslmode` 和 `search_path`,不提供隐藏 schema 默认值; - - 缺少 schema 或形状不合法时,在任何 route 调用前返回具名配置错误。 -- 附件运行参数与 namespace 内的临时 PVC 来自 target YAML: - - `runtime.attachment.storageRoot` 和 `runtime.attachment.maxBytes` 直接写入严格 runtime YAML; - - PVC 名称、容量、StorageClass 和 access mode 只从同一 target 声明读取; - - API 挂载该 PVC,删除临时 namespace 时一并删除 PVC。 -- 空数据库启动固定分为三个受控阶段: - - 先渲染基础对象并等待 namespace 内 PostgreSQL 就绪; - - 再使用 `source.images.migration` 和 `migration.command` 运行产品内置 `pikaoa migrate up` Job; - - 迁移 Job 完成后才创建 API 和 Worker Deployment 并等待 rollout。 -- PostgreSQL、迁移 Job 或业务 Deployment 失败会返回具名失败码,不会静默进入下一阶段。 -- TTL 和清理策略来自 target YAML: - - TTL 写入 namespace annotation; - - 当前 `cleanup.mode=delete-namespace`,由显式 `stop` 执行; - - `cleanup.requireOwnershipLabels=true` 时,删除前必须校验归属标签; - - 不建立额外控制器或长期状态库。 + - `pikaoa.unidesk.io/instance=`。 +- `stop` 只允许删除同时匹配固定 namespace 和全部所有权标签的运行面。 +- CLI 使用异步任务状态目录记录有界状态和事件,不建立第二数据库、控制器、租约或锁服务。 -## Secret 与可观测性 +## 初始化顺序 -- Secret 值只从 YAML `sourceRef` 读取并写入目标 Secret。 -- CLI 输出只显示 `sourceRef`、`targetKey`、presence 和 fingerprint,始终保持 `valuesPrinted=false`。 -- `plan` 的数据库投影只显示 schema 和 DSN 参数 presence,不显示完整 DSN、用户名、密码或参数值。 -- 含 Secret 的 runner 文件使用 owner-only 权限,运行后立即删除;manifest 只在 runner 的临时目录存活,终态前由 trap 清理。 -- 状态和事件只记录公开 ID、阶段、typed code 和时间,不保存命令 stderr、DSN、manifest 正文或 Secret 值。 -- Secret 缺失会在连接测试集群前阻塞 `start`。 -- 迁移 Job、API 和 Worker 从同一 Secret key 挂载严格的 PikaOA runtime YAML,数据库 schema 与 `search_path` 因此保持一致。 -- OTel endpoint、Prometheus scrape、指标路径和探针全部由 target YAML 声明。 -- 配置一致性、镜像/commit 漂移和 OTel exporter 失败只产生 `blocking=false` warning,不作为 MVP 门禁。 +1. 使用 `platform-db postgres` 受控入口准备 PK01 `pikaoa_test` role/database,并 export `DATABASE_URL`。 +2. 执行 foundation: -## 本地验收 + ```bash + bun scripts/cli.ts pikaoa test-target start \ + --target NC01 \ + --instance default \ + --step foundation \ + --confirm + ``` -`config/fixtures/pikaoa-test-target.yaml` 只用于本地验证: +3. foundation 只创建带所有权标签的固定 `pikaoa-test` Namespace 和附件 PVC,不要求业务 Secret 已存在。 +4. 执行 Secret 下发: -- 证明 namespace、选择器、附件 PVC、迁移 Job、工作负载和 Secret 引用的渲染结果; -- 证明 TTL、OTel、Prometheus 和正式 namespace 保护; -- fixture target 固定为 `validationOnly=true`,不得用于真实集群操作。 + ```bash + bun scripts/cli.ts secrets sync \ + --config config/secrets-distribution.yaml \ + --scope pikaoa-test \ + --confirm + ``` -```bash -bun scripts/cli.ts pikaoa test-target plan \ - --config config/fixtures/pikaoa-test-target.yaml \ - --target TEST01 \ - --instance issue-2046 \ - --commit 0123456789abcdef \ - --output json -``` +5. `secrets sync` 按 YAML 声明补齐允许生成的本地来源,并下发 `pikaoa-test-runtime`。 +6. 使用同一 commit 依次执行: + - `--step migration`; + - `--step api`; + - `--step worker`; + - `--step web`。 +7. Web 固定通过 `152.53.229.148:32080` 的 NodePort 入口验收。 + +## 单步运行 + +- foundation 不需要 `--commit`,也不检查业务 Secret。 +- migration、api、worker、web 和 all 必须显式提供 `--commit`。 +- 每个 `start` 立即返回任务 ID;使用同一 target、instance 和 step 的 `status` 查询终态。 +- request identity 包含: + - target; + - instance; + - 产品 commit; + - step; + - 当前 step 的稳定 structural manifest fingerprint。 +- request fingerprint 对 Secret `stringData` 只保留 key 结构和脱敏占位,不包含 Secret 原值。 +- request fingerprint 排除 `pikaoa.unidesk.io/expires-at` 等每次渲染变化的易变字段。 +- 完全相同 request 重复提交只返回已有任务状态,不并行创建第二任务。 +- 不同 request 遇旧任务处于 queued/running 且 worker 存活时返回 `start-instance-conflict`。 +- 不同 request 遇旧任务已 succeeded/failed/canceled,或 queued/running 但 worker 明确缺失时: + - 原子退役该 step 的旧有界状态目录; + - 继续使用同一 `target/instance/step` 状态路径提交新任务; + - `status` 只读取该 step 的最新任务。 +- foundation 也遵循相同规则。 +- foundation 需要显式形成新 request 时,可以: + - 提供新的 `--commit` 修订标识; + - 修改 foundation 的稳定 manifest 结构。 +- 不为重跑引入第二状态库、控制器、租约或长期历史存储。 +- 迁移、rollout 或运行面查询失败返回具名失败码,不静默进入下一阶段。 +- OTel exporter、配置版本和 commit 对齐漂移只产生 `blocking=false` warning,不恢复 dedicated 门禁。 + +## 交付阶段 + +- 当前阶段先稳定 YAML-first CLI 原入口和固定 NC01 测试运行面。 +- CLI 闭环通过后,再建设独立 test CI/CD: + - 复用同一 owning YAML; + - 按 foundation、Secret sync、migration、api、worker、web 顺序编排; + - 不创建专用测试集群; + - 不生成动态 namespace; + - 不创建 namespace-local PostgreSQL; + - 不把测试 CI/CD 变成生产交付或用户业务的阻塞门禁。 +- PR、构建或单测不能替代固定 NodePort 原入口验收。 + +## 本地验证 + +- `config/fixtures/pikaoa-test-target.yaml` 只用于 renderer 和 CLI 测试。 +- fixture target 固定为 `validationOnly=true`,不得连接真实 route。 +- 最小验证包括: + - `bun --check scripts/src/pikaoa-test-target.ts`; + - `bun test scripts/src/pikaoa-test-target-async.test.ts`; + - `bun scripts/cli.ts check --syntax-only`; + - `pikaoa test-target plan --step foundation`; + - `git diff --check`。 diff --git a/scripts/src/pikaoa-test-target-async.test.ts b/scripts/src/pikaoa-test-target-async.test.ts index 9b79999b..1c1ed373 100644 --- a/scripts/src/pikaoa-test-target-async.test.ts +++ b/scripts/src/pikaoa-test-target-async.test.ts @@ -21,10 +21,9 @@ test("PikaOA test target submits once and exposes bounded typed status", async ( const secretValues = ["fixture-db-password", "fixture-admin-password", "fixture-employee-password", "fixture-session-secret"]; for (const [name, value] of [ - ["pikaoa-test-database-password.txt", secretValues[0]], + ["pikaoa-test.env", `DATABASE_URL=postgresql://pikaoa_test:${secretValues[0]}@db.example.invalid:5432/pikaoa_test?sslmode=require\nPIKAOA_SESSION_SECRET=${secretValues[3]}`], ["pikaoa-test-admin-password.txt", secretValues[1]], ["pikaoa-test-employee-password.txt", secretValues[2]], - ["pikaoa-test-session-secret.txt", secretValues[3]], ]) writeFileSync(join(secretsDir, name), `${value}\n`, { mode: 0o600 }); const fakeKubectl = join(binDir, "kubectl"); @@ -53,18 +52,20 @@ if printf '%s' "$args" | grep -Eq '(^| )apply( |$)'; then previous="$argument" done if grep -q '"kind":"Namespace"' "$file"; then - applied_namespace="$(python3 - "$file" <<'PY' + applied_identity="$(python3 - "$file" <<'PY' import json, pathlib, sys for line in pathlib.Path(sys.argv[1]).read_text().splitlines(): if not line or line == "---": continue item = json.loads(line) if item.get("kind") == "Namespace": - print(item["metadata"]["name"]) + print(item["metadata"]["name"] + "|" + item["metadata"]["labels"]["pikaoa.unidesk.io/instance"]) break PY )" - : >"$root/namespaces/$applied_namespace" + applied_namespace="\${applied_identity%%|*}" + applied_instance="\${applied_identity##*|}" + printf '%s' "$applied_instance" >"$root/namespaces/$applied_namespace" fi [ ! -f "$root/slow" ] || sleep 0.2 exit 0 @@ -88,14 +89,14 @@ if printf '%s' "$args" | grep -Eq '(^| )get namespace '; then printf 'Error from server (NotFound): namespaces "%s" not found\n' "$name" >&2 exit 1 fi - instance="\${name#pikaoa-test-}" + instance="$(cat "$root/namespaces/$name")" if printf '%s' "$args" | grep -q 'managed-by'; then printf '%s' 'unidesk-pikaoa-test-target'; exit 0; fi if printf '%s' "$args" | grep -q 'unidesk.*target'; then printf '%s' 'TEST01'; exit 0; fi if printf '%s' "$args" | grep -q 'unidesk.*instance'; then printf '%s' "$instance"; exit 0; fi printf '{"metadata":{"name":"%s","labels":{"app.kubernetes.io/managed-by":"unidesk-pikaoa-test-target","pikaoa.unidesk.io/target":"TEST01","pikaoa.unidesk.io/instance":"%s"}}}\n' "$name" "$instance" exit 0 fi -if printf '%s' "$args" | grep -q ' get deployment,statefulset,job,service,persistentvolumeclaim,pod '; then +if printf '%s' "$args" | grep -q ' get deployment,job,service,persistentvolumeclaim,pod '; then if [ -f "$root/fail-runtime-resource-query" ]; then printf '%s\n' 'resource query failed' >&2 exit 1 @@ -137,19 +138,26 @@ exit 64 lastCapture = { stdout, stderr, exitCode }; return lastCapture; }; - const args = (action: string, instance: string, withCommit = false): string[] => [ + const args = (action: string, instance: string, withCommit = false, step = "all"): string[] => [ "test-target", action, "--config", fixturePath, "--target", "TEST01", "--instance", instance, ...(withCommit ? ["--commit", "0123456789abcdef"] : []), + "--step", step, ...((action === "start" || action === "stop") ? ["--confirm"] : []), "--output", "json", ]; - const projection = async (action: string, instance: string, withCommit = false): Promise> => { - const result = await runPikaoaCommand(config, args(action, instance, withCommit), localCapture); + const projection = async (action: string, instance: string, withCommit = false, step = "all"): Promise> => { + const result = await runPikaoaCommand(config, args(action, instance, withCommit, step), localCapture); return JSON.parse(result.renderedText) as Record; }; - const waitFor = async (instance: string, expectedCode: string): Promise> => { + const projectionWithCommit = async (action: string, instance: string, commit: string, step = "all"): Promise> => { + const commandArgs = args(action, instance, true, step); + commandArgs[commandArgs.indexOf("--commit") + 1] = commit; + const result = await runPikaoaCommand(config, commandArgs, localCapture); + return JSON.parse(result.renderedText) as Record; + }; + const waitFor = async (instance: string, expectedCode: string, step = "all"): Promise> => { for (let attempt = 0; attempt < 80; attempt += 1) { - const value = await projection("status", instance); + const value = await projection("status", instance, false, step); const status = value.status as Record; if (status.code === expectedCode) return value; await Bun.sleep(25); @@ -158,11 +166,23 @@ exit 64 }; try { - const missing = await projection("status", "missing"); + const planned = await projection("plan", "runtime", true); + const plan = planned.plan as Record; + const secret = plan.secret as Record; + const sources = secret.sources as Array>; + assert.equal(sources.find((source) => source.purpose === "database.connection")?.presence, true); + const objects = plan.objects as Array>; + assert.equal(objects.some((object) => object.kind === "StatefulSet" || object.name === "pikaoa-postgres"), false); + assert.equal(objects.some((object) => object.kind === "Deployment" && object.name === "pikaoa-web"), true); + assert.equal(objects.find((object) => object.kind === "Deployment" && object.name === "pikaoa-api")?.podFsGroup, 65532); + assert.deepEqual(plan.exposure, { serviceType: "NodePort", serviceName: "pikaoa-web", hostIP: "192.0.2.10", port: 32080 }); + assert.deepEqual(plan.webApiUpstream, { envName: "PIKAOA_API_UPSTREAM", value: "http://pikaoa-api:8080", scope: "same-namespace" }); + + const missing = await projection("status", "runtime", false, "api"); assert.equal((missing.status as Record).code, "start-not-found"); writeFileSync(join(fakeRoot, "slow"), "1\n"); - const submitted = await projection("start", "success", true); + const submitted = await projection("start", "runtime", true); assert.equal(submitted.code, "start-submitted", JSON.stringify(lastCapture)); assert.equal(submitted.mutation, true); assert.deepEqual((submitted.target as Record).taskState, { @@ -170,27 +190,32 @@ exit 64 statusRequestTimeoutSeconds: 31, logTailLines: 101, }); - const running = await projection("status", "success"); + const running = await projection("status", "runtime"); assert.equal((running.status as Record).code, "start-running"); + const concurrentCommit = "fedcba9876543210"; + const concurrent = await projectionWithCommit("start", "runtime", concurrentCommit); + assert.equal(concurrent.code, "start-instance-conflict"); + assert.equal(concurrent.mutation, false); + assert.equal(concurrent.previousTaskReset, false); rmSync(join(fakeRoot, "slow")); - const succeeded = await waitFor("success", "start-succeeded"); + const succeeded = await waitFor("runtime", "start-succeeded"); const succeededStatus = succeeded.status as Record; assert.equal((succeededStatus.task as Record).exitCode, 0); assert.ok((succeededStatus.logTail as unknown[]).length <= 101); writeFileSync(join(fakeRoot, "fail-runtime-query"), "1\n"); - const runtimeQueryFailed = await projection("status", "success"); + const runtimeQueryFailed = await projection("status", "runtime"); const runtimeQueryFailedStatus = runtimeQueryFailed.status as Record; assert.equal(runtimeQueryFailed.ok, false); assert.equal(runtimeQueryFailedStatus.code, "runtime-query-failed"); assert.equal(runtimeQueryFailedStatus.taskCode, "start-succeeded"); assert.equal((runtimeQueryFailedStatus.runtime as Record).code, "runtime-query-failed"); - const runtimeQueryFailedText = await runPikaoaCommand(config, args("status", "success").slice(0, -2), localCapture); + const runtimeQueryFailedText = await runPikaoaCommand(config, args("status", "runtime").slice(0, -2), localCapture); assert.match(runtimeQueryFailedText.renderedText, /status=runtime-query-failed/); rmSync(join(fakeRoot, "fail-runtime-query")); writeFileSync(join(fakeRoot, "fail-runtime-resource-query"), "1\n"); - const runtimeResourceQueryFailed = await projection("status", "success"); + const runtimeResourceQueryFailed = await projection("status", "runtime"); const runtimeResourceQueryFailedStatus = runtimeResourceQueryFailed.status as Record; assert.equal(runtimeResourceQueryFailed.ok, false); assert.equal(runtimeResourceQueryFailedStatus.code, "runtime-resource-query-failed"); @@ -198,32 +223,99 @@ exit 64 assert.equal((runtimeResourceQueryFailedStatus.runtime as Record).code, "runtime-resource-query-failed"); rmSync(join(fakeRoot, "fail-runtime-resource-query")); - const repeated = await projection("start", "success", true); + const repeated = await projection("start", "runtime", true); assert.equal(repeated.code, "start-already-succeeded"); assert.equal(repeated.mutation, false); + const rerunSubmitted = await projectionWithCommit("start", "runtime", concurrentCommit); + assert.equal(rerunSubmitted.code, "start-submitted"); + assert.equal(rerunSubmitted.previousTaskReset, true); + const rerunSucceeded = await waitFor("runtime", "start-succeeded"); + assert.equal(((rerunSucceeded.status as Record).task as Record).sourceCommit, concurrentCommit); + writeFileSync(fixturePath, readFileSync(fixturePath, "utf8").replace(" livenessPath: /healthz\n", " livenessPath: /healthz-v2\n")); + const manifestRerunSubmitted = await projectionWithCommit("start", "runtime", concurrentCommit); + assert.equal(manifestRerunSubmitted.code, "start-submitted"); + assert.equal(manifestRerunSubmitted.previousTaskReset, true); + const manifestRerunSucceeded = await waitFor("runtime", "start-succeeded"); + await Bun.sleep(5); + const sameRequestAfterRenderTimeChanged = await projectionWithCommit("start", "runtime", concurrentCommit); + assert.equal(sameRequestAfterRenderTimeChanged.code, "start-already-succeeded"); + assert.equal(sameRequestAfterRenderTimeChanged.previousTaskReset, false); + + writeFileSync(join(fakeRoot, "slow"), "1\n"); + const workerMissingSubmitted = await projection("start", "runtime", true, "api"); + const workerPid = Number((workerMissingSubmitted.remote as Record).pid); + assert.equal(Number.isSafeInteger(workerPid) && workerPid > 0, true); + process.kill(workerPid, "SIGKILL"); + await Bun.sleep(25); + const workerMissingReset = await projectionWithCommit("start", "runtime", concurrentCommit, "api"); + assert.equal(workerMissingReset.code, "start-submitted"); + assert.equal(workerMissingReset.previousTaskReset, true); + rmSync(join(fakeRoot, "slow")); + const workerMissingSucceeded = await waitFor("runtime", "start-succeeded", "api"); writeFileSync(join(fakeRoot, "fail-migration"), "1\n"); - await projection("start", "failed", true); - const failed = await waitFor("failed", "start-failed"); + await projection("start", "runtime", true, "migration"); + const failed = await waitFor("runtime", "start-failed", "migration"); const failedTask = ((failed.status as Record).task as Record); assert.equal(failedTask.code, "migration-wait-failed"); assert.equal(failedTask.exitCode, 46); rmSync(join(fakeRoot, "fail-migration")); writeFileSync(join(fakeRoot, "slow"), "1\n"); - await projection("start", "cancel", true); - const stopped = await projection("stop", "cancel"); + await projection("start", "runtime", true, "web"); + const stopped = await projection("stop", "runtime", false, "web"); assert.equal(stopped.startTaskDisposition, "cancel-requested"); assert.equal(stopped.mutation, true); - await waitFor("cancel", "start-canceled"); + await waitFor("runtime", "start-canceled", "web"); - const serialized = JSON.stringify({ submitted, succeeded, failed, stopped }); + for (const name of [ + "pikaoa-test.env", + "pikaoa-test-admin-password.txt", + "pikaoa-test-employee-password.txt", + ]) rmSync(join(secretsDir, name)); + const foundationPlan = await projection("plan", "foundation", false, "foundation"); + assert.equal(foundationPlan.readyForMutation, true); + assert.deepEqual(foundationPlan.missingFields, []); + assert.deepEqual((foundationPlan.blockers as unknown[]), []); + const foundationObjects = ((foundationPlan.plan as Record).objects as Array>); + assert.deepEqual(foundationObjects.map((object) => object.kind), ["Namespace", "PersistentVolumeClaim"]); + assert.equal(((foundationPlan.plan as Record).secret as Record).requiredForStep, false); + const foundationSubmitted = await projection("start", "foundation", false, "foundation"); + assert.equal(foundationSubmitted.code, "start-submitted", JSON.stringify(lastCapture)); + const foundationSucceeded = await waitFor("foundation", "start-succeeded", "foundation"); + const foundationRepeated = await projection("start", "foundation", false, "foundation"); + assert.equal(foundationRepeated.code, "start-already-succeeded"); + const foundationRerun = await projectionWithCommit("start", "foundation", concurrentCommit, "foundation"); + assert.equal(foundationRerun.code, "start-submitted"); + assert.equal(foundationRerun.previousTaskReset, true); + const foundationRerunSucceeded = await waitFor("foundation", "start-succeeded", "foundation"); + + const serialized = JSON.stringify({ + submitted, + succeeded, + concurrent, + rerunSubmitted, + rerunSucceeded, + manifestRerunSubmitted, + manifestRerunSucceeded, + sameRequestAfterRenderTimeChanged, + workerMissingSubmitted, + workerMissingReset, + workerMissingSucceeded, + failed, + stopped, + foundationSubmitted, + foundationSucceeded, + foundationRepeated, + foundationRerun, + foundationRerunSucceeded, + }); for (const value of secretValues) assert.equal(serialized.includes(value), false); - assert.equal(serialized.includes("postgres://pikaoa_test:"), false); + assert.equal(serialized.includes("postgresql://pikaoa_test:"), false); } finally { rmSync(root, { recursive: true, force: true }); } -}, 15_000); +}, 30_000); test("validation-only fixture never invokes remote capture", async () => { let calls = 0; @@ -245,7 +337,16 @@ test("validation-only fixture never invokes remote capture", async () => { const planPayload = JSON.parse(planResult.renderedText) as Record; const plan = planPayload.plan as Record; assert.deepEqual(plan.database, { + configRef: "config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url", + name: "pikaoa_test", + username: "pikaoa_test", schema: "pikaoa", + connection: { + sourceRef: "./secrets/pikaoa-test.env", + sourceKey: "DATABASE_URL", + targetKey: "DATABASE_URL", + valuesPrinted: false, + }, dsnParameterPresence: { sslmode: true, search_path: true }, valuesPrinted: false, }); @@ -277,9 +378,16 @@ test("validation-only fixture never invokes remote capture", async () => { rmSync(missingSchemaRoot, { recursive: true, force: true }); } - const unconfigured = await runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--output", "json"], forbiddenCapture); - const unconfiguredPayload = JSON.parse(unconfigured.renderedText) as Record; - assert.equal(calls, 0); - assert.equal(unconfiguredPayload.configured, false); - assert.equal(unconfiguredPayload.remoteQueried, false); + const unconfiguredRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-unconfigured-")); + const unconfiguredPath = join(unconfiguredRoot, "pikaoa.yaml"); + writeFileSync(unconfiguredPath, "version: 1\nkind: pikaoa-platform-delivery\ntestRuntime:\n defaultTargetId: null\n targets: {}\n"); + try { + const unconfigured = await runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", unconfiguredPath, "--output", "json"], forbiddenCapture); + const unconfiguredPayload = JSON.parse(unconfigured.renderedText) as Record; + assert.equal(calls, 0); + assert.equal(unconfiguredPayload.configured, false); + assert.equal(unconfiguredPayload.remoteQueried, false); + } finally { + rmSync(unconfiguredRoot, { recursive: true, force: true }); + } }); diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index 4235704f..69c6af15 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -1,4 +1,4 @@ -// SPEC: pikasTech/unidesk#2046 PikaOA YAML-first dedicated test runtime. +// SPEC: pikasTech/unidesk#2079 PikaOA YAML-first NC01 test runtime. import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -11,6 +11,7 @@ import { CliInputError } from "./output"; import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library"; type TestTargetAction = "plan" | "status" | "start" | "stop"; +type TestTargetStep = "all" | "foundation" | "migration" | "api" | "worker" | "web"; interface TestTargetOptions { action: TestTargetAction; @@ -18,6 +19,7 @@ interface TestTargetOptions { targetId: string | null; instanceId: string | null; commit: string | null; + step: TestTargetStep; confirm: boolean; output: "text" | "json"; } @@ -25,6 +27,7 @@ interface TestTargetOptions { interface SecretSourceSpec { purpose: string; sourceRef: string; + sourceKey: string | null; targetKey: string; } @@ -32,10 +35,9 @@ interface TestTargetSpec { id: string; enabled: boolean; validationOnly: boolean; - dedicated: boolean; node: string; route: string; - namespacePrefix: string; + namespace: string; ttlSeconds: number; waitTimeoutSeconds: number; fieldManager: string; @@ -54,27 +56,31 @@ interface TestTargetSpec { commitPolicy: string; apiImage: string; workerImage: string; + webImage: string; migrationImage: string; pullPolicy: string; }; database: { - image: string; + configRef: string; name: string; username: string; schema: string; - port: number; - password: SecretSourceSpec; + connection: SecretSourceSpec; }; runtime: { secretName: string; + apiServiceName: string; apiPort: number; workerMetricsPort: number; + webPort: number; + webApiUpstreamEnv: string; attachment: { storageRoot: string; maxBytes: number; claimName: string; storageRequest: string; storageClassName: string | null; + fsGroup: number; accessModes: string[]; }; workerInterval: string; @@ -92,6 +98,12 @@ interface TestTargetSpec { backoffLimit: number; command: string[]; }; + exposure: { + serviceName: string; + serviceType: "NodePort"; + hostIP: string; + port: number; + }; observability: { otlpEndpoint: string; prometheusScrape: boolean; @@ -105,6 +117,8 @@ interface TestTargetSpec { apiReadinessPath: string; workerLivenessPath: string; workerReadinessPath: string; + webLivenessPath: string; + webReadinessPath: string; }; } @@ -133,15 +147,17 @@ interface Blocker { interface RenderContext { instanceId: string; commit: string; + step: TestTargetStep; namespace: string; expiresAt: string; apiImage: string; workerImage: string; + webImage: string; migrationImage: string; } interface SecretMaterial { - databasePassword: string; + databaseURL: string; sessionSecret: string; administratorPassword: string; employeePassword: string; @@ -163,18 +179,18 @@ const INSTANCE_LABEL = "pikaoa.unidesk.io/instance"; export function pikaoaTestTargetHelp(): Record { return { command: "pikaoa test-target", - description: "通过 YAML-first 专用测试集群 target 管理 PikaOA 临时 k8s 后端运行面,不经过 CI/CD。", + description: "通过 YAML-first target 管理 PikaOA 固定 namespace 测试运行面,不经过正式 CI/CD。", usage: [ "bun scripts/cli.ts pikaoa test-target plan [--config path] [--target id] [--instance id] [--commit sha] [--output json]", "bun scripts/cli.ts pikaoa test-target status [--config path] [--target id] [--instance id] [--output json]", - "bun scripts/cli.ts pikaoa test-target start --target id --instance id --commit sha --confirm", + "bun scripts/cli.ts pikaoa test-target start --target id --instance id [--commit sha] [--step foundation|migration|api|worker|web|all] --confirm", "bun scripts/cli.ts pikaoa test-target stop --target id --instance id --confirm", ], source: "config/pikaoa.yaml#testRuntime", guarantees: [ - "未声明专用 target 时 plan/status 返回 configured=false,start/stop 在远端调用前失败。", + "未声明测试 target 时 plan/status 返回 configured=false,start/stop 在远端调用前失败。", "validationOnly target 只用于本地渲染,不连接 route。", - "start 只提交具名远程任务并立即返回;status 用短连接读取阶段、终态和有界事件尾部。", + "start 可按 foundation、migration、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。", "正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。", "Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。", ], @@ -201,17 +217,19 @@ function parseOptions(args: string[]): TestTargetOptions { let targetId: string | null = null; let instanceId: string | null = null; let commit: string | null = null; + let step: TestTargetStep = "all"; let confirm = false; let output: "text" | "json" = "text"; for (let index = 1; index < args.length; index += 1) { const arg = args[index]!; - if (arg === "--config" || arg === "--target" || arg === "--instance" || arg === "--commit" || arg === "--output" || arg === "-o") { + if (arg === "--config" || arg === "--target" || arg === "--instance" || arg === "--commit" || arg === "--step" || arg === "--output" || arg === "-o") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, "missing-option-value", arg); if (arg === "--config") configPath = resolveConfigPath(value); else if (arg === "--target") targetId = simpleId(value, arg); else if (arg === "--instance") instanceId = kubernetesName(value, arg, 30); else if (arg === "--commit") commit = sourceCommit(value); + else if (arg === "--step") step = enumString(value, arg, ["all", "foundation", "migration", "api", "worker", "web"]); else { if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]); output = value; @@ -228,7 +246,7 @@ function parseOptions(args: string[]): TestTargetOptions { } } if ((action === "plan" || action === "status") && confirm) throw inputError(`${action} 是只读操作,不接受 --confirm`, "confirm-not-allowed", "--confirm"); - return { action, configPath, targetId, instanceId, commit, confirm, output }; + return { action, configPath, targetId, instanceId, commit, step, confirm, output }; } function readSelection(options: TestTargetOptions): Selection { @@ -276,11 +294,13 @@ function parseTarget(id: string, root: Record, configLabel: str const administrator = record(runtime.administrator, `${path}.runtime.administrator`); const employee = record(runtime.employee, `${path}.runtime.employee`); const observability = record(root.observability, `${path}.observability`); + const exposure = record(root.exposure, `${path}.exposure`); const prometheus = record(observability.prometheus, `${path}.observability.prometheus`); const exporterFailure = record(observability.exporterFailure, `${path}.observability.exporterFailure`); const probes = record(root.probes, `${path}.probes`); const apiProbes = record(probes.api, `${path}.probes.api`); const workerProbes = record(probes.worker, `${path}.probes.worker`); + const webProbes = record(probes.web, `${path}.probes.web`); const cleanup = record(root.cleanup, `${path}.cleanup`); const migration = record(root.migration, `${path}.migration`); const taskState = record(root.taskState, `${path}.taskState`); @@ -291,10 +311,9 @@ function parseTarget(id: string, root: Record, configLabel: str id, enabled: boolean(root.enabled, `${path}.enabled`), validationOnly: boolean(root.validationOnly, `${path}.validationOnly`), - dedicated: boolean(root.dedicated, `${path}.dedicated`), node: nonEmpty(root.node, `${path}.node`), route: nonEmpty(root.route, `${path}.route`), - namespacePrefix: kubernetesName(nonEmpty(root.namespacePrefix, `${path}.namespacePrefix`), `${path}.namespacePrefix`, 40), + namespace: kubernetesName(nonEmpty(root.namespace, `${path}.namespace`), `${path}.namespace`, 63), ttlSeconds: positiveInteger(root.ttlSeconds, `${path}.ttlSeconds`, 86_400), waitTimeoutSeconds: positiveInteger(root.waitTimeoutSeconds, `${path}.waitTimeoutSeconds`, 900), fieldManager: kubernetesName(nonEmpty(root.fieldManager, `${path}.fieldManager`), `${path}.fieldManager`, 63), @@ -313,27 +332,31 @@ function parseTarget(id: string, root: Record, configLabel: str commitPolicy: exactString(source.commitPolicy, `${path}.source.commitPolicy`, "cli-required"), apiImage: imageReference(images.api, `${path}.source.images.api`), workerImage: imageReference(images.worker, `${path}.source.images.worker`), + webImage: imageReference(images.web, `${path}.source.images.web`), migrationImage: imageReference(images.migration, `${path}.source.images.migration`), pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]), }, database: { - image: imageReference(database.image, `${path}.database.image`), + configRef: nonEmpty(database.configRef, `${path}.database.configRef`), name: postgresIdentifier(database.name, `${path}.database.name`), username: postgresIdentifier(database.username, `${path}.database.username`), schema: postgresIdentifier(database.schema, `${path}.database.schema`), - port: positiveInteger(database.port, `${path}.database.port`, 65_535), - password: secretSource(database.password, "database.password", `${path}.database.password`), + connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true), }, runtime: { secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63), + apiServiceName: kubernetesName(nonEmpty(runtime.apiServiceName, `${path}.runtime.apiServiceName`), `${path}.runtime.apiServiceName`, 63), apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535), workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535), + webPort: positiveInteger(runtime.webPort, `${path}.runtime.webPort`, 65_535), + webApiUpstreamEnv: envName(runtime.webApiUpstreamEnv, `${path}.runtime.webApiUpstreamEnv`), attachment: { storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`), maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER), claimName: kubernetesName(nonEmpty(attachment.claimName, `${path}.runtime.attachment.claimName`), `${path}.runtime.attachment.claimName`, 63), storageRequest: nonEmpty(attachment.storageRequest, `${path}.runtime.attachment.storageRequest`), storageClassName: nullableString(attachment.storageClassName, `${path}.runtime.attachment.storageClassName`), + fsGroup: positiveInteger(attachment.fsGroup, `${path}.runtime.attachment.fsGroup`, 2_147_483_647), accessModes: enumStringList(attachment.accessModes, `${path}.runtime.attachment.accessModes`, ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]), }, workerInterval: duration(runtime.workerInterval, `${path}.runtime.workerInterval`), @@ -357,6 +380,12 @@ function parseTarget(id: string, root: Record, configLabel: str backoffLimit: nonNegativeInteger(migration.backoffLimit, `${path}.migration.backoffLimit`), command: nonEmptyStringList(migration.command, `${path}.migration.command`), }, + exposure: { + serviceName: kubernetesName(nonEmpty(exposure.serviceName, `${path}.exposure.serviceName`), `${path}.exposure.serviceName`, 63), + serviceType: exactString(exposure.serviceType, `${path}.exposure.serviceType`, "NodePort") as "NodePort", + hostIP: ipv4Address(exposure.hostIP, `${path}.exposure.hostIP`), + port: positiveInteger(exposure.port, `${path}.exposure.port`, 65_535), + }, observability: { otlpEndpoint: nonEmpty(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`), prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`), @@ -370,6 +399,8 @@ function parseTarget(id: string, root: Record, configLabel: str apiReadinessPath: httpPath(apiProbes.readinessPath, `${path}.probes.api.readinessPath`), workerLivenessPath: httpPath(workerProbes.livenessPath, `${path}.probes.worker.livenessPath`), workerReadinessPath: httpPath(workerProbes.readinessPath, `${path}.probes.worker.readinessPath`), + webLivenessPath: httpPath(webProbes.livenessPath, `${path}.probes.web.livenessPath`), + webReadinessPath: httpPath(webProbes.readinessPath, `${path}.probes.web.readinessPath`), }, }; const secretKeys = secretSources(target).map((item) => item.targetKey); @@ -382,11 +413,17 @@ function parseTarget(id: string, root: Record, configLabel: str function planPayload(options: TestTargetOptions, selection: Selection): Record { const base = basePayload(options, selection); if (selection.target === null) return base; - const requireCommit = options.action === "plan" || options.action === "start"; + const requireCommit = (options.action === "plan" || options.action === "start") && options.step !== "foundation"; const context = renderContext(options, selection.target, requireCommit); const blockers = safetyBlockers(options, selection, context, requireCommit); + if ((options.action === "plan" || options.action === "start") && options.step !== "foundation") { + for (const source of secretSources(selection.target)) { + const summary = secretSourceSummary(selection.configPath, source); + if (summary.presence !== true) blockers.push({ code: "secret-source-missing", field: source.sourceRef, message: `${source.purpose} 的 sourceRef/sourceKey 不存在或为空。` }); + } + } const warnings = [...selection.warnings, ...consistencyWarnings(selection.target, context)]; - const renderWorkloads = context !== null && options.commit !== null && (options.action === "plan" || options.action === "start"); + const renderWorkloads = context !== null && (options.commit !== null || options.step === "foundation") && (options.action === "plan" || options.action === "start"); return { ...base, target: targetSummary(selection.target), @@ -395,7 +432,9 @@ function planPayload(options: TestTargetOptions, selection: Selection): Record --instance --commit ", + plan: "bun scripts/cli.ts pikaoa test-target plan --target --commit ", } : nextCommands(options, selection.target), valuesPrinted: false, }; @@ -431,9 +471,9 @@ function renderedPlan(selection: Selection, context: RenderContext): Record ({ ...source, presence: "unchecked", fingerprint: null, valuesPrinted: false })), + sources: secretSources(target).map((source) => secretSourceSummary(selection.configPath, source)), }, database: databasePlanSummary(target), runtimeConfig: { @@ -460,26 +501,31 @@ function renderedPlan(selection: Selection, context: RenderContext): Record { const planned = planPayload(options, selection); - const context = selection.target === null ? null : renderContext(options, selection.target); - const blockers = safetyBlockers(options, selection, context); + const requireCommit = options.step !== "foundation"; + const context = selection.target === null ? null : renderContext(options, selection.target, requireCommit); + const blockers = safetyBlockers(options, selection, context, requireCommit); if (!options.confirm) blockers.push({ code: "confirmation-required", field: "--confirm", message: "start 需要显式 --confirm。" }); if (selection.target?.validationOnly === true) blockers.push({ code: "validation-only-target", field: "testRuntime.targets.*.validationOnly", message: "validationOnly target 禁止远端操作。" }); if (selection.target === null || context === null || blockers.length > 0) { return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: uniqueBlockers(blockers), failure: "preflight-blocked" }, options); } - const material = readSecretMaterial(selection, selection.target); + const material = options.step === "foundation" ? { + ok: true, + values: placeholderSecrets(), + sources: secretSources(selection.target).map((source) => secretSourceSummary(selection.configPath, source)), + blockers: [] as Blocker[], + } : readSecretMaterial(selection, selection.target); if (!material.ok) { return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: material.blockers, secret: { sources: material.sources, valuesPrinted: false }, failure: "secret-preflight-blocked" }, options); } - const manifest = buildManifest(selection.target, context, material.values); + const manifest = manifestForStep(buildManifest(selection.target, context, material.values), options.step); const remote = await remoteCapture(config, selection.target.route, ["sh"], startScript(selection.target, context, manifest)); const parsed = parseJsonOutput(remote.stdout); const mutation = parsed?.mutation === true; @@ -537,6 +589,7 @@ async function startResult(config: UniDeskConfig, options: TestTargetOptions, se mode: mutation ? "submitted" : parsed === null ? "submit-failed" : "existing-task", code: parsed?.code ?? "start-submit-unreadable", jobId: parsed?.jobId ?? asyncJobIdentity(selection.target, context).id, + previousTaskReset: parsed?.previousTaskReset === true, task: parsed?.task ?? null, secret: { sources: material.sources, valuesPrinted: false }, remote: parsed ?? compactCapture(remote, { full: true }), @@ -569,23 +622,25 @@ async function stopResult(config: UniDeskConfig, options: TestTargetOptions, sel } function renderContext(options: TestTargetOptions, target: TestTargetSpec, requireCommit = true): RenderContext | null { - if (options.instanceId === null || (requireCommit && options.commit === null)) return null; - const commit = options.commit ?? "not-required-for-stop"; - const namespace = `${target.namespacePrefix}-${options.instanceId}`; + if (requireCommit && options.commit === null) return null; + const instanceId = options.instanceId ?? "default"; + const commit = options.commit ?? (options.step === "foundation" ? "foundation" : "not-required-for-stop"); const expiresAt = new Date(Date.now() + target.ttlSeconds * 1000).toISOString(); return { - instanceId: options.instanceId, + instanceId, commit, - namespace, + step: options.step, + namespace: target.namespace, expiresAt, apiImage: renderImage(target.source.apiImage, commit), workerImage: renderImage(target.source.workerImage, commit), + webImage: renderImage(target.source.webImage, commit), migrationImage: renderImage(target.source.migrationImage, commit), }; } function asyncJobIdentity(target: TestTargetSpec, context: RenderContext): AsyncJobIdentity { - const digest = createHash("sha256").update(target.id).update("\0").update(context.instanceId).digest("hex").slice(0, 20); + const digest = createHash("sha256").update(target.id).update("\0").update(context.instanceId).update("\0").update(context.step).digest("hex").slice(0, 20); const id = `pikaoa-${digest}`; return { id, stateDir: `${target.taskState.rootPath}/${id}` }; } @@ -594,16 +649,13 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex const blockers: Blocker[] = []; const target = selection.target; if (target === null) { - blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的专用测试 target。" }); + blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的测试 target。" }); return blockers; } - if (!target.dedicated) blockers.push({ code: "target-not-dedicated", field: `testRuntime.targets.${target.id}.dedicated`, message: "target 必须显式声明 dedicated=true。" }); - if (options.instanceId === null) blockers.push({ code: "instance-required", field: "--instance", message: "临时 namespace 必须带显式运行实例 ID。" }); - if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "start 必须选择源码 commit。" }); + if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "当前 step 必须选择源码 commit。" }); if (context !== null) { - if (context.namespace.length > 63 || !isKubernetesName(context.namespace)) blockers.push({ code: "unsafe-namespace", field: "namespacePrefix/--instance", message: `渲染后的 namespace 不合法:${context.namespace}` }); + if (context.namespace.length > 63 || !isKubernetesName(context.namespace)) blockers.push({ code: "unsafe-namespace", field: "namespace", message: `YAML namespace 不合法:${context.namespace}` }); if (selection.protectedNamespaces.includes(context.namespace)) blockers.push({ code: "production-namespace-protected", field: "namespace", message: `${context.namespace} 属于 delivery 正式 namespace。` }); - if (!context.namespace.startsWith(`${target.namespacePrefix}-`) || context.namespace === target.namespacePrefix) blockers.push({ code: "instance-not-in-namespace", field: "namespace", message: "namespace 必须由前缀和实例 ID 共同组成。" }); } return blockers; } @@ -611,8 +663,8 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] { const warnings: Warning[] = []; if (!target.migration.command.includes("migrate") || !target.migration.command.includes("up")) warnings.push(warning("migration-command-unrecognized", "迁移命令未显示产品 migrate up;配置一致性只报告 warning。")); - if (!target.source.apiImage.includes("${commit}") || !target.source.workerImage.includes("${commit}") || !target.source.migrationImage.includes("${commit}")) warnings.push(warning("image-template-not-commit-scoped", "API、Worker 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。")); - if (context !== null && (!context.apiImage.includes(context.commit) || !context.workerImage.includes(context.commit) || !context.migrationImage.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。")); + if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.migrationImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。")); + if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.migrationImage].every((image) => image.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。")); if (target.observability.exporterWarning) warnings.push(warning("otel-exporter-warning-only", "OTel exporter 失败按 owning YAML 仅报告 warning,不阻塞业务运行。")); if (!target.observability.prometheusScrape) warnings.push(warning("prometheus-scrape-disabled", "Prometheus scrape 已在 target YAML 中关闭。")); return warnings; @@ -623,7 +675,7 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: const secretData = runtimeSecretData(target, context, secrets); const apiSelector = componentSelector("api", context.instanceId); const workerSelector = componentSelector("worker", context.instanceId); - const postgresSelector = componentSelector("postgres", context.instanceId); + const webSelector = componentSelector("web", context.instanceId); const migrationSelector = componentSelector("migration", context.instanceId); const commonPodAnnotations = (path: string, port: number): Record => target.observability.prometheusScrape ? { "prometheus.io/scrape": "true", @@ -646,37 +698,17 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: }, }, { - apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-postgres", namespace: context.namespace, labels: { ...labels, ...postgresSelector } }, - spec: { selector: postgresSelector, ports: [{ name: "postgres", port: target.database.port, targetPort: target.database.port }] }, - }, - { - apiVersion: "apps/v1", kind: "StatefulSet", metadata: { name: "pikaoa-postgres", namespace: context.namespace, labels: { ...labels, ...postgresSelector } }, - spec: { - serviceName: "pikaoa-postgres", replicas: 1, selector: { matchLabels: postgresSelector }, - template: { - metadata: { labels: { ...labels, ...postgresSelector } }, - spec: { - containers: [{ - name: "postgres", image: target.database.image, imagePullPolicy: target.source.pullPolicy, - ports: [{ name: "postgres", containerPort: target.database.port }], - env: [ - { name: "POSTGRES_DB", value: target.database.name }, - { name: "POSTGRES_USER", value: target.database.username }, - { name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.database.password.targetKey } } }, - { name: "PGDATA", value: "/var/lib/postgresql/data/pgdata" }, - ], - readinessProbe: { exec: { command: ["sh", "-ec", `pg_isready -U ${target.database.username} -d ${target.database.name}`] }, periodSeconds: 3, failureThreshold: 20 }, - volumeMounts: [{ name: "data", mountPath: "/var/lib/postgresql/data" }], - }], - volumes: [{ name: "data", emptyDir: {} }], - }, - }, - }, - }, - { - apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-api", namespace: context.namespace, labels: { ...labels, ...apiSelector } }, + apiVersion: "v1", kind: "Service", metadata: { name: target.runtime.apiServiceName, namespace: context.namespace, labels: { ...labels, ...apiSelector } }, spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] }, }, + { + apiVersion: "v1", kind: "Service", metadata: { name: target.exposure.serviceName, namespace: context.namespace, labels: { ...labels, ...webSelector } }, + spec: { + type: target.exposure.serviceType, + selector: webSelector, + ports: [{ name: "http", port: target.runtime.webPort, targetPort: target.runtime.webPort, nodePort: target.exposure.port }], + }, + }, { apiVersion: "batch/v1", kind: "Job", metadata: { name: target.migration.jobName, namespace: context.namespace, labels: { ...labels, ...migrationSelector } }, spec: { @@ -707,13 +739,29 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [ { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, ], target.probes.workerLivenessPath, target.probes.workerReadinessPath), + deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, [ + { name: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}:${target.runtime.apiPort}` }, + ], target.probes.webLivenessPath, target.probes.webReadinessPath), ]; } +function manifestForStep(manifest: Record[], step: TestTargetStep): Record[] { + if (step === "all") return manifest; + return manifest.filter((object) => { + if (object.kind === "Namespace" || object.kind === "PersistentVolumeClaim") return true; + if (step === "foundation") return false; + if (object.kind === "Secret") return true; + if (step === "migration") return object.kind === "Job"; + const metadata = optionalRecord(object.metadata, "metadata") ?? {}; + const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; + return labels["app.kubernetes.io/component"] === step; + }); +} + function deployment( target: TestTargetSpec, context: RenderContext, - component: "api" | "worker", + component: "api" | "worker" | "web", image: string, selector: Record, port: number, @@ -730,18 +778,19 @@ function deployment( template: { metadata: { labels: { ...labels, ...selector }, annotations }, spec: { + ...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}), containers: [{ name: component, image, imagePullPolicy: target.source.pullPolicy, env, - ports: [{ name: component === "api" ? "http" : "metrics", containerPort: port }], + ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }], volumeMounts: [ - { name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }, + ...(component === "web" ? [] : [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }]), ...(component === "api" ? [{ name: target.runtime.attachment.claimName, mountPath: target.runtime.attachment.storageRoot }] : []), ], livenessProbe: { httpGet: { path: livenessPath, port }, periodSeconds: 5, failureThreshold: 12 }, readinessProbe: { httpGet: { path: readinessPath, port }, periodSeconds: 3, failureThreshold: 20 }, }], volumes: [ - { name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }, + ...(component === "web" ? [] : [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }]), ...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []), ], }, @@ -751,9 +800,9 @@ function deployment( } function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record { - const databaseURL = renderDatabaseURL(target, secrets.databasePassword); + const databaseURL = renderDatabaseURL(target, secrets.databaseURL); return { - [target.database.password.targetKey]: secrets.databasePassword, + [target.database.connection.targetKey]: databaseURL, [target.runtime.sessionSecret.targetKey]: secrets.sessionSecret, [target.runtime.administrator.password.targetKey]: secrets.administratorPassword, [target.runtime.employee.password.targetKey]: secrets.employeePassword, @@ -783,21 +832,20 @@ function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secre }; } -function renderDatabaseURL(target: TestTargetSpec, password: string): string { - const databaseURL = new URL("postgres://pikaoa-postgres"); - databaseURL.username = target.database.username; - databaseURL.password = password; - databaseURL.port = String(target.database.port); - databaseURL.pathname = `/${target.database.name}`; - databaseURL.searchParams.set("sslmode", "disable"); +function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string { + const databaseURL = new URL(sourceURL); databaseURL.searchParams.set("search_path", target.database.schema); return databaseURL.toString(); } function databasePlanSummary(target: TestTargetSpec): Record { - const databaseURL = new URL(renderDatabaseURL(target, "")); + const databaseURL = new URL(renderDatabaseURL(target, "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require")); return { + configRef: target.database.configRef, + name: target.database.name, + username: target.database.username, schema: target.database.schema, + connection: { sourceRef: target.database.connection.sourceRef, sourceKey: target.database.connection.sourceKey, targetKey: target.database.connection.targetKey, valuesPrinted: false }, dsnParameterPresence: { sslmode: databaseURL.searchParams.has("sslmode"), search_path: databaseURL.searchParams.has("search_path"), @@ -819,23 +867,37 @@ function readSecretMaterial(selection: Selection, target: TestTargetSpec): { for (const source of sources) { const path = resolveSourceRef(selection.configPath, source.sourceRef); const present = existsSync(path); - const value = present ? readFileSync(path, "utf8").trim() : ""; + const raw = present ? readFileSync(path, "utf8") : ""; + const value = source.sourceKey === null ? raw.trim() : envValue(raw, source.sourceKey); const valid = present && value.length > 0; if (!valid) blockers.push({ code: "secret-source-missing", field: source.sourceRef, message: `${source.purpose} 的 sourceRef 不存在或为空。` }); if (valid) values.set(source.purpose, value); summaries.push({ purpose: source.purpose, sourceRef: source.sourceRef, + sourceKey: source.sourceKey, targetKey: source.targetKey, presence: valid, fingerprint: valid ? sha256Fingerprint(value) : null, valuesPrinted: false, }); } + const databaseURL = values.get("database.connection"); + if (databaseURL !== undefined) { + try { + const parsed = new URL(databaseURL); + const databaseName = parsed.pathname.replace(/^\/+/, ""); + if (parsed.username !== target.database.username || databaseName !== target.database.name) { + blockers.push({ code: "database-identity-mismatch", field: target.database.connection.sourceRef, message: "外部 DSN 必须匹配 YAML 声明的独立测试 database 和 role。" }); + } + } catch { + blockers.push({ code: "database-url-invalid", field: target.database.connection.sourceRef, message: "外部测试数据库 sourceKey 不是合法 PostgreSQL URL。" }); + } + } return { ok: blockers.length === 0, values: { - databasePassword: values.get("database.password") ?? "", + databaseURL: values.get("database.connection") ?? "", sessionSecret: values.get("runtime.sessionSecret") ?? "", administratorPassword: values.get("runtime.administrator.password") ?? "", employeePassword: values.get("runtime.employee.password") ?? "", @@ -845,14 +907,49 @@ function readSecretMaterial(selection: Selection, target: TestTargetSpec): { }; } +function secretSourceSummary(configPath: string, source: SecretSourceSpec): Record { + const path = resolveSourceRef(configPath, source.sourceRef); + const present = existsSync(path); + const raw = present ? readFileSync(path, "utf8") : ""; + const value = source.sourceKey === null ? raw.trim() : envValue(raw, source.sourceKey); + const available = value.length > 0; + return { + purpose: source.purpose, + sourceRef: source.sourceRef, + sourceKey: source.sourceKey, + targetKey: source.targetKey, + presence: available, + fingerprint: available ? sha256Fingerprint(value) : null, + valuesPrinted: false, + }; +} + +function envValue(raw: string, key: string): string { + for (const line of raw.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const separator = trimmed.indexOf("="); + if (separator < 1 || trimmed.slice(0, separator).trim() !== key) continue; + const value = trimmed.slice(separator + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1); + return value; + } + return ""; +} + function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record[]): string { const job = asyncJobIdentity(target, context); + const manifestFingerprint = requestStructuralFingerprint(manifest); const requestId = createHash("sha256") .update(target.id) .update("\0") .update(context.instanceId) .update("\0") .update(context.commit) + .update("\0") + .update(context.step) + .update("\0") + .update(manifestFingerprint) .digest("hex"); const runnerEncoded = Buffer.from(startRunnerScript(target, context, manifest, job), "utf8").toString("base64"); return ` @@ -865,29 +962,38 @@ request_file="$state_dir/request-id" pid_file="$state_dir/pid" job_id=${shQuote(job.id)} request_id=${shQuote(requestId)} -emit_existing() { +reset_previous=false +inspect_existing() { python3 - "$request_file" "$status_file" "$pid_file" "$job_id" "$request_id" <<'PY' import json, os, pathlib, sys request_path, status_path, pid_path = map(pathlib.Path, sys.argv[1:4]) job_id, expected_request = sys.argv[4:6] actual_request = request_path.read_text(errors="replace").strip() if request_path.exists() else "" -if actual_request != expected_request: - print(json.dumps({"ok": False, "mutation": False, "code": "start-instance-conflict", "jobId": job_id, "submitted": False, "task": None, "valuesPrinted": False})) - raise SystemExit(0) try: task = json.loads(status_path.read_text()) except Exception: print(json.dumps({"ok": False, "mutation": False, "code": "start-state-unreadable", "jobId": job_id, "submitted": False, "task": None, "valuesPrinted": False})) - raise SystemExit(0) + raise SystemExit(10) state = str(task.get("state") or "unknown") runner_alive = False pid = task.get("pid") +if not isinstance(pid, int) or pid <= 0: + try: + pid = int(pid_path.read_text().strip()) + except Exception: + pid = None if isinstance(pid, int) and pid > 0: try: os.kill(pid, 0) runner_alive = True except OSError: pass +worker_missing = state in ("queued", "running") and not runner_alive +if actual_request != expected_request: + if state in ("succeeded", "failed", "canceled") or worker_missing: + raise SystemExit(20) + print(json.dumps({"ok": False, "mutation": False, "code": "start-instance-conflict", "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False})) + raise SystemExit(10) code_by_state = { "queued": "start-already-running", "running": "start-already-running", @@ -895,20 +1001,40 @@ code_by_state = { "failed": "start-already-failed", "canceled": "start-already-canceled", } -code = "start-worker-missing" if state in ("queued", "running") and not runner_alive else code_by_state.get(state, "start-state-unknown") +code = "start-worker-missing" if worker_missing else code_by_state.get(state, "start-state-unknown") ok = state not in ("failed", "canceled", "unknown") and code != "start-worker-missing" print(json.dumps({"ok": ok, "mutation": False, "code": code, "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False})) +raise SystemExit(10) PY } if ! mkdir -p "$state_root"; then printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-root-create-failed","valuesPrinted":false}' exit 41 fi -if ! mkdir "$state_dir" 2>/dev/null; then - if [ -d "$state_dir" ]; then emit_existing; exit 0; fi - printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-create-failed","valuesPrinted":false}' - exit 42 -fi +claim_attempt=0 +while ! mkdir "$state_dir" 2>/dev/null; do + if [ ! -d "$state_dir" ]; then + printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-create-failed","valuesPrinted":false}' + exit 42 + fi + inspect_existing + decision=$? + if [ "$decision" -eq 10 ]; then exit 0; fi + if [ "$decision" -ne 20 ]; then + printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-inspect-failed","valuesPrinted":false}' + exit 42 + fi + retired_dir="$state_dir.reset.$request_id.$$" + if mv "$state_dir" "$retired_dir" 2>/dev/null; then + rm -rf "$retired_dir" + reset_previous=true + fi + claim_attempt=$((claim_attempt + 1)) + if [ "$claim_attempt" -ge 5 ]; then + printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-reset-conflict","valuesPrinted":false}' + exit 42 + fi +done started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf '%s\n' "$request_id" >"$request_file" printf '%s\n' "$started_at" >"$state_dir/started-at" @@ -918,13 +1044,13 @@ if ! printf '%s' ${shQuote(runnerEncoded)} | base64 -d >"$state_dir/job.sh"; the exit 43 fi chmod 0700 "$state_dir/job.sh" -python3 - "$status_file" "$job_id" ${shQuote(target.id)} ${shQuote(context.instanceId)} ${shQuote(context.namespace)} ${shQuote(context.commit)} "$started_at" <<'PY' +python3 - "$status_file" "$job_id" ${shQuote(target.id)} ${shQuote(context.instanceId)} ${shQuote(context.namespace)} ${shQuote(context.commit)} ${shQuote(context.step)} "$started_at" <<'PY' import json, os, pathlib, sys path = pathlib.Path(sys.argv[1]) payload = { "ok": True, "jobId": sys.argv[2], "targetId": sys.argv[3], "instanceId": sys.argv[4], - "namespace": sys.argv[5], "sourceCommit": sys.argv[6], "state": "running", "phase": "submitted", - "code": "start-running", "startedAt": sys.argv[7], "updatedAt": sys.argv[7], "finishedAt": None, + "namespace": sys.argv[5], "sourceCommit": sys.argv[6], "step": sys.argv[7], "state": "running", "phase": "submitted", + "code": "start-running", "startedAt": sys.argv[8], "updatedAt": sys.argv[8], "finishedAt": None, "exitCode": None, "pid": None, "valuesPrinted": False, } tmp = path.with_name(path.name + ".tmp") @@ -952,18 +1078,27 @@ if payload.get("pid") is None and payload.get("phase") == "submitted": tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n") os.replace(tmp, path) PY -printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"jobId":"%s","pid":%s,"task":{"state":"running","phase":"submitted","code":"start-running"},"valuesPrinted":false}\n' "$job_id" "$pid" +printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"previousTaskReset":%s,"jobId":"%s","pid":%s,"task":{"state":"running","phase":"submitted","code":"start-running"},"valuesPrinted":false}\n' "$reset_previous" "$job_id" "$pid" `; } function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record[], job: AsyncJobIdentity): string { const migration = manifest.filter((object) => object.kind === "Job"); - const workloads = manifest.filter((object) => object.kind === "Deployment"); - const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment"); + const workload = (name: string): Record[] => manifest.filter((object) => { + if (object.kind !== "Deployment" && object.kind !== "Service") return false; + const metadata = optionalRecord(object.metadata, "metadata") ?? {}; + const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; + return labels["app.kubernetes.io/component"] === name; + }); + const foundation = manifest.filter((object) => object.kind === "Namespace" || object.kind === "PersistentVolumeClaim"); + const runtime = manifest.filter((object) => object.kind === "Secret"); const encode = (objects: Record[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); const foundationEncoded = encode(foundation); + const runtimeEncoded = encode(runtime); const migrationEncoded = encode(migration); - const workloadsEncoded = encode(workloads); + const apiEncoded = encode(workload("api")); + const workerEncoded = encode(workload("worker")); + const webEncoded = encode(workload("web")); return `#!/bin/sh set -eu umask 077 @@ -977,6 +1112,7 @@ managed_by=${shQuote(MANAGED_BY)} target_id=${shQuote(target.id)} instance_id=${shQuote(context.instanceId)} source_commit=${shQuote(context.commit)} +step=${shQuote(context.step)} current_phase=runner-start failure_code=start-runner-failed tmp="" @@ -992,7 +1128,7 @@ exit_code = None if sys.argv[13] == "null" else int(sys.argv[13]) terminal = state in ("succeeded", "failed", "canceled") payload = { "ok": state in ("running", "succeeded"), "jobId": sys.argv[3], "targetId": sys.argv[4], - "instanceId": sys.argv[5], "namespace": sys.argv[6], "sourceCommit": sys.argv[7], + "instanceId": sys.argv[5], "namespace": sys.argv[6], "sourceCommit": sys.argv[7], "step": ${JSON.stringify(context.step)}, "state": state, "phase": phase, "code": code, "startedAt": sys.argv[11], "updatedAt": sys.argv[12], "finishedAt": sys.argv[12] if terminal else None, "exitCode": exit_code, "pid": int(sys.argv[14]), "valuesPrinted": False, @@ -1040,8 +1176,11 @@ if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statu fi fi printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml" +printf '%s' ${shQuote(runtimeEncoded)} | base64 -d >"$tmp/runtime.yaml" printf '%s' ${shQuote(migrationEncoded)} | base64 -d >"$tmp/migration.yaml" -printf '%s' ${shQuote(workloadsEncoded)} | base64 -d >"$tmp/workloads.yaml" +printf '%s' ${shQuote(apiEncoded)} | base64 -d >"$tmp/api.yaml" +printf '%s' ${shQuote(workerEncoded)} | base64 -d >"$tmp/worker.yaml" +printf '%s' ${shQuote(webEncoded)} | base64 -d >"$tmp/web.yaml" current_phase=foundation-apply write_status running "$current_phase" start-running null check_canceled @@ -1049,48 +1188,38 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar failure_code=apply-failed exit 43 fi -current_phase=postgres-rollout +if [ "$step" = foundation ]; then + exit 0 +fi +current_phase=runtime-secret-apply write_status running "$current_phase" start-running null check_canceled -if ! kubectl -n "$namespace" rollout status statefulset/pikaoa-postgres --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err"; then - failure_code=postgres-wait-failed +if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/runtime.yaml" >"$tmp/runtime-apply.out" 2>"$tmp/runtime-apply.err"; then + failure_code=runtime-secret-apply-failed exit 44 fi -current_phase=migration-apply -write_status running "$current_phase" start-running null -check_canceled -if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then - failure_code=migration-apply-failed - exit 45 -fi -current_phase=migration-wait -write_status running "$current_phase" start-running null -check_canceled -if ! kubectl -n "$namespace" wait --for=condition=complete job/${target.migration.jobName} --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/migration-wait.err"; then - failure_code=migration-wait-failed - exit 46 -fi -current_phase=workloads-apply -write_status running "$current_phase" start-running null -check_canceled -if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/workloads.yaml" >"$tmp/workloads-apply.out" 2>"$tmp/workloads-apply.err"; then - failure_code=workloads-apply-failed - exit 47 -fi -current_phase=api-rollout -write_status running "$current_phase" start-running null -check_canceled -if ! kubectl -n "$namespace" rollout status deployment/pikaoa-api --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err"; then - failure_code=api-rollout-wait-failed - exit 48 -fi -current_phase=worker-rollout -write_status running "$current_phase" start-running null -check_canceled -if ! kubectl -n "$namespace" rollout status deployment/pikaoa-worker --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err"; then - failure_code=worker-rollout-wait-failed - exit 49 +if [ "$step" = all ] || [ "$step" = migration ]; then + current_phase=migration-apply + write_status running "$current_phase" start-running null + check_canceled + kubectl -n "$namespace" delete job/${target.migration.jobName} --ignore-not-found --wait=false >/dev/null 2>&1 || true + if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then failure_code=migration-apply-failed; exit 45; fi + current_phase=migration-wait + write_status running "$current_phase" start-running null + check_canceled + if ! kubectl -n "$namespace" wait --for=condition=complete job/${target.migration.jobName} --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/migration-wait.err"; then failure_code=migration-wait-failed; exit 46; fi fi +for component in api worker web; do + if [ "$step" != all ] && [ "$step" != "$component" ]; then continue; fi + current_phase="$component-apply" + write_status running "$current_phase" start-running null + check_canceled + if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/$component.yaml" >"$tmp/$component-apply.out" 2>"$tmp/$component-apply.err"; then failure_code="$component-apply-failed"; exit 47; fi + current_phase="$component-rollout" + write_status running "$current_phase" start-running null + check_canceled + if ! kubectl -n "$namespace" rollout status "deployment/pikaoa-$component" --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/$component-wait.err"; then failure_code="$component-rollout-wait-failed"; exit 48; fi +done `; } @@ -1107,7 +1236,7 @@ trap 'rm -rf "$tmp"' EXIT runtime_code=runtime-not-found if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/namespace.json" 2>"$tmp/namespace.err"; then runtime_code=runtime-present - if ! kubectl -n "$namespace" get deployment,statefulset,job,service,persistentvolumeclaim,pod --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/resources.json" 2>"$tmp/resources.err"; then + if ! kubectl -n "$namespace" get deployment,job,service,persistentvolumeclaim,pod --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/resources.json" 2>"$tmp/resources.err"; then runtime_code=runtime-resource-query-failed fi elif ! grep -Eiq 'not[[:space:]-]*found' "$tmp/namespace.err"; then @@ -1246,7 +1375,7 @@ printf '{"ok":true,"mutation":true,"code":"stop-requested","jobId":"%s","startTa `; } -function summarizeRemoteStatus(parsed: Record, target: TestTargetSpec, context: RenderContext): Record { +function summarizeRemoteStatus(parsed: Record, target: TestTargetSpec, context: RenderContext, configPath: string): Record { const runtime = optionalRecord(parsed.runtime, "status.runtime") ?? {}; const namespace = optionalRecord(runtime.namespace, "status.runtime.namespace") ?? {}; const metadata = optionalRecord(namespace.metadata, "status.namespace.metadata") ?? {}; @@ -1285,6 +1414,9 @@ function summarizeRemoteStatus(parsed: Record, target: TestTarg phase: status.phase ?? null, }; }), + database: { external: true, configRef: target.database.configRef, ...secretSourceSummary(configPath, target.database.connection) }, + exposure: { hostIP: target.exposure.hostIP, port: target.exposure.port, serviceName: target.exposure.serviceName }, + observability: { otlpEndpoint: target.observability.otlpEndpoint, prometheusScrape: target.observability.prometheusScrape, blocking: false }, }, valuesPrinted: false, }; @@ -1313,7 +1445,7 @@ function renderText(payload: Record): string { `PIKAOA TEST TARGET ${String(payload.action ?? "").split(" ").at(-1)?.toUpperCase() ?? ""} (${payload.ok === false ? "blocked" : "ok"})`, `configured=${String(payload.configured === true)} mutation=${String(payload.mutation === true)} config=${String(payload.configPath ?? "-")}`, `target=${String(target?.id ?? "-")} node=${String(target?.node ?? "-")} validationOnly=${String(target?.validationOnly ?? "-")}`, - `instance=${String(instance?.id ?? "-")} namespace=${String(instance?.namespace ?? "-")}`, + `instance=${String(instance?.id ?? "-")} namespace=${String(instance?.namespace ?? "-")} step=${String(payload.step ?? "all")}`, ...(payload.code === undefined ? [] : [`code=${String(payload.code)} jobId=${String(payload.jobId ?? "-")}`]), ...(status === null ? [] : [`status=${status.remoteQueried === false ? String(status.reason ?? "local") : String(status.code ?? "unknown")} phase=${String(task?.phase ?? "-")}`]), `warnings=${warnings.length} blockers=${blockers.length} valuesPrinted=false`, @@ -1330,9 +1462,9 @@ function targetSummary(target: TestTargetSpec): Record { id: target.id, node: target.node, route: target.route, - dedicated: target.dedicated, validationOnly: target.validationOnly, - namespacePrefix: target.namespacePrefix, + namespace: target.namespace, + exposure: target.exposure, ttlSeconds: target.ttlSeconds, taskState: target.taskState, cleanup: target.cleanup, @@ -1345,19 +1477,23 @@ function sourceSummary(target: TestTargetSpec, commit: string | null, context?: repository: target.source.repository, commitPolicy: target.source.commitPolicy, commit, - images: context === undefined ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, migrationTemplate: target.source.migrationImage } : { api: context.apiImage, worker: context.workerImage, migration: context.migrationImage }, + images: context === undefined + ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, webTemplate: target.source.webImage, migrationTemplate: target.source.migrationImage } + : { api: context.apiImage, worker: context.workerImage, web: context.webImage, migration: context.migrationImage }, }; } function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Record { const configFlag = options.configPath === DEFAULT_CONFIG_PATH ? "" : ` --config ${displayPath(options.configPath)}`; const targetFlag = `--target ${target.id}`; - const instance = options.instanceId ?? ""; + const instance = options.instanceId ?? "default"; const commit = options.commit ?? ""; + const step = options.step; + const commitFlag = step === "foundation" ? "" : ` --commit ${commit}`; return { - plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance} --commit ${commit}`, + plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step}`, status: `bun scripts/cli.ts pikaoa test-target status${configFlag} ${targetFlag} --instance ${instance}`, - start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance} --commit ${commit} --confirm`, + start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step} --confirm`, stop: `bun scripts/cli.ts pikaoa test-target stop${configFlag} ${targetFlag} --instance ${instance} --confirm`, }; } @@ -1365,8 +1501,7 @@ function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Recor function missingMutationFields(options: TestTargetOptions, selection: Selection): string[] { return [ ...(selection.target === null ? ["--target"] : []), - ...(options.instanceId === null ? ["--instance"] : []), - ...((options.action === "plan" || options.action === "start") && options.commit === null ? ["--commit"] : []), + ...((options.action === "plan" || options.action === "start") && options.step !== "foundation" && options.commit === null ? ["--commit"] : []), ]; } @@ -1398,35 +1533,59 @@ function objectSummary(object: Record): Record const metadata = optionalRecord(object.metadata, "metadata") ?? {}; const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; const spec = optionalRecord(object.spec, "spec") ?? {}; - const selector = optionalRecord(spec.selector, "spec.selector") ?? {}; + const template = optionalRecord(spec.template, "spec.template") ?? {}; + const podSpec = optionalRecord(template.spec, "spec.template.spec") ?? {}; + const securityContext = optionalRecord(podSpec.securityContext, "spec.template.spec.securityContext") ?? {}; return { kind: object.kind ?? null, name: metadata.name ?? null, - namespace: metadata.namespace ?? null, - ownershipLabelsPresent: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string", - selector: selector.matchLabels ?? selector, + owned: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string", + podFsGroup: securityContext.fsGroup, secretValuesRedacted: object.kind === "Secret" ? true : undefined, }; } function structuralFingerprint(manifest: Record[]): string { - const redacted = manifest.map((object) => object.kind === "Secret" ? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, ""])) } : object); - return `sha256:${createHash("sha256").update(JSON.stringify(redacted)).digest("hex")}`; + return manifestFingerprint(manifest, false); +} + +function requestStructuralFingerprint(manifest: Record[]): string { + return manifestFingerprint(manifest, true); +} + +function manifestFingerprint(manifest: Record[], stableRequest: boolean): string { + const normalized = manifest.map((object) => { + let value = object.kind === "Secret" + ? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, ""])) } + : object; + if (!stableRequest) return value; + const metadata = optionalRecord(value.metadata, "metadata"); + const annotations = metadata === null ? null : optionalRecord(metadata.annotations, "metadata.annotations"); + if (metadata === null || annotations === null || annotations["pikaoa.unidesk.io/expires-at"] === undefined) return value; + const stableAnnotations = { ...annotations }; + delete stableAnnotations["pikaoa.unidesk.io/expires-at"]; + value = { ...value, metadata: { ...metadata, annotations: stableAnnotations } }; + return value; + }); + return `sha256:${createHash("sha256").update(JSON.stringify(normalized)).digest("hex")}`; } function placeholderSecrets(): SecretMaterial { - return { databasePassword: "", sessionSecret: "", administratorPassword: "", employeePassword: "" }; + return { databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require", sessionSecret: "", administratorPassword: "", employeePassword: "" }; } function secretSources(target: TestTargetSpec): SecretSourceSpec[] { - return [target.database.password, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password]; + return [target.database.connection, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password]; } -function secretSource(value: unknown, purpose: string, path: string): SecretSourceSpec { +function secretSource(value: unknown, purpose: string, path: string, requireSourceKey = false): SecretSourceSpec { const root = record(value, path); const targetKey = nonEmpty(root.targetKey, `${path}.targetKey`); if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(targetKey)) throw new Error(`${path}.targetKey 必须是合法 Secret key`); - return { purpose, sourceRef: nonEmpty(root.sourceRef, `${path}.sourceRef`), targetKey }; + const sourceKey = nullableString(root.sourceKey, `${path}.sourceKey`); + if (requireSourceKey && sourceKey === null) throw new Error(`${path}.sourceKey 必须显式声明`); + if (sourceKey !== null && !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(sourceKey)) throw new Error(`${path}.sourceKey 必须是合法 env key`); + return { purpose, sourceRef: nonEmpty(root.sourceRef, `${path}.sourceRef`), sourceKey, targetKey }; } function deliveryNamespaces(root: Record): string[] { @@ -1521,6 +1680,19 @@ function httpPath(value: unknown, path: string): string { return result; } +function envName(value: unknown, path: string): string { + const result = nonEmpty(value, path); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${path} 必须是合法环境变量名`); + return result; +} + +function ipv4Address(value: unknown, path: string): string { + const result = nonEmpty(value, path); + const octets = result.split("."); + if (octets.length !== 4 || octets.some((octet) => !/^(?:0|[1-9][0-9]{0,2})$/u.test(octet) || Number(octet) > 255)) throw new Error(`${path} 必须是 IPv4 地址`); + return result; +} + function duration(value: unknown, path: string): string { const result = nonEmpty(value, path); if (!/^[1-9][0-9]*(?:ms|s|m|h)$/u.test(result)) throw new Error(`${path} 必须是正 duration`);