// 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"; 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"; import { resolveConfigRef } from "./ops/config-refs"; import { pikaoaPublicEdgeHelp, runPikaoaPublicEdgeCommand } from "./pikaoa-public-edge"; import { pikaoaManifestForStep, placeholderPikaoaSecrets, renderDatabaseURL, renderPikaoaTestRuntimeManifest, type PikaoaTestRenderContext, type PikaoaTestSecretMaterial, type PikaoaTestTargetStep, } from "./pikaoa-test-runtime-manifest"; type TestTargetAction = "plan" | "status" | "start" | "stop"; type TestTargetStep = PikaoaTestTargetStep; interface TestTargetOptions { action: TestTargetAction; configPath: string; targetId: string | null; instanceId: string | null; commit: string | null; step: TestTargetStep; confirm: boolean; output: "text" | "json"; } interface SecretSourceSpec { purpose: string; sourceRef: string; sourceKey: string | null; targetKey: string; } interface PrometheusSelectorSpec { sourceRef: string; key: string; value: string; presence: true; fingerprint: string; valuesPrinted: false; } export interface TestTargetSpec { id: string; enabled: boolean; validationOnly: boolean; node: string; route: string; namespace: string; ttlSeconds: number; waitTimeoutSeconds: number; fieldManager: string; taskState: { rootPath: string; statusRequestTimeoutSeconds: number; logTailLines: number; }; cleanup: { mode: "delete-namespace"; requireOwnershipLabels: true; }; source: { mode: string; repository: string; commitPolicy: string; branch: string; worktreeRemote: string; readUrl: string; snapshotPrefix: string; apiImage: string; workerImage: string; webImage: string; initializerImage: string; pullPolicy: string; }; ci: { lane: string; namespace: string; pipeline: string; pipelineRunPrefix: string; serviceAccountName: string; workspaceSize: string; pipelineTimeout: string; toolsImage: string; buildkitImage: string; }; build: { dockerfiles: { api: string; worker: string; web: string }; images: { api: string; worker: string; web: string }; networkMode: string; }; gitops: { readUrl: string; writeUrl: string; branch: string; manifestPath: string; credentialSecretName: string; credentialTokenKey: string; credentialUsername: string; authorName: string; authorEmail: string; }; database: { configRef: string; name: string; username: string; schema: string; connection: SecretSourceSpec; }; runtime: { replicas: { api: number; worker: number; web: number }; resources: Record<"api" | "worker" | "web" | "initializer", Record>; secretName: string; apiServiceName: string; apiPort: number; workerMetricsPort: number; webPort: number; webApiUpstreamEnv: string; attachment: { storageRoot: string; maxBytes: number; claimName: string; storageRequest: string; storageClassName: string | null; fsGroup: number; accessModes: string[]; }; workerInterval: string; outboxBatchSize: number; outboxMaxAttempts: number; outboxRetryDelay: string; sessionTTL: string; shutdownTimeout: string; administrator: { username: string; password: SecretSourceSpec }; employee: { username: string; password: SecretSourceSpec }; sessionSecret: SecretSourceSpec; }; initializer: { mode: "fresh-database-only"; jobName: string; backoffLimit: number; command: string[]; }; exposure: { serviceName: string; serviceType: "NodePort"; hostIP: string; port: number; }; observability: { otlpEndpoint: string; prometheusScrape: boolean; prometheusSelector: PrometheusSelectorSpec; apiMetricsPath: string; workerMetricsPath: string; exporterWarning: boolean; exporterBlocking: false; }; probes: { apiLivenessPath: string; apiReadinessPath: string; workerLivenessPath: string; workerReadinessPath: string; webLivenessPath: string; webReadinessPath: 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; } type RenderContext = PikaoaTestRenderContext; type SecretMaterial = PikaoaTestSecretMaterial; type RemoteCapture = typeof capture; interface AsyncJobIdentity { id: string; stateDir: 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 固定 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] [--step foundation|init|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 可按 foundation、init、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。", "正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。", "Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。", ], }; } export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise { if (args.length === 0) return renderMachine("pikaoa", { ok: true, commands: [pikaoaTestTargetHelp(), pikaoaPublicEdgeHelp()] }, "json"); if (args[0] === "public-edge") return await runPikaoaPublicEdgeCommand(config, args.slice(1)); if (args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json"); if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target 或 public-edge", "unsupported-pikaoa-command", "pikaoa", ["test-target", "public-edge"]); 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, remoteCapture); if (options.action === "start") return await startResult(config, options, selection, remoteCapture); return await stopResult(config, options, selection, remoteCapture); } 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 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 === "--step" || arg === "--output" || arg === "-o") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, "missing-option-value", arg); if (arg === "--config") configPath = resolveConfigPath(value); else if (arg === "--target") targetId = simpleId(value, arg); else if (arg === "--instance") instanceId = kubernetesName(value, arg, 30); else if (arg === "--commit") commit = sourceCommit(value); else if (arg === "--step") step = enumString(value, arg, ["all", "foundation", "init", "api", "worker", "web"]); 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, step, 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, runtimeSection = "testRuntime"): TestTargetSpec { const path = `${configLabel}.${runtimeSection}.targets.${id}`; simpleId(id, "target id"); const source = record(root.source, `${path}.source`); const images = record(source.images, `${path}.source.images`); const ci = record(root.ci, `${path}.ci`); const build = record(root.build, `${path}.build`); const dockerfiles = record(build.dockerfiles, `${path}.build.dockerfiles`); const buildImages = record(build.images, `${path}.build.images`); const gitops = record(root.gitops, `${path}.gitops`); const gitopsCredential = record(gitops.credential, `${path}.gitops.credential`); const gitopsAuthor = record(gitops.author, `${path}.gitops.author`); const database = record(root.database, `${path}.database`); const runtime = record(root.runtime, `${path}.runtime`); const replicas = record(runtime.replicas, `${path}.runtime.replicas`); const resources = record(runtime.resources, `${path}.runtime.resources`); const attachment = record(runtime.attachment, `${path}.runtime.attachment`); 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 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 initializer = record(root.initializer, `${path}.initializer`); const taskState = record(root.taskState, `${path}.taskState`); 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`), node: nonEmpty(root.node, `${path}.node`), route: nonEmpty(root.route, `${path}.route`), 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), taskState: { rootPath: taskStateRoot(taskState.rootPath, `${path}.taskState.rootPath`), statusRequestTimeoutSeconds: positiveSafeInteger(taskState.statusRequestTimeoutSeconds, `${path}.taskState.statusRequestTimeoutSeconds`), logTailLines: positiveSafeInteger(taskState.logTailLines, `${path}.taskState.logTailLines`), }, 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"), branch: nonEmpty(source.branch, `${path}.source.branch`), worktreeRemote: nonEmpty(source.worktreeRemote, `${path}.source.worktreeRemote`), readUrl: nonEmpty(source.readUrl, `${path}.source.readUrl`), snapshotPrefix: nonEmpty(source.snapshotPrefix, `${path}.source.snapshotPrefix`), apiImage: imageReference(images.api, `${path}.source.images.api`), workerImage: imageReference(images.worker, `${path}.source.images.worker`), webImage: imageReference(images.web, `${path}.source.images.web`), initializerImage: imageReference(images.initializer, `${path}.source.images.initializer`), pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]), }, ci: { lane: simpleId(nonEmpty(ci.lane, `${path}.ci.lane`), `${path}.ci.lane`), namespace: kubernetesName(nonEmpty(ci.namespace, `${path}.ci.namespace`), `${path}.ci.namespace`, 63), pipeline: kubernetesName(nonEmpty(ci.pipeline, `${path}.ci.pipeline`), `${path}.ci.pipeline`, 63), pipelineRunPrefix: kubernetesName(nonEmpty(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`), `${path}.ci.pipelineRunPrefix`, 63), serviceAccountName: kubernetesName(nonEmpty(ci.serviceAccountName, `${path}.ci.serviceAccountName`), `${path}.ci.serviceAccountName`, 63), workspaceSize: nonEmpty(ci.workspaceSize, `${path}.ci.workspaceSize`), pipelineTimeout: nonEmpty(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`), toolsImage: imageReference(ci.toolsImage, `${path}.ci.toolsImage`), buildkitImage: imageReference(ci.buildkitImage, `${path}.ci.buildkitImage`), }, build: { dockerfiles: { api: relativeSourcePath(dockerfiles.api, `${path}.build.dockerfiles.api`), worker: relativeSourcePath(dockerfiles.worker, `${path}.build.dockerfiles.worker`), web: relativeSourcePath(dockerfiles.web, `${path}.build.dockerfiles.web`), }, images: { api: imageRepository(buildImages.api, `${path}.build.images.api`), worker: imageRepository(buildImages.worker, `${path}.build.images.worker`), web: imageRepository(buildImages.web, `${path}.build.images.web`), }, networkMode: exactString(build.networkMode, `${path}.build.networkMode`, "host"), }, gitops: { readUrl: nonEmpty(gitops.readUrl, `${path}.gitops.readUrl`), writeUrl: nonEmpty(gitops.writeUrl, `${path}.gitops.writeUrl`), branch: nonEmpty(gitops.branch, `${path}.gitops.branch`), manifestPath: relativeSourcePath(gitops.manifestPath, `${path}.gitops.manifestPath`), credentialSecretName: kubernetesName(nonEmpty(gitopsCredential.secretName, `${path}.gitops.credential.secretName`), `${path}.gitops.credential.secretName`, 63), credentialTokenKey: nonEmpty(gitopsCredential.tokenKey, `${path}.gitops.credential.tokenKey`), credentialUsername: nonEmpty(gitopsCredential.username, `${path}.gitops.credential.username`), authorName: nonEmpty(gitopsAuthor.name, `${path}.gitops.author.name`), authorEmail: nonEmpty(gitopsAuthor.email, `${path}.gitops.author.email`), }, database: { 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`), connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true), }, runtime: { replicas: { api: positiveInteger(replicas.api, `${path}.runtime.replicas.api`, 20), worker: positiveInteger(replicas.worker, `${path}.runtime.replicas.worker`, 20), web: positiveInteger(replicas.web, `${path}.runtime.replicas.web`, 20), }, resources: { api: record(resources.api, `${path}.runtime.resources.api`), worker: record(resources.worker, `${path}.runtime.resources.worker`), web: record(resources.web, `${path}.runtime.resources.web`), initializer: record(resources.initializer, `${path}.runtime.resources.initializer`), }, secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63), apiServiceName: kubernetesName(nonEmpty(runtime.apiServiceName, `${path}.runtime.apiServiceName`), `${path}.runtime.apiServiceName`, 63), apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535), workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535), webPort: positiveInteger(runtime.webPort, `${path}.runtime.webPort`, 65_535), webApiUpstreamEnv: envName(runtime.webApiUpstreamEnv, `${path}.runtime.webApiUpstreamEnv`), attachment: { storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`), maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER), claimName: kubernetesName(nonEmpty(attachment.claimName, `${path}.runtime.attachment.claimName`), `${path}.runtime.attachment.claimName`, 63), storageRequest: nonEmpty(attachment.storageRequest, `${path}.runtime.attachment.storageRequest`), storageClassName: nullableString(attachment.storageClassName, `${path}.runtime.attachment.storageClassName`), fsGroup: positiveInteger(attachment.fsGroup, `${path}.runtime.attachment.fsGroup`, 2_147_483_647), accessModes: enumStringList(attachment.accessModes, `${path}.runtime.attachment.accessModes`, ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]), }, workerInterval: duration(runtime.workerInterval, `${path}.runtime.workerInterval`), 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`), }, initializer: { mode: exactString(initializer.mode, `${path}.initializer.mode`, "fresh-database-only") as "fresh-database-only", jobName: kubernetesName(nonEmpty(initializer.jobName, `${path}.initializer.jobName`), `${path}.initializer.jobName`, 63), backoffLimit: nonNegativeInteger(initializer.backoffLimit, `${path}.initializer.backoffLimit`), command: nonEmptyStringList(initializer.command, `${path}.initializer.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: otlpGrpcEndpoint(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`), prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`), prometheusSelector: prometheusSelector(prometheus.configRef, `${path}.observability.prometheus`), 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`), webLivenessPath: httpPath(webProbes.livenessPath, `${path}.probes.web.livenessPath`), webReadinessPath: httpPath(webProbes.readinessPath, `${path}.probes.web.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; } export function pikaoaTestRuntimeConfigRef(targetId: string): string { simpleId(targetId, "PikaOA test runtime target id"); return `config/pikaoa.yaml#testRuntime.targets.${targetId}`; } export function resolvePikaoaTestRuntimeTargetByConfigRef(configRef: string): TestTargetSpec { const resolved = resolveConfigRef(configRef, "PikaOA test runtime configRef"); const match = /^testRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment); if (resolved.file !== "config/pikaoa.yaml" || match === null) { throw new Error("PikaOA test runtime configRef must select config/pikaoa.yaml#testRuntime.targets."); } return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "testRuntime"); } export function pikaoaReleaseRuntimeConfigRef(targetId: string): string { simpleId(targetId, "PikaOA release runtime target id"); return `config/pikaoa.yaml#releaseRuntime.targets.${targetId}`; } export function resolvePikaoaReleaseRuntimeTargetByConfigRef(configRef: string): TestTargetSpec { const resolved = resolveConfigRef(configRef, "PikaOA release runtime configRef"); const match = /^releaseRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment); if (resolved.file !== "config/pikaoa.yaml" || match === null) { throw new Error("PikaOA release runtime configRef must select config/pikaoa.yaml#releaseRuntime.targets."); } return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "releaseRuntime"); } 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") && options.step !== "foundation"; const context = renderContext(options, selection.target, requireCommit); const blockers = safetyBlockers(options, selection, context, requireCommit); if ((options.action === "plan" || options.action === "start") && options.step !== "foundation") { for (const source of secretSources(selection.target)) { const summary = secretSourceSummary(selection.configPath, source); if (summary.presence !== true) blockers.push({ code: "secret-source-missing", field: source.sourceRef, message: `${source.purpose} 的 sourceRef/sourceKey 不存在或为空。` }); } } const warnings = [...selection.warnings, ...consistencyWarnings(selection.target, context)]; const renderWorkloads = context !== null && (options.commit !== null || options.step === "foundation") && (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 && options.step !== "foundation" ? sourceSummary(selection.target, context.commit, context) : sourceSummary(selection.target, options.commit), readyForMutation: blockers.length === 0 && !selection.target.validationOnly, missingFields: missingMutationFields(options, selection), blockers, warnings, observability: observabilitySummary(selection.target), 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, step: options.step, 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 --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), web: componentSelector("web", context.instanceId), }; const structuralManifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(target, context, { secretMode: "materialized", secrets: placeholderPikaoaSecrets(), }), context.step); return { namespace: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context), ttlSeconds: target.ttlSeconds, cleanup: target.cleanup, }, objects: structuralManifest.map((object) => objectSummary(object, target.observability.prometheusSelector.key)), selectors, secret: { name: target.runtime.secretName, requiredForStep: context.step !== "foundation", valuesPrinted: false, sources: secretSources(target).map((source) => secretSourceSummary(selection.configPath, source)), }, database: databasePlanSummary(target), runtimeConfig: { secretName: target.runtime.secretName, key: "pikaoa.yaml", mountPath: "/etc/pikaoa/pikaoa.yaml", consumers: ["initializer", "api", "worker"], valuesPrinted: false, }, probes: { api: { liveness: target.probes.apiLivenessPath, readiness: target.probes.apiReadinessPath, metrics: target.observability.apiMetricsPath }, worker: { liveness: target.probes.workerLivenessPath, readiness: target.probes.workerReadinessPath, metrics: target.observability.workerMetricsPath }, web: { liveness: target.probes.webLivenessPath, readiness: target.probes.webReadinessPath }, }, observability: { ...observabilitySummary(target), exporterFailure: { warning: target.observability.exporterWarning, blocking: false }, }, exposure: { serviceType: target.exposure.serviceType, serviceName: target.exposure.serviceName, hostIP: target.exposure.hostIP, port: target.exposure.port }, webApiUpstream: { envName: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}:${target.runtime.apiPort}`, scope: "same-namespace" }, attachment: { storageRoot: target.runtime.attachment.storageRoot, maxBytes: target.runtime.attachment.maxBytes, claimName: target.runtime.attachment.claimName, storageRequest: target.runtime.attachment.storageRequest, storageClassName: target.runtime.attachment.storageClassName, fsGroup: target.runtime.attachment.fsGroup, accessModes: target.runtime.attachment.accessModes, }, initializer: { mode: target.initializer.mode, jobName: target.initializer.jobName, image: context.initializerImage, command: target.initializer.command, applyPhase: "before-workloads", }, step: context.step, taskState: { jobId: asyncJobIdentity(target, context).id, rootPath: target.taskState.rootPath, statusRequestTimeoutSeconds: target.taskState.statusRequestTimeoutSeconds, logTailLines: target.taskState.logTailLines, mutation: false, }, manifestFingerprint: structuralFingerprint(structuralManifest), mutation: false, }; } async function statusResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): Promise { const planned = planPayload(options, selection); if (selection.target === null) { return renderResult({ ...planned, remoteQueried: false, status: { remoteQueried: false, reason: "test-target-not-configured" } }, options); } if (selection.target.validationOnly) { return renderResult({ ...planned, remoteQueried: false, 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 remoteCapture(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, selection.configPath); return renderResult({ ...planned, ok: remote.exitCode === 0 && status.ok !== false, remoteQueried: true, status: { remoteQueried: true, ...status } }, options); } async function startResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): Promise { const planned = planPayload(options, selection); const requireCommit = options.step !== "foundation"; const context = selection.target === null ? null : renderContext(options, selection.target, requireCommit); const blockers = safetyBlockers(options, selection, context, requireCommit); if (!options.confirm) blockers.push({ code: "confirmation-required", field: "--confirm", message: "start 需要显式 --confirm。" }); if (selection.target?.validationOnly === true) blockers.push({ code: "validation-only-target", field: "testRuntime.targets.*.validationOnly", message: "validationOnly target 禁止远端操作。" }); if (selection.target === null || context === null || blockers.length > 0) { return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: uniqueBlockers(blockers), failure: "preflight-blocked" }, options); } const material = options.step === "foundation" ? { ok: true, values: placeholderPikaoaSecrets(), sources: secretSources(selection.target).map((source) => secretSourceSummary(selection.configPath, source)), blockers: [] as Blocker[], } : readSecretMaterial(selection, selection.target); if (!material.ok) { return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: material.blockers, secret: { sources: material.sources, valuesPrinted: false }, failure: "secret-preflight-blocked" }, options); } const manifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(selection.target, context, { secretMode: "materialized", secrets: material.values, }), options.step); const remote = await remoteCapture(config, selection.target.route, ["sh"], startScript(selection.target, context, manifest)); const parsed = parseJsonOutput(remote.stdout); const mutation = parsed?.mutation === true; return renderResult({ ...planned, ok: remote.exitCode === 0 && parsed?.ok === true, mutation, remoteQueried: true, mode: mutation ? "submitted" : parsed === null ? "submit-failed" : "existing-task", code: parsed?.code ?? "start-submit-unreadable", jobId: parsed?.jobId ?? asyncJobIdentity(selection.target, context).id, previousTaskReset: parsed?.previousTaskReset === true, task: parsed?.task ?? null, secret: { sources: material.sources, valuesPrinted: false }, remote: parsed ?? compactCapture(remote, { full: true }), manifestFingerprint: structuralFingerprint(manifest), }, options); } async function stopResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): 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 remoteCapture(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, remoteQueried: true, mode: parsed?.mutation === true ? "confirmed" : "no-change", code: parsed?.code ?? "stop-result-unreadable", jobId: parsed?.jobId ?? asyncJobIdentity(selection.target, context).id, startTaskDisposition: parsed?.startTaskDisposition ?? "unknown", remote: parsed ?? compactCapture(remote, { full: true }), }, options); } function renderContext(options: TestTargetOptions, target: TestTargetSpec, requireCommit = true): RenderContext | null { if (requireCommit && options.commit === null) return null; const instanceId = options.instanceId ?? "default"; const commit = options.commit ?? (options.step === "foundation" ? "foundation" : "not-required-for-stop"); const expiresAt = new Date(Date.now() + target.ttlSeconds * 1000).toISOString(); return { instanceId, commit, step: options.step, namespace: target.namespace, expiresAt, apiImage: renderImage(target.source.apiImage, commit), workerImage: renderImage(target.source.workerImage, commit), webImage: renderImage(target.source.webImage, commit), initializerImage: renderImage(target.source.initializerImage, commit), }; } function asyncJobIdentity(target: TestTargetSpec, context: RenderContext): AsyncJobIdentity { const digest = createHash("sha256").update(target.id).update("\0").update(context.instanceId).update("\0").update(context.step).digest("hex").slice(0, 20); const id = `pikaoa-${digest}`; return { id, stateDir: `${target.taskState.rootPath}/${id}` }; } 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 (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "当前 step 必须选择源码 commit。" }); if (context !== null) { if (context.namespace.length > 63 || !isKubernetesName(context.namespace)) blockers.push({ code: "unsafe-namespace", field: "namespace", message: `YAML namespace 不合法:${context.namespace}` }); if (selection.protectedNamespaces.includes(context.namespace)) blockers.push({ code: "production-namespace-protected", field: "namespace", message: `${context.namespace} 属于 delivery 正式 namespace。` }); } return blockers; } function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] { const warnings: Warning[] = []; if (!target.initializer.command.includes("migrate") || !target.initializer.command.includes("up")) warnings.push(warning("initializer-command-unrecognized", "空库 initializer 的当前产品命令未显示 migrate up;配置一致性只报告 warning。")); if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.initializerImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或 initializer 镜像模板未包含 ${commit};版本一致性只报告 warning。")); if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.initializerImage].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; } function observabilitySummary(target: TestTargetSpec): Record { return { otlpEndpoint: target.observability.otlpEndpoint, prometheus: { scrape: target.observability.prometheusScrape, selector: target.observability.prometheusSelector, }, valuesPrinted: false, }; } function prometheusRuntimeStatus(items: Record[], selector: PrometheusSelectorSpec, runtimePresent: boolean, step: TestTargetStep): Record { const components = step === "all" ? ["api", "worker"] : step === "api" || step === "worker" ? [step] : []; const workloads = components.map((component) => { const deployment = items.find((item) => item.kind === "Deployment" && optionalRecord(item.metadata, "resource.metadata")?.name === `pikaoa-${component}`); const spec = deployment === undefined ? null : optionalRecord(deployment.spec, "resource.spec"); const template = spec === null ? null : optionalRecord(spec.template, "resource.spec.template"); const metadata = template === null ? null : optionalRecord(template.metadata, "resource.spec.template.metadata"); const labels = metadata === null ? null : optionalRecord(metadata.labels, "resource.spec.template.metadata.labels"); const actual = labels?.[selector.key]; return { component, present: deployment !== undefined, actual: typeof actual === "string" ? actual : null, matches: actual === selector.value }; }); const checked = runtimePresent && components.length > 0; const drift = checked && workloads.some((workload) => !workload.matches); return { selector, checked, workloads, drift, warning: drift ? warning("prometheus-selector-label-drift", "API/Worker Pod template 的 Prometheus selector label 与 owning configRef 不一致;该漂移不阻塞业务运行。") : null, blocking: false, valuesPrinted: false, }; } function databasePlanSummary(target: TestTargetSpec): Record { 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"), }, valuesPrinted: false, }; } 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 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: { databaseURL: values.get("database.connection") ?? "", sessionSecret: values.get("runtime.sessionSecret") ?? "", administratorPassword: values.get("runtime.administrator.password") ?? "", employeePassword: values.get("runtime.employee.password") ?? "", }, sources: summaries, blockers, }; } function secretSourceSummary(configPath: string, source: SecretSourceSpec): Record { const path = resolveSourceRef(configPath, source.sourceRef); const present = existsSync(path); const raw = present ? readFileSync(path, "utf8") : ""; const value = source.sourceKey === null ? raw.trim() : envValue(raw, source.sourceKey); const available = value.length > 0; return { purpose: source.purpose, sourceRef: source.sourceRef, sourceKey: source.sourceKey, targetKey: source.targetKey, presence: available, fingerprint: available ? sha256Fingerprint(value) : null, valuesPrinted: false, }; } function envValue(raw: string, key: string): string { for (const line of raw.split(/\r?\n/u)) { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith("#")) continue; const separator = trimmed.indexOf("="); if (separator < 1 || trimmed.slice(0, separator).trim() !== key) continue; const value = trimmed.slice(separator + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1); return value; } return ""; } function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record[]): string { const job = asyncJobIdentity(target, context); const manifestFingerprint = requestStructuralFingerprint(manifest); const requestId = createHash("sha256") .update(target.id) .update("\0") .update(context.instanceId) .update("\0") .update(context.commit) .update("\0") .update(context.step) .update("\0") .update(manifestFingerprint) .digest("hex"); const runnerEncoded = Buffer.from(startRunnerScript(target, context, manifest, job), "utf8").toString("base64"); return ` set -u umask 077 state_root=${shQuote(target.taskState.rootPath)} state_dir=${shQuote(job.stateDir)} status_file="$state_dir/status.json" request_file="$state_dir/request-id" pid_file="$state_dir/pid" job_id=${shQuote(job.id)} request_id=${shQuote(requestId)} reset_previous=false inspect_existing() { python3 - "$request_file" "$status_file" "$pid_file" "$job_id" "$request_id" <<'PY' import json, os, pathlib, sys request_path, status_path, pid_path = map(pathlib.Path, sys.argv[1:4]) job_id, expected_request = sys.argv[4:6] actual_request = request_path.read_text(errors="replace").strip() if request_path.exists() else "" try: task = json.loads(status_path.read_text()) except Exception: print(json.dumps({"ok": False, "mutation": False, "code": "start-state-unreadable", "jobId": job_id, "submitted": False, "task": None, "valuesPrinted": False})) raise SystemExit(10) state = str(task.get("state") or "unknown") runner_alive = False pid = task.get("pid") if not isinstance(pid, int) or pid <= 0: try: pid = int(pid_path.read_text().strip()) except Exception: pid = None if isinstance(pid, int) and pid > 0: try: os.kill(pid, 0) runner_alive = True except OSError: pass worker_missing = state in ("queued", "running") and not runner_alive if actual_request != expected_request: if state in ("succeeded", "failed", "canceled") or worker_missing: raise SystemExit(20) print(json.dumps({"ok": False, "mutation": False, "code": "start-instance-conflict", "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False})) raise SystemExit(10) code_by_state = { "queued": "start-already-running", "running": "start-already-running", "succeeded": "start-already-succeeded", "failed": "start-already-failed", "canceled": "start-already-canceled", } code = "start-worker-missing" if worker_missing else code_by_state.get(state, "start-state-unknown") ok = state not in ("failed", "canceled", "unknown") and code != "start-worker-missing" print(json.dumps({"ok": ok, "mutation": False, "code": code, "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False})) raise SystemExit(10) PY } if ! mkdir -p "$state_root"; then printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-root-create-failed","valuesPrinted":false}' exit 41 fi claim_attempt=0 while ! mkdir "$state_dir" 2>/dev/null; do if [ ! -d "$state_dir" ]; then printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-create-failed","valuesPrinted":false}' exit 42 fi inspect_existing decision=$? if [ "$decision" -eq 10 ]; then exit 0; fi if [ "$decision" -ne 20 ]; then printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-inspect-failed","valuesPrinted":false}' exit 42 fi retired_dir="$state_dir.reset.$request_id.$$" if mv "$state_dir" "$retired_dir" 2>/dev/null; then rm -rf "$retired_dir" reset_previous=true fi claim_attempt=$((claim_attempt + 1)) if [ "$claim_attempt" -ge 5 ]; then printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-reset-conflict","valuesPrinted":false}' exit 42 fi done started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf '%s\n' "$request_id" >"$request_file" printf '%s\n' "$started_at" >"$state_dir/started-at" if ! printf '%s' ${shQuote(runnerEncoded)} | base64 -d >"$state_dir/job.sh"; then rm -rf "$state_dir" printf '%s\n' '{"ok":false,"mutation":false,"code":"task-runner-write-failed","valuesPrinted":false}' 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)} ${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], "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") tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n") os.replace(tmp, path) PY if [ "$?" -ne 0 ]; then rm -rf "$state_dir" printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-initialize-failed","valuesPrinted":false}' exit 44 fi nohup sh "$state_dir/job.sh" >/dev/null 2>&1 "$pid_file" python3 - "$status_file" "$pid" <<'PY' import json, os, pathlib, sys path = pathlib.Path(sys.argv[1]) try: payload = json.loads(path.read_text()) except Exception: raise SystemExit(0) if payload.get("pid") is None and payload.get("phase") == "submitted": payload["pid"] = int(sys.argv[2]) tmp = path.with_name(path.name + ".tmp.submit") tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n") os.replace(tmp, path) PY printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"previousTaskReset":%s,"jobId":"%s","pid":%s,"task":{"state":"running","phase":"submitted","code":"start-running"},"valuesPrinted":false}\n' "$reset_previous" "$job_id" "$pid" `; } function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record[], job: AsyncJobIdentity): string { const initializer = manifest.filter((object) => object.kind === "Job"); const workload = (name: string): Record[] => manifest.filter((object) => { if (object.kind !== "Deployment" && object.kind !== "Service") return false; const metadata = optionalRecord(object.metadata, "metadata") ?? {}; const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; return labels["app.kubernetes.io/component"] === name; }); const foundation = manifest.filter((object) => object.kind === "Namespace" || object.kind === "PersistentVolumeClaim"); const runtime = manifest.filter((object) => object.kind === "Secret"); const encode = (objects: Record[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64"); const foundationEncoded = encode(foundation); const runtimeEncoded = encode(runtime); const initializerEncoded = encode(initializer); const apiEncoded = encode(workload("api")); const workerEncoded = encode(workload("worker")); const webEncoded = encode(workload("web")); return `#!/bin/sh set -eu umask 077 state_dir=${shQuote(job.stateDir)} status_file="$state_dir/status.json" events_file="$state_dir/events.ndjson" started_at="$(cat "$state_dir/started-at")" job_id=${shQuote(job.id)} namespace=${shQuote(context.namespace)} 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="" write_status() { state="$1"; phase="$2"; code="$3"; exit_code="$4" now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" python3 - "$status_file" "$events_file" "$job_id" "$target_id" "$instance_id" "$namespace" "$source_commit" "$state" "$phase" "$code" "$started_at" "$now" "$exit_code" "$$" <<'PY' import json, os, pathlib, sys status_path = pathlib.Path(sys.argv[1]) events_path = pathlib.Path(sys.argv[2]) state, phase, code = sys.argv[8:11] 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], "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, } tmp = status_path.with_name(status_path.name + ".tmp." + sys.argv[14]) tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n") os.replace(tmp, status_path) event = {"at": sys.argv[12], "state": state, "phase": phase, "code": code, "valuesPrinted": False} with events_path.open("a", encoding="utf-8") as handle: handle.write(json.dumps(event, separators=(",", ":")) + "\\n") PY } check_canceled() { if [ -f "$state_dir/cancel-requested" ]; then failure_code=start-canceled; exit 130; fi } finish() { rc=$? trap - EXIT HUP INT TERM if [ -f "$state_dir/cancel-requested" ]; then write_status canceled canceled start-canceled "$rc" || true elif [ "$rc" -eq 0 ]; then write_status succeeded completed started 0 || true else write_status failed "$current_phase" "$failure_code" "$rc" || true fi [ -z "$tmp" ] || rm -rf "$tmp" exit "$rc" } trap finish EXIT trap 'failure_code=start-runner-hangup; exit 129' HUP trap 'failure_code=start-runner-interrupted; exit 130' INT trap 'failure_code=start-runner-terminated; exit 143' TERM rm -f "$state_dir/job.sh" tmp="$(mktemp -d)" current_phase=ownership-check write_status running "$current_phase" start-running null check_canceled if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s >/dev/null 2>&1; then actual_managed="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.app\\.kubernetes\\.io/managed-by}' 2>/dev/null || true)" actual_target="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/target}' 2>/dev/null || true)" actual_instance="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -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 failure_code=namespace-ownership-mismatch exit 42 fi fi printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml" printf '%s' ${shQuote(runtimeEncoded)} | base64 -d >"$tmp/runtime.yaml" printf '%s' ${shQuote(initializerEncoded)} | base64 -d >"$tmp/initializer.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 if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/foundation.yaml" >"$tmp/apply.out" 2>"$tmp/apply.err"; then failure_code=apply-failed exit 43 fi if [ "$step" = foundation ]; then exit 0 fi current_phase=runtime-secret-apply write_status running "$current_phase" start-running null check_canceled if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/runtime.yaml" >"$tmp/runtime-apply.out" 2>"$tmp/runtime-apply.err"; then failure_code=runtime-secret-apply-failed exit 44 fi if [ "$step" = all ] || [ "$step" = init ]; then current_phase=initializer-apply write_status running "$current_phase" start-running null check_canceled kubectl -n "$namespace" delete job/${target.initializer.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/initializer.yaml" >"$tmp/initializer-apply.out" 2>"$tmp/initializer-apply.err"; then failure_code=initializer-apply-failed; exit 45; fi current_phase=initializer-wait write_status running "$current_phase" start-running null initializer_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} )) while :; do check_canceled if ! kubectl -n "$namespace" get job/${target.initializer.jobName} --request-timeout=${Math.min(target.taskState.statusRequestTimeoutSeconds, 5)}s -o json >"$tmp/initializer-job.json" 2>"$tmp/initializer-wait.err"; then failure_code=initializer-job-query-failed exit 46 fi if ! initializer_condition="$(python3 - "$tmp/initializer-job.json" <<'PY' import json, pathlib, sys payload = json.loads(pathlib.Path(sys.argv[1]).read_text()) conditions = { item.get("type"): item.get("status") for item in payload.get("status", {}).get("conditions", []) if isinstance(item, dict) } if conditions.get("Complete") == "True": print("complete") elif conditions.get("FailureTarget") == "True": print("failure-target") elif conditions.get("Failed") == "True": print("failed") else: print("running") PY )"; then failure_code=initializer-job-condition-invalid exit 46 fi case "$initializer_condition" in complete) break ;; failure-target) failure_code=initializer-job-failure-target; exit 46 ;; failed) failure_code=initializer-job-failed; exit 46 ;; esac if [ "$(date +%s)" -ge "$initializer_deadline" ]; then failure_code=initializer-job-timeout exit 46 fi sleep 1 done 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 `; } function statusScript(target: TestTargetSpec, context: RenderContext): string { const job = asyncJobIdentity(target, context); return ` set -u umask 077 state_dir=${shQuote(job.stateDir)} job_id=${shQuote(job.id)} namespace=${shQuote(context.namespace)} tmp="$(mktemp -d)" 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,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 runtime_code=runtime-query-failed fi python3 - "$state_dir" "$job_id" "$runtime_code" "$tmp/namespace.json" "$tmp/resources.json" ${shQuote(String(target.taskState.logTailLines))} <<'PY' import json, os, pathlib, sys state_dir = pathlib.Path(sys.argv[1]) job_id, runtime_code = sys.argv[2:4] namespace_path, resources_path = map(pathlib.Path, sys.argv[4:6]) tail_lines = int(sys.argv[6]) status_path = state_dir / "status.json" task = None task_error = None if status_path.exists(): try: task = json.loads(status_path.read_text()) except Exception: task_error = "start-state-unreadable" pid = task.get("pid") if isinstance(task, dict) else None runner_alive = False if isinstance(pid, int) and pid > 0: try: os.kill(pid, 0) runner_alive = True except OSError: pass events = [] events_path = state_dir / "events.ndjson" if events_path.exists(): for line in events_path.read_text(errors="replace").splitlines()[-tail_lines:]: try: item = json.loads(line) except Exception: continue if isinstance(item, dict): events.append({key: item.get(key) for key in ("at", "state", "phase", "code", "valuesPrinted")}) if task_error is not None: code, ok, exists = task_error, False, True elif task is None: code, ok, exists = "start-not-found", True, False else: state = str(task.get("state") or "unknown") if state in ("queued", "running") and not runner_alive: code, ok = "start-worker-missing", False else: code = {"queued": "start-running", "running": "start-running", "succeeded": "start-succeeded", "failed": "start-failed", "canceled": "start-canceled"}.get(state, "start-state-unknown") ok = state not in ("failed", "canceled", "unknown") exists = True task_code = code runtime = {"queried": True, "exists": runtime_code in ("runtime-present", "runtime-resource-query-failed"), "code": runtime_code} runtime_paths = [] if runtime_code in ("runtime-present", "runtime-resource-query-failed"): runtime_paths.append(("namespace", namespace_path)) if runtime_code == "runtime-present": runtime_paths.append(("resources", resources_path)) for key, path in runtime_paths: if path.exists() and path.stat().st_size > 0: try: runtime[key] = json.loads(path.read_text()) except Exception: runtime["code"] = "runtime-response-unreadable" else: runtime["code"] = "runtime-response-unreadable" if runtime["code"] in ("runtime-query-failed", "runtime-resource-query-failed", "runtime-response-unreadable"): code, ok = runtime["code"], False payload = {"ok": ok, "mutation": False, "code": code, "taskCode": task_code, "jobId": job_id, "taskExists": exists, "task": task, "runnerAlive": runner_alive, "logTail": events, "runtime": runtime, "valuesPrinted": False} print(json.dumps(payload, separators=(",", ":"))) PY `; } function stopScript(target: TestTargetSpec, context: RenderContext): string { const job = asyncJobIdentity(target, context); return ` set -u umask 077 state_dir=${shQuote(job.stateDir)} job_id=${shQuote(job.id)} namespace=${shQuote(context.namespace)} managed_by=${shQuote(MANAGED_BY)} target_id=${shQuote(target.id)} instance_id=${shQuote(context.instanceId)} mark_cancel() { python3 - "$state_dir" <<'PY' import json, pathlib, sys state_dir = pathlib.Path(sys.argv[1]) status_path = state_dir / "status.json" if not status_path.exists(): print("not-found|false") raise SystemExit(0) try: state = str(json.loads(status_path.read_text()).get("state") or "unknown") except Exception: print("state-unreadable|false") raise SystemExit(0) if state in ("queued", "running"): cancel_path = state_dir / "cancel-requested" if cancel_path.exists(): print("cancel-already-requested|false") else: cancel_path.touch(mode=0o600, exist_ok=False) print("cancel-requested|true") else: print("already-terminal|false") PY } tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT if ! kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/namespace.json" 2>"$tmp/namespace.err"; then if ! grep -Eiq 'not[[:space:]-]*found' "$tmp/namespace.err"; then printf '{"ok":false,"mutation":false,"code":"runtime-query-failed","jobId":"%s","startTaskDisposition":"unknown","valuesPrinted":false}\n' "$job_id" exit 45 fi cancel_result="$(mark_cancel)" cancel_disposition="\${cancel_result%%|*}" cancel_mutation="\${cancel_result##*|}" printf '{"ok":true,"mutation":%s,"code":"already-absent","jobId":"%s","startTaskDisposition":"%s","valuesPrinted":false}\n' "$cancel_mutation" "$job_id" "$cancel_disposition" exit 0 fi actual_managed="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.app\\.kubernetes\\.io/managed-by}' 2>/dev/null || true)" actual_target="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o jsonpath='{.metadata.labels.pikaoa\\.unidesk\\.io/target}' 2>/dev/null || true)" actual_instance="$(kubectl get namespace "$namespace" --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -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 '{"ok":false,"mutation":false,"code":"namespace-ownership-mismatch","jobId":"%s","startTaskDisposition":"not-touched","valuesPrinted":false}\n' "$job_id" exit 42 fi cancel_result="$(mark_cancel)" cancel_disposition="\${cancel_result%%|*}" cancel_mutation="\${cancel_result##*|}" if ! kubectl delete namespace "$namespace" --wait=false --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s >/dev/null 2>"$tmp/delete.err"; then printf '{"ok":false,"mutation":%s,"code":"namespace-delete-failed","jobId":"%s","startTaskDisposition":"%s","valuesPrinted":false}\n' "$cancel_mutation" "$job_id" "$cancel_disposition" exit 43 fi printf '{"ok":true,"mutation":true,"code":"stop-requested","jobId":"%s","startTaskDisposition":"%s","valuesPrinted":false}\n' "$job_id" "$cancel_disposition" `; } 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") ?? {}; const labels = optionalRecord(metadata.labels, "status.namespace.metadata.labels") ?? {}; const resources = optionalRecord(runtime.resources, "status.runtime.resources") ?? {}; const items = Array.isArray(resources.items) ? resources.items.filter(isRecord) : []; const prometheus = prometheusRuntimeStatus(items, target.observability.prometheusSelector, runtime.exists === true, context.step); return { ok: parsed.ok === true, code: parsed.code ?? "start-status-unknown", taskCode: parsed.taskCode ?? "start-status-unknown", jobId: parsed.jobId ?? asyncJobIdentity(target, context).id, taskExists: parsed.taskExists === true, task: optionalRecord(parsed.task, "status.task"), runnerAlive: parsed.runnerAlive === true, logTail: Array.isArray(parsed.logTail) ? parsed.logTail.filter(isRecord) : [], mutation: false, runtime: { queried: runtime.queried === true, exists: runtime.exists === true, code: runtime.code ?? "runtime-status-unknown", ownership: runtime.exists !== true ? null : { 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, }; }), 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: { ...observabilitySummary(target), prometheus, blocking: false }, }, 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"); const task = status === null ? null : optionalRecord(status.task, "status.task"); 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 ?? "-")} 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`, ...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, validationOnly: target.validationOnly, namespace: target.namespace, exposure: target.exposure, ttlSeconds: target.ttlSeconds, taskState: target.taskState, 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, webTemplate: target.source.webImage, initializerTemplate: target.source.initializerImage } : { api: context.apiImage, worker: context.workerImage, web: context.webImage, initializer: context.initializerImage }, }; } 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 ?? "default"; const commit = options.commit ?? ""; const step = options.step; const commitFlag = step === "foundation" ? "" : ` --commit ${commit}`; return { plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step}`, status: `bun scripts/cli.ts pikaoa test-target status${configFlag} ${targetFlag} --instance ${instance}`, start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step} --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.action === "plan" || options.action === "start") && options.step !== "foundation" && 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, prometheusSelectorKey: string): Record { const metadata = optionalRecord(object.metadata, "metadata") ?? {}; const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {}; const spec = optionalRecord(object.spec, "spec") ?? {}; const template = optionalRecord(spec.template, "spec.template") ?? {}; const templateMetadata = optionalRecord(template.metadata, "spec.template.metadata") ?? {}; const podLabels = optionalRecord(templateMetadata.labels, "spec.template.metadata.labels") ?? {}; const podSpec = optionalRecord(template.spec, "spec.template.spec") ?? {}; const securityContext = optionalRecord(podSpec.securityContext, "spec.template.spec.securityContext") ?? {}; return { kind: object.kind ?? null, name: metadata.name ?? null, owned: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string", prometheusSelectorLabel: object.kind === "Deployment" && typeof podLabels[prometheusSelectorKey] === "string" ? podLabels[prometheusSelectorKey] : undefined, podFsGroup: securityContext.fsGroup, secretValuesRedacted: object.kind === "Secret" ? true : undefined, }; } function structuralFingerprint(manifest: Record[]): string { return manifestFingerprint(manifest, false); } function requestStructuralFingerprint(manifest: Record[]): string { return manifestFingerprint(manifest, true); } function manifestFingerprint(manifest: Record[], stableRequest: boolean): string { const normalized = manifest.map((object) => { let value = object.kind === "Secret" ? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, ""])) } : object; if (!stableRequest) return value; const metadata = optionalRecord(value.metadata, "metadata"); const annotations = metadata === null ? null : optionalRecord(metadata.annotations, "metadata.annotations"); if (metadata === null || annotations === null || annotations["pikaoa.unidesk.io/expires-at"] === undefined) return value; const stableAnnotations = { ...annotations }; delete stableAnnotations["pikaoa.unidesk.io/expires-at"]; value = { ...value, metadata: { ...metadata, annotations: stableAnnotations } }; return value; }); return `sha256:${createHash("sha256").update(JSON.stringify(normalized)).digest("hex")}`; } function secretSources(target: TestTargetSpec): SecretSourceSpec[] { return [target.database.connection, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password]; } 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`); 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[] { 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 imageRepository(value: unknown, path: string): string { const image = nonEmpty(value, path); if (/\s/u.test(image) || image.includes("@") || /:[^/]+$/u.test(image)) throw new Error(`${path} 必须是不带 tag/digest 的镜像仓库`); return image; } function relativeSourcePath(value: unknown, path: string): string { const sourcePath = nonEmpty(value, path); if (sourcePath.startsWith("/") || sourcePath.split(/[\\/]/u).includes("..")) throw new Error(`${path} 必须是无 .. 的仓库相对路径`); return sourcePath; } function otlpGrpcEndpoint(value: unknown, path: string): string { const endpoint = nonEmpty(value, path); if (endpoint.includes("://") || !/^[A-Za-z0-9.-]+:[1-9][0-9]{0,4}$/u.test(endpoint)) throw new Error(`${path} 必须是无 scheme 的 OTLP gRPC host:port`); const port = Number(endpoint.slice(endpoint.lastIndexOf(":") + 1)); if (port > 65_535) throw new Error(`${path} 端口必须是 1-65535`); return endpoint; } function prometheusSelector(value: unknown, path: string): PrometheusSelectorSpec { const sourceRef = nonEmpty(value, `${path}.configRef`); let resolved: ReturnType; try { resolved = resolveConfigRef(sourceRef, `${path}.configRef`); } catch (error) { throw new Error(`${path}.configRef 解析失败:${error instanceof Error ? error.message : String(error)}`); } const selector = record(resolved.value, `${path}.configRef (${sourceRef})`); const key = kubernetesLabelKey(nonEmpty(selector.key, `${path}.configRef.key`), `${path}.configRef.key`); const selectorValue = kubernetesLabelValue(nonEmpty(selector.value, `${path}.configRef.value`), `${path}.configRef.value`); return { sourceRef, key, value: selectorValue, presence: true, fingerprint: `sha256:${resolved.sha256}`, valuesPrinted: false }; } function kubernetesLabelKey(value: string, path: string): string { const parts = value.split("/"); const name = parts.pop() ?? ""; const prefix = parts.length === 1 ? parts[0] : null; const labelNamePattern = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u; const dnsPrefixPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/u; if (parts.length > 1 || !labelNamePattern.test(name) || (prefix !== null && !dnsPrefixPattern.test(prefix))) throw new Error(`${path} 必须是合法 Kubernetes label key`); return value; } function kubernetesLabelValue(value: string, path: string): string { if (!/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(value)) throw new Error(`${path} 必须是合法 Kubernetes label value`); return value; } 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 envName(value: unknown, path: string): string { const result = nonEmpty(value, path); if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${path} 必须是合法环境变量名`); return result; } function ipv4Address(value: unknown, path: string): string { const result = nonEmpty(value, path); const octets = result.split("."); if (octets.length !== 4 || octets.some((octet) => !/^(?:0|[1-9][0-9]{0,2})$/u.test(octet) || Number(octet) > 255)) throw new Error(`${path} 必须是 IPv4 地址`); return result; } function duration(value: unknown, path: string): string { const result = nonEmpty(value, path); if (!/^[1-9][0-9]*(?:ms|s|m|h)$/u.test(result)) throw new Error(`${path} 必须是正 duration`); 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 absolutePath(value: unknown, path: string): string { const result = nonEmpty(value, path); if (!result.startsWith("/") || result.includes("\0")) throw new Error(`${path} 必须是绝对路径`); return result; } function taskStateRoot(value: unknown, path: string): string { const result = absolutePath(value, path).replace(/\/+$/u, ""); if (result.length === 0) throw new Error(`${path} 不能是文件系统根目录`); return result; } function nonEmptyStringList(value: unknown, path: string): string[] { if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`); return value.map((item, index) => nonEmpty(item, `${path}[${index}]`)); } function enumStringList(value: unknown, path: string, allowed: readonly T[]): T[] { const items = nonEmptyStringList(value, path).map((item) => enumString(item, path, allowed)); if (new Set(items).size !== items.length) throw new Error(`${path} 不能包含重复值`); return items; } 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 positiveSafeInteger(value: unknown, path: string): number { if (!Number.isSafeInteger(value) || Number(value) <= 0) throw new Error(`${path} 必须是正安全整数`); return Number(value); } function nonNegativeInteger(value: unknown, path: string): number { if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error(`${path} 必须是非负安全整数`); 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"; }