Merge remote-tracking branch 'origin/master' into feat/1923-cicd-otel-observability
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user