diff --git a/AGENTS.md b/AGENTS.md index c1f31528..61f61f10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,5 +145,6 @@ - OTel/可观测性: `docs/reference/observability.md`。 - YAML-first: `docs/reference/yaml-first-ops.md`。 - Platform infrastructure: `docs/reference/platform-infra.md`。 +- PikaOA 临时测试运行面: `docs/reference/pikaoa.md`。 - Webterm 配置与受控部署: `docs/reference/webterm.md`。 - Secretary: `docs/reference/secretary-reference.md`。 diff --git a/config/fixtures/pikaoa-test-target.yaml b/config/fixtures/pikaoa-test-target.yaml new file mode 100644 index 00000000..e3bc8618 --- /dev/null +++ b/config/fixtures/pikaoa-test-target.yaml @@ -0,0 +1,88 @@ +version: 1 +kind: pikaoa-platform-delivery + +metadata: + id: pikaoa-enterprise-fixture + owner: unidesk + repository: pikainc/pikaoa + spec: PJ2026-03 + +testRuntime: + defaultTargetId: TEST01 + targets: + TEST01: + enabled: true + validationOnly: true + dedicated: true + node: TEST01 + route: TEST01:k3s + namespacePrefix: pikaoa-test + ttlSeconds: 7200 + waitTimeoutSeconds: 180 + fieldManager: unidesk-pikaoa-test-target + cleanup: + mode: delete-namespace + requireOwnershipLabels: true + source: + mode: prebuilt-images + repository: pikainc/pikaoa + commitPolicy: cli-required + images: + api: registry.invalid/pikaoa/api:${commit} + worker: registry.invalid/pikaoa/worker:${commit} + pullPolicy: IfNotPresent + database: + image: postgres:16-alpine + name: pikaoa_test + username: pikaoa_test + port: 5432 + password: + sourceRef: ./secrets/pikaoa-test-database-password.txt + targetKey: PIKAOA_DATABASE_PASSWORD + runtime: + secretName: pikaoa-test-runtime + apiPort: 8080 + workerMetricsPort: 8081 + workerInterval: 2s + outbox: + batchSize: 50 + maxAttempts: 4 + retryDelay: 2s + sessionTTL: 8h + shutdownTimeout: 10s + administrator: + username: admin + password: + sourceRef: ./secrets/pikaoa-test-admin-password.txt + targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD + employee: + username: employee + password: + sourceRef: ./secrets/pikaoa-test-employee-password.txt + targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD + sessionSecret: + sourceRef: ./secrets/pikaoa-test-session-secret.txt + targetKey: PIKAOA_SESSION_SECRET + observability: + otlpEndpoint: http://otel-collector.observability.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 + +delivery: + targets: + PROD: + namespace: pikaoa + ci: + namespace: pikaoa-ci diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml index c7dd7e55..a95d7998 100644 --- a/config/pikaoa.yaml +++ b/config/pikaoa.yaml @@ -26,6 +26,11 @@ modules: events: transactional-outbox workers: named-consumer +testRuntime: + # 专用测试集群 target 由用户明确声明后才能启用;空对象表示当前不允许远端测试运行。 + defaultTargetId: null + targets: {} + delivery: targets: NC01: diff --git a/docs/reference/pikaoa.md b/docs/reference/pikaoa.md new file mode 100644 index 00000000..9c7d8d12 --- /dev/null +++ b/docs/reference/pikaoa.md @@ -0,0 +1,72 @@ +# PikaOA 开发与测试运行面 + +## 配置真相 + +- PikaOA 平台配置唯一真相是 `config/pikaoa.yaml`。 +- 正式交付与临时测试运行面职责分离: + - `delivery.targets` 只描述正式 CI/CD、GitOps、运行 namespace、数据库和公网暴露; + - `testRuntime.targets` 只描述专用测试集群上的临时后端运行面。 +- 禁止把现有节点推断成 PikaOA 专用测试集群。 +- `testRuntime.targets` 为空时表示当前没有获准的测试运行面: + - `plan` 和 `status` 返回 `configured=false`、`mutation=false`; + - `start` 和 `stop` 在任何远端调用前失败。 + +## 受控入口 + +```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 +``` + +- `plan` 是本地只读渲染入口,不接受 `--dry-run`。 +- `start` 和 `stop` 只在显式 `--confirm` 后执行。 +- `--config ` 可用于 fixture 或本地 override;`validationOnly=true` 的 target 永远不连接 route。 +- 默认输出是紧凑文本;`--output json` 或 `--json` 输出单一 JSON 文档。 + +## 临时运行面 + +- namespace 固定由 YAML `namespacePrefix` 和 CLI `--instance` 组成。 +- namespace、API、Worker、临时 PostgreSQL、Service 和 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。 +- 正式 namespace 保护集合来自以下 YAML 字段: + - `delivery.targets.*.namespace`; + - `delivery.targets.*.ci.namespace`。 +- 测试入口不得渲染或删除保护集合中的任何对象。 +- PostgreSQL 使用 namespace 内的临时存储,namespace 删除后测试数据随之清理。 +- TTL 和清理策略来自 target YAML: + - TTL 写入 namespace annotation; + - 当前 `cleanup.mode=delete-namespace`,由显式 `stop` 执行; + - `cleanup.requireOwnershipLabels=true` 时,删除前必须校验归属标签; + - 不建立额外控制器或长期状态库。 + +## Secret 与可观测性 + +- Secret 值只从 YAML `sourceRef` 读取并写入目标 Secret。 +- CLI 输出只显示 `sourceRef`、`targetKey`、presence 和 fingerprint,始终保持 `valuesPrinted=false`。 +- Secret 缺失会在连接测试集群前阻塞 `start`。 +- API 和 Worker 从同一 Secret 挂载严格的 PikaOA runtime YAML。 +- OTel endpoint、Prometheus scrape、指标路径和探针全部由 target YAML 声明。 +- 配置一致性、镜像/commit 漂移和 OTel exporter 失败只产生 `blocking=false` warning,不作为 MVP 门禁。 + +## 本地验收 + +`config/fixtures/pikaoa-test-target.yaml` 只用于本地验证: + +- 证明 namespace、选择器、工作负载和 Secret 引用的渲染结果; +- 证明 TTL、OTel、Prometheus 和正式 namespace 保护; +- fixture target 固定为 `validationOnly=true`,不得用于真实集群操作。 + +```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 +``` diff --git a/scripts/cli.ts b/scripts/cli.ts index 32c9102c..1a3b8229 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -428,6 +428,16 @@ async function main(): Promise { return; } + if (top === "pikaoa") { + const { runPikaoaCommand } = await import("./src/pikaoa-test-target"); + const result = await runPikaoaCommand(readConfig(), args.slice(1)); + if (isRenderedCliResult(result)) { + emitText(result.renderedText, result.command || commandName); + if (!result.ok) process.exitCode = 1; + return; + } + } + if (top === "platform-db") { const result = await runPlatformDbCommand(readConfig(), args.slice(1)); const ok = (result as { ok?: unknown }).ok !== false; diff --git a/scripts/src/help.ts b/scripts/src/help.ts index d7af69c8..f1795a03 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -75,6 +75,7 @@ export function rootHelp(): unknown { { command: "hwlab nodes control-plane|git-mirror|hwpod-preinstall|secret|test-accounts --node NC01 --lane v03", description: "Operate the NC01 HWLAB v0.3 development and deployment lane from YAML source truth." }, { command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." }, { command: "platform-infra sub2api|langbot|n8n|webterm|wechat-archive ...", description: "Deploy platform-infra services such as Sub2API, LangBot, n8n and Web Terminal, manage YAML-controlled public Caddy/FRP exposure and WeChat archive workflows, and inspect status/logs without printing secrets." }, + { command: "pikaoa test-target plan|status|start|stop", description: "管理 YAML-first 专用测试集群中的 PikaOA 临时后端运行面;不经过 CI/CD,也不触碰正式 namespace。" }, { command: "secrets plan|sync|status", description: "Plan, push and inspect YAML-declared local secret source keys to Kubernetes Secrets without printing or reverse-engineering values." }, { command: "platform-db postgres plan|status|export-secrets|apply", description: "Manage YAML-declared host-native PostgreSQL 16 on PK01 for Sub2API/platform state, with PostgreSQL native TLS and redacted secret exports." }, { command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Archived HWLAB DEV CD diagnostics only; current HWLAB rollout uses NC01 GitOps." }, @@ -1048,6 +1049,7 @@ export async function staticNamespaceHelp(args: string[]): Promise (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary()); + if (top === "pikaoa") return loadHelp(async () => (await import("./pikaoa-test-target")).pikaoaTestTargetHelp(), { command: "pikaoa test-target", source: "config/pikaoa.yaml#testRuntime" }); if (top === "platform-db") return platformDbHelp(); if (top === "secrets") return secretsHelp(); if (top === "web-probe") return loadHelp(async () => (await import("./web-probe")).webProbeHelp(), webProbeHelpSummary()); diff --git a/scripts/src/pikaoa-test-target.ts b/scripts/src/pikaoa-test-target.ts new file mode 100644 index 00000000..5c1d27cd --- /dev/null +++ b/scripts/src/pikaoa-test-target.ts @@ -0,0 +1,1078 @@ +// SPEC: pikasTech/unidesk#2046 PikaOA YAML-first dedicated test runtime. +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; +import type { UniDeskConfig } from "./config"; +import { repoRoot, rootPath } from "./config"; +import { renderMachine } from "./cicd-render"; +import type { RenderedCliResult } from "./output"; +import { CliInputError } from "./output"; +import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library"; + +type TestTargetAction = "plan" | "status" | "start" | "stop"; + +interface TestTargetOptions { + action: TestTargetAction; + configPath: string; + targetId: string | null; + instanceId: string | null; + commit: string | null; + confirm: boolean; + output: "text" | "json"; +} + +interface SecretSourceSpec { + purpose: string; + sourceRef: string; + targetKey: string; +} + +interface TestTargetSpec { + id: string; + enabled: boolean; + validationOnly: boolean; + dedicated: boolean; + node: string; + route: string; + namespacePrefix: string; + ttlSeconds: number; + waitTimeoutSeconds: number; + fieldManager: string; + cleanup: { + mode: "delete-namespace"; + requireOwnershipLabels: true; + }; + source: { + mode: string; + repository: string; + commitPolicy: string; + apiImage: string; + workerImage: string; + pullPolicy: string; + }; + database: { + image: string; + name: string; + username: string; + port: number; + password: SecretSourceSpec; + }; + runtime: { + secretName: string; + apiPort: number; + workerMetricsPort: number; + workerInterval: string; + outboxBatchSize: number; + outboxMaxAttempts: number; + outboxRetryDelay: string; + sessionTTL: string; + shutdownTimeout: string; + administrator: { username: string; password: SecretSourceSpec }; + employee: { username: string; password: SecretSourceSpec }; + sessionSecret: SecretSourceSpec; + }; + observability: { + otlpEndpoint: string; + prometheusScrape: boolean; + apiMetricsPath: string; + workerMetricsPath: string; + exporterWarning: boolean; + exporterBlocking: false; + }; + probes: { + apiLivenessPath: string; + apiReadinessPath: string; + workerLivenessPath: string; + workerReadinessPath: string; + }; +} + +interface Selection { + configPath: string; + configLabel: string; + target: TestTargetSpec | null; + candidateTargetIds: string[]; + protectedNamespaces: string[]; + missingFields: string[]; + warnings: Warning[]; +} + +interface Warning { + code: string; + message: string; + blocking: false; +} + +interface Blocker { + code: string; + field: string; + message: string; +} + +interface RenderContext { + instanceId: string; + commit: string; + namespace: string; + expiresAt: string; + apiImage: string; + workerImage: string; +} + +interface SecretMaterial { + databasePassword: string; + sessionSecret: string; + administratorPassword: string; + employeePassword: string; +} + +const DEFAULT_CONFIG_PATH = rootPath("config", "pikaoa.yaml"); +const MANAGED_BY = "unidesk-pikaoa-test-target"; +const TEST_RUNTIME_LABEL = "pikaoa.unidesk.io/test-runtime"; +const TARGET_LABEL = "pikaoa.unidesk.io/target"; +const INSTANCE_LABEL = "pikaoa.unidesk.io/instance"; + +export function pikaoaTestTargetHelp(): Record { + return { + command: "pikaoa test-target", + description: "通过 YAML-first 专用测试集群 target 管理 PikaOA 临时 k8s 后端运行面,不经过 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 stop --target id --instance id --confirm", + ], + source: "config/pikaoa.yaml#testRuntime", + guarantees: [ + "未声明专用 target 时 plan/status 返回 configured=false,start/stop 在远端调用前失败。", + "validationOnly target 只用于本地渲染,不连接 route。", + "正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。", + "Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。", + ], + }; +} + +export async function runPikaoaCommand(config: UniDeskConfig, args: string[]): Promise { + if (args.length === 0 || args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json"); + if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target", "unsupported-pikaoa-command", "pikaoa", ["test-target"]); + const options = parseOptions(args.slice(1)); + const selection = readSelection(options); + if (options.action === "plan") return renderResult(planPayload(options, selection), options); + if (options.action === "status") return await statusResult(config, options, selection); + if (options.action === "start") return await startResult(config, options, selection); + return await stopResult(config, options, selection); +} + +function parseOptions(args: string[]): TestTargetOptions { + const action = args[0]; + if (action !== "plan" && action !== "status" && action !== "start" && action !== "stop") { + throw inputError("test-target action 必须是 plan、status、start 或 stop", "invalid-action", action ?? "", ["plan", "status", "start", "stop"]); + } + let configPath = DEFAULT_CONFIG_PATH; + let targetId: string | null = null; + let instanceId: string | null = null; + let commit: string | null = null; + 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") { + 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 (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]); + output = value; + } + index += 1; + } else if (arg === "--json") { + output = "json"; + } else if (arg === "--confirm") { + confirm = true; + } else if (arg === "--dry-run") { + throw inputError("test-target 不支持 --dry-run;使用 plan 完成本地只读渲染", "dry-run-unsupported", arg); + } else { + throw inputError(`不支持的 test-target 参数:${arg}`, "unsupported-option", arg); + } + } + if ((action === "plan" || action === "status") && confirm) throw inputError(`${action} 是只读操作,不接受 --confirm`, "confirm-not-allowed", "--confirm"); + return { action, configPath, targetId, instanceId, commit, confirm, output }; +} + +function readSelection(options: TestTargetOptions): Selection { + if (!existsSync(options.configPath)) throw inputError(`找不到 PikaOA owning YAML:${displayPath(options.configPath)}`, "config-not-found", "--config"); + const root = readYamlRecord(options.configPath, "pikaoa-platform-delivery"); + const configLabel = displayPath(options.configPath); + const protectedNamespaces = deliveryNamespaces(root); + const testRuntime = optionalRecord(root.testRuntime, `${configLabel}.testRuntime`); + const targets = testRuntime === null ? {} : optionalRecord(testRuntime.targets, `${configLabel}.testRuntime.targets`) ?? {}; + const targetRecords = Object.entries(targets).filter(([, value]) => isRecord(value)); + const candidateTargetIds = targetRecords.filter(([, value]) => (value as Record).enabled === true).map(([id]) => id).sort(); + const defaultTargetId = testRuntime === null ? null : nullableString(testRuntime.defaultTargetId, `${configLabel}.testRuntime.defaultTargetId`); + const selectedId = options.targetId ?? defaultTargetId ?? (candidateTargetIds.length === 1 ? candidateTargetIds[0]! : null); + const missingFields: string[] = []; + const warnings: Warning[] = []; + if (selectedId === null) { + if (candidateTargetIds.length > 1) missingFields.push("--target"); + return { configPath: options.configPath, configLabel, target: null, candidateTargetIds, protectedNamespaces, missingFields, warnings }; + } + const selected = targets[selectedId]; + if (!isRecord(selected) || selected.enabled !== true) { + warnings.push(warning("target-not-enabled", `testRuntime.targets.${selectedId} 不存在或未启用。`)); + return { configPath: options.configPath, configLabel, target: null, candidateTargetIds, protectedNamespaces, missingFields, warnings }; + } + return { + configPath: options.configPath, + configLabel, + target: parseTarget(selectedId, selected, configLabel), + candidateTargetIds, + protectedNamespaces, + missingFields, + warnings, + }; +} + +function parseTarget(id: string, root: Record, configLabel: string): TestTargetSpec { + const path = `${configLabel}.testRuntime.targets.${id}`; + simpleId(id, "target id"); + const source = record(root.source, `${path}.source`); + const images = record(source.images, `${path}.source.images`); + const database = record(root.database, `${path}.database`); + const runtime = record(root.runtime, `${path}.runtime`); + const outbox = record(runtime.outbox, `${path}.runtime.outbox`); + const administrator = record(runtime.administrator, `${path}.runtime.administrator`); + const employee = record(runtime.employee, `${path}.runtime.employee`); + const observability = record(root.observability, `${path}.observability`); + 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 cleanup = record(root.cleanup, `${path}.cleanup`); + if (exporterFailure.blocking !== false) throw new Error(`${path}.observability.exporterFailure.blocking 必须为 false`); + if (exporterFailure.warning !== true) throw new Error(`${path}.observability.exporterFailure.warning 必须为 true`); + if (cleanup.requireOwnershipLabels !== true) throw new Error(`${path}.cleanup.requireOwnershipLabels 必须为 true`); + const target: TestTargetSpec = { + 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), + 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), + cleanup: { + mode: exactString(cleanup.mode, `${path}.cleanup.mode`, "delete-namespace") as "delete-namespace", + requireOwnershipLabels: true, + }, + source: { + mode: exactString(source.mode, `${path}.source.mode`, "prebuilt-images"), + repository: nonEmpty(source.repository, `${path}.source.repository`), + 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`), + pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]), + }, + database: { + image: imageReference(database.image, `${path}.database.image`), + name: postgresIdentifier(database.name, `${path}.database.name`), + username: postgresIdentifier(database.username, `${path}.database.username`), + port: positiveInteger(database.port, `${path}.database.port`, 65_535), + password: secretSource(database.password, "database.password", `${path}.database.password`), + }, + 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), + workerInterval: duration(runtime.workerInterval, `${path}.runtime.workerInterval`), + outboxBatchSize: positiveInteger(outbox.batchSize, `${path}.runtime.outbox.batchSize`, 1000), + outboxMaxAttempts: positiveInteger(outbox.maxAttempts, `${path}.runtime.outbox.maxAttempts`, 100), + outboxRetryDelay: duration(outbox.retryDelay, `${path}.runtime.outbox.retryDelay`), + sessionTTL: duration(runtime.sessionTTL, `${path}.runtime.sessionTTL`), + shutdownTimeout: duration(runtime.shutdownTimeout, `${path}.runtime.shutdownTimeout`), + administrator: { + username: nonEmpty(administrator.username, `${path}.runtime.administrator.username`), + password: secretSource(administrator.password, "runtime.administrator.password", `${path}.runtime.administrator.password`), + }, + employee: { + username: nonEmpty(employee.username, `${path}.runtime.employee.username`), + password: secretSource(employee.password, "runtime.employee.password", `${path}.runtime.employee.password`), + }, + sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`), + }, + observability: { + otlpEndpoint: nonEmpty(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`), + prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`), + apiMetricsPath: httpPath(prometheus.apiPath, `${path}.observability.prometheus.apiPath`), + workerMetricsPath: httpPath(prometheus.workerPath, `${path}.observability.prometheus.workerPath`), + exporterWarning: boolean(exporterFailure.warning, `${path}.observability.exporterFailure.warning`), + exporterBlocking: false, + }, + probes: { + apiLivenessPath: httpPath(apiProbes.livenessPath, `${path}.probes.api.livenessPath`), + apiReadinessPath: httpPath(apiProbes.readinessPath, `${path}.probes.api.readinessPath`), + workerLivenessPath: httpPath(workerProbes.livenessPath, `${path}.probes.worker.livenessPath`), + workerReadinessPath: httpPath(workerProbes.readinessPath, `${path}.probes.worker.readinessPath`), + }, + }; + const secretKeys = secretSources(target).map((item) => item.targetKey); + if (new Set(secretKeys).size !== secretKeys.length || secretKeys.includes("pikaoa.yaml")) { + throw new Error(`${path} 的 Secret targetKey 必须唯一且不能使用 pikaoa.yaml`); + } + return target; +} + +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 context = renderContext(options, selection.target, requireCommit); + const blockers = safetyBlockers(options, selection, context, requireCommit); + const warnings = [...selection.warnings, ...consistencyWarnings(selection.target, context)]; + const renderWorkloads = context !== null && options.commit !== null && (options.action === "plan" || options.action === "start"); + return { + ...base, + target: targetSummary(selection.target), + instance: context === null ? null : { + id: context.instanceId, + namespace: context.namespace, + ...((options.action === "plan" || options.action === "start") ? { expiresAt: context.expiresAt } : {}), + }, + source: renderWorkloads && context !== null ? sourceSummary(selection.target, context.commit, context) : sourceSummary(selection.target, options.commit), + readyForMutation: blockers.length === 0 && !selection.target.validationOnly, + missingFields: missingMutationFields(options, selection), + blockers, + warnings, + plan: renderWorkloads && context !== null ? renderedPlan(selection, context) : null, + next: nextCommands(options, selection.target), + }; +} + +function basePayload(options: TestTargetOptions, selection: Selection): Record { + return { + ok: true, + action: `pikaoa test-target ${options.action}`, + configured: selection.target !== null, + mutation: false, + configPath: selection.configLabel, + target: null, + candidateTargetIds: selection.candidateTargetIds, + protectedNamespaces: selection.protectedNamespaces, + missingFields: selection.missingFields, + warnings: selection.warnings, + next: selection.target === null ? { + configure: `${selection.configLabel}#testRuntime.targets`, + plan: "bun scripts/cli.ts pikaoa test-target plan --target --instance --commit ", + } : nextCommands(options, selection.target), + valuesPrinted: false, + }; +} + +function renderedPlan(selection: Selection, context: RenderContext): Record { + const target = selection.target!; + const labels = ownershipLabels(target, context); + const selectors = { + api: componentSelector("api", context.instanceId), + worker: componentSelector("worker", context.instanceId), + postgres: componentSelector("postgres", context.instanceId), + }; + const structuralManifest = buildManifest(target, context, placeholderSecrets()); + return { + namespace: { + name: context.namespace, + labels, + annotations: namespaceAnnotations(target, context), + ttlSeconds: target.ttlSeconds, + cleanup: target.cleanup, + }, + objects: structuralManifest.map(objectSummary), + selectors, + secret: { + name: target.runtime.secretName, + valuesPrinted: false, + sources: secretSources(target).map((source) => ({ ...source, presence: "unchecked", fingerprint: null, 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 }, + }, + observability: { + otlpEndpoint: target.observability.otlpEndpoint, + prometheusScrape: target.observability.prometheusScrape, + exporterFailure: { warning: target.observability.exporterWarning, blocking: false }, + }, + manifestFingerprint: structuralFingerprint(structuralManifest), + mutation: false, + }; +} + +async function statusResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection): Promise { + const planned = planPayload(options, selection); + if (selection.target === null) return renderResult(planned, options); + if (selection.target.validationOnly) { + return renderResult({ ...planned, status: { remoteQueried: false, reason: "validation-only-target" } }, options); + } + const context = renderContext(options, selection.target, false); + if (context === null) return renderResult({ ...planned, ok: false, status: { remoteQueried: false, reason: "instance-required" } }, options); + const blockers = safetyBlockers(options, selection, context, false); + if (blockers.length > 0) { + return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers, status: { remoteQueried: false, reason: "preflight-blocked" } }, options); + } + const remote = await capture(config, selection.target.route, ["sh"], statusScript(selection.target, context)); + const parsed = parseJsonOutput(remote.stdout); + const status = parsed === null ? { ok: false, remote: compactCapture(remote, { full: true }) } : summarizeRemoteStatus(parsed, selection.target, context); + return renderResult({ ...planned, ok: remote.exitCode === 0 && status.ok !== false, status: { remoteQueried: true, ...status } }, options); +} + +async function startResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection): Promise { + const planned = planPayload(options, selection); + const context = selection.target === null ? null : renderContext(options, selection.target); + const blockers = safetyBlockers(options, selection, context); + 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); + if (!material.ok) { + return renderResult({ ...planned, ok: false, mutation: false, blockers: material.blockers, secret: { sources: material.sources, valuesPrinted: false }, failure: "secret-preflight-blocked" }, options); + } + const manifest = buildManifest(selection.target, context, material.values); + const remote = await capture(config, selection.target.route, ["sh"], startScript(selection.target, context, manifest)); + const parsed = parseJsonOutput(remote.stdout); + const mutation = parsed?.mutation === true; + return renderResult({ + ...planned, + ok: remote.exitCode === 0 && parsed?.ok === true, + mutation, + mode: mutation ? "confirmed" : "preflight-blocked", + secret: { sources: material.sources, valuesPrinted: false }, + remote: parsed ?? compactCapture(remote, { full: true }), + manifestFingerprint: structuralFingerprint(manifest), + }, options); +} + +async function stopResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection): Promise { + const planned = planPayload(options, selection); + const context = selection.target === null ? null : renderContext(options, selection.target, false); + const blockers = safetyBlockers(options, selection, context, false); + if (!options.confirm) blockers.push({ code: "confirmation-required", field: "--confirm", message: "stop 需要显式 --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 remote = await capture(config, selection.target.route, ["sh"], stopScript(selection.target, context)); + const parsed = parseJsonOutput(remote.stdout); + return renderResult({ + ...planned, + ok: remote.exitCode === 0 && parsed?.ok === true, + mutation: parsed?.mutation === true, + mode: parsed?.mutation === true ? "confirmed" : "no-change", + remote: parsed ?? compactCapture(remote, { full: true }), + }, options); +} + +function renderContext(options: TestTargetOptions, target: TestTargetSpec, requireCommit = true): RenderContext | null { + if (options.instanceId === null || (requireCommit && options.commit === null)) return null; + const commit = options.commit ?? "not-required-for-stop"; + const namespace = `${target.namespacePrefix}-${options.instanceId}`; + const expiresAt = new Date(Date.now() + target.ttlSeconds * 1000).toISOString(); + return { + instanceId: options.instanceId, + commit, + namespace, + expiresAt, + apiImage: renderImage(target.source.apiImage, commit), + workerImage: renderImage(target.source.workerImage, commit), + }; +} + +function safetyBlockers(options: TestTargetOptions, selection: Selection, context: RenderContext | null, requireCommit = true): Blocker[] { + const blockers: Blocker[] = []; + const target = selection.target; + if (target === null) { + blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的专用测试 target。" }); + return blockers; + } + if (!target.dedicated) blockers.push({ code: "target-not-dedicated", field: `testRuntime.targets.${target.id}.dedicated`, message: "target 必须显式声明 dedicated=true。" }); + if (options.instanceId === null) blockers.push({ code: "instance-required", field: "--instance", message: "临时 namespace 必须带显式运行实例 ID。" }); + if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "start 必须选择源码 commit。" }); + if (context !== null) { + if (context.namespace.length > 63 || !isKubernetesName(context.namespace)) blockers.push({ code: "unsafe-namespace", field: "namespacePrefix/--instance", message: `渲染后的 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; +} + +function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] { + const warnings: Warning[] = []; + if (!target.source.apiImage.includes("${commit}") || !target.source.workerImage.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))) 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; +} + +function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record[] { + const labels = ownershipLabels(target, context); + 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 commonPodAnnotations = (path: string, port: number): Record => target.observability.prometheusScrape ? { + "prometheus.io/scrape": "true", + "prometheus.io/path": path, + "prometheus.io/port": String(port), + } : {}; + return [ + { + apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) }, + }, + { + apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque", stringData: secretData, + }, + { + 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 }] }, + }, + deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [ + { name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }, + { name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } }, + { name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username }, + { name: "PIKAOA_BOOTSTRAP_ADMIN_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.administrator.password.targetKey } } }, + { name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username }, + { name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } }, + ], target.probes.apiLivenessPath, target.probes.apiReadinessPath), + 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), + ]; +} + +function deployment( + target: TestTargetSpec, + context: RenderContext, + component: "api" | "worker", + image: string, + selector: Record, + port: number, + annotations: Record, + env: Array>, + livenessPath: string, + readinessPath: string, +): Record { + const labels = ownershipLabels(target, context); + return { + apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } }, + spec: { + replicas: 1, selector: { matchLabels: selector }, + template: { + metadata: { labels: { ...labels, ...selector }, annotations }, + spec: { + containers: [{ + name: component, image, imagePullPolicy: target.source.pullPolicy, env, + ports: [{ name: component === "api" ? "http" : "metrics", containerPort: port }], + volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }], + 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" }] } }], + }, + }, + }, + }; +} + +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`; + return { + [target.database.password.targetKey]: secrets.databasePassword, + [target.runtime.sessionSecret.targetKey]: secrets.sessionSecret, + [target.runtime.administrator.password.targetKey]: secrets.administratorPassword, + [target.runtime.employee.password.targetKey]: secrets.employeePassword, + "pikaoa.yaml": [ + "api:", + ` listen: 0.0.0.0:${target.runtime.apiPort}`, + "worker:", + ` interval: ${target.runtime.workerInterval}`, + ` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`, + " outbox:", + ` batchSize: ${target.runtime.outboxBatchSize}`, + ` maxAttempts: ${target.runtime.outboxMaxAttempts}`, + ` retryDelay: ${target.runtime.outboxRetryDelay}`, + "database:", + ` url: ${JSON.stringify(databaseURL)}`, + "identity:", + ` sessionTTL: ${target.runtime.sessionTTL}`, + "observability:", + ` serviceName: pikaoa-test-${context.instanceId}`, + ` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`, + `shutdownTimeout: ${target.runtime.shutdownTimeout}`, + "", + ].join("\n"), + }; +} + +function readSecretMaterial(selection: Selection, target: TestTargetSpec): { + ok: boolean; + values: SecretMaterial; + sources: Array>; + blockers: Blocker[]; +} { + const sources = secretSources(target); + const values = new Map(); + const summaries: Array> = []; + const blockers: Blocker[] = []; + for (const source of sources) { + const path = resolveSourceRef(selection.configPath, source.sourceRef); + const present = existsSync(path); + const value = present ? readFileSync(path, "utf8").trim() : ""; + 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, + targetKey: source.targetKey, + presence: valid, + fingerprint: valid ? sha256Fingerprint(value) : null, + valuesPrinted: false, + }); + } + return { + ok: blockers.length === 0, + values: { + databasePassword: values.get("database.password") ?? "", + sessionSecret: values.get("runtime.sessionSecret") ?? "", + administratorPassword: values.get("runtime.administrator.password") ?? "", + employeePassword: values.get("runtime.employee.password") ?? "", + }, + sources: summaries, + blockers, + }; +} + +function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record[]): string { + const encoded = Buffer.from(manifest.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); + return ` +set -u +namespace=${shQuote(context.namespace)} +managed_by=${shQuote(MANAGED_BY)} +target_id=${shQuote(target.id)} +instance_id=${shQuote(context.instanceId)} +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +if kubectl get namespace "$namespace" >/dev/null 2>&1; then + actual_managed="$(kubectl get namespace "$namespace" -o jsonpath='{.metadata.labels.app\\.kubernetes\\.io/managed-by}' 2>/dev/null || true)" + actual_target="$(kubectl get namespace "$namespace" -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/target}' 2>/dev/null || true)" + actual_instance="$(kubectl get namespace "$namespace" -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/instance}' 2>/dev/null || true)" + if [ "$actual_managed" != "$managed_by" ] || [ "$actual_target" != "$target_id" ] || [ "$actual_instance" != "$instance_id" ]; then + printf '%s\n' '{"ok":false,"mutation":false,"code":"namespace-ownership-mismatch","valuesPrinted":false}' + exit 42 + fi +fi +printf '%s' ${shQuote(encoded)} | base64 -d >"$tmp/manifest.yaml" +if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/manifest.yaml" >"$tmp/apply.out" 2>"$tmp/apply.err"; then + cat "$tmp/apply.err" >&2 + printf '%s\n' '{"ok":false,"mutation":true,"code":"apply-failed","valuesPrinted":false}' + exit 43 +fi +wait_rc=0 +kubectl -n "$namespace" rollout status statefulset/pikaoa-postgres --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err" || wait_rc=$? +if [ "$wait_rc" -eq 0 ]; then kubectl -n "$namespace" rollout status deployment/pikaoa-api --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err" || wait_rc=$?; fi +if [ "$wait_rc" -eq 0 ]; then kubectl -n "$namespace" rollout status deployment/pikaoa-worker --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err" || wait_rc=$?; fi +if [ "$wait_rc" -ne 0 ]; then + cat "$tmp/wait.err" >&2 + printf '%s\n' '{"ok":false,"mutation":true,"code":"rollout-wait-failed","valuesPrinted":false}' + exit 44 +fi +printf '%s\n' '{"ok":true,"mutation":true,"code":"started","valuesPrinted":false}' +`; +} + +function statusScript(target: TestTargetSpec, context: RenderContext): string { + return ` +set -u +namespace=${shQuote(context.namespace)} +if ! kubectl get namespace "$namespace" >/dev/null 2>&1; then + printf '%s\n' '{"ok":true,"exists":false,"mutation":false,"valuesPrinted":false}' + exit 0 +fi +printf '%s' '{"ok":true,"exists":true,"mutation":false,"namespace":' +kubectl get namespace "$namespace" -o json +printf '%s' ',"resources":' +kubectl -n "$namespace" get deployment,statefulset,service,pod -o json +printf '%s\n' ',"valuesPrinted":false}' +`; +} + +function stopScript(target: TestTargetSpec, context: RenderContext): string { + return ` +set -u +namespace=${shQuote(context.namespace)} +managed_by=${shQuote(MANAGED_BY)} +target_id=${shQuote(target.id)} +instance_id=${shQuote(context.instanceId)} +if ! kubectl get namespace "$namespace" >/dev/null 2>&1; then + printf '%s\n' '{"ok":true,"mutation":false,"code":"already-absent","valuesPrinted":false}' + exit 0 +fi +actual_managed="$(kubectl get namespace "$namespace" -o jsonpath='{.metadata.labels.app\\.kubernetes\\.io/managed-by}' 2>/dev/null || true)" +actual_target="$(kubectl get namespace "$namespace" -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/target}' 2>/dev/null || true)" +actual_instance="$(kubectl get namespace "$namespace" -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/instance}' 2>/dev/null || true)" +if [ "$actual_managed" != "$managed_by" ] || [ "$actual_target" != "$target_id" ] || [ "$actual_instance" != "$instance_id" ]; then + printf '%s\n' '{"ok":false,"mutation":false,"code":"namespace-ownership-mismatch","valuesPrinted":false}' + exit 42 +fi +kubectl delete namespace "$namespace" --wait=false >/dev/null +printf '%s\n' '{"ok":true,"mutation":true,"code":"stop-requested","valuesPrinted":false}' +`; +} + +function summarizeRemoteStatus(parsed: Record, target: TestTargetSpec, context: RenderContext): Record { + if (parsed.exists !== true) return { ok: parsed.ok === true, exists: false, mutation: false, valuesPrinted: false }; + const namespace = optionalRecord(parsed.namespace, "status.namespace") ?? {}; + const metadata = optionalRecord(namespace.metadata, "status.namespace.metadata") ?? {}; + const labels = optionalRecord(metadata.labels, "status.namespace.metadata.labels") ?? {}; + const resources = optionalRecord(parsed.resources, "status.resources") ?? {}; + const items = Array.isArray(resources.items) ? resources.items.filter(isRecord) : []; + return { + ok: parsed.ok === true, + exists: true, + mutation: false, + ownership: { + valid: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TARGET_LABEL] === target.id && labels[INSTANCE_LABEL] === context.instanceId, + managedBy: labels["app.kubernetes.io/managed-by"] ?? null, + target: labels[TARGET_LABEL] ?? null, + instance: labels[INSTANCE_LABEL] ?? null, + }, + resources: items.map((item) => { + const itemMetadata = optionalRecord(item.metadata, "resource.metadata") ?? {}; + const status = optionalRecord(item.status, "resource.status") ?? {}; + const spec = optionalRecord(item.spec, "resource.spec") ?? {}; + return { + kind: item.kind ?? null, + name: itemMetadata.name ?? null, + desired: spec.replicas ?? null, + ready: status.readyReplicas ?? status.numberReady ?? null, + phase: status.phase ?? null, + }; + }), + valuesPrinted: false, + }; +} + +function renderResult(payload: Record, options: TestTargetOptions): RenderedCliResult { + if (options.output === "json") return renderMachine(`pikaoa test-target ${options.action}`, payload, "json", payload.ok !== false); + return { + ok: payload.ok !== false, + command: `pikaoa test-target ${options.action}`, + contentType: "text/plain", + renderedText: renderText(payload), + projection: payload, + }; +} + +function renderText(payload: Record): string { + const target = optionalRecord(payload.target, "target"); + const instance = optionalRecord(payload.instance, "instance"); + const next = optionalRecord(payload.next, "next") ?? {}; + const warnings = Array.isArray(payload.warnings) ? payload.warnings.filter(isRecord) : []; + const blockers = Array.isArray(payload.blockers) ? payload.blockers.filter(isRecord) : []; + const status = optionalRecord(payload.status, "status"); + return [ + `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 ?? "-")}`, + ...(status === null ? [] : [`status=${status.remoteQueried === false ? String(status.reason ?? "local") : status.exists === false ? "absent" : status.ok === false ? "failed" : "present"}`]), + `warnings=${warnings.length} blockers=${blockers.length} valuesPrinted=false`, + ...warnings.map((item) => `WARNING ${String(item.code ?? "warning")} blocking=false ${String(item.message ?? "")}`), + ...blockers.map((item) => `BLOCKER ${String(item.code ?? "blocked")} field=${String(item.field ?? "-")} ${String(item.message ?? "")}`), + "NEXT", + ...Object.entries(next).map(([key, value]) => ` ${key}: ${String(value)}`), + "", + ].join("\n"); +} + +function targetSummary(target: TestTargetSpec): Record { + return { + id: target.id, + node: target.node, + route: target.route, + dedicated: target.dedicated, + validationOnly: target.validationOnly, + namespacePrefix: target.namespacePrefix, + ttlSeconds: target.ttlSeconds, + cleanup: target.cleanup, + }; +} + +function sourceSummary(target: TestTargetSpec, commit: string | null, context?: RenderContext): Record { + return { + mode: target.source.mode, + repository: target.source.repository, + commitPolicy: target.source.commitPolicy, + commit, + images: context === undefined ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage } : { api: context.apiImage, worker: context.workerImage }, + }; +} + +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 commit = options.commit ?? ""; + 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`, + stop: `bun scripts/cli.ts pikaoa test-target stop${configFlag} ${targetFlag} --instance ${instance} --confirm`, + }; +} + +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"] : []), + ]; +} + +function ownershipLabels(target: TestTargetSpec, context: RenderContext): Record { + return { + "app.kubernetes.io/name": "pikaoa", + "app.kubernetes.io/part-of": "pikaoa-test-runtime", + "app.kubernetes.io/managed-by": MANAGED_BY, + [TEST_RUNTIME_LABEL]: "true", + [TARGET_LABEL]: target.id, + [INSTANCE_LABEL]: context.instanceId, + }; +} + +function componentSelector(component: string, instanceId: string): Record { + return { "app.kubernetes.io/name": "pikaoa", "app.kubernetes.io/component": component, [INSTANCE_LABEL]: instanceId }; +} + +function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): Record { + return { + "pikaoa.unidesk.io/source-repository": target.source.repository, + "pikaoa.unidesk.io/source-commit": context.commit, + "pikaoa.unidesk.io/expires-at": context.expiresAt, + "pikaoa.unidesk.io/ttl-seconds": String(target.ttlSeconds), + }; +} + +function objectSummary(object: Record): Record { + const metadata = optionalRecord(object.metadata, "metadata") ?? {}; + 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, + labels: metadata.labels ?? {}, + selector: selector.matchLabels ?? selector, + secretValuesRedacted: object.kind === "Secret" ? true : undefined, + }; +} + +function structuralFingerprint(manifest: Record[]): string { + const redacted = manifest.map((object) => object.kind === "Secret" ? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, ""])) } : object); + return `sha256:${createHash("sha256").update(JSON.stringify(redacted)).digest("hex")}`; +} + +function placeholderSecrets(): SecretMaterial { + return { databasePassword: "", sessionSecret: "", administratorPassword: "", employeePassword: "" }; +} + +function secretSources(target: TestTargetSpec): SecretSourceSpec[] { + return [target.database.password, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password]; +} + +function secretSource(value: unknown, purpose: string, path: string): 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 }; +} + +function deliveryNamespaces(root: Record): string[] { + const delivery = optionalRecord(root.delivery, "delivery"); + const targets = delivery === null ? null : optionalRecord(delivery.targets, "delivery.targets"); + if (targets === null) return []; + const namespaces = Object.values(targets).filter(isRecord).flatMap((target) => { + const ci = optionalRecord(target.ci, "delivery.targets.*.ci"); + return [target.namespace, ci?.namespace]; + }).filter((value): value is string => typeof value === "string" && value.length > 0); + return [...new Set(namespaces)].sort(); +} + +function resolveConfigPath(value: string): string { + if (value.startsWith("~/")) return resolve(homedir(), value.slice(2)); + return isAbsolute(value) ? resolve(value) : resolve(repoRoot, value); +} + +function resolveSourceRef(configPath: string, sourceRef: string): string { + if (sourceRef.startsWith("~/")) return resolve(homedir(), sourceRef.slice(2)); + if (isAbsolute(sourceRef)) return resolve(sourceRef); + return resolve(dirname(configPath), sourceRef); +} + +function displayPath(path: string): string { + const rel = relative(repoRoot, path); + return rel.length > 0 && !rel.startsWith("..") ? rel : path; +} + +function renderImage(template: string, commit: string): string { + return template.replaceAll("${commit}", commit); +} + +function warning(code: string, message: string): Warning { + return { code, message, blocking: false }; +} + +function uniqueBlockers(blockers: Blocker[]): Blocker[] { + const seen = new Set(); + return blockers.filter((blocker) => { + const key = `${blocker.code}\0${blocker.field}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function inputError(message: string, code: string, argument?: string, supported?: string[]): CliInputError { + return new CliInputError(message, { + code, + argument, + supported, + usage: "bun scripts/cli.ts pikaoa test-target plan|status|start|stop [options]", + hint: "运行 bun scripts/cli.ts pikaoa test-target --help 查看受控入口。", + }); +} + +function simpleId(value: string, field: string): string { + if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw inputError(`${field} 必须是简单 ID`, "invalid-simple-id", field); + return value; +} + +function kubernetesName(value: string, field: string, maxLength: number): string { + if (value.length > maxLength || !isKubernetesName(value)) throw inputError(`${field} 必须是长度不超过 ${maxLength} 的 Kubernetes DNS label`, "invalid-kubernetes-name", field); + return value; +} + +function isKubernetesName(value: string): boolean { + return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(value); +} + +function sourceCommit(value: string): string { + if (!/^[0-9a-f]{7,64}$/u.test(value)) throw inputError("--commit 必须是 7-64 位小写十六进制 commit", "invalid-commit", "--commit"); + return value; +} + +function imageReference(value: unknown, path: string): string { + const image = nonEmpty(value, path); + if (/\s/u.test(image) || !image.includes(":")) throw new Error(`${path} 必须是带 tag/template 的镜像引用`); + return image; +} + +function postgresIdentifier(value: unknown, path: string): string { + const name = nonEmpty(value, path); + if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(name)) throw new Error(`${path} 必须是简单 PostgreSQL identifier`); + return name; +} + +function httpPath(value: unknown, path: string): string { + const result = nonEmpty(value, path); + if (!result.startsWith("/") || /[\s?#]/u.test(result)) throw new Error(`${path} 必须是绝对 HTTP path`); + 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`); + return result; +} + +function record(value: unknown, path: string): Record { + if (!isRecord(value)) throw new Error(`${path} 必须是 YAML object`); + return value; +} + +function optionalRecord(value: unknown, path: string): Record | null { + if (value === undefined || value === null) return null; + return record(value, path); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmpty(value: unknown, path: string): string { + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`); + return value.trim(); +} + +function nullableString(value: unknown, path: string): string | null { + if (value === undefined || value === null) return null; + return nonEmpty(value, path); +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${path} 必须是 boolean`); + return value; +} + +function positiveInteger(value: unknown, path: string, max: number): number { + if (!Number.isInteger(value) || Number(value) <= 0 || Number(value) > max) throw new Error(`${path} 必须是 1-${max} 的整数`); + return Number(value); +} + +function enumString(value: unknown, path: string, allowed: readonly T[]): T { + if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} 必须是 ${allowed.join("、")}`); + return value as T; +} + +function exactString(value: unknown, path: string, expected: string): string { + if (value !== expected) throw new Error(`${path} 必须是 ${expected}`); + return expected; +} + +function isHelpToken(value: string | undefined): boolean { + return value === "help" || value === "--help" || value === "-h"; +}