chore: merge main into runtime migration automation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -31,6 +32,8 @@ const requiredWebAssets = Object.freeze([
|
||||
"third_party/marked/LICENSE"
|
||||
]);
|
||||
|
||||
const liveIdentityWebAssets = Object.freeze(["index.html", "styles.css", "app.mjs"]);
|
||||
|
||||
const chineseWorkbenchLabels = Object.freeze([
|
||||
"HWLAB 云工作台",
|
||||
"硬件资源",
|
||||
@@ -369,13 +372,64 @@ function runStaticSmoke() {
|
||||
async function runLiveSmoke(args) {
|
||||
const checks = [];
|
||||
const blockers = [];
|
||||
const sourceIdentity = observeSourceIdentity();
|
||||
const runtimeIdentity = await observeLiveRuntimeIdentity(runtime.endpoints.api);
|
||||
const expectedRuntimeIdentity = observeExpectedRuntimeIdentity("hwlab-cloud-api");
|
||||
const deploymentIdentity = classifyLiveDeploymentIdentity(sourceIdentity, runtimeIdentity, expectedRuntimeIdentity);
|
||||
addCheck(checks, blockers, "live-runtime-current-main", deploymentIdentity.status, deploymentIdentity.summary, {
|
||||
blocker: "observability_blocker",
|
||||
evidence: deploymentIdentity.evidence,
|
||||
observations: deploymentIdentity
|
||||
});
|
||||
|
||||
const http = await fetchText(args.url);
|
||||
addCheck(checks, blockers, "live-http-html", http.ok && http.status === 200 && /text\/html/u.test(http.contentType) && liveHtmlLooksLikeWorkbench(http.body), "Live URL serves the Cloud Workbench HTML.", {
|
||||
blocker: "runtime_blocker",
|
||||
evidence: [args.url, `HTTP ${http.status ?? "none"}`, http.contentType ?? "no content-type"]
|
||||
});
|
||||
|
||||
const webAssetIdentity = http.ok
|
||||
? await inspectLiveWebAssetIdentity(args.url, http.body)
|
||||
: {
|
||||
status: "blocked",
|
||||
summary: "Live web asset identity could not be checked because the 16666 HTML fetch failed.",
|
||||
assets: []
|
||||
};
|
||||
addCheck(checks, blockers, "live-web-assets-current-main", webAssetIdentity.status, webAssetIdentity.summary, {
|
||||
blocker: "observability_blocker",
|
||||
evidence: webAssetIdentity.assets.map((asset) => `${asset.path}: ${asset.status}`),
|
||||
observations: webAssetIdentity
|
||||
});
|
||||
|
||||
const identityBlocked = deploymentIdentity.status !== "pass" || webAssetIdentity.status !== "pass";
|
||||
if (identityBlocked) {
|
||||
return baseReport({
|
||||
mode: "live",
|
||||
url: args.url,
|
||||
status: "blocked",
|
||||
checks,
|
||||
blockers,
|
||||
evidenceLevel: "BLOCKED",
|
||||
devLive: false,
|
||||
runtimeIdentity,
|
||||
sourceIdentity,
|
||||
deploymentIdentity,
|
||||
expectedRuntimeIdentity,
|
||||
webAssetIdentity,
|
||||
safety: {
|
||||
prodTouched: false,
|
||||
servicesRestarted: false,
|
||||
heavyE2E: false,
|
||||
hardwareWriteApis: false,
|
||||
sourceIsDevLive: false,
|
||||
liveMode: "deployment-identity-preflight",
|
||||
codeAgentBrowserJourneySkipped: true,
|
||||
retainedApiFields: ["runtime.commitId", "runtime.imageTag", "web asset hash/status"],
|
||||
statement: "Live smoke blocked before the browser chat journey because deployed runtime/assets do not match the current source identity; it did not post Code Agent chat, call hardware write APIs, restart services, or read secret values."
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const dom = http.ok ? await inspectLiveDom(args.url) : { status: "skip", summary: "Browser DOM check skipped because HTTP fetch failed.", evidence: [] };
|
||||
addCheck(checks, blockers, "live-browser-dom", dom.status, dom.summary, {
|
||||
blocker: dom.status === "pass" ? null : "observability_blocker",
|
||||
@@ -418,6 +472,10 @@ async function runLiveSmoke(args) {
|
||||
evidenceLevel: status === "pass" ? "DEV-LIVE-BROWSER" : "BLOCKED",
|
||||
devLive: status === "pass",
|
||||
runtimeIdentity,
|
||||
sourceIdentity,
|
||||
deploymentIdentity,
|
||||
expectedRuntimeIdentity,
|
||||
webAssetIdentity,
|
||||
safety: {
|
||||
prodTouched: false,
|
||||
servicesRestarted: false,
|
||||
@@ -431,8 +489,22 @@ async function runLiveSmoke(args) {
|
||||
});
|
||||
}
|
||||
|
||||
function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, help = undefined, safety, url = null, runtimeIdentity = null }) {
|
||||
const sourceIdentity = observeSourceIdentity();
|
||||
function baseReport({
|
||||
mode,
|
||||
status,
|
||||
checks,
|
||||
blockers,
|
||||
evidenceLevel,
|
||||
devLive,
|
||||
help = undefined,
|
||||
safety,
|
||||
url = null,
|
||||
runtimeIdentity = null,
|
||||
sourceIdentity = observeSourceIdentity(),
|
||||
deploymentIdentity = null,
|
||||
expectedRuntimeIdentity = null,
|
||||
webAssetIdentity = null
|
||||
}) {
|
||||
const liveJourneyPassed = mode === "live" && status === "pass";
|
||||
return {
|
||||
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
|
||||
@@ -473,6 +545,9 @@ function baseReport({ mode, status, checks, blockers, evidenceLevel, devLive, he
|
||||
devLive,
|
||||
sourceIdentity,
|
||||
runtimeIdentity: runtimeIdentity ?? notObservedRuntimeIdentity("live runtime identity was not observed in this smoke mode"),
|
||||
...(expectedRuntimeIdentity ? { expectedRuntimeIdentity } : {}),
|
||||
...(deploymentIdentity ? { deploymentIdentity } : {}),
|
||||
...(webAssetIdentity ? { webAssetIdentity } : {}),
|
||||
sourceContract: {
|
||||
status: "pass",
|
||||
documents: [
|
||||
@@ -630,6 +705,172 @@ function runGit(args) {
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyLiveDeploymentIdentity(sourceIdentity, runtimeIdentity, expectedRuntimeIdentity = null) {
|
||||
const expectedCommit = sanitizeRevision(expectedRuntimeIdentity?.commitId ?? sourceIdentity?.commitId);
|
||||
const expectedShort = expectedCommit === "unknown" ? "unknown" : expectedCommit.slice(0, 7);
|
||||
const expectedReport = sourceIdentity?.reportCommitId ?? "unknown";
|
||||
const expectedImageTag = sanitizeRuntimeString(expectedRuntimeIdentity?.imageTag ?? expectedShort).toLowerCase();
|
||||
const observedRuntimeCommit = sanitizeRevision(runtimeIdentity?.commitId);
|
||||
const observedImageTag = sanitizeRuntimeString(runtimeIdentity?.imageTag).toLowerCase();
|
||||
const runtimeObserved = runtimeIdentity?.status === "observed";
|
||||
const expectedObserved = expectedRuntimeIdentity === null || expectedRuntimeIdentity?.status === "observed";
|
||||
const sourceClean =
|
||||
sourceIdentity?.status === "observed" &&
|
||||
sourceIdentity?.worktreeState === "clean" &&
|
||||
expectedReport !== "unknown";
|
||||
const accepted = new Set([...commitIdentityVariants(expectedCommit), ...commitIdentityVariants(expectedImageTag)]);
|
||||
const runtimeMatches =
|
||||
runtimeObserved &&
|
||||
sourceClean &&
|
||||
expectedObserved &&
|
||||
expectedCommit !== "unknown" &&
|
||||
(identityVariantMatches(accepted, observedRuntimeCommit) || identityVariantMatches(accepted, observedImageTag));
|
||||
|
||||
if (runtimeMatches) {
|
||||
return {
|
||||
status: "pass",
|
||||
expectedCommit,
|
||||
expectedReportCommitId: expectedReport,
|
||||
expectedImageTag,
|
||||
expectedSource: expectedRuntimeIdentity?.source ?? "source-git-head",
|
||||
observedRuntimeCommit,
|
||||
observedImageTag,
|
||||
evidence: [
|
||||
`expected=${expectedShort}`,
|
||||
`expectedImageTag=${expectedImageTag}`,
|
||||
`runtimeCommit=${observedRuntimeCommit}`,
|
||||
`imageTag=${observedImageTag}`
|
||||
],
|
||||
summary: "Live API runtime identity matches the current source desired runtime identity."
|
||||
};
|
||||
}
|
||||
|
||||
const reason = !sourceClean
|
||||
? "current source identity is not cleanly attributable"
|
||||
: !expectedObserved || expectedCommit === "unknown"
|
||||
? "source desired runtime identity was not observed"
|
||||
: !runtimeObserved
|
||||
? "live API runtime identity was not observed"
|
||||
: "live API runtime commit/image tag does not match current source desired runtime identity";
|
||||
return {
|
||||
status: "blocked",
|
||||
expectedCommit,
|
||||
expectedReportCommitId: expectedReport,
|
||||
expectedImageTag,
|
||||
expectedSource: expectedRuntimeIdentity?.source ?? "source-git-head",
|
||||
observedRuntimeCommit,
|
||||
observedImageTag,
|
||||
reason,
|
||||
evidence: [
|
||||
`expected=${expectedShort}`,
|
||||
`expectedImageTag=${expectedImageTag}`,
|
||||
`runtimeCommit=${observedRuntimeCommit}`,
|
||||
`imageTag=${observedImageTag}`
|
||||
],
|
||||
summary: `Deployment drift: ${reason}.`
|
||||
};
|
||||
}
|
||||
|
||||
function observeExpectedRuntimeIdentity(serviceId) {
|
||||
const deploy = readJsonOrNull("deploy/deploy.json");
|
||||
const catalog = readJsonOrNull("deploy/artifact-catalog.dev.json");
|
||||
const deployService = Array.isArray(deploy?.services)
|
||||
? deploy.services.find((service) => service?.serviceId === serviceId)
|
||||
: null;
|
||||
const catalogService = Array.isArray(catalog?.services)
|
||||
? catalog.services.find((service) => service?.serviceId === serviceId)
|
||||
: null;
|
||||
const image = sanitizeRuntimeString(catalogService?.image ?? deployService?.image);
|
||||
const imageTag = sanitizeRuntimeString(catalogService?.imageTag ?? deployService?.env?.HWLAB_IMAGE_TAG ?? imageTagFromImage(image));
|
||||
const commitId = sanitizeRevision(catalogService?.commitId ?? deployService?.env?.HWLAB_COMMIT_ID ?? deploy?.commitId ?? catalog?.commitId ?? imageTag);
|
||||
const sources = [];
|
||||
if (catalogService) sources.push("deploy/artifact-catalog.dev.json");
|
||||
if (deployService || deploy?.commitId) sources.push("deploy/deploy.json");
|
||||
const observed = commitId !== "unknown" || imageTag !== "unknown";
|
||||
return {
|
||||
status: observed ? "observed" : "not_observed",
|
||||
serviceId,
|
||||
source: sources.length > 0 ? sources.join("+") : "not_observed",
|
||||
commitId,
|
||||
imageTag,
|
||||
image,
|
||||
summary: observed
|
||||
? "Expected DEV runtime identity was derived from current source deploy desired state, not from live health."
|
||||
: "Expected DEV runtime identity was not found in current source deploy desired state."
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonOrNull(relativePath) {
|
||||
try {
|
||||
return JSON.parse(readText(relativePath));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function imageTagFromImage(image) {
|
||||
if (typeof image !== "string") return "unknown";
|
||||
const match = image.match(/:([^:/]+)$/u);
|
||||
return match?.[1] ?? "unknown";
|
||||
}
|
||||
|
||||
function commitIdentityVariants(commitId) {
|
||||
if (typeof commitId !== "string" || !/^[a-f0-9]{7,40}$/u.test(commitId)) return new Set();
|
||||
return new Set([commitId, commitId.slice(0, 12), commitId.slice(0, 7)]);
|
||||
}
|
||||
|
||||
function identityVariantMatches(accepted, observed) {
|
||||
if (typeof observed !== "string" || observed === "unknown" || observed === "not_observed") return false;
|
||||
if (accepted.has(observed)) return true;
|
||||
return [...accepted].some((expected) => expected.length >= 7 && observed.startsWith(expected));
|
||||
}
|
||||
|
||||
async function inspectLiveWebAssetIdentity(baseUrl, fetchedIndexHtml) {
|
||||
const assets = [];
|
||||
for (const assetPath of liveIdentityWebAssets) {
|
||||
const sourceText = readText(`web/hwlab-cloud-web/${assetPath}`);
|
||||
const live = assetPath === "index.html"
|
||||
? { ok: true, status: 200, contentType: "text/html; charset=utf-8", body: fetchedIndexHtml }
|
||||
: await fetchText(new URL(assetPath, baseUrl).toString());
|
||||
assets.push(compareLiveAsset(assetPath, live, sourceText));
|
||||
}
|
||||
return classifyLiveWebAssetIdentity(assets);
|
||||
}
|
||||
|
||||
export function classifyLiveWebAssetIdentity(assets) {
|
||||
const mismatches = assets.filter((asset) => asset.status !== "match");
|
||||
return {
|
||||
status: mismatches.length === 0 ? "pass" : "blocked",
|
||||
assets,
|
||||
mismatches: mismatches.map((asset) => asset.path),
|
||||
summary: mismatches.length === 0
|
||||
? "Live 16666 primary web assets match the current clean source files."
|
||||
: `Deployment drift: live 16666 primary web assets differ from current source (${mismatches.map((asset) => asset.path).join(", ")}).`
|
||||
};
|
||||
}
|
||||
|
||||
function compareLiveAsset(assetPath, live, sourceText) {
|
||||
const liveText = typeof live?.body === "string" ? live.body : "";
|
||||
const sourceHash = hashText(sourceText);
|
||||
const liveHash = hashText(liveText);
|
||||
const fetched = live?.ok === true && live?.status === 200;
|
||||
const matches = fetched && liveText === sourceText;
|
||||
return {
|
||||
path: assetPath,
|
||||
status: matches ? "match" : fetched ? "mismatch" : "fetch_failed",
|
||||
httpStatus: live?.status ?? null,
|
||||
contentType: live?.contentType ?? "",
|
||||
sourceHash,
|
||||
liveHash,
|
||||
sourceLength: sourceText.length,
|
||||
liveLength: liveText.length
|
||||
};
|
||||
}
|
||||
|
||||
function hashText(value) {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
function assetsExist() {
|
||||
return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user