Files
pikasTech-unidesk/scripts/src/pikaoa-test-target.ts
T

1678 lines
82 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";
type TestTargetAction = "plan" | "status" | "start" | "stop";
type TestTargetStep = "all" | "migration" | "api" | "worker" | "web";
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 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;
apiImage: string;
workerImage: string;
webImage: string;
migrationImage: string;
pullPolicy: string;
};
database: {
configRef: string;
name: string;
username: string;
schema: string;
connection: SecretSourceSpec;
};
runtime: {
secretName: string;
apiPort: number;
workerMetricsPort: number;
webPort: number;
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;
};
migration: {
jobName: string;
backoffLimit: number;
command: string[];
};
exposure: {
serviceName: string;
serviceType: "NodePort";
hostIP: string;
port: number;
};
observability: {
otlpEndpoint: string;
prometheusScrape: boolean;
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;
}
interface RenderContext {
instanceId: string;
commit: string;
step: TestTargetStep;
namespace: string;
expiresAt: string;
apiImage: string;
workerImage: string;
webImage: string;
migrationImage: string;
}
interface SecretMaterial {
databaseURL: string;
sessionSecret: string;
administratorPassword: string;
employeePassword: string;
}
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 migration|api|worker|web|all] --confirm",
"bun scripts/cli.ts pikaoa test-target stop --target id --instance id --confirm",
],
source: "config/pikaoa.yaml#testRuntime",
guarantees: [
"未声明专用 target 时 plan/status 返回 configured=falsestart/stop 在远端调用前失败。",
"validationOnly target 只用于本地渲染,不连接 route。",
"start 可按 migration、api、worker、web 或 all 单步提交并立即返回;status 用短连接读取有界状态。",
"正式 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 || args.some(isHelpToken)) return renderMachine("pikaoa test-target", pikaoaTestTargetHelp(), "json");
if (args[0] !== "test-target") throw inputError("pikaoa 只支持 test-target", "unsupported-pikaoa-command", "pikaoa", ["test-target"]);
const options = parseOptions(args.slice(1));
const selection = readSelection(options);
if (options.action === "plan") return renderResult(planPayload(options, selection), options);
if (options.action === "status") return await statusResult(config, options, selection, 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", "migration", "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): TestTargetSpec {
const path = `${configLabel}.testRuntime.targets.${id}`;
simpleId(id, "target id");
const source = record(root.source, `${path}.source`);
const images = record(source.images, `${path}.source.images`);
const database = record(root.database, `${path}.database`);
const runtime = record(root.runtime, `${path}.runtime`);
const 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 migration = record(root.migration, `${path}.migration`);
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"),
apiImage: imageReference(images.api, `${path}.source.images.api`),
workerImage: imageReference(images.worker, `${path}.source.images.worker`),
webImage: imageReference(images.web, `${path}.source.images.web`),
migrationImage: imageReference(images.migration, `${path}.source.images.migration`),
pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]),
},
database: {
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: {
secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63),
apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535),
workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535),
webPort: positiveInteger(runtime.webPort, `${path}.runtime.webPort`, 65_535),
attachment: {
storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`),
maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER),
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`),
},
migration: {
jobName: kubernetesName(nonEmpty(migration.jobName, `${path}.migration.jobName`), `${path}.migration.jobName`, 63),
backoffLimit: nonNegativeInteger(migration.backoffLimit, `${path}.migration.backoffLimit`),
command: nonEmptyStringList(migration.command, `${path}.migration.command`),
},
exposure: {
serviceName: kubernetesName(nonEmpty(exposure.serviceName, `${path}.exposure.serviceName`), `${path}.exposure.serviceName`, 63),
serviceType: exactString(exposure.serviceType, `${path}.exposure.serviceType`, "NodePort") as "NodePort",
hostIP: ipv4Address(exposure.hostIP, `${path}.exposure.hostIP`),
port: positiveInteger(exposure.port, `${path}.exposure.port`, 65_535),
},
observability: {
otlpEndpoint: nonEmpty(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`),
prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`),
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;
}
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";
const context = renderContext(options, selection.target, requireCommit);
const blockers = safetyBlockers(options, selection, context, requireCommit);
if (options.action === "plan" || options.action === "start") {
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.action === "plan" || options.action === "start");
return {
...base,
target: targetSummary(selection.target),
instance: context === null ? null : {
id: context.instanceId,
namespace: context.namespace,
...((options.action === "plan" || options.action === "start") ? { expiresAt: context.expiresAt } : {}),
},
source: renderWorkloads && context !== null ? sourceSummary(selection.target, context.commit, context) : sourceSummary(selection.target, options.commit),
readyForMutation: blockers.length === 0 && !selection.target.validationOnly,
missingFields: missingMutationFields(options, selection),
blockers,
warnings,
plan: renderWorkloads && context !== null ? renderedPlan(selection, context) : null,
next: nextCommands(options, selection.target),
};
}
function basePayload(options: TestTargetOptions, selection: Selection): Record<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 = buildManifest(target, context, placeholderSecrets());
return {
namespace: {
name: context.namespace,
labels,
annotations: namespaceAnnotations(target, context),
ttlSeconds: target.ttlSeconds,
cleanup: target.cleanup,
},
objects: structuralManifest.map(objectSummary),
selectors,
secret: {
name: target.runtime.secretName,
valuesPrinted: false,
sources: secretSources(target).map((source) => secretSourceSummary(selection.configPath, source)),
},
database: databasePlanSummary(target),
runtimeConfig: {
secretName: target.runtime.secretName,
key: "pikaoa.yaml",
mountPath: "/etc/pikaoa/pikaoa.yaml",
consumers: ["migration", "api", "worker"],
valuesPrinted: false,
},
probes: {
api: { liveness: target.probes.apiLivenessPath, readiness: target.probes.apiReadinessPath, metrics: target.observability.apiMetricsPath },
worker: { liveness: target.probes.workerLivenessPath, readiness: target.probes.workerReadinessPath, metrics: target.observability.workerMetricsPath },
web: { liveness: target.probes.webLivenessPath, readiness: target.probes.webReadinessPath },
},
observability: {
otlpEndpoint: target.observability.otlpEndpoint,
prometheusScrape: target.observability.prometheusScrape,
exporterFailure: { warning: target.observability.exporterWarning, blocking: false },
},
exposure: { serviceType: target.exposure.serviceType, serviceName: target.exposure.serviceName, hostIP: target.exposure.hostIP, port: target.exposure.port },
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,
},
migration: {
jobName: target.migration.jobName,
image: context.migrationImage,
command: target.migration.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 context = selection.target === null ? null : renderContext(options, selection.target);
const blockers = safetyBlockers(options, selection, context);
if (!options.confirm) blockers.push({ code: "confirmation-required", field: "--confirm", message: "start 需要显式 --confirm。" });
if (selection.target?.validationOnly === true) blockers.push({ code: "validation-only-target", field: "testRuntime.targets.*.validationOnly", message: "validationOnly target 禁止远端操作。" });
if (selection.target === null || context === null || blockers.length > 0) {
return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: uniqueBlockers(blockers), failure: "preflight-blocked" }, options);
}
const material = readSecretMaterial(selection, selection.target);
if (!material.ok) {
return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: material.blockers, secret: { sources: material.sources, valuesPrinted: false }, failure: "secret-preflight-blocked" }, options);
}
const manifest = buildManifest(selection.target, context, material.values);
const remote = await 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,
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 ?? "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),
migrationImage: renderImage(target.source.migrationImage, 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: "start 必须选择源码 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.migration.command.includes("migrate") || !target.migration.command.includes("up")) warnings.push(warning("migration-command-unrecognized", "迁移命令未显示产品 migrate up;配置一致性只报告 warning。"));
if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.migrationImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。"));
if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.migrationImage].every((image) => image.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。"));
if (target.observability.exporterWarning) warnings.push(warning("otel-exporter-warning-only", "OTel exporter 失败按 owning YAML 仅报告 warning,不阻塞业务运行。"));
if (!target.observability.prometheusScrape) warnings.push(warning("prometheus-scrape-disabled", "Prometheus scrape 已在 target YAML 中关闭。"));
return warnings;
}
function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, unknown>[] {
const labels = ownershipLabels(target, context);
const secretData = runtimeSecretData(target, context, secrets);
const apiSelector = componentSelector("api", context.instanceId);
const workerSelector = componentSelector("worker", context.instanceId);
const webSelector = componentSelector("web", context.instanceId);
const migrationSelector = componentSelector("migration", context.instanceId);
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
"prometheus.io/scrape": "true",
"prometheus.io/path": path,
"prometheus.io/port": String(port),
} : {};
return [
{
apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) },
},
{
apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque", stringData: secretData,
},
{
apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels },
spec: {
accessModes: target.runtime.attachment.accessModes,
resources: { requests: { storage: target.runtime.attachment.storageRequest } },
...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }),
},
},
{
apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-api", namespace: context.namespace, labels: { ...labels, ...apiSelector } },
spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] },
},
{
apiVersion: "v1", kind: "Service", metadata: { name: target.exposure.serviceName, namespace: context.namespace, labels: { ...labels, ...webSelector } },
spec: {
type: target.exposure.serviceType,
selector: webSelector,
ports: [{ name: "http", port: target.runtime.webPort, targetPort: target.runtime.webPort, nodePort: target.exposure.port }],
},
},
{
apiVersion: "batch/v1", kind: "Job", metadata: { name: target.migration.jobName, namespace: context.namespace, labels: { ...labels, ...migrationSelector } },
spec: {
backoffLimit: target.migration.backoffLimit,
template: {
metadata: { labels: { ...labels, ...migrationSelector } },
spec: {
restartPolicy: "Never",
containers: [{
name: "migration", image: context.migrationImage, imagePullPolicy: target.source.pullPolicy,
command: target.migration.command,
env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }],
volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }],
}],
volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }],
},
},
},
},
deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [
{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" },
{ name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } },
{ name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username },
{ name: "PIKAOA_BOOTSTRAP_ADMIN_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.administrator.password.targetKey } } },
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username },
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } },
], target.probes.apiLivenessPath, target.probes.apiReadinessPath),
deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [
{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" },
], target.probes.workerLivenessPath, target.probes.workerReadinessPath),
deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, [], target.probes.webLivenessPath, target.probes.webReadinessPath),
];
}
function deployment(
target: TestTargetSpec,
context: RenderContext,
component: "api" | "worker" | "web",
image: string,
selector: Record<string, string>,
port: number,
annotations: Record<string, string>,
env: Array<Record<string, unknown>>,
livenessPath: string,
readinessPath: string,
): Record<string, unknown> {
const labels = ownershipLabels(target, context);
return {
apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } },
spec: {
replicas: 1, selector: { matchLabels: selector },
template: {
metadata: { labels: { ...labels, ...selector }, annotations },
spec: {
...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}),
containers: [{
name: component, image, imagePullPolicy: target.source.pullPolicy, env,
ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }],
volumeMounts: [
...(component === "web" ? [] : [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }]),
...(component === "api" ? [{ name: target.runtime.attachment.claimName, mountPath: target.runtime.attachment.storageRoot }] : []),
],
livenessProbe: { httpGet: { path: livenessPath, port }, periodSeconds: 5, failureThreshold: 12 },
readinessProbe: { httpGet: { path: readinessPath, port }, periodSeconds: 3, failureThreshold: 20 },
}],
volumes: [
...(component === "web" ? [] : [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }]),
...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []),
],
},
},
},
};
}
function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, string> {
const databaseURL = renderDatabaseURL(target, secrets.databaseURL);
return {
[target.database.connection.targetKey]: databaseURL,
[target.runtime.sessionSecret.targetKey]: secrets.sessionSecret,
[target.runtime.administrator.password.targetKey]: secrets.administratorPassword,
[target.runtime.employee.password.targetKey]: secrets.employeePassword,
"pikaoa.yaml": [
"api:",
` listen: 0.0.0.0:${target.runtime.apiPort}`,
"worker:",
` interval: ${target.runtime.workerInterval}`,
` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`,
" outbox:",
` batchSize: ${target.runtime.outboxBatchSize}`,
` maxAttempts: ${target.runtime.outboxMaxAttempts}`,
` retryDelay: ${target.runtime.outboxRetryDelay}`,
"database:",
` url: ${JSON.stringify(databaseURL)}`,
"identity:",
` sessionTTL: ${target.runtime.sessionTTL}`,
"attachment:",
` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`,
` maxBytes: ${target.runtime.attachment.maxBytes}`,
"observability:",
` serviceName: pikaoa-test-${context.instanceId}`,
` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`,
`shutdownTimeout: ${target.runtime.shutdownTimeout}`,
"",
].join("\n"),
};
}
function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string {
const databaseURL = new URL(sourceURL);
databaseURL.searchParams.set("search_path", target.database.schema);
return databaseURL.toString();
}
function databasePlanSummary(target: TestTargetSpec): Record<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 requestId = createHash("sha256")
.update(target.id)
.update("\0")
.update(context.instanceId)
.update("\0")
.update(context.commit)
.update("\0")
.update(context.step)
.digest("hex");
const runnerEncoded = Buffer.from(startRunnerScript(target, context, manifest, job), "utf8").toString("base64");
return `
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)}
emit_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 ""
if actual_request != expected_request:
print(json.dumps({"ok": False, "mutation": False, "code": "start-instance-conflict", "jobId": job_id, "submitted": False, "task": None, "valuesPrinted": False}))
raise SystemExit(0)
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(0)
state = str(task.get("state") or "unknown")
runner_alive = False
pid = task.get("pid")
if isinstance(pid, int) and pid > 0:
try:
os.kill(pid, 0)
runner_alive = True
except OSError:
pass
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 state in ("queued", "running") and not runner_alive 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}))
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
if ! mkdir "$state_dir" 2>/dev/null; then
if [ -d "$state_dir" ]; then emit_existing; exit 0; fi
printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-create-failed","valuesPrinted":false}'
exit 42
fi
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,"jobId":"%s","pid":%s,"task":{"state":"running","phase":"submitted","code":"start-running"},"valuesPrinted":false}\n' "$job_id" "$pid"
`;
}
function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[], job: AsyncJobIdentity): string {
const migration = 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;
return optionalRecord(object.metadata, "metadata")?.name === `pikaoa-${name}`;
});
const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment" && object.kind !== "Service");
const encode = (objects: Record<string, unknown>[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64");
const foundationEncoded = encode(foundation);
const migrationEncoded = encode(migration);
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(migrationEncoded)} | base64 -d >"$tmp/migration.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" = all ] || [ "$step" = migration ]; then
current_phase=migration-apply
write_status running "$current_phase" start-running null
check_canceled
kubectl -n "$namespace" delete job/${target.migration.jobName} --ignore-not-found --wait=false >/dev/null 2>&1 || true
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then failure_code=migration-apply-failed; exit 45; fi
current_phase=migration-wait
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" wait --for=condition=complete job/${target.migration.jobName} --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/migration-wait.err"; then failure_code=migration-wait-failed; exit 46; fi
fi
for component in api worker web; do
if [ "$step" != all ] && [ "$step" != "$component" ]; then continue; fi
current_phase="$component-apply"
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/$component.yaml" >"$tmp/$component-apply.out" 2>"$tmp/$component-apply.err"; then failure_code="$component-apply-failed"; exit 47; fi
current_phase="$component-rollout"
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" rollout status "deployment/pikaoa-$component" --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/$component-wait.err"; then failure_code="$component-rollout-wait-failed"; exit 48; fi
done
`;
}
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) : [];
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: { otlpEndpoint: target.observability.otlpEndpoint, prometheusScrape: target.observability.prometheusScrape, 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, migrationTemplate: target.source.migrationImage }
: { api: context.apiImage, worker: context.workerImage, web: context.webImage, migration: context.migrationImage },
};
}
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;
return {
plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance} --commit ${commit}`,
status: `bun scripts/cli.ts pikaoa test-target status${configFlag} ${targetFlag} --instance ${instance}`,
start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance} --commit ${commit} --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.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>): 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 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",
podFsGroup: securityContext.fsGroup,
secretValuesRedacted: object.kind === "Secret" ? true : undefined,
};
}
function structuralFingerprint(manifest: Record<string, unknown>[]): string {
const redacted = manifest.map((object) => object.kind === "Secret" ? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, "<redacted>"])) } : object);
return `sha256:${createHash("sha256").update(JSON.stringify(redacted)).digest("hex")}`;
}
function placeholderSecrets(): SecretMaterial {
return { databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require", sessionSecret: "<redacted>", administratorPassword: "<redacted>", employeePassword: "<redacted>" };
}
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 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 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";
}