From d5f4a4c13b97d2f363f3ea13a32db89cbe603b1c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 15 Jul 2026 04:08:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(pikaoa):=20=E8=A1=A5=E9=BD=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20Secret=20=E5=88=9D=E5=A7=8B=E5=8C=96=E9=97=AD?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/fixtures/pikaoa-test-target.yaml | 5 +- config/pikaoa.yaml | 3 +- config/secrets-distribution.yaml | 33 ++++ .../R1.5.9.5_Task_Report.md | 52 +++++ docs/MDTODO/pikaoa-enterprise-platform.md | 2 +- docs/reference/pikaoa.md | 180 ++++++++++-------- scripts/src/pikaoa-test-target-async.test.ts | 23 ++- scripts/src/pikaoa-test-target.ts | 74 +++++-- 8 files changed, 264 insertions(+), 108 deletions(-) create mode 100644 docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.5_Task_Report.md diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml index 98218fcd..9b17b945 100644 --- a/config/fixtures/pikaoa-test-target.yaml +++ b/config/fixtures/pikaoa-test-target.yaml @@ -42,7 +42,7 @@ testRuntime: username: pikaoa_test schema: pikaoa connection: - sourceRef: ./secrets/pikaoa-test-database-url.env + sourceRef: ./secrets/pikaoa-test.env sourceKey: DATABASE_URL targetKey: DATABASE_URL runtime: @@ -79,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 diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index 8e88b6b2..e3a4a12a 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -98,7 +98,8 @@ testRuntime: sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD sessionSecret: - sourceRef: ~/.unidesk/.env/pikaoa-session-secret.txt + sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env + sourceKey: PIKAOA_SESSION_SECRET targetKey: PIKAOA_SESSION_SECRET migration: jobName: pikaoa-migrate 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..6ea0dea3 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.9.5_Task_Report.md @@ -0,0 +1,52 @@ +# 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 脱敏。 +- `docs/reference/pikaoa.md`: + - 删除专用测试集群、动态 namespace 和临时 PostgreSQL 旧设计; + - 写明 foundation、Secret sync、migration、api、worker、web 顺序; + - 写明先稳定 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。 +- `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..d90bc0ab 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -1,99 +1,117 @@ -# 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` 查询终态。 +- 相同任务重复提交只返回已有任务状态,不并行创建第二任务。 +- 迁移、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 4f5b0a76..1290698f 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-url.env", `DATABASE_URL=postgresql://pikaoa_test:${secretValues[0]}@db.example.invalid:5432/pikaoa_test?sslmode=require`], + ["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"); @@ -232,7 +231,23 @@ exit 64 assert.equal(stopped.mutation, true); 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 serialized = JSON.stringify({ submitted, succeeded, failed, stopped, foundationSubmitted, foundationSucceeded }); for (const value of secretValues) assert.equal(serialized.includes(value), false); assert.equal(serialized.includes("postgresql://pikaoa_test:"), false); } finally { @@ -265,7 +280,7 @@ test("validation-only fixture never invokes remote capture", async () => { username: "pikaoa_test", schema: "pikaoa", connection: { - sourceRef: "./secrets/pikaoa-test-database-url.env", + sourceRef: "./secrets/pikaoa-test.env", sourceKey: "DATABASE_URL", targetKey: "DATABASE_URL", valuesPrinted: false, diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index 87a38dce..58bd6297 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -11,7 +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" | "migration" | "api" | "worker" | "web"; +type TestTargetStep = "all" | "foundation" | "migration" | "api" | "worker" | "web"; interface TestTargetOptions { action: TestTargetAction; @@ -183,14 +183,14 @@ export function pikaoaTestTargetHelp(): Record { 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 [--step migration|api|worker|web|all] --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 在远端调用前失败。", "validationOnly target 只用于本地渲染,不连接 route。", - "start 可按 migration、api、worker、web 或 all 单步提交并立即返回;status 用短连接读取有界状态。", + "start 可按 foundation、migration、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。", "正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。", "Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。", ], @@ -229,7 +229,7 @@ function parseOptions(args: string[]): TestTargetOptions { 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", "migration", "api", "worker", "web"]); + 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; @@ -413,17 +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") { + 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), @@ -432,7 +432,9 @@ function planPayload(options: TestTargetOptions, selection: Selection): Record secretSourceSummary(selection.configPath, source)), }, @@ -557,18 +560,24 @@ async function statusResult(config: UniDeskConfig, options: TestTargetOptions, s async function startResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): Promise { 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; @@ -614,7 +623,7 @@ async function stopResult(config: UniDeskConfig, options: TestTargetOptions, sel function renderContext(options: TestTargetOptions, target: TestTargetSpec, requireCommit = true): RenderContext | null { if (requireCommit && options.commit === null) return null; const instanceId = options.instanceId ?? "default"; - const commit = options.commit ?? "not-required-for-stop"; + const commit = options.commit ?? (options.step === "foundation" ? "foundation" : "not-required-for-stop"); const expiresAt = new Date(Date.now() + target.ttlSeconds * 1000).toISOString(); return { instanceId, @@ -642,7 +651,7 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的专用测试 target。" }); return blockers; } - 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: "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。` }); @@ -735,6 +744,19 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: ]; } +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, @@ -1035,9 +1057,11 @@ function startRunnerScript(target: TestTargetSpec, context: RenderContext, manif const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; return labels["app.kubernetes.io/component"] === name; }); - const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment" && object.kind !== "Service"); + 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 apiEncoded = encode(workload("api")); const workerEncoded = encode(workload("worker")); @@ -1119,6 +1143,7 @@ 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(apiEncoded)} | base64 -d >"$tmp/api.yaml" printf '%s' ${shQuote(workerEncoded)} | base64 -d >"$tmp/worker.yaml" @@ -1130,6 +1155,16 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar failure_code=apply-failed exit 43 fi +if [ "$step" = foundation ]; then + exit 0 +fi +current_phase=runtime-secret-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/runtime.yaml" >"$tmp/runtime-apply.out" 2>"$tmp/runtime-apply.err"; then + failure_code=runtime-secret-apply-failed + exit 44 +fi if [ "$step" = all ] || [ "$step" = migration ]; then current_phase=migration-apply write_status running "$current_phase" start-running null @@ -1421,10 +1456,11 @@ function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Recor 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} --step ${step} --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`, }; } @@ -1432,7 +1468,7 @@ function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Recor function missingMutationFields(options: TestTargetOptions, selection: Selection): string[] { return [ ...(selection.target === null ? ["--target"] : []), - ...((options.action === "plan" || options.action === "start") && options.commit === null ? ["--commit"] : []), + ...((options.action === "plan" || options.action === "start") && options.step !== "foundation" && options.commit === null ? ["--commit"] : []), ]; }