feat(pikaoa): 声明 NC01 测试运行面

This commit is contained in:
Codex
2026-07-15 03:30:51 +02:00
parent 3464e93acd
commit 035817deef
4 changed files with 383 additions and 185 deletions
+16 -7
View File
@@ -13,10 +13,9 @@ testRuntime:
TEST01:
enabled: true
validationOnly: true
dedicated: true
node: TEST01
route: TEST01:k3s
namespacePrefix: pikaoa-test
namespace: pikaoa-test
ttlSeconds: 7200
waitTimeoutSeconds: 180
fieldManager: unidesk-pikaoa-test-target
@@ -34,21 +33,23 @@ testRuntime:
images:
api: registry.invalid/pikaoa/api:${commit}
worker: registry.invalid/pikaoa/worker:${commit}
web: registry.invalid/pikaoa/web:${commit}
migration: registry.invalid/pikaoa/migration:${commit}
pullPolicy: IfNotPresent
database:
image: postgres:16-alpine
configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url
name: pikaoa_test
username: pikaoa_test
schema: pikaoa
port: 5432
password:
sourceRef: ./secrets/pikaoa-test-database-password.txt
targetKey: PIKAOA_DATABASE_PASSWORD
connection:
sourceRef: ./secrets/pikaoa-test-database-url.env
sourceKey: DATABASE_URL
targetKey: DATABASE_URL
runtime:
secretName: pikaoa-test-runtime
apiPort: 8080
workerMetricsPort: 8081
webPort: 8080
attachment:
storageRoot: /var/lib/pikaoa/attachments
maxBytes: 104857600
@@ -86,6 +87,11 @@ testRuntime:
- up
- --config
- /etc/pikaoa/pikaoa.yaml
exposure:
serviceName: pikaoa-web
serviceType: NodePort
hostIP: 192.0.2.10
port: 32080
observability:
otlpEndpoint: http://otel-collector.observability.svc.cluster.local:4318
prometheus:
@@ -102,6 +108,9 @@ testRuntime:
worker:
livenessPath: /healthz
readinessPath: /readyz
web:
livenessPath: /healthz
readinessPath: /healthz
delivery:
targets:
+104 -3
View File
@@ -27,9 +27,110 @@ modules:
workers: named-consumer
testRuntime:
# 专用测试集群 target 由用户明确声明后才能启用;空对象表示当前不允许远端测试运行。
defaultTargetId: null
targets: {}
defaultTargetId: NC01
targets:
NC01:
enabled: true
validationOnly: false
node: NC01
route: NC01:k3s
namespace: pikaoa-test
ttlSeconds: 86400
waitTimeoutSeconds: 300
fieldManager: unidesk-pikaoa-test-target
taskState:
rootPath: /var/lib/unidesk/pikaoa-test-target
statusRequestTimeoutSeconds: 10
logTailLines: 80
cleanup:
mode: delete-namespace
requireOwnershipLabels: true
source:
mode: prebuilt-images
repository: pikainc/pikaoa
commitPolicy: cli-required
images:
api: 127.0.0.1:5000/pikaoa/api:${commit}
worker: 127.0.0.1:5000/pikaoa/worker:${commit}
web: 127.0.0.1:5000/pikaoa/web:${commit}
migration: 127.0.0.1:5000/pikaoa/api:${commit}
pullPolicy: IfNotPresent
database:
configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url
name: pikaoa_test
username: pikaoa_test
schema: pikaoa
connection:
sourceRef: /root/.unidesk/.state/secrets/platform-infra/pikaoa-test.env
sourceKey: DATABASE_URL
targetKey: DATABASE_URL
runtime:
secretName: pikaoa-test-runtime
apiPort: 8080
workerMetricsPort: 8081
webPort: 8080
attachment:
storageRoot: /var/lib/pikaoa/attachments
maxBytes: 104857600
claimName: pikaoa-attachments
storageRequest: 10Gi
storageClassName: null
accessModes:
- ReadWriteOnce
workerInterval: 2s
outbox:
batchSize: 50
maxAttempts: 4
retryDelay: 2s
sessionTTL: 8h
shutdownTimeout: 10s
administrator:
username: admin
password:
sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt
targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD
employee:
username: employee
password:
sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt
targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD
sessionSecret:
sourceRef: ~/.unidesk/.env/pikaoa-session-secret.txt
targetKey: PIKAOA_SESSION_SECRET
migration:
jobName: pikaoa-migrate
backoffLimit: 2
command:
- /usr/local/bin/pikaoa
- migrate
- up
- --config
- /etc/pikaoa/pikaoa.yaml
exposure:
serviceName: pikaoa-web
serviceType: NodePort
hostIP: 152.53.229.148
port: 32080
observability:
configRef: config/platform-infra/observability.yaml
otlpEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318
prometheus:
scrape: true
apiPath: /metrics
workerPath: /metrics
exporterFailure:
warning: true
blocking: false
probes:
api:
livenessPath: /healthz
readinessPath: /readyz
worker:
livenessPath: /healthz
readinessPath: /readyz
web:
livenessPath: /healthz
readinessPath: /healthz
delivery:
targets:
+59 -30
View File
@@ -21,7 +21,7 @@ test("PikaOA test target submits once and exposes bounded typed status", async (
const secretValues = ["fixture-db-password", "fixture-admin-password", "fixture-employee-password", "fixture-session-secret"];
for (const [name, value] of [
["pikaoa-test-database-password.txt", secretValues[0]],
["pikaoa-test-database-url.env", `DATABASE_URL=postgresql://pikaoa_test:${secretValues[0]}@db.example.invalid:5432/pikaoa_test?sslmode=require`],
["pikaoa-test-admin-password.txt", secretValues[1]],
["pikaoa-test-employee-password.txt", secretValues[2]],
["pikaoa-test-session-secret.txt", secretValues[3]],
@@ -53,18 +53,20 @@ if printf '%s' "$args" | grep -Eq '(^| )apply( |$)'; then
previous="$argument"
done
if grep -q '"kind":"Namespace"' "$file"; then
applied_namespace="$(python3 - "$file" <<'PY'
applied_identity="$(python3 - "$file" <<'PY'
import json, pathlib, sys
for line in pathlib.Path(sys.argv[1]).read_text().splitlines():
if not line or line == "---":
continue
item = json.loads(line)
if item.get("kind") == "Namespace":
print(item["metadata"]["name"])
print(item["metadata"]["name"] + "|" + item["metadata"]["labels"]["pikaoa.unidesk.io/instance"])
break
PY
)"
: >"$root/namespaces/$applied_namespace"
applied_namespace="\${applied_identity%%|*}"
applied_instance="\${applied_identity##*|}"
printf '%s' "$applied_instance" >"$root/namespaces/$applied_namespace"
fi
[ ! -f "$root/slow" ] || sleep 0.2
exit 0
@@ -88,14 +90,14 @@ if printf '%s' "$args" | grep -Eq '(^| )get namespace '; then
printf 'Error from server (NotFound): namespaces "%s" not found\n' "$name" >&2
exit 1
fi
instance="\${name#pikaoa-test-}"
instance="$(cat "$root/namespaces/$name")"
if printf '%s' "$args" | grep -q 'managed-by'; then printf '%s' 'unidesk-pikaoa-test-target'; exit 0; fi
if printf '%s' "$args" | grep -q 'unidesk.*target'; then printf '%s' 'TEST01'; exit 0; fi
if printf '%s' "$args" | grep -q 'unidesk.*instance'; then printf '%s' "$instance"; exit 0; fi
printf '{"metadata":{"name":"%s","labels":{"app.kubernetes.io/managed-by":"unidesk-pikaoa-test-target","pikaoa.unidesk.io/target":"TEST01","pikaoa.unidesk.io/instance":"%s"}}}\n' "$name" "$instance"
exit 0
fi
if printf '%s' "$args" | grep -q ' get deployment,statefulset,job,service,persistentvolumeclaim,pod '; then
if printf '%s' "$args" | grep -q ' get deployment,job,service,persistentvolumeclaim,pod '; then
if [ -f "$root/fail-runtime-resource-query" ]; then
printf '%s\n' 'resource query failed' >&2
exit 1
@@ -137,19 +139,20 @@ exit 64
lastCapture = { stdout, stderr, exitCode };
return lastCapture;
};
const args = (action: string, instance: string, withCommit = false): string[] => [
const args = (action: string, instance: string, withCommit = false, step = "all"): string[] => [
"test-target", action, "--config", fixturePath, "--target", "TEST01", "--instance", instance,
...(withCommit ? ["--commit", "0123456789abcdef"] : []),
"--step", step,
...((action === "start" || action === "stop") ? ["--confirm"] : []),
"--output", "json",
];
const projection = async (action: string, instance: string, withCommit = false): Promise<Record<string, unknown>> => {
const result = await runPikaoaCommand(config, args(action, instance, withCommit), localCapture);
const projection = async (action: string, instance: string, withCommit = false, step = "all"): Promise<Record<string, unknown>> => {
const result = await runPikaoaCommand(config, args(action, instance, withCommit, step), localCapture);
return JSON.parse(result.renderedText) as Record<string, unknown>;
};
const waitFor = async (instance: string, expectedCode: string): Promise<Record<string, unknown>> => {
const waitFor = async (instance: string, expectedCode: string, step = "all"): Promise<Record<string, unknown>> => {
for (let attempt = 0; attempt < 80; attempt += 1) {
const value = await projection("status", instance);
const value = await projection("status", instance, false, step);
const status = value.status as Record<string, unknown>;
if (status.code === expectedCode) return value;
await Bun.sleep(25);
@@ -158,11 +161,21 @@ exit 64
};
try {
const missing = await projection("status", "missing");
const planned = await projection("plan", "runtime", true);
const plan = planned.plan as Record<string, unknown>;
const secret = plan.secret as Record<string, unknown>;
const sources = secret.sources as Array<Record<string, unknown>>;
assert.equal(sources.find((source) => source.purpose === "database.connection")?.presence, true);
const objects = plan.objects as Array<Record<string, unknown>>;
assert.equal(objects.some((object) => object.kind === "StatefulSet" || object.name === "pikaoa-postgres"), false);
assert.equal(objects.some((object) => object.kind === "Deployment" && object.name === "pikaoa-web"), true);
assert.deepEqual(plan.exposure, { serviceType: "NodePort", serviceName: "pikaoa-web", hostIP: "192.0.2.10", port: 32080 });
const missing = await projection("status", "runtime", false, "api");
assert.equal((missing.status as Record<string, unknown>).code, "start-not-found");
writeFileSync(join(fakeRoot, "slow"), "1\n");
const submitted = await projection("start", "success", true);
const submitted = await projection("start", "runtime", true);
assert.equal(submitted.code, "start-submitted", JSON.stringify(lastCapture));
assert.equal(submitted.mutation, true);
assert.deepEqual((submitted.target as Record<string, unknown>).taskState, {
@@ -170,27 +183,27 @@ exit 64
statusRequestTimeoutSeconds: 31,
logTailLines: 101,
});
const running = await projection("status", "success");
const running = await projection("status", "runtime");
assert.equal((running.status as Record<string, unknown>).code, "start-running");
rmSync(join(fakeRoot, "slow"));
const succeeded = await waitFor("success", "start-succeeded");
const succeeded = await waitFor("runtime", "start-succeeded");
const succeededStatus = succeeded.status as Record<string, unknown>;
assert.equal((succeededStatus.task as Record<string, unknown>).exitCode, 0);
assert.ok((succeededStatus.logTail as unknown[]).length <= 101);
writeFileSync(join(fakeRoot, "fail-runtime-query"), "1\n");
const runtimeQueryFailed = await projection("status", "success");
const runtimeQueryFailed = await projection("status", "runtime");
const runtimeQueryFailedStatus = runtimeQueryFailed.status as Record<string, unknown>;
assert.equal(runtimeQueryFailed.ok, false);
assert.equal(runtimeQueryFailedStatus.code, "runtime-query-failed");
assert.equal(runtimeQueryFailedStatus.taskCode, "start-succeeded");
assert.equal((runtimeQueryFailedStatus.runtime as Record<string, unknown>).code, "runtime-query-failed");
const runtimeQueryFailedText = await runPikaoaCommand(config, args("status", "success").slice(0, -2), localCapture);
const runtimeQueryFailedText = await runPikaoaCommand(config, args("status", "runtime").slice(0, -2), localCapture);
assert.match(runtimeQueryFailedText.renderedText, /status=runtime-query-failed/);
rmSync(join(fakeRoot, "fail-runtime-query"));
writeFileSync(join(fakeRoot, "fail-runtime-resource-query"), "1\n");
const runtimeResourceQueryFailed = await projection("status", "success");
const runtimeResourceQueryFailed = await projection("status", "runtime");
const runtimeResourceQueryFailedStatus = runtimeResourceQueryFailed.status as Record<string, unknown>;
assert.equal(runtimeResourceQueryFailed.ok, false);
assert.equal(runtimeResourceQueryFailedStatus.code, "runtime-resource-query-failed");
@@ -198,28 +211,28 @@ exit 64
assert.equal((runtimeResourceQueryFailedStatus.runtime as Record<string, unknown>).code, "runtime-resource-query-failed");
rmSync(join(fakeRoot, "fail-runtime-resource-query"));
const repeated = await projection("start", "success", true);
const repeated = await projection("start", "runtime", true);
assert.equal(repeated.code, "start-already-succeeded");
assert.equal(repeated.mutation, false);
writeFileSync(join(fakeRoot, "fail-migration"), "1\n");
await projection("start", "failed", true);
const failed = await waitFor("failed", "start-failed");
await projection("start", "runtime", true, "migration");
const failed = await waitFor("runtime", "start-failed", "migration");
const failedTask = ((failed.status as Record<string, unknown>).task as Record<string, unknown>);
assert.equal(failedTask.code, "migration-wait-failed");
assert.equal(failedTask.exitCode, 46);
rmSync(join(fakeRoot, "fail-migration"));
writeFileSync(join(fakeRoot, "slow"), "1\n");
await projection("start", "cancel", true);
const stopped = await projection("stop", "cancel");
await projection("start", "runtime", true, "web");
const stopped = await projection("stop", "runtime", false, "web");
assert.equal(stopped.startTaskDisposition, "cancel-requested");
assert.equal(stopped.mutation, true);
await waitFor("cancel", "start-canceled");
await waitFor("runtime", "start-canceled", "web");
const serialized = JSON.stringify({ submitted, succeeded, failed, stopped });
for (const value of secretValues) assert.equal(serialized.includes(value), false);
assert.equal(serialized.includes("postgres://pikaoa_test:"), false);
assert.equal(serialized.includes("postgresql://pikaoa_test:"), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -245,7 +258,16 @@ test("validation-only fixture never invokes remote capture", async () => {
const planPayload = JSON.parse(planResult.renderedText) as Record<string, unknown>;
const plan = planPayload.plan as Record<string, unknown>;
assert.deepEqual(plan.database, {
configRef: "config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-test-database-url",
name: "pikaoa_test",
username: "pikaoa_test",
schema: "pikaoa",
connection: {
sourceRef: "./secrets/pikaoa-test-database-url.env",
sourceKey: "DATABASE_URL",
targetKey: "DATABASE_URL",
valuesPrinted: false,
},
dsnParameterPresence: { sslmode: true, search_path: true },
valuesPrinted: false,
});
@@ -277,9 +299,16 @@ test("validation-only fixture never invokes remote capture", async () => {
rmSync(missingSchemaRoot, { recursive: true, force: true });
}
const unconfigured = await runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--output", "json"], forbiddenCapture);
const unconfiguredPayload = JSON.parse(unconfigured.renderedText) as Record<string, unknown>;
assert.equal(calls, 0);
assert.equal(unconfiguredPayload.configured, false);
assert.equal(unconfiguredPayload.remoteQueried, false);
const unconfiguredRoot = mkdtempSync(join(tmpdir(), "pikaoa-test-target-unconfigured-"));
const unconfiguredPath = join(unconfiguredRoot, "pikaoa.yaml");
writeFileSync(unconfiguredPath, "version: 1\nkind: pikaoa-platform-delivery\ntestRuntime:\n defaultTargetId: null\n targets: {}\n");
try {
const unconfigured = await runPikaoaCommand({} as UniDeskConfig, ["test-target", "status", "--config", unconfiguredPath, "--output", "json"], forbiddenCapture);
const unconfiguredPayload = JSON.parse(unconfigured.renderedText) as Record<string, unknown>;
assert.equal(calls, 0);
assert.equal(unconfiguredPayload.configured, false);
assert.equal(unconfiguredPayload.remoteQueried, false);
} finally {
rmSync(unconfiguredRoot, { recursive: true, force: true });
}
});
+204 -145
View File
@@ -1,4 +1,4 @@
// SPEC: pikasTech/unidesk#2046 PikaOA YAML-first dedicated test runtime.
// 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";
@@ -11,6 +11,7 @@ import { CliInputError } from "./output";
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
type TestTargetAction = "plan" | "status" | "start" | "stop";
type TestTargetStep = "all" | "migration" | "api" | "worker" | "web";
interface TestTargetOptions {
action: TestTargetAction;
@@ -18,6 +19,7 @@ interface TestTargetOptions {
targetId: string | null;
instanceId: string | null;
commit: string | null;
step: TestTargetStep;
confirm: boolean;
output: "text" | "json";
}
@@ -25,6 +27,7 @@ interface TestTargetOptions {
interface SecretSourceSpec {
purpose: string;
sourceRef: string;
sourceKey: string | null;
targetKey: string;
}
@@ -32,10 +35,9 @@ interface TestTargetSpec {
id: string;
enabled: boolean;
validationOnly: boolean;
dedicated: boolean;
node: string;
route: string;
namespacePrefix: string;
namespace: string;
ttlSeconds: number;
waitTimeoutSeconds: number;
fieldManager: string;
@@ -54,21 +56,22 @@ interface TestTargetSpec {
commitPolicy: string;
apiImage: string;
workerImage: string;
webImage: string;
migrationImage: string;
pullPolicy: string;
};
database: {
image: string;
configRef: string;
name: string;
username: string;
schema: string;
port: number;
password: SecretSourceSpec;
connection: SecretSourceSpec;
};
runtime: {
secretName: string;
apiPort: number;
workerMetricsPort: number;
webPort: number;
attachment: {
storageRoot: string;
maxBytes: number;
@@ -92,6 +95,12 @@ interface TestTargetSpec {
backoffLimit: number;
command: string[];
};
exposure: {
serviceName: string;
serviceType: "NodePort";
hostIP: string;
port: number;
};
observability: {
otlpEndpoint: string;
prometheusScrape: boolean;
@@ -105,6 +114,8 @@ interface TestTargetSpec {
apiReadinessPath: string;
workerLivenessPath: string;
workerReadinessPath: string;
webLivenessPath: string;
webReadinessPath: string;
};
}
@@ -133,15 +144,17 @@ interface Blocker {
interface RenderContext {
instanceId: string;
commit: string;
step: TestTargetStep;
namespace: string;
expiresAt: string;
apiImage: string;
workerImage: string;
webImage: string;
migrationImage: string;
}
interface SecretMaterial {
databasePassword: string;
databaseURL: string;
sessionSecret: string;
administratorPassword: string;
employeePassword: string;
@@ -163,18 +176,18 @@ const INSTANCE_LABEL = "pikaoa.unidesk.io/instance";
export function pikaoaTestTargetHelp(): Record<string, unknown> {
return {
command: "pikaoa test-target",
description: "通过 YAML-first 专用测试集群 target 管理 PikaOA 临时 k8s 后端运行面,不经过 CI/CD。",
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 --confirm",
"bun scripts/cli.ts pikaoa test-target start --target id --instance id --commit sha [--step migration|api|worker|web|all] --confirm",
"bun scripts/cli.ts pikaoa test-target stop --target id --instance id --confirm",
],
source: "config/pikaoa.yaml#testRuntime",
guarantees: [
"未声明专用 target 时 plan/status 返回 configured=falsestart/stop 在远端调用前失败。",
"validationOnly target 只用于本地渲染,不连接 route。",
"start 只提交具名远程任务并立即返回;status 用短连接读取阶段、终态和有界事件尾部。",
"start 可按 migration、api、worker、web 或 all 单步提交并立即返回;status 用短连接读取有界状态。",
"正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。",
"Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。",
],
@@ -201,17 +214,19 @@ function parseOptions(args: string[]): TestTargetOptions {
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 === "--output" || arg === "-o") {
if (arg === "--config" || arg === "--target" || arg === "--instance" || arg === "--commit" || arg === "--step" || arg === "--output" || arg === "-o") {
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw inputError(`${arg} 需要参数`, "missing-option-value", arg);
if (arg === "--config") configPath = resolveConfigPath(value);
else if (arg === "--target") targetId = simpleId(value, arg);
else if (arg === "--instance") instanceId = kubernetesName(value, arg, 30);
else if (arg === "--commit") commit = sourceCommit(value);
else if (arg === "--step") step = enumString(value, arg, ["all", "migration", "api", "worker", "web"]);
else {
if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]);
output = value;
@@ -228,7 +243,7 @@ function parseOptions(args: string[]): TestTargetOptions {
}
}
if ((action === "plan" || action === "status") && confirm) throw inputError(`${action} 是只读操作,不接受 --confirm`, "confirm-not-allowed", "--confirm");
return { action, configPath, targetId, instanceId, commit, confirm, output };
return { action, configPath, targetId, instanceId, commit, step, confirm, output };
}
function readSelection(options: TestTargetOptions): Selection {
@@ -276,11 +291,13 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
const administrator = record(runtime.administrator, `${path}.runtime.administrator`);
const employee = record(runtime.employee, `${path}.runtime.employee`);
const observability = record(root.observability, `${path}.observability`);
const exposure = record(root.exposure, `${path}.exposure`);
const prometheus = record(observability.prometheus, `${path}.observability.prometheus`);
const exporterFailure = record(observability.exporterFailure, `${path}.observability.exporterFailure`);
const probes = record(root.probes, `${path}.probes`);
const apiProbes = record(probes.api, `${path}.probes.api`);
const workerProbes = record(probes.worker, `${path}.probes.worker`);
const webProbes = record(probes.web, `${path}.probes.web`);
const cleanup = record(root.cleanup, `${path}.cleanup`);
const migration = record(root.migration, `${path}.migration`);
const taskState = record(root.taskState, `${path}.taskState`);
@@ -291,10 +308,9 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
id,
enabled: boolean(root.enabled, `${path}.enabled`),
validationOnly: boolean(root.validationOnly, `${path}.validationOnly`),
dedicated: boolean(root.dedicated, `${path}.dedicated`),
node: nonEmpty(root.node, `${path}.node`),
route: nonEmpty(root.route, `${path}.route`),
namespacePrefix: kubernetesName(nonEmpty(root.namespacePrefix, `${path}.namespacePrefix`), `${path}.namespacePrefix`, 40),
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),
@@ -313,21 +329,22 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
commitPolicy: exactString(source.commitPolicy, `${path}.source.commitPolicy`, "cli-required"),
apiImage: imageReference(images.api, `${path}.source.images.api`),
workerImage: imageReference(images.worker, `${path}.source.images.worker`),
webImage: imageReference(images.web, `${path}.source.images.web`),
migrationImage: imageReference(images.migration, `${path}.source.images.migration`),
pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]),
},
database: {
image: imageReference(database.image, `${path}.database.image`),
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`),
port: positiveInteger(database.port, `${path}.database.port`, 65_535),
password: secretSource(database.password, "database.password", `${path}.database.password`),
connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true),
},
runtime: {
secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63),
apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535),
workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535),
webPort: positiveInteger(runtime.webPort, `${path}.runtime.webPort`, 65_535),
attachment: {
storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`),
maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER),
@@ -357,6 +374,12 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
backoffLimit: nonNegativeInteger(migration.backoffLimit, `${path}.migration.backoffLimit`),
command: nonEmptyStringList(migration.command, `${path}.migration.command`),
},
exposure: {
serviceName: kubernetesName(nonEmpty(exposure.serviceName, `${path}.exposure.serviceName`), `${path}.exposure.serviceName`, 63),
serviceType: exactString(exposure.serviceType, `${path}.exposure.serviceType`, "NodePort") as "NodePort",
hostIP: ipv4Address(exposure.hostIP, `${path}.exposure.hostIP`),
port: positiveInteger(exposure.port, `${path}.exposure.port`, 65_535),
},
observability: {
otlpEndpoint: nonEmpty(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`),
prometheusScrape: boolean(prometheus.scrape, `${path}.observability.prometheus.scrape`),
@@ -370,6 +393,8 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
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);
@@ -385,6 +410,12 @@ function planPayload(options: TestTargetOptions, selection: Selection): Record<s
const requireCommit = options.action === "plan" || options.action === "start";
const context = renderContext(options, selection.target, requireCommit);
const blockers = safetyBlockers(options, selection, context, requireCommit);
if (options.action === "plan" || options.action === "start") {
for (const source of secretSources(selection.target)) {
const summary = secretSourceSummary(selection.configPath, source);
if (summary.presence !== true) blockers.push({ code: "secret-source-missing", field: source.sourceRef, message: `${source.purpose} 的 sourceRef/sourceKey 不存在或为空。` });
}
}
const warnings = [...selection.warnings, ...consistencyWarnings(selection.target, context)];
const renderWorkloads = context !== null && options.commit !== null && (options.action === "plan" || options.action === "start");
return {
@@ -411,6 +442,7 @@ function basePayload(options: TestTargetOptions, selection: Selection): Record<s
action: `pikaoa test-target ${options.action}`,
configured: selection.target !== null,
mutation: false,
step: options.step,
configPath: selection.configLabel,
target: null,
candidateTargetIds: selection.candidateTargetIds,
@@ -419,7 +451,7 @@ function basePayload(options: TestTargetOptions, selection: Selection): Record<s
warnings: selection.warnings,
next: selection.target === null ? {
configure: `${selection.configLabel}#testRuntime.targets`,
plan: "bun scripts/cli.ts pikaoa test-target plan --target <dedicated-test-target> --instance <run-id> --commit <sha>",
plan: "bun scripts/cli.ts pikaoa test-target plan --target <test-target> --commit <sha>",
} : nextCommands(options, selection.target),
valuesPrinted: false,
};
@@ -431,7 +463,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
const selectors = {
api: componentSelector("api", context.instanceId),
worker: componentSelector("worker", context.instanceId),
postgres: componentSelector("postgres", context.instanceId),
web: componentSelector("web", context.instanceId),
};
const structuralManifest = buildManifest(target, context, placeholderSecrets());
return {
@@ -447,7 +479,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
secret: {
name: target.runtime.secretName,
valuesPrinted: false,
sources: secretSources(target).map((source) => ({ ...source, presence: "unchecked", fingerprint: null, valuesPrinted: false })),
sources: secretSources(target).map((source) => secretSourceSummary(selection.configPath, source)),
},
database: databasePlanSummary(target),
runtimeConfig: {
@@ -460,12 +492,14 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
probes: {
api: { liveness: target.probes.apiLivenessPath, readiness: target.probes.apiReadinessPath, metrics: target.observability.apiMetricsPath },
worker: { liveness: target.probes.workerLivenessPath, readiness: target.probes.workerReadinessPath, metrics: target.observability.workerMetricsPath },
web: { liveness: target.probes.webLivenessPath, readiness: target.probes.webReadinessPath },
},
observability: {
otlpEndpoint: target.observability.otlpEndpoint,
prometheusScrape: target.observability.prometheusScrape,
exporterFailure: { warning: target.observability.exporterWarning, blocking: false },
},
exposure: { serviceType: target.exposure.serviceType, serviceName: target.exposure.serviceName, hostIP: target.exposure.hostIP, port: target.exposure.port },
attachment: {
storageRoot: target.runtime.attachment.storageRoot,
maxBytes: target.runtime.attachment.maxBytes,
@@ -478,8 +512,9 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
jobName: target.migration.jobName,
image: context.migrationImage,
command: target.migration.command,
applyPhase: "after-postgres-before-workloads",
applyPhase: "before-workloads",
},
step: context.step,
taskState: {
jobId: asyncJobIdentity(target, context).id,
rootPath: target.taskState.rootPath,
@@ -508,7 +543,7 @@ async function statusResult(config: UniDeskConfig, options: TestTargetOptions, s
}
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);
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);
}
@@ -569,23 +604,25 @@ async function stopResult(config: UniDeskConfig, options: TestTargetOptions, sel
}
function renderContext(options: TestTargetOptions, target: TestTargetSpec, requireCommit = true): RenderContext | null {
if (options.instanceId === null || (requireCommit && options.commit === null)) return null;
if (requireCommit && options.commit === null) return null;
const instanceId = options.instanceId ?? "default";
const commit = options.commit ?? "not-required-for-stop";
const namespace = `${target.namespacePrefix}-${options.instanceId}`;
const expiresAt = new Date(Date.now() + target.ttlSeconds * 1000).toISOString();
return {
instanceId: options.instanceId,
instanceId,
commit,
namespace,
step: options.step,
namespace: target.namespace,
expiresAt,
apiImage: renderImage(target.source.apiImage, commit),
workerImage: renderImage(target.source.workerImage, commit),
webImage: renderImage(target.source.webImage, commit),
migrationImage: renderImage(target.source.migrationImage, commit),
};
}
function asyncJobIdentity(target: TestTargetSpec, context: RenderContext): AsyncJobIdentity {
const digest = createHash("sha256").update(target.id).update("\0").update(context.instanceId).digest("hex").slice(0, 20);
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}` };
}
@@ -597,13 +634,10 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex
blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的专用测试 target。" });
return blockers;
}
if (!target.dedicated) blockers.push({ code: "target-not-dedicated", field: `testRuntime.targets.${target.id}.dedicated`, message: "target 必须显式声明 dedicated=true。" });
if (options.instanceId === null) blockers.push({ code: "instance-required", field: "--instance", message: "临时 namespace 必须带显式运行实例 ID。" });
if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "start 必须选择源码 commit。" });
if (context !== null) {
if (context.namespace.length > 63 || !isKubernetesName(context.namespace)) blockers.push({ code: "unsafe-namespace", field: "namespacePrefix/--instance", message: `渲染后的 namespace 不合法:${context.namespace}` });
if (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。` });
if (!context.namespace.startsWith(`${target.namespacePrefix}-`) || context.namespace === target.namespacePrefix) blockers.push({ code: "instance-not-in-namespace", field: "namespace", message: "namespace 必须由前缀和实例 ID 共同组成。" });
}
return blockers;
}
@@ -611,8 +645,8 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex
function consistencyWarnings(target: TestTargetSpec, context: RenderContext | null): Warning[] {
const warnings: Warning[] = [];
if (!target.migration.command.includes("migrate") || !target.migration.command.includes("up")) warnings.push(warning("migration-command-unrecognized", "迁移命令未显示产品 migrate up;配置一致性只报告 warning。"));
if (!target.source.apiImage.includes("${commit}") || !target.source.workerImage.includes("${commit}") || !target.source.migrationImage.includes("${commit}")) warnings.push(warning("image-template-not-commit-scoped", "API、Worker 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。"));
if (context !== null && (!context.apiImage.includes(context.commit) || !context.workerImage.includes(context.commit) || !context.migrationImage.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。"));
if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.migrationImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或迁移镜像模板未包含 ${commit};版本一致性只报告 warning。"));
if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.migrationImage].every((image) => image.includes(context.commit))) warnings.push(warning("image-commit-not-visible", "渲染镜像引用未显示选中 commit;该漂移不阻塞当前 MVP。"));
if (target.observability.exporterWarning) warnings.push(warning("otel-exporter-warning-only", "OTel exporter 失败按 owning YAML 仅报告 warning,不阻塞业务运行。"));
if (!target.observability.prometheusScrape) warnings.push(warning("prometheus-scrape-disabled", "Prometheus scrape 已在 target YAML 中关闭。"));
return warnings;
@@ -623,7 +657,7 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
const secretData = runtimeSecretData(target, context, secrets);
const apiSelector = componentSelector("api", context.instanceId);
const workerSelector = componentSelector("worker", context.instanceId);
const postgresSelector = componentSelector("postgres", context.instanceId);
const webSelector = componentSelector("web", context.instanceId);
const migrationSelector = componentSelector("migration", context.instanceId);
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
"prometheus.io/scrape": "true",
@@ -645,38 +679,18 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }),
},
},
{
apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-postgres", namespace: context.namespace, labels: { ...labels, ...postgresSelector } },
spec: { selector: postgresSelector, ports: [{ name: "postgres", port: target.database.port, targetPort: target.database.port }] },
},
{
apiVersion: "apps/v1", kind: "StatefulSet", metadata: { name: "pikaoa-postgres", namespace: context.namespace, labels: { ...labels, ...postgresSelector } },
spec: {
serviceName: "pikaoa-postgres", replicas: 1, selector: { matchLabels: postgresSelector },
template: {
metadata: { labels: { ...labels, ...postgresSelector } },
spec: {
containers: [{
name: "postgres", image: target.database.image, imagePullPolicy: target.source.pullPolicy,
ports: [{ name: "postgres", containerPort: target.database.port }],
env: [
{ name: "POSTGRES_DB", value: target.database.name },
{ name: "POSTGRES_USER", value: target.database.username },
{ name: "POSTGRES_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.database.password.targetKey } } },
{ name: "PGDATA", value: "/var/lib/postgresql/data/pgdata" },
],
readinessProbe: { exec: { command: ["sh", "-ec", `pg_isready -U ${target.database.username} -d ${target.database.name}`] }, periodSeconds: 3, failureThreshold: 20 },
volumeMounts: [{ name: "data", mountPath: "/var/lib/postgresql/data" }],
}],
volumes: [{ name: "data", emptyDir: {} }],
},
},
},
},
{
apiVersion: "v1", kind: "Service", metadata: { name: "pikaoa-api", namespace: context.namespace, labels: { ...labels, ...apiSelector } },
spec: { selector: apiSelector, ports: [{ name: "http", port: target.runtime.apiPort, targetPort: target.runtime.apiPort }] },
},
{
apiVersion: "v1", kind: "Service", metadata: { name: target.exposure.serviceName, namespace: context.namespace, labels: { ...labels, ...webSelector } },
spec: {
type: target.exposure.serviceType,
selector: webSelector,
ports: [{ name: "http", port: target.runtime.webPort, targetPort: target.runtime.webPort, nodePort: target.exposure.port }],
},
},
{
apiVersion: "batch/v1", kind: "Job", metadata: { name: target.migration.jobName, namespace: context.namespace, labels: { ...labels, ...migrationSelector } },
spec: {
@@ -707,13 +721,14 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, commonPodAnnotations(target.observability.workerMetricsPath, target.runtime.workerMetricsPort), [
{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" },
], target.probes.workerLivenessPath, target.probes.workerReadinessPath),
deployment(target, context, "web", context.webImage, webSelector, target.runtime.webPort, {}, [], target.probes.webLivenessPath, target.probes.webReadinessPath),
];
}
function deployment(
target: TestTargetSpec,
context: RenderContext,
component: "api" | "worker",
component: "api" | "worker" | "web",
image: string,
selector: Record<string, string>,
port: number,
@@ -732,16 +747,16 @@ function deployment(
spec: {
containers: [{
name: component, image, imagePullPolicy: target.source.pullPolicy, env,
ports: [{ name: component === "api" ? "http" : "metrics", containerPort: port }],
ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }],
volumeMounts: [
{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true },
...(component === "web" ? [] : [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }]),
...(component === "api" ? [{ name: target.runtime.attachment.claimName, mountPath: target.runtime.attachment.storageRoot }] : []),
],
livenessProbe: { httpGet: { path: livenessPath, port }, periodSeconds: 5, failureThreshold: 12 },
readinessProbe: { httpGet: { path: readinessPath, port }, periodSeconds: 3, failureThreshold: 20 },
}],
volumes: [
{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } },
...(component === "web" ? [] : [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }]),
...(component === "api" ? [{ name: target.runtime.attachment.claimName, persistentVolumeClaim: { claimName: target.runtime.attachment.claimName } }] : []),
],
},
@@ -751,9 +766,9 @@ function deployment(
}
function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, string> {
const databaseURL = renderDatabaseURL(target, secrets.databasePassword);
const databaseURL = renderDatabaseURL(target, secrets.databaseURL);
return {
[target.database.password.targetKey]: secrets.databasePassword,
[target.database.connection.targetKey]: databaseURL,
[target.runtime.sessionSecret.targetKey]: secrets.sessionSecret,
[target.runtime.administrator.password.targetKey]: secrets.administratorPassword,
[target.runtime.employee.password.targetKey]: secrets.employeePassword,
@@ -783,21 +798,20 @@ function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secre
};
}
function renderDatabaseURL(target: TestTargetSpec, password: string): string {
const databaseURL = new URL("postgres://pikaoa-postgres");
databaseURL.username = target.database.username;
databaseURL.password = password;
databaseURL.port = String(target.database.port);
databaseURL.pathname = `/${target.database.name}`;
databaseURL.searchParams.set("sslmode", "disable");
function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string {
const databaseURL = new URL(sourceURL);
databaseURL.searchParams.set("search_path", target.database.schema);
return databaseURL.toString();
}
function databasePlanSummary(target: TestTargetSpec): Record<string, unknown> {
const databaseURL = new URL(renderDatabaseURL(target, "<redacted>"));
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"),
@@ -819,23 +833,37 @@ function readSecretMaterial(selection: Selection, target: TestTargetSpec): {
for (const source of sources) {
const path = resolveSourceRef(selection.configPath, source.sourceRef);
const present = existsSync(path);
const value = present ? readFileSync(path, "utf8").trim() : "";
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: {
databasePassword: values.get("database.password") ?? "",
databaseURL: values.get("database.connection") ?? "",
sessionSecret: values.get("runtime.sessionSecret") ?? "",
administratorPassword: values.get("runtime.administrator.password") ?? "",
employeePassword: values.get("runtime.employee.password") ?? "",
@@ -845,6 +873,36 @@ function readSecretMaterial(selection: Selection, target: TestTargetSpec): {
};
}
function secretSourceSummary(configPath: string, source: SecretSourceSpec): Record<string, unknown> {
const path = resolveSourceRef(configPath, source.sourceRef);
const present = existsSync(path);
const raw = present ? readFileSync(path, "utf8") : "";
const value = source.sourceKey === null ? raw.trim() : envValue(raw, source.sourceKey);
const available = value.length > 0;
return {
purpose: source.purpose,
sourceRef: source.sourceRef,
sourceKey: source.sourceKey,
targetKey: source.targetKey,
presence: available,
fingerprint: available ? sha256Fingerprint(value) : null,
valuesPrinted: false,
};
}
function envValue(raw: string, key: string): string {
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) continue;
const separator = trimmed.indexOf("=");
if (separator < 1 || trimmed.slice(0, separator).trim() !== key) continue;
const value = trimmed.slice(separator + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
return value;
}
return "";
}
function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[]): string {
const job = asyncJobIdentity(target, context);
const requestId = createHash("sha256")
@@ -853,6 +911,8 @@ function startScript(target: TestTargetSpec, context: RenderContext, manifest: R
.update(context.instanceId)
.update("\0")
.update(context.commit)
.update("\0")
.update(context.step)
.digest("hex");
const runnerEncoded = Buffer.from(startRunnerScript(target, context, manifest, job), "utf8").toString("base64");
return `
@@ -918,13 +978,13 @@ if ! printf '%s' ${shQuote(runnerEncoded)} | base64 -d >"$state_dir/job.sh"; the
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)} "$started_at" <<'PY'
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], "state": "running", "phase": "submitted",
"code": "start-running", "startedAt": sys.argv[7], "updatedAt": sys.argv[7], "finishedAt": None,
"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")
@@ -958,12 +1018,17 @@ printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"jo
function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[], job: AsyncJobIdentity): string {
const migration = manifest.filter((object) => object.kind === "Job");
const workloads = manifest.filter((object) => object.kind === "Deployment");
const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment");
const workload = (name: string): Record<string, unknown>[] => manifest.filter((object) => {
if (object.kind !== "Deployment" && object.kind !== "Service") return false;
return optionalRecord(object.metadata, "metadata")?.name === `pikaoa-${name}`;
});
const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment" && object.kind !== "Service");
const encode = (objects: Record<string, unknown>[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64");
const foundationEncoded = encode(foundation);
const migrationEncoded = encode(migration);
const workloadsEncoded = encode(workloads);
const apiEncoded = encode(workload("api"));
const workerEncoded = encode(workload("worker"));
const webEncoded = encode(workload("web"));
return `#!/bin/sh
set -eu
umask 077
@@ -977,6 +1042,7 @@ 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=""
@@ -992,7 +1058,7 @@ 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],
"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,
@@ -1041,7 +1107,9 @@ if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statu
fi
printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml"
printf '%s' ${shQuote(migrationEncoded)} | base64 -d >"$tmp/migration.yaml"
printf '%s' ${shQuote(workloadsEncoded)} | base64 -d >"$tmp/workloads.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
@@ -1049,48 +1117,28 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar
failure_code=apply-failed
exit 43
fi
current_phase=postgres-rollout
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" rollout status statefulset/pikaoa-postgres --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err"; then
failure_code=postgres-wait-failed
exit 44
fi
current_phase=migration-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/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then
failure_code=migration-apply-failed
exit 45
fi
current_phase=migration-wait
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" wait --for=condition=complete job/${target.migration.jobName} --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/migration-wait.err"; then
failure_code=migration-wait-failed
exit 46
fi
current_phase=workloads-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/workloads.yaml" >"$tmp/workloads-apply.out" 2>"$tmp/workloads-apply.err"; then
failure_code=workloads-apply-failed
exit 47
fi
current_phase=api-rollout
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" rollout status deployment/pikaoa-api --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/wait.err"; then
failure_code=api-rollout-wait-failed
exit 48
fi
current_phase=worker-rollout
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" rollout status deployment/pikaoa-worker --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>>"$tmp/wait.err"; then
failure_code=worker-rollout-wait-failed
exit 49
if [ "$step" = all ] || [ "$step" = migration ]; then
current_phase=migration-apply
write_status running "$current_phase" start-running null
check_canceled
kubectl -n "$namespace" delete job/${target.migration.jobName} --ignore-not-found --wait=false >/dev/null 2>&1 || true
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/migration.yaml" >"$tmp/migration-apply.out" 2>"$tmp/migration-apply.err"; then failure_code=migration-apply-failed; exit 45; fi
current_phase=migration-wait
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" wait --for=condition=complete job/${target.migration.jobName} --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/migration-wait.err"; then failure_code=migration-wait-failed; exit 46; fi
fi
for component in api worker web; do
if [ "$step" != all ] && [ "$step" != "$component" ]; then continue; fi
current_phase="$component-apply"
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/$component.yaml" >"$tmp/$component-apply.out" 2>"$tmp/$component-apply.err"; then failure_code="$component-apply-failed"; exit 47; fi
current_phase="$component-rollout"
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl -n "$namespace" rollout status "deployment/pikaoa-$component" --timeout=${target.waitTimeoutSeconds}s >/dev/null 2>"$tmp/$component-wait.err"; then failure_code="$component-rollout-wait-failed"; exit 48; fi
done
`;
}
@@ -1107,7 +1155,7 @@ 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,statefulset,job,service,persistentvolumeclaim,pod --request-timeout=${target.taskState.statusRequestTimeoutSeconds}s -o json >"$tmp/resources.json" 2>"$tmp/resources.err"; then
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
@@ -1246,7 +1294,7 @@ printf '{"ok":true,"mutation":true,"code":"stop-requested","jobId":"%s","startTa
`;
}
function summarizeRemoteStatus(parsed: Record<string, unknown>, target: TestTargetSpec, context: RenderContext): Record<string, unknown> {
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") ?? {};
@@ -1285,6 +1333,9 @@ function summarizeRemoteStatus(parsed: Record<string, unknown>, target: TestTarg
phase: status.phase ?? null,
};
}),
database: { external: true, configRef: target.database.configRef, ...secretSourceSummary(configPath, target.database.connection) },
exposure: { hostIP: target.exposure.hostIP, port: target.exposure.port, serviceName: target.exposure.serviceName },
observability: { otlpEndpoint: target.observability.otlpEndpoint, prometheusScrape: target.observability.prometheusScrape, blocking: false },
},
valuesPrinted: false,
};
@@ -1313,7 +1364,7 @@ function renderText(payload: Record<string, unknown>): string {
`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 ?? "-")}`,
`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`,
@@ -1330,9 +1381,9 @@ function targetSummary(target: TestTargetSpec): Record<string, unknown> {
id: target.id,
node: target.node,
route: target.route,
dedicated: target.dedicated,
validationOnly: target.validationOnly,
namespacePrefix: target.namespacePrefix,
namespace: target.namespace,
exposure: target.exposure,
ttlSeconds: target.ttlSeconds,
taskState: target.taskState,
cleanup: target.cleanup,
@@ -1345,19 +1396,22 @@ function sourceSummary(target: TestTargetSpec, commit: string | null, context?:
repository: target.source.repository,
commitPolicy: target.source.commitPolicy,
commit,
images: context === undefined ? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, migrationTemplate: target.source.migrationImage } : { api: context.apiImage, worker: context.workerImage, migration: context.migrationImage },
images: context === undefined
? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, webTemplate: target.source.webImage, migrationTemplate: target.source.migrationImage }
: { api: context.apiImage, worker: context.workerImage, web: context.webImage, migration: context.migrationImage },
};
}
function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Record<string, unknown> {
const configFlag = options.configPath === DEFAULT_CONFIG_PATH ? "" : ` --config ${displayPath(options.configPath)}`;
const targetFlag = `--target ${target.id}`;
const instance = options.instanceId ?? "<run-id>";
const instance = options.instanceId ?? "default";
const commit = options.commit ?? "<sha>";
const step = options.step;
return {
plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance} --commit ${commit}`,
status: `bun scripts/cli.ts pikaoa test-target status${configFlag} ${targetFlag} --instance ${instance}`,
start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance} --commit ${commit} --confirm`,
start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance} --commit ${commit} --step ${step} --confirm`,
stop: `bun scripts/cli.ts pikaoa test-target stop${configFlag} ${targetFlag} --instance ${instance} --confirm`,
};
}
@@ -1365,7 +1419,6 @@ function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Recor
function missingMutationFields(options: TestTargetOptions, selection: Selection): string[] {
return [
...(selection.target === null ? ["--target"] : []),
...(options.instanceId === null ? ["--instance"] : []),
...((options.action === "plan" || options.action === "start") && options.commit === null ? ["--commit"] : []),
];
}
@@ -1397,14 +1450,10 @@ function namespaceAnnotations(target: TestTargetSpec, context: RenderContext): R
function objectSummary(object: Record<string, unknown>): Record<string, unknown> {
const metadata = optionalRecord(object.metadata, "metadata") ?? {};
const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {};
const spec = optionalRecord(object.spec, "spec") ?? {};
const selector = optionalRecord(spec.selector, "spec.selector") ?? {};
return {
kind: object.kind ?? null,
name: metadata.name ?? null,
namespace: metadata.namespace ?? null,
ownershipLabelsPresent: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string",
selector: selector.matchLabels ?? selector,
owned: labels["app.kubernetes.io/managed-by"] === MANAGED_BY && labels[TEST_RUNTIME_LABEL] === "true" && typeof labels[TARGET_LABEL] === "string" && typeof labels[INSTANCE_LABEL] === "string",
secretValuesRedacted: object.kind === "Secret" ? true : undefined,
};
}
@@ -1415,18 +1464,21 @@ function structuralFingerprint(manifest: Record<string, unknown>[]): string {
}
function placeholderSecrets(): SecretMaterial {
return { databasePassword: "<redacted>", sessionSecret: "<redacted>", administratorPassword: "<redacted>", employeePassword: "<redacted>" };
return { databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require", sessionSecret: "<redacted>", administratorPassword: "<redacted>", employeePassword: "<redacted>" };
}
function secretSources(target: TestTargetSpec): SecretSourceSpec[] {
return [target.database.password, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password];
return [target.database.connection, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password];
}
function secretSource(value: unknown, purpose: string, path: string): SecretSourceSpec {
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`);
return { purpose, sourceRef: nonEmpty(root.sourceRef, `${path}.sourceRef`), targetKey };
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[] {
@@ -1521,6 +1573,13 @@ function httpPath(value: unknown, path: string): string {
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`);