feat(pikaoa): 补齐测试 Secret 初始化闭环
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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=false,start/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"] : []),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user