Files
pikasTech-unidesk/scripts/src/pikaoa-test-target.ts
T
2026-07-15 10:57:25 +02:00

1872 lines
91 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, unknown>>;
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<string, unknown> {
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=falsestart/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<RenderedCliResult> {
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 ?? "<missing>", ["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<string, unknown>).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<string, unknown>, 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.<id>");
}
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.<id>");
}
return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "releaseRuntime");
}
function planPayload(options: TestTargetOptions, selection: Selection): Record<string, unknown> {
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<string, unknown> {
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 <test-target> --commit <sha>",
} : nextCommands(options, selection.target),
valuesPrinted: false,
};
}
function renderedPlan(selection: Selection, context: RenderContext): Record<string, unknown> {
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<RenderedCliResult> {
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<RenderedCliResult> {
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<RenderedCliResult> {
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<string, unknown> {
return {
otlpEndpoint: target.observability.otlpEndpoint,
prometheus: {
scrape: target.observability.prometheusScrape,
selector: target.observability.prometheusSelector,
},
valuesPrinted: false,
};
}
function prometheusRuntimeStatus(items: Record<string, unknown>[], selector: PrometheusSelectorSpec, runtimePresent: boolean, step: TestTargetStep): Record<string, unknown> {
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<string, unknown> {
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<Record<string, unknown>>;
blockers: Blocker[];
} {
const sources = secretSources(target);
const values = new Map<string, string>();
const summaries: Array<Record<string, unknown>> = [];
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<string, unknown> {
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, unknown>[]): 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 </dev/null &
pid=$!
printf '%s\n' "$pid" >"$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<string, unknown>[], job: AsyncJobIdentity): string {
const initializer = manifest.filter((object) => object.kind === "Job");
const workload = (name: string): Record<string, unknown>[] => 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, unknown>[]): 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<string, unknown>, target: TestTargetSpec, context: RenderContext, configPath: string): Record<string, unknown> {
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<string, unknown>, 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, unknown>): 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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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 ?? "<sha>";
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<string, string> {
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<string, string> {
return { "app.kubernetes.io/name": "pikaoa", "app.kubernetes.io/component": component, [INSTANCE_LABEL]: instanceId };
}
function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): Record<string, string> {
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<string, unknown>, prometheusSelectorKey: string): Record<string, unknown> {
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, unknown>[]): string {
return manifestFingerprint(manifest, false);
}
function requestStructuralFingerprint(manifest: Record<string, unknown>[]): string {
return manifestFingerprint(manifest, true);
}
function manifestFingerprint(manifest: Record<string, unknown>[], 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, "<redacted>"])) }
: 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, unknown>): 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<string>();
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<typeof resolveConfigRef>;
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<string, unknown> {
if (!isRecord(value)) throw new Error(`${path} 必须是 YAML object`);
return value;
}
function optionalRecord(value: unknown, path: string): Record<string, unknown> | null {
if (value === undefined || value === null) return null;
return record(value, path);
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<T extends string>(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<T extends string>(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";
}