Merge remote-tracking branch 'origin/master' into feat/1923-cicd-otel-observability

This commit is contained in:
Codex
2026-07-13 14:59:12 +02:00
47 changed files with 1686 additions and 161 deletions
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test";
import { renderNodeStatus } from "./cicd-node-status";
const next = {
status: "bun scripts/cli.ts cicd status --node NC01",
history: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01",
fixAutomaticDelivery: { reference: ".agents/skills/unidesk-cicd/SKILL.md" },
};
function result(status: "ready" | "warning" | "blocked", consumers: Array<Record<string, unknown>>): Record<string, unknown> {
return {
ok: status === "ready",
node: "NC01",
target: { id: "NC01" },
summary: {
ready: status === "ready",
status,
code: status === "ready" ? "cicd-node-ready" : status === "warning" ? "cicd-node-no-consumers" : "cicd-node-blocked",
total: consumers.length,
readyCount: consumers.filter((consumer) => consumer.ready === true).length,
blockedCount: consumers.filter((consumer) => consumer.ready !== true).length,
elapsedMs: 12,
},
consumers,
next,
};
}
const readyConsumer = {
consumer: "platform-infra-gitea-nc01",
ready: true,
pipelineStatus: "Succeeded",
durationSeconds: 9,
sourceCommit: "a".repeat(40),
gitopsCommit: "b".repeat(40),
argo: { sync: "Synced", health: "Healthy", revisionRelation: { relation: "exact" } },
runtime: { readyReplicas: 1, replicas: 1, digest: `sha256:${"c".repeat(64)}` },
reason: "ready",
latestPipelineRun: "platform-infra-gitea-nc01-fixture",
digest: `sha256:${"c".repeat(64)}`,
imageStatus: "-",
diagnostics: { hint: "GitOps-only consumer is ready", registry: { applicability: "not-configured", status: "not-configured", present: null } },
};
test("node status renderer is non-empty for zero consumers, ready, warning, and blocked projections", () => {
const fixtures = [
result("warning", []),
result("ready", [readyConsumer]),
result("warning", [{ ...readyConsumer, ready: false, reason: "diagnostics-warning" }]),
result("blocked", [{ ...readyConsumer, ready: false, reason: "pac-registry-missing" }]),
];
for (const fixture of fixtures) {
const rendered = renderNodeStatus(fixture, true).renderedText;
expect(rendered.trim().length).toBeGreaterThan(0);
expect(rendered).toContain("CI/CD NODE STATUS");
expect(rendered).toContain("NEXT");
}
});
test("node full renderer shows registry N/A without hiding digest evidence", () => {
const rendered = renderNodeStatus(result("ready", [readyConsumer]), true).renderedText;
expect(rendered).toContain("N/A");
expect(rendered).toContain("sha256:");
expect(rendered).toContain("platform-infra-gitea-nc01-fixture");
});
+11 -3
View File
@@ -76,7 +76,7 @@ function parseNodeStatusOptions(args: string[]): NodeStatusOptions {
return { nodeId, targetId, full, raw, output };
}
function renderNodeStatus(result: Record<string, unknown>, full: boolean): RenderedCliResult {
export function renderNodeStatus(result: Record<string, unknown>, full: boolean): RenderedCliResult {
const target = record(result.target);
const summary = record(result.summary);
const consumers = arrayRecords(result.consumers);
@@ -100,13 +100,16 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
stringValue(row.latestPipelineRun),
short(stringValue(row.digest), 18),
stringValue(row.imageStatus),
registryText(record(record(row.diagnostics).registry)),
compactLine(stringValue(record(row.diagnostics).hint)),
]);
const lines = [
"CI/CD NODE STATUS",
...table(["NODE", "TARGET", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[
...table(["NODE", "TARGET", "STATUS", "CODE", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[
stringValue(result.node),
stringValue(target.id),
stringValue(summary.status),
stringValue(summary.code),
boolText(summary.ready),
`${stringValue(summary.readyCount)}/${stringValue(summary.total)}`,
stringValue(summary.blockedCount),
@@ -118,7 +121,7 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
"",
...(full ? [
"DETAIL",
...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "HINT"], detailRows)),
...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "REGISTRY", "HINT"], detailRows)),
"",
] : []),
"NEXT",
@@ -129,6 +132,11 @@ function renderNodeStatus(result: Record<string, unknown>, full: boolean): Rende
return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" };
}
function registryText(registry: Record<string, unknown>): string {
const status = stringValue(registry.status);
return status === "not-configured" ? "N/A" : status;
}
function isHelpToken(value: string | undefined): boolean {
return value === "help" || value === "--help" || value === "-h";
}
+5
View File
@@ -6091,6 +6091,9 @@ function compactSkillsStatus(value: unknown): Record<string, unknown> | null {
readonly: record.readonly ?? false,
skillCount: record.skillCount ?? 0,
version: record.version ?? null,
capabilities: record.capabilities ?? null,
warnings: Array.isArray(record.warnings) ? record.warnings.map(String) : [],
next: record.next ?? null,
sourceSkillCount: record.sourceSkillCount ?? null,
targetSkillCount: record.targetSkillCount ?? null,
sourceMissingSkills: Array.isArray(record.sourceMissingSkills) ? record.sourceMissingSkills.map(String) : [],
@@ -6151,6 +6154,8 @@ function compactSkillsSyncStatus(value: unknown, full = false): Record<string, u
expected: record.expected ?? null,
counts: record.counts ?? null,
version: record.version ?? null,
capabilities: record.capabilities ?? null,
warnings: Array.isArray(record.warnings) ? record.warnings.map(String) : [],
missing: record.missing ?? null,
permissionFailures: Array.isArray(record.permissionFailures) ? record.permissionFailures.slice(0, full ? undefined : 4) : [],
permissionFailureCount: Array.isArray(record.permissionFailures) ? record.permissionFailures.length : 0,
@@ -410,8 +410,12 @@ function projectManagementPaneGapRows(projectSamples) {
const maxGapPx = maxNumber(severeGaps.map((item) => item.bottomGapPx));
const maxGapRatio = maxNumber(severeGaps.map((item) => item.bottomGapRatio));
const multiPane = severeGaps.length >= 2;
const singleExtreme = maxGapPx >= 240 && maxGapRatio >= 0.45;
if (!multiPane && !singleExtreme) continue;
const singlePrimaryPaneExtreme = severeGaps.some((item) =>
item.name !== "task-tree"
&& Number(item.bottomGapPx ?? 0) >= 240
&& Number(item.bottomGapRatio ?? 0) >= 0.45
);
if (!multiPane && !singlePrimaryPaneExtreme) continue;
const selectedTaskRefHash = sample?.projectManagement?.selectedTaskRef?.hash ?? null;
const isMdtodo = sample?.projectManagement?.pageKind === "project-management-mdtodo";
const isInitialEmptyDetail = isMdtodo && !selectedTaskRefHash;
+15 -2
View File
@@ -165,14 +165,15 @@ function renderWebObserveStatusTable(value: Record<string, unknown>): string {
] : []),
...(Object.keys(exactCommand).length > 0 ? [
"Exact command:",
webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "REPORT_SHA", "DETAIL"], [[
webObserveTable(["COMMAND", "TYPE", "PHASE", "OK", "STATUS", "PROFILE_SHA", "REPORT_SHA", "DETAIL"], [[
webObserveShort(webObserveText(exactCommand.commandId), 40),
exactCommand.type,
exactCommand.phase,
exactCommand.ok,
exactResult?.status,
webObserveShort(webObserveText(exactResult?.profileSha256 ?? exactErrorDetails?.profileSha256), 32),
webObserveShort(webObserveText(exactResult?.reportSha256 ?? exactErrorDetails?.reportSha256), 32),
webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath), 96),
webObserveShort(webObserveText(exactError.message ?? exactResult?.reportPath ?? exactErrorDetails?.reportPath ?? exactResult?.profilePath ?? exactErrorDetails?.profilePath), 96),
]]),
"",
] : []),
@@ -436,6 +437,7 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
const details = nullableRecord(error?.details);
const replayEvidence = result ?? details;
const replayScreenshot = record(result?.screenshot ?? details?.screenshot);
const performanceCapture = observerCommand?.type === "performanceCapture" ? result ?? details : null;
const rawReadiness = record(error?.navigationReadiness) ?? record(details?.readiness) ?? record(details?.readinessAfterWait) ?? record(details?.readinessBeforeClick);
const readiness = record(rawReadiness?.snapshot) ?? rawReadiness;
const id = webObserveText(value.id);
@@ -454,6 +456,17 @@ function renderWebObserveCommandTable(value: Record<string, unknown>): string {
webObserveShort(webObserveText(asyncTurn?.traceId), 24),
webObserveShort(webObserveText(result?.mark ?? result?.currentUrl ?? observer?.error ?? observer?.queued), 80),
]]),
...(performanceCapture !== null ? [
"",
"Performance capture:",
webObserveTable(["CAPTURE", "DURATION_MS", "PROFILE_SHA", "REPORT_SHA", "COLLECT"], [[
performanceCapture.captureId,
performanceCapture.durationMs,
webObserveShort(webObserveText(performanceCapture.profileSha256), 32),
webObserveShort(webObserveText(performanceCapture.reportSha256), 32),
`observe collect ${id} --file ${webObserveText(performanceCapture.reportPath)}`,
]]),
] : []),
...(observerCommand?.type === "validateWorkbenchKafkaDebugReplay" && replayEvidence !== null ? [
"",
...renderWorkbenchKafkaDebugReplayEvidence(
@@ -1120,12 +1120,14 @@ async function waitForProjectManagementCommandReady(options = {}) {
while (Date.now() <= deadline) {
last = await projectManagementCommandSnapshot();
const path = String(last?.path || safeUrlPath(currentPageUrl()) || "");
const needsTask = /\/tasks\//u.test(path);
const selectedTaskReady = Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true);
const taskCollectionReady = Number(last?.taskCount || 0) > 0;
const baseReady = last?.pageKind === "project-management-mdtodo"
&& Number(last?.sourceCount || 0) > 0
&& Number(last?.fileCount || 0) > 0
&& Number(last?.taskCount || 0) > 0;
const needsTask = /\/tasks\//u.test(path);
const taskReady = !needsTask || Boolean(last?.selectedTaskId || last?.selectedTaskRef?.hash || last?.taskBodyVisible === true || last?.launchButtonVisible === true);
&& (needsTask ? selectedTaskReady : taskCollectionReady);
const taskReady = !needsTask || selectedTaskReady;
const needsReport = /\/reports\//u.test(path);
const reportReady = !needsReport || last?.reportPreviewVisible === true || last?.reportFullscreenVisible === true;
if (baseReady && taskReady && reportReady) return { ok: true, reason: "project-management-command-ready", durationMs: Date.now() - started, snapshot: last, valuesRedacted: true };
@@ -5,12 +5,36 @@ import {
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
nodeWebObserveRunnerSamplingSource,
} from "./hwlab-node-web-observe-runner-sampling-source";
import { nodeWebObserveAnalyzerProjectSource } from "./hwlab-node-web-observe-analyzer-project-source";
import { nodeWebObserveRunnerControlSource } from "./hwlab-node-web-observe-runner-control-source";
test("MDTODO task tree pane gap treats the pinned facts footer as content", async () => {
assert.match(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR, /\[data-testid="mdtodo-task-tree-facts"\]/u);
assert.match(
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
/\[data-testid="mdtodo-task-tree-facts"\]/u,
);
assert.match(
MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR,
/\[data-testid="mdtodo-task-page-fact"\]/u,
);
const source = nodeWebObserveRunnerSamplingSource();
assert.ok(source.includes(`measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`));
assert.ok(
source.includes(
`measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)})`,
),
);
assert.match(
source,
/semanticTaskCandidates\.length > 0 \? semanticTaskCandidates : fallbackTaskCandidates/u,
);
assert.match(
source,
/taskCandidates\.filter\(\(candidate\) => !taskRefElement\(candidate\)\)\.length/u,
);
assert.doesNotMatch(source, /taskCandidates\.length - taskItems\.length/u);
assert.match(source, /mdtodo-body-read/u);
assert.match(source, /mdtodo-launch-workbench/u);
const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], {
stdin: "pipe",
@@ -19,6 +43,91 @@ test("MDTODO task tree pane gap treats the pinned facts footer as content", asyn
});
child.stdin.write(source);
child.stdin.end();
const [stderr, exitCode] = await Promise.all([new Response(child.stderr).text(), child.exited]);
const [stderr, exitCode] = await Promise.all([
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
});
test("MDTODO pane gap ignores a naturally short task tree but keeps primary pane regressions", () => {
const projectManagementPaneGapRows = new Function(
"maxNumber",
"projectManagementSampleRef",
`${nodeWebObserveAnalyzerProjectSource()}; return projectManagementPaneGapRows;`,
)(
(values: unknown[]) => Math.max(0, ...values.map((value) => Number(value ?? 0))),
(sample: Record<string, unknown>) => ({ seq: sample.seq ?? null }),
) as (samples: unknown[]) => { actionable: unknown[]; ignored: unknown[] };
const pane = (name: string, bottomGapPx: number, bottomGapRatio: number) => ({
name,
visible: true,
widthPx: 300,
heightPx: 600,
bottomGapPx,
bottomGapRatio,
contentNodeCount: 4,
});
const sample = (paneGaps: unknown[]) => ({
seq: 1,
projectManagement: {
pageKind: "project-management-mdtodo",
selectedTaskRef: { hash: "sha256:opaque" },
paneGaps,
},
});
assert.deepEqual(
projectManagementPaneGapRows([sample([pane("task-tree", 391, 0.618)])]),
{ actionable: [], ignored: [], valuesRedacted: true },
);
assert.equal(
projectManagementPaneGapRows([sample([pane("task-detail", 391, 0.618)])]).actionable.length,
1,
);
assert.equal(
projectManagementPaneGapRows([sample([
pane("task-tree", 240, 0.4),
pane("task-detail", 180, 0.3),
])]).actionable.length,
1,
);
});
test("MDTODO command readiness accepts a selected mobile task when the hidden tree has no visible rows", async () => {
const source = nodeWebObserveRunnerControlSource();
const makeReady = (snapshot: Record<string, unknown>) => new Function(
"projectManagementCommandSnapshot",
"safeUrlPath",
"currentPageUrl",
"page",
`${source}; return waitForProjectManagementCommandReady;`,
)(
async () => snapshot,
() => String(snapshot.path ?? ""),
() => `https://hwlab.pikapython.com${String(snapshot.path ?? "")}`,
{ waitForTimeout: async () => undefined },
) as (options: { timeoutMs: number }) => Promise<{ ok: boolean; reason: string }>;
const selectedMobileTask = {
path: "/projects/mdtodo/sources/source/files/file/tasks/R1",
pageKind: "project-management-mdtodo",
sourceCount: 3,
fileCount: 18,
taskCount: 0,
selectedTaskRef: { hash: "sha256:opaque" },
taskBodyVisible: true,
launchButtonVisible: true,
};
assert.equal((await makeReady(selectedMobileTask)({ timeoutMs: 1 })).ok, true);
const unselectedRoot = {
path: "/projects/mdtodo",
pageKind: "project-management-mdtodo",
sourceCount: 3,
fileCount: 18,
taskCount: 0,
};
assert.equal((await makeReady(unselectedRoot)({ timeoutMs: 1 })).ok, false);
});
@@ -3,6 +3,7 @@
export const MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR = [
'[data-testid="mdtodo-task-tree-facts"]',
'[data-testid="mdtodo-task-page-fact"]',
"[data-task-ref]",
'[role="treeitem"]',
'[role="listitem"]',
@@ -433,22 +434,32 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
selected: option.selected === true,
})) : [];
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
const taskItems = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] [data-task-ref], [data-task-ref]')).filter(visible);
const taskCandidates = Array.from(document.querySelectorAll('[data-testid="mdtodo-task-tree"] li, [data-testid="mdtodo-task-tree"] [role="treeitem"], [data-testid="mdtodo-task-tree"] [role="listitem"]')).filter(visible);
const taskTree = document.querySelector('[data-testid="mdtodo-task-tree"]');
const semanticTaskCandidates = taskTree
? Array.from(taskTree.querySelectorAll('[role="treeitem"], [role="listitem"]')).filter(visible)
: [];
const fallbackTaskCandidates = taskTree
? Array.from(taskTree.querySelectorAll("li")).filter(visible)
: [];
const taskCandidates = semanticTaskCandidates.length > 0 ? semanticTaskCandidates : fallbackTaskCandidates;
const taskRefElement = (candidate) => candidate.matches("[data-task-ref]")
? candidate
: candidate.querySelector("[data-task-ref]");
const taskItems = Array.from(new Set(taskCandidates.map(taskRefElement).filter(visible)));
const selectedSource = document.querySelector('[data-source-id][data-selected="true"], [data-source-id][aria-selected="true"], [data-source-id].selected, [data-source-id].is-selected');
const selectedFile = document.querySelector('[data-file-ref][data-selected="true"], [data-file-ref][aria-selected="true"], [data-file-ref].selected, [data-file-ref].is-selected');
const selectedTask = document.querySelector('[data-task-ref][data-selected="true"], [data-task-ref][aria-selected="true"], [data-task-ref].selected, [data-task-ref].is-selected');
const statusCounts = {};
for (const task of taskItems) {
const status = task.getAttribute("data-task-status") || "unknown";
const status = task.getAttribute("data-task-status") || task.querySelector("[data-state]")?.getAttribute("data-state") || "unknown";
statusCounts[status] = (statusCounts[status] || 0) + 1;
}
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]');
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
const reportLinks = Array.from(document.querySelectorAll('[data-testid="mdtodo-report-link"]')).filter(visible);
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
const blockers = Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [role="alert"]')).filter(visible).slice(0, 12).map((element, index) => ({
index,
testId: element.getAttribute("data-testid"),
role: element.getAttribute("role"),
@@ -475,7 +486,7 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
};
const paneGaps = [
measurePaneGap("task-tree", '[data-testid="mdtodo-task-tree"]', ${JSON.stringify(MDTODO_TASK_TREE_PANE_CONTENT_SELECTOR)}),
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .task-status-stack > *, .task-document-footer'),
measurePaneGap("task-detail", '[data-testid="mdtodo-task-detail"]', '[data-testid="mdtodo-body-rendered"] > *, [data-testid="mdtodo-body-read"], [data-testid="mdtodo-report-section"], [data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-testid="mdtodo-delete-task"], [data-testid="mdtodo-task-detail-error"], .mdtodo-detail-header, .document-header, .task-status-stack > *, .task-document-footer, .document-footer'),
measurePaneGap("report-sidebar", '[data-testid="mdtodo-report-sidebar"]', '[data-testid="mdtodo-report-preview"] > *, [data-testid="mdtodo-report-error"], [data-testid="mdtodo-report-fullscreen"], [data-testid="mdtodo-report-close"], .report-sidebar-header, .report-preview .markdown-body > *'),
];
return {
@@ -486,17 +497,17 @@ async function sampleOnePage(targetPage, { reason, groupSeq, pageRole, targetPag
sourceCount: Math.max(sourceItems.length, sourceOptionCount),
fileCount: Math.max(fileItems.length, fileOptionCount),
taskCount: taskItems.length,
taskRefMissingCount: Math.max(0, taskCandidates.length - taskItems.length),
taskRefMissingCount: taskCandidates.filter((candidate) => !taskRefElement(candidate)).length,
selectedSourceId: opaqueDomId(selectedSource?.getAttribute("data-source-id") || sourceSelect?.value),
selectedFileRef: opaqueDomId(selectedFile?.getAttribute("data-file-ref") || fileSelect?.value),
selectedFileLabel: selectedFile ? trim(selectedFile.textContent || "", 180) : selectedFileOption?.label || null,
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 24),
selectedTaskRef: opaqueDomId(selectedTask?.getAttribute("data-task-ref")),
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || null,
sourceSelectVisible: visible(sourceSelect),
fileSelectVisible: visible(fileSelect),
sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')),
taskBodyVisible: visible(bodyRendered),
taskBodyText: visible(bodyRendered) ? trim(bodyRendered.textContent || "", 500) : "",
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import { nodeWebObserveRunnerWorkbenchSource } from "./hwlab-node-web-observe-runner-workbench-source";
test("MDTODO observe commands recognize the rewritten workspace contracts", async () => {
const source = nodeWebObserveRunnerWorkbenchSource();
for (const testId of [
"mdtodo-body-read",
"mdtodo-body-editor",
"mdtodo-body-save",
"mdtodo-launch-workbench",
]) {
assert.match(source, new RegExp(testId, "u"));
}
for (const legacyTestId of [
"mdtodo-body-rendered",
"mdtodo-edit-body",
"mdtodo-edit-body-save",
"mdtodo-workbench-launch",
]) {
assert.match(source, new RegExp(legacyTestId, "u"));
}
const child = Bun.spawn(["node", "--input-type=module", "--check", "-"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
});
child.stdin.write(source);
child.stdin.end();
const [stderr, exitCode] = await Promise.all([
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
});
@@ -764,7 +764,7 @@ async function selectMdtodoTask(command) {
const clicked = await target.locator.evaluate((element) => ({
taskRef: element.getAttribute("data-task-ref") || null,
taskId: element.getAttribute("data-task-id") || element.getAttribute("data-rxx-id") || null,
status: element.getAttribute("data-task-status") || null,
status: element.getAttribute("data-task-status") || element.querySelector("[data-state]")?.getAttribute("data-state") || null,
selected: element.getAttribute("data-selected") === "true" || element.getAttribute("aria-selected") === "true"
})).catch(() => ({ taskRef: target.taskRef || null, taskId: target.taskId || null, status: null }));
if (!clicked.selected) {
@@ -895,12 +895,22 @@ async function closeMdtodoSourceConfigIfOpen(options = {}) {
return result;
}
function mdtodoTestIdCandidates(value) {
return (Array.isArray(value) ? value : [value]).filter((item) => typeof item === "string" && item.length > 0);
}
function mdtodoTestIdLocator(value) {
const candidates = mdtodoTestIdCandidates(value);
return page.locator(candidates.map((testId) => '[data-testid="' + cssEscape(testId) + '"]').join(", ")).first();
}
async function fillMdtodoField(testId, value) {
if (typeof value !== "string" || !value.trim()) return { testId, filled: false };
const locator = page.locator('[data-testid="' + cssEscape(testId) + '"]').first();
const locator = mdtodoTestIdLocator(testId);
await locator.waitFor({ state: "visible", timeout: 10000 });
await locator.fill(value);
return { testId, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
const resolvedTestId = await locator.getAttribute("data-testid").catch(() => null);
return { testId: resolvedTestId || mdtodoTestIdCandidates(testId)[0] || null, filled: true, value: opaqueIdSummary(value), valuesRedacted: true };
}
async function clickProjectButtonAndMaybeWait(testId, pathPattern) {
@@ -1003,7 +1013,7 @@ async function saveMdtodoTaskWithButton(command, type, testId, fields) {
if (field.openByDblClickTestId) inlineEditors.push(await ensureMdtodoInlineEditor(field.testId, field.openByDblClickTestId));
if (field.kind === "fill") await fillMdtodoField(field.testId, field.value);
if (field.kind === "select") {
const locator = page.locator('[data-testid="' + cssEscape(field.testId) + '"]').first();
const locator = mdtodoTestIdLocator(field.testId);
await locator.waitFor({ state: "visible", timeout: 10000 });
await selectHtmlOptionByValueOrLabel(locator, field.value);
}
@@ -1023,9 +1033,9 @@ async function clickProjectButtonAndMaybeWaitAny(testIds, pathPattern) {
}
async function ensureMdtodoInlineEditor(editorTestId, readTestId) {
const editor = page.locator('[data-testid="' + cssEscape(editorTestId) + '"]').first();
const editor = mdtodoTestIdLocator(editorTestId);
if (await visibleLocator(editor)) return { editorTestId, readTestId, opened: false };
const read = page.locator('[data-testid="' + cssEscape(readTestId) + '"]').first();
const read = mdtodoTestIdLocator(readTestId);
await read.waitFor({ state: "visible", timeout: 10000 });
await read.dblclick();
await editor.waitFor({ state: "visible", timeout: 10000 });
@@ -1041,7 +1051,7 @@ async function editMdtodoTaskTitle(command) {
async function editMdtodoTaskBody(command) {
const body = commandValue(command, ["text", "body", "value"]);
if (!body) throw new Error("editMdtodoTaskBody requires --text or --text-stdin");
return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
return saveMdtodoTaskWithButton(command, "editMdtodoTaskBody", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]);
}
async function toggleMdtodoTaskStatus(command) {
@@ -1060,7 +1070,7 @@ async function editMdtodoTaskInline(command) {
if (field === "body" || field === "content") {
const body = commandValue(command, ["body", "text"]);
if (!body) throw new Error("editMdtodoTaskInline --field body requires --body, --text, or --text-stdin");
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", "mdtodo-edit-body-save", [{ kind: "fill", testId: "mdtodo-edit-body", value: body, openByDblClickTestId: "mdtodo-body-rendered" }]);
return saveMdtodoTaskWithButton(command, "editMdtodoTaskInline", ["mdtodo-body-save", "mdtodo-edit-body-save"], [{ kind: "fill", testId: ["mdtodo-body-editor", "mdtodo-edit-body"], value: body, openByDblClickTestId: ["mdtodo-body-read", "mdtodo-body-rendered"] }]);
}
if (field === "status") {
const status = commandValue(command, ["status", "text"]);
@@ -1219,7 +1229,9 @@ async function deleteMdtodoTask(command) {
const selection = await selectTaskIfCommandTargetsOne(command);
const firstClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", null);
let confirmClick = null;
const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first());
const deleteButton = page.locator('[data-testid="mdtodo-delete-task"]').first();
const confirmVisible = await visibleLocator(page.locator('[data-testid="mdtodo-delete-cancel"]').first())
|| await deleteButton.evaluate((element) => /确认|confirm/iu.test(String(element.textContent || ""))).catch(() => false);
if (confirmVisible) confirmClick = await clickProjectButtonAndMaybeWait("mdtodo-delete-task", /^\/v1\/project-management\/mdtodo\/tasks/u);
return { beforeUrl, afterUrl: currentPageUrl(), type: "deleteMdtodoTask", selection, firstClick, confirmClick, beforeProject, afterProject: await projectManagementCommandSnapshot(), pageId, valuesRedacted: true };
}
@@ -1250,7 +1262,7 @@ async function launchWorkbenchFromTask(command) {
: null;
const providerSelection = await selectMdtodoProviderProfileForLaunch(command);
const projectBeforeClick = await projectManagementCommandSnapshot({ includeRaw: true });
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]').first();
const button = page.locator('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]').first();
await button.waitFor({ state: "visible", timeout: 15000 });
const buttonState = await button.evaluate((element) => ({
disabled: Boolean(element.disabled) || element.getAttribute("aria-disabled") === "true",
@@ -1392,8 +1404,8 @@ async function projectManagementCommandSnapshot(options = {}) {
selected: option.selected === true
})) : [];
const selectedFileOption = fileOptions.find((option) => option.selected) || null;
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"]');
const launch = document.querySelector('[data-testid="mdtodo-workbench-launch"], [data-testid="mdtodo-launch-workbench"], [data-action="launch-workbench"]');
const bodyRendered = document.querySelector('[data-testid="mdtodo-body-rendered"], [data-testid="mdtodo-body-read"]');
const reportPreview = document.querySelector('[data-testid="mdtodo-report-preview"]');
const reportError = document.querySelector('[data-testid="mdtodo-report-error"]');
const reportFullscreen = document.querySelector('[data-testid="mdtodo-report-fullscreen-dialog"]');
@@ -1410,11 +1422,11 @@ async function projectManagementCommandSnapshot(options = {}) {
fileOptionLabels: fileOptions.map((option) => option.label).filter(Boolean).slice(0, 20),
selectedTaskRefRaw: selectedTask?.getAttribute("data-task-ref") || null,
selectedTaskId: selectedTask?.getAttribute("data-task-id") || selectedTask?.getAttribute("data-rxx-id") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || null,
selectedTaskStatus: selectedTask?.getAttribute("data-task-status") || selectedTask?.querySelector("[data-state]")?.getAttribute("data-state") || null,
sourceSelectVisible: visible(sourceSelect),
fileSelectVisible: visible(fileSelect),
sourceConfigVisible: visible(document.querySelector('[data-testid="mdtodo-source-form-hwpod"], [data-testid="mdtodo-source-config-dialog"], [role="dialog"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"]')),
taskEditorVisible: visible(document.querySelector('[data-testid="mdtodo-edit-title"], [data-testid="mdtodo-edit-body"], [data-testid="mdtodo-body-editor"]')),
taskBodyVisible: visible(bodyRendered),
taskBodyText: visible(bodyRendered) ? text(bodyRendered) : "",
newTaskDraftVisible: visible(document.querySelector('[data-testid="mdtodo-new-title"], [data-testid="mdtodo-new-body"]')),
@@ -1427,7 +1439,7 @@ async function projectManagementCommandSnapshot(options = {}) {
launchButtonVisible: visible(launch),
launchButtonEnabled: visible(launch) && !launch.disabled && launch.getAttribute("aria-disabled") !== "true",
launchButtonText: text(launch),
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8),
blockerTexts: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-launch-blocker"], [data-testid="mdtodo-workbench-launch-error"], [data-testid="mdtodo-create-blocker"], .launch-blocker, [role="alert"]')).filter(visible).map(text).filter(Boolean).slice(0, 8),
workbenchLinkCount: Array.from(document.querySelectorAll('[data-testid="mdtodo-workbench-link-summary"] li, a[href*="/workbench/sessions/"]')).filter(visible).length,
valuesRedacted: true
};
@@ -25,6 +25,36 @@ test("observe command renderer separates control completion from async turn term
assert.match(text, /--view turn-summary --command-id cmd-fixture/u);
});
test("observe command renderer exposes bounded performance capture artifact drill-down", () => {
const rendered = withWebObserveCommandRendered({
ok: true,
status: "control-completed",
command: "web-probe observe command",
id: "webobs-fixture",
commandId: "cmd-performance",
observerCommand: { type: "performanceCapture" },
observer: {
ok: true,
completedAt: "2026-07-13T12:00:00.000Z",
result: {
captureId: "cpu-fixture",
durationMs: 1200,
profileSha256: "sha256:profile",
reportPath: ".state/web-observe/performance/cpu-fixture/summary.json",
reportSha256: "sha256:report",
},
},
control: { executionStatus: "completed", businessTurnTerminalImplied: false },
asyncTurn: { applicable: false, submissionStatus: "not-applicable", terminalStatus: null, terminalObserved: false },
});
const text = String(rendered.renderedText);
assert.match(text, /Performance capture:/u);
assert.match(text, /sha256:profile/u);
assert.match(text, /sha256:report/u);
assert.match(text, /observe collect webobs-fixture --file .*summary\.json/u);
assert.doesNotMatch(text, /nodes.*callFrame/u);
});
test("observe status renderer exposes the exact command phase and failed report evidence", () => {
const rendered = withWebObserveStatusRendered({
ok: true,
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "bun:test";
import { nodeWebObserveStatusNodeScript } from "./web-observe-scripts";
import { nodeWebObserveStatusNodeScript, nodeWebObserveWaitCommandShell } from "./web-observe-scripts";
async function runStatusScript(stateDir: string, commandId: string): Promise<Record<string, any>> {
const child = Bun.spawn(["bash", "-lc", nodeWebObserveStatusNodeScript(1, "NC01", "v03", commandId)], {
@@ -58,6 +58,77 @@ test("observe status returns one exact completed command without prompt payloads
assert.equal(status.exactCommand.valuesRedacted, true);
});
test("observe status summarizes performance capture artifact without embedding CPU profile", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-status-"));
const commandId = "cmd-performance";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "performanceCapture",
completedAt: "2026-07-13T12:00:00.000Z",
result: {
ok: true,
captureId: "cpu-fixture",
durationMs: 1200,
profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] },
artifact: {
path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile",
sha256: "sha256:profile",
summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json",
summarySha256: "sha256:report",
},
},
}));
const status = await runStatusScript(stateDir, commandId);
assert.equal(status.exactCommand.result.captureId, "cpu-fixture");
assert.equal(status.exactCommand.result.profileSha256, "sha256:profile");
assert.equal(status.exactCommand.result.reportSha256, "sha256:report");
assert.equal(JSON.stringify(status).includes("large-profile-payload"), false);
});
test("observe command wait emits bounded performance capture summary", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-performance-wait-"));
const commandId = "cmd-performance";
await mkdir(join(stateDir, "commands", "done"), { recursive: true });
await writeFile(join(stateDir, "commands", "done", `${commandId}.json`), JSON.stringify({
ok: true,
commandId,
type: "performanceCapture",
completedAt: "2026-07-13T12:00:00.000Z",
result: {
ok: true,
captureId: "cpu-fixture",
durationMs: 1200,
profile: { nodes: [{ id: 1, callFrame: { functionName: "large-profile-payload" } }] },
artifact: {
path: ".state/web-observe/performance/cpu-fixture/profile.cpuprofile",
sha256: "sha256:profile",
summaryPath: ".state/web-observe/performance/cpu-fixture/summary.json",
summarySha256: "sha256:report",
},
},
}));
const child = Bun.spawn(["bash", "-lc", nodeWebObserveWaitCommandShell(commandId, 1000)], {
env: { ...process.env, state_dir: stateDir },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
assert.equal(exitCode, 0, stderr);
const summary = JSON.parse(stdout);
assert.equal(summary.status, "done");
assert.equal(summary.result.profileSha256, "sha256:profile");
assert.equal(summary.result.reportSha256, "sha256:report");
assert.equal(stdout.includes("large-profile-payload"), false);
assert.ok(Buffer.byteLength(stdout) < 2048);
});
test("observe status preserves bounded existing-session refresh evidence", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-existing-refresh-status-"));
const commandId = "cmd-existing-refresh";
+14 -3
View File
@@ -101,6 +101,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
const compactTracePhase=(value)=>{const item=value&&typeof value==='object'?value:null;return item?{observed:item.observed??null,stable:item.stable??null,reason:item.reason||null,status:item.status||null,elapsedMs:item.elapsedMs??null,sampleCount:item.sampleCount??null,snapshot:compactTraceSnapshot(item.snapshot),valuesRedacted:true}:null;};
const compactTraceReadabilityEvidence=(value)=>{const item=value&&typeof value==='object'?value:null;if(!item)return{};const scope=item.scope&&typeof item.scope==='object'?item.scope:null;const disclosure=item.disclosure&&typeof item.disclosure==='object'?item.disclosure:null;const retention=item.runningRetention&&typeof item.runningRetention==='object'?item.runningRetention:null;return{startedDuringRunning:item.startedDuringRunning??null,forcedExpansion:item.forcedExpansion??null,scope:scope?{selector:short(scope.selector),conversationCount:scope.conversationCount??null,productCardCount:scope.productCardCount??null,debugPanelCountInsideConversation:scope.debugPanelCountInsideConversation??null,isolatedDebugExcluded:scope.isolatedDebugExcluded??null,valuesRedacted:true}:null,disclosure:disclosure?{openBefore:disclosure.openBefore??null,openAfterExpansion:disclosure.openAfterExpansion??null,openAfterTerminal:disclosure.openAfterTerminal??null,autoReadable:disclosure.autoReadable??null,valuesRedacted:true}:null,running:compactTracePhase(item.running),terminalArrival:compactTracePhase(item.terminalArrival),terminal:compactTracePhase(item.terminal),runningRetention:retention?{mode:retention.mode||null,passed:retention.passed??null,retainedRowCount:retention.retainedRowCount??null,runningEventCount:retention.runningEventCount??null,terminalEventCount:retention.terminalEventCount??null,runningSourceSeqMin:retention.runningSourceSeqMin??null,runningSourceSeqMax:retention.runningSourceSeqMax??null,terminalSourceSeqMin:retention.terminalSourceSeqMin??null,terminalSourceSeqMax:retention.terminalSourceSeqMax??null,valuesRedacted:true}:null,retainedRunningRowCount:item.retainedRunningRowCount??null,failures:Array.isArray(item.failures)?item.failures.slice(0,8).map((failure)=>({code:failure&&failure.code||null,message:short(failure&&failure.message),valuesRedacted:true})):null};};
const compactScreenshot=(value)=>value&&typeof value==='object'?{path:value.path||null,sha256:value.sha256||null,available:value.available??null,valuesRedacted:true}:null;
const compactPerformanceCapture=(value)=>{const item=value&&typeof value==='object'?value:null;const artifact=item&&item.artifact&&typeof item.artifact==='object'?item.artifact:null;if(!item&&!artifact)return{};return{captureId:item&&item.captureId||artifact&&artifact.captureId||null,durationMs:item&&item.durationMs!==undefined?item.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||null,reportSha256:artifact&&artifact.summarySha256||null,valuesRedacted:true};};
const commandSummary=(name)=>{
const root=commandDir(name); let entries=[];
try{entries=fs.readdirSync(root).filter((item)=>item.endsWith('.json')).sort();}catch{}
@@ -115,7 +116,7 @@ export function nodeWebObserveStatusNodeScript(tailLines: number, node: string,
if(!parsed)continue;
const result=parsed.result&&typeof parsed.result==='object'?parsed.result:null;
const error=parsed.error&&typeof parsed.error==='object'?parsed.error:null;
return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,profile:result.profile||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{profile:error.details.profile||null,...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),valuesRedacted:true}:null}:null,valuesRedacted:true};
return {commandId:exactCommandId,phase:bucket,ok:parsed.ok??null,type:parsed.type||null,createdAt:parsed.createdAt||null,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,sessionId:result.sessionId||null,traceId:result.traceId||null,traceIds:Array.isArray(result.traceIds)?result.traceIds.slice(0,4):[],runIds:Array.isArray(result.runIds)?result.runIds.slice(0,4):[],commandIds:Array.isArray(result.commandIds)?result.commandIds.slice(0,4):[],topic:result.topic||null,groupId:result.groupId||null,groupPrefix:result.groupPrefix||null,receivedCount:result.receivedCount??null,appliedCount:result.appliedCount??null,terminalStatus:result.terminalStatus||null,...compactReplayEvidence(result),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(result):{}),...compactTraceReadabilityEvidence(result),warmRunnerReused:result.warmRunnerReused??null,forbiddenRequestCount:result.forbiddenRequestCount??null,replayedEventCount:result.replayedEventCount??null,reportPath:result.reportPath||null,reportSha256:result.reportSha256||null,screenshot:compactScreenshot(result.screenshot),...(parsed.type==='performanceCapture'?compactPerformanceCapture(result):{profile:result.profile||null}),valuesRedacted:true}:null,error:error?{name:error.name||null,message:short(error.message,200),details:error.details&&typeof error.details==='object'?{...compactReplayEvidence(error.details),...(parsed.type==='validateExistingSessionRefresh'?compactExistingSessionRefreshEvidence(error.details):{}),...compactTraceReadabilityEvidence(error.details),sessionId:error.details.sessionId||null,traceId:error.details.traceId||null,traceIds:Array.isArray(error.details.traceIds)?error.details.traceIds.slice(0,4):[],topic:error.details.topic||null,groupId:error.details.groupId||null,receivedCount:error.details.receivedCount??null,appliedCount:error.details.appliedCount??null,terminalStatus:error.details.terminalStatus||null,forbiddenRequestCount:error.details.forbiddenRequestCount??null,reportPath:error.details.reportPath||null,reportSha256:error.details.reportSha256||null,screenshot:compactScreenshot(error.details.screenshot),...(parsed.type==='performanceCapture'?compactPerformanceCapture(error.details):{profile:error.details.profile||null}),valuesRedacted:true}:null}:null,valuesRedacted:true};
}
return {commandId:exactCommandId,phase:'not-found',ok:false,valuesRedacted:true};
};
@@ -425,12 +426,22 @@ export function nodeWebObserveWaitCommandShell(commandId: string, waitMs: number
].join("\n");
}
const waitSeconds = Math.max(1, Math.ceil(waitMs / 1000));
const printCommandSummary = `node -e ${shellQuote(`
const fs=require('fs');
const file=process.argv[1];
const phase=process.argv[2];
const parsed=JSON.parse(fs.readFileSync(file,'utf8'));
const result=parsed&&parsed.result&&typeof parsed.result==='object'?parsed.result:null;
const artifact=result&&result.artifact&&typeof result.artifact==='object'?result.artifact:null;
const summary={ok:parsed.ok!==false,queued:false,commandId:parsed.commandId||parsed.id||null,type:parsed.type||null,status:phase,completedAt:parsed.completedAt||null,failedAt:parsed.failedAt||null,result:result?{ok:result.ok??null,status:result.status||null,captureId:result.captureId||artifact&&artifact.captureId||null,durationMs:result.durationMs!==undefined?result.durationMs:artifact&&artifact.durationMs!==undefined?artifact.durationMs:null,profilePath:artifact&&artifact.path||null,profileSha256:artifact&&artifact.sha256||null,reportPath:artifact&&artifact.summaryPath||result.reportPath||null,reportSha256:artifact&&artifact.summarySha256||result.reportSha256||null,valuesRedacted:true}:null,error:parsed.error?{name:parsed.error.name||null,message:String(parsed.error.message||'').replace(/\\s+/g,' ').slice(0,200),valuesRedacted:true}:null,valuesRedacted:true};
console.log(JSON.stringify(summary));
`)}`;
return [
`command_id=${shellQuote(commandId)}`,
`deadline=$(( $(date +%s) + ${waitSeconds} ))`,
"while [ \"$(date +%s)\" -le \"$deadline\" ]; do",
" if [ -f \"$state_dir/commands/done/${command_id}.json\" ]; then cat \"$state_dir/commands/done/${command_id}.json\"; exit 0; fi",
" if [ -f \"$state_dir/commands/failed/${command_id}.json\" ]; then cat \"$state_dir/commands/failed/${command_id}.json\"; exit 2; fi",
` if [ -f "$state_dir/commands/done/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/done/\${command_id}.json" done; exit 0; fi`,
` if [ -f "$state_dir/commands/failed/\${command_id}.json" ]; then ${printCommandSummary} "$state_dir/commands/failed/\${command_id}.json" failed; exit 2; fi`,
" sleep 1",
"done",
"printf '{\"ok\":true,\"queued\":true,\"waitTimedOut\":true,\"commandId\":\"%s\",\"stateDir\":\"%s\"}\\n' \"$command_id\" \"$state_dir\"",
@@ -110,6 +110,32 @@ test("web observe action JSON recovery accepts concrete start contract", () => {
assert.equal(resolved.diagnostics.stdoutContractAccepted, true);
});
test("web observe action JSON recovery accepts bounded performance capture command summary", () => {
const resolved = resolveWebObserveActionJson({
stdout: JSON.stringify({
ok: true,
queued: false,
commandId: "cmd-performance",
type: "performanceCapture",
status: "done",
result: {
captureId: "cpu-fixture",
profileSha256: "sha256:profile",
reportSha256: "sha256:report",
valuesRedacted: true,
},
valuesRedacted: true,
}),
stderr: "",
exitCode: 0,
timedOut: false,
}, "command");
assert.equal(resolved.source, "stdout");
assert.equal(resolved.parsed?.commandId, "cmd-performance");
assert.equal(resolved.diagnostics.stdoutContractAccepted, true);
});
test("web observe action JSON recovery reads dump wrapper for status contract", async () => {
const dir = await mkdtemp(join(tmpdir(), "unidesk-web-observe-status-dump-"));
const dumpPath = join(dir, "status.json");
@@ -0,0 +1,87 @@
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import {
collectMdtodoSafeTitleStdinCapability,
renderMdtodoSafeTitleCommand,
} from "../../src/components/microservices/code-queue/src/skill-availability";
function writeFixture(root: string, stdinReady: boolean): void {
const scripts = join(root, "mdtodo-edit", "scripts");
mkdirSync(scripts, { recursive: true });
const option = stdinReady ? " --stdin" : " title";
const script = `#!/usr/bin/env python3\nimport sys\nprint("usage: mdtodo " + sys.argv[1] + "\\n${option}")\n`;
const path = join(scripts, "mdtodo-edit-cli.py");
writeFileSync(path, script);
chmodSync(path, 0o755);
}
describe("MDTODO safe title stdin adoption", () => {
test("reports stale and ready source/target capability without blocking", () => {
const temporary = mkdtempSync("/tmp/unidesk-mdtodo-safe-input-");
try {
const source = join(temporary, "source");
const staleSource = join(temporary, "stale-source");
const staleTarget = join(temporary, "stale-target");
const readyTarget = join(temporary, "ready-target");
writeFixture(source, true);
writeFixture(staleSource, false);
writeFixture(staleTarget, false);
writeFixture(readyTarget, true);
expect(collectMdtodoSafeTitleStdinCapability(source, staleTarget)).toMatchObject({
nonBlocking: true,
ready: false,
freshness: "target-stale",
source: { ready: true },
target: { ready: false },
});
expect(collectMdtodoSafeTitleStdinCapability(source, readyTarget)).toMatchObject({
nonBlocking: true,
ready: true,
freshness: "ready",
warning: null,
});
expect(collectMdtodoSafeTitleStdinCapability(staleSource, readyTarget)).toMatchObject({
nonBlocking: true,
ready: true,
freshness: "source-stale",
source: { ready: false },
target: { ready: true },
warning: expect.stringContaining("approved source"),
});
} finally {
rmSync(temporary, { recursive: true, force: true });
}
});
test("keeps Markdown and shell syntax in quoted heredoc stdin", () => {
const temporary = mkdtempSync("/tmp/unidesk-mdtodo-command-");
try {
const home = join(temporary, "home");
const scripts = join(home, ".agents", "skills", "mdtodo-edit", "scripts");
const output = join(temporary, "title.txt");
const forbidden = join(temporary, "forbidden");
mkdirSync(scripts, { recursive: true });
writeFileSync(join(scripts, "mdtodo-edit-cli.py"), [
"import os, sys",
"open(os.environ['TITLE_OUTPUT'], 'w', encoding='utf-8').write(sys.stdin.read())",
].join("\n"));
const title = `修复 \`path\`$(touch ${forbidden}) 的 'quoted' 标题`;
const command = renderMdtodoSafeTitleCommand("docs/MDTODO/demo file.md", "add").replace("<title>", title);
expect(command).toContain("'docs/MDTODO/demo file.md'");
expect(command).toContain("--stdin <<'EOF'");
expect(command.split("--stdin", 1)[0]).not.toContain(title);
const result = Bun.spawnSync(["bash", "-c", command], {
env: { ...process.env, HOME: home, TITLE_OUTPUT: output },
});
expect(result.exitCode).toBe(0);
expect(readFileSync(output, "utf8")).toBe(`${title}\n`);
expect(existsSync(forbidden)).toBe(false);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test";
import {
githubTokenEnvNameForSecretKey,
readGiteaConfig,
resolveTarget,
} from "./platform-infra-gitea-config";
import { buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea";
import { renderMirrorWebhookCredentialBlocked } from "./platform-infra-gitea-render";
function syntheticMissingSelfmediaCredential(): Record<string, unknown> {
return {
id: "github-upstream:selfmedia-nc01",
repository: "pikainc/selfmedia",
sourceRef: "pikainc-selfmedia-gh-token.txt",
present: false,
requiredKeysPresent: false,
fingerprint: null,
permissionResult: {
status: "blocked-missing-credential",
permissions: { contents: "read", metadata: "read", webhooks: "read-write" },
error: "credential material is absent",
},
valuesPrinted: false,
};
}
function syntheticBlockedResult(allTargetRepositories = false): Record<string, unknown> {
const config = readGiteaConfig();
const target = resolveTarget(config, "NC01");
const repositories = config.sourceAuthority.repositories.filter((repo) => repo.targetId === "NC01"
&& (allTargetRepositories || repo.key === "selfmedia-nc01"));
const credential = syntheticMissingSelfmediaCredential();
return buildMirrorWebhookCredentialBlockedResult(config, target, repositories, [credential], [String(credential.id)]);
}
describe("platform-infra Gitea repository GitHub credentials", () => {
test("keeps the global credential and selfmedia override on distinct Secret keys", () => {
const config = readGiteaConfig();
const globalCredential = config.sourceAuthority.credentials.github;
const selfmedia = config.sourceAuthority.repositories.find((repo) => repo.key === "selfmedia-nc01");
const override = selfmedia?.credentialOverride?.github;
expect(override?.sourceRef).toBe("pikainc-selfmedia-gh-token.txt");
expect(override?.permissions).toEqual({ contents: "read", metadata: "read", webhooks: "read-write" });
expect(override?.gitFetchCredential.secretRef.key).not.toBe(globalCredential.gitFetchCredential.secretRef.key);
expect(githubTokenEnvNameForSecretKey(override?.gitFetchCredential.secretRef.key ?? ""))
.not.toBe(githubTokenEnvNameForSecretKey(globalCredential.gitFetchCredential.secretRef.key));
});
test("normalizes Secret keys to bounded environment variable names", () => {
expect(githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"))
.toBe("UNIDESK_GITEA_GITHUB_TOKEN_GITHUB_TOKEN_PIKAINC_SELFMEDIA");
});
test("keeps repository credential overrides scoped to their configured target", () => {
const config = readGiteaConfig();
const jd01Overrides = config.sourceAuthority.repositories
.filter((repo) => repo.targetId === "JD01")
.flatMap((repo) => repo.credentialOverride?.github ?? []);
const nc01Overrides = config.sourceAuthority.repositories
.filter((repo) => repo.targetId === "NC01")
.flatMap((repo) => repo.credentialOverride?.github ?? []);
expect(jd01Overrides).toHaveLength(0);
expect(nc01Overrides.map((credential) => credential.sourceRef)).toContain("pikainc-selfmedia-gh-token.txt");
});
test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => {
const config = readGiteaConfig();
const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia");
const globalTokenEnv = githubTokenEnvNameForSecretKey(config.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key);
const jd01Manifest = renderManifest(config, resolveTarget(config, "JD01"));
const nc01Manifest = renderManifest(config, resolveTarget(config, "NC01"));
expect(jd01Manifest).toContain(globalTokenEnv);
expect(jd01Manifest).not.toContain(selfmediaTokenEnv);
expect(nc01Manifest).toContain(globalTokenEnv);
expect(nc01Manifest).toContain(selfmediaTokenEnv);
expect(nc01Manifest).toContain(`"tokenEnv": "${selfmediaTokenEnv}"`);
});
test("keeps missing selfmedia webhook status bounded and stack-free", () => {
const payload = syntheticBlockedResult();
const credentials = payload.credentials as Array<Record<string, unknown>>;
const selfmedia = credentials[0];
const serialized = JSON.stringify(payload);
expect((payload.error as Record<string, unknown>).code).toBe("github-credential-unavailable");
expect(selfmedia).toMatchObject({
sourceRef: "pikainc-selfmedia-gh-token.txt",
present: false,
fingerprint: null,
valuesPrinted: false,
});
expect(selfmedia).not.toHaveProperty("sourcePath");
expect(serialized).not.toContain('"stack"');
expect(serialized).not.toContain("/root/.worktrees/");
});
test("renders missing selfmedia webhook status as compact text by default", () => {
const stdout = renderMirrorWebhookCredentialBlocked(syntheticBlockedResult()).renderedText;
expect(stdout).toContain("PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS");
expect(stdout).toContain("SOURCE_REF");
expect(stdout).toContain("pikainc-selfmedia-gh-token.txt");
expect(stdout).toContain("PERMISSION_RESULT");
expect(stdout).toContain("github-upstream:selfmedia-nc01");
expect(stdout.trimStart()).not.toStartWith("{");
expect(stdout).not.toContain('"stack"');
});
test("matches compact blocker rows to their repository without --repo", () => {
const stdout = renderMirrorWebhookCredentialBlocked(syntheticBlockedResult(true)).renderedText;
expect(stdout).toContain("pikainc/selfmedia");
expect(stdout).toContain("pikainc-selfmedia-gh-token.txt");
expect(stdout).toContain("github-upstream:selfmedia-nc01");
expect(stdout).not.toContain("pikasTech/agentrun");
expect(stdout.trimStart()).not.toStartWith("{");
});
});
@@ -160,6 +160,16 @@ export interface GiteaGithubCredential {
};
}
export interface GiteaGithubPermissions {
contents: "read";
metadata: "read";
webhooks: "read-write";
}
export function githubTokenEnvNameForSecretKey(secretKey: string): string {
return `UNIDESK_GITEA_GITHUB_TOKEN_${secretKey.replace(/[^A-Za-z0-9_]/gu, "_").toUpperCase()}`;
}
export interface GiteaWebhookSync {
enabled: boolean;
direction: "github-to-gitea";
@@ -273,6 +283,9 @@ export interface GiteaSourceResponsibility {
export interface GiteaMirrorRepository {
key: string;
targetId: string;
credentialOverride: {
github: GiteaGithubCredential & { permissions: GiteaGithubPermissions };
} | null;
upstream: {
repository: string;
cloneUrl: string;
@@ -657,9 +670,28 @@ function parseMirrorRepository(record: Record<string, unknown>, index: number):
const legacyGitMirror = record.legacyGitMirror === null || record.legacyGitMirror === undefined
? null
: y.objectField(record, "legacyGitMirror", path);
const credentialOverride = record.credentialOverride === undefined
? null
: y.objectField(record, "credentialOverride", path);
const githubOverride = credentialOverride === null
? null
: y.objectField(credentialOverride, "github", `${path}.credentialOverride`);
const githubPermissions = githubOverride === null
? null
: y.objectField(githubOverride, "permissions", `${path}.credentialOverride.github`);
return {
key: y.stringField(record, "key", path),
targetId: y.stringField(record, "targetId", path),
credentialOverride: githubOverride === null || githubPermissions === null ? null : {
github: {
...parseGithubCredential(githubOverride, `${path}.credentialOverride.github`),
permissions: {
contents: y.enumField(githubPermissions, "contents", `${path}.credentialOverride.github.permissions`, ["read"] as const),
metadata: y.enumField(githubPermissions, "metadata", `${path}.credentialOverride.github.permissions`, ["read"] as const),
webhooks: y.enumField(githubPermissions, "webhooks", `${path}.credentialOverride.github.permissions`, ["read-write"] as const),
},
},
},
upstream: {
repository: repositoryField(upstream, "repository", `${path}.upstream`),
cloneUrl: gitCloneUrlField(upstream, "cloneUrl", `${path}.upstream`),
@@ -738,6 +770,35 @@ function validateConfig(gitea: GiteaConfig): void {
if (gitea.targets.some((target) => target.enabled && target.namespace !== gitFetchCredential.secretRef.namespace)) {
throw new Error(`${configLabel}.sourceAuthority.credentials.github.gitFetchCredential.secretRef.namespace must match every enabled Gitea target namespace`);
}
const credentialKeys = new Set([gitFetchCredential.secretRef.key]);
const credentialEnvNames = new Set([githubTokenEnvNameForSecretKey(gitFetchCredential.secretRef.key)]);
for (const repo of gitea.sourceAuthority.repositories) {
const override = repo.credentialOverride?.github;
if (override === undefined) continue;
if (!override.requiredFor.includes("managed-repository-fetch")
|| !override.requiredFor.includes("github-head-observe")
|| !override.requiredFor.includes("github-hooks-list")
|| !override.requiredFor.includes("github-hooks-reconcile")) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.requiredFor must cover fetch, head observation and hook reconciliation`);
}
if (override.gitFetchCredential.secretRef.name !== gitea.sourceAuthority.webhookSync.bridge.secretName) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.name must match sourceAuthority.webhookSync.bridge.secretName`);
}
const target = resolveTarget(gitea, repo.targetId);
if (override.gitFetchCredential.secretRef.namespace !== target.namespace) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.namespace must match repository target namespace`);
}
const key = override.gitFetchCredential.secretRef.key;
if (!/^[A-Za-z0-9._-]+$/u.test(key) || credentialKeys.has(key)) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key must be a unique Kubernetes Secret data key`);
}
credentialKeys.add(key);
const envName = githubTokenEnvNameForSecretKey(key);
if (credentialEnvNames.has(envName)) {
throw new Error(`${configLabel}.sourceAuthority.repositories.${repo.key}.credentialOverride.github.gitFetchCredential.secretRef.key collides after environment variable normalization`);
}
credentialEnvNames.add(envName);
}
if (!gitea.sourceAuthority.enabled) throw new Error(`${configLabel}.sourceAuthority.enabled must be true for GH-1550`);
if (gitea.sourceAuthority.repositories.length < 1) throw new Error(`${configLabel}.sourceAuthority.repositories must not be empty`);
if (!/^docker\.gitea\.com\/gitea$/u.test(gitea.app.image.repository)) throw new Error(`${configLabel}.app.image.repository must use the official Gitea image registry`);
@@ -751,13 +751,13 @@ export async function syncRepository(repo, delivery, runtime) {
let requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]);
if (requestedObject.status !== 0) {
const branchFetch = await executeResult([
"git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`,
"git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeaders[repo.key]}`,
"fetch", "--no-tags", repo.upstream.cloneUrl,
`+refs/heads/${repo.upstream.branch}:refs/remotes/github/${repo.upstream.branch}`,
]);
requestedObject = await executeResult(["git", "-C", work, "cat-file", "-e", `${delivery.requestedCommit}^{commit}`]);
if (requestedObject.status !== 0) {
const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeader}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]);
const exactFetch = await executeResult(["git", "-C", work, "-c", `http.extraHeader=${runtime.githubAuthHeaders[repo.key]}`, "fetch", "--no-tags", repo.upstream.cloneUrl, `${delivery.requestedCommit}:refs/remotes/github/delivery`]);
if (exactFetch.status !== 0) {
return {
ok: false,
@@ -931,14 +931,19 @@ export function retryDelayMs(syncAttempt, initialDelayMs, maxDelayMs) {
}
export function loadRuntimeConfig() {
const githubToken = requiredEnv("GITHUB_TOKEN");
const giteaUsername = requiredEnv("GITEA_USERNAME");
const giteaPassword = requiredEnv("GITEA_PASSWORD");
const repos = JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8"));
const defaultTokenEnv = requiredEnv("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV");
const githubAuthHeaders = Object.fromEntries(repos.map((repo) => {
const token = requiredEnv(repo.githubCredential?.tokenEnv || defaultTokenEnv);
return [repo.key, `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`];
}));
return {
port: Number.parseInt(process.env.UNIDESK_GITEA_WEBHOOK_PORT || "8080", 10),
path: process.env.UNIDESK_GITEA_WEBHOOK_PATH || "/_unidesk/github-to-gitea",
responseBudgetMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RESPONSE_BUDGET_MS"),
repos: JSON.parse(readFileSync(process.env.UNIDESK_GITEA_REPOS_PATH || "/etc/gitea-github-sync/repos.json", "utf8")),
repos,
webhookSecret: requiredEnv("GITHUB_WEBHOOK_SECRET"),
giteaBaseUrl: requiredEnv("UNIDESK_GITEA_SERVICE_BASE_URL").replace(/\/+$/u, ""),
inbox: {
@@ -958,7 +963,7 @@ export function loadRuntimeConfig() {
},
shutdownGraceMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS"),
maxBodyBytes: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_MAX_BODY_BYTES"),
githubAuthHeader: `Authorization: Basic ${Buffer.from(`x-access-token:${githubToken}`).toString("base64")}`,
githubAuthHeaders,
giteaAuthHeader: `Authorization: Basic ${Buffer.from(`${giteaUsername}:${giteaPassword}`).toString("base64")}`,
log,
run,
@@ -7,6 +7,14 @@ import { renderGiteaRemotePayloadMaterializer, summarizeGiteaRemotePayloads } fr
import { renderGiteaWebhookSyncDesiredManifest } from "./platform-infra-gitea";
const remoteScriptPath = resolve(import.meta.dir, "platform-infra-gitea-remote.sh");
const orchestrationPath = resolve(import.meta.dir, "platform-infra-gitea.ts");
function functionSource(source: string, name: string, nextName: string): string {
const start = source.indexOf(`${name}() {`);
const end = source.indexOf(`\n${nextName}() {`, start);
if (start < 0 || end < 0) throw new Error(`missing shell function ${name}`);
return source.slice(start, end);
}
test("stdin file envelope materializes a payload larger than the current Gitea manifest without argv or env", () => {
const currentManifest = renderGiteaWebhookSyncDesiredManifest("NC01");
@@ -68,3 +76,68 @@ test("Gitea remote runner consumes materialized files instead of base64 environm
source.lastIndexOf('timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts'),
);
});
test("mirror bootstrap materializes every selected GitHub credential key before repository mutation", () => {
const source = readFileSync(remoteScriptPath, "utf8");
const orchestration = readFileSync(orchestrationPath, "utf8");
const upsert = functionSource(source, "upsert_webhook_sync_secret", "run_apply");
const bootstrap = functionSource(source, "run_mirror_bootstrap", "run_mirror_sync");
expect(bootstrap.indexOf("upsert_webhook_sync_secret")).toBeLessThan(bootstrap.indexOf('pod="$(kubectl'));
expect(bootstrap).toContain('"apiBootstrap": {"exitCode": None, "skipped": True}');
expect(bootstrap).toContain("return 1");
expect(orchestration).toContain("ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, true, true)");
expect(orchestration).toContain("Object.keys(params.secrets.githubTokens)");
expect(orchestration).toContain("env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP");
const result = spawnSync("sh", ["-s"], {
encoding: "utf8",
input: [
"set -u",
'tmp="$(mktemp -d)"',
'trap \'rm -rf "$tmp"\' EXIT',
'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager',
'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"',
'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook',
'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; printf "%s\\n" "$*" >>"$tmp/kubectl.log"; return 0; fi; return 1; }',
upsert,
"upsert_webhook_sync_secret",
'cat "$tmp/kubectl.log"',
].join("\n"),
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("--from-file=github-token=");
expect(result.stdout).toContain("--from-file=github-token-pikainc-selfmedia=");
expect(result.stdout).not.toContain("fixture-global");
expect(result.stdout).not.toContain("fixture-selfmedia");
const failed = spawnSync("sh", ["-s"], {
encoding: "utf8",
input: [
"set -u",
'tmp="$(mktemp -d)"',
'trap \'rm -rf "$tmp"\' EXIT',
'export tmp UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED=1 UNIDESK_GITEA_DRY_RUN=0 UNIDESK_GITEA_NAMESPACE=devops-infra UNIDESK_GITEA_WEBHOOK_SECRET_NAME=gitea-github-sync-secrets UNIDESK_GITEA_FIELD_MANAGER=test-manager UNIDESK_GITEA_TARGET_ID=NC01',
'export UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP="github-token=GLOBAL_TOKEN,github-token-pikainc-selfmedia=SELFMEDIA_TOKEN"',
'export GLOBAL_TOKEN=fixture-global SELFMEDIA_TOKEN=fixture-selfmedia UNIDESK_GITEA_ADMIN_USERNAME=fixture-admin UNIDESK_GITEA_ADMIN_PASSWORD=fixture-password UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET=fixture-webhook',
'mirror_repos_json() { printf "%s\\n" "[]" >"$tmp/repos.json"; }',
'kubectl() { if [ "$1" = "-n" ] && [ "$3" = "create" ]; then printf "%s\\n" "apiVersion: v1" "kind: Secret"; return 0; fi; if [ "$1" = "apply" ]; then cat >/dev/null; return 1; fi; touch "$tmp/unexpected-mutation"; return 1; }',
upsert,
bootstrap,
"run_mirror_bootstrap >\"$tmp/result.json\"",
'rc=$?',
'python3 - "$tmp/result.json" "$tmp/unexpected-mutation" "$rc" <<\'PY\'',
"import json, pathlib, sys",
"payload=json.load(open(sys.argv[1], encoding='utf-8'))",
"assert int(sys.argv[3]) == 1",
"assert payload['steps']['apiBootstrap']['skipped'] is True",
"assert payload['valuesPrinted'] is False",
"assert not pathlib.Path(sys.argv[2]).exists()",
"print('bootstrap-secret-fail-closed')",
"PY",
].join("\n"),
});
expect(failed.status).toBe(0);
expect(failed.stdout).toContain("bootstrap-secret-fail-closed");
expect(failed.stdout).not.toContain("fixture-global");
expect(failed.stdout).not.toContain("fixture-selfmedia");
});
+96 -45
View File
@@ -32,6 +32,49 @@ capture_json() {
printf '%s' "$rc" >"$tmp/$name.rc"
}
upsert_webhook_sync_secret() {
webhook_secret_rc=0
: >"$tmp/webhook-secret.out"
: >"$tmp/webhook-secret.err"
if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" != "1" ]; then
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err"
return 0
fi
if [ "$UNIDESK_GITEA_DRY_RUN" = "1" ]; then
printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err"
return 0
fi
github_secret_args=""
old_ifs="$IFS"
IFS=','
for mapping in $UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP; do
secret_key=${mapping%%=*}
env_name=${mapping#*=}
eval "secret_value=\${$env_name-}"
if [ -z "$secret_value" ]; then
printf '%s\n' "missing GitHub credential environment for Secret key $secret_key" >"$tmp/webhook-secret.err"
webhook_secret_rc=1
break
fi
secret_file="$tmp/github-secret-$secret_key"
printf '%s' "$secret_value" >"$secret_file"
chmod 600 "$secret_file"
github_secret_args="$github_secret_args --from-file=$secret_key=$secret_file"
done
IFS="$old_ifs"
if [ "$webhook_secret_rc" -ne 0 ]; then
return "$webhook_secret_rc"
fi
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \
$github_secret_args \
--from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \
--from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \
--from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err"
webhook_secret_rc=$?
return "$webhook_secret_rc"
}
run_apply() {
manifest="$tmp/gitea.k8s.yaml"
if [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
@@ -51,23 +94,8 @@ run_apply() {
printf '%s\n' "frpc disabled" >"$tmp/frpc-secret.err"
fi
fi
if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ] && [ "$UNIDESK_GITEA_DRY_RUN" != "1" ]; then
kubectl -n "$UNIDESK_GITEA_NAMESPACE" create secret generic "$UNIDESK_GITEA_WEBHOOK_SECRET_NAME" \
--from-literal="$UNIDESK_GITEA_GITHUB_SECRET_KEY=$UNIDESK_GITEA_GITHUB_TOKEN" \
--from-literal=gitea-username="$UNIDESK_GITEA_ADMIN_USERNAME" \
--from-literal=gitea-password="$UNIDESK_GITEA_ADMIN_PASSWORD" \
--from-literal=github-webhook-secret="$UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET" \
--dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f - >"$tmp/webhook-secret.out" 2>"$tmp/webhook-secret.err"
webhook_secret_rc=$?
else
webhook_secret_rc=0
: >"$tmp/webhook-secret.out"
if [ "$UNIDESK_GITEA_WEBHOOK_SYNC_ENABLED" = "1" ]; then
printf '%s\n' "dry-run: webhook sync secret apply skipped" >"$tmp/webhook-secret.err"
else
printf '%s\n' "webhook sync disabled" >"$tmp/webhook-secret.err"
fi
fi
upsert_webhook_sync_secret
webhook_secret_rc=$?
timeout "$UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS" kubectl apply --server-side --force-conflicts --dry-run=server \
--field-manager="$UNIDESK_GITEA_FIELD_MANAGER" -f "$manifest" >"$tmp/server-dry-run.out" 2>"$tmp/server-dry-run.err"
server_dry_run_rc=$?
@@ -389,6 +417,32 @@ PY
run_mirror_bootstrap() {
mirror_repos_json
upsert_webhook_sync_secret
webhook_secret_rc=$?
if [ "$webhook_secret_rc" -ne 0 ]; then
python3 - "$webhook_secret_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" <<'PY'
import json, os, sys
def text(path, limit=1600):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
payload = {
"ok": False,
"target": os.environ.get("UNIDESK_GITEA_TARGET_ID"),
"namespace": os.environ.get("UNIDESK_GITEA_NAMESPACE"),
"steps": {
"webhookSyncSecret": {"exitCode": int(sys.argv[1]), "stdoutTail": text(sys.argv[2]), "stderrTail": text(sys.argv[3])},
"apiBootstrap": {"exitCode": None, "skipped": True},
},
"repositories": [],
"valuesPrinted": False,
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(1)
PY
return 1
fi
pod="$(kubectl -n "$UNIDESK_GITEA_NAMESPACE" get pod -l "app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" -o jsonpath='{.items[0].metadata.name}' 2>"$tmp/pod.err")"
create_rc=1
change_rc=1
@@ -470,26 +524,27 @@ json.dump(payload, open(out_path, "w", encoding="utf-8"), ensure_ascii=False, in
sys.exit(0 if ok else 1)
PY
api_rc=$?
python3 - "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY'
python3 - "$webhook_secret_rc" "$create_rc" "$change_rc" "$unset_rc" "$api_rc" "$tmp/webhook-secret.out" "$tmp/webhook-secret.err" "$tmp/admin-create.out" "$tmp/admin-create.err" "$tmp/admin-pass.out" "$tmp/admin-pass.err" "$tmp/admin-unset.out" "$tmp/admin-unset.err" "$tmp/api.json" <<'PY'
import json, sys
def text(path, limit=1600):
try:
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
except FileNotFoundError:
return ""
create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:5])
webhook_secret_rc, create_rc, change_rc, unset_rc, api_rc = map(int, sys.argv[1:6])
try:
api = json.load(open(sys.argv[11], encoding="utf-8"))
api = json.load(open(sys.argv[14], encoding="utf-8"))
except Exception:
api = {}
payload = {
"ok": create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0,
"ok": webhook_secret_rc == 0 and create_rc == 0 and change_rc == 0 and unset_rc == 0 and api_rc == 0,
"target": __import__("os").environ.get("UNIDESK_GITEA_TARGET_ID"),
"namespace": __import__("os").environ.get("UNIDESK_GITEA_NAMESPACE"),
"steps": {
"adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[5]), "stderrTail": text(sys.argv[6])},
"adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[7]), "stderrTail": text(sys.argv[8])},
"adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[9]), "stderrTail": text(sys.argv[10])},
"webhookSyncSecret": {"exitCode": webhook_secret_rc, "stdoutTail": text(sys.argv[6]), "stderrTail": text(sys.argv[7])},
"adminCreate": {"exitCode": create_rc, "stdoutTail": text(sys.argv[8]), "stderrTail": text(sys.argv[9])},
"adminPassword": {"exitCode": change_rc, "stdoutTail": text(sys.argv[10]), "stderrTail": text(sys.argv[11])},
"adminMustChangePasswordUnset": {"exitCode": unset_rc, "stdoutTail": text(sys.argv[12]), "stderrTail": text(sys.argv[13])},
"apiBootstrap": {"exitCode": api_rc},
},
"api": api,
@@ -518,11 +573,12 @@ for i, repo in enumerate(repos):
sync_gitops_from_upstream = repo["gitops"].get("flushDisposition") != "gitea-writeback"
branches = [source_branch] + ([] if not sync_gitops_from_upstream or gitops_branch == source_branch or gitops_branch == "not-a-gitops-branch" else [gitops_branch])
work = f"$tmp/repo-{i}.git"
token_env = (repo.get("githubCredential") or {}).get("tokenEnv") or os.environ.get("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV", "")
gitea_write_url = base_url + urllib.parse.urlparse(repo["gitea"]["readUrl"]).path
script += [
f"mkdir -p {shlex.quote(work)}",
f"git init --bare {shlex.quote(work)} >$tmp/{key}.init.out 2>$tmp/{key}.init.err; printf '%s' \"$?\" >$tmp/{key}.init.rc",
f"git -C {shlex.quote(work)} -c http.extraHeader=\"$GITHUB_AUTH_HEADER\" fetch --prune {shlex.quote(repo['upstream']['cloneUrl'])} " + " ".join(shlex.quote(f"+refs/heads/{b}:refs/remotes/github/{b}") for b in branches) + f" >$tmp/{key}.fetch.out 2>$tmp/{key}.fetch.err; fetch_rc=$?; printf '%s' \"$fetch_rc\" >$tmp/{key}.fetch.rc",
f"repo_token=$(printenv {shlex.quote(token_env)}); repo_basic=$(printf 'x-access-token:%s' \"$repo_token\" | base64 | tr -d '\\n'); git -C {shlex.quote(work)} -c http.extraHeader=\"Authorization: Basic $repo_basic\" fetch --prune {shlex.quote(repo['upstream']['cloneUrl'])} " + " ".join(shlex.quote(f"+refs/heads/{b}:refs/remotes/github/{b}") for b in branches) + f" >$tmp/{key}.fetch.out 2>$tmp/{key}.fetch.err; fetch_rc=$?; unset repo_token repo_basic; printf '%s' \"$fetch_rc\" >$tmp/{key}.fetch.rc",
f"if [ \"$fetch_rc\" -eq 0 ]; then sha=$(git -C {shlex.quote(work)} rev-parse refs/remotes/github/{shlex.quote(source_branch)} 2>$tmp/{key}.revparse.err); revparse_rc=$?; printf '%s\\n' \"$sha\" >$tmp/{key}.revparse.out; else sha=''; revparse_rc=1; : >$tmp/{key}.revparse.out; printf '%s\\n' 'fetch failed; revparse skipped' >$tmp/{key}.revparse.err; fi; printf '%s' \"$revparse_rc\" >$tmp/{key}.revparse.rc",
f"if [ \"$revparse_rc\" -eq 0 ]; then snapshot_ref={shlex.quote(repo['snapshot']['prefix'])}/$sha; git -C {shlex.quote(work)} -c http.extraHeader=\"$GITEA_AUTH_HEADER\" push --force {shlex.quote(gitea_write_url)} $sha:refs/heads/{shlex.quote(source_branch)} $sha:$snapshot_ref >$tmp/{key}.push.out 2>$tmp/{key}.push.err; push_rc=$?; else snapshot_ref=''; push_rc=1; : >$tmp/{key}.push.out; printf '%s\\n' 'revparse failed; push skipped' >$tmp/{key}.push.err; fi; printf '%s' \"$push_rc\" >$tmp/{key}.push.rc",
]
@@ -537,15 +593,8 @@ for i, repo in enumerate(repos):
open(sys.argv[2], "w", encoding="utf-8").write("\n".join(script) + "\n")
json.dump(meta, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False, indent=2)
PY
github_basic="$(python3 - <<'PY'
import base64, os
raw = f"x-access-token:{os.environ['UNIDESK_GITEA_GITHUB_TOKEN']}".encode()
print(base64.b64encode(raw).decode())
PY
)"
GITHUB_AUTH_HEADER="Authorization: Basic $github_basic"
GITEA_AUTH_HEADER="Authorization: Basic $basic"
export GITHUB_AUTH_HEADER GITEA_AUTH_HEADER
export GITEA_AUTH_HEADER
unset GIT_SSH GIT_SSH_COMMAND SSH_AUTH_SOCK
export GIT_TERMINAL_PROMPT=0
export GIT_CONFIG_NOSYSTEM=1
@@ -685,7 +734,6 @@ run_mirror_webhook_apply() {
python3 - "$tmp/repos.json" <<'PY'
import json, os, sys, urllib.error, urllib.request
repos = json.load(open(sys.argv[1], encoding="utf-8"))
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
secret = os.environ["UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET"]
events = [item for item in os.environ.get("UNIDESK_GITEA_WEBHOOK_EVENTS", "push").split(",") if item]
@@ -707,6 +755,8 @@ def request(method, api_path, payload=None, expected=(200, 201, 204), tolerate=(
rows = []
for repo in repos:
repository = repo["upstream"]["repository"]
token_env = (repo.get("githubCredential") or {}).get("tokenEnv") or os.environ["UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV"]
token = os.environ[token_env]
list_result = request("GET", f"/repos/{repository}/hooks")
hooks = []
try:
@@ -757,7 +807,7 @@ NODE
python3 - "$tmp/repos.json" "$bridge_ready" "$service_exists" "$mirror_status_rc" "$bridge_logs_rc" "$inbox_status_rc" "$tmp/mirror-status.json" "$tmp/mirror-status.err" "$tmp/bridge.log" "$tmp/bridge.err" "$tmp/inbox-status.json" "$tmp/inbox-status.err" "$tmp/hook-topology.json" <<'PY'
import hashlib, json, os, sys, urllib.error, urllib.parse, urllib.request
sys.path.insert(0, os.environ["tmp"])
from platform_infra_gitea_status_evaluator import evaluate_repository, select_committed_head_record, select_target_delivery
from platform_infra_gitea_status_evaluator import evaluate_repository, github_hook_url, parse_github_hooks_response, select_committed_head_record, select_target_delivery
repos = json.load(open(sys.argv[1], encoding="utf-8"))
bridge_ready = sys.argv[2]
service_exists = bool(sys.argv[3])
@@ -771,7 +821,6 @@ bridge_log_err_path = sys.argv[10]
inbox_status_path = sys.argv[11]
inbox_status_err_path = sys.argv[12]
hook_topology_path = sys.argv[13]
token = os.environ["UNIDESK_GITEA_GITHUB_TOKEN"]
url = os.environ["UNIDESK_GITEA_WEBHOOK_PUBLIC_URL"]
try:
hook_topology = json.load(open(hook_topology_path, encoding="utf-8"))
@@ -783,12 +832,15 @@ topology_repositories = hook_topology.get("repositories") if isinstance(hook_top
def url_hash(value):
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
def hook_url(item):
config = item.get("config") if isinstance(item.get("config"), dict) else {}
return config.get("url") if isinstance(config.get("url"), str) else ""
return github_hook_url(item)
def is_managed_url(value):
return value == managed_url_prefix or value.startswith(managed_url_prefix.rstrip("/") + "/")
def hook_config_matches(item):
config = item.get("config") if isinstance(item.get("config"), dict) else {}
config = item.get("config")
if isinstance(config, str):
return item.get("name") == "web" and item.get("active") is True and set(item.get("events") or []) == set(desired_events)
if not isinstance(config, dict):
return False
return item.get("name") == "web" and item.get("active") is True and set(item.get("events") or []) == set(desired_events) and config.get("content_type") == "json" and str(config.get("insecure_ssl", "0")) == "0"
def topology_observation(repository, hooks):
spec = next((item for item in topology_repositories if isinstance(item, dict) and item.get("repository") == repository), {})
@@ -987,13 +1039,12 @@ for line in text(bridge_log_path, 12000).splitlines():
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"]]
branch = repo["upstream"]["branch"]
result = request(f"/repos/{repository}/hooks")
hooks = []
try:
hooks = json.loads(result.get("body") or "[]")
except Exception:
hooks = []
hooks_result = parse_github_hooks_response(result.get("ok"), result.get("status"), result.get("body"))
hooks = hooks_result["hooks"]
matches = [item for item in hooks if hook_url(item) == url]
topology = topology_observation(repository, hooks)
head = github_head(repository, branch)
@@ -1044,7 +1095,7 @@ for repo in repos:
"latest": latest_delivery,
"latestPush": latest_push_delivery,
"selectedPush": selected_push_delivery,
"error": result.get("body", "")[:500] if not result.get("ok") else (delivery_errors[0] if delivery_errors else topology_reason),
"error": hooks_result["error"] or (delivery_errors[0] if delivery_errors else topology_reason),
}
rows.append(evaluate_repository(repo, hook, head, mirror, bridge_events, inbox_records, inbox_status.get("journal")))
bridge = {
@@ -107,6 +107,44 @@ export function renderMirrorWebhookApply(result: Record<string, unknown>): Rende
]);
}
export function renderMirrorWebhookCredentialBlocked(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const error = record(result.error);
const blockerIds = Array.isArray(error.blockers) ? error.blockers.map(String) : [];
const repositories = arrayRecords(result.repositories);
const credentials = arrayRecords(result.credentials);
const rows = blockerIds.map((blocker) => {
const credential = credentials.find((item) => stringValue(item.id) === blocker) ?? {};
const permissionResult = record(credential.permissionResult);
const permissions = record(permissionResult.permissions);
const repoKey = blocker.includes(":") ? blocker.slice(blocker.indexOf(":") + 1) : "";
const repository = repositories.find((item) => stringValue(item.key) === repoKey) ?? {};
const permissionText = [
`status=${stringValue(permissionResult.status)}`,
`contents=${stringValue(permissions.contents)}`,
`metadata=${stringValue(permissions.metadata)}`,
`webhooks=${stringValue(permissions.webhooks)}`,
].join(",");
return [
stringValue(target.id),
stringValue(credential.repository, stringValue(repository.upstreamRepository)),
stringValue(credential.sourceRef),
boolText(credential.present),
stringValue(credential.fingerprint),
permissionText,
blocker,
boolText(credential.valuesPrinted),
];
});
const next = record(result.next);
return rendered(result, "platform-infra gitea mirror webhook status", [
"PLATFORM-INFRA GITEA MIRROR WEBHOOK STATUS",
...(rows.length === 0 ? ["-"] : table(["TARGET", "REPOSITORY", "SOURCE_REF", "PRESENT", "FINGERPRINT", "PERMISSION_RESULT", "BLOCKER", "VALUES_PRINTED"], rows)),
"",
`NEXT ${stringValue(next.webhookStatusFull)}`,
]);
}
export function renderMirrorWebhookStatus(result: Record<string, unknown>, disclosure: Record<string, unknown>): RenderedCliResult {
const repos = arrayRecords(result.repositories).map((repo) => {
const delivery = record(repo.selectedPushDelivery);
@@ -5,6 +5,33 @@ import { resolve } from "node:path";
const evaluator = resolve(import.meta.dir, "platform_infra_gitea_status_evaluator.py");
describe("Gitea source authority status evaluator", () => {
test("fails closed for GitHub hooks error objects and malformed lists", () => {
expect(evaluate({
action: "parse-github-hooks-response",
ok: false,
status: 403,
body: JSON.stringify({ message: "forbidden" }),
})).toEqual({ ok: false, status: 403, hooks: [], error: "github-hooks-http-403" });
expect(evaluate({
action: "parse-github-hooks-response",
ok: true,
status: 200,
body: JSON.stringify({ message: "not a list" }),
}).error).toBe("github-hooks-response-not-list");
expect(evaluate({
action: "parse-github-hooks-response",
ok: true,
status: 200,
body: JSON.stringify([{ id: 1 }, "malformed"]),
}).error).toBe("github-hooks-item-not-object");
});
test("reads GitHub hook URLs from object and string config shapes", () => {
expect(evaluate({ action: "github-hook-url", hook: { config: { url: "https://hooks.example/object" } } })).toBe("https://hooks.example/object");
expect(evaluate({ action: "github-hook-url", hook: { config: "https://hooks.example/string" } })).toBe("https://hooks.example/string");
expect(evaluate({ action: "github-hook-url", hook: "malformed" })).toBe("");
});
test("parses branch and exact snapshot from ls-remote output larger than the display tail budget", () => {
const head = "2".repeat(40);
const prefix = "refs/unidesk/snapshots/gitea-actions/unidesk-master-nc01";
+174 -47
View File
@@ -30,12 +30,14 @@ import {
renderMirrorStatus,
renderMirrorSync,
renderMirrorWebhookApply,
renderMirrorWebhookCredentialBlocked,
renderMirrorWebhookStatus,
renderPlan,
renderStatus,
} from "./platform-infra-gitea-render";
import {
GITEA_CONFIG_LABEL,
githubTokenEnvNameForSecretKey,
readGiteaConfig,
resolveTarget,
targetWebhookSync,
@@ -83,7 +85,7 @@ interface MirrorWebhookStatusOptions extends MirrorOptions {
interface MirrorSecrets {
adminUsername: string;
adminPassword: string;
githubToken: string;
githubTokens: Record<string, string>;
webhookSecret: string;
}
@@ -188,7 +190,10 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
const policy = policyChecks(gitea, target, manifest);
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-gitea-apply", mode: "policy-blocked", mutation: false, policy };
const frpcSecret = prepareGiteaFrpcSecret(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun ? ensureMirrorSecrets(gitea, true, true) : undefined;
const targetRepos = repositoriesForTarget(gitea, target);
const secrets = gitea.sourceAuthority.webhookSync.enabled && !options.dryRun
? ensureMirrorSecrets(gitea, target, targetRepos, true, true, true)
: undefined;
const remote = remoteScript("apply", gitea, target, manifest, options, { frpcSecret, secrets });
const result = await capture(config, target.route, ["sh"], remote.script);
const parsed = parseJsonOutput(result.stdout);
@@ -294,6 +299,7 @@ async function mirrorWebhookCommand(config: UniDeskConfig, args: string[]): Prom
if (action === "status") {
const options = parseMirrorWebhookStatusOptions(args.slice(1));
const result = await mirrorWebhookStatus(config, options);
if (result.credentialBlocked === true) return options.full || options.raw ? result : renderMirrorWebhookCredentialBlocked(result);
if (options.raw) return rawMirrorWebhookStatus(result);
const disclosure = buildMirrorWebhookStatusDisclosure(result, options);
return options.full ? disclosure : renderMirrorWebhookStatus(result, disclosure);
@@ -316,7 +322,7 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
sourceAuthority: sourceAuthoritySummary(gitea, target),
responsibilities: gitea.sourceAuthority.responsibilities.map(responsibilitySummary),
repositories: repositoriesForTarget(gitea, target).map((repo) => repositorySummary(gitea, repo)),
credentials: credentialSummaries(gitea),
credentials: credentialSummaries(gitea, targetRepos),
next: mirrorReadOnlyNextCommands(target.id),
};
}
@@ -324,11 +330,26 @@ function mirrorPlan(options: CommonOptions): Record<string, unknown> {
async function mirrorStatus(config: UniDeskConfig, options: CommonOptions & { repoKey?: string | null }): Promise<Record<string, unknown>> {
const gitea = readGiteaConfig();
const target = resolveCommandTarget(gitea, options.targetId);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const credentials = credentialSummaries(gitea, selectedRepos);
const blockers = credentialBlockers(credentials);
if (blockers.length > 0) {
return {
ok: false,
action: "platform-infra-gitea-mirror-status",
mutation: false,
target: targetSummary(target),
serviceHealth: { ok: false },
sourceAuthority: { ...sourceAuthoritySummary(gitea, target), mirrorReady: false, stageReady: false },
repositories: selectedRepos.map((repo) => repositorySummary(gitea, repo)),
credentials,
error: { code: "github-credential-unavailable", blockers, valuesPrinted: false },
next: mirrorReadOnlyNextCommands(target.id),
};
}
const health = await validate(config, options);
const healthValidation = record(health.validation);
const credentials = credentialSummaries(gitea);
const secrets = ensureMirrorSecrets(gitea, false, false);
const selectedRepos = selectedRepositories(gitea, target, options.repoKey ?? null);
const secrets = ensureMirrorSecrets(gitea, target, selectedRepos, false, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos: selectedRepos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
@@ -362,10 +383,8 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
const target = resolveCommandTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
if (!options.confirm) {
const credentials = credentialSummaries(gitea);
const blockers = credentials
.filter((item) => item.id === "github-upstream" && (item.present !== true || item.requiredKeysPresent !== true))
.map((item) => String(item.id));
const credentials = credentialSummaries(gitea, repos);
const blockers = credentialBlockers(credentials);
const repoFlag = options.repoKey === null ? "" : ` --repo ${options.repoKey}`;
return {
ok: blockers.length === 0,
@@ -382,7 +401,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
},
};
}
const secrets = ensureMirrorSecrets(gitea, true, true);
const secrets = ensureMirrorSecrets(gitea, target, repositoriesForTarget(gitea, target), true, true, true);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-bootstrap", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
@@ -392,7 +411,7 @@ async function mirrorBootstrap(config: UniDeskConfig, options: MirrorOptions): P
mode: "confirmed",
target: targetSummary(target),
repositories: arrayRecords(record(parsed).repositories),
credentials: credentialSummaries(gitea),
credentials: credentialSummaries(gitea, repos),
remote: parsed ?? compactCapture(result, { full: true }),
next: mirrorReadOnlyNextCommands(target.id),
};
@@ -414,7 +433,7 @@ async function mirrorSync(config: UniDeskConfig, options: MirrorOptions): Promis
}
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-sync", mutation: false, mode: "missing-confirm", error: "mirror sync requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-sync", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
@@ -435,7 +454,7 @@ async function mirrorWebhookApply(config: UniDeskConfig, options: MirrorOptions)
if (!gitea.sourceAuthority.webhookSync.enabled) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, error: "sourceAuthority.webhookSync.enabled is false" };
if (!options.confirm) return { ok: false, action: "platform-infra-gitea-mirror-webhook-apply", mutation: false, mode: "missing-confirm", error: "mirror webhook apply requires --confirm" };
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-apply", gitea, target, "", { ...options, dryRun: false, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
return {
@@ -454,7 +473,10 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook
const gitea = readGiteaConfig();
const target = resolveCommandTarget(gitea, options.targetId);
const repos = selectedRepositories(gitea, target, options.repoKey);
const secrets = ensureMirrorSecrets(gitea, false, false);
const credentials = credentialSummaries(gitea, repos);
const blockers = credentialBlockers(credentials);
if (blockers.length > 0) return buildMirrorWebhookCredentialBlockedResult(gitea, target, repos, credentials, blockers);
const secrets = ensureMirrorSecrets(gitea, target, repos, false, false, false);
const result = await capture(config, target.route, ["sh"], remoteScript("mirror-webhook-status", gitea, target, "", { ...options, confirm: false, dryRun: true, wait: false }, { repos, secrets }).script);
const parsed = parseJsonOutput(result.stdout);
const repositories = arrayRecords(record(parsed).repositories);
@@ -473,7 +495,28 @@ async function mirrorWebhookStatus(config: UniDeskConfig, options: MirrorWebhook
};
}
function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
export function buildMirrorWebhookCredentialBlockedResult(
gitea: GiteaConfig,
target: GiteaTarget,
repos: GiteaMirrorRepository[],
credentials: Array<Record<string, unknown>>,
blockers: string[],
): Record<string, unknown> {
return {
ok: false,
action: "platform-infra-gitea-mirror-webhook-status",
mutation: false,
credentialBlocked: true,
target: targetSummary(target),
webhook: webhookSyncSummary(gitea, target),
repositories: repos.map((repo) => repositorySummary(gitea, repo)),
credentials,
error: { code: "github-credential-unavailable", blockers, valuesPrinted: false },
next: mirrorReadOnlyNextCommands(target.id),
};
}
export function renderManifest(gitea: GiteaConfig, target: GiteaTarget): string {
const app = gitea.app;
const image = `${app.image.repository}:${app.image.tag}`;
const labels = ` app.kubernetes.io/name: ${app.name}
@@ -649,6 +692,8 @@ function renderGithubSyncManifest(gitea: GiteaConfig, target: GiteaTarget): stri
const ownsHookReconcile = sync.hookReconcile.enabled && sync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase();
const hookTopologyJson = ownsHookReconcile ? JSON.stringify(githubHookTopology(gitea), null, 2) : "";
const hookReconcilerSource = ownsHookReconcile ? readFileSync(githubHookReconcilerFile, "utf8") : "";
const bridgeTokenEnv = githubTokenSecretEnv(gitea, target, false);
const hookTokenEnv = githubTokenSecretEnv(gitea, target, true);
const hookReconcilerConfig = ownsHookReconcile ? ` hook-topology.json: |
${indentBlock(hookTopologyJson, 4)}
platform_infra_gitea_hook_reconciler.py: |
@@ -670,11 +715,9 @@ ${indentBlock(hookReconcilerSource, 4)}
env:
- name: UNIDESK_GITEA_TARGET_ID
value: ${yamlQuote(target.id)}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV
value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))}
${hookTokenEnv}
- name: GITHUB_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
@@ -818,11 +861,9 @@ spec:
value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))}
- name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS
value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV
value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))}
${bridgeTokenEnv}
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
@@ -984,11 +1025,9 @@ ${hookRecoveryStateEnv} - name: UNIDESK_GITEA_WEBHOOK_INBOX_MAX_BYTES
value: ${yamlQuote(String(sync.bridge.inbox.cleanupIntervalMs))}
- name: UNIDESK_GITEA_WEBHOOK_SHUTDOWN_GRACE_MS
value: ${yamlQuote(String(sync.bridge.shutdownGraceMs))}
- name: GITHUB_TOKEN
valueFrom:
secretKeyRef:
name: ${sync.bridge.secretName}
key: ${gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key}
- name: UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV
value: ${yamlQuote(githubTokenEnvName(gitea.sourceAuthority.credentials.github.gitFetchCredential.secretRef.key))}
${bridgeTokenEnv}
- name: GITEA_USERNAME
valueFrom:
secretKeyRef:
@@ -1099,6 +1138,10 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
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)
.map((credential) => `${credential.gitFetchCredential.secretRef.key}=${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}`)
.join(","),
UNIDESK_GITEA_NO_PROXY: gitea.sourceAuthority.githubProxy.noProxy.join(","),
UNIDESK_GITEA_FRPC_ENABLED: frpcExposure === null ? "0" : "1",
UNIDESK_GITEA_FRPC_DEPLOYMENT_NAME: frpcExposure?.frpc.deploymentName ?? gitea.app.publicExposure.frpc.deploymentName,
@@ -1120,7 +1163,11 @@ function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstra
if (params.secrets !== undefined) {
env.UNIDESK_GITEA_ADMIN_USERNAME = params.secrets.adminUsername;
env.UNIDESK_GITEA_ADMIN_PASSWORD = params.secrets.adminPassword;
env.UNIDESK_GITEA_GITHUB_TOKEN = params.secrets.githubToken;
for (const [key, token] of Object.entries(params.secrets.githubTokens)) env[githubTokenEnvName(key)] = token;
env.UNIDESK_GITEA_GITHUB_SECRET_ENV_MAP = Object.keys(params.secrets.githubTokens)
.sort()
.map((key) => `${key}=${githubTokenEnvName(key)}`)
.join(",");
env.UNIDESK_GITEA_GITHUB_WEBHOOK_SECRET = params.secrets.webhookSecret;
}
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
@@ -1445,7 +1492,14 @@ function githubHookTopology(gitea: GiteaConfig): Record<string, unknown> {
repoKeys: [...item.repoKeys].sort(),
branches: [...item.branches].sort(),
}));
return { repository, desiredUrls: desiredHooks.map((item) => item.url).sort(), desiredHooks };
const repo = gitea.sourceAuthority.repositories.find((item) => item.upstream.repository === repository);
const credential = repo === undefined ? gitea.sourceAuthority.credentials.github : githubCredentialForRepo(gitea, repo);
return {
repository,
githubTokenEnv: githubTokenEnvName(credential.gitFetchCredential.secretRef.key),
desiredUrls: desiredHooks.map((item) => item.url).sort(),
desiredHooks,
};
}),
};
}
@@ -1521,6 +1575,7 @@ function repositorySummary(gitea: GiteaConfig, repo: GiteaMirrorRepository): Rec
}
function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
const github = repo.credentialOverride?.github;
return {
key: repo.key,
upstream: repo.upstream,
@@ -1528,6 +1583,10 @@ function remoteRepoSpec(repo: GiteaMirrorRepository): Record<string, unknown> {
gitops: repo.gitops,
snapshot: repo.snapshot,
legacyGitMirror: repo.legacyGitMirror,
githubCredential: github === undefined ? null : {
secretKey: github.gitFetchCredential.secretRef.key,
tokenEnv: githubTokenEnvName(github.gitFetchCredential.secretRef.key),
},
};
}
@@ -1577,41 +1636,67 @@ function webhookSyncSummary(gitea: GiteaConfig, target: GiteaTarget): Record<str
};
}
function credentialSummaries(gitea: GiteaConfig): Array<Record<string, unknown>> {
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) : {};
return [
const summaries: Array<Record<string, unknown>> = [
{
id: "gitea-admin",
format: gitea.sourceAuthority.credentials.admin.format,
sourceRef: gitea.sourceAuthority.credentials.admin.sourceRef,
sourcePath: adminPath,
present: existsSync(adminPath),
requiredKeysPresent: Boolean(adminLinePair.username) && Boolean(adminLinePair.password),
fingerprint: fingerprintSecretParts([adminLinePair.username, adminLinePair.password]),
permissionResult: { status: "not-applicable", error: null },
valuesPrinted: false,
},
{
id: "github-upstream",
transport: github.transport,
sourceRef: github.sourceRef,
sourcePath: githubPath,
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,
});
}
return summaries;
}
function credentialBlockers(credentials: Array<Record<string, unknown>>): string[] {
return credentials.filter((item) => item.present !== true || item.requiredKeysPresent !== true).map((item) => String(item.id));
}
function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWebhookSecret: boolean): MirrorSecrets {
function ensureMirrorSecrets(
gitea: GiteaConfig,
target: GiteaTarget,
repositories: GiteaMirrorRepository[],
includeHookOwnerCredentials: boolean,
createAdmin: boolean,
createWebhookSecret: boolean,
): MirrorSecrets {
const admin = gitea.sourceAuthority.credentials.admin;
const adminPath = credentialPath(gitea, admin.sourceRef);
if (!existsSync(adminPath)) {
@@ -1623,17 +1708,59 @@ function ensureMirrorSecrets(gitea: GiteaConfig, createAdmin: boolean, createWeb
chmodSync(adminPath, 0o600);
}
const adminLinePair = parseLinePairCredential(adminPath, admin);
const github = gitea.sourceAuthority.credentials.github;
const githubPath = credentialPath(gitea, github.sourceRef);
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
const githubEnv = parseGithubCredentialFile(githubPath, github);
const githubTokens: Record<string, string> = {};
for (const github of githubCredentialsForConsumers(gitea, target, repositories, includeHookOwnerCredentials)) {
const githubPath = credentialPath(gitea, github.sourceRef);
if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot sync GitHub upstream`);
const githubEnv = parseGithubCredentialFile(githubPath, github);
const githubToken = githubEnv[github.sourceKey];
if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`);
githubTokens[github.gitFetchCredential.secretRef.key] = githubToken;
}
const webhookSecret = ensureWebhookSecret(gitea, createWebhookSecret);
const adminUsername = adminLinePair.username;
const adminPassword = adminLinePair.password;
const githubToken = githubEnv[github.sourceKey];
if (!adminUsername || !adminPassword) throw new Error(`${admin.sourceRef} must contain username on line ${admin.usernameLine} and password on line ${admin.passwordLine}`);
if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`);
return { adminUsername, adminPassword, githubToken, webhookSecret };
return { adminUsername, adminPassword, githubTokens, webhookSecret };
}
function githubCredentialForRepo(gitea: GiteaConfig, repo: GiteaMirrorRepository): GiteaGithubCredential {
return repo.credentialOverride?.github ?? gitea.sourceAuthority.credentials.github;
}
function githubCredentialsForTarget(gitea: GiteaConfig, target: GiteaTarget): GiteaGithubCredential[] {
const credentials = [gitea.sourceAuthority.credentials.github, ...repositoriesForTarget(gitea, target).flatMap((repo) => repo.credentialOverride?.github ?? [])];
return [...new Map(credentials.map((credential) => [credential.gitFetchCredential.secretRef.key, credential])).values()];
}
function githubCredentialsForConsumers(
gitea: GiteaConfig,
target: GiteaTarget,
repositories: GiteaMirrorRepository[],
includeHookOwnerCredentials: boolean,
): GiteaGithubCredential[] {
const credentials = [gitea.sourceAuthority.credentials.github, ...repositories.flatMap((repo) => repo.credentialOverride?.github ?? [])];
if (includeHookOwnerCredentials
&& gitea.sourceAuthority.webhookSync.hookReconcile.ownerTargetId.toLowerCase() === target.id.toLowerCase()) {
credentials.push(...gitea.sourceAuthority.repositories.flatMap((repo) => {
const github = repo.credentialOverride?.github;
return github !== undefined && github.requiredFor.some((item) => item.startsWith("github-hooks-")) ? [github] : [];
}));
}
return [...new Map(credentials.map((credential) => [credential.gitFetchCredential.secretRef.key, credential])).values()];
}
function githubTokenEnvName(secretKey: string): string {
return githubTokenEnvNameForSecretKey(secretKey);
}
function githubTokenSecretEnv(gitea: GiteaConfig, target: GiteaTarget, includeHookOwnerCredentials: boolean): string {
const secretName = targetWebhookSync(gitea, target).bridge.secretName;
return githubCredentialsForConsumers(gitea, target, repositoriesForTarget(gitea, target), includeHookOwnerCredentials).map((credential) => ` - name: ${githubTokenEnvName(credential.gitFetchCredential.secretRef.key)}
valueFrom:
secretKeyRef:
name: ${secretName}
key: ${credential.gitFetchCredential.secretRef.key}`).join("\n");
}
function ensureWebhookSecret(gitea: GiteaConfig, createIfMissing: boolean): string {
@@ -347,7 +347,7 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu
test("provenance fixtures fail closed for pre-bootstrap, forged marker, wrong SA, and policy mismatch", () => {
const result = evaluator.runPacStatusFixtureChecks();
expect(result.ok).toBe(true);
for (const id of ["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", "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"]) {
expect(result.checks.find((item) => item.id === id)?.ok).toBe(true);
}
});
@@ -1297,8 +1297,9 @@ const runtimeSourceCommit = artifact.runtimeSourceCommit || (noRuntimeChange ? n
const tag = !noRuntimeChange && runtimeSourceCommit ? String(runtimeSourceCommit).slice(0, 12) : '';
const prefix = process.env.UNIDESK_PAC_CONSUMER_ID === 'unidesk-host' ? 'unidesk_host_' : '';
const param = (name) => params[`${prefix}${name}`] ?? params[name];
const imageRepository = typeof param('image_repository') === 'string' ? param('image_repository') : '';
const probeBase = typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : '';
const registryConfigured = process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY === 'configured';
const imageRepository = registryConfigured && typeof param('image_repository') === 'string' ? param('image_repository') : '';
const probeBase = registryConfigured && typeof param('registry_probe_base') === 'string' ? param('registry_probe_base').replace(/\/+$/u, '') : '';
let registryUrl = '';
if (imageRepository && tag) {
const firstSlash = imageRepository.indexOf('/');
@@ -1407,6 +1408,7 @@ const evaluated = evaluatePacStatus({
sourceCommit: process.env.UNIDESK_PAC_DIAG_SOURCE_COMMIT || latest.sourceCommit || null,
runtimeSourceCommit: process.env.UNIDESK_PAC_DIAG_RUNTIME_SOURCE_COMMIT || artifact.runtimeSourceCommit || null,
imageRepository: process.env.UNIDESK_PAC_DIAG_IMAGE_REPOSITORY || null,
registryApplicability: process.env.UNIDESK_PAC_REGISTRY_APPLICABILITY || 'not-configured',
registryProbeBase: process.env.UNIDESK_PAC_DIAG_REGISTRY_PROBE_BASE || null,
imageTag: process.env.UNIDESK_PAC_DIAG_IMAGE_TAG || null,
registryUrl: process.env.UNIDESK_PAC_DIAG_REGISTRY_URL || null,
@@ -1069,9 +1069,6 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
const target = resolveTarget(pac, options.targetId ?? options.nodeId);
const nodeId = options.nodeId ?? target.id;
const consumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === nodeId.toLowerCase());
if (consumers.length === 0) {
throw new Error(`no Pipelines-as-Code consumers are configured for node ${nodeId}; known nodes: ${Array.from(new Set(pac.consumers.map((item) => item.node))).sort().join(", ")}`);
}
const startedAt = Date.now();
const statuses = await Promise.all(consumers.map(async (consumer) => {
const repository = resolveRepository(pac, consumer.repositoryRef);
@@ -1101,8 +1098,9 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
}
}));
const ready = statuses.every((row) => row.ready === true);
const configured = statuses.length > 0;
return {
ok: ready,
ok: configured && ready,
action: "cicd-node-status",
mutation: false,
node: nodeId,
@@ -1115,7 +1113,10 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
},
consumers: statuses,
summary: {
ready,
ready: configured && ready,
status: configured ? (ready ? "ready" : "blocked") : "warning",
code: configured ? (ready ? "cicd-node-ready" : "cicd-node-blocked") : "cicd-node-no-consumers",
hint: configured ? null : `no Pipelines-as-Code consumers are configured for node ${nodeId}`,
total: statuses.length,
readyCount: statuses.filter((row) => row.ready === true).length,
blockedCount: statuses.filter((row) => row.ready !== true).length,
@@ -1420,6 +1421,7 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey,
UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit),
UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params),
UNIDESK_PAC_REGISTRY_APPLICABILITY: registryApplicability(consumer, repository),
UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline,
UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix,
UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"),
@@ -1453,6 +1455,14 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
}
function registryApplicability(consumer: PacConsumer, repository: PacRepository): "configured" | "not-configured" {
const prefix = consumer.id === "unidesk-host" ? "unidesk_host_" : "";
const imageRepository = repository.params[`${prefix}image_repository`] ?? repository.params.image_repository;
if (typeof imageRepository !== "string" || imageRepository.length === 0) return "not-configured";
if (prefix.length > 0) return "configured";
return repository.params.pipeline_name === consumer.pipeline ? "configured" : "not-configured";
}
function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record<string, unknown> {
const repository = resolveRepository(pac, consumer.repositoryRef);
const admissionIdentity = pacAdmissionDesiredIdentity(consumer.node);
@@ -1738,6 +1748,7 @@ function nodeStatusRow(targetId: string, consumer: PacConsumer, repository: PacR
code: stringValue(diagnostics.code),
phase: stringValue(diagnostics.phase),
hint: compactLine(stringValue(diagnostics.hint)),
registry: record(diagnostics.registry),
},
traceId: stringValue(artifact.traceId),
firstBreak: record(artifact.firstBreak),
@@ -2840,6 +2851,7 @@ function envReuseText(envReuse: Record<string, unknown>): string {
function registryText(registry: Record<string, unknown>): string {
if (Object.keys(registry).length === 0) return "-";
if (registry.status === "not-configured" || registry.applicability === "not-configured") return "N/A";
const present = registry.present === true ? "present" : "missing";
const digest = short(stringValue(registry.digest), 18);
return digest === "-" ? present : `${present}:${digest}`;
@@ -602,7 +602,7 @@ def reconcile_repository(topology, repository_spec, token, secret, force_patch):
return observation, observed
def reconcile_once(topology, token, secret, force_patch):
def reconcile_once(topology, secret, force_patch):
started = time.monotonic()
topology_ready = True
recovery_ready = True
@@ -624,6 +624,7 @@ def reconcile_once(topology, token, secret, force_patch):
emit("github-hook-delivery-recovery-ledger-unavailable", ok=False, errorType=type(error).__name__, error=str(error)[:160])
for repository_spec in topology["repositories"]:
try:
token = os.environ[repository_spec["githubTokenEnv"]]
observation, observed_hooks = reconcile_repository(topology, repository_spec, token, secret, force_patch)
if not observation["ready"]:
topology_ready = False
@@ -675,13 +676,12 @@ def main():
topology = load_topology(sys.argv[1])
if topology.get("ownerTargetId") != os.environ.get("UNIDESK_GITEA_TARGET_ID"):
raise SystemExit("hook reconciler target is not the YAML owner")
token = os.environ["GITHUB_TOKEN"]
secret = os.environ["GITHUB_WEBHOOK_SECRET"]
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
force_patch = True
while not STOP.is_set():
if reconcile_once(topology, token, secret, force_patch):
if reconcile_once(topology, secret, force_patch):
force_patch = False
STOP.wait(topology["reconcile"]["intervalMs"] / 1000)
@@ -13,6 +13,31 @@ def parse_ls_remote_lines(lines):
return refs
def parse_github_hooks_response(ok, status, body):
if ok is not True:
return {"ok": False, "status": status, "hooks": [], "error": f"github-hooks-http-{status or 'unknown'}"}
try:
payload = json.loads(body or "[]")
except (TypeError, json.JSONDecodeError):
return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-response-invalid-json"}
if not isinstance(payload, list):
return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-response-not-list"}
if any(not isinstance(item, dict) for item in payload):
return {"ok": False, "status": status, "hooks": [], "error": "github-hooks-item-not-object"}
return {"ok": True, "status": status, "hooks": payload, "error": None}
def github_hook_url(item):
if not isinstance(item, dict):
return ""
config = item.get("config")
if isinstance(config, str):
return config
if isinstance(config, dict) and isinstance(config.get("url"), str):
return config["url"]
return ""
def select_committed_head_record(repo_key, github_head, records):
candidates = []
for record in records:
@@ -255,6 +280,10 @@ def main():
action = payload.get("action")
if action == "parse-ls-remote":
result = parse_ls_remote_lines(str(payload.get("output") or "").splitlines())
elif action == "parse-github-hooks-response":
result = parse_github_hooks_response(payload.get("ok"), payload.get("status"), payload.get("body"))
elif action == "github-hook-url":
result = github_hook_url(payload.get("hook"))
elif action == "select-committed-head-record":
result = select_committed_head_record(payload.get("repoKey"), payload.get("githubHead"), payload.get("records", []))
elif action == "select-target-delivery":