fix(pikaoa): 允许终态测试步骤安全重跑

This commit is contained in:
Codex
2026-07-15 04:27:02 +02:00
parent d5f4a4c13b
commit 8ac9267e1b
4 changed files with 181 additions and 22 deletions
+64 -2
View File
@@ -149,6 +149,12 @@ exit 64
const result = await runPikaoaCommand(config, args(action, instance, withCommit, step), localCapture);
return JSON.parse(result.renderedText) as Record<string, unknown>;
};
const projectionWithCommit = async (action: string, instance: string, commit: string, step = "all"): Promise<Record<string, unknown>> => {
const commandArgs = args(action, instance, true, step);
commandArgs[commandArgs.indexOf("--commit") + 1] = commit;
const result = await runPikaoaCommand(config, commandArgs, localCapture);
return JSON.parse(result.renderedText) as 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, false, step);
@@ -186,6 +192,11 @@ exit 64
});
const running = await projection("status", "runtime");
assert.equal((running.status as Record<string, unknown>).code, "start-running");
const concurrentCommit = "fedcba9876543210";
const concurrent = await projectionWithCommit("start", "runtime", concurrentCommit);
assert.equal(concurrent.code, "start-instance-conflict");
assert.equal(concurrent.mutation, false);
assert.equal(concurrent.previousTaskReset, false);
rmSync(join(fakeRoot, "slow"));
const succeeded = await waitFor("runtime", "start-succeeded");
const succeededStatus = succeeded.status as Record<string, unknown>;
@@ -215,6 +226,32 @@ exit 64
const repeated = await projection("start", "runtime", true);
assert.equal(repeated.code, "start-already-succeeded");
assert.equal(repeated.mutation, false);
const rerunSubmitted = await projectionWithCommit("start", "runtime", concurrentCommit);
assert.equal(rerunSubmitted.code, "start-submitted");
assert.equal(rerunSubmitted.previousTaskReset, true);
const rerunSucceeded = await waitFor("runtime", "start-succeeded");
assert.equal(((rerunSucceeded.status as Record<string, unknown>).task as Record<string, unknown>).sourceCommit, concurrentCommit);
writeFileSync(fixturePath, readFileSync(fixturePath, "utf8").replace(" livenessPath: /healthz\n", " livenessPath: /healthz-v2\n"));
const manifestRerunSubmitted = await projectionWithCommit("start", "runtime", concurrentCommit);
assert.equal(manifestRerunSubmitted.code, "start-submitted");
assert.equal(manifestRerunSubmitted.previousTaskReset, true);
const manifestRerunSucceeded = await waitFor("runtime", "start-succeeded");
await Bun.sleep(5);
const sameRequestAfterRenderTimeChanged = await projectionWithCommit("start", "runtime", concurrentCommit);
assert.equal(sameRequestAfterRenderTimeChanged.code, "start-already-succeeded");
assert.equal(sameRequestAfterRenderTimeChanged.previousTaskReset, false);
writeFileSync(join(fakeRoot, "slow"), "1\n");
const workerMissingSubmitted = await projection("start", "runtime", true, "api");
const workerPid = Number((workerMissingSubmitted.remote as Record<string, unknown>).pid);
assert.equal(Number.isSafeInteger(workerPid) && workerPid > 0, true);
process.kill(workerPid, "SIGKILL");
await Bun.sleep(25);
const workerMissingReset = await projectionWithCommit("start", "runtime", concurrentCommit, "api");
assert.equal(workerMissingReset.code, "start-submitted");
assert.equal(workerMissingReset.previousTaskReset, true);
rmSync(join(fakeRoot, "slow"));
const workerMissingSucceeded = await waitFor("runtime", "start-succeeded", "api");
writeFileSync(join(fakeRoot, "fail-migration"), "1\n");
await projection("start", "runtime", true, "migration");
@@ -246,14 +283,39 @@ exit 64
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 foundationRepeated = await projection("start", "foundation", false, "foundation");
assert.equal(foundationRepeated.code, "start-already-succeeded");
const foundationRerun = await projectionWithCommit("start", "foundation", concurrentCommit, "foundation");
assert.equal(foundationRerun.code, "start-submitted");
assert.equal(foundationRerun.previousTaskReset, true);
const foundationRerunSucceeded = await waitFor("foundation", "start-succeeded", "foundation");
const serialized = JSON.stringify({ submitted, succeeded, failed, stopped, foundationSubmitted, foundationSucceeded });
const serialized = JSON.stringify({
submitted,
succeeded,
concurrent,
rerunSubmitted,
rerunSucceeded,
manifestRerunSubmitted,
manifestRerunSucceeded,
sameRequestAfterRenderTimeChanged,
workerMissingSubmitted,
workerMissingReset,
workerMissingSucceeded,
failed,
stopped,
foundationSubmitted,
foundationSucceeded,
foundationRepeated,
foundationRerun,
foundationRerunSucceeded,
});
for (const value of secretValues) assert.equal(serialized.includes(value), false);
assert.equal(serialized.includes("postgresql://pikaoa_test:"), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 15_000);
}, 30_000);
test("validation-only fixture never invokes remote capture", async () => {
let calls = 0;
+69 -16
View File
@@ -188,7 +188,7 @@ export function pikaoaTestTargetHelp(): Record<string, unknown> {
],
source: "config/pikaoa.yaml#testRuntime",
guarantees: [
"未声明专用 target 时 plan/status 返回 configured=falsestart/stop 在远端调用前失败。",
"未声明测试 target 时 plan/status 返回 configured=falsestart/stop 在远端调用前失败。",
"validationOnly target 只用于本地渲染,不连接 route。",
"start 可按 foundation、migration、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。",
"正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。",
@@ -589,6 +589,7 @@ async function startResult(config: UniDeskConfig, options: TestTargetOptions, se
mode: mutation ? "submitted" : parsed === null ? "submit-failed" : "existing-task",
code: parsed?.code ?? "start-submit-unreadable",
jobId: parsed?.jobId ?? asyncJobIdentity(selection.target, context).id,
previousTaskReset: parsed?.previousTaskReset === true,
task: parsed?.task ?? null,
secret: { sources: material.sources, valuesPrinted: false },
remote: parsed ?? compactCapture(remote, { full: true }),
@@ -648,7 +649,7 @@ function safetyBlockers(options: TestTargetOptions, selection: Selection, contex
const blockers: Blocker[] = [];
const target = selection.target;
if (target === null) {
blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的专用测试 target。" });
blockers.push({ code: "test-target-not-configured", field: `${selection.configLabel}#testRuntime.targets`, message: "没有选中已启用的测试 target。" });
return blockers;
}
if (requireCommit && options.commit === null) blockers.push({ code: "commit-required", field: "--commit", message: "当前 step 必须选择源码 commit。" });
@@ -938,6 +939,7 @@ function envValue(raw: string, key: string): string {
function startScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[]): string {
const job = asyncJobIdentity(target, context);
const manifestFingerprint = requestStructuralFingerprint(manifest);
const requestId = createHash("sha256")
.update(target.id)
.update("\0")
@@ -946,6 +948,8 @@ function startScript(target: TestTargetSpec, context: RenderContext, manifest: R
.update(context.commit)
.update("\0")
.update(context.step)
.update("\0")
.update(manifestFingerprint)
.digest("hex");
const runnerEncoded = Buffer.from(startRunnerScript(target, context, manifest, job), "utf8").toString("base64");
return `
@@ -958,29 +962,38 @@ request_file="$state_dir/request-id"
pid_file="$state_dir/pid"
job_id=${shQuote(job.id)}
request_id=${shQuote(requestId)}
emit_existing() {
reset_previous=false
inspect_existing() {
python3 - "$request_file" "$status_file" "$pid_file" "$job_id" "$request_id" <<'PY'
import json, os, pathlib, sys
request_path, status_path, pid_path = map(pathlib.Path, sys.argv[1:4])
job_id, expected_request = sys.argv[4:6]
actual_request = request_path.read_text(errors="replace").strip() if request_path.exists() else ""
if actual_request != expected_request:
print(json.dumps({"ok": False, "mutation": False, "code": "start-instance-conflict", "jobId": job_id, "submitted": False, "task": None, "valuesPrinted": False}))
raise SystemExit(0)
try:
task = json.loads(status_path.read_text())
except Exception:
print(json.dumps({"ok": False, "mutation": False, "code": "start-state-unreadable", "jobId": job_id, "submitted": False, "task": None, "valuesPrinted": False}))
raise SystemExit(0)
raise SystemExit(10)
state = str(task.get("state") or "unknown")
runner_alive = False
pid = task.get("pid")
if not isinstance(pid, int) or pid <= 0:
try:
pid = int(pid_path.read_text().strip())
except Exception:
pid = None
if isinstance(pid, int) and pid > 0:
try:
os.kill(pid, 0)
runner_alive = True
except OSError:
pass
worker_missing = state in ("queued", "running") and not runner_alive
if actual_request != expected_request:
if state in ("succeeded", "failed", "canceled") or worker_missing:
raise SystemExit(20)
print(json.dumps({"ok": False, "mutation": False, "code": "start-instance-conflict", "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False}))
raise SystemExit(10)
code_by_state = {
"queued": "start-already-running",
"running": "start-already-running",
@@ -988,20 +1001,40 @@ code_by_state = {
"failed": "start-already-failed",
"canceled": "start-already-canceled",
}
code = "start-worker-missing" if state in ("queued", "running") and not runner_alive else code_by_state.get(state, "start-state-unknown")
code = "start-worker-missing" if worker_missing else code_by_state.get(state, "start-state-unknown")
ok = state not in ("failed", "canceled", "unknown") and code != "start-worker-missing"
print(json.dumps({"ok": ok, "mutation": False, "code": code, "jobId": job_id, "submitted": False, "task": task, "valuesPrinted": False}))
raise SystemExit(10)
PY
}
if ! mkdir -p "$state_root"; then
printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-root-create-failed","valuesPrinted":false}'
exit 41
fi
if ! mkdir "$state_dir" 2>/dev/null; then
if [ -d "$state_dir" ]; then emit_existing; exit 0; fi
printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-create-failed","valuesPrinted":false}'
exit 42
fi
claim_attempt=0
while ! mkdir "$state_dir" 2>/dev/null; do
if [ ! -d "$state_dir" ]; then
printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-create-failed","valuesPrinted":false}'
exit 42
fi
inspect_existing
decision=$?
if [ "$decision" -eq 10 ]; then exit 0; fi
if [ "$decision" -ne 20 ]; then
printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-inspect-failed","valuesPrinted":false}'
exit 42
fi
retired_dir="$state_dir.reset.$request_id.$$"
if mv "$state_dir" "$retired_dir" 2>/dev/null; then
rm -rf "$retired_dir"
reset_previous=true
fi
claim_attempt=$((claim_attempt + 1))
if [ "$claim_attempt" -ge 5 ]; then
printf '%s\n' '{"ok":false,"mutation":false,"code":"task-state-reset-conflict","valuesPrinted":false}'
exit 42
fi
done
started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '%s\n' "$request_id" >"$request_file"
printf '%s\n' "$started_at" >"$state_dir/started-at"
@@ -1045,7 +1078,7 @@ if payload.get("pid") is None and payload.get("phase") == "submitted":
tmp.write_text(json.dumps(payload, separators=(",", ":")) + "\\n")
os.replace(tmp, path)
PY
printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"jobId":"%s","pid":%s,"task":{"state":"running","phase":"submitted","code":"start-running"},"valuesPrinted":false}\n' "$job_id" "$pid"
printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"previousTaskReset":%s,"jobId":"%s","pid":%s,"task":{"state":"running","phase":"submitted","code":"start-running"},"valuesPrinted":false}\n' "$reset_previous" "$job_id" "$pid"
`;
}
@@ -1513,8 +1546,28 @@ function objectSummary(object: Record<string, unknown>): Record<string, unknown>
}
function structuralFingerprint(manifest: Record<string, unknown>[]): string {
const redacted = manifest.map((object) => object.kind === "Secret" ? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, "<redacted>"])) } : object);
return `sha256:${createHash("sha256").update(JSON.stringify(redacted)).digest("hex")}`;
return manifestFingerprint(manifest, false);
}
function requestStructuralFingerprint(manifest: Record<string, unknown>[]): string {
return manifestFingerprint(manifest, true);
}
function manifestFingerprint(manifest: Record<string, unknown>[], stableRequest: boolean): string {
const normalized = manifest.map((object) => {
let value = object.kind === "Secret"
? { ...object, stringData: Object.fromEntries(Object.keys(record(object.stringData, "Secret.stringData")).sort().map((key) => [key, "<redacted>"])) }
: object;
if (!stableRequest) return value;
const metadata = optionalRecord(value.metadata, "metadata");
const annotations = metadata === null ? null : optionalRecord(metadata.annotations, "metadata.annotations");
if (metadata === null || annotations === null || annotations["pikaoa.unidesk.io/expires-at"] === undefined) return value;
const stableAnnotations = { ...annotations };
delete stableAnnotations["pikaoa.unidesk.io/expires-at"];
value = { ...value, metadata: { ...metadata, annotations: stableAnnotations } };
return value;
});
return `sha256:${createHash("sha256").update(JSON.stringify(normalized)).digest("hex")}`;
}
function placeholderSecrets(): SecretMaterial {