feat(pikaoa): 声明 NC01 测试运行面
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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=false,start/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`);
|
||||
|
||||
Reference in New Issue
Block a user