Merge origin/master into feat/2010-hwlab-release-production-lane
This commit is contained in:
@@ -222,9 +222,9 @@ function evaluatePacAdmissionState(inputValue) {
|
||||
if (annotations["unidesk.ai/pac-controller-identity"] !== expected.controllerIdentity) reasons.push(`${kind}-controller-identity-mismatch`);
|
||||
if (annotations["unidesk.ai/effective-config-sha256"] !== expected.configSha256) reasons.push(`${kind}-config-sha256-mismatch`);
|
||||
}
|
||||
if (policySpec.failurePolicy !== "Fail") reasons.push("policy-failure-policy-not-fail");
|
||||
if (policySpec.failurePolicy !== "Ignore") reasons.push("policy-failure-policy-not-ignore");
|
||||
if (bindingSpec.policyName !== expected.policyName) reasons.push("binding-policy-name-mismatch");
|
||||
if (!Array.isArray(bindingSpec.validationActions) || bindingSpec.validationActions.length !== 1 || bindingSpec.validationActions[0] !== "Deny") reasons.push("binding-validation-actions-not-deny");
|
||||
if (!Array.isArray(bindingSpec.validationActions) || stableCanonicalJson(bindingSpec.validationActions) !== stableCanonicalJson(["Warn", "Audit"])) reasons.push("binding-validation-actions-not-warning-only");
|
||||
if (!Number.isInteger(policyMetadata.generation) || !Number.isInteger(policyStatus.observedGeneration) || policyStatus.observedGeneration < policyMetadata.generation) reasons.push("policy-generation-not-observed");
|
||||
if (warnings.length > 0) reasons.push("policy-expression-warning");
|
||||
if (roleMetadata.name !== "unidesk-pac-consumer-runner") reasons.push("default-role-missing");
|
||||
@@ -258,6 +258,9 @@ function evaluatePacAdmissionState(inputValue) {
|
||||
configured: true,
|
||||
required: true,
|
||||
ready: reasons.length === 0,
|
||||
blocking: false,
|
||||
warning: reasons.length > 0,
|
||||
warnings: reasons,
|
||||
source: "live-admissionregistration-v1",
|
||||
policyName: stringOrNull(policyMetadata.name),
|
||||
bindingName: stringOrNull(bindingMetadata.name),
|
||||
@@ -357,13 +360,18 @@ function classifyPacPipelineRun(consumerInput, itemInput) {
|
||||
deliveryClass,
|
||||
reason,
|
||||
candidate,
|
||||
deliveryAuthorityEligible: outer && (!provenanceRequired || admissionProvenanceVerified),
|
||||
deliveryAuthorityEligible: outer,
|
||||
deliveryOwner: outer ? admissionProvenanceVerified ? "pac-controller-admission" : "pac-controller-observed" : null,
|
||||
executionOwner: inner ? "tekton-child-unattached" : null,
|
||||
parentPipelineRun: null,
|
||||
parentRelation: inner ? "unproven" : null,
|
||||
metadataObservationOnly: outer && !admissionProvenanceVerified,
|
||||
admissionProvenanceVerified,
|
||||
admissionWarning: outer && !admissionProvenanceVerified ? {
|
||||
code: "pac-admission-provenance-unverified",
|
||||
blocking: false,
|
||||
reasons: Array.isArray(admissionState.reasons) ? admissionState.reasons : [],
|
||||
} : null,
|
||||
admissionPolicyReady: admissionState.ready === true,
|
||||
admissionPolicyName: stringOrNull(admissionState.policyName),
|
||||
admissionBindingName: stringOrNull(admissionState.bindingName),
|
||||
@@ -1285,19 +1293,19 @@ function runPacStatusFixtureChecks() {
|
||||
};
|
||||
const classificationCases = [
|
||||
{ id: "admission-provenance-verified", expected: true, item: verifiedRun, state: admissionState },
|
||||
{ id: "pre-bootstrap-run-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, creationTimestamp: "2025-12-31T23:59:59Z" } }, state: admissionState },
|
||||
{ id: "forged-name-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState },
|
||||
{ id: "forged-label-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", labels: { ...verifiedRun.metadata.labels, "tekton.dev/pipeline": consumer.pipeline }, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState },
|
||||
{ id: "forged-pipeline-ref-candidate-fails-closed", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } }, spec: { ...verifiedRun.spec, pipelineRef: { name: consumer.pipeline } } }, state: admissionState },
|
||||
{ id: "wrong-service-account-fails-closed", expected: false, item: { ...verifiedRun, spec: { taskRunTemplate: { serviceAccountName: "default" } } }, state: admissionState },
|
||||
{ id: "policy-identity-mismatch-fails-closed", expected: false, item: verifiedRun, state: { ...admissionState, configSha256: `sha256:${"2".repeat(64)}` } },
|
||||
{ id: "pre-bootstrap-run-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, creationTimestamp: "2025-12-31T23:59:59Z" } }, state: admissionState },
|
||||
{ id: "marker-mismatch-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState },
|
||||
{ id: "candidate-label-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", labels: { ...verifiedRun.metadata.labels, "tekton.dev/pipeline": consumer.pipeline }, annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } } }, state: admissionState },
|
||||
{ id: "candidate-pipeline-ref-warning-only", expected: false, item: { ...verifiedRun, metadata: { ...verifiedRun.metadata, name: "unrelated", annotations: { ...verifiedRun.metadata.annotations, [provenance.markerAnnotation]: "forged" } }, spec: { ...verifiedRun.spec, pipelineRef: { name: consumer.pipeline } } }, state: admissionState },
|
||||
{ id: "service-account-mismatch-warning-only", expected: false, item: { ...verifiedRun, spec: { taskRunTemplate: { serviceAccountName: "default" } } }, state: admissionState },
|
||||
{ id: "policy-identity-mismatch-warning-only", expected: false, item: verifiedRun, state: { ...admissionState, configSha256: `sha256:${"2".repeat(64)}` } },
|
||||
];
|
||||
for (const item of classificationCases) {
|
||||
const result = classifyPacPipelineRun({ ...consumer, admissionState: item.state }, item.item);
|
||||
checks.push({
|
||||
id: item.id,
|
||||
ok: result.admissionProvenanceVerified === item.expected && result.deliveryAuthorityEligible === item.expected,
|
||||
expectedOk: item.expected,
|
||||
ok: result.admissionProvenanceVerified === item.expected && result.deliveryAuthorityEligible === true,
|
||||
expectedOk: true,
|
||||
actualOk: result.deliveryAuthorityEligible,
|
||||
expectedCode: item.expected ? "admission-owned-pac-push-provenance-verified" : "pac-push-metadata-observed-but-admission-provenance-unverified",
|
||||
actualCode: result.reason,
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { rmSync } from "node:fs";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { runAgentRunCommand } from "./agentrun";
|
||||
|
||||
const RUN_ID = "run_long_poll_fixture";
|
||||
const agentRunClientYaml = [
|
||||
"version: 1",
|
||||
"kind: AgentRunConfig",
|
||||
"metadata:",
|
||||
" name: agentrun",
|
||||
"manager:",
|
||||
" baseUrl: __BASE_URL__",
|
||||
" timeoutMs: 15000",
|
||||
"auth:",
|
||||
" env: HWLAB_API_KEY",
|
||||
" file: /tmp/hwlab-api-key.env",
|
||||
" header: Authorization",
|
||||
" scheme: Bearer",
|
||||
"client:",
|
||||
" role: render-only",
|
||||
" transport: direct-http",
|
||||
" sessionPolicy:",
|
||||
" tenantId: unidesk",
|
||||
" projectId: test",
|
||||
" providerId: NC01",
|
||||
" backendProfile: codex",
|
||||
" workspaceRef:",
|
||||
" kind: opaque",
|
||||
" executionPolicy:",
|
||||
" sandbox: workspace-write",
|
||||
" approval: never",
|
||||
" timeoutMs: 7200000",
|
||||
" network: enabled",
|
||||
" secretScope:",
|
||||
" allowCredentialEcho: false",
|
||||
].join("\n");
|
||||
|
||||
function renderedText(value: Awaited<ReturnType<typeof runAgentRunCommand>>): string {
|
||||
if (!("renderedText" in value) || typeof value.renderedText !== "string") throw new Error("expected rendered AgentRun CLI output");
|
||||
return value.renderedText;
|
||||
}
|
||||
|
||||
function event(seq: number): Record<string, unknown> {
|
||||
return {
|
||||
id: `evt_${seq}`,
|
||||
seq,
|
||||
type: "backend_status",
|
||||
payload: { commandId: "cmd_fixture", phase: `phase-${seq}`, message: `event-${seq}` },
|
||||
};
|
||||
}
|
||||
|
||||
async function withManager(
|
||||
handler: (request: Request) => Response | Promise<Response>,
|
||||
action: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const server = Bun.serve({ port: 0, fetch: handler });
|
||||
const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
const previousKey = process.env.HWLAB_API_KEY;
|
||||
const configPath = `/tmp/unidesk-agentrun-events-long-poll-${process.pid}-${Date.now()}.yaml`;
|
||||
process.env.AGENTRUN_CLIENT_CONFIG = configPath;
|
||||
process.env.HWLAB_API_KEY = "fixture-secret";
|
||||
await Bun.write(configPath, agentRunClientYaml.replace("__BASE_URL__", server.url.href.replace(/\/$/u, "")));
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
server.stop(true);
|
||||
rmSync(configPath, { force: true });
|
||||
if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG;
|
||||
else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig;
|
||||
if (previousKey === undefined) delete process.env.HWLAB_API_KEY;
|
||||
else process.env.HWLAB_API_KEY = previousKey;
|
||||
}
|
||||
}
|
||||
|
||||
describe.serial("AgentRun events manager-held long polling", () => {
|
||||
test("forwards default 120s timeout and returns as soon as expect is reached", async () => {
|
||||
let requests = 0;
|
||||
await withManager(async (request) => {
|
||||
requests += 1;
|
||||
const url = new URL(request.url);
|
||||
expect(url.pathname).toBe(`/api/v1/runs/${RUN_ID}/events`);
|
||||
expect(url.searchParams.get("afterSeq")).toBe("100");
|
||||
expect(url.searchParams.get("expect")).toBe("2");
|
||||
expect(url.searchParams.get("timeoutMs")).toBe("120000");
|
||||
expect(url.searchParams.get("limit")).toBe("21");
|
||||
await Promise.all([Bun.sleep(10), Bun.sleep(20)]);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [event(101), event(102)],
|
||||
timedOut: false,
|
||||
observed: 2,
|
||||
nextAfterSeq: 102,
|
||||
completionReason: "expect-reached",
|
||||
},
|
||||
});
|
||||
}, async () => {
|
||||
const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "100", "--expect", "2", "-o", "json"]);
|
||||
expect(result.ok).toBe(true);
|
||||
const payload = JSON.parse(renderedText(result)) as Record<string, unknown>;
|
||||
expect(payload).toMatchObject({ count: 2, expect: 2, timeoutMs: 120000, timedOut: false, observed: 2, nextAfterSeq: 102, completionReason: "expect-reached" });
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders partial and empty timeout responses with an explicit cursor", async () => {
|
||||
for (const fixture of [
|
||||
{ items: [event(201)], observed: 1, nextAfterSeq: 201 },
|
||||
{ items: [], observed: 0, nextAfterSeq: 200 },
|
||||
]) {
|
||||
await withManager((request) => {
|
||||
const url = new URL(request.url);
|
||||
expect(url.searchParams.get("timeoutMs")).toBe("3000");
|
||||
return Response.json({ ok: true, data: { ...fixture, timedOut: true, completionReason: "timeout" } });
|
||||
}, async () => {
|
||||
const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "200", "--expect", "10", "--timeout", "3s", "-o", "json"]);
|
||||
const payload = JSON.parse(renderedText(result)) as Record<string, unknown>;
|
||||
expect(payload).toMatchObject({ timedOut: true, observed: fixture.observed, nextAfterSeq: fixture.nextAfterSeq, completionReason: "timeout" });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("returns concurrent event arrivals in one manager request", async () => {
|
||||
let requests = 0;
|
||||
await withManager(async () => {
|
||||
requests += 1;
|
||||
const arrivals: Record<string, unknown>[] = [];
|
||||
await Promise.all([
|
||||
Bun.sleep(10).then(() => arrivals.push(event(301))),
|
||||
Bun.sleep(20).then(() => arrivals.push(event(302))),
|
||||
]);
|
||||
return Response.json({ ok: true, data: { items: arrivals, timedOut: false, observed: 2, nextAfterSeq: 302, completionReason: "expect-reached" } });
|
||||
}, async () => {
|
||||
const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "300", "--expect", "2", "--timeout", "1m", "-o", "json"]);
|
||||
const payload = JSON.parse(renderedText(result)) as { items?: Array<{ seq?: number }> };
|
||||
expect(payload.items?.map((item) => item.seq)).toEqual([301, 302]);
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("returns immediately when the target is terminal before expect is reached", async () => {
|
||||
await withManager(() => Response.json({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [],
|
||||
timedOut: false,
|
||||
observed: 0,
|
||||
nextAfterSeq: 400,
|
||||
completionReason: "target-completed",
|
||||
},
|
||||
}), async () => {
|
||||
const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "400", "--expect", "10", "--timeout", "120s", "-o", "json"]);
|
||||
const payload = JSON.parse(renderedText(result)) as Record<string, unknown>;
|
||||
expect(payload).toMatchObject({ timedOut: false, observed: 0, nextAfterSeq: 400, completionReason: "target-completed" });
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid duration before request and preserves manager errors", async () => {
|
||||
let requests = 0;
|
||||
await withManager(() => {
|
||||
requests += 1;
|
||||
return Response.json({ ok: false, failureKind: "manager-unavailable", message: "event notifier unavailable" }, { status: 503 });
|
||||
}, async () => {
|
||||
const invalid = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "2", "--timeout", "3minutes", "-o", "json"]);
|
||||
expect(invalid.ok).toBe(false);
|
||||
expect(renderedText(invalid)).toContain("--timeout must use a positive integer duration with ms, s, or m units");
|
||||
expect(requests).toBe(0);
|
||||
|
||||
const failed = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "2", "--timeout", "1s", "-o", "json"]);
|
||||
expect(failed.ok).toBe(false);
|
||||
expect(renderedText(failed)).toContain("event notifier unavailable");
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("fails visibly when a manager ignores the long-poll response contract", async () => {
|
||||
await withManager(() => Response.json({ ok: true, data: { items: [], nextAfterSeq: 500 } }), async () => {
|
||||
const result = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--after-seq", "500", "--expect", "2", "--timeout", "1s", "-o", "json"]);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(renderedText(result)).toContain("schema-mismatch");
|
||||
expect(renderedText(result)).toContain("must include boolean timedOut and non-negative observed fields");
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts 60m and rejects zero, values above 60m, and hour units before request", async () => {
|
||||
let requests = 0;
|
||||
await withManager((request) => {
|
||||
requests += 1;
|
||||
const url = new URL(request.url);
|
||||
expect(url.searchParams.get("timeoutMs")).toBe("3600000");
|
||||
return Response.json({ ok: true, data: { items: [], timedOut: true, observed: 0, nextAfterSeq: 0, completionReason: "timeout" } });
|
||||
}, async () => {
|
||||
const accepted = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "1", "--timeout", "60m", "-o", "json"]);
|
||||
expect(accepted.ok).toBe(true);
|
||||
expect(requests).toBe(1);
|
||||
|
||||
for (const invalidDuration of ["0ms", "61m", "1h"]) {
|
||||
const invalid = await runAgentRunCommand(null, ["events", `run/${RUN_ID}`, "--expect", "1", "--timeout", invalidDuration, "-o", "json"]);
|
||||
expect(invalid.ok).toBe(false);
|
||||
}
|
||||
expect(requests).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -371,6 +371,7 @@ export function agentRunQuery(args: string[], names: string[]): string {
|
||||
const params = new URLSearchParams();
|
||||
const keyMap: Record<string, string> = {
|
||||
"after-seq": "afterSeq",
|
||||
"timeout-ms": "timeoutMs",
|
||||
"backend-profile": "backendProfile",
|
||||
"command": "commandId",
|
||||
"command-id": "commandId",
|
||||
|
||||
@@ -59,7 +59,7 @@ export function agentRunHelp(): unknown {
|
||||
"bun scripts/cli.ts agentrun get tasks -o json",
|
||||
"bun scripts/cli.ts agentrun describe task/<taskId>",
|
||||
"bun scripts/cli.ts agentrun describe task/<taskId> --input -o json",
|
||||
"bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --limit 100",
|
||||
"bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --expect 10 --timeout 120s --limit 100",
|
||||
"bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq>",
|
||||
"bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
|
||||
"bun scripts/cli.ts agentrun result run/<runId> --command <commandId>",
|
||||
@@ -292,9 +292,10 @@ export function agentRunHelpText(args: string[]): string {
|
||||
}
|
||||
if (verb === "events") {
|
||||
return [
|
||||
"Usage: bun scripts/cli.ts agentrun events run/<runId> [--after-seq N] [--limit 100] [-o json|yaml] [--full|--raw]",
|
||||
"Usage: bun scripts/cli.ts agentrun events run/<runId> [--after-seq N] [--expect N [--timeout 120s]] [--limit 100] [-o json|yaml] [--full|--raw]",
|
||||
" bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq> [-o json|yaml]",
|
||||
"",
|
||||
"--expect waits in one manager-held request until N new events, target completion, or timeout; --timeout defaults to 120s. --limit remains an independent output budget.",
|
||||
"--detail-seq returns one bounded Secret-safe EventDetail projection. Use the separate --after-seq <seq-1> --limit 1 --full|--raw path only for explicit complete disclosure.",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -390,7 +391,7 @@ export function agentRunHelpText(args: string[]): string {
|
||||
" bun scripts/cli.ts agentrun get tasks --queue commander --limit 20",
|
||||
" bun scripts/cli.ts agentrun describe task/<taskId>",
|
||||
" bun scripts/cli.ts agentrun describe run/<runId> --node NC01 --lane nc01-v02",
|
||||
" bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --limit 100",
|
||||
" bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --expect 10 --timeout 120s --limit 100",
|
||||
" bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
|
||||
"",
|
||||
"Use --raw on a resource command when you need the direct AgentRun REST envelope.",
|
||||
@@ -407,7 +408,7 @@ export function agentRunHelpText(args: string[]): string {
|
||||
" bun scripts/cli.ts agentrun describe task/<taskId>",
|
||||
" bun scripts/cli.ts agentrun describe task/<taskId> --input -o json",
|
||||
" bun scripts/cli.ts agentrun describe run/<runId> --node NC01 --lane nc01-v02",
|
||||
" bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --limit 100",
|
||||
" bun scripts/cli.ts agentrun events run/<runId> --after-seq 0 --expect 10 --timeout 120s --limit 100",
|
||||
" bun scripts/cli.ts agentrun events run/<runId> --detail-seq <seq>",
|
||||
" bun scripts/cli.ts agentrun logs session/<sessionId> --tail 100",
|
||||
" bun scripts/cli.ts agentrun retry task/<taskId> --idempotency-key <key> --reason <text> --dry-run",
|
||||
|
||||
@@ -360,6 +360,13 @@ export interface EventLikePageDisclosure {
|
||||
hasMore: boolean;
|
||||
nextAfterSeq: number | string | null;
|
||||
detailCommand: string;
|
||||
wait: {
|
||||
expect: number;
|
||||
timeoutMs: number;
|
||||
timedOut: boolean;
|
||||
observed: number;
|
||||
completionReason: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function renderEventLike(command: string, raw: Record<string, unknown>, options: AgentRunResourceOptions, kind: string, items: Record<string, unknown>[], sourceName: string, pageDisclosure: EventLikePageDisclosure | null = null): RenderedCliResult {
|
||||
@@ -376,6 +383,7 @@ export function renderEventLike(command: string, raw: Record<string, unknown>, o
|
||||
effectiveLimit: pageDisclosure.effectiveLimit,
|
||||
fetchedCount: pageDisclosure.fetchedCount,
|
||||
hasMore: pageDisclosure.hasMore,
|
||||
...(pageDisclosure.wait === null ? {} : pageDisclosure.wait),
|
||||
disclosure: {
|
||||
output: "omitted; metadata only",
|
||||
identity: "item.identity; detail --detail-seq item.seq",
|
||||
@@ -415,6 +423,9 @@ export function renderEventLike(command: string, raw: Record<string, unknown>, o
|
||||
])),
|
||||
];
|
||||
if (pageDisclosure !== null) lines.push(`Detail: ${pageDisclosure.detailCommand}`);
|
||||
if (pageDisclosure?.wait !== null && pageDisclosure?.wait !== undefined) {
|
||||
lines.push(`Wait: expect=${pageDisclosure.wait.expect} observed=${pageDisclosure.wait.observed} timedOut=${String(pageDisclosure.wait.timedOut)} timeoutMs=${pageDisclosure.wait.timeoutMs} nextAfterSeq=${String(nextAfterSeq ?? 0)}${pageDisclosure.wait.completionReason === null ? "" : ` reason=${pageDisclosure.wait.completionReason}`}`);
|
||||
}
|
||||
if (nextAfterSeq !== undefined && nextAfterSeq !== null) lines.push(`Next: bun scripts/cli.ts ${nextPagedResourceCommand(command, String(nextAfterSeq), options.limit)}`);
|
||||
return renderedCliResult(raw.ok !== false, command, lines.join("\n"));
|
||||
}
|
||||
|
||||
@@ -178,6 +178,8 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
|
||||
commandId: null,
|
||||
sessionId: null,
|
||||
afterSeq: null,
|
||||
expect: null,
|
||||
timeoutMs: 120_000,
|
||||
eventDetailSeq: null,
|
||||
tail: null,
|
||||
fullText: false,
|
||||
@@ -196,7 +198,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
|
||||
mdtodoId: null,
|
||||
passthroughArgs: [],
|
||||
};
|
||||
const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]);
|
||||
const valueFlags = new Set(["-o", "--output", "--limit", "--cursor", "--queue", "--state", "--reader-id", "--task", "--task-id", "--run", "--run-id", "--command", "--command-id", "--session", "--session-id", "--after-seq", "--expect", "--timeout", "--detail-seq", "--tail", "--reason", "-f", "--file", "--filename", "--aipod", "--idempotency-key", "--node", "--lane", "--target", "--target-workspace", "--repo", "--ref", "--mdtodo-id"]);
|
||||
const booleanFlags = new Set(["--full", "--raw", "--debug", "--input", "--unread", "--dry-run", "--full-text", "--prompt-stdin", "--stdin"]);
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
@@ -243,7 +245,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
|
||||
);
|
||||
}
|
||||
if (options.eventDetailSeq !== null) {
|
||||
const conflictingOptions = ["--after-seq", "--limit", "--full", "--raw"]
|
||||
const conflictingOptions = ["--after-seq", "--expect", "--timeout", "--limit", "--full", "--raw"]
|
||||
.filter((flag) => resourceOptionPresent(args, flag));
|
||||
if (conflictingOptions.length > 0) {
|
||||
throw new AgentRunRestError(
|
||||
@@ -253,7 +255,7 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
|
||||
details: {
|
||||
conflictingOptions: ["--detail-seq", ...conflictingOptions],
|
||||
recoveryActions: [
|
||||
"Use --detail-seq <seq> without --after-seq, --limit, --full, or --raw for the bounded Secret-safe EventDetail projection.",
|
||||
"Use --detail-seq <seq> without --after-seq, --expect, --timeout, --limit, --full, or --raw for the bounded Secret-safe EventDetail projection.",
|
||||
"Use --after-seq <seq-1> --limit 1 --full -o json or --raw only when explicitly requesting the complete durable event.",
|
||||
],
|
||||
valuesPrinted: false,
|
||||
@@ -262,6 +264,18 @@ export function parseResourceOptions(args: string[]): AgentRunResourceOptions {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (resourceOptionPresent(args, "--timeout") && options.expect === null) {
|
||||
throw new AgentRunRestError(
|
||||
"validation-failed",
|
||||
"--timeout requires --expect <N>; timeout controls the manager-held long poll and is not a client-side polling interval.",
|
||||
{
|
||||
details: {
|
||||
recoveryActions: ["Use --expect <N> [--timeout <duration>] or remove --timeout for an immediate paged read."],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -278,6 +292,18 @@ function validateResourceOptionsForVerb(verb: AgentRunResourceVerb, options: Age
|
||||
},
|
||||
);
|
||||
}
|
||||
if (options.expect !== null && verb !== "events") {
|
||||
throw new AgentRunRestError(
|
||||
"validation-failed",
|
||||
"--expect and --timeout are supported only by agentrun events run/<runId>.",
|
||||
{
|
||||
details: {
|
||||
recoveryActions: ["Use: bun scripts/cli.ts agentrun events run/<runId> --after-seq <N> --expect <N> [--timeout 120s]."],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resourceOptionPresent(args: string[], flag: string): boolean {
|
||||
@@ -308,6 +334,8 @@ export function applyResourceOption(options: AgentRunResourceOptions, flag: stri
|
||||
else if (flag === "--command" || flag === "--command-id") options.commandId = requiredValue(value, flag);
|
||||
else if (flag === "--session" || flag === "--session-id") options.sessionId = requiredValue(value, flag);
|
||||
else if (flag === "--after-seq") options.afterSeq = parseNonNegativeInt(value, "--after-seq", 0, Number.MAX_SAFE_INTEGER);
|
||||
else if (flag === "--expect") options.expect = parsePositiveInt(value, "--expect");
|
||||
else if (flag === "--timeout") options.timeoutMs = parseDurationMs(value, "--timeout");
|
||||
else if (flag === "--detail-seq") options.eventDetailSeq = parsePositiveInt(value, "--detail-seq");
|
||||
else if (flag === "--tail") options.tail = parseNonNegativeInt(value, "--tail", 100, 1000);
|
||||
else if (flag === "--reason") options.reason = requiredValue(value, flag);
|
||||
@@ -330,6 +358,17 @@ function parsePositiveInt(raw: string | null, flag: string): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseDurationMs(raw: string | null, flag: string): number {
|
||||
if (raw === null || raw.length === 0) throw new Error(`${flag} requires a duration`);
|
||||
const match = /^(\d+)(ms|s|m)$/u.exec(raw.trim());
|
||||
if (match === null) throw new Error(`${flag} must use a positive integer duration with ms, s, or m units, such as 500ms, 120s, or 3m`);
|
||||
const amount = Number(match[1]);
|
||||
const multiplier = match[2] === "ms" ? 1 : match[2] === "s" ? 1_000 : 60_000;
|
||||
const value = amount * multiplier;
|
||||
if (!Number.isSafeInteger(value) || value < 1 || value > 3_600_000) throw new Error(`${flag} must be between 1ms and 60m`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseNonNegativeInt(raw: string | null, flag: string, defaultValue: number, maxValue: number): number {
|
||||
if (raw === null || raw.length === 0) return defaultValue;
|
||||
const value = Number(raw);
|
||||
@@ -607,6 +646,7 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri
|
||||
const effectiveLimit = options.full || options.raw ? requestedLimit : Math.min(requestedLimit, 20);
|
||||
const requestLimit = options.full || options.raw ? effectiveLimit : Math.min(500, effectiveLimit + 1);
|
||||
const eventArgs = ["events", runId, "--after-seq", String(options.afterSeq ?? 0), "--limit", String(requestLimit)];
|
||||
if (options.expect !== null) eventArgs.push("--expect", String(options.expect), "--timeout-ms", String(options.timeoutMs));
|
||||
const result = await runAgentRunRestCommand(config, "runs", eventArgs);
|
||||
if (options.raw) return renderMachine(command, result, "json", result.ok !== false);
|
||||
if (options.full) return renderMachine(command, result, options.output === "yaml" ? "yaml" : "json", result.ok !== false);
|
||||
@@ -616,7 +656,32 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri
|
||||
const hasMore = fetchedItems.length > visibleItems.length;
|
||||
const lastVisibleSeq = visibleItems.length === 0 ? null : nonNegativeIntegerOrNull(visibleItems.at(-1)?.seq);
|
||||
const responseNextAfterSeq = nonNegativeIntegerOrNull(record(innerData(result)).nextAfterSeq);
|
||||
const nextAfterSeq = hasMore ? lastVisibleSeq : responseNextAfterSeq;
|
||||
const nextAfterSeq = hasMore ? lastVisibleSeq : responseNextAfterSeq ?? options.afterSeq ?? 0;
|
||||
const waitData = record(innerData(result));
|
||||
const observed = nonNegativeIntegerOrNull(waitData.observed);
|
||||
if (options.expect !== null && (typeof waitData.timedOut !== "boolean" || observed === null)) {
|
||||
throw new AgentRunRestError(
|
||||
"schema-mismatch",
|
||||
"AgentRun manager long-poll response must include boolean timedOut and non-negative observed fields.",
|
||||
{
|
||||
details: {
|
||||
runId,
|
||||
expect: options.expect,
|
||||
timeoutMs: options.timeoutMs,
|
||||
returnedTimedOutType: typeof waitData.timedOut,
|
||||
returnedObserved: waitData.observed ?? null,
|
||||
valuesPrinted: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const waitDisclosure = options.expect === null ? null : {
|
||||
expect: options.expect,
|
||||
timeoutMs: options.timeoutMs,
|
||||
timedOut: waitData.timedOut === true,
|
||||
observed: observed ?? fetchedItems.length,
|
||||
completionReason: stringOrNull(waitData.completionReason) ?? stringOrNull(waitData.reason),
|
||||
};
|
||||
const targetArgs = [
|
||||
options.node === null ? null : `--node ${options.node}`,
|
||||
options.lane === null ? null : `--lane ${options.lane}`,
|
||||
@@ -629,6 +694,7 @@ export async function resourceEvents(config: UniDeskConfig | null, command: stri
|
||||
hasMore,
|
||||
nextAfterSeq,
|
||||
detailCommand,
|
||||
wait: waitDisclosure,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -523,7 +523,16 @@ export async function runAgentRunSessionsRest(action: string | undefined, id: st
|
||||
export async function runAgentRunRunsRest(action: string | undefined, id: string | undefined, args: string[]): Promise<Record<string, unknown>> {
|
||||
if (action === "create") return await agentRunRestRequest("agentrun runs create", "POST", "/api/v1/runs", await requiredJsonBody(args, "runs create"));
|
||||
if (action === "show" && id) return await agentRunRestRequest("agentrun runs show", "GET", `/api/v1/runs/${encodeURIComponent(id)}`);
|
||||
if (action === "events" && id) return await agentRunRestRequest("agentrun runs events", "GET", `/api/v1/runs/${encodeURIComponent(id)}/events${agentRunQuery(args, ["after-seq", "limit"])}`);
|
||||
if (action === "events" && id) {
|
||||
const timeoutMs = Number(agentRunOption(args, "timeout-ms") ?? "0");
|
||||
return await agentRunRestRequest(
|
||||
"agentrun runs events",
|
||||
"GET",
|
||||
`/api/v1/runs/${encodeURIComponent(id)}/events${agentRunQuery(args, ["after-seq", "expect", "timeout-ms", "limit"])}`,
|
||||
undefined,
|
||||
Number.isSafeInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs + 5_000 : undefined,
|
||||
);
|
||||
}
|
||||
if (action === "result" && id) return await agentRunRestRequest("agentrun runs result", "GET", `/api/v1/runs/${encodeURIComponent(id)}/result${agentRunQuery(args, ["command-id", "command"])}`);
|
||||
if (action === "cancel" && id) return await agentRunRestRequest("agentrun runs cancel", "POST", `/api/v1/runs/${encodeURIComponent(id)}/cancel`, cancelBodyFromArgs(args));
|
||||
throw new AgentRunRestError("validation-failed", `unsupported runs command: ${[action, id].filter(Boolean).join(" ") || "(empty)"}`);
|
||||
@@ -1157,14 +1166,15 @@ export async function sessionRunnerJobBody(args: string[], defaults: Record<stri
|
||||
return runnerBody;
|
||||
}
|
||||
|
||||
export async function agentRunRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body?: unknown): Promise<Record<string, unknown>> {
|
||||
export async function agentRunRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body?: unknown, requestTimeoutMs?: number): Promise<Record<string, unknown>> {
|
||||
const clientConfig = readAgentRunClientConfig();
|
||||
if (activeAgentRunRestTarget !== null) return await agentRunLaneRestRequest(command, method, pathValue, body, clientConfig, activeAgentRunRestTarget);
|
||||
const effectiveTimeoutMs = Math.max(clientConfig.manager.timeoutMs, requestTimeoutMs ?? 0);
|
||||
if (activeAgentRunRestTarget !== null) return await agentRunLaneRestRequest(command, method, pathValue, body, clientConfig, activeAgentRunRestTarget, effectiveTimeoutMs);
|
||||
const auth = resolveAgentRunAuth(clientConfig);
|
||||
const bridgeBase = agentRunRestBridgeMetadata(clientConfig, auth, method, pathValue);
|
||||
const startedAt = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), clientConfig.manager.timeoutMs);
|
||||
const timeout = setTimeout(() => controller.abort(), effectiveTimeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -1178,7 +1188,7 @@ export async function agentRunRestRequest(command: string, method: AgentRunHttpM
|
||||
response = await fetch(new URL(pathValue, clientConfig.manager.baseUrl), init);
|
||||
} catch (error) {
|
||||
const timedOut = isAbortLikeError(error);
|
||||
throw new AgentRunRestError(timedOut ? "agentrun-timeout" : "agentrun-connect-failed", timedOut ? `AgentRun server request timed out for ${method} ${pathValue} after ${clientConfig.manager.timeoutMs}ms` : `AgentRun server connection failed for ${method} ${pathValue}: ${error instanceof Error ? error.message : String(error)}`, { bridge: { ...bridgeBase, elapsedMs: Date.now() - startedAt } });
|
||||
throw new AgentRunRestError(timedOut ? "agentrun-timeout" : "agentrun-connect-failed", timedOut ? `AgentRun server request timed out for ${method} ${pathValue} after ${effectiveTimeoutMs}ms` : `AgentRun server connection failed for ${method} ${pathValue}: ${error instanceof Error ? error.message : String(error)}`, { bridge: { ...bridgeBase, elapsedMs: Date.now() - startedAt } });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
@@ -1215,7 +1225,7 @@ export function isAbortLikeError(error: unknown): boolean {
|
||||
return error.name === "AbortError" || /abort|timed out|timeout/iu.test(error.message);
|
||||
}
|
||||
|
||||
export async function agentRunLaneRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body: unknown, clientConfig: AgentRunClientConfig, target: AgentRunRestTarget): Promise<Record<string, unknown>> {
|
||||
export async function agentRunLaneRestRequest(command: string, method: AgentRunHttpMethod, pathValue: string, body: unknown, clientConfig: AgentRunClientConfig, target: AgentRunRestTarget, requestTimeoutMs = clientConfig.manager.timeoutMs): Promise<Record<string, unknown>> {
|
||||
const bridgeBase = agentRunLaneRestBridgeMetadata(clientConfig, target, method, pathValue);
|
||||
const startedAt = Date.now();
|
||||
const proxyBaseUrl = `http://127.0.0.1:${target.spec.runtime.managerPort}`;
|
||||
@@ -1225,7 +1235,7 @@ export async function agentRunLaneRestRequest(command: string, method: AgentRunH
|
||||
body: body ?? null,
|
||||
baseUrl: proxyBaseUrl,
|
||||
serviceBaseUrl: target.spec.runtime.internalBaseUrl,
|
||||
timeoutMs: clientConfig.manager.timeoutMs,
|
||||
timeoutMs: requestTimeoutMs,
|
||||
authEnv: clientConfig.auth.env,
|
||||
header: clientConfig.auth.header,
|
||||
scheme: clientConfig.auth.scheme,
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface AgentRunResourceOptions {
|
||||
commandId: string | null;
|
||||
sessionId: string | null;
|
||||
afterSeq: number | null;
|
||||
expect: number | null;
|
||||
timeoutMs: number;
|
||||
eventDetailSeq: number | null;
|
||||
tail: number | null;
|
||||
fullText: boolean;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { renderPikaoaTestDesiredPipeline } from "./pikaoa-test-delivery-renderer";
|
||||
|
||||
function pacDocument(): Record<string, unknown> {
|
||||
return Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function rendererBinding(id = "pikaoa-test-nc01"): Parameters<typeof renderPikaoaTestDesiredPipeline>[0] {
|
||||
const document = pacDocument();
|
||||
const repositories = document.repositories as Array<Record<string, unknown>>;
|
||||
const consumers = document.consumers as Array<Record<string, unknown>>;
|
||||
const consumer = consumers.find((item) => item.id === id);
|
||||
const repository = repositories.find((item) => item.id === consumer?.repositoryRef);
|
||||
if (repository === undefined || consumer === undefined) throw new Error(`PikaOA PaC fixture is required: ${id}`);
|
||||
return {
|
||||
consumer,
|
||||
repository: {
|
||||
...repository,
|
||||
params: {
|
||||
...(repository.params as Record<string, unknown>),
|
||||
...((consumer.params as Record<string, unknown> | undefined) ?? {}),
|
||||
},
|
||||
},
|
||||
} as Parameters<typeof renderPikaoaTestDesiredPipeline>[0];
|
||||
}
|
||||
|
||||
function sourceFixture(): string {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "pikaoa-test-renderer-"));
|
||||
mkdirSync(resolve(root, "deploy"), { recursive: true });
|
||||
for (const file of ["api.Dockerfile", "worker.Dockerfile", "web.Dockerfile"]) writeFileSync(resolve(root, "deploy", file), "FROM scratch\n");
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("PikaOA test PaC renderer", () => {
|
||||
test("shares one PaC repository while isolating master and release consumers", () => {
|
||||
const document = pacDocument();
|
||||
const repositories = document.repositories as Array<Record<string, unknown>>;
|
||||
const consumers = document.consumers as Array<Record<string, unknown>>;
|
||||
const repository = repositories.find((item) => item.id === "pikaoa-test-nc01");
|
||||
const testConsumer = consumers.find((item) => item.id === "pikaoa-test-nc01");
|
||||
const productionConsumer = consumers.find((item) => item.id === "pikaoa-nc01");
|
||||
const duplicateProductionRepository = repositories.find((item) => item.id === "pikaoa-nc01");
|
||||
expect((repository?.params as Record<string, unknown>).source_branch).toBe("master");
|
||||
expect((testConsumer?.sourceArtifact as Record<string, unknown>).pipelineRunPath).toBe(".tekton/pikaoa-test-nc01-pac.yaml");
|
||||
expect((testConsumer?.sourceArtifact as Record<string, unknown>).renderer).toBe("pikaoa-test-runtime");
|
||||
expect((productionConsumer?.params as Record<string, unknown>).source_branch).toBe("release");
|
||||
expect((productionConsumer?.params as Record<string, unknown>).source_snapshot_prefix).toBe("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01");
|
||||
expect(productionConsumer?.repositoryRef).toBe("pikaoa-test-nc01");
|
||||
expect(duplicateProductionRepository).toBeUndefined();
|
||||
expect((productionConsumer?.sourceArtifact as Record<string, unknown>).renderer).toBe("pikaoa-release-runtime");
|
||||
expect((productionConsumer?.sourceArtifact as Record<string, unknown>).configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01");
|
||||
});
|
||||
|
||||
test("renders release runtime from its isolated owning selector", () => {
|
||||
const source = sourceFixture();
|
||||
try {
|
||||
const rendered = renderPikaoaTestDesiredPipeline(rendererBinding("pikaoa-nc01"), source);
|
||||
expect(rendered.configRef).toBe("config/pikaoa.yaml#releaseRuntime.targets.NC01");
|
||||
const serialized = JSON.stringify(rendered.pipeline);
|
||||
expect(serialized).toContain("pikaoa-nc01-pac");
|
||||
expect(serialized).toContain("refs/unidesk/snapshots/gitea-actions/pikaoa-release-nc01");
|
||||
expect(serialized).toContain("pac-gitea-pikaoa-nc01");
|
||||
expect(serialized).not.toContain("pikaoa-test-nc01-pac");
|
||||
} finally {
|
||||
rmSync(source, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("renders parallel digest builds and a PreSync fresh database initializer", () => {
|
||||
const source = sourceFixture();
|
||||
try {
|
||||
const rendered = renderPikaoaTestDesiredPipeline(rendererBinding(), source);
|
||||
const spec = rendered.pipeline.spec as Record<string, unknown>;
|
||||
const tasks = spec.tasks as Array<Record<string, unknown>>;
|
||||
for (const task of tasks) {
|
||||
const taskSpec = task.taskSpec as Record<string, unknown>;
|
||||
expect(taskSpec.workspaces).toEqual([{ name: "workspace", mountPath: "/workspace" }]);
|
||||
const steps = taskSpec.steps as Array<Record<string, unknown>>;
|
||||
for (const step of steps) {
|
||||
const mounts = (step.volumeMounts ?? []) as Array<Record<string, unknown>>;
|
||||
expect(mounts.some((mount) => mount.name === "workspace")).toBe(false);
|
||||
}
|
||||
}
|
||||
for (const name of ["build-api", "build-worker", "build-web"]) {
|
||||
const task = tasks.find((item) => item.name === name);
|
||||
expect(task?.runAfter).toEqual(["source"]);
|
||||
expect(JSON.stringify(task)).toContain("buildctl-daemonless.sh build");
|
||||
expect(JSON.stringify(task)).toContain("$(params.revision)");
|
||||
expect(JSON.stringify(task)).not.toContain("source-token");
|
||||
const buildScript = (((task?.taskSpec as Record<string, unknown>).steps as Array<Record<string, unknown>>)[0]?.script) as string;
|
||||
expect(buildScript).toContain('"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)"');
|
||||
expect(buildScript).toContain(".*/\\1/p");
|
||||
expect(buildScript).not.toContain("\u0001");
|
||||
expect(buildScript).toContain("Docker-Content-Digest:");
|
||||
expect(buildScript).toContain("build_status=$?");
|
||||
expect(buildScript).toContain('"code":"buildctl-nonzero-artifact-verified"');
|
||||
expect(buildScript).toContain('"artifactVerified":true');
|
||||
}
|
||||
const digest = "sha256:" + "a".repeat(64);
|
||||
const metadata = JSON.stringify({ "containerimage.digest": digest }, null, 2);
|
||||
const parsedDigest = /"containerimage\.digest"\s*:\s*"(sha256:[0-9a-f]{64})"/u.exec(metadata)?.[1];
|
||||
expect(parsedDigest).toBe(digest);
|
||||
const sourceTask = tasks.find((item) => item.name === "source");
|
||||
expect(JSON.stringify(sourceTask)).toContain("source-token");
|
||||
expect(JSON.stringify(sourceTask)).toContain("GIT_ASKPASS");
|
||||
expect(JSON.stringify(sourceTask)).toContain("$(params.gitops-secret-name)");
|
||||
for (const task of tasks) {
|
||||
const taskSpec = task.taskSpec as Record<string, unknown> | undefined;
|
||||
if (taskSpec === undefined) continue;
|
||||
expect(taskSpec.workspaces).toEqual([{ name: "workspace", mountPath: "/workspace" }]);
|
||||
const steps = taskSpec.steps as Array<Record<string, unknown>>;
|
||||
for (const step of steps) {
|
||||
expect((step.volumeMounts as Array<Record<string, unknown>> | undefined)?.some((mount) => mount.name === "workspace") ?? false).toBe(false);
|
||||
}
|
||||
}
|
||||
const publish = tasks.find((item) => item.name === "gitops-publish");
|
||||
expect(publish?.runAfter).toEqual(["build-api", "build-worker", "build-web"]);
|
||||
const script = ((((publish?.taskSpec as Record<string, unknown>).steps as Array<Record<string, unknown>>)[0]?.script) as string);
|
||||
for (const evidence of [
|
||||
'"status":"published"',
|
||||
'"registryVerified":true',
|
||||
'"publishedCount":3',
|
||||
'"requiredServiceCount":3',
|
||||
'"gitopsCommit"',
|
||||
'"service":"api"',
|
||||
'"service":"worker"',
|
||||
'"service":"web"',
|
||||
]) expect(script).toContain(evidence);
|
||||
const encoded = /printf '%s' '([A-Za-z0-9+/=]+)' \| base64 -d/u.exec(script)?.[1];
|
||||
if (encoded === undefined) throw new Error("embedded GitOps manifest is required");
|
||||
const manifest = Buffer.from(encoded, "base64").toString("utf8");
|
||||
expect(manifest).toContain("argocd.argoproj.io/hook: PreSync");
|
||||
expect(manifest).toContain("name: pikaoa-init");
|
||||
expect(manifest).toContain("name: initializer");
|
||||
expect(manifest).not.toContain("kind: Secret");
|
||||
expect(manifest).toContain("secretName: pikaoa-test-runtime");
|
||||
expect(manifest).toContain("image: __PIKAOA_API_IMAGE__");
|
||||
expect(manifest).toContain("pikaoa.unidesk.io/source-commit: __PIKAOA_SOURCE_COMMIT__");
|
||||
expect(script).toContain('s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g');
|
||||
expect(script).toContain("GitOps manifest contains unresolved provenance or image placeholders");
|
||||
expect(script).not.toContain("grep -Eq '$(params.revision)");
|
||||
expect(script).toContain("grep -Eq '__PIKAOA_SOURCE_COMMIT__|__PIKAOA_(API|WORKER|WEB)_IMAGE__'");
|
||||
expect(script).toContain("git ls-remote --exit-code --heads");
|
||||
expect(script).toContain("git -C /workspace/gitops checkout --orphan");
|
||||
} finally {
|
||||
rmSync(source, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
pikaoaTestRuntimeConfigRef,
|
||||
pikaoaReleaseRuntimeConfigRef,
|
||||
resolvePikaoaReleaseRuntimeTargetByConfigRef,
|
||||
resolvePikaoaTestRuntimeTargetByConfigRef,
|
||||
type TestTargetSpec,
|
||||
} from "./pikaoa-test-target";
|
||||
import { renderPikaoaTestRuntimeManifest, type PikaoaTestRenderContext } from "./pikaoa-test-runtime-manifest";
|
||||
import { stableJsonSha256 } from "./stable-json";
|
||||
|
||||
export interface PikaoaTestRendererBinding {
|
||||
readonly consumer: {
|
||||
readonly id: string;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly namespace: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly sourceArtifact: {
|
||||
readonly configRef: string;
|
||||
readonly mode: string;
|
||||
readonly renderer: string;
|
||||
};
|
||||
};
|
||||
readonly repository: {
|
||||
readonly id: string;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PikaoaTestRenderedPipeline {
|
||||
readonly pipeline: Record<string, unknown>;
|
||||
readonly configRef: string;
|
||||
readonly effectiveConfigSha256: string;
|
||||
readonly sourceWorktreeRemote: string;
|
||||
}
|
||||
|
||||
export function renderPikaoaTestDesiredPipeline(binding: PikaoaTestRendererBinding, sourceWorktree: string): PikaoaTestRenderedPipeline {
|
||||
const release = binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime";
|
||||
const target = release
|
||||
? resolvePikaoaReleaseRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef)
|
||||
: resolvePikaoaTestRuntimeTargetByConfigRef(binding.consumer.sourceArtifact.configRef);
|
||||
const configRef = release ? pikaoaReleaseRuntimeConfigRef(target.id) : pikaoaTestRuntimeConfigRef(target.id);
|
||||
if ((!release && binding.consumer.sourceArtifact.renderer !== "pikaoa-test-runtime") || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
|
||||
throw new Error(`${binding.consumer.sourceArtifact.renderer} requires embedded-pipeline-spec`);
|
||||
}
|
||||
if (binding.consumer.sourceArtifact.configRef !== configRef) {
|
||||
throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}`);
|
||||
}
|
||||
assertBinding(target, binding);
|
||||
assertSourceRepositoryContract(target, sourceWorktree);
|
||||
return {
|
||||
pipeline: pipeline(target),
|
||||
configRef,
|
||||
effectiveConfigSha256: stableJsonSha256(target),
|
||||
sourceWorktreeRemote: target.source.worktreeRemote,
|
||||
};
|
||||
}
|
||||
|
||||
export function pikaoaTestSourceWorktreeRemote(targetId: string): string {
|
||||
return resolvePikaoaTestRuntimeTargetByConfigRef(pikaoaTestRuntimeConfigRef(targetId)).source.worktreeRemote;
|
||||
}
|
||||
|
||||
export function pikaoaReleaseSourceWorktreeRemote(targetId: string): string {
|
||||
return resolvePikaoaReleaseRuntimeTargetByConfigRef(pikaoaReleaseRuntimeConfigRef(targetId)).source.worktreeRemote;
|
||||
}
|
||||
|
||||
function pipeline(target: TestTargetSpec): Record<string, unknown> {
|
||||
const runtimeManifest = renderGitOpsManifest(target);
|
||||
return {
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "Pipeline",
|
||||
metadata: { name: target.ci.pipeline, namespace: target.ci.namespace },
|
||||
spec: {
|
||||
params: [
|
||||
{ name: "revision", type: "string" },
|
||||
{ name: "source-snapshot-prefix", type: "string", default: target.source.snapshotPrefix },
|
||||
{ name: "git-read-url", type: "string", default: target.source.readUrl },
|
||||
{ name: "gitops-read-url", type: "string", default: target.gitops.readUrl },
|
||||
{ name: "gitops-write-url", type: "string", default: target.gitops.writeUrl },
|
||||
{ name: "gitops-username", type: "string", default: target.gitops.credentialUsername },
|
||||
{ name: "gitops-secret-name", type: "string", default: target.gitops.credentialSecretName },
|
||||
],
|
||||
workspaces: [{ name: "workspace" }],
|
||||
tasks: [
|
||||
sourceTask(target),
|
||||
imageBuildTask(target, "api"),
|
||||
imageBuildTask(target, "worker"),
|
||||
imageBuildTask(target, "web"),
|
||||
gitOpsTask(target, runtimeManifest),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sourceTask(target: TestTargetSpec): Record<string, unknown> {
|
||||
return {
|
||||
name: "source",
|
||||
taskSpec: {
|
||||
params: [
|
||||
{ name: "revision", type: "string" },
|
||||
{ name: "source-snapshot-prefix", type: "string" },
|
||||
{ name: "git-read-url", type: "string" },
|
||||
{ name: "gitops-username", type: "string" },
|
||||
{ name: "gitops-secret-name", type: "string" },
|
||||
],
|
||||
workspaces: [{ name: "workspace", mountPath: "/workspace" }],
|
||||
steps: [{
|
||||
name: "checkout",
|
||||
image: target.ci.toolsImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
script: `#!/bin/sh
|
||||
set -eu
|
||||
revision="$(params.revision)"
|
||||
case "$revision" in *[!0-9a-f]*|'') echo 'revision must be a full lowercase source commit' >&2; exit 2;; esac
|
||||
test "\${#revision}" -eq 40
|
||||
askpass=/tmp/pikaoa-source-git-askpass
|
||||
cat >"$askpass" <<'ASKPASS'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$PIKAOA_GIT_USERNAME" ;;
|
||||
*Password*) cat /var/run/pikaoa-source/token ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS
|
||||
chmod 700 "$askpass"
|
||||
export PIKAOA_GIT_USERNAME="$(params.gitops-username)"
|
||||
export GIT_ASKPASS="$askpass"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
rm -rf /workspace/source
|
||||
git init /workspace/source
|
||||
git -C /workspace/source remote add origin "$(params.git-read-url)"
|
||||
git -C /workspace/source fetch --depth=1 origin "$(params.source-snapshot-prefix)/$revision"
|
||||
git -C /workspace/source checkout --detach FETCH_HEAD
|
||||
test "$(git -C /workspace/source rev-parse HEAD)" = "$revision"
|
||||
rm -f "$askpass"
|
||||
printf '{"ok":true,"phase":"source","sourceCommit":"%s","valuesPrinted":false}\n' "$revision"
|
||||
`,
|
||||
securityContext: nonRootSecurity(),
|
||||
volumeMounts: [
|
||||
{ name: "source-token", mountPath: "/var/run/pikaoa-source", readOnly: true },
|
||||
],
|
||||
}],
|
||||
volumes: [{ name: "source-token", secret: { secretName: "$(params.gitops-secret-name)", items: [{ key: target.gitops.credentialTokenKey, path: "token" }] } }],
|
||||
},
|
||||
params: [
|
||||
{ name: "revision", value: "$(params.revision)" },
|
||||
{ name: "source-snapshot-prefix", value: "$(params.source-snapshot-prefix)" },
|
||||
{ name: "git-read-url", value: "$(params.git-read-url)" },
|
||||
{ name: "gitops-username", value: "$(params.gitops-username)" },
|
||||
{ name: "gitops-secret-name", value: "$(params.gitops-secret-name)" },
|
||||
],
|
||||
workspaces: [{ name: "workspace", workspace: "workspace" }],
|
||||
};
|
||||
}
|
||||
|
||||
function imageBuildTask(target: TestTargetSpec, component: "api" | "worker" | "web"): Record<string, unknown> {
|
||||
const taskName = `build-${component}`;
|
||||
const imageRepository = target.build.images[component];
|
||||
const dockerfile = target.build.dockerfiles[component];
|
||||
return {
|
||||
name: taskName,
|
||||
runAfter: ["source"],
|
||||
taskSpec: {
|
||||
params: [{ name: "revision", type: "string" }],
|
||||
workspaces: [{ name: "workspace", mountPath: "/workspace" }],
|
||||
results: [{ name: "digest", type: "string" }, { name: "image", type: "string" }],
|
||||
steps: [{
|
||||
name: "build",
|
||||
image: target.ci.buildkitImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
workingDir: "/workspace/source",
|
||||
env: [{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" }],
|
||||
script: `#!/bin/sh
|
||||
set -eu
|
||||
revision="$(params.revision)"
|
||||
tag=${shellSingleQuote(imageRepository)}:"$revision"
|
||||
metadata=/tmp/${component}-metadata.json
|
||||
set +e
|
||||
buildctl-daemonless.sh build \\
|
||||
--frontend dockerfile.v0 \\
|
||||
--local context=. \\
|
||||
--local dockerfile=. \\
|
||||
--opt filename=${shellSingleQuote(dockerfile)} \\
|
||||
--opt network=${shellSingleQuote(target.build.networkMode)} \\
|
||||
--output "type=image,name=$tag,push=true" \\
|
||||
--metadata-file "$metadata"
|
||||
build_status=$?
|
||||
set -e
|
||||
metadata_compact=$(tr -d '\\n\\r' < "$metadata")
|
||||
digest=$(printf '%s' "$metadata_compact" | sed -n 's/.*"containerimage.digest"[[:space:]]*:[[:space:]]*"\\(sha256:[0-9a-f]\\{64\\}\\)".*/\\1/p' | head -n1)
|
||||
if ! printf '%s' "$digest" | grep -Eq '^sha256:[0-9a-f]{64}$'; then
|
||||
registry_host=${shellSingleQuote(imageRepository.split("/")[0] ?? "")}
|
||||
registry_path=${shellSingleQuote(imageRepository.split("/").slice(1).join("/"))}
|
||||
headers=$(wget --server-response --spider --header='Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' "http://$registry_host/v2/$registry_path/manifests/$revision" 2>&1 || true)
|
||||
digest=$(printf '%s\\n' "$headers" | awk 'BEGIN{IGNORECASE=1} /^[[:space:]]*Docker-Content-Digest:/ {gsub(/\\r/, "", $2); print $2; exit}')
|
||||
fi
|
||||
case "$digest" in sha256:[0-9a-f][0-9a-f]*) test "\${#digest}" -eq 71 ;; *) echo 'build digest missing from metadata and registry manifest' >&2; exit 3;; esac
|
||||
if [ "$build_status" -ne 0 ]; then
|
||||
printf '{"level":"warning","code":"buildctl-nonzero-artifact-verified","component":"${component}","buildctlExitCode":%s,"artifactVerified":true,"valuesPrinted":false}\\n' "$build_status" >&2
|
||||
fi
|
||||
printf '%s' "$digest" > "$(results.digest.path)"
|
||||
printf '%s@%s' ${shellSingleQuote(imageRepository)} "$digest" > "$(results.image.path)"
|
||||
printf '{"ok":true,"phase":"${taskName}","sourceCommit":"%s","imageTag":"%s","digest":"%s","valuesPrinted":false}\n' "$revision" "$tag" "$digest"
|
||||
`,
|
||||
securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 },
|
||||
}],
|
||||
},
|
||||
params: [{ name: "revision", value: "$(params.revision)" }],
|
||||
workspaces: [{ name: "workspace", workspace: "workspace" }],
|
||||
};
|
||||
}
|
||||
|
||||
function gitOpsTask(target: TestTargetSpec, runtimeManifest: string): Record<string, unknown> {
|
||||
const manifestB64 = Buffer.from(runtimeManifest, "utf8").toString("base64");
|
||||
return {
|
||||
name: "gitops-publish",
|
||||
runAfter: ["build-api", "build-worker", "build-web"],
|
||||
taskSpec: {
|
||||
params: [
|
||||
{ name: "revision", type: "string" },
|
||||
{ name: "gitops-read-url", type: "string" },
|
||||
{ name: "gitops-write-url", type: "string" },
|
||||
{ name: "gitops-username", type: "string" },
|
||||
{ name: "gitops-secret-name", type: "string" },
|
||||
{ name: "api-image", type: "string" },
|
||||
{ name: "worker-image", type: "string" },
|
||||
{ name: "web-image", type: "string" },
|
||||
],
|
||||
workspaces: [{ name: "workspace", mountPath: "/workspace" }],
|
||||
steps: [{
|
||||
name: "publish",
|
||||
image: target.ci.toolsImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
env: [{ name: "SOURCE_COMMIT", value: "$(params.revision)" }],
|
||||
script: `#!/bin/sh
|
||||
set -eu
|
||||
askpass=/tmp/pikaoa-git-askpass
|
||||
cat >"$askpass" <<'ASKPASS'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$PIKAOA_GIT_USERNAME" ;;
|
||||
*Password*) cat /var/run/pikaoa-gitops/token ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS
|
||||
chmod 700 "$askpass"
|
||||
export PIKAOA_GIT_USERNAME="$(params.gitops-username)"
|
||||
export GIT_ASKPASS="$askpass"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
case "$SOURCE_COMMIT" in *[!0-9a-f]*|'') echo 'source commit must be a full lowercase commit' >&2; exit 2;; esac
|
||||
test "\${#SOURCE_COMMIT}" -eq 40
|
||||
api_image="$(params.api-image)"
|
||||
worker_image="$(params.worker-image)"
|
||||
web_image="$(params.web-image)"
|
||||
for image in "$api_image" "$worker_image" "$web_image"; do
|
||||
case "$image" in *@sha256:[0-9a-f]*) ;; *) echo 'required digestRef missing' >&2; exit 4;; esac
|
||||
done
|
||||
rm -rf /workspace/gitops
|
||||
mkdir -p /workspace/gitops
|
||||
git -C /workspace/gitops init
|
||||
git -C /workspace/gitops remote add origin "$(params.gitops-read-url)"
|
||||
if git ls-remote --exit-code --heads "$(params.gitops-read-url)" ${shellSingleQuote(target.gitops.branch)} >/dev/null 2>&1; then
|
||||
git -C /workspace/gitops fetch --depth=1 origin ${shellSingleQuote(target.gitops.branch)}
|
||||
git -C /workspace/gitops checkout -B ${shellSingleQuote(target.gitops.branch)} FETCH_HEAD
|
||||
else
|
||||
git -C /workspace/gitops checkout --orphan ${shellSingleQuote(target.gitops.branch)}
|
||||
git -C /workspace/gitops rm -rf . >/dev/null 2>&1 || true
|
||||
fi
|
||||
manifest=/workspace/gitops/${shellSingleQuote(target.gitops.manifestPath)}
|
||||
mkdir -p "$(dirname "$manifest")"
|
||||
printf '%s' ${shellSingleQuote(manifestB64)} | base64 -d > "$manifest"
|
||||
sed -i \\
|
||||
-e "s|__PIKAOA_SOURCE_COMMIT__|$SOURCE_COMMIT|g" \\
|
||||
-e "s|__PIKAOA_API_IMAGE__|$(params.api-image)|g" \\
|
||||
-e "s|__PIKAOA_WORKER_IMAGE__|$(params.worker-image)|g" \\
|
||||
-e "s|__PIKAOA_WEB_IMAGE__|$(params.web-image)|g" \\
|
||||
"$manifest"
|
||||
if grep -Eq '__PIKAOA_SOURCE_COMMIT__|__PIKAOA_(API|WORKER|WEB)_IMAGE__' "$manifest"; then
|
||||
echo 'GitOps manifest contains unresolved provenance or image placeholders' >&2
|
||||
exit 6
|
||||
fi
|
||||
git -C /workspace/gitops config user.name ${shellSingleQuote(target.gitops.authorName)}
|
||||
git -C /workspace/gitops config user.email ${shellSingleQuote(target.gitops.authorEmail)}
|
||||
git -C /workspace/gitops add -- ${shellSingleQuote(target.gitops.manifestPath)}
|
||||
if git -C /workspace/gitops diff --cached --quiet; then
|
||||
changed=false
|
||||
else
|
||||
git -C /workspace/gitops commit -m "chore(gitops): deploy pikaoa $SOURCE_COMMIT"
|
||||
git -C /workspace/gitops push "$(params.gitops-write-url)" HEAD:${shellSingleQuote(target.gitops.branch)}
|
||||
changed=true
|
||||
fi
|
||||
gitops_commit=$(git -C /workspace/gitops rev-parse HEAD)
|
||||
case "$gitops_commit" in [0-9a-f][0-9a-f][0-9a-f][0-9a-f]*) ;; *) echo 'gitops commit missing' >&2; exit 5;; esac
|
||||
rm -f "$askpass"
|
||||
printf '{"ok":true,"status":"published","phase":"gitops-publish","sourceCommitId":"%s","registryVerified":true,"publishedCount":3,"reusedCount":0,"requiredServiceCount":3,"services":[{"service":"api","digestRef":"%s","digest":"%s"},{"service":"worker","digestRef":"%s","digest":"%s"},{"service":"web","digestRef":"%s","digest":"%s"}],"gitopsCommit":"%s","changed":%s,"valuesPrinted":false}\n' \
|
||||
"$SOURCE_COMMIT" "$api_image" "\${api_image##*@}" "$worker_image" "\${worker_image##*@}" "$web_image" "\${web_image##*@}" "$gitops_commit" "$changed"
|
||||
`,
|
||||
securityContext: nonRootSecurity(),
|
||||
volumeMounts: [
|
||||
{ name: "gitops-token", mountPath: "/var/run/pikaoa-gitops", readOnly: true },
|
||||
],
|
||||
}],
|
||||
volumes: [{ name: "gitops-token", secret: { secretName: "$(params.gitops-secret-name)", items: [{ key: target.gitops.credentialTokenKey, path: "token" }] } }],
|
||||
},
|
||||
params: [
|
||||
{ name: "revision", value: "$(params.revision)" },
|
||||
{ name: "gitops-read-url", value: "$(params.gitops-read-url)" },
|
||||
{ name: "gitops-write-url", value: "$(params.gitops-write-url)" },
|
||||
{ name: "gitops-username", value: "$(params.gitops-username)" },
|
||||
{ name: "gitops-secret-name", value: "$(params.gitops-secret-name)" },
|
||||
{ name: "api-image", value: "$(tasks.build-api.results.image)" },
|
||||
{ name: "worker-image", value: "$(tasks.build-worker.results.image)" },
|
||||
{ name: "web-image", value: "$(tasks.build-web.results.image)" },
|
||||
],
|
||||
workspaces: [{ name: "workspace", workspace: "workspace" }],
|
||||
};
|
||||
}
|
||||
|
||||
function renderGitOpsManifest(target: TestTargetSpec): string {
|
||||
const context: PikaoaTestRenderContext = {
|
||||
instanceId: "default",
|
||||
commit: "__PIKAOA_SOURCE_COMMIT__",
|
||||
step: "all",
|
||||
namespace: target.namespace,
|
||||
expiresAt: "gitops-managed",
|
||||
apiImage: "__PIKAOA_API_IMAGE__",
|
||||
workerImage: "__PIKAOA_WORKER_IMAGE__",
|
||||
webImage: "__PIKAOA_WEB_IMAGE__",
|
||||
initializerImage: "__PIKAOA_API_IMAGE__",
|
||||
};
|
||||
const manifest = renderPikaoaTestRuntimeManifest(target, context, { secretMode: "existing", initializerHook: "argo-presync" });
|
||||
return manifest.map((object) => Bun.YAML.stringify(object, null, 2).trim()).join("\n---\n") + "\n";
|
||||
}
|
||||
|
||||
function assertBinding(target: TestTargetSpec, binding: PikaoaTestRendererBinding): void {
|
||||
const expected: Readonly<Record<string, string>> = {
|
||||
id: target.source.branch === "release" ? "pikaoa-nc01" : "pikaoa-test-nc01",
|
||||
node: target.node,
|
||||
lane: target.ci.lane,
|
||||
namespace: target.ci.namespace,
|
||||
pipeline: target.ci.pipeline,
|
||||
pipelineRunPrefix: target.ci.pipelineRunPrefix,
|
||||
sourceBranch: target.source.branch,
|
||||
gitopsBranch: target.gitops.branch,
|
||||
serviceAccount: target.ci.serviceAccountName,
|
||||
};
|
||||
const actual: Readonly<Record<string, string | undefined>> = {
|
||||
id: binding.consumer.id,
|
||||
node: binding.consumer.node,
|
||||
lane: binding.consumer.lane,
|
||||
namespace: binding.consumer.namespace,
|
||||
pipeline: binding.consumer.pipeline,
|
||||
pipelineRunPrefix: binding.consumer.pipelineRunPrefix,
|
||||
sourceBranch: binding.repository.params.source_branch,
|
||||
gitopsBranch: binding.repository.params.gitops_branch,
|
||||
serviceAccount: binding.repository.params.service_account,
|
||||
};
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (actual[key] !== value) throw new Error(`PikaOA runtime binding ${key} must equal owning YAML value ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSourceRepositoryContract(target: TestTargetSpec, sourceWorktree: string): void {
|
||||
for (const path of Object.values(target.build.dockerfiles)) {
|
||||
if (!existsSync(resolve(sourceWorktree, path))) throw new Error(`PikaOA source worktree is missing ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function nonRootSecurity(): Record<string, unknown> {
|
||||
return { allowPrivilegeEscalation: false, runAsNonRoot: true, runAsUser: 1000, runAsGroup: 1000 };
|
||||
}
|
||||
|
||||
function shellSingleQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { TestTargetSpec } from "./pikaoa-test-target";
|
||||
|
||||
export type PikaoaTestTargetStep = "all" | "foundation" | "init" | "api" | "worker" | "web";
|
||||
|
||||
export interface PikaoaTestRenderContext {
|
||||
instanceId: string;
|
||||
commit: string;
|
||||
step: PikaoaTestTargetStep;
|
||||
namespace: string;
|
||||
expiresAt: string;
|
||||
apiImage: string;
|
||||
workerImage: string;
|
||||
webImage: string;
|
||||
initializerImage: string;
|
||||
}
|
||||
|
||||
export interface PikaoaTestSecretMaterial {
|
||||
databaseURL: string;
|
||||
sessionSecret: string;
|
||||
administratorPassword: string;
|
||||
employeePassword: string;
|
||||
}
|
||||
|
||||
export interface PikaoaTestManifestOptions {
|
||||
secretMode: "materialized" | "existing";
|
||||
secrets?: PikaoaTestSecretMaterial;
|
||||
initializerHook?: "argo-presync";
|
||||
}
|
||||
|
||||
const TARGET_LABEL = "pikaoa.unidesk.io/target";
|
||||
const INSTANCE_LABEL = "pikaoa.unidesk.io/instance";
|
||||
|
||||
export function renderPikaoaTestRuntimeManifest(
|
||||
target: TestTargetSpec,
|
||||
context: PikaoaTestRenderContext,
|
||||
options: PikaoaTestManifestOptions,
|
||||
): Record<string, unknown>[] {
|
||||
if (options.secretMode === "materialized" && options.secrets === undefined) {
|
||||
throw new Error("materialized PikaOA test manifest requires secret material");
|
||||
}
|
||||
const labels = ownershipLabels(target, context);
|
||||
const apiSelector = componentSelector("api", context.instanceId);
|
||||
const workerSelector = componentSelector("worker", context.instanceId);
|
||||
const webSelector = componentSelector("web", context.instanceId);
|
||||
const initializerSelector = componentSelector("initializer", context.instanceId);
|
||||
const prometheusPodLabels = target.observability.prometheusScrape ? {
|
||||
[target.observability.prometheusSelector.key]: target.observability.prometheusSelector.value,
|
||||
} : {};
|
||||
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
|
||||
"prometheus.io/scrape": "true",
|
||||
"prometheus.io/path": path,
|
||||
"prometheus.io/port": String(port),
|
||||
} : {};
|
||||
const initializerAnnotations = options.initializerHook === "argo-presync" ? {
|
||||
"argocd.argoproj.io/hook": "PreSync",
|
||||
"argocd.argoproj.io/hook-delete-policy": "BeforeHookCreation,HookSucceeded",
|
||||
} : {};
|
||||
return [
|
||||
{
|
||||
apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) },
|
||||
},
|
||||
...(options.secretMode === "materialized" ? [{
|
||||
apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque",
|
||||
stringData: runtimeSecretData(target, context, options.secrets!),
|
||||
}] : []),
|
||||
{
|
||||
apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels },
|
||||
spec: {
|
||||
accessModes: target.runtime.attachment.accessModes,
|
||||
resources: { requests: { storage: target.runtime.attachment.storageRequest } },
|
||||
...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }),
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: "v1", kind: "Service", metadata: { name: target.runtime.apiServiceName, 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.initializer.jobName, namespace: context.namespace, labels: { ...labels, ...initializerSelector }, annotations: initializerAnnotations },
|
||||
spec: {
|
||||
backoffLimit: target.initializer.backoffLimit,
|
||||
template: {
|
||||
metadata: { labels: { ...labels, ...initializerSelector } },
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
containers: [{
|
||||
name: "initializer", image: context.initializerImage, imagePullPolicy: target.source.pullPolicy,
|
||||
command: target.initializer.command,
|
||||
resources: target.runtime.resources.initializer,
|
||||
env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }],
|
||||
volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }],
|
||||
}],
|
||||
volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, prometheusPodLabels, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [
|
||||
{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" },
|
||||
{ name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } },
|
||||
{ name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username },
|
||||
{ name: "PIKAOA_BOOTSTRAP_ADMIN_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.administrator.password.targetKey } } },
|
||||
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username },
|
||||
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } },
|
||||
], target.probes.apiLivenessPath, target.probes.apiReadinessPath),
|
||||
deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, prometheusPodLabels, 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, {}, {}, [
|
||||
{ name: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}.${context.namespace}.svc.cluster.local:${target.runtime.apiPort}` },
|
||||
], target.probes.webLivenessPath, target.probes.webReadinessPath),
|
||||
];
|
||||
}
|
||||
|
||||
export function pikaoaManifestForStep(manifest: Record<string, unknown>[], step: PikaoaTestTargetStep): 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 === "init") return object.kind === "Job";
|
||||
const metadata = optionalRecord(object.metadata);
|
||||
const labels = optionalRecord(metadata?.labels);
|
||||
return labels?.["app.kubernetes.io/component"] === step;
|
||||
});
|
||||
}
|
||||
|
||||
export function ownershipLabels(target: TestTargetSpec, context: PikaoaTestRenderContext): Record<string, string> {
|
||||
const release = target.source.branch === "release";
|
||||
return {
|
||||
"app.kubernetes.io/name": "pikaoa",
|
||||
"app.kubernetes.io/part-of": release ? "pikaoa-release-runtime" : "pikaoa-test-runtime",
|
||||
"app.kubernetes.io/managed-by": target.fieldManager,
|
||||
[release ? "pikaoa.unidesk.io/release-runtime" : "pikaoa.unidesk.io/test-runtime"]: "true",
|
||||
[TARGET_LABEL]: target.id,
|
||||
[INSTANCE_LABEL]: context.instanceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function componentSelector(component: string, instanceId: string): Record<string, string> {
|
||||
return { "app.kubernetes.io/name": "pikaoa", "app.kubernetes.io/component": component, [INSTANCE_LABEL]: instanceId };
|
||||
}
|
||||
|
||||
export function namespaceAnnotations(target: TestTargetSpec, context: PikaoaTestRenderContext): Record<string, string> {
|
||||
return {
|
||||
"pikaoa.unidesk.io/source-repository": target.source.repository,
|
||||
"pikaoa.unidesk.io/source-commit": context.commit,
|
||||
"pikaoa.unidesk.io/expires-at": context.expiresAt,
|
||||
"pikaoa.unidesk.io/ttl-seconds": String(target.ttlSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderDatabaseURL(target: TestTargetSpec, sourceURL: string): string {
|
||||
const databaseURL = new URL(sourceURL);
|
||||
databaseURL.searchParams.set("search_path", target.database.schema);
|
||||
return databaseURL.toString();
|
||||
}
|
||||
|
||||
export function placeholderPikaoaSecrets(): PikaoaTestSecretMaterial {
|
||||
return {
|
||||
databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require",
|
||||
sessionSecret: "<redacted>",
|
||||
administratorPassword: "<redacted>",
|
||||
employeePassword: "<redacted>",
|
||||
};
|
||||
}
|
||||
|
||||
function deployment(
|
||||
target: TestTargetSpec,
|
||||
context: PikaoaTestRenderContext,
|
||||
component: "api" | "worker" | "web",
|
||||
image: string,
|
||||
selector: Record<string, string>,
|
||||
port: number,
|
||||
podLabels: Record<string, string>,
|
||||
annotations: Record<string, string>,
|
||||
env: Array<Record<string, unknown>>,
|
||||
livenessPath: string,
|
||||
readinessPath: string,
|
||||
): Record<string, unknown> {
|
||||
const labels = ownershipLabels(target, context);
|
||||
return {
|
||||
apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } },
|
||||
spec: {
|
||||
replicas: target.runtime.replicas[component], selector: { matchLabels: selector },
|
||||
template: {
|
||||
metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations },
|
||||
spec: {
|
||||
...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}),
|
||||
containers: [{
|
||||
name: component, image, imagePullPolicy: target.source.pullPolicy, env, resources: target.runtime.resources[component],
|
||||
ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }],
|
||||
volumeMounts: [
|
||||
...(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: [
|
||||
...(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 } }] : []),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSecretData(target: TestTargetSpec, context: PikaoaTestRenderContext, secrets: PikaoaTestSecretMaterial): Record<string, string> {
|
||||
const databaseURL = renderDatabaseURL(target, secrets.databaseURL);
|
||||
return {
|
||||
[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,
|
||||
"pikaoa.yaml": [
|
||||
"api:",
|
||||
` listen: 0.0.0.0:${target.runtime.apiPort}`,
|
||||
"worker:",
|
||||
` interval: ${target.runtime.workerInterval}`,
|
||||
` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`,
|
||||
" outbox:",
|
||||
` batchSize: ${target.runtime.outboxBatchSize}`,
|
||||
` maxAttempts: ${target.runtime.outboxMaxAttempts}`,
|
||||
` retryDelay: ${target.runtime.outboxRetryDelay}`,
|
||||
"database:",
|
||||
` url: ${JSON.stringify(databaseURL)}`,
|
||||
"identity:",
|
||||
` sessionTTL: ${target.runtime.sessionTTL}`,
|
||||
"attachment:",
|
||||
` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`,
|
||||
` maxBytes: ${target.runtime.attachment.maxBytes}`,
|
||||
"observability:",
|
||||
` serviceName: ${target.source.branch === "release" ? "pikaoa" : `pikaoa-test-${context.instanceId}`}`,
|
||||
` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`,
|
||||
`shutdownTimeout: ${target.runtime.shutdownTimeout}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function optionalRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
||||
}
|
||||
@@ -74,11 +74,11 @@ if printf '%s' "$args" | grep -q ' rollout status '; then
|
||||
[ ! -f "$root/slow" ] || sleep 0.4
|
||||
exit 0
|
||||
fi
|
||||
if printf '%s' "$args" | grep -q ' get job/pikaoa-migrate '; then
|
||||
if [ -f "$root/fail-migration-query" ]; then printf '%s\n' 'job query failed' >&2; exit 1; fi
|
||||
if [ -f "$root/failure-target-migration" ]; then
|
||||
if printf '%s' "$args" | grep -q ' get job/pikaoa-init '; then
|
||||
if [ -f "$root/fail-initializer-query" ]; then printf '%s\n' 'job query failed' >&2; exit 1; fi
|
||||
if [ -f "$root/failure-target-initializer" ]; then
|
||||
printf '%s\n' '{"status":{"conditions":[{"type":"FailureTarget","status":"True","reason":"BackoffLimitExceeded"}]}}'
|
||||
elif [ -f "$root/fail-migration" ]; then
|
||||
elif [ -f "$root/fail-initializer" ]; then
|
||||
printf '%s\n' '{"status":{"conditions":[{"type":"Failed","status":"True","reason":"BackoffLimitExceeded"}]}}'
|
||||
else
|
||||
printf '%s\n' '{"status":{"conditions":[{"type":"Complete","status":"True"}]}}'
|
||||
@@ -273,33 +273,33 @@ exit 64
|
||||
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");
|
||||
const failed = await waitFor("runtime", "start-failed", "migration");
|
||||
writeFileSync(join(fakeRoot, "fail-initializer"), "1\n");
|
||||
await projection("start", "runtime", true, "init");
|
||||
const failed = await waitFor("runtime", "start-failed", "init");
|
||||
const failedTask = ((failed.status as Record<string, unknown>).task as Record<string, unknown>);
|
||||
assert.equal(failedTask.code, "migration-job-failed");
|
||||
assert.equal(failedTask.code, "initializer-job-failed");
|
||||
assert.equal(failedTask.exitCode, 46);
|
||||
rmSync(join(fakeRoot, "fail-migration"));
|
||||
rmSync(join(fakeRoot, "fail-initializer"));
|
||||
|
||||
writeFileSync(join(fakeRoot, "failure-target-migration"), "1\n");
|
||||
await projectionWithCommit("start", "runtime", concurrentCommit, "migration");
|
||||
const failureTarget = await waitFor("runtime", "start-failed", "migration");
|
||||
writeFileSync(join(fakeRoot, "failure-target-initializer"), "1\n");
|
||||
await projectionWithCommit("start", "runtime", concurrentCommit, "init");
|
||||
const failureTarget = await waitFor("runtime", "start-failed", "init");
|
||||
const failureTargetTask = ((failureTarget.status as Record<string, unknown>).task as Record<string, unknown>);
|
||||
assert.equal(failureTargetTask.code, "migration-job-failure-target");
|
||||
assert.equal(failureTargetTask.code, "initializer-job-failure-target");
|
||||
assert.equal(failureTargetTask.exitCode, 46);
|
||||
rmSync(join(fakeRoot, "failure-target-migration"));
|
||||
rmSync(join(fakeRoot, "failure-target-initializer"));
|
||||
|
||||
writeFileSync(join(fakeRoot, "fail-migration-query"), "1\n");
|
||||
await projectionWithCommit("start", "runtime", "abcdef0123456789", "migration");
|
||||
const migrationQueryFailed = await waitFor("runtime", "start-failed", "migration");
|
||||
const migrationQueryFailedTask = ((migrationQueryFailed.status as Record<string, unknown>).task as Record<string, unknown>);
|
||||
assert.equal(migrationQueryFailedTask.code, "migration-job-query-failed");
|
||||
assert.equal(migrationQueryFailedTask.exitCode, 46);
|
||||
rmSync(join(fakeRoot, "fail-migration-query"));
|
||||
writeFileSync(join(fakeRoot, "fail-initializer-query"), "1\n");
|
||||
await projectionWithCommit("start", "runtime", "abcdef0123456789", "init");
|
||||
const initializerQueryFailed = await waitFor("runtime", "start-failed", "init");
|
||||
const initializerQueryFailedTask = ((initializerQueryFailed.status as Record<string, unknown>).task as Record<string, unknown>);
|
||||
assert.equal(initializerQueryFailedTask.code, "initializer-job-query-failed");
|
||||
assert.equal(initializerQueryFailedTask.exitCode, 46);
|
||||
rmSync(join(fakeRoot, "fail-initializer-query"));
|
||||
|
||||
await projectionWithCommit("start", "runtime", "1234567890abcdef", "migration");
|
||||
const migrationSucceeded = await waitFor("runtime", "start-succeeded", "migration");
|
||||
assert.equal((((migrationSucceeded.status as Record<string, unknown>).task as Record<string, unknown>).exitCode), 0);
|
||||
await projectionWithCommit("start", "runtime", "1234567890abcdef", "init");
|
||||
const initializerSucceeded = await waitFor("runtime", "start-succeeded", "init");
|
||||
assert.equal((((initializerSucceeded.status as Record<string, unknown>).task as Record<string, unknown>).exitCode), 0);
|
||||
|
||||
writeFileSync(join(fakeRoot, "slow"), "1\n");
|
||||
await projection("start", "runtime", true, "web");
|
||||
@@ -344,8 +344,8 @@ exit 64
|
||||
workerMissingSucceeded,
|
||||
failed,
|
||||
failureTarget,
|
||||
migrationQueryFailed,
|
||||
migrationSucceeded,
|
||||
initializerQueryFailed,
|
||||
initializerSucceeded,
|
||||
stopped,
|
||||
foundationSubmitted,
|
||||
foundationSucceeded,
|
||||
@@ -397,7 +397,7 @@ test("validation-only fixture never invokes remote capture", async () => {
|
||||
secretName: "pikaoa-test-runtime",
|
||||
key: "pikaoa.yaml",
|
||||
mountPath: "/etc/pikaoa/pikaoa.yaml",
|
||||
consumers: ["migration", "api", "worker"],
|
||||
consumers: ["initializer", "api", "worker"],
|
||||
valuesPrinted: false,
|
||||
});
|
||||
assert.equal(planPayload.valuesPrinted, false);
|
||||
|
||||
+199
-241
@@ -10,9 +10,18 @@ import type { RenderedCliResult } from "./output";
|
||||
import { CliInputError } from "./output";
|
||||
import { capture, compactCapture, parseJsonOutput, readYamlRecord, sha256Fingerprint, shQuote } from "./platform-infra-ops-library";
|
||||
import { resolveConfigRef } from "./ops/config-refs";
|
||||
import {
|
||||
pikaoaManifestForStep,
|
||||
placeholderPikaoaSecrets,
|
||||
renderDatabaseURL,
|
||||
renderPikaoaTestRuntimeManifest,
|
||||
type PikaoaTestRenderContext,
|
||||
type PikaoaTestSecretMaterial,
|
||||
type PikaoaTestTargetStep,
|
||||
} from "./pikaoa-test-runtime-manifest";
|
||||
|
||||
type TestTargetAction = "plan" | "status" | "start" | "stop";
|
||||
type TestTargetStep = "all" | "foundation" | "migration" | "api" | "worker" | "web";
|
||||
type TestTargetStep = PikaoaTestTargetStep;
|
||||
|
||||
interface TestTargetOptions {
|
||||
action: TestTargetAction;
|
||||
@@ -41,7 +50,7 @@ interface PrometheusSelectorSpec {
|
||||
valuesPrinted: false;
|
||||
}
|
||||
|
||||
interface TestTargetSpec {
|
||||
export interface TestTargetSpec {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
validationOnly: boolean;
|
||||
@@ -64,12 +73,43 @@ interface TestTargetSpec {
|
||||
mode: string;
|
||||
repository: string;
|
||||
commitPolicy: string;
|
||||
branch: string;
|
||||
worktreeRemote: string;
|
||||
readUrl: string;
|
||||
snapshotPrefix: string;
|
||||
apiImage: string;
|
||||
workerImage: string;
|
||||
webImage: string;
|
||||
migrationImage: string;
|
||||
initializerImage: string;
|
||||
pullPolicy: string;
|
||||
};
|
||||
ci: {
|
||||
lane: string;
|
||||
namespace: string;
|
||||
pipeline: string;
|
||||
pipelineRunPrefix: string;
|
||||
serviceAccountName: string;
|
||||
workspaceSize: string;
|
||||
pipelineTimeout: string;
|
||||
toolsImage: string;
|
||||
buildkitImage: string;
|
||||
};
|
||||
build: {
|
||||
dockerfiles: { api: string; worker: string; web: string };
|
||||
images: { api: string; worker: string; web: string };
|
||||
networkMode: string;
|
||||
};
|
||||
gitops: {
|
||||
readUrl: string;
|
||||
writeUrl: string;
|
||||
branch: string;
|
||||
manifestPath: string;
|
||||
credentialSecretName: string;
|
||||
credentialTokenKey: string;
|
||||
credentialUsername: string;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
};
|
||||
database: {
|
||||
configRef: string;
|
||||
name: string;
|
||||
@@ -78,6 +118,8 @@ interface TestTargetSpec {
|
||||
connection: SecretSourceSpec;
|
||||
};
|
||||
runtime: {
|
||||
replicas: { api: number; worker: number; web: number };
|
||||
resources: Record<"api" | "worker" | "web" | "initializer", Record<string, unknown>>;
|
||||
secretName: string;
|
||||
apiServiceName: string;
|
||||
apiPort: number;
|
||||
@@ -103,7 +145,8 @@ interface TestTargetSpec {
|
||||
employee: { username: string; password: SecretSourceSpec };
|
||||
sessionSecret: SecretSourceSpec;
|
||||
};
|
||||
migration: {
|
||||
initializer: {
|
||||
mode: "fresh-database-only";
|
||||
jobName: string;
|
||||
backoffLimit: number;
|
||||
command: string[];
|
||||
@@ -155,24 +198,8 @@ interface Blocker {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
instanceId: string;
|
||||
commit: string;
|
||||
step: TestTargetStep;
|
||||
namespace: string;
|
||||
expiresAt: string;
|
||||
apiImage: string;
|
||||
workerImage: string;
|
||||
webImage: string;
|
||||
migrationImage: string;
|
||||
}
|
||||
|
||||
interface SecretMaterial {
|
||||
databaseURL: string;
|
||||
sessionSecret: string;
|
||||
administratorPassword: string;
|
||||
employeePassword: string;
|
||||
}
|
||||
type RenderContext = PikaoaTestRenderContext;
|
||||
type SecretMaterial = PikaoaTestSecretMaterial;
|
||||
|
||||
type RemoteCapture = typeof capture;
|
||||
|
||||
@@ -194,14 +221,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 foundation|migration|api|worker|web|all] --confirm",
|
||||
"bun scripts/cli.ts pikaoa test-target start --target id --instance id [--commit sha] [--step foundation|init|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 可按 foundation、migration、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。",
|
||||
"start 可按 foundation、init、api、worker、web 或 all 单步提交并立即返回;foundation 只创建固定 Namespace/PVC,不要求业务 Secret 已存在。",
|
||||
"正式 delivery namespace、PaC、GitOps、公网暴露和生产数据库不属于该入口的写入范围。",
|
||||
"Secret 只披露 sourceRef、targetKey、presence 和 fingerprint,不输出值。",
|
||||
],
|
||||
@@ -240,7 +267,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", "foundation", "migration", "api", "worker", "web"]);
|
||||
else if (arg === "--step") step = enumString(value, arg, ["all", "foundation", "init", "api", "worker", "web"]);
|
||||
else {
|
||||
if (value !== "json" && value !== "text") throw inputError(`${arg} 只支持 text 或 json`, "invalid-output", value, ["text", "json"]);
|
||||
output = value;
|
||||
@@ -293,13 +320,22 @@ function readSelection(options: TestTargetOptions): Selection {
|
||||
};
|
||||
}
|
||||
|
||||
function parseTarget(id: string, root: Record<string, unknown>, configLabel: string): TestTargetSpec {
|
||||
const path = `${configLabel}.testRuntime.targets.${id}`;
|
||||
function parseTarget(id: string, root: Record<string, unknown>, configLabel: string, runtimeSection = "testRuntime"): TestTargetSpec {
|
||||
const path = `${configLabel}.${runtimeSection}.targets.${id}`;
|
||||
simpleId(id, "target id");
|
||||
const source = record(root.source, `${path}.source`);
|
||||
const images = record(source.images, `${path}.source.images`);
|
||||
const ci = record(root.ci, `${path}.ci`);
|
||||
const build = record(root.build, `${path}.build`);
|
||||
const dockerfiles = record(build.dockerfiles, `${path}.build.dockerfiles`);
|
||||
const buildImages = record(build.images, `${path}.build.images`);
|
||||
const gitops = record(root.gitops, `${path}.gitops`);
|
||||
const gitopsCredential = record(gitops.credential, `${path}.gitops.credential`);
|
||||
const gitopsAuthor = record(gitops.author, `${path}.gitops.author`);
|
||||
const database = record(root.database, `${path}.database`);
|
||||
const runtime = record(root.runtime, `${path}.runtime`);
|
||||
const replicas = record(runtime.replicas, `${path}.runtime.replicas`);
|
||||
const resources = record(runtime.resources, `${path}.runtime.resources`);
|
||||
const attachment = record(runtime.attachment, `${path}.runtime.attachment`);
|
||||
const outbox = record(runtime.outbox, `${path}.runtime.outbox`);
|
||||
const administrator = record(runtime.administrator, `${path}.runtime.administrator`);
|
||||
@@ -313,7 +349,7 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
||||
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 initializer = record(root.initializer, `${path}.initializer`);
|
||||
const taskState = record(root.taskState, `${path}.taskState`);
|
||||
if (exporterFailure.blocking !== false) throw new Error(`${path}.observability.exporterFailure.blocking 必须为 false`);
|
||||
if (exporterFailure.warning !== true) throw new Error(`${path}.observability.exporterFailure.warning 必须为 true`);
|
||||
@@ -341,12 +377,51 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
||||
mode: exactString(source.mode, `${path}.source.mode`, "prebuilt-images"),
|
||||
repository: nonEmpty(source.repository, `${path}.source.repository`),
|
||||
commitPolicy: exactString(source.commitPolicy, `${path}.source.commitPolicy`, "cli-required"),
|
||||
branch: nonEmpty(source.branch, `${path}.source.branch`),
|
||||
worktreeRemote: nonEmpty(source.worktreeRemote, `${path}.source.worktreeRemote`),
|
||||
readUrl: nonEmpty(source.readUrl, `${path}.source.readUrl`),
|
||||
snapshotPrefix: nonEmpty(source.snapshotPrefix, `${path}.source.snapshotPrefix`),
|
||||
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`),
|
||||
initializerImage: imageReference(images.initializer, `${path}.source.images.initializer`),
|
||||
pullPolicy: enumString(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]),
|
||||
},
|
||||
ci: {
|
||||
lane: simpleId(nonEmpty(ci.lane, `${path}.ci.lane`), `${path}.ci.lane`),
|
||||
namespace: kubernetesName(nonEmpty(ci.namespace, `${path}.ci.namespace`), `${path}.ci.namespace`, 63),
|
||||
pipeline: kubernetesName(nonEmpty(ci.pipeline, `${path}.ci.pipeline`), `${path}.ci.pipeline`, 63),
|
||||
pipelineRunPrefix: kubernetesName(nonEmpty(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`), `${path}.ci.pipelineRunPrefix`, 63),
|
||||
serviceAccountName: kubernetesName(nonEmpty(ci.serviceAccountName, `${path}.ci.serviceAccountName`), `${path}.ci.serviceAccountName`, 63),
|
||||
workspaceSize: nonEmpty(ci.workspaceSize, `${path}.ci.workspaceSize`),
|
||||
pipelineTimeout: nonEmpty(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`),
|
||||
toolsImage: imageReference(ci.toolsImage, `${path}.ci.toolsImage`),
|
||||
buildkitImage: imageReference(ci.buildkitImage, `${path}.ci.buildkitImage`),
|
||||
},
|
||||
build: {
|
||||
dockerfiles: {
|
||||
api: relativeSourcePath(dockerfiles.api, `${path}.build.dockerfiles.api`),
|
||||
worker: relativeSourcePath(dockerfiles.worker, `${path}.build.dockerfiles.worker`),
|
||||
web: relativeSourcePath(dockerfiles.web, `${path}.build.dockerfiles.web`),
|
||||
},
|
||||
images: {
|
||||
api: imageRepository(buildImages.api, `${path}.build.images.api`),
|
||||
worker: imageRepository(buildImages.worker, `${path}.build.images.worker`),
|
||||
web: imageRepository(buildImages.web, `${path}.build.images.web`),
|
||||
},
|
||||
networkMode: exactString(build.networkMode, `${path}.build.networkMode`, "host"),
|
||||
},
|
||||
gitops: {
|
||||
readUrl: nonEmpty(gitops.readUrl, `${path}.gitops.readUrl`),
|
||||
writeUrl: nonEmpty(gitops.writeUrl, `${path}.gitops.writeUrl`),
|
||||
branch: nonEmpty(gitops.branch, `${path}.gitops.branch`),
|
||||
manifestPath: relativeSourcePath(gitops.manifestPath, `${path}.gitops.manifestPath`),
|
||||
credentialSecretName: kubernetesName(nonEmpty(gitopsCredential.secretName, `${path}.gitops.credential.secretName`), `${path}.gitops.credential.secretName`, 63),
|
||||
credentialTokenKey: nonEmpty(gitopsCredential.tokenKey, `${path}.gitops.credential.tokenKey`),
|
||||
credentialUsername: nonEmpty(gitopsCredential.username, `${path}.gitops.credential.username`),
|
||||
authorName: nonEmpty(gitopsAuthor.name, `${path}.gitops.author.name`),
|
||||
authorEmail: nonEmpty(gitopsAuthor.email, `${path}.gitops.author.email`),
|
||||
},
|
||||
database: {
|
||||
configRef: nonEmpty(database.configRef, `${path}.database.configRef`),
|
||||
name: postgresIdentifier(database.name, `${path}.database.name`),
|
||||
@@ -355,6 +430,17 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
||||
connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true),
|
||||
},
|
||||
runtime: {
|
||||
replicas: {
|
||||
api: positiveInteger(replicas.api, `${path}.runtime.replicas.api`, 20),
|
||||
worker: positiveInteger(replicas.worker, `${path}.runtime.replicas.worker`, 20),
|
||||
web: positiveInteger(replicas.web, `${path}.runtime.replicas.web`, 20),
|
||||
},
|
||||
resources: {
|
||||
api: record(resources.api, `${path}.runtime.resources.api`),
|
||||
worker: record(resources.worker, `${path}.runtime.resources.worker`),
|
||||
web: record(resources.web, `${path}.runtime.resources.web`),
|
||||
initializer: record(resources.initializer, `${path}.runtime.resources.initializer`),
|
||||
},
|
||||
secretName: kubernetesName(nonEmpty(runtime.secretName, `${path}.runtime.secretName`), `${path}.runtime.secretName`, 63),
|
||||
apiServiceName: kubernetesName(nonEmpty(runtime.apiServiceName, `${path}.runtime.apiServiceName`), `${path}.runtime.apiServiceName`, 63),
|
||||
apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535),
|
||||
@@ -386,10 +472,11 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
||||
},
|
||||
sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`),
|
||||
},
|
||||
migration: {
|
||||
jobName: kubernetesName(nonEmpty(migration.jobName, `${path}.migration.jobName`), `${path}.migration.jobName`, 63),
|
||||
backoffLimit: nonNegativeInteger(migration.backoffLimit, `${path}.migration.backoffLimit`),
|
||||
command: nonEmptyStringList(migration.command, `${path}.migration.command`),
|
||||
initializer: {
|
||||
mode: exactString(initializer.mode, `${path}.initializer.mode`, "fresh-database-only") as "fresh-database-only",
|
||||
jobName: kubernetesName(nonEmpty(initializer.jobName, `${path}.initializer.jobName`), `${path}.initializer.jobName`, 63),
|
||||
backoffLimit: nonNegativeInteger(initializer.backoffLimit, `${path}.initializer.backoffLimit`),
|
||||
command: nonEmptyStringList(initializer.command, `${path}.initializer.command`),
|
||||
},
|
||||
exposure: {
|
||||
serviceName: kubernetesName(nonEmpty(exposure.serviceName, `${path}.exposure.serviceName`), `${path}.exposure.serviceName`, 63),
|
||||
@@ -422,6 +509,34 @@ function parseTarget(id: string, root: Record<string, unknown>, configLabel: str
|
||||
return target;
|
||||
}
|
||||
|
||||
export function pikaoaTestRuntimeConfigRef(targetId: string): string {
|
||||
simpleId(targetId, "PikaOA test runtime target id");
|
||||
return `config/pikaoa.yaml#testRuntime.targets.${targetId}`;
|
||||
}
|
||||
|
||||
export function resolvePikaoaTestRuntimeTargetByConfigRef(configRef: string): TestTargetSpec {
|
||||
const resolved = resolveConfigRef(configRef, "PikaOA test runtime configRef");
|
||||
const match = /^testRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment);
|
||||
if (resolved.file !== "config/pikaoa.yaml" || match === null) {
|
||||
throw new Error("PikaOA test runtime configRef must select config/pikaoa.yaml#testRuntime.targets.<id>");
|
||||
}
|
||||
return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "testRuntime");
|
||||
}
|
||||
|
||||
export function pikaoaReleaseRuntimeConfigRef(targetId: string): string {
|
||||
simpleId(targetId, "PikaOA release runtime target id");
|
||||
return `config/pikaoa.yaml#releaseRuntime.targets.${targetId}`;
|
||||
}
|
||||
|
||||
export function resolvePikaoaReleaseRuntimeTargetByConfigRef(configRef: string): TestTargetSpec {
|
||||
const resolved = resolveConfigRef(configRef, "PikaOA release runtime configRef");
|
||||
const match = /^releaseRuntime\.targets\.([A-Za-z0-9._-]+)$/u.exec(resolved.fragment);
|
||||
if (resolved.file !== "config/pikaoa.yaml" || match === null) {
|
||||
throw new Error("PikaOA release runtime configRef must select config/pikaoa.yaml#releaseRuntime.targets.<id>");
|
||||
}
|
||||
return parseTarget(match[1]!, record(resolved.value, configRef), resolved.file, "releaseRuntime");
|
||||
}
|
||||
|
||||
function planPayload(options: TestTargetOptions, selection: Selection): Record<string, unknown> {
|
||||
const base = basePayload(options, selection);
|
||||
if (selection.target === null) return base;
|
||||
@@ -486,7 +601,10 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
|
||||
worker: componentSelector("worker", context.instanceId),
|
||||
web: componentSelector("web", context.instanceId),
|
||||
};
|
||||
const structuralManifest = manifestForStep(buildManifest(target, context, placeholderSecrets()), context.step);
|
||||
const structuralManifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(target, context, {
|
||||
secretMode: "materialized",
|
||||
secrets: placeholderPikaoaSecrets(),
|
||||
}), context.step);
|
||||
return {
|
||||
namespace: {
|
||||
name: context.namespace,
|
||||
@@ -508,7 +626,7 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
|
||||
secretName: target.runtime.secretName,
|
||||
key: "pikaoa.yaml",
|
||||
mountPath: "/etc/pikaoa/pikaoa.yaml",
|
||||
consumers: ["migration", "api", "worker"],
|
||||
consumers: ["initializer", "api", "worker"],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
probes: {
|
||||
@@ -531,10 +649,11 @@ function renderedPlan(selection: Selection, context: RenderContext): Record<stri
|
||||
fsGroup: target.runtime.attachment.fsGroup,
|
||||
accessModes: target.runtime.attachment.accessModes,
|
||||
},
|
||||
migration: {
|
||||
jobName: target.migration.jobName,
|
||||
image: context.migrationImage,
|
||||
command: target.migration.command,
|
||||
initializer: {
|
||||
mode: target.initializer.mode,
|
||||
jobName: target.initializer.jobName,
|
||||
image: context.initializerImage,
|
||||
command: target.initializer.command,
|
||||
applyPhase: "before-workloads",
|
||||
},
|
||||
step: context.step,
|
||||
@@ -582,14 +701,17 @@ async function startResult(config: UniDeskConfig, options: TestTargetOptions, se
|
||||
}
|
||||
const material = options.step === "foundation" ? {
|
||||
ok: true,
|
||||
values: placeholderSecrets(),
|
||||
values: placeholderPikaoaSecrets(),
|
||||
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 = manifestForStep(buildManifest(selection.target, context, material.values), options.step);
|
||||
const manifest = pikaoaManifestForStep(renderPikaoaTestRuntimeManifest(selection.target, context, {
|
||||
secretMode: "materialized",
|
||||
secrets: 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;
|
||||
@@ -647,7 +769,7 @@ function renderContext(options: TestTargetOptions, target: TestTargetSpec, requi
|
||||
apiImage: renderImage(target.source.apiImage, commit),
|
||||
workerImage: renderImage(target.source.workerImage, commit),
|
||||
webImage: renderImage(target.source.webImage, commit),
|
||||
migrationImage: renderImage(target.source.migrationImage, commit),
|
||||
initializerImage: renderImage(target.source.initializerImage, commit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -674,9 +796,9 @@ 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, 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.initializer.command.includes("migrate") || !target.initializer.command.includes("up")) warnings.push(warning("initializer-command-unrecognized", "空库 initializer 的当前产品命令未显示 migrate up;配置一致性只报告 warning。"));
|
||||
if (![target.source.apiImage, target.source.workerImage, target.source.webImage, target.source.initializerImage].every((image) => image.includes("${commit}"))) warnings.push(warning("image-template-not-commit-scoped", "API、Worker、Web 或 initializer 镜像模板未包含 ${commit};版本一致性只报告 warning。"));
|
||||
if (context !== null && ![context.apiImage, context.workerImage, context.webImage, context.initializerImage].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;
|
||||
@@ -717,178 +839,6 @@ function prometheusRuntimeStatus(items: Record<string, unknown>[], selector: Pro
|
||||
};
|
||||
}
|
||||
|
||||
function buildManifest(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, unknown>[] {
|
||||
const labels = ownershipLabels(target, context);
|
||||
const secretData = runtimeSecretData(target, context, secrets);
|
||||
const apiSelector = componentSelector("api", context.instanceId);
|
||||
const workerSelector = componentSelector("worker", context.instanceId);
|
||||
const webSelector = componentSelector("web", context.instanceId);
|
||||
const migrationSelector = componentSelector("migration", context.instanceId);
|
||||
const prometheusPodLabels = target.observability.prometheusScrape ? {
|
||||
[target.observability.prometheusSelector.key]: target.observability.prometheusSelector.value,
|
||||
} : {};
|
||||
const commonPodAnnotations = (path: string, port: number): Record<string, string> => target.observability.prometheusScrape ? {
|
||||
"prometheus.io/scrape": "true",
|
||||
"prometheus.io/path": path,
|
||||
"prometheus.io/port": String(port),
|
||||
} : {};
|
||||
return [
|
||||
{
|
||||
apiVersion: "v1", kind: "Namespace", metadata: { name: context.namespace, labels, annotations: namespaceAnnotations(target, context) },
|
||||
},
|
||||
{
|
||||
apiVersion: "v1", kind: "Secret", metadata: { name: target.runtime.secretName, namespace: context.namespace, labels }, type: "Opaque", stringData: secretData,
|
||||
},
|
||||
{
|
||||
apiVersion: "v1", kind: "PersistentVolumeClaim", metadata: { name: target.runtime.attachment.claimName, namespace: context.namespace, labels },
|
||||
spec: {
|
||||
accessModes: target.runtime.attachment.accessModes,
|
||||
resources: { requests: { storage: target.runtime.attachment.storageRequest } },
|
||||
...(target.runtime.attachment.storageClassName === null ? {} : { storageClassName: target.runtime.attachment.storageClassName }),
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: "v1", kind: "Service", metadata: { name: target.runtime.apiServiceName, 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: {
|
||||
backoffLimit: target.migration.backoffLimit,
|
||||
template: {
|
||||
metadata: { labels: { ...labels, ...migrationSelector } },
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
containers: [{
|
||||
name: "migration", image: context.migrationImage, imagePullPolicy: target.source.pullPolicy,
|
||||
command: target.migration.command,
|
||||
env: [{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" }],
|
||||
volumeMounts: [{ name: "runtime", mountPath: "/etc/pikaoa", readOnly: true }],
|
||||
}],
|
||||
volumes: [{ name: "runtime", secret: { secretName: target.runtime.secretName, items: [{ key: "pikaoa.yaml", path: "pikaoa.yaml" }] } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment(target, context, "api", context.apiImage, apiSelector, target.runtime.apiPort, prometheusPodLabels, commonPodAnnotations(target.observability.apiMetricsPath, target.runtime.apiPort), [
|
||||
{ name: "PIKAOA_CONFIG", value: "/etc/pikaoa/pikaoa.yaml" },
|
||||
{ name: "PIKAOA_SESSION_SECRET", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.sessionSecret.targetKey } } },
|
||||
{ name: "PIKAOA_BOOTSTRAP_ADMIN_USERNAME", value: target.runtime.administrator.username },
|
||||
{ name: "PIKAOA_BOOTSTRAP_ADMIN_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.administrator.password.targetKey } } },
|
||||
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_USERNAME", value: target.runtime.employee.username },
|
||||
{ name: "PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD", valueFrom: { secretKeyRef: { name: target.runtime.secretName, key: target.runtime.employee.password.targetKey } } },
|
||||
], target.probes.apiLivenessPath, target.probes.apiReadinessPath),
|
||||
deployment(target, context, "worker", context.workerImage, workerSelector, target.runtime.workerMetricsPort, prometheusPodLabels, 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, {}, {}, [
|
||||
{ name: target.runtime.webApiUpstreamEnv, value: `http://${target.runtime.apiServiceName}:${target.runtime.apiPort}` },
|
||||
], target.probes.webLivenessPath, target.probes.webReadinessPath),
|
||||
];
|
||||
}
|
||||
|
||||
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,
|
||||
component: "api" | "worker" | "web",
|
||||
image: string,
|
||||
selector: Record<string, string>,
|
||||
port: number,
|
||||
podLabels: Record<string, string>,
|
||||
annotations: Record<string, string>,
|
||||
env: Array<Record<string, unknown>>,
|
||||
livenessPath: string,
|
||||
readinessPath: string,
|
||||
): Record<string, unknown> {
|
||||
const labels = ownershipLabels(target, context);
|
||||
return {
|
||||
apiVersion: "apps/v1", kind: "Deployment", metadata: { name: `pikaoa-${component}`, namespace: context.namespace, labels: { ...labels, ...selector } },
|
||||
spec: {
|
||||
replicas: 1, selector: { matchLabels: selector },
|
||||
template: {
|
||||
metadata: { labels: { ...labels, ...selector, ...podLabels }, annotations },
|
||||
spec: {
|
||||
...(component === "api" ? { securityContext: { fsGroup: target.runtime.attachment.fsGroup } } : {}),
|
||||
containers: [{
|
||||
name: component, image, imagePullPolicy: target.source.pullPolicy, env,
|
||||
ports: [{ name: component === "worker" ? "metrics" : "http", containerPort: port }],
|
||||
volumeMounts: [
|
||||
...(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: [
|
||||
...(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 } }] : []),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSecretData(target: TestTargetSpec, context: RenderContext, secrets: SecretMaterial): Record<string, string> {
|
||||
const databaseURL = renderDatabaseURL(target, secrets.databaseURL);
|
||||
return {
|
||||
[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,
|
||||
"pikaoa.yaml": [
|
||||
"api:",
|
||||
` listen: 0.0.0.0:${target.runtime.apiPort}`,
|
||||
"worker:",
|
||||
` interval: ${target.runtime.workerInterval}`,
|
||||
` metricsListen: 0.0.0.0:${target.runtime.workerMetricsPort}`,
|
||||
" outbox:",
|
||||
` batchSize: ${target.runtime.outboxBatchSize}`,
|
||||
` maxAttempts: ${target.runtime.outboxMaxAttempts}`,
|
||||
` retryDelay: ${target.runtime.outboxRetryDelay}`,
|
||||
"database:",
|
||||
` url: ${JSON.stringify(databaseURL)}`,
|
||||
"identity:",
|
||||
` sessionTTL: ${target.runtime.sessionTTL}`,
|
||||
"attachment:",
|
||||
` storageRoot: ${JSON.stringify(target.runtime.attachment.storageRoot)}`,
|
||||
` maxBytes: ${target.runtime.attachment.maxBytes}`,
|
||||
"observability:",
|
||||
` serviceName: pikaoa-test-${context.instanceId}`,
|
||||
` otlpEndpoint: ${JSON.stringify(target.observability.otlpEndpoint)}`,
|
||||
`shutdownTimeout: ${target.runtime.shutdownTimeout}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
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, "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require"));
|
||||
return {
|
||||
@@ -1134,7 +1084,7 @@ printf '{"ok":true,"mutation":true,"code":"start-submitted","submitted":true,"pr
|
||||
}
|
||||
|
||||
function startRunnerScript(target: TestTargetSpec, context: RenderContext, manifest: Record<string, unknown>[], job: AsyncJobIdentity): string {
|
||||
const migration = manifest.filter((object) => object.kind === "Job");
|
||||
const initializer = manifest.filter((object) => object.kind === "Job");
|
||||
const workload = (name: string): Record<string, unknown>[] => manifest.filter((object) => {
|
||||
if (object.kind !== "Deployment" && object.kind !== "Service") return false;
|
||||
const metadata = optionalRecord(object.metadata, "metadata") ?? {};
|
||||
@@ -1146,7 +1096,7 @@ function startRunnerScript(target: TestTargetSpec, context: RenderContext, manif
|
||||
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 initializerEncoded = encode(initializer);
|
||||
const apiEncoded = encode(workload("api"));
|
||||
const workerEncoded = encode(workload("worker"));
|
||||
const webEncoded = encode(workload("web"));
|
||||
@@ -1228,7 +1178,7 @@ if kubectl get namespace "$namespace" --request-timeout=${target.taskState.statu
|
||||
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(initializerEncoded)} | base64 -d >"$tmp/initializer.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"
|
||||
@@ -1249,22 +1199,22 @@ if ! kubectl apply --server-side --force-conflicts --field-manager=${shQuote(tar
|
||||
failure_code=runtime-secret-apply-failed
|
||||
exit 44
|
||||
fi
|
||||
if [ "$step" = all ] || [ "$step" = migration ]; then
|
||||
current_phase=migration-apply
|
||||
if [ "$step" = all ] || [ "$step" = init ]; then
|
||||
current_phase=initializer-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
|
||||
kubectl -n "$namespace" delete job/${target.initializer.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/initializer.yaml" >"$tmp/initializer-apply.out" 2>"$tmp/initializer-apply.err"; then failure_code=initializer-apply-failed; exit 45; fi
|
||||
current_phase=initializer-wait
|
||||
write_status running "$current_phase" start-running null
|
||||
migration_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} ))
|
||||
initializer_deadline=$(( $(date +%s) + ${target.waitTimeoutSeconds} ))
|
||||
while :; do
|
||||
check_canceled
|
||||
if ! kubectl -n "$namespace" get job/${target.migration.jobName} --request-timeout=${Math.min(target.taskState.statusRequestTimeoutSeconds, 5)}s -o json >"$tmp/migration-job.json" 2>"$tmp/migration-wait.err"; then
|
||||
failure_code=migration-job-query-failed
|
||||
if ! kubectl -n "$namespace" get job/${target.initializer.jobName} --request-timeout=${Math.min(target.taskState.statusRequestTimeoutSeconds, 5)}s -o json >"$tmp/initializer-job.json" 2>"$tmp/initializer-wait.err"; then
|
||||
failure_code=initializer-job-query-failed
|
||||
exit 46
|
||||
fi
|
||||
if ! migration_condition="$(python3 - "$tmp/migration-job.json" <<'PY'
|
||||
if ! initializer_condition="$(python3 - "$tmp/initializer-job.json" <<'PY'
|
||||
import json, pathlib, sys
|
||||
payload = json.loads(pathlib.Path(sys.argv[1]).read_text())
|
||||
conditions = {
|
||||
@@ -1282,16 +1232,16 @@ else:
|
||||
print("running")
|
||||
PY
|
||||
)"; then
|
||||
failure_code=migration-job-condition-invalid
|
||||
failure_code=initializer-job-condition-invalid
|
||||
exit 46
|
||||
fi
|
||||
case "$migration_condition" in
|
||||
case "$initializer_condition" in
|
||||
complete) break ;;
|
||||
failure-target) failure_code=migration-job-failure-target; exit 46 ;;
|
||||
failed) failure_code=migration-job-failed; exit 46 ;;
|
||||
failure-target) failure_code=initializer-job-failure-target; exit 46 ;;
|
||||
failed) failure_code=initializer-job-failed; exit 46 ;;
|
||||
esac
|
||||
if [ "$(date +%s)" -ge "$migration_deadline" ]; then
|
||||
failure_code=migration-job-timeout
|
||||
if [ "$(date +%s)" -ge "$initializer_deadline" ]; then
|
||||
failure_code=initializer-job-timeout
|
||||
exit 46
|
||||
fi
|
||||
sleep 1
|
||||
@@ -1567,8 +1517,8 @@ function sourceSummary(target: TestTargetSpec, commit: string | null, context?:
|
||||
commitPolicy: target.source.commitPolicy,
|
||||
commit,
|
||||
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 },
|
||||
? { apiTemplate: target.source.apiImage, workerTemplate: target.source.workerImage, webTemplate: target.source.webImage, initializerTemplate: target.source.initializerImage }
|
||||
: { api: context.apiImage, worker: context.workerImage, web: context.webImage, initializer: context.initializerImage },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1662,10 +1612,6 @@ function manifestFingerprint(manifest: Record<string, unknown>[], stableRequest:
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(normalized)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function placeholderSecrets(): SecretMaterial {
|
||||
return { databaseURL: "postgresql://redacted@db.invalid/pikaoa_test?sslmode=require", sessionSecret: "<redacted>", administratorPassword: "<redacted>", employeePassword: "<redacted>" };
|
||||
}
|
||||
|
||||
function secretSources(target: TestTargetSpec): SecretSourceSpec[] {
|
||||
return [target.database.connection, target.runtime.sessionSecret, target.runtime.administrator.password, target.runtime.employee.password];
|
||||
}
|
||||
@@ -1760,6 +1706,18 @@ function imageReference(value: unknown, path: string): string {
|
||||
return image;
|
||||
}
|
||||
|
||||
function imageRepository(value: unknown, path: string): string {
|
||||
const image = nonEmpty(value, path);
|
||||
if (/\s/u.test(image) || image.includes("@") || /:[^/]+$/u.test(image)) throw new Error(`${path} 必须是不带 tag/digest 的镜像仓库`);
|
||||
return image;
|
||||
}
|
||||
|
||||
function relativeSourcePath(value: unknown, path: string): string {
|
||||
const sourcePath = nonEmpty(value, path);
|
||||
if (sourcePath.startsWith("/") || sourcePath.split(/[\\/]/u).includes("..")) throw new Error(`${path} 必须是无 .. 的仓库相对路径`);
|
||||
return sourcePath;
|
||||
}
|
||||
|
||||
function otlpGrpcEndpoint(value: unknown, path: string): string {
|
||||
const endpoint = nonEmpty(value, path);
|
||||
if (endpoint.includes("://") || !/^[A-Za-z0-9.-]+:[1-9][0-9]{0,4}$/u.test(endpoint)) throw new Error(`${path} 必须是无 scheme 的 OTLP gRPC host:port`);
|
||||
|
||||
@@ -47,6 +47,22 @@ describe("platform-infra Gitea repository GitHub credentials", () => {
|
||||
.not.toBe(githubTokenEnvNameForSecretKey(globalCredential.gitFetchCredential.secretRef.key));
|
||||
});
|
||||
|
||||
test("keeps PikaOA master and release authorities on distinct Secret keys", () => {
|
||||
const config = readGiteaConfig();
|
||||
const testAuthority = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-test-nc01");
|
||||
const productionAuthority = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-nc01");
|
||||
const testKey = testAuthority?.credentialOverride?.github.gitFetchCredential.secretRef.key;
|
||||
const productionKey = productionAuthority?.credentialOverride?.github.gitFetchCredential.secretRef.key;
|
||||
|
||||
expect(testAuthority?.upstream.branch).toBe("master");
|
||||
expect(productionAuthority?.upstream.branch).toBe("release");
|
||||
expect(testKey).toBe("github-token-pikainc-pikaoa");
|
||||
expect(productionKey).toBe("github-token-pikainc-pikaoa-release");
|
||||
expect(productionKey).not.toBe(testKey);
|
||||
expect(githubTokenEnvNameForSecretKey(productionKey ?? ""))
|
||||
.not.toBe(githubTokenEnvNameForSecretKey(testKey ?? ""));
|
||||
});
|
||||
|
||||
test("normalizes Secret keys to bounded environment variable names", () => {
|
||||
expect(githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"))
|
||||
.toBe("UNIDESK_GITEA_GITHUB_TOKEN_GITHUB_TOKEN_PIKAINC_SELFMEDIA");
|
||||
@@ -87,6 +103,25 @@ describe("platform-infra Gitea repository GitHub credentials", () => {
|
||||
expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.github.sourceRef);
|
||||
});
|
||||
|
||||
test("production PikaOA status scope never reads the unselected master credential", () => {
|
||||
const config = readGiteaConfig();
|
||||
const production = config.sourceAuthority.repositories.find((repo) => repo.key === "pikaoa-nc01");
|
||||
expect(production).toBeDefined();
|
||||
const requested: string[] = [];
|
||||
const result = buildGiteaMirrorBootstrapCredentialPreflight(config, [production!], (credential) => {
|
||||
requested.push(credential.gitFetchCredential.secretRef.key);
|
||||
return { [credential.sourceKey]: "fixture-release-token" };
|
||||
});
|
||||
|
||||
expect(result.blockers).toEqual([]);
|
||||
expect(requested).toEqual(["github-token-pikainc-pikaoa-release"]);
|
||||
expect(result.credentials).toHaveLength(1);
|
||||
expect(result.credentials[0]).toMatchObject({
|
||||
id: "github-upstream:pikaoa-nc01",
|
||||
valuesPrinted: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => {
|
||||
const config = readGiteaConfig();
|
||||
const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia");
|
||||
|
||||
@@ -1074,7 +1074,9 @@ rows = []
|
||||
for repo in repos:
|
||||
repository = repo["upstream"]["repository"]
|
||||
topology_spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {})
|
||||
token = os.environ[topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]]
|
||||
credential_spec = repo.get("githubCredential") or {}
|
||||
token_env = credential_spec.get("tokenEnv") or topology_spec.get("githubTokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]
|
||||
token = os.environ[token_env]
|
||||
branch = repo["upstream"]["branch"]
|
||||
result = request(f"/repos/{repository}/hooks")
|
||||
hooks_result = parse_github_hooks_response(result.get("ok"), result.get("status"), result.get("body"))
|
||||
|
||||
@@ -1182,6 +1182,9 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string {
|
||||
function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record<string, unknown> } {
|
||||
const frpcExposure = targetFrpcExposure(gitea, target);
|
||||
const sync = targetWebhookSync(gitea, target);
|
||||
const selectedRepositories = params.repos ?? repositoriesForTarget(gitea, target);
|
||||
const selectedCredentials = githubCredentialsForConsumers(gitea, target, selectedRepositories, false);
|
||||
const defaultCredential = selectedCredentials[0] ?? gitea.sourceAuthority.credentials.github;
|
||||
const env: Record<string, string> = {
|
||||
UNIDESK_GITEA_ACTION: action,
|
||||
UNIDESK_GITEA_TARGET_ID: target.id,
|
||||
@@ -1201,9 +1204,9 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-prefligh
|
||||
UNIDESK_GITEA_ROOT_URL: gitea.app.server.rootUrl,
|
||||
UNIDESK_GITEA_GITHUB_PROXY_ENABLED: gitea.sourceAuthority.githubProxy.enabled ? "1" : "0",
|
||||
UNIDESK_GITEA_GITHUB_PROXY_URL: gitea.sourceAuthority.githubProxy.url,
|
||||
UNIDESK_GITEA_GITHUB_SECRET_KEY: gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key,
|
||||
UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key),
|
||||
UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), true)
|
||||
UNIDESK_GITEA_GITHUB_SECRET_KEY: defaultCredential.gitFetchCredential.secretRef.key,
|
||||
UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV: githubTokenEnvName(defaultCredential.gitFetchCredential.secretRef.key),
|
||||
UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP: selectedCredentials
|
||||
.map((credential) => `${credential.gitFetchCredential.secretRef.key}=${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}`)
|
||||
.join(","),
|
||||
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
|
||||
@@ -1702,10 +1705,7 @@ function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<str
|
||||
|
||||
function credentialSummaries(gitea: GiteaConfig, repositories = gitea.sourceAuthority.repositories): Array<Record<string, unknown>> {
|
||||
const adminPath = credentialPath(gitea, gitea.sourceAuthority.credentials.admin.sourceRef);
|
||||
const github = gitea.sourceAuthority.credentials.github;
|
||||
const githubPath = credentialPath(gitea, github.sourceRef);
|
||||
const adminLinePair = existsSync(adminPath) ? parseLinePairCredential(adminPath, gitea.sourceAuthority.credentials.admin) : { username: null, password: null };
|
||||
const githubEnv = existsSync(githubPath) ? parseGithubCredentialFile(githubPath, github) : {};
|
||||
const summaries: Array<Record<string, unknown>> = [
|
||||
{
|
||||
id: "gitea-admin",
|
||||
@@ -1716,36 +1716,8 @@ function credentialSummaries(gitea: GiteaConfig, repositories = gitea.sourceAuth
|
||||
permissionResult: { status: "not-applicable", error: null },
|
||||
valuesPrinted: false,
|
||||
},
|
||||
{
|
||||
id: "github-upstream",
|
||||
sourceRef: github.sourceRef,
|
||||
present: existsSync(githubPath),
|
||||
requiredKeysPresent: Boolean(githubEnv[github.sourceKey]),
|
||||
fingerprint: fingerprintKeys(githubEnv, [github.sourceKey]),
|
||||
permissionResult: existsSync(githubPath) && Boolean(githubEnv[github.sourceKey])
|
||||
? { status: "not-probed", error: null }
|
||||
: { status: "blocked-missing-credential", error: "credential material is absent" },
|
||||
valuesPrinted: false,
|
||||
},
|
||||
];
|
||||
for (const repo of repositories) {
|
||||
const githubOverride = repo.credentialOverride?.github;
|
||||
if (githubOverride === undefined) continue;
|
||||
const sourcePath = credentialPath(gitea, githubOverride.sourceRef);
|
||||
const values = existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, githubOverride) : {};
|
||||
summaries.push({
|
||||
id: `github-upstream:${repo.key}`,
|
||||
repository: repo.upstream.repository,
|
||||
sourceRef: githubOverride.sourceRef,
|
||||
present: existsSync(sourcePath),
|
||||
requiredKeysPresent: Boolean(values[githubOverride.sourceKey]),
|
||||
fingerprint: fingerprintKeys(values, [githubOverride.sourceKey]),
|
||||
permissionResult: existsSync(sourcePath) && Boolean(values[githubOverride.sourceKey])
|
||||
? { status: "not-probed", permissions: githubOverride.permissions, error: null }
|
||||
: { status: "blocked-missing-credential", permissions: githubOverride.permissions, error: "credential material is absent" },
|
||||
valuesPrinted: false,
|
||||
});
|
||||
}
|
||||
summaries.push(...buildGiteaMirrorBootstrapCredentialPreflight(gitea, repositories).credentials);
|
||||
return summaries;
|
||||
}
|
||||
|
||||
@@ -1803,7 +1775,7 @@ function githubCredentialsForConsumers(
|
||||
repositories: GiteaMirrorRepository[],
|
||||
includeHookOwnerCredentials: boolean,
|
||||
): GiteaGithubCredential[] {
|
||||
const credentials = [gitea.sourceAuthority.credentials.github, ...repositories.flatMap((repo) => repo.credentialOverride?.github ?? [])];
|
||||
const credentials = repositories.map((repo) => githubCredentialForRepo(gitea, repo));
|
||||
if (includeHookOwnerCredentials
|
||||
&& gitea.sourceAuthority.webhookSync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase()) {
|
||||
credentials.push(...gitea.sourceAuthority.repositories.flatMap((repo) => {
|
||||
|
||||
@@ -38,6 +38,11 @@ test("admission contract rejects malformed required declarations instead of sile
|
||||
const duplicatedRequiredConsumers = duplicatedServiceAccount.consumers.filter((item: any) => item.deliveryProvenance !== undefined);
|
||||
duplicatedRequiredConsumers[1].deliveryProvenance.executionServiceAccountName = duplicatedRequiredConsumers[0].deliveryProvenance.executionServiceAccountName;
|
||||
expect(() => parsePacAdmissionContract(duplicatedServiceAccount)).toThrow("executionServiceAccountName must be unique per target");
|
||||
|
||||
const denyingAdmission = structuredClone(source);
|
||||
denyingAdmission.deliveryProvenance.admission.failurePolicy = "Fail";
|
||||
denyingAdmission.deliveryProvenance.admission.validationActions = ["Deny"];
|
||||
expect(() => parsePacAdmissionContract(denyingAdmission)).toThrow("validationActions must be exactly [Warn, Audit]");
|
||||
});
|
||||
|
||||
test("admission queue transition contract rejects absent sources, unknown Tekton states, and duplicate transitions", () => {
|
||||
@@ -55,7 +60,7 @@ test("admission queue transition contract rejects absent sources, unknown Tekton
|
||||
expect(() => parsePacAdmissionContract(duplicate)).toThrow("deliveryProvenance.queueTransition.allowed must be unique");
|
||||
});
|
||||
|
||||
test("versioned admission desired state protects controller proof, candidate identity, params, and execution SA", () => {
|
||||
test("versioned admission desired state reports controller proof and candidate drift without denying delivery", () => {
|
||||
const contract = readPacAdmissionContract();
|
||||
const owningYaml = Bun.YAML.parse(readFileSync(resolve(root, "config/platform-infra/pipelines-as-code.yaml"), "utf8")) as any;
|
||||
expect(owningYaml.deliveryProvenance.terminalRoles).toBeUndefined();
|
||||
@@ -69,15 +74,15 @@ test("versioned admission desired state protects controller proof, candidate ide
|
||||
toState: "started",
|
||||
}]);
|
||||
expect(contract.child.enabled).toBe(false);
|
||||
expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01"]);
|
||||
expect(contract.consumers.map((item) => item.id)).toEqual(["agentrun-nc01-v02", "platform-infra-sub2rank-nc01", "selfmedia-nc01", "selfmedia-production-nc01", "pikaoa-test-nc01"]);
|
||||
const items = documents(renderPacAdmissionDesiredFragment("NC01"));
|
||||
expect(items.map((item) => item.kind)).toEqual(["ValidatingAdmissionPolicy", "ValidatingAdmissionPolicyBinding"]);
|
||||
const policy = items[0];
|
||||
const expressions = policy.spec.validations.map((item: any) => item.expression).join("\n");
|
||||
const messages = policy.spec.validations.map((item: any) => item.message).join("\n");
|
||||
expect(policy.spec.failurePolicy).toBe("Fail");
|
||||
expect(policy.spec.failurePolicy).toBe("Ignore");
|
||||
expect(policy.spec.matchConstraints.matchPolicy).toBe("Equivalent");
|
||||
expect(items[1].spec.validationActions).toEqual(["Deny"]);
|
||||
expect(items[1].spec.validationActions).toEqual(["Warn", "Audit"]);
|
||||
expect(policy.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("0");
|
||||
expect(items[1].metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("1");
|
||||
expect(expressions).toContain("request.userInfo.username");
|
||||
@@ -134,8 +139,8 @@ test("versioned admission desired state protects controller proof, candidate ide
|
||||
|
||||
test("required consumer default RBAC has one automatic desired owner per namespace and no Tekton mutation verbs", () => {
|
||||
const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01"));
|
||||
expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]);
|
||||
expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci"]);
|
||||
expect(rbac.map((item) => item.kind)).toEqual(["Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding", "Role", "RoleBinding"]);
|
||||
expect(rbac.filter((item) => item.kind === "Role").map((item) => item.metadata.namespace)).toEqual(["agentrun-ci", "devops-infra", "selfmedia-ci", "pikaoa-ci"]);
|
||||
for (const role of rbac.filter((item) => item.kind === "Role")) {
|
||||
expect(role.metadata.annotations["argocd.argoproj.io/sync-wave"]).toBe("-1");
|
||||
expect(role.metadata.annotations["unidesk.ai/effective-config-sha256"]).toBe(pacConsumerRbacDesiredIdentity("NC01").configSha256);
|
||||
@@ -150,6 +155,8 @@ test("required consumer default RBAC has one automatic desired owner per namespa
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"Role",
|
||||
"RoleBinding",
|
||||
"ValidatingAdmissionPolicy",
|
||||
"ValidatingAdmissionPolicyBinding",
|
||||
"ServiceAccount",
|
||||
@@ -158,7 +165,7 @@ test("required consumer default RBAC has one automatic desired owner per namespa
|
||||
]);
|
||||
});
|
||||
|
||||
test("live admission evaluator requires exact desired hashes, observed generation, default-SA loss, and a new resource epoch", () => {
|
||||
test("live admission evaluator reports exact desired drift as non-blocking warnings", () => {
|
||||
const contract = readPacAdmissionContract();
|
||||
const admission = documents(renderPacAdmissionDesiredFragment("NC01"));
|
||||
const rbac = documents(renderPacConsumerRbacDesiredFragment("NC01"));
|
||||
@@ -174,7 +181,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio
|
||||
rbacConfigSha256: rbac[0].metadata.annotations["unidesk.ai/effective-config-sha256"],
|
||||
};
|
||||
const input = { policy, binding, role: rbac[0], roleBinding: rbac[1], namespace: "agentrun-ci", defaultCanMutate: false, expected };
|
||||
expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false });
|
||||
expect(evaluator.evaluatePacAdmissionState(input)).toMatchObject({ ready: true, blocking: false, warning: false, activeAfter: "2026-01-01T00:00:01Z", defaultCanMutate: false });
|
||||
expect(evaluator.evaluatePacAdmissionState({
|
||||
...input,
|
||||
policy: {
|
||||
@@ -189,7 +196,7 @@ test("live admission evaluator requires exact desired hashes, observed generatio
|
||||
},
|
||||
},
|
||||
},
|
||||
})).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-expression-warning"]) });
|
||||
})).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["policy-expression-warning"]) });
|
||||
const apiDefaultedPolicy = {
|
||||
...policy,
|
||||
spec: {
|
||||
@@ -206,10 +213,10 @@ test("live admission evaluator requires exact desired hashes, observed generatio
|
||||
ready: true,
|
||||
liveSpecSha256: expected.configSha256,
|
||||
});
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: true })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-serviceaccount-can-mutate-tekton-runs"]) });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: null })).toMatchObject({ ready: false, defaultCanMutate: null, reasons: expect.arrayContaining(["default-serviceaccount-mutation-probe-unavailable"]) });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, role: { ...rbac[0], rules: [rbac[0].rules[0]] } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["default-role-rules-mismatch"]) });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, expected: { ...expected, configSha256: `sha256:${"0".repeat(64)}` } })).toMatchObject({ ready: false, reasons: expect.arrayContaining(["policy-config-sha256-mismatch"]) });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: true })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["default-serviceaccount-can-mutate-tekton-runs"]) });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, defaultCanMutate: null })).toMatchObject({ ready: false, blocking: false, warning: true, defaultCanMutate: null, reasons: expect.arrayContaining(["default-serviceaccount-mutation-probe-unavailable"]) });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, role: { ...rbac[0], rules: [rbac[0].rules[0]] } })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["default-role-rules-mismatch"]) });
|
||||
expect(evaluator.evaluatePacAdmissionState({ ...input, expected: { ...expected, configSha256: `sha256:${"0".repeat(64)}` } })).toMatchObject({ ready: false, blocking: false, warning: true, reasons: expect.arrayContaining(["policy-config-sha256-mismatch"]) });
|
||||
const mutatedPolicy = {
|
||||
...policy,
|
||||
spec: {
|
||||
@@ -345,10 +352,10 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu
|
||||
})).toMatchObject({ ok: true, code: "pac-ready-gitops-exact" });
|
||||
});
|
||||
|
||||
test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => {
|
||||
test("provenance fixtures keep outer PaC delivery eligible while reporting admission warnings", () => {
|
||||
const result = evaluator.runPacStatusFixtureChecks();
|
||||
expect(result.ok).toBe(true);
|
||||
for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "equal-revisions-ignore-owning-git-fetch-failure", "different-revisions-fetch-failure-remains-unknown", "admission-provenance-verified", "pre-bootstrap-run-fails-closed", "forged-name-candidate-fails-closed", "forged-label-candidate-fails-closed", "forged-pipeline-ref-candidate-fails-closed", "wrong-service-account-fails-closed", "policy-identity-mismatch-fails-closed"]) {
|
||||
for (const id of ["gitops-only-registry-not-configured", "image-registry-missing-fails-closed", "equal-revisions-ignore-owning-git-fetch-failure", "different-revisions-fetch-failure-remains-unknown", "admission-provenance-verified", "pre-bootstrap-run-warning-only", "marker-mismatch-warning-only", "candidate-label-warning-only", "candidate-pipeline-ref-warning-only", "service-account-mismatch-warning-only", "policy-identity-mismatch-warning-only"]) {
|
||||
expect(result.checks.find((item) => item.id === id)?.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,8 +33,8 @@ export interface PacAdmissionContract {
|
||||
readonly allowedQueueTransitions: readonly PacAdmissionQueueTransitionContract[];
|
||||
readonly policyName: string;
|
||||
readonly bindingName: string;
|
||||
readonly failurePolicy: "Fail";
|
||||
readonly validationActions: readonly ["Deny"];
|
||||
readonly failurePolicy: "Ignore";
|
||||
readonly validationActions: readonly ["Warn", "Audit"];
|
||||
readonly child: {
|
||||
readonly enabled: false;
|
||||
readonly parentNameAnnotation: string;
|
||||
@@ -83,7 +83,9 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): PacA
|
||||
const transitionKeys = new Set(allowedQueueTransitions.map((transition) => [transition.fromSpecStatus, transition.toSpecStatus, transition.fromState, transition.toState].join("\u0000")));
|
||||
if (transitionKeys.size !== allowedQueueTransitions.length) throw new Error("deliveryProvenance.queueTransition.allowed must be unique");
|
||||
const validationActions = stringArray(admission.validationActions, "deliveryProvenance.admission.validationActions");
|
||||
if (validationActions.length !== 1 || validationActions[0] !== "Deny") throw new Error("deliveryProvenance.admission.validationActions must be exactly [Deny]");
|
||||
if (validationActions.length !== 2 || validationActions[0] !== "Warn" || validationActions[1] !== "Audit") {
|
||||
throw new Error("deliveryProvenance.admission.validationActions must be exactly [Warn, Audit]");
|
||||
}
|
||||
const consumers = recordArray(root.consumers, "consumers").flatMap((consumer, index) => {
|
||||
if (consumer.deliveryProvenance === undefined) return [];
|
||||
const value = record(consumer.deliveryProvenance, `consumers[${index}].deliveryProvenance`);
|
||||
@@ -127,8 +129,8 @@ export function parsePacAdmissionContract(source: Record<string, unknown>): PacA
|
||||
allowedQueueTransitions,
|
||||
policyName,
|
||||
bindingName,
|
||||
failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Fail"),
|
||||
validationActions: ["Deny"],
|
||||
failurePolicy: literal(admission.failurePolicy, "deliveryProvenance.admission.failurePolicy", "Ignore"),
|
||||
validationActions: ["Warn", "Audit"],
|
||||
child: {
|
||||
enabled: literal(child.enabled, "deliveryProvenance.child.enabled", false),
|
||||
parentNameAnnotation: required(child.parentNameAnnotation, "deliveryProvenance.child.parentNameAnnotation"),
|
||||
|
||||
@@ -120,19 +120,19 @@ test("unselected parse and repository errors remain warnings while selected obje
|
||||
expect(() => parsePacConfigDocument(invalidConsumerDocument, { consumerId: "selfmedia-production-nc01" })).toThrow("namespace");
|
||||
|
||||
const invalidRepositoryDocument = pacConfigDocument();
|
||||
const invalidRepository = (invalidRepositoryDocument.repositories as Array<Record<string, unknown>>).find((item) => item.id === "pikaoa-nc01");
|
||||
const invalidRepository = (invalidRepositoryDocument.repositories as Array<Record<string, unknown>>).find((item) => item.id === "pikaoa-test-nc01");
|
||||
if (invalidRepository === undefined) throw new Error("pikaoa repository fixture is required");
|
||||
invalidRepository.cloneUrl = "https://gitea.example.invalid/mirrors/pikaoa.git";
|
||||
const isolated = parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "selfmedia-nc01" });
|
||||
expect(isolated.validationWarnings).toContainEqual({
|
||||
code: "pac-unselected-repository-invalid",
|
||||
object: { kind: "repository", id: "pikaoa-nc01" },
|
||||
object: { kind: "repository", id: "pikaoa-test-nc01" },
|
||||
consumer: null,
|
||||
configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-nc01",
|
||||
configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-test-nc01",
|
||||
blocking: false,
|
||||
evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u),
|
||||
});
|
||||
expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-nc01")).toBe(false);
|
||||
expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-test-nc01")).toBe(false);
|
||||
expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl");
|
||||
|
||||
const duplicateSelectedRepository = pacConfigDocument();
|
||||
@@ -166,11 +166,23 @@ test("PaC apply checks the Gitea repository before dry-run return and cluster mu
|
||||
const preflight = remote.indexOf("if ! repository_preflight", action);
|
||||
const dryRun = remote.indexOf('if [ "$UNIDESK_PAC_DRY_RUN" = "1" ]', action);
|
||||
const release = remote.indexOf(" apply_release", action);
|
||||
const admission = remote.indexOf(" apply_admission_manifest", release);
|
||||
const rbac = remote.indexOf(" rbac_tmp=$(mktemp)", admission);
|
||||
expect(action).toBeGreaterThanOrEqual(0);
|
||||
expect(preflight).toBeGreaterThan(action);
|
||||
expect(dryRun).toBeGreaterThan(preflight);
|
||||
expect(release).toBeGreaterThan(dryRun);
|
||||
expect(admission).toBeGreaterThan(release);
|
||||
expect(rbac).toBeGreaterThan(admission);
|
||||
expect(remote).toContain('"admission":{"applied":%s,"ready":%s}');
|
||||
expect(remote).toContain('"admissionProvenance":%s');
|
||||
expect(remote).toContain('"error":"gitea-repository-missing"');
|
||||
const statusAction = remote.slice(remote.indexOf("status_action() {"), remote.indexOf("history_action() {"));
|
||||
expect(statusAction).toContain('[ "$bootstrap_ready" = "true" ]');
|
||||
expect(statusAction).toContain("output.code = 'pac-consumer-bootstrap-not-ready'");
|
||||
expect(statusAction).toContain("output.ok = false");
|
||||
expect(statusAction).toContain("code: 'pac-admission-provenance-not-ready'");
|
||||
expect(statusAction).toContain("blocking: false");
|
||||
});
|
||||
|
||||
test("platform bootstrap help advertises the composite entry before low-level apply", async () => {
|
||||
@@ -197,13 +209,42 @@ test("PaC bootstrap projects typed stages and compact JSON", () => {
|
||||
const projection = projectPacBootstrapResult(result);
|
||||
expect(projection.ok).toBe(true);
|
||||
expect(JSON.stringify(projection)).not.toContain("pipelinesAsCodeApply");
|
||||
expect((projection.stages as Record<string, unknown>[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "tekton-consumer", "gitops-argo"]);
|
||||
expect((projection.stages as Record<string, unknown>[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "pac-admission", "tekton-consumer", "gitops-argo"]);
|
||||
const rendered = renderPacBootstrap(result).renderedText;
|
||||
expect(rendered).toContain("PRECONDITIONS");
|
||||
expect(rendered).toContain("yaml-repository-exact-match");
|
||||
expect(rendered).toContain("confirm:");
|
||||
});
|
||||
|
||||
test("PaC bootstrap keeps admission drift as a non-blocking warning", () => {
|
||||
const result = {
|
||||
ok: true,
|
||||
action: "platform-infra-pipelines-as-code-bootstrap",
|
||||
mutation: true,
|
||||
mode: "confirmed",
|
||||
target: { id: "NC01" },
|
||||
consumer: { id: "widgets", node: "NC01", lane: "v01" },
|
||||
repository: { id: "widgets", namespace: "ci" },
|
||||
mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name },
|
||||
githubPreflight: { ok: true, mutation: false, repositories: [{ key: mirror.key, code: "github-upstream-available", ok: true }] },
|
||||
giteaBootstrap: { ok: true, mutation: false, mode: "skipped", blockers: [] },
|
||||
pipelinesAsCodeApply: {
|
||||
ok: true,
|
||||
mutation: true,
|
||||
mode: "confirmed",
|
||||
remote: {
|
||||
ok: true,
|
||||
admission: { applied: true, ready: false },
|
||||
admissionProvenance: { policyName: "unidesk-pac-delivery-provenance-v2", reasons: ["policy-generation-not-observed"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const projection = projectPacBootstrapResult(result);
|
||||
expect(projection.ok).toBe(true);
|
||||
expect((projection.stages as Record<string, unknown>[]).find((item) => item.id === "pac-admission")).toMatchObject({ ok: true, state: "warning", blocking: false });
|
||||
expect(projection.warnings).toEqual([expect.objectContaining({ code: "pac-admission-provenance-not-ready", blocking: false })]);
|
||||
});
|
||||
|
||||
test("PaC bootstrap returns fix-yaml next for zero YAML matches", () => {
|
||||
const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: no exact match"));
|
||||
expect(projectPacBootstrapResult(result)).toBe(result);
|
||||
@@ -237,6 +278,6 @@ test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stage
|
||||
});
|
||||
expect((projection.blockers as Record<string, unknown>[])).toEqual([{ code: "github-repository-not-found-or-no-access", stage: "github-upstream", reason: "repository unavailable" }]);
|
||||
expect((projection.preconditions as Record<string, unknown>[])[2]).toMatchObject({ ok: null, code: "gitea-bootstrap-skipped" });
|
||||
expect((projection.stages as Record<string, unknown>[]).map((item) => item.state)).toEqual(["skipped", "skipped", "skipped", "skipped"]);
|
||||
expect((projection.stages as Record<string, unknown>[]).map((item) => item.state)).toEqual(["skipped", "skipped", "skipped", "skipped", "skipped"]);
|
||||
expect((projection.next as Record<string, unknown>).kind).toBe("read-only-status");
|
||||
});
|
||||
|
||||
@@ -69,6 +69,8 @@ export function projectPacBootstrapResult(result: Record<string, unknown>): Reco
|
||||
const gitea = record(result.giteaBootstrap);
|
||||
const apply = record(result.pipelinesAsCodeApply);
|
||||
const remote = record(apply.remote);
|
||||
const admission = record(remote.admission);
|
||||
const admissionProvenance = record(remote.admissionProvenance);
|
||||
const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote);
|
||||
const dryRun = result.mode === "dry-run";
|
||||
const repositoryMissing = remote.error === "gitea-repository-missing";
|
||||
@@ -77,6 +79,16 @@ export function projectPacBootstrapResult(result: Record<string, unknown>): Reco
|
||||
const giteaSkipped = gitea.mode === "skipped";
|
||||
const applySkipped = apply.mode === "skipped";
|
||||
const applyReady = apply.ok === true || (dryRun && repositoryMissing);
|
||||
const warnings = [
|
||||
...records(result.warnings),
|
||||
...(applySkipped || dryRun || repositoryMissing || admission.ready === true ? [] : [{
|
||||
object: { kind: "ValidatingAdmissionPolicy", id: stringValue(admissionProvenance.policyName, "PaC admission") },
|
||||
blocking: false,
|
||||
code: "pac-admission-provenance-not-ready",
|
||||
configPath: "config/platform-infra/pipelines-as-code.yaml#deliveryProvenance.admission",
|
||||
reasons: Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons : [],
|
||||
}]),
|
||||
];
|
||||
const next = bootstrapNext(result, blockers);
|
||||
return {
|
||||
ok: blockers.length === 0 && giteaReady && applyReady,
|
||||
@@ -101,11 +113,12 @@ export function projectPacBootstrapResult(result: Record<string, unknown>): Reco
|
||||
stages: [
|
||||
{ id: "gitea-init", ok: giteaSkipped ? null : giteaReady, state: giteaSkipped ? "skipped" : giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true },
|
||||
{ id: "pac-controller", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true },
|
||||
{ id: "pac-admission", ok: applySkipped ? null : true, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : dryRun ? "planned" : admission.ready === true ? "ready" : "warning", blocking: false, mutation: admission.applied === true },
|
||||
{ id: "tekton-consumer", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true },
|
||||
{ id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true },
|
||||
],
|
||||
repository: { id: repository.id, namespace: repository.namespace },
|
||||
warnings: result.warnings,
|
||||
warnings,
|
||||
blockers,
|
||||
next,
|
||||
valuesPrinted: false,
|
||||
|
||||
@@ -284,6 +284,13 @@ apply_release() {
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
apply_admission_manifest() {
|
||||
tmp=$(mktemp)
|
||||
printf '%s' "$UNIDESK_PAC_ADMISSION_MANIFEST_B64" | base64 -d > "$tmp"
|
||||
kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-admission-bootstrap" -f "$tmp" >/dev/null
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
wait_ready() {
|
||||
timeout="${UNIDESK_PAC_WAIT_TIMEOUT_SECONDS:-55}"
|
||||
end=$(( $(now_epoch) + timeout ))
|
||||
@@ -313,6 +320,11 @@ apply_action() {
|
||||
return
|
||||
fi
|
||||
apply_release
|
||||
admission_applied=false
|
||||
if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then
|
||||
apply_admission_manifest
|
||||
admission_applied=true
|
||||
fi
|
||||
kubectl create ns "$UNIDESK_PAC_TARGET_NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||
if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then
|
||||
rbac_tmp=$(mktemp)
|
||||
@@ -333,7 +345,11 @@ apply_action() {
|
||||
{ printf '%s' "$token"; printf '\0'; printf '%s' "$UNIDESK_PAC_WEBHOOK_SECRET"; } \
|
||||
| pac_repository_secret_manifest \
|
||||
| kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER-repository-secret" -f - >/dev/null
|
||||
repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
|
||||
if [ "${UNIDESK_PAC_REPOSITORY_APPLY:-1}" = "1" ]; then
|
||||
repository_manifest | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_PAC_FIELD_MANAGER" -f - >/dev/null
|
||||
else
|
||||
kubectl -n "$UNIDESK_PAC_TARGET_NAMESPACE" get repository.pipelinesascode.tekton.dev "$UNIDESK_PAC_REPOSITORY_NAME" >/dev/null
|
||||
fi
|
||||
argo_bootstrap=false
|
||||
if [ -n "${UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64:-}" ]; then
|
||||
if [ -n "${UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME:-}" ]; then
|
||||
@@ -351,7 +367,14 @@ apply_action() {
|
||||
hook_id=$(ensure_webhook)
|
||||
ready=false
|
||||
if wait_ready; then ready=true; fi
|
||||
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ready" "$ready" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
|
||||
prepare_admission_provenance_state
|
||||
admission_ready=true
|
||||
if [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" = "1" ]; then
|
||||
admission_ready=$(printf '%s' "$UNIDESK_PAC_ADMISSION_STATE_JSON" | node -e 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.stdout.write(JSON.parse(input).ready===true?"true":"false"));')
|
||||
fi
|
||||
ok=false
|
||||
if [ "$ready" = true ]; then ok=true; fi
|
||||
printf '{"ok":%s,"mode":"confirmed","mutation":true,"controllerReady":%s,"admission":{"applied":%s,"ready":%s},"admissionProvenance":%s,"argoBootstrap":%s,"preflight":{"repositoryExists":true,"httpStatus":"2xx","repository":"%s/%s","valuesPrinted":false},"webhook":{"present":true,"id":"%s","url":"%s"},"repository":"%s","namespace":"%s","valuesPrinted":false}\n' "$ok" "$ready" "$admission_applied" "$admission_ready" "$UNIDESK_PAC_ADMISSION_STATE_JSON" "$argo_bootstrap" "$(json_string "$UNIDESK_PAC_GITEA_OWNER")" "$(json_string "$UNIDESK_PAC_GITEA_REPO")" "$(json_string "$hook_id")" "$(json_string "$UNIDESK_PAC_WEBHOOK_URL")" "$(json_string "$UNIDESK_PAC_REPOSITORY_NAME")" "$(json_string "$UNIDESK_PAC_TARGET_NAMESPACE")"
|
||||
}
|
||||
|
||||
condition_status() {
|
||||
@@ -590,7 +613,7 @@ function defaultCanMutate(namespace) {
|
||||
|
||||
function admissionStateForConsumer(consumer) {
|
||||
const expected = consumer.deliveryProvenance;
|
||||
if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false };
|
||||
if (!expected || expected.required !== true) return { configured: false, required: false, ready: null, blocking: false, warning: false, warnings: [], reasons: ['consumer-admission-provenance-not-required'], valuesPrinted: false };
|
||||
admissionPolicy ||= kubectlJson(['get', 'validatingadmissionpolicy', expected.policyName, '-o', 'json'], {}, 'admission-policy');
|
||||
admissionBinding ||= kubectlJson(['get', 'validatingadmissionpolicybinding', expected.bindingName, '-o', 'json'], {}, 'admission-binding');
|
||||
if (!admissionRbac.has(consumer.namespace)) {
|
||||
@@ -1527,6 +1550,9 @@ if (!passwordPresent) reasons.push('argocd-repository-secret-password-missing');
|
||||
process.stdout.write(JSON.stringify({
|
||||
configured: runnerConfigured || argoConfigured,
|
||||
ready: reasons.length === 0,
|
||||
blocking: false,
|
||||
warning: reasons.length > 0,
|
||||
warnings: reasons,
|
||||
runner: {
|
||||
configured: runnerConfigured,
|
||||
serviceAccount: runnerName || null,
|
||||
@@ -1563,7 +1589,6 @@ status_action() {
|
||||
repository_condition=$(condition_status "$UNIDESK_PAC_TARGET_NAMESPACE" repository "$UNIDESK_PAC_REPOSITORY_NAME")
|
||||
prepare_admission_provenance_state
|
||||
consumer_bootstrap=$(consumer_bootstrap_state)
|
||||
bootstrap_ready=$(UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE||"{}"); process.stdout.write(s.configured===true&&s.ready!==true?"false":"true")')
|
||||
pipelines=$(pipeline_rows)
|
||||
latest=$(printf '%s' "$pipelines" | node -e 'const fs=require("fs"); const a=JSON.parse(fs.readFileSync(0,"utf8")||"[]"); process.stdout.write(a[0]?.name||"")')
|
||||
export UNIDESK_PAC_TARGET_PIPELINERUN="$latest"
|
||||
@@ -1587,39 +1612,30 @@ NODE
|
||||
)
|
||||
runtime=$(json_normalize "$(runtime_summary)")
|
||||
diagnostics=$(cicd_diagnostics "$pipelines" "$artifact" "$argo" "$runtime")
|
||||
admission_ready=$(UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" node -e 'const s=JSON.parse(process.env.UNIDESK_PAC_STATE||"{}"); process.stdout.write(s.ready===true?"true":"false")')
|
||||
diagnostics=$(UNIDESK_PAC_DIAGNOSTICS="$diagnostics" UNIDESK_PAC_STATE="$UNIDESK_PAC_ADMISSION_STATE_JSON" UNIDESK_PAC_BOOTSTRAP_STATE="$consumer_bootstrap" node <<'NODE'
|
||||
const diagnostics = JSON.parse(process.env.UNIDESK_PAC_DIAGNOSTICS || '{}');
|
||||
const admissionProvenance = JSON.parse(process.env.UNIDESK_PAC_STATE || '{}');
|
||||
const consumerBootstrap = JSON.parse(process.env.UNIDESK_PAC_BOOTSTRAP_STATE || '{}');
|
||||
const output = { ...diagnostics, admissionProvenance, consumerBootstrap, valuesPrinted: false };
|
||||
if (process.env.UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED === '1' && admissionProvenance.ready !== true) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
...diagnostics,
|
||||
ok: false,
|
||||
code: 'pac-admission-provenance-not-ready',
|
||||
phase: 'admission-provenance-not-ready',
|
||||
hint: 'required PaC admission policy, binding, desired RBAC, or live default-SA mutation gate is not ready',
|
||||
admissionProvenance,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
} else if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
...diagnostics,
|
||||
ok: false,
|
||||
code: 'pac-consumer-bootstrap-not-ready',
|
||||
phase: 'consumer-bootstrap-not-ready',
|
||||
hint: 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted',
|
||||
admissionProvenance,
|
||||
consumerBootstrap,
|
||||
valuesPrinted: false,
|
||||
}));
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({ ...diagnostics, admissionProvenance, consumerBootstrap, valuesPrinted: false }));
|
||||
output.warnings = [...(Array.isArray(output.warnings) ? output.warnings : []), {
|
||||
code: 'pac-admission-provenance-not-ready',
|
||||
blocking: false,
|
||||
hint: 'PaC admission policy, binding, desired RBAC, or live default-SA observation is drifted; delivery continues',
|
||||
reasons: Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons : [],
|
||||
}];
|
||||
}
|
||||
if (consumerBootstrap.configured === true && consumerBootstrap.ready !== true) {
|
||||
output.ok = false;
|
||||
output.code = 'pac-consumer-bootstrap-not-ready';
|
||||
output.phase = 'consumer-bootstrap-not-ready';
|
||||
output.hint = 'declared runner ServiceAccount/RoleBinding or Argo repository credential is missing or drifted';
|
||||
}
|
||||
process.stdout.write(JSON.stringify(output));
|
||||
NODE
|
||||
)
|
||||
printf '{"ok":%s,"crdPresent":%s,"controllerReady":"%s","admissionProvenance":%s,"consumerBootstrap":%s,"repository":{"name":"%s","repo":"%s/%s","url":"%s","condition":"%s"},"repositoryCondition":"%s","webhooks":%s,"pipelineRuns":%s,"taskRuns":%s,"artifact":%s,"argo":%s,"runtime":%s,"diagnostics":%s,"valuesPrinted":false}\n' \
|
||||
"$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && { [ "${UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED:-0}" != "1" ] || [ "$admission_ready" = "true" ]; } && echo true || echo false )" \
|
||||
"$( [ -n "$crd" ] && [ "$controller_ready" != "0/0" ] && [ "$bootstrap_ready" = "true" ] && echo true || echo false )" \
|
||||
"$( [ -n "$crd" ] && echo true || echo false )" \
|
||||
"$(json_string "$controller_ready")" \
|
||||
"$UNIDESK_PAC_ADMISSION_STATE_JSON" \
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
canonicalizePacPipelineSpec,
|
||||
firstPacSourceArtifactDrift,
|
||||
pacSourceArtifactSafeError,
|
||||
pacSourceArtifactProvenanceFromManifest,
|
||||
pipelineRunLabels,
|
||||
pipelineRunWorkspaces,
|
||||
pacValueEvidence,
|
||||
pacSourceArtifactYaml,
|
||||
optionalPipelineRunWorkspaces,
|
||||
@@ -96,6 +99,46 @@ function runtimeInput(commit: string | null = sourceCommit): Record<string, unkn
|
||||
}
|
||||
|
||||
describe("PaC source artifact CLI contract", () => {
|
||||
test("accepts PikaOA test runtime provenance", () => {
|
||||
const expected = {
|
||||
configRef: "config/pikaoa.yaml#testRuntime.targets.NC01",
|
||||
effectiveConfigSha256: `sha256:${"a".repeat(64)}`,
|
||||
renderer: "pikaoa-test-runtime",
|
||||
mode: "embedded-pipeline-spec",
|
||||
} as const;
|
||||
const manifest = {
|
||||
metadata: { annotations: pipelineProvenanceAnnotations(expected) },
|
||||
};
|
||||
expect(pacSourceArtifactProvenanceFromManifest(manifest)).toEqual(expected);
|
||||
});
|
||||
|
||||
test("uses PikaOA-specific PipelineRun labels", () => {
|
||||
const binding = { consumer: { id: "pikaoa-test-nc01", node: "NC01" } } as Parameters<typeof pipelineRunLabels>[0];
|
||||
expect(pipelineRunLabels(binding, "pikaoa")).toEqual({
|
||||
"app.kubernetes.io/name": "pikaoa-test-nc01-pac",
|
||||
"app.kubernetes.io/part-of": "pikaoa",
|
||||
"unidesk.ai/source-commit": "{{ revision }}",
|
||||
"pikaoa.unidesk.io/source-commit": "{{ revision }}",
|
||||
"pikaoa.unidesk.io/trigger": "pipelines-as-code",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps the PikaOA shared workspace to the declared RWO PVC size", () => {
|
||||
const binding = {
|
||||
consumer: { sourceArtifact: { renderer: "pikaoa-test-runtime" } },
|
||||
repository: { params: { workspace_pvc_size: "8Gi" } },
|
||||
} as Parameters<typeof pipelineRunWorkspaces>[0];
|
||||
expect(pipelineRunWorkspaces(binding, { workspaces: [{ name: "workspace" }] })).toEqual([{
|
||||
name: "workspace",
|
||||
volumeClaimTemplate: {
|
||||
spec: {
|
||||
accessModes: ["ReadWriteOnce"],
|
||||
resources: { requests: { storage: "8Gi" } },
|
||||
},
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
test("omits empty PipelineRun workspaces and preserves declared bindings", () => {
|
||||
expect(optionalPipelineRunWorkspaces([])).toEqual({});
|
||||
const workspaces = [{ name: "source", emptyDir: {} }];
|
||||
|
||||
@@ -19,9 +19,10 @@ import { stableJsonSha256, stableJsonValue } from "./stable-json";
|
||||
import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance";
|
||||
import { renderSub2RankDesiredPipeline, sub2RankOwningSourceRemote } from "./platform-infra-sub2rank-pipeline";
|
||||
import { renderSelfMediaDesiredPipeline, selfMediaSourceWorktreeRemote } from "./selfmedia-delivery-renderer";
|
||||
import { renderPikaoaTestDesiredPipeline, pikaoaReleaseSourceWorktreeRemote, pikaoaTestSourceWorktreeRemote } from "./pikaoa-test-delivery-renderer";
|
||||
|
||||
export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation";
|
||||
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime";
|
||||
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane" | "sub2rank-platform-service" | "selfmedia-runtime" | "pikaoa-test-runtime" | "pikaoa-release-runtime";
|
||||
export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime";
|
||||
export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable";
|
||||
|
||||
@@ -499,11 +500,13 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree
|
||||
? renderHwlabDesiredPipeline(binding, sourceWorktree)
|
||||
: sourceArtifact.renderer === "sub2rank-platform-service"
|
||||
? renderSub2RankDesiredPipeline(binding)
|
||||
: renderSelfMediaPipeline(binding, sourceWorktree);
|
||||
: sourceArtifact.renderer === "selfmedia-runtime"
|
||||
? renderSelfMediaPipeline(binding, sourceWorktree)
|
||||
: renderPikaoaTestPipeline(binding, sourceWorktree);
|
||||
const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane"
|
||||
? rendered.pipeline
|
||||
: withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance);
|
||||
if (!provenanceEquals(provenanceFromManifest(pipeline), rendered.provenance)) {
|
||||
if (!provenanceEquals(pacSourceArtifactProvenanceFromManifest(pipeline), rendered.provenance)) {
|
||||
throw new Error(`${sourceArtifact.renderer} did not render the declared Pipeline provenance contract`);
|
||||
}
|
||||
const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec");
|
||||
@@ -583,6 +586,19 @@ function renderSelfMediaPipeline(binding: PacSourceArtifactBinding, sourceWorktr
|
||||
};
|
||||
}
|
||||
|
||||
function renderPikaoaTestPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record<string, unknown>; provenance: PacSourceArtifactProvenance } {
|
||||
const rendered = renderPikaoaTestDesiredPipeline(binding, sourceWorktree);
|
||||
return {
|
||||
pipeline: rendered.pipeline,
|
||||
provenance: {
|
||||
configRef: rendered.configRef,
|
||||
effectiveConfigSha256: rendered.effectiveConfigSha256,
|
||||
renderer: binding.consumer.sourceArtifact.renderer,
|
||||
mode: "embedded-pipeline-spec",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record<string, unknown> {
|
||||
const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-"));
|
||||
const temporarySource = resolve(temporaryRoot, "source");
|
||||
@@ -659,7 +675,9 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec
|
||||
? "sub2rank"
|
||||
: provenance.renderer === "selfmedia-runtime"
|
||||
? "selfmedia"
|
||||
: "agentrun",
|
||||
: provenance.renderer === "pikaoa-test-runtime"
|
||||
? "pikaoa"
|
||||
: "agentrun",
|
||||
),
|
||||
},
|
||||
spec: {
|
||||
@@ -732,7 +750,7 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia"): Record<string, string> {
|
||||
export function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab" | "sub2rank" | "selfmedia" | "pikaoa"): Record<string, string> {
|
||||
if (partOf === "agentrun") {
|
||||
return {
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
@@ -751,6 +769,15 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun"
|
||||
"selfmedia.pikapython.com/trigger": "pipelines-as-code",
|
||||
};
|
||||
}
|
||||
if (partOf === "pikaoa") {
|
||||
return {
|
||||
"app.kubernetes.io/name": `${binding.consumer.id}-pac`,
|
||||
"app.kubernetes.io/part-of": "pikaoa",
|
||||
"unidesk.ai/source-commit": "{{ revision }}",
|
||||
"pikaoa.unidesk.io/source-commit": "{{ revision }}",
|
||||
"pikaoa.unidesk.io/trigger": "pipelines-as-code",
|
||||
};
|
||||
}
|
||||
if (partOf === "sub2rank") {
|
||||
return {
|
||||
"app.kubernetes.io/name": "sub2rank",
|
||||
@@ -825,7 +852,7 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor
|
||||
if (name === "source-stage-ref") return { name, value: "{{ source_snapshot_prefix }}/{{ revision }}" };
|
||||
if (name === "git-url") return { name, value: "{{ repo_url }}" };
|
||||
const repositoryParam = name.replaceAll("-", "_");
|
||||
if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" && Object.prototype.hasOwnProperty.call(param, "default")) {
|
||||
if ((binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime") && Object.prototype.hasOwnProperty.call(param, "default")) {
|
||||
return { name, value: param.default };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(binding.repository.params, repositoryParam)) {
|
||||
@@ -836,11 +863,11 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor
|
||||
});
|
||||
}
|
||||
|
||||
function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>): Record<string, unknown>[] {
|
||||
export function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>): Record<string, unknown>[] {
|
||||
if (desiredSpec.workspaces === undefined) return [];
|
||||
return arrayRecords(desiredSpec.workspaces, "Pipeline.spec.workspaces").map((workspace) => {
|
||||
const name = requiredString(workspace.name, "Pipeline.spec.workspaces[].name");
|
||||
if (name === "source") {
|
||||
if (name === "source" || (name === "workspace" && (binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime" || binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime"))) {
|
||||
return {
|
||||
name,
|
||||
volumeClaimTemplate: {
|
||||
@@ -875,9 +902,9 @@ function inspectSourceArtifact(sourceWorktree: string, binding: PacSourceArtifac
|
||||
const pipelineDrift = firstPacSourceArtifactDrift(desired.pipeline, actualPipeline);
|
||||
const pipelineRunDrift = firstPacSourceArtifactDrift(desired.pipelineRun, actualPipelineRun);
|
||||
const actualProvenance = sourceArtifact.mode === "remote-pipeline-annotation"
|
||||
? provenanceFromManifest(actualPipeline)
|
||||
: provenanceFromManifest(actualPipelineRun);
|
||||
const wrapperProvenance = provenanceFromManifest(actualPipelineRun);
|
||||
? pacSourceArtifactProvenanceFromManifest(actualPipeline)
|
||||
: pacSourceArtifactProvenanceFromManifest(actualPipelineRun);
|
||||
const wrapperProvenance = pacSourceArtifactProvenanceFromManifest(actualPipelineRun);
|
||||
const provenanceAligned = provenanceEquals(actualProvenance, desired.provenance) && provenanceEquals(wrapperProvenance, desired.provenance);
|
||||
const aligned = pipelineDrift === null && pipelineRunDrift === null && provenanceAligned;
|
||||
return {
|
||||
@@ -918,6 +945,8 @@ function owningSourceRemote(binding: PacSourceArtifactBinding): string {
|
||||
}
|
||||
if (binding.consumer.sourceArtifact.renderer === "sub2rank-platform-service") return sub2RankOwningSourceRemote();
|
||||
if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime") return selfMediaSourceWorktreeRemote(binding.consumer.node);
|
||||
if (binding.consumer.sourceArtifact.renderer === "pikaoa-test-runtime") return pikaoaTestSourceWorktreeRemote(binding.consumer.node);
|
||||
if (binding.consumer.sourceArtifact.renderer === "pikaoa-release-runtime") return pikaoaReleaseSourceWorktreeRemote(binding.consumer.node);
|
||||
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved");
|
||||
return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl;
|
||||
}
|
||||
@@ -1030,13 +1059,15 @@ function assertPipelineIdentity(pipeline: Record<string, unknown>, binding: PacS
|
||||
if (metadata.namespace !== binding.consumer.namespace) throw new Error(`rendered Pipeline namespace ${String(metadata.namespace)} does not match consumer ${binding.consumer.namespace}`);
|
||||
}
|
||||
|
||||
function provenanceFromManifest(manifest: Record<string, unknown>): PacSourceArtifactProvenance | null {
|
||||
export function pacSourceArtifactProvenanceFromManifest(manifest: Record<string, unknown>): PacSourceArtifactProvenance | null {
|
||||
const provenance = pipelineProvenanceFromManifest(manifest);
|
||||
if (provenance === null) return null;
|
||||
if (provenance.renderer !== "agentrun-control-plane"
|
||||
&& provenance.renderer !== "hwlab-runtime-lane"
|
||||
&& provenance.renderer !== "sub2rank-platform-service"
|
||||
&& provenance.renderer !== "selfmedia-runtime") return null;
|
||||
&& provenance.renderer !== "selfmedia-runtime"
|
||||
&& provenance.renderer !== "pikaoa-test-runtime"
|
||||
&& provenance.renderer !== "pikaoa-release-runtime") return null;
|
||||
if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null;
|
||||
return provenance as PacSourceArtifactProvenance;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type PacSourceArtifactRuntimeObservation,
|
||||
type PacSourceArtifactSpec,
|
||||
} from "./platform-infra-pipelines-as-code-source-artifact";
|
||||
import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance";
|
||||
import { pacAdmissionDesiredIdentity, renderPacAdmissionDesiredFragment } from "./platform-infra-pac-provenance";
|
||||
import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac";
|
||||
import { runGiteaMirrorBootstrapPreflight, runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
|
||||
import { readGiteaConfig } from "./platform-infra-gitea-config";
|
||||
@@ -801,7 +801,7 @@ function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: s
|
||||
|
||||
function parseSourceArtifact(value: Record<string, unknown>, path: string): PacSourceArtifactSpec {
|
||||
const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const);
|
||||
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime"] as const);
|
||||
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-test-runtime", "pikaoa-release-runtime"] as const);
|
||||
const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`);
|
||||
const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`);
|
||||
const taskRunTemplate = y.objectField(value, "taskRunTemplate", path);
|
||||
@@ -811,6 +811,8 @@ function parseSourceArtifact(value: Record<string, unknown>, path: string): PacS
|
||||
if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`);
|
||||
if (renderer === "sub2rank-platform-service" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer sub2rank-platform-service requires embedded-pipeline-spec`);
|
||||
if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`);
|
||||
if (renderer === "pikaoa-test-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-test-runtime requires embedded-pipeline-spec`);
|
||||
if (renderer === "pikaoa-release-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-release-runtime requires embedded-pipeline-spec`);
|
||||
return {
|
||||
mode,
|
||||
renderer,
|
||||
@@ -1103,10 +1105,10 @@ function validateConsumerConfig(
|
||||
if (consumer.sourceArtifact?.renderer === "sub2rank-platform-service" && consumer.runnerServiceAccount === null) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for sub2rank-platform-service`);
|
||||
}
|
||||
if (consumer.sourceArtifact?.renderer === "selfmedia-runtime") {
|
||||
if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for selfmedia-runtime`);
|
||||
if (consumer.sourceArtifact?.renderer === "selfmedia-runtime" || consumer.sourceArtifact?.renderer === "pikaoa-test-runtime") {
|
||||
if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for private GitOps runtime renderers`);
|
||||
if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) {
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private selfmedia repository`);
|
||||
throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private repository`);
|
||||
}
|
||||
if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`);
|
||||
if (params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.consumers.${consumer.id}.effective params.gitops_secret_name must match repository secretName`);
|
||||
@@ -1720,6 +1722,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
|
||||
const admissionIdentity = pacAdmissionDesiredIdentity(target.id);
|
||||
const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id);
|
||||
const consumerConfig = historyConsumerConfig(pac, consumer);
|
||||
const repositoryOwner = pac.consumers.find((item) => item.repositoryRef === repository.id);
|
||||
const env: Record<string, string> = {
|
||||
UNIDESK_PAC_ACTION: action,
|
||||
UNIDESK_PAC_EVALUATOR_B64: Buffer.from(readFileSync(evaluatorFile, "utf8"), "utf8").toString("base64"),
|
||||
@@ -1741,6 +1744,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
|
||||
UNIDESK_PAC_WEBHOOK_URL: webhookUrl,
|
||||
UNIDESK_PAC_WEBHOOK_SECRET: secrets.webhookSecret,
|
||||
UNIDESK_PAC_REPOSITORY_NAME: repository.name,
|
||||
UNIDESK_PAC_REPOSITORY_APPLY: repositoryOwner?.id === consumer.id ? "1" : "0",
|
||||
UNIDESK_PAC_REPOSITORY_URL: repository.url,
|
||||
UNIDESK_PAC_SECRET_NAME: repository.secretName,
|
||||
UNIDESK_PAC_TOKEN_KEY: repository.tokenKey,
|
||||
@@ -1753,6 +1757,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
|
||||
UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix,
|
||||
UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0",
|
||||
UNIDESK_PAC_ADMISSION_MANIFEST_B64: Buffer.from(renderPacAdmissionDesiredFragment(target.id), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64: Buffer.from(runnerServiceAccountManifest(consumer), "utf8").toString("base64"),
|
||||
UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME: consumer.runnerServiceAccount?.name ?? "",
|
||||
|
||||
Reference in New Issue
Block a user