Files
pikasTech-unidesk/scripts/src/web-probe-artifact.ts
T
pikastech 06684cc8e9
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success
fix: improve SuperAPI web smoke diagnostics
2026-07-18 16:26:15 +02:00

244 lines
11 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { basename, dirname, relative, resolve } from "node:path";
import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery";
import { rootPath } from "./config";
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane } from "./hwlab-node-lanes";
import { isSafeWebProbeScriptArtifactPath, runTransWorkspaceStdinScript } from "./hwlab-node/public-exposure";
import { compactCommandResultRedacted, shellQuote } from "./hwlab-node/utils";
import { CliInputError } from "./output";
interface WebProbeArtifactInspectOptions {
readonly node: string;
readonly lane: string;
readonly path: string;
readonly sha256: string;
}
export function parseWebProbeArtifactInspectOptions(args: string[]): WebProbeArtifactInspectOptions {
if (args[0] !== "inspect") throw artifactInputError("web-probe artifact 需要 inspect action");
const values = new Map<string, string>();
const known = new Set(["--node", "--lane", "--path", "--sha256"]);
for (let index = 1; index < args.length; index += 1) {
const item = args[index];
const equals = item.indexOf("=");
const key = equals > 0 ? item.slice(0, equals) : item;
if (!known.has(key)) throw artifactInputError(`web-probe artifact inspect 不支持参数 ${item}`, item);
const value = equals > 0 ? item.slice(equals + 1) : args[++index];
if (value === undefined || value.startsWith("--")) throw artifactInputError(`${key} 需要一个值`, key);
if (values.has(key)) throw artifactInputError(`${key} 不能重复`, key);
values.set(key, value);
}
const node = required(values, "--node");
const lane = required(values, "--lane");
const path = required(values, "--path");
const sha256 = required(values, "--sha256");
if (!/^[A-Za-z0-9_.-]+$/u.test(node)) throw artifactInputError("--node 只能包含字母、数字、点、下划线和连字符", "--node");
if (!isHwlabRuntimeLane(lane)) throw artifactInputError(`未知 --lane ${lane}`, "--lane");
if (!isSafeWebProbeScriptArtifactPath(path) && !isSafeDownloadedWebProbeArtifactPath(path)) {
throw artifactInputError("--path 不是受控 web-probe script artifact 路径", "--path");
}
if (!/^sha256:[a-f0-9]{64}$/u.test(sha256)) throw artifactInputError("--sha256 必须是 sha256:<64位小写十六进制>", "--sha256");
return { node, lane, path, sha256 };
}
export function runWebProbeArtifactInspect(args: string[]): Record<string, unknown> {
const options = parseWebProbeArtifactInspectOptions(args);
if (isSafeDownloadedWebProbeArtifactPath(options.path)) return inspectDownloadedWebProbeArtifact(options);
const spec = hwlabRuntimeLaneSpecForNode(options.lane, options.node);
const result = runTransWorkspaceStdinScript(options.node, spec.workspace, artifactInspectScript(options), 30);
const resolution = resolveCliChildJsonCommandResult({
result,
requestedStdoutType: "bounded web-probe artifact inspection JSON",
acceptParsed: (value) => typeof value.ok === "boolean" && typeof value.sha256 === "string" && typeof value.path === "string",
});
const artifact = resolution.parsed;
return {
ok: result.exitCode === 0 && artifact?.ok === true,
status: result.exitCode === 0 && artifact?.ok === true ? "inspected" : "blocked",
command: "web-probe artifact inspect",
node: options.node,
lane: options.lane,
artifact,
stdoutRecovery: resolution.diagnostics,
result: compactCommandResultRedacted(result, []),
valuesRedacted: true,
};
}
function inspectDownloadedWebProbeArtifact(options: WebProbeArtifactInspectOptions): Record<string, unknown> {
const bytes = readFileSync(rootPath(options.path));
const sha256 = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
const verified = sha256 === options.sha256;
const report = options.path.endsWith(".json") ? JSON.parse(bytes.toString("utf8")) as unknown : null;
return {
ok: verified,
status: verified ? "inspected" : "blocked",
command: "web-probe artifact inspect",
node: options.node,
lane: options.lane,
artifact: {
ok: verified,
kind: report === null ? "screenshot" : "json",
path: options.path,
byteCount: bytes.length,
sha256,
expectedSha256: options.sha256,
verified,
evidence: report === null ? null : compactDownloadedReport(report),
valuesRedacted: true,
},
valuesRedacted: true,
};
}
function compactDownloadedReport(value: unknown): Record<string, unknown> {
const report = objectValue(value);
const summary = objectValue(report.summary);
return {
ok: report.ok === true,
status: typeof report.status === "string" ? report.status : null,
failureKind: typeof report.failureKind === "string" ? report.failureKind : null,
auth: compactArtifactValue(report.auth),
summary: {
ok: summary.ok === true,
status: summary.status ?? null,
degradedReason: summary.degradedReason ?? null,
failureKind: summary.failureKind ?? null,
failedCondition: compactArtifactValue(summary.failedCondition),
nextAction: compactArtifactValue(summary.nextAction),
baseUrl: summary.baseUrl ?? null,
finalUrl: summary.finalUrl ?? null,
reportPath: summary.reportPath ?? null,
reportSha256: summary.reportSha256 ?? null,
lastStep: compactArtifactValue(summary.lastStep),
issueEvidence: compactIssueEvidence(summary.issueEvidence),
},
issueEvidence: compactIssueEvidence(report.issueEvidence),
failureEvidence: compactArtifactValue(report.failureEvidence),
lastStep: compactArtifactValue(report.lastStep ?? summary.lastStep),
steps: compactArtifactValue(report.steps),
artifacts: compactArtifactValue(report.artifacts),
valuesRedacted: true,
};
}
function compactIssueEvidence(value: unknown): unknown {
const issue = objectValue(value);
const result = objectValue(issue.result);
if (!("pageErrors" in result || "consoleErrors" in result || "requestFailures" in result || "responseErrors" in result)) {
return compactArtifactValue(value);
}
return {
ok: issue.ok ?? result.ok ?? null,
status: issue.status ?? result.status ?? null,
profileName: result.profileName ?? null,
configRef: result.configRef ?? null,
failureCount: result.failureCount ?? null,
failures: compactArtifactValue(result.failures),
pageErrorCount: result.pageErrorCount ?? null,
pageErrors: compactArtifactValue(result.pageErrors),
consoleErrorCount: result.consoleErrorCount ?? null,
consoleErrors: compactArtifactValue(result.consoleErrors),
requestFailureCount: result.requestFailureCount ?? null,
requestFailures: compactArtifactValue(result.requestFailures),
responseErrorCount: result.responseErrorCount ?? null,
responseErrors: compactArtifactValue(result.responseErrors),
valuesRedacted: true,
};
}
function compactArtifactValue(value: unknown, depth = 0): unknown {
if (value === null || value === undefined) return value ?? null;
if (typeof value === "string") return value.replace(/\s+/gu, " ").trim().slice(0, 300);
if (typeof value === "number" || typeof value === "boolean") return value;
if (depth >= 5) return "[max-depth]";
if (Array.isArray(value)) return value.slice(0, 4).map((item) => compactArtifactValue(item, depth + 1));
if (typeof value === "object") {
const output: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>).slice(0, 24)) {
output[key] = compactArtifactValue(nested, depth + 1);
}
return output;
}
return String(value).slice(0, 300);
}
function objectValue(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isSafeDownloadedWebProbeArtifactPath(path: string): boolean {
const root = resolve(rootPath(".state/web-probe-script-artifacts"));
const candidate = resolve(rootPath(path));
const relativePath = relative(root, candidate);
return dirname(relativePath) === "."
&& basename(relativePath) === relativePath
&& /^[A-Za-z0-9_.-]+[.](?:png|json)$/u.test(relativePath);
}
export function artifactInspectScript(options: WebProbeArtifactInspectOptions): string {
return [
"set -eu",
`node - ${shellQuote(options.path)} ${shellQuote(options.sha256)} <<'NODE'`,
"const fs = require('fs');",
"const { createHash } = require('crypto');",
"const path = process.argv[2];",
"const expectedSha256 = process.argv[3];",
"const bytes = fs.readFileSync(path);",
"const sha256 = 'sha256:' + createHash('sha256').update(bytes).digest('hex');",
"const compact = (value, depth = 0) => {",
" if (value === null || value === undefined) return value ?? null;",
" if (typeof value === 'string') return value.replace(/\\s+/gu, ' ').trim().slice(0, 600);",
" if (typeof value === 'number' || typeof value === 'boolean') return value;",
" if (depth >= 6) return '[max-depth]';",
" if (Array.isArray(value)) return value.slice(-8).map((item) => compact(item, depth + 1));",
" if (typeof value === 'object') {",
" const out = {};",
" for (const [key, nested] of Object.entries(value).slice(0, 32)) out[key] = compact(nested, depth + 1);",
" return out;",
" }",
" return String(value).slice(0, 600);",
"};",
"let evidence = null;",
"if (path.endsWith('.json')) {",
" const report = JSON.parse(bytes.toString('utf8'));",
" evidence = {",
" ok: report.ok === true,",
" status: typeof report.status === 'string' ? report.status : null,",
" failureKind: typeof report.failureKind === 'string' ? report.failureKind : null,",
" auth: compact(report.auth),",
" summary: compact(report.summary),",
" issueEvidence: compact(report.issueEvidence),",
" failureEvidence: compact(report.failureEvidence),",
" lastStep: compact(report.lastStep ?? report.summary?.lastStep),",
" steps: compact(report.steps),",
" artifacts: compact(report.artifacts),",
" valuesRedacted: true,",
" };",
"}",
"const ok = sha256 === expectedSha256;",
"console.log(JSON.stringify({ ok, kind: path.endsWith('.json') ? 'json' : 'screenshot', path, byteCount: bytes.length, sha256, expectedSha256, verified: ok, evidence, valuesRedacted: true }));",
"if (!ok) process.exitCode = 3;",
"NODE",
].join("\n");
}
function required(values: Map<string, string>, key: string): string {
const value = values.get(key);
if (value === undefined || value.length === 0) throw artifactInputError(`${key} 是必填参数`, key);
return value;
}
function artifactInputError(message: string, argument?: string): CliInputError {
return new CliInputError(message, {
code: "web-probe-artifact-input-invalid",
reason: message,
mutation: false,
argument,
usage: "bun scripts/cli.ts web-probe artifact inspect --node NODE --lane LANE --path PATH --sha256 sha256:<digest>",
hint: "仅使用 product-smoke 输出的受控 artifact 路径和对应 SHA。",
});
}