Formalize Cloud Web DEV CD contract

Merge PR #152 after Code Queue rebase and validation. This lands the DEV Cloud Web publish/apply contract, dist freshness checks, artifact identity evidence, and read-only rollout observation. No PROD changes and no live deploy claim.
This commit is contained in:
Lyon
2026-05-23 00:20:19 +08:00
committed by GitHub
parent eb710860dd
commit a44438779f
11 changed files with 509 additions and 85 deletions
+15 -2
View File
@@ -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`
+11 -3
View File
@@ -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-<tag>.json
```
+15 -1
View File
@@ -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):
+1 -1
View File
@@ -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",
+30 -1
View File
@@ -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
@@ -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
);
}
+219 -38
View File
@@ -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),
+66
View File
@@ -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`);
+6 -29
View File
@@ -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)`);
+7 -7
View File
@@ -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/);
@@ -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;
}