fix(pikaoa): 允许终态测试步骤安全重跑
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -188,7 +188,7 @@ export function pikaoaTestTargetHelp(): Record<string, unknown> {
|
||||
],
|
||||
source: "config/pikaoa.yaml#testRuntime",
|
||||
guarantees: [
|
||||
"未声明专用 target 时 plan/status 返回 configured=false,start/stop 在远端调用前失败。",
|
||||
"未声明测试 target 时 plan/status 返回 configured=false,start/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 {
|
||||
|
||||
Reference in New Issue
Block a user