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:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user