1872 lines
91 KiB
TypeScript
1872 lines
91 KiB
TypeScript
// 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=false,start/stop 在远端调用前失败。",
|
||
"validationOnly target 只用于本地渲染,不连接 route。",
|
||
"start 可按 foundation、init、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。",
|
||
"正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。",
|
||
"Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。",
|
||
],
|
||
};
|
||
}
|
||
|
||
export async function runPikaoaCommand(config: UniDeskConfig, args: string[], remoteCapture: RemoteCapture = capture): Promise<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";
|
||
}
|