feat(pikaoa): 补齐测试 Secret 初始化闭环

This commit is contained in:
Codex
2026-07-15 04:08:33 +02:00
parent 712fc66cbf
commit d5f4a4c13b
8 changed files with 264 additions and 108 deletions
+19 -4
View File
@@ -21,10 +21,9 @@ 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-url.env", `DATABASE_URL=postgresql://pikaoa_test:${secretValues[0]}@db.example.invalid:5432/pikaoa_test?sslmode=require`],
["pikaoa-test.env", `DATABASE_URL=postgresql://pikaoa_test:${secretValues[0]}@db.example.invalid:5432/pikaoa_test?sslmode=require\nPIKAOA_SESSION_SECRET=${secretValues[3]}`],
["pikaoa-test-admin-password.txt", secretValues[1]],
["pikaoa-test-employee-password.txt", secretValues[2]],
["pikaoa-test-session-secret.txt", secretValues[3]],
]) writeFileSync(join(secretsDir, name), `${value}\n`, { mode: 0o600 });
const fakeKubectl = join(binDir, "kubectl");
@@ -232,7 +231,23 @@ exit 64
assert.equal(stopped.mutation, true);
await waitFor("runtime", "start-canceled", "web");
const serialized = JSON.stringify({ submitted, succeeded, failed, stopped });
for (const name of [
"pikaoa-test.env",
"pikaoa-test-admin-password.txt",
"pikaoa-test-employee-password.txt",
]) rmSync(join(secretsDir, name));
const foundationPlan = await projection("plan", "foundation", false, "foundation");
assert.equal(foundationPlan.readyForMutation, true);
assert.deepEqual(foundationPlan.missingFields, []);
assert.deepEqual((foundationPlan.blockers as unknown[]), []);
const foundationObjects = ((foundationPlan.plan as Record<string, unknown>).objects as Array<Record<string, unknown>>);
assert.deepEqual(foundationObjects.map((object) => object.kind), ["Namespace", "PersistentVolumeClaim"]);
assert.equal(((foundationPlan.plan as Record<string, unknown>).secret as Record<string, unknown>).requiredForStep, false);
const foundationSubmitted = await projection("start", "foundation", false, "foundation");
assert.equal(foundationSubmitted.code, "start-submitted", JSON.stringify(lastCapture));
const foundationSucceeded = await waitFor("foundation", "start-succeeded", "foundation");
const serialized = JSON.stringify({ submitted, succeeded, failed, stopped, foundationSubmitted, foundationSucceeded });
for (const value of secretValues) assert.equal(serialized.includes(value), false);
assert.equal(serialized.includes("postgresql://pikaoa_test:"), false);
} finally {
@@ -265,7 +280,7 @@ test("validation-only fixture never invokes remote capture", async () => {
username: "pikaoa_test",
schema: "pikaoa",
connection: {
sourceRef: "./secrets/pikaoa-test-database-url.env",
sourceRef: "./secrets/pikaoa-test.env",
sourceKey: "DATABASE_URL",
targetKey: "DATABASE_URL",
valuesPrinted: false,
+55 -19
View File
@@ -11,7 +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";
type TestTargetStep = "all" | "foundation" | "migration" | "api" | "worker" | "web";
interface TestTargetOptions {
action: TestTargetAction;
@@ -183,14 +183,14 @@ export function pikaoaTestTargetHelp(): Record<string, unknown> {
usage: [
"bun scripts/cli.ts pikaoa test-target plan [--config path] [--target id] [--instance id] [--commit sha] [--output json]",
"bun scripts/cli.ts pikaoa test-target status [--config path] [--target id] [--instance id] [--output json]",
"bun scripts/cli.ts pikaoa test-target start --target id --instance id --commit sha [--step migration|api|worker|web|all] --confirm",
"bun scripts/cli.ts pikaoa test-target start --target id --instance id [--commit sha] [--step foundation|migration|api|worker|web|all] --confirm",
"bun scripts/cli.ts pikaoa test-target stop --target id --instance id --confirm",
],
source: "config/pikaoa.yaml#testRuntime",
guarantees: [
"未声明专用 target 时 plan/status 返回 configured=falsestart/stop 在远端调用前失败。",
"validationOnly target 只用于本地渲染,不连接 route。",
"start 可按 migration、api、worker、web 或 all 单步提交并立即返回;status 用短连接读取有界状态。",
"start 可按 foundation、migration、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。",
"正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。",
"Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。",
],
@@ -229,7 +229,7 @@ function parseOptions(args: string[]): TestTargetOptions {
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 (arg === "--step") step = enumString(value, arg, ["all", "foundation", "migration", "api", "worker", "web"]);
else {
if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]);
output = value;
@@ -413,17 +413,17 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
function planPayload(options: TestTargetOptions, selection: Selection): Record<string, unknown> {
const base = basePayload(options, selection);
if (selection.target === null) return base;
const requireCommit = options.action === "plan" || options.action === "start";
const requireCommit = (options.action === "plan" || options.action === "start") && options.step !== "foundation";
const context = renderContext(options, selection.target, requireCommit);
const blockers = safetyBlockers(options, selection, context, requireCommit);
if (options.action === "plan" || options.action === "start") {
if ((options.action === "plan" || options.action === "start") && options.step !== "foundation") {
for (const source of secretSources(selection.target)) {
const summary = secretSourceSummary(selection.configPath, source);
if (summary.presence !== true) blockers.push({ code: "secret-source-missing", field: source.sourceRef, message: `${source.purpose} 的 sourceRef/sourceKey 不存在或为空。` });
}
}
const warnings = [...selection.warnings, ...consistencyWarnings(selection.target, context)];
const renderWorkloads = context !== null && options.commit !== null && (options.action === "plan" || options.action === "start");
const renderWorkloads = context !== null && (options.commit !== null || options.step === "foundation") && (options.action === "plan" || options.action === "start");
return {
...base,
target: targetSummary(selection.target),
@@ -432,7 +432,9 @@ function planPayload(options: TestTargetOptions, selection: Selection): Record<s
namespace: context.namespace,
...((options.action === "plan" || options.action === "start") ? { expiresAt: context.expiresAt } : {}),
},
source: renderWorkloads && context !== null ? sourceSummary(selection.target, context.commit, context) : sourceSummary(selection.target, options.commit),
source: renderWorkloads && context !== null && options.step !== "foundation"
? sourceSummary(selection.target, context.commit, context)
: sourceSummary(selection.target, options.commit),
readyForMutation: blockers.length === 0 && !selection.target.validationOnly,
missingFields: missingMutationFields(options, selection),
blockers,
@@ -471,7 +473,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
worker: componentSelector("worker", context.instanceId),
web: componentSelector("web", context.instanceId),
};
const structuralManifest = buildManifest(target, context, placeholderSecrets());
const structuralManifest = manifestForStep(buildManifest(target, context, placeholderSecrets()), context.step);
return {
namespace: {
name: context.namespace,
@@ -484,6 +486,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
selectors,
secret: {
name: target.runtime.secretName,
requiredForStep: context.step !== "foundation",
valuesPrinted: false,
sources: secretSources(target).map((source) => secretSourceSummary(selection.configPath, source)),
},
@@ -557,18 +560,24 @@ async function statusResult(config: UniDeskConfig, options: TestTargetOptions, s
async function startResult(config: UniDeskConfig, options: TestTargetOptions, selection: Selection, remoteCapture: RemoteCapture): Promise<RenderedCliResult> {
const planned = planPayload(options, selection);
const context = selection.target === null ? null : renderContext(options, selection.target);
const blockers = safetyBlockers(options, selection, context);
const requireCommit = options.step !== "foundation";
const context = selection.target === null ? null : renderContext(options, selection.target, requireCommit);
const blockers = safetyBlockers(options, selection, context, requireCommit);
if (!options.confirm) blockers.push({ code: "confirmation-required", field: "--confirm", message: "start 需要显式 --confirm。" });
if (selection.target?.validationOnly === true) blockers.push({ code: "validation-only-target", field: "testRuntime.targets.*.validationOnly", message: "validationOnly target 禁止远端操作。" });
if (selection.target === null || context === null || blockers.length > 0) {
return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: uniqueBlockers(blockers), failure: "preflight-blocked" }, options);
}
const material = readSecretMaterial(selection, selection.target);
const material = options.step === "foundation" ? {
ok: true,
values: placeholderSecrets(),
sources: secretSources(selection.target).map((source) => secretSourceSummary(selection.configPath, source)),
blockers: [] as Blocker[],
} : readSecretMaterial(selection, selection.target);
if (!material.ok) {
return renderResult({ ...planned, ok: false, mutation: false, remoteQueried: false, blockers: material.blockers, secret: { sources: material.sources, valuesPrinted: false }, failure: "secret-preflight-blocked" }, options);
}
const manifest = buildManifest(selection.target, context, material.values);
const manifest = manifestForStep(buildManifest(selection.target, context, material.values), options.step);
const remote = await remoteCapture(config, selection.target.route, ["sh"], startScript(selection.target, context, manifest));
const parsed = parseJsonOutput(remote.stdout);
const mutation = parsed?.mutation === true;
@@ -614,7 +623,7 @@ async function stopResult(config: UniDeskConfig, options: TestTargetOptions, sel
function renderContext(options: TestTargetOptions, target: TestTargetSpec, requireCommit = true): RenderContext | null {
if (requireCommit && options.commit === null) return null;
const instanceId = options.instanceId ?? "default";
const commit = options.commit ?? "not-required-for-stop";
const commit = options.commit ?? (options.step === "foundation" ? "foundation" : "not-required-for-stop");
const expiresAt = new Date(Date.now() + target.ttlSeconds * 1000).toISOString();
return {
instanceId,
@@ -642,7 +651,7 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex
blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的专用测试 target。" });
return blockers;
}
if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "start 必须选择源码 commit。" });
if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "当前 step 必须选择源码 commit。" });
if (context !== null) {
if (context.namespace.length > 63 || !isKubernetesName(context.namespace)) blockers.push({ code: "unsafe-namespace", field: "namespace", message: `YAML namespace 不合法:${context.namespace}` });
if (selection.protectedNamespaces.includes(context.namespace)) blockers.push({ code: "production-namespace-protected", field: "namespace", message: `${context.namespace} 属于 delivery 正式 namespace。` });
@@ -735,6 +744,19 @@ function buildManifest(target: TestTargetSpec, context: RenderContext, secrets:
];
}
function manifestForStep(manifest: Record<string, unknown>[], step: TestTargetStep): Record<string, unknown>[] {
if (step === "all") return manifest;
return manifest.filter((object) => {
if (object.kind === "Namespace" || object.kind === "PersistentVolumeClaim") return true;
if (step === "foundation") return false;
if (object.kind === "Secret") return true;
if (step === "migration") return object.kind === "Job";
const metadata = optionalRecord(object.metadata, "metadata") ?? {};
const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {};
return labels["app.kubernetes.io/component"] === step;
});
}
function deployment(
target: TestTargetSpec,
context: RenderContext,
@@ -1035,9 +1057,11 @@ function startRunnerScript(target: TestTargetSpec, context: RenderContext, manif
const labels = optionalRecord(metadata.labels, "metadata.labels") ?? {};
return labels["app.kubernetes.io/component"] === name;
});
const foundation = manifest.filter((object) => object.kind !== "Job" && object.kind !== "Deployment" && object.kind !== "Service");
const foundation = manifest.filter((object) => object.kind === "Namespace" || object.kind === "PersistentVolumeClaim");
const runtime = manifest.filter((object) => object.kind === "Secret");
const encode = (objects: Record<string, unknown>[]): string => Buffer.from(objects.map((object) => JSON.stringify(object)).join("\n---\n"), "utf8").toString("base64");
const foundationEncoded = encode(foundation);
const runtimeEncoded = encode(runtime);
const migrationEncoded = encode(migration);
const apiEncoded = encode(workload("api"));
const workerEncoded = encode(workload("worker"));
@@ -1119,6 +1143,7 @@ if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statu
fi
fi
printf '%s' ${shQuote(foundationEncoded)} | base64 -d >"$tmp/foundation.yaml"
printf '%s' ${shQuote(runtimeEncoded)} | base64 -d >"$tmp/runtime.yaml"
printf '%s' ${shQuote(migrationEncoded)} | base64 -d >"$tmp/migration.yaml"
printf '%s' ${shQuote(apiEncoded)} | base64 -d >"$tmp/api.yaml"
printf '%s' ${shQuote(workerEncoded)} | base64 -d >"$tmp/worker.yaml"
@@ -1130,6 +1155,16 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar
failure_code=apply-failed
exit 43
fi
if [ "$step" = foundation ]; then
exit 0
fi
current_phase=runtime-secret-apply
write_status running "$current_phase" start-running null
check_canceled
if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(target.fieldManager)} -f "$tmp/runtime.yaml" >"$tmp/runtime-apply.out" 2>"$tmp/runtime-apply.err"; then
failure_code=runtime-secret-apply-failed
exit 44
fi
if [ "$step" = all ] || [ "$step" = migration ]; then
current_phase=migration-apply
write_status running "$current_phase" start-running null
@@ -1421,10 +1456,11 @@ function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Recor
const instance = options.instanceId ?? "default";
const commit = options.commit ?? "<sha>";
const step = options.step;
const commitFlag = step === "foundation" ? "" : ` --commit ${commit}`;
return {
plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance} --commit ${commit}`,
plan: `bun scripts/cli.ts pikaoa test-target plan${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step}`,
status: `bun scripts/cli.ts pikaoa test-target status${configFlag} ${targetFlag} --instance ${instance}`,
start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance} --commit ${commit} --step ${step} --confirm`,
start: `bun scripts/cli.ts pikaoa test-target start${configFlag} ${targetFlag} --instance ${instance}${commitFlag} --step ${step} --confirm`,
stop: `bun scripts/cli.ts pikaoa test-target stop${configFlag} ${targetFlag} --instance ${instance} --confirm`,
};
}
@@ -1432,7 +1468,7 @@ function nextCommands(options: TestTargetOptions, target: TestTargetSpec): Recor
function missingMutationFields(options: TestTargetOptions, selection: Selection): string[] {
return [
...(selection.target === null ? ["--target"] : []),
...((options.action === "plan" || options.action === "start") && options.commit === null ? ["--commit"] : []),
...((options.action === "plan" || options.action === "start") && options.step !== "foundation" && options.commit === null ? ["--commit"] : []),
];
}