diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml index 93b44620..273558cf 100644 --- a/config/fixtures/pikaoa-test-target.yaml +++ b/config/fixtures/pikaoa-test-target.yaml @@ -40,6 +40,7 @@ testRuntime: image: postgres:16-alpine name: pikaoa_test username: pikaoa_test + schema: pikaoa port: 5432 password: sourceRef: ./secrets/pikaoa-test-database-password.txt diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md index d8622530..89c78cd5 100644 --- a/docs/reference/pikaoa.md +++ b/docs/reference/pikaoa.md @@ -50,6 +50,10 @@ bun scripts/cli.ts pikaoa test-target stop --target --instance --c - `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 声明读取; @@ -69,10 +73,11 @@ bun scripts/cli.ts pikaoa test-target stop --target --instance --c - 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`。 -- API 和 Worker 从同一 Secret 挂载严格的 PikaOA runtime YAML。 +- 迁移 Job、API 和 Worker 从同一 Secret key 挂载严格的 PikaOA runtime YAML,数据库 schema 与 `search_path` 因此保持一致。 - OTel endpoint、Prometheus scrape、指标路径和探针全部由 target YAML 声明。 - 配置一致性、镜像/commit 漂移和 OTel exporter 失败只产生 `blocking=false` warning,不作为 MVP 门禁。 diff --git a/scripts/src/pikaoa-test-target-async.test.ts b/scripts/src/pikaoa-test-target-async.test.ts index c45faf27..9b79999b 100644 --- a/scripts/src/pikaoa-test-target-async.test.ts +++ b/scripts/src/pikaoa-test-target-async.test.ts @@ -239,6 +239,44 @@ test("validation-only fixture never invokes remote capture", async () => { assert.equal(payload.remoteQueried, false); assert.equal((payload.status as Record).remoteQueried, false); + const planResult = await runPikaoaCommand({} as UniDeskConfig, [ + "test-target", "plan", "--config", "config/fixtures/pikaoa-test-target.yaml", "--target", "TEST01", "--instance", "validation-only", "--commit", "0123456789abcdef", "--output", "json", + ], forbiddenCapture); + const planPayload = JSON.parse(planResult.renderedText) as Record; + const plan = planPayload.plan as Record; + assert.deepEqual(plan.database, { + schema: "pikaoa", + dsnParameterPresence: { sslmode: true, search_path: true }, + valuesPrinted: false, + }); + assert.deepEqual(plan.runtimeConfig, { + secretName: "pikaoa-test-runtime", + key: "pikaoa.yaml", + mountPath: "/etc/pikaoa/pikaoa.yaml", + consumers: ["migration", "api", "worker"], + valuesPrinted: false, + }); + assert.equal(planPayload.valuesPrinted, false); + assert.equal(JSON.stringify(planPayload).includes("fixture-db-password"), false); + assert.equal(JSON.stringify(planPayload).includes("postgres://"), false); + + const missingSchemaRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-missing-schema-")); + const missingSchemaPath = join(missingSchemaRoot, "pikaoa.yaml"); + const missingSchemaFixture = readFileSync(rootPath("config", "fixtures", "pikaoa-test-target.yaml"), "utf8") + .replace(" schema: pikaoa\n", ""); + writeFileSync(missingSchemaPath, missingSchemaFixture); + try { + await assert.rejects( + runPikaoaCommand({} as UniDeskConfig, [ + "test-target", "status", "--config", missingSchemaPath, "--target", "TEST01", "--instance", "missing-schema", "--output", "json", + ], forbiddenCapture), + /testRuntime\.targets\.TEST01\.database\.schema 必须是非空字符串/u, + ); + assert.equal(calls, 0); + } finally { + 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); diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts index ffd15d54..4235704f 100644 --- a/scripts/src/pikaoa-test-target.ts +++ b/scripts/src/pikaoa-test-target.ts @@ -61,6 +61,7 @@ interface TestTargetSpec { image: string; name: string; username: string; + schema: string; port: number; password: SecretSourceSpec; }; @@ -319,6 +320,7 @@ function parseTarget(id: string, root: Record, configLabel: str image: imageReference(database.image, `${path}.database.image`), 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`), }, @@ -447,6 +449,14 @@ function renderedPlan(selection: Selection, context: RenderContext): Record ({ ...source, presence: "unchecked", fingerprint: null, valuesPrinted: false })), }, + database: databasePlanSummary(target), + runtimeConfig: { + secretName: target.runtime.secretName, + key: "pikaoa.yaml", + mountPath: "/etc/pikaoa/pikaoa.yaml", + consumers: ["migration", "api", "worker"], + valuesPrinted: false, + }, probes: { api: { liveness: target.probes.apiLivenessPath, readiness: target.probes.apiReadinessPath, metrics: target.observability.apiMetricsPath }, worker: { liveness: target.probes.workerLivenessPath, readiness: target.probes.workerReadinessPath, metrics: target.observability.workerMetricsPath }, @@ -741,7 +751,7 @@ function deployment( } function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record { - const databaseURL = `postgres://${encodeURIComponent(target.database.username)}:${encodeURIComponent(secrets.databasePassword)}@pikaoa-postgres:${target.database.port}/${encodeURIComponent(target.database.name)}?sslmode=disable`; + const databaseURL = renderDatabaseURL(target, secrets.databasePassword); return { [target.database.password.targetKey]: secrets.databasePassword, [target.runtime.sessionSecret.targetKey]: secrets.sessionSecret, @@ -773,6 +783,29 @@ 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"); + databaseURL.searchParams.set("search_path", target.database.schema); + return databaseURL.toString(); +} + +function databasePlanSummary(target: TestTargetSpec): Record { + const databaseURL = new URL(renderDatabaseURL(target, "")); + return { + schema: target.database.schema, + dsnParameterPresence: { + sslmode: databaseURL.searchParams.has("sslmode"), + search_path: databaseURL.searchParams.has("search_path"), + }, + valuesPrinted: false, + }; +} + function readSecretMaterial(selection: Selection, target: TestTargetSpec): { ok: boolean; values: SecretMaterial;