diff --git a/docs/dev-artifact-publish.md b/docs/dev-artifact-publish.md index 08d64878..56827610 100644 --- a/docs/dev-artifact-publish.md +++ b/docs/dev-artifact-publish.md @@ -59,7 +59,14 @@ Build and publish to the D601 local/internal registry: node scripts/dev-artifact-publish.mjs --publish ``` -For `hwlab-cloud-web`, refresh the static bundle before publishing: +Daily Cloud Web source validation does not require ignored `dist/` to exist: + +```sh +node web/hwlab-cloud-web/scripts/check.mjs +``` + +For `hwlab-cloud-web`, the build/publish path refreshes and verifies the static +bundle before image build: ```sh node web/hwlab-cloud-web/scripts/build.mjs @@ -69,7 +76,12 @@ node scripts/dev-artifact-publish.mjs --publish --services hwlab-cloud-web --rep The runtime wrapper serves `/app/web/hwlab-cloud-web/dist` before the source directory. If `dist` is stale, a newly tagged image can still serve old HTML, CSS, and JavaScript. A publish for Cloud Web is not valid until the image has -been probed locally and the served HTML matches the intended source. +fresh `dist` evidence. `build.mjs` regenerates `dist` and verifies every +runtime file and route alias matches source. `dev-artifact-publish.mjs` +re-runs that build/freshness gate and records +`artifactPublish.services[].distFreshness` for `hwlab-cloud-web` before the +Docker build. `check.mjs` remains a clean-checkout source contract and must not +fail only because ignored `dist/` is absent. Use `--registry-prefix` only for another localhost/private/internal D601 registry prefix. The script rejects third-party registry hosts and any prefix @@ -89,6 +101,7 @@ Each service record contains: - `image` - `imageTag` - `digest` +- `distFreshness` for `hwlab-cloud-web` build/publish runs - `publishEnabled` - `artifactRequired` - `artifactScope` diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index c9139c94..ba6e5cbb 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -79,6 +79,10 @@ operator fields: - `devDeployApply.templateJobReplacements`: per-Job replacement decisions with namespace, Job name, old image, new image, and result (`planned`, `replaced`, `not_needed`, `not_found`, or blocked/failure states). +- `devDeployApply.cloudWebRollout`: Cloud Web source commit, image tag, image + reference, digest, live image, and Kubernetes rollout revision when read-only + Deployment access is available. If access is missing, this field records the + read/rollout commands and the blocker instead of claiming a live rollout. - `devDeployApply.manualCommands`: dry-run/report commands before human approval; the apply command is present only when the plan is ready. - `devDeployApply.rollbackHint`: pre-apply capture commands, deployment @@ -118,11 +122,15 @@ cd /home/ubuntu/workspace/hwlab git pull --ff-only origin main ``` -Refresh the static bundle before artifact publish. Cloud Web images serve -`web/hwlab-cloud-web/dist` first, so stale ignored build output can keep an old -English or old layout bundle alive even after the source commit is correct. +Validate source in a clean checkout, then refresh the static bundle before +artifact publish. Cloud Web images serve `web/hwlab-cloud-web/dist` first, so +stale ignored build output can keep an old English or old layout bundle alive +even after the source commit is correct. `check.mjs` does not require ignored +`dist/`; `build.mjs` and `dev-artifact-publish.mjs` are the build-first +freshness gates. ```sh +node web/hwlab-cloud-web/scripts/check.mjs node web/hwlab-cloud-web/scripts/build.mjs node scripts/dev-artifact-publish.mjs --publish --services hwlab-cloud-web --report reports/dev-gate/dev-artifacts-hwlab-cloud-web-.json ``` diff --git a/docs/reference/deployment-publish.md b/docs/reference/deployment-publish.md index 29da9c06..780b49dc 100644 --- a/docs/reference/deployment-publish.md +++ b/docs/reference/deployment-publish.md @@ -45,13 +45,19 @@ node scripts/dev-artifact-publish.mjs --publish For a single Cloud Web image: ```sh +node web/hwlab-cloud-web/scripts/check.mjs node web/hwlab-cloud-web/scripts/build.mjs node scripts/dev-artifact-publish.mjs --publish --services hwlab-cloud-web ``` Cloud Web must build `web/hwlab-cloud-web/dist` before publish because the runtime wrapper serves `dist` before source files. A correct source commit with -stale `dist` can still publish an old UI. +stale `dist` can still publish an old UI. The build/check pair is part of the +publish contract, but with separate responsibilities: `check.mjs` validates +source and does not require ignored `dist/` in a clean checkout. `build.mjs` +regenerates `dist` and verifies every runtime file and route alias matches +source. `scripts/dev-artifact-publish.mjs` runs that build/freshness gate and +records the `distFreshness` result before it builds the image. After a successful multi-service publish, refresh the artifact catalog from the publish report: @@ -118,6 +124,13 @@ The rollout must use D601 native k3s: KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev get deploy hwlab-cloud-web -o wide ``` +The rollout report must tie the live deployment back to artifact identity: +source commit, image tag, image reference, registry digest or `not_published`, +and Kubernetes rollout revision. `scripts/dev-deploy-apply.mjs` reports this +under `devDeployApply.cloudWebRollout` when read-only Deployment access exists; +without that access, it reports the blocker and the exact read/rollout commands +instead of claiming a live rollout. + Public verification must check the browser entry on `16666`, not legacy ports: @@ -216,6 +229,7 @@ or agent services as the runtime being validated. command and report contract. - [docs/dev-deploy-apply.md](../dev-deploy-apply.md): apply/report and Cloud Web manual rollout details. +- [web/hwlab-cloud-web/scripts/dist-contract.mjs](../../web/hwlab-cloud-web/scripts/dist-contract.mjs): Cloud Web `dist` freshness contract used by build/check/publish. - [pikasTech/HWLAB#61](https://github.com/pikasTech/HWLAB/issues/61): manual rollout review and automation target. - [pikasTech/HWLAB#116](https://github.com/pikasTech/HWLAB/issues/116): diff --git a/package.json b/package.json index 502cba1b..8d21d5d3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index 4d6f9bdd..b3eb70ae 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -18,6 +18,7 @@ import { } from "./src/dev-artifact-services.mjs"; import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs"; import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs"; +import { inspectCloudWebDistFreshness } from "../web/hwlab-cloud-web/scripts/dist-contract.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const defaultRegistryPrefix = @@ -718,6 +719,29 @@ async function buildService({ args, repo, commitId, shortCommit, service }) { logTail: tailText(`${build.stdout}\n${build.stderr}`) }; } + + const distFreshness = await inspectCloudWebDistFreshness(path.join(repoRoot, "web/hwlab-cloud-web")); + if (distFreshness.status !== "pass") { + return { + ...service, + image: ref, + imageTag: tag, + status: "build_failed", + digest: "not_published", + distFreshness, + blocker: blocker({ + type: "contract_blocker", + scope: service.serviceId, + summary: `cloud web dist is stale or incomplete before Docker build: ${distFreshness.mismatches.join(", ")}`, + next: "Run node web/hwlab-cloud-web/scripts/build.mjs and node web/hwlab-cloud-web/scripts/check.mjs before publishing hwlab-cloud-web." + }) + }; + } + + service = { + ...service, + distFreshness + }; } const port = servicePorts.get(service.serviceId) ?? 8080; @@ -902,9 +926,10 @@ function artifactNotPublishedReason(artifact, mode, fatalBlocked) { return artifact.notPublishedReason ?? "publish_not_run"; } -function artifactRecord({ args, service, shortCommit, status, digest = "not_published" }) { +function artifactRecord({ args, service, commitId, shortCommit, status, digest = "not_published" }) { return { ...service, + sourceCommitId: commitId, status, image: imageRef(args.registryPrefix, service.serviceId, shortCommit), imageTag: shortCommit, @@ -1047,6 +1072,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif digest: artifact.digest ?? "not_published", repositoryDigest: artifact.repositoryDigest ?? null, runtimeKind: artifact.runtimeKind, + distFreshness: artifact.distFreshness, implementationState: artifact.implementationState, sourceState: artifact.sourceState, entrypoint: artifact.entrypoint, @@ -1101,6 +1127,7 @@ async function main() { let artifacts = services.map((service) => artifactRecord({ args, service, + commitId, shortCommit, status: service.artifactRequired ? "preflight_only" : "not_required" })); @@ -1152,7 +1179,9 @@ async function main() { serviceId: service.serviceId, status: service.status, image: service.image, + imageTag: service.imageTag, digest: service.digest, + distFreshness: service.distFreshness, notPublishedReason: service.notPublishedReason })), blockers diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 8cef0150..b4dabf21 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -162,6 +162,7 @@ function runStaticSmoke() { const source = Object.values(files).join("\n"); const artifactPublisher = readText("scripts/dev-artifact-publish.mjs"); const buildScript = readText("web/hwlab-cloud-web/scripts/build.mjs"); + const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.mjs"); addCheck(checks, blockers, "static-web-assets", assetsExist(), "Cloud Web source assets are present.", { evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`) @@ -197,7 +198,7 @@ function runStaticSmoke() { evidence: diagnosticsRequiredTerms }); - addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", { + addCheck(checks, blockers, "internal-gate-route-aliases", gateRouteAliasesAreServed(files.app, artifactPublisher, buildScript, distContractScript), "/gate and /diagnostics/gate are direct internal diagnostic aliases, not default routes.", { blocker: "runtime_blocker", evidence: gateRouteAliases }); @@ -461,12 +462,15 @@ function gateRetainsDiagnostics(html, app) { return diagnosticsRequiredTerms.every((term) => gateSource.includes(term)); } -function gateRouteAliasesAreServed(app, artifactPublisher, buildScript) { +function gateRouteAliasesAreServed(app, artifactPublisher, buildScript, distContractScript) { + const buildCopiesAliases = + /\["gate", "diagnostics\/gate"\]/u.test(buildScript) || + (/gate\/index\.html/u.test(distContractScript) && /diagnostics\/gate\/index\.html/u.test(distContractScript)); return ( /internalGatePathnames\(\)\.has/u.test(app) && gateRouteAliases.every((route) => app.includes(`"${route}"`)) && gateRouteAliases.every((route) => artifactPublisher.includes(`routePath === "${route}"`)) && - /\["gate", "diagnostics\/gate"\]/u.test(buildScript) + buildCopiesAliases ); } diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index 0d5f8949..3a04dfc1 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -17,6 +17,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../ const reportPath = "reports/dev-gate/dev-deploy-report.json"; const namespace = "hwlab-dev"; const healthPath = "/health/live"; +const d601KubeconfigPath = "/etc/rancher/k3s/k3s.yaml"; const devTemplateJobReplacementPolicy = { status: "active", namespace, @@ -94,7 +95,11 @@ async function readText(relativePath) { async function commandResult(command, args, timeoutMs = 15000, options = {}) { return new Promise((resolve) => { - const child = spawn(command, args, { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(command, args, { + cwd: repoRoot, + env: options.env ?? process.env, + stdio: ["pipe", "pipe", "pipe"] + }); const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs); let stdout = ""; let stderr = ""; @@ -129,6 +134,64 @@ async function findExecutable(name) { return null; } +async function resolveD601Kubectl() { + const kubectlPath = await findExecutable("kubectl"); + const commandPrefix = `KUBECONFIG=${d601KubeconfigPath} kubectl`; + const base = { + executor: kubectlPath, + kubeconfig: d601KubeconfigPath, + commandPrefix, + env: { + ...process.env, + KUBECONFIG: d601KubeconfigPath + } + }; + + if (!kubectlPath) { + return { + ...base, + status: "blocked", + reason: "kubectl is not installed in the runner" + }; + } + + try { + await access(d601KubeconfigPath, fsConstants.R_OK); + } catch { + return { + ...base, + status: "blocked", + reason: `D601 k3s kubeconfig is not readable at ${d601KubeconfigPath}` + }; + } + + return { + ...base, + status: "ready", + reason: "D601 k3s kubeconfig is readable" + }; +} + +function kubectlCommand(kubectl, args) { + return `${kubectl.commandPrefix} ${args.join(" ")}`; +} + +async function kubectlResult(kubectl, args, timeoutMs = 15000, options = {}) { + if (kubectl.status !== "ready") { + return { + ok: false, + code: null, + stdout: "", + stderr: kubectl.reason, + blocked: true + }; + } + return commandResult(kubectl.executor, args, timeoutMs, { + ...options, + env: kubectl.env + }); +} + function listItems(document) { if (!document) return []; return document.kind === "List" ? document.items ?? [] : [document]; @@ -322,6 +385,103 @@ function buildArtifactPlan(deploy, catalog, sourceCommitId, artifactEvidence) { }; } +function artifactIdentityForService(deploy, catalog, serviceId) { + const deployService = deploy?.services?.find((service) => service.serviceId === serviceId) ?? null; + const catalogService = catalog?.services?.find((service) => service.serviceId === serviceId) ?? null; + return { + serviceId, + sourceCommitId: catalogService?.commitId ?? deploy?.commitId ?? "unknown", + image: deployService?.image ?? catalogService?.image ?? null, + imageTag: catalogService?.imageTag ?? parseImageTag(deployService?.image), + digest: catalogService?.digest ?? "not_published", + repositoryDigest: catalogService?.repositoryDigest ?? null, + publishState: catalogService?.publishState ?? "unknown" + }; +} + +function parseImageTag(image) { + if (typeof image !== "string") return null; + const slashIndex = image.lastIndexOf("/"); + const colonIndex = image.lastIndexOf(":"); + if (colonIndex <= slashIndex) return null; + return image.slice(colonIndex + 1); +} + +function deploymentRevision(deployment) { + return deployment?.metadata?.annotations?.["deployment.kubernetes.io/revision"] ?? null; +} + +function containerImageForDeployment(deployment, containerName) { + const containers = deployment?.spec?.template?.spec?.containers ?? []; + return containers.find((container) => container.name === containerName)?.image ?? null; +} + +function buildRolloutVerificationCommand(serviceId) { + return `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev rollout status deployment/${serviceId} --timeout=180s`; +} + +async function observeDeploymentRollout(kubectl, deploy, catalog, serviceId, blockers) { + const artifact = artifactIdentityForService(deploy, catalog, serviceId); + const commandArgs = ["-n", namespace, "get", "deployment", serviceId, "-o", "json"]; + const base = { + serviceId, + namespace, + status: "not_evaluated", + sourceCommitId: artifact.sourceCommitId, + image: artifact.image ?? "unknown", + imageTag: artifact.imageTag ?? "unknown", + digest: artifact.digest, + repositoryDigest: artifact.repositoryDigest, + publishState: artifact.publishState, + rolloutRevision: null, + liveImage: null, + imageMatchesDesired: false, + verificationCommand: buildRolloutVerificationCommand(serviceId), + readCommand: kubectlCommand(kubectl, commandArgs), + kubeconfig: kubectl.kubeconfig + }; + + if (kubectl.status !== "ready") { + addBlocker(blockers, "environment_blocker", `rollout-read-${serviceId}`, kubectl.reason); + return { + ...base, + status: "blocked", + blocker: kubectl.reason + }; + } + + const result = await kubectlResult(kubectl, commandArgs, 15000); + if (!result.ok) { + const summary = oneLine(result.stderr || result.stdout); + addBlocker(blockers, "environment_blocker", `rollout-read-${serviceId}`, `Cannot read DEV Deployment ${serviceId} rollout revision: ${summary}`); + return { + ...base, + status: "blocked", + blocker: summary + }; + } + + try { + const deployment = JSON.parse(result.stdout); + const liveImage = containerImageForDeployment(deployment, serviceId); + return { + ...base, + status: "observed", + rolloutRevision: deploymentRevision(deployment), + liveImage, + imageMatchesDesired: Boolean(artifact.image && liveImage === artifact.image) + }; + } catch (error) { + const summary = oneLine(error.message); + addBlocker(blockers, "contract_blocker", `rollout-read-${serviceId}`, `Cannot parse DEV Deployment ${serviceId} rollout revision: ${summary}`); + return { + ...base, + status: "blocked", + blocker: summary + }; + } +} + function blockerHint(blocker) { if (blocker.scope === "artifact-publish") { return "Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence."; @@ -329,12 +489,15 @@ function blockerHint(blocker) { if (blocker.scope === "artifact-source-commit") { return "Regenerate deploy/deploy.json and deploy/artifact-catalog.dev.json for the source commit that will be promoted."; } - if (blocker.scope === "kubectl" || blocker.scope === "kubectl-version") { - return "Provide a kubectl client configured for the D601 DEV k3s context without exposing token or secret values."; + if (blocker.scope === "kubectl" || blocker.scope === "d601-kubeconfig" || blocker.scope === "kubectl-version") { + return `Provide kubectl with readable D601 k3s kubeconfig at ${d601KubeconfigPath} without exposing token or secret values.`; } if (blocker.scope === "cluster-read") { return "Restore read-only visibility for pods, services, configmaps, deployments, and jobs in hwlab-dev."; } + if (blocker.scope.startsWith("rollout-read-")) { + return "Restore read-only Deployment visibility in hwlab-dev so rollout revision, image tag, digest, and source commit can be reported after apply."; + } if (blocker.scope === "dev-health" || blocker.scope === "dev-health-identity") { return "Restore the public DEV health path and confirm it identifies HWLAB dev, not a substitute runtime."; } @@ -366,13 +529,13 @@ function buildRollbackHint(workloads) { namespace, strategy: "DEV-only Kubernetes rollback using deployment revision history; do not touch PROD or UniDesk services.", captureBeforeApply: [ - "kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide", - "kubectl -n hwlab-dev get deployments -o json" + `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide`, + `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev get deployments -o json` ], - deploymentRollbackCommands: deploymentNames.map((name) => `kubectl -n hwlab-dev rollout undo deployment/${name}`), - jobCleanupCommands: jobNames.map((name) => `kubectl -n hwlab-dev delete job ${name} --ignore-not-found`), + deploymentRollbackCommands: deploymentNames.map((name) => `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev rollout undo deployment/${name}`), + jobCleanupCommands: jobNames.map((name) => `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev delete job ${name} --ignore-not-found`), postRollbackChecks: [ - "kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s", + `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s`, `curl -fsS ${new URL(healthPath, DEV_ENDPOINT).href}` ] }; @@ -403,8 +566,8 @@ function buildApplyBoundary(args, status, applyStep) { mutationAttempted: applyStep.mutationAttempted === true && args.apply, mutationAllowed: status === "pass" && args.apply, applyRequiresFlags: ["--apply", "--confirm-dev", "--confirmed-non-production"], - writeScope: "kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only; delete/recreate is allowed only for explicitly allowlisted suspended DEV template Jobs", - noWriteScope: "source/catalog/k8s validation, optional DEV health probe, kubectl read, replacement planning, and server-side dry-run only", + writeScope: `KUBECONFIG=${d601KubeconfigPath} kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only; delete/recreate is allowed only for explicitly allowlisted suspended DEV template Jobs`, + noWriteScope: `source/catalog/k8s validation, optional DEV health probe, KUBECONFIG=${d601KubeconfigPath} kubectl read, replacement planning, and server-side dry-run only`, forbiddenActions }; } @@ -751,25 +914,40 @@ function httpGet(url, timeoutMs) { }); } -async function observeCluster(kubectlPath, blockers) { - if (!kubectlPath) { - addBlocker(blockers, "environment_blocker", "kubectl", "kubectl is not installed in the runner"); - return { status: "blocked", executor: "missing", resources: [] }; +async function observeCluster(kubectl, blockers) { + const blockedBase = { + status: "blocked", + executor: kubectl.executor ?? "missing", + kubeconfig: kubectl.kubeconfig, + commandPrefix: kubectl.commandPrefix, + resources: [] + }; + if (!kubectl.executor) { + addBlocker(blockers, "environment_blocker", "kubectl", kubectl.reason); + return blockedBase; } - const version = await commandResult(kubectlPath, ["version", "--client=true", "--output=yaml"]); + if (kubectl.status !== "ready") { + addBlocker(blockers, "environment_blocker", "d601-kubeconfig", kubectl.reason); + return blockedBase; + } + const version = await kubectlResult(kubectl, ["version", "--client=true", "--output=yaml"]); if (!version.ok) { addBlocker(blockers, "environment_blocker", "kubectl-version", `kubectl client failed: ${version.stderr || version.stdout}`); - return { status: "blocked", executor: kubectlPath, resources: [] }; + return blockedBase; } - const get = await commandResult(kubectlPath, ["get", "pods,services,configmaps,deployments,jobs", "-n", namespace, "-o", "json"], 20000); + const getArgs = ["get", "pods,services,configmaps,deployments,jobs", "-n", namespace, "-o", "json"]; + const get = await kubectlResult(kubectl, getArgs, 20000); if (!get.ok) { addBlocker(blockers, "environment_blocker", "cluster-read", `Cannot read hwlab-dev resources: ${get.stderr || get.stdout}`); - return { status: "blocked", executor: kubectlPath, version: version.stdout, resources: [] }; + return { ...blockedBase, version: version.stdout, readCommand: kubectlCommand(kubectl, getArgs) }; } const parsed = JSON.parse(get.stdout); return { status: "pass", - executor: kubectlPath, + executor: kubectl.executor, + kubeconfig: kubectl.kubeconfig, + commandPrefix: kubectl.commandPrefix, + readCommand: kubectlCommand(kubectl, getArgs), version: version.stdout, resources: (parsed.items ?? []).map((item) => ({ kind: item.kind, @@ -785,11 +963,11 @@ function isNotFoundOutput(value) { return /\bnot\s+found\b|notfound/iu.test(value); } -async function readLiveTemplateJob(kubectlPath, jobName, blockers) { - if (!kubectlPath) { - return { status: "not_evaluated", reason: "kubectl is missing" }; +async function readLiveTemplateJob(kubectl, jobName, blockers) { + if (kubectl.status !== "ready") { + return { status: "not_evaluated", reason: kubectl.reason }; } - const get = await commandResult(kubectlPath, ["-n", namespace, "get", "job", jobName, "-o", "json"], 15000); + const get = await kubectlResult(kubectl, ["-n", namespace, "get", "job", jobName, "-o", "json"], 15000); if (!get.ok) { const output = get.stderr || get.stdout; if (isNotFoundOutput(output)) { @@ -806,12 +984,12 @@ async function readLiveTemplateJob(kubectlPath, jobName, blockers) { } } -async function buildDevTemplateJobReplacementPlan(kubectlPath, workloads, blockers) { +async function buildDevTemplateJobReplacementPlan(kubectl, workloads, blockers) { const desiredJobs = listItems(workloads).filter(isAllowedDesiredTemplateJob); const replacements = []; for (const desiredJob of desiredJobs) { const jobName = desiredJob.metadata.name; - const live = await readLiveTemplateJob(kubectlPath, jobName, blockers); + const live = await readLiveTemplateJob(kubectl, jobName, blockers); if (live.status === "not_evaluated") { replacements.push({ namespace, @@ -866,7 +1044,7 @@ async function buildDevTemplateJobReplacementPlan(kubectlPath, workloads, blocke return replacements; } -async function executeDevTemplateJobReplacements(kubectlPath, replacements, blockers) { +async function executeDevTemplateJobReplacements(kubectl, replacements, blockers) { const planned = replacements.filter((replacement) => replacement.replace && replacement.result === "planned"); for (const replacement of planned) { const commandArgs = [ @@ -877,8 +1055,8 @@ async function executeDevTemplateJobReplacements(kubectlPath, replacements, bloc replacement.jobName, "--ignore-not-found=true" ]; - const result = await commandResult(kubectlPath, commandArgs, 20000); - replacement.deleteCommand = `kubectl ${commandArgs.join(" ")}`; + const result = await kubectlResult(kubectl, commandArgs, 20000); + replacement.deleteCommand = kubectlCommand(kubectl, commandArgs); replacement.deleteStdout = result.stdout; replacement.deleteStderr = result.stderr; if (!result.ok) { @@ -913,7 +1091,7 @@ function isExpectedTemplateJobImmutableFailure(result, replacementsNeeded) { ); } -async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements) { +async function runApplyStep(args, kubectl, blockers, templateJobReplacements) { if (blockers.length > 0) { return { status: "not_run", summary: "Skipped because preflight blockers are open" }; } @@ -921,12 +1099,12 @@ async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements const replacementsNeeded = templateJobReplacements.filter((replacement) => replacement.replace); if (args.apply) { - const replaceCount = await executeDevTemplateJobReplacements(kubectlPath, templateJobReplacements, blockers); + const replaceCount = await executeDevTemplateJobReplacements(kubectl, templateJobReplacements, blockers); mutationAttempted = replaceCount > 0; if (blockers.length > 0) { return { status: "blocked", - command: "kubectl apply -k deploy/k8s/dev", + command: kubectlCommand(kubectl, ["apply", "-k", "deploy/k8s/dev"]), mutationAttempted, summary: "Skipped kubectl apply because DEV template Job replacement failed" }; @@ -936,7 +1114,7 @@ async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements const commandArgs = args.apply ? ["apply", "-k", "deploy/k8s/dev"] : ["apply", "--dry-run=server", "-k", "deploy/k8s/dev"]; - const result = await commandResult(kubectlPath, commandArgs, 30000); + const result = await kubectlResult(kubectl, commandArgs, 30000); const expectedImmutableDryRun = !args.apply && isExpectedTemplateJobImmutableFailure(result, replacementsNeeded); mutationAttempted = mutationAttempted || args.apply; if (!result.ok && !expectedImmutableDryRun) { @@ -952,7 +1130,7 @@ async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements } return { status: result.ok || expectedImmutableDryRun ? "pass" : "blocked", - command: `kubectl ${commandArgs.join(" ")}`, + command: kubectlCommand(kubectl, commandArgs), stdout: result.stdout, stderr: result.stderr, mutationAttempted, @@ -1007,13 +1185,14 @@ export async function runDevDeployApply(argv, io = {}) { addBlocker(blockers, "contract_blocker", "cloud-api-health-path", "cloud-api does not implement the k8s /health/live path"); } - const kubectlPath = await findExecutable("kubectl"); - const clusterObservation = await observeCluster(kubectlPath, blockers); + const kubectl = await resolveD601Kubectl(); + const clusterObservation = await observeCluster(kubectl, blockers); + const cloudWebRollout = await observeDeploymentRollout(kubectl, deploy, catalog, "hwlab-cloud-web", blockers); const liveProbe = args.skipLiveProbe ? { status: "not_run", reason: "--skip-live-probe" } : await probeDevEndpoint(blockers); - const templateJobReplacements = await buildDevTemplateJobReplacementPlan(kubectlPath, workloads, blockers); + const templateJobReplacements = await buildDevTemplateJobReplacementPlan(kubectl, workloads, blockers); const applyStep = await runApplyStep( args, - kubectlPath, + kubectl, blockers, templateJobReplacements ); @@ -1070,7 +1249,8 @@ export async function runDevDeployApply(argv, io = {}) { `expected artifact commit: ${artifactPlan.expectedArtifactCommit}`, `namespace: ${namespace}`, `workloads planned: ${workloadPlan.length}`, - `kubectl executor: ${kubectlPath ?? "missing"}`, + `kubectl executor: ${kubectl.executor ?? "missing"}`, + `kubectl kubeconfig: ${kubectl.kubeconfig}`, `live health: ${liveProbe.status}`, `template job replacements: ${templateJobReplacements.filter((replacement) => replacement.replace).length}` ], @@ -1102,6 +1282,7 @@ export async function runDevDeployApply(argv, io = {}) { servicePlan, templateJobReplacementPolicy: devTemplateJobReplacementPolicy, templateJobReplacements, + cloudWebRollout, applyStep, manualCommands, rollbackHint: buildRollbackHint(workloads), diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index de61ac04..1b86e14a 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -585,13 +585,37 @@ async function validateReport(relativePath) { const serviceLabel = `${label}.artifactPublish.services[${index}]`; assertObject(service, serviceLabel); assertString(service.serviceId, `${serviceLabel}.serviceId`); + if (Object.hasOwn(service, "sourceCommitId")) { + assertString(service.sourceCommitId, `${serviceLabel}.sourceCommitId`); + } assertString(service.status, `${serviceLabel}.status`); + assertString(service.image, `${serviceLabel}.image`); + assertString(service.imageTag, `${serviceLabel}.imageTag`); + assertShaOrNotPublished(service.digest, `${serviceLabel}.digest`); assertString(service.implementationState, `${serviceLabel}.implementationState`); assertString(service.sourceState, `${serviceLabel}.sourceState`); assert.ok( artifactSourceStates.has(service.sourceState), `${serviceLabel}.sourceState must be source-present or intentionally-disabled` ); + if (Object.hasOwn(service, "distFreshness")) { + assertObject(service.distFreshness, `${serviceLabel}.distFreshness`); + assert.equal(service.distFreshness.status, "pass", `${serviceLabel}.distFreshness.status`); + assert.equal(service.distFreshness.distPath, "web/hwlab-cloud-web/dist", `${serviceLabel}.distFreshness.distPath`); + assert.equal( + service.distFreshness.buildCommand, + "node web/hwlab-cloud-web/scripts/build.mjs", + `${serviceLabel}.distFreshness.buildCommand` + ); + assert.equal( + service.distFreshness.freshnessCommand, + "node web/hwlab-cloud-web/scripts/build.mjs", + `${serviceLabel}.distFreshness.freshnessCommand` + ); + assertArray(service.distFreshness.files, `${serviceLabel}.distFreshness.files`); + assertArray(service.distFreshness.mismatches, `${serviceLabel}.distFreshness.mismatches`); + assert.equal(service.distFreshness.mismatches.length, 0, `${serviceLabel}.distFreshness.mismatches`); + } if (service.sourceState === "intentionally-disabled") { assert.equal(service.entrypoint, null, `${serviceLabel}.entrypoint must be null when intentionally disabled`); } @@ -813,6 +837,48 @@ function assertDevDeployApplyReport(report, label) { assertString(replacement.reason, `${replacementLabel}.reason`); } + if (Object.hasOwn(plan, "cloudWebRollout")) { + assertObject(plan.cloudWebRollout, `${label}.devDeployApply.cloudWebRollout`); + for (const field of [ + "serviceId", + "namespace", + "status", + "sourceCommitId", + "image", + "imageTag", + "digest", + "publishState", + "rolloutRevision", + "liveImage", + "imageMatchesDesired", + "verificationCommand", + "readCommand" + ]) { + assert.ok(Object.hasOwn(plan.cloudWebRollout, field), `${label}.devDeployApply.cloudWebRollout missing ${field}`); + } + assert.equal(plan.cloudWebRollout.serviceId, "hwlab-cloud-web", `${label}.devDeployApply.cloudWebRollout.serviceId`); + assert.equal(plan.cloudWebRollout.namespace, "hwlab-dev", `${label}.devDeployApply.cloudWebRollout.namespace`); + assert.ok(["observed", "blocked", "not_evaluated"].includes(plan.cloudWebRollout.status), `${label}.devDeployApply.cloudWebRollout.status`); + assertString(plan.cloudWebRollout.sourceCommitId, `${label}.devDeployApply.cloudWebRollout.sourceCommitId`); + assertString(plan.cloudWebRollout.image, `${label}.devDeployApply.cloudWebRollout.image`); + assertString(plan.cloudWebRollout.imageTag, `${label}.devDeployApply.cloudWebRollout.imageTag`); + assertShaOrNotPublished(plan.cloudWebRollout.digest, `${label}.devDeployApply.cloudWebRollout.digest`); + if (plan.cloudWebRollout.rolloutRevision !== null) { + assertString(plan.cloudWebRollout.rolloutRevision, `${label}.devDeployApply.cloudWebRollout.rolloutRevision`); + } + if (plan.cloudWebRollout.liveImage !== null) { + assertString(plan.cloudWebRollout.liveImage, `${label}.devDeployApply.cloudWebRollout.liveImage`); + } + assert.equal(typeof plan.cloudWebRollout.imageMatchesDesired, "boolean", `${label}.devDeployApply.cloudWebRollout.imageMatchesDesired`); + assert.match(plan.cloudWebRollout.verificationCommand, /rollout status deployment\/hwlab-cloud-web/u, `${label}.devDeployApply.cloudWebRollout.verificationCommand`); + assert.match(plan.cloudWebRollout.verificationCommand, /^KUBECONFIG=\/etc\/rancher\/k3s\/k3s\.yaml kubectl /u, `${label}.devDeployApply.cloudWebRollout.verificationCommand`); + assert.match(plan.cloudWebRollout.readCommand, /get deployment hwlab-cloud-web/u, `${label}.devDeployApply.cloudWebRollout.readCommand`); + assert.match(plan.cloudWebRollout.readCommand, /^KUBECONFIG=\/etc\/rancher\/k3s\/k3s\.yaml kubectl /u, `${label}.devDeployApply.cloudWebRollout.readCommand`); + if (plan.cloudWebRollout.status === "blocked") { + assertString(plan.cloudWebRollout.blocker, `${label}.devDeployApply.cloudWebRollout.blocker`); + } + } + assertObject(plan.manualCommands, `${label}.devDeployApply.manualCommands`); assert.ok(["blocked", "ready"].includes(plan.manualCommands.status), `${label}.devDeployApply.manualCommands.status`); assertCommandList(plan.manualCommands.beforeHumanApproval, `${label}.devDeployApply.manualCommands.beforeHumanApproval`); diff --git a/web/hwlab-cloud-web/scripts/build.mjs b/web/hwlab-cloud-web/scripts/build.mjs index 0d287a3e..afba036b 100644 --- a/web/hwlab-cloud-web/scripts/build.mjs +++ b/web/hwlab-cloud-web/scripts/build.mjs @@ -1,32 +1,9 @@ -import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; -const rootDir = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); -const distDir = path.resolve(rootDir, "dist"); -fs.mkdirSync(distDir, { recursive: true }); +import { assertCloudWebDistFresh, buildCloudWebDist } from "./dist-contract.mjs"; -const runtimeFiles = [ - "index.html", - "styles.css", - "app.mjs", - "gate-summary.mjs", - "runtime.mjs", - "workbench-hardware-panel.mjs", - "help.md", - "third_party/marked/marked.esm.js", - "third_party/marked/LICENSE" -]; - -for (const file of runtimeFiles) { - const targetPath = path.resolve(distDir, file); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.copyFileSync(path.resolve(rootDir, file), targetPath); -} - -for (const alias of ["gate", "diagnostics/gate"]) { - const targetPath = path.resolve(distDir, alias, "index.html"); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.copyFileSync(path.resolve(rootDir, "index.html"), targetPath); -} - -console.log(`hwlab-cloud-web build ok: ${distDir}`); +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = buildCloudWebDist(rootDir); +const distFreshness = await assertCloudWebDistFresh(rootDir); +console.log(`hwlab-cloud-web build ok: ${distDir} (${distFreshness.files.length} dist files verified fresh)`); diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 722a9fe3..3d1df502 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -34,18 +34,17 @@ const styles = fs.readFileSync(path.resolve(rootDir, "styles.css"), "utf8"); const app = fs.readFileSync(path.resolve(rootDir, "app.mjs"), "utf8"); const helpMarkdown = fs.readFileSync(path.resolve(rootDir, "help.md"), "utf8"); const buildScript = fs.readFileSync(path.resolve(rootDir, "scripts/build.mjs"), "utf8"); +const distContractScript = fs.readFileSync(path.resolve(rootDir, "scripts/dist-contract.mjs"), "utf8"); const markedLicense = fs.readFileSync(path.resolve(rootDir, "third_party/marked/LICENSE"), "utf8"); const artifactPublisher = fs.readFileSync(path.resolve(repoRoot, "scripts/dev-artifact-publish.mjs"), "utf8"); const frontendSource = `${html}\n${app}\n${artifactPublisher}`; const workbenchSmoke = runDevCloudWorkbenchStaticSmoke(); const helpSmokeCheck = workbenchSmoke.checks.find((check) => check.id === "help-md-contract"); -const gateAliasCheck = workbenchSmoke.checks.find((check) => check.id === "internal-gate-route-aliases"); assert.equal(workbenchSmoke.status, "pass", JSON.stringify(workbenchSmoke.blockers, null, 2)); assert.equal(workbenchSmoke.evidenceLevel, "SOURCE"); assert.equal(workbenchSmoke.devLive, false); assert.equal(helpSmokeCheck?.status, "pass", "PR #114 help Markdown route must be present and ready"); -assert.equal(gateAliasCheck?.status, "pass", "internal Gate aliases must be directly served"); assert.equal(workbenchSmoke.help.status, "pass", "smoke report must not leave #114 help contract pending"); assert.match(html, /HWLAB 云工作台/); @@ -203,11 +202,12 @@ assert.match(functionBody(app, "renderWiringList"), /M3_TRUSTED_ROUTE\.fromResou assert.match(functionBody(app, "renderWiringList"), /M3_TRUSTED_ROUTE\.patchPanelServiceId/); assert.doesNotMatch(functionBody(app, "renderWiringList"), /activeConnections\.map/u); assert.doesNotMatch(functionBody(app, "renderWiringList"), /link\.fromResourceId/u); -assert.match(buildScript, /"help\.md"/); -assert.match(buildScript, /"third_party\/marked\/marked\.esm\.js"/); -assert.match(buildScript, /"third_party\/marked\/LICENSE"/); -assert.match(buildScript, /\["gate", "diagnostics\/gate"\]/); -assert.match(buildScript, /alias,\s*"index\.html"/); +assert.match(buildScript, /buildCloudWebDist/); +assert.match(distContractScript, /"help\.md"/); +assert.match(distContractScript, /"third_party\/marked\/marked\.esm\.js"/); +assert.match(distContractScript, /"third_party\/marked\/LICENSE"/); +assert.match(distContractScript, /gate\/index\.html/); +assert.match(distContractScript, /diagnostics\/gate\/index\.html/); assert.match(markedLicense, /MarkedJS/); assert.match(markedLicense, /Permission is hereby granted, free of charge/); assert.match(html, /live-status/); diff --git a/web/hwlab-cloud-web/scripts/dist-contract.mjs b/web/hwlab-cloud-web/scripts/dist-contract.mjs new file mode 100644 index 00000000..cf9ab008 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/dist-contract.mjs @@ -0,0 +1,132 @@ +import fs from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +export const cloudWebDistPath = "web/hwlab-cloud-web/dist"; +export const cloudWebDistBuildCommand = "node web/hwlab-cloud-web/scripts/build.mjs"; +export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand; +export const cloudWebDistRuntimeFiles = Object.freeze([ + "index.html", + "styles.css", + "app.mjs", + "gate-summary.mjs", + "runtime.mjs", + "workbench-hardware-panel.mjs", + "help.md", + "third_party/marked/marked.esm.js", + "third_party/marked/LICENSE" +]); +export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html"]); + +function absolutePath(rootDir, relativePath) { + return path.resolve(rootDir, relativePath); +} + +function exists(filePath) { + return fs.existsSync(filePath); +} + +async function compareFile({ files, mismatches, kind, relativePath, sourcePath, distPath }) { + const entry = { + kind, + path: relativePath, + sourcePath, + distPath, + status: "match" + }; + + if (!exists(sourcePath)) { + entry.status = "missing_source"; + entry.reason = "source file is missing"; + } else if (!exists(distPath)) { + entry.status = "missing_dist"; + entry.reason = "dist file is missing"; + } else { + const [sourceText, distText] = await Promise.all([ + readFile(sourcePath, "utf8"), + readFile(distPath, "utf8") + ]); + if (sourceText !== distText) { + entry.status = "mismatch"; + entry.reason = "dist file differs from source"; + } + } + + if (entry.status !== "match") { + mismatches.push(relativePath); + } + files.push(entry); +} + +export function buildCloudWebDist(rootDir) { + const distRoot = absolutePath(rootDir, "dist"); + fs.rmSync(distRoot, { recursive: true, force: true }); + fs.mkdirSync(distRoot, { recursive: true }); + + for (const relativePath of cloudWebDistRuntimeFiles) { + const distPath = absolutePath(distRoot, relativePath); + fs.mkdirSync(path.dirname(distPath), { recursive: true }); + fs.copyFileSync(absolutePath(rootDir, relativePath), distPath); + } + + for (const aliasPath of cloudWebDistAliasFiles) { + const distPath = absolutePath(distRoot, aliasPath); + fs.mkdirSync(path.dirname(distPath), { recursive: true }); + fs.copyFileSync(absolutePath(rootDir, "index.html"), distPath); + } + + return distRoot; +} + +export async function inspectCloudWebDistFreshness(rootDir) { + const distRoot = absolutePath(rootDir, "dist"); + const files = []; + const mismatches = []; + + for (const relativePath of cloudWebDistRuntimeFiles) { + await compareFile({ + files, + mismatches, + kind: "runtime", + relativePath, + sourcePath: absolutePath(rootDir, relativePath), + distPath: absolutePath(distRoot, relativePath) + }); + } + + for (const aliasPath of cloudWebDistAliasFiles) { + await compareFile({ + files, + mismatches, + kind: "alias", + relativePath: aliasPath, + sourcePath: absolutePath(rootDir, "index.html"), + distPath: absolutePath(distRoot, aliasPath) + }); + } + + return { + status: mismatches.length === 0 ? "pass" : "blocked", + distPath: cloudWebDistPath, + buildCommand: cloudWebDistBuildCommand, + freshnessCommand: cloudWebDistFreshnessCommand, + files, + mismatches + }; +} + +export function cloudWebDistFreshnessMessage(distFreshness) { + const mismatches = distFreshness.mismatches.length > 0 ? distFreshness.mismatches.join(", ") : "unknown"; + return [ + `Cloud Web dist is stale or missing before image build: ${mismatches}.`, + `Run ${cloudWebDistBuildCommand} before publishing ${cloudWebDistPath}.` + ].join(" "); +} + +export async function assertCloudWebDistFresh(rootDir) { + const distFreshness = await inspectCloudWebDistFreshness(rootDir); + if (distFreshness.status !== "pass") { + throw new Error(cloudWebDistFreshnessMessage(distFreshness)); + } + return distFreshness; +}