diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml index 273558cf..5d0dff99 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,21 +33,23 @@ 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-database-url.env + sourceKey: DATABASE_URL + targetKey: DATABASE_URL runtime: secretName: pikaoa-test-runtime apiPort: 8080 workerMetricsPort: 8081 + webPort: 8080 attachment: storageRoot: /var/lib/pikaoa/attachments maxBytes: 104857600 @@ -86,6 +87,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 +108,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..762f51bd 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -27,9 +27,110 @@ 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 + apiPort: 8080 + workerMetricsPort: 8081 + webPort: 8080 + attachment: + storageRoot: /var/lib/pikaoa/attachments + maxBytes: 104857600 + claimName: pikaoa-attachments + storageRequest: 10Gi + storageClassName: null + 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: ~/.unidesk/.env/pikaoa-session-secret.txt + 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/scripts/src/pikaoa-test-target-async.test.ts b/scripts/src/pikaoa-test-target-async.test.ts index 9b79999b..72ab5103 100644 --- a/scripts/src/pikaoa-test-target-async.test.ts +++ b/scripts/src/pikaoa-test-target-async.test.ts @@ -21,7 +21,7 @@ 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-database-url.env", `DATABASE_URL=postgresql://pikaoa_test:${secretValues[0]}@db.example.invalid:5432/pikaoa_test?sslmode=require`], ["pikaoa-test-admin-password.txt", secretValues[1]], ["pikaoa-test-employee-password.txt", secretValues[2]], ["pikaoa-test-session-secret.txt", secretValues[3]], @@ -53,18 +53,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 +90,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 +139,20 @@ 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 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 +161,21 @@ 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.deepEqual(plan.exposure, { serviceType: "NodePort", serviceName: "pikaoa-web", hostIP: "192.0.2.10", port: 32080 }); + + 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 +183,27 @@ 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"); 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,28 +211,28 @@ 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); 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 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 }); } @@ -245,7 +258,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-database-url.env", + sourceKey: "DATABASE_URL", + targetKey: "DATABASE_URL", + valuesPrinted: false, + }, dsnParameterPresence: { sslmode: true, search_path: true }, valuesPrinted: false, }); @@ -277,9 +299,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..6054e266 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" | "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,21 +56,22 @@ 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; apiPort: number; workerMetricsPort: number; + webPort: number; attachment: { storageRoot: string; maxBytes: number; @@ -92,6 +95,12 @@ interface TestTargetSpec { backoffLimit: number; command: string[]; }; + exposure: { + serviceName: string; + serviceType: "NodePort"; + hostIP: string; + port: number; + }; observability: { otlpEndpoint: string; prometheusScrape: boolean; @@ -105,6 +114,8 @@ interface TestTargetSpec { apiReadinessPath: string; workerLivenessPath: string; workerReadinessPath: string; + webLivenessPath: string; + webReadinessPath: string; }; } @@ -133,15 +144,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 +176,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 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 只提交具名远程任务并立即返回;status 用短连接读取阶段、终态和有界事件尾部。", + "start 可按 migration、api、worker、web 或 all 单步提交并立即返回;status 用短连接读取有界状态。", "正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。", "Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。", ], @@ -201,17 +214,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", "migration", "api", "worker", "web"]); else { if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]); output = value; @@ -228,7 +243,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 +291,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 +308,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,21 +329,22 @@ 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), 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), attachment: { storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`), maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER), @@ -357,6 +374,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 +393,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); @@ -385,6 +410,12 @@ 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,7 +463,7 @@ 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,12 +492,14 @@ function renderedPlan(selection: Selection, context: RenderContext): Record 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 +645,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 +657,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", @@ -645,38 +679,18 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: ...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }), }, }, - { - 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 } }, 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 +721,14 @@ 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, {}, [], target.probes.webLivenessPath, target.probes.webReadinessPath), ]; } function deployment( target: TestTargetSpec, context: RenderContext, - component: "api" | "worker", + component: "api" | "worker" | "web", image: string, selector: Record, port: number, @@ -732,16 +747,16 @@ function deployment( spec: { 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 +766,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 +798,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 +833,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,6 +873,36 @@ 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 requestId = createHash("sha256") @@ -853,6 +911,8 @@ function startScript(target: TestTargetSpec, context: RenderContext, manifest: R .update(context.instanceId) .update("\0") .update(context.commit) + .update("\0") + .update(context.step) .digest("hex"); const runnerEncoded = Buffer.from(startRunnerScript(target, context, manifest, job), "utf8").toString("base64"); return ` @@ -918,13 +978,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") @@ -958,12 +1018,17 @@ printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"jo 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; + return optionalRecord(object.metadata, "metadata")?.name === `pikaoa-${name}`; + }); + const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment" && object.kind !== "Service"); const encode = (objects: Record[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); const foundationEncoded = encode(foundation); 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 +1042,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 +1058,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, @@ -1041,7 +1107,9 @@ if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statu fi printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.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 +1117,28 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar failure_code=apply-failed exit 43 fi -current_phase=postgres-rollout -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 - 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 +1155,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 +1294,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 +1333,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 +1364,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 +1381,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 +1396,22 @@ 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; return { plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance} --commit ${commit}`, 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} --commit ${commit} --step ${step} --confirm`, stop: `bun scripts/cli.ts pikaoa test-target stop${configFlag} ${targetFlag} --instance ${instance} --confirm`, }; } @@ -1365,7 +1419,6 @@ 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"] : []), ]; } @@ -1397,14 +1450,10 @@ function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): R 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") ?? {}; 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", secretValuesRedacted: object.kind === "Secret" ? true : undefined, }; } @@ -1415,18 +1464,21 @@ function structuralFingerprint(manifest: Record[]): string { } 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 +1573,13 @@ function httpPath(value: unknown, path: string): string { 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`);