diff --git a/docs/runtime-boundary-guard.md b/docs/runtime-boundary-guard.md new file mode 100644 index 00000000..e1f9ee1a --- /dev/null +++ b/docs/runtime-boundary-guard.md @@ -0,0 +1,60 @@ +# HWLAB Runtime Boundary Guard + +The HWLAB runtime may use UniDesk CI/CD infrastructure and Code Queue +scheduling, but it must not use UniDesk backend, provider-gateway, or +microservice proxy as replacement runtime services. Those systems are external +supporting infrastructure only. + +This guard is intentionally static. It reads repository files and does not +contact the DEV endpoint, deploy to DEV or PROD, read secrets, or restart any +UniDesk or HWLAB runtime process. + +## Boundary Rule + +- HWLAB runtime service identity is the frozen `hwlab-*` service ID set. +- DEV is the only accepted runtime environment for this guard. +- The fixed DEV endpoint is `http://74.48.78.17:6667`. +- The DEV namespace is `hwlab-dev`. +- PROD deployment and PROD acceptance remain disabled. +- UniDesk backend, provider-gateway, and microservice proxy are forbidden + runtime substitutes. + +UniDesk may remain in the control-plane path for CI, CD, and Code Queue +scheduling. It must not terminate HWLAB health, API, gateway, router, tunnel, +agent, simulator, patch-panel, CLI, or skills runtime contracts on behalf of +HWLAB. + +## Checked Artifacts + +The machine-readable fixture is +`protocol/examples/runtime-boundary/guard.dev.json`. It records the expected +DEV-only boundary, frozen service IDs, forbidden substitutes, and paths checked +by `scripts/validate-runtime-boundary.mjs`. + +The validator checks: + +- `deploy/deploy.json` keeps `environment: dev`, `hwlab-dev`, the fixed DEV + endpoint, the complete HWLAB service set, HWLAB image names, and a disabled + PROD profile. +- `deploy/master-edge/health-contract.json` keeps the `frp` D601-to-master + reverse link, HWLAB contract service IDs, and the forbidden substitute list. +- `deploy/k8s/base/workloads.yaml` keeps workload names, labels, container + names, and images under HWLAB service IDs. +- `deploy/k8s/base/services.yaml` keeps Kubernetes service names and labels + under HWLAB service IDs. +- `deploy/k8s/dev/*` keeps the DEV overlay and runtime substitute policy. +- `deploy/k8s/prod/*` keeps PROD as a disabled placeholder only. + +## Validation + +Run: + +```sh +node --check scripts/validate-runtime-boundary.mjs +node scripts/validate-runtime-boundary.mjs +``` + +The script is lightweight and has no package dependency. It fails if a checked +artifact drifts toward a UniDesk runtime substitute, a non-DEV environment, an +unexpected namespace, an unexpected endpoint, or an incomplete HWLAB service +boundary. diff --git a/protocol/examples/runtime-boundary/guard.dev.json b/protocol/examples/runtime-boundary/guard.dev.json new file mode 100644 index 00000000..90f2daef --- /dev/null +++ b/protocol/examples/runtime-boundary/guard.dev.json @@ -0,0 +1,104 @@ +{ + "guard": "hwlab-runtime-boundary", + "schemaVersion": "v1", + "environment": "dev", + "endpoint": "http://74.48.78.17:6667", + "namespace": "hwlab-dev", + "scope": "static-runtime-boundary", + "policy": { + "runtimeSubstitution": "forbidden", + "prodDeployment": "forbidden", + "realDevDeployment": "out_of_scope", + "allowedUniDeskRoles": ["ci", "cd", "code_queue_scheduling"], + "forbiddenRuntimeSubstitutes": [ + "unidesk-backend", + "provider-gateway", + "microservice-proxy" + ] + }, + "requiredManifest": { + "path": "deploy/deploy.json", + "environment": "dev", + "namespace": "hwlab-dev", + "prodProfileEnabled": false, + "requiredServiceIds": [ + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-gateway", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-router", + "hwlab-tunnel-client", + "hwlab-edge-proxy", + "hwlab-cli", + "hwlab-agent-skills" + ] + }, + "requiredHealthContract": { + "path": "deploy/master-edge/health-contract.json", + "reverseLink": { + "mode": "frp", + "direction": "d601-to-master", + "client": "hwlab-tunnel-client" + }, + "requiredContractServiceIds": [ + "hwlab-edge-proxy", + "hwlab-router", + "hwlab-tunnel-client", + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel" + ] + }, + "requiredK3sSkeleton": { + "workloadsPath": "deploy/k8s/base/workloads.yaml", + "servicesPath": "deploy/k8s/base/services.yaml", + "devKustomizationPath": "deploy/k8s/dev/kustomization.yaml", + "devHealthContractPath": "deploy/k8s/dev/health-contract.yaml", + "prodKustomizationPath": "deploy/k8s/prod/kustomization.yaml", + "prodDisabledPath": "deploy/k8s/prod/prod-disabled.yaml", + "requiredWorkloadServiceIds": [ + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-gateway", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-router", + "hwlab-tunnel-client", + "hwlab-edge-proxy", + "hwlab-cli", + "hwlab-agent-skills" + ], + "requiredServiceServiceIds": [ + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-gateway", + "hwlab-cli", + "hwlab-agent-skills", + "hwlab-agent-mgr", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-router", + "hwlab-tunnel-client", + "hwlab-edge-proxy" + ] + }, + "validation": { + "script": "scripts/validate-runtime-boundary.mjs", + "commands": [ + "node --check scripts/validate-runtime-boundary.mjs", + "node scripts/validate-runtime-boundary.mjs" + ] + } +} diff --git a/scripts/validate-runtime-boundary.mjs b/scripts/validate-runtime-boundary.mjs new file mode 100644 index 00000000..20ad0298 --- /dev/null +++ b/scripts/validate-runtime-boundary.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const serviceIdLabel = "hwlab.pikastech.local/service-id"; +const namespace = "hwlab-dev"; +const forbiddenRuntimeSubstitutes = Object.freeze([ + "unidesk-backend", + "provider-gateway", + "microservice-proxy" +]); + +async function readText(relativePath) { + return readFile(path.join(repoRoot, relativePath), "utf8"); +} + +async function readJSON(relativePath) { + const raw = await readText(relativePath); + return JSON.parse(raw); +} + +function assertObject(value, label) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); +} + +function assertArray(value, label) { + assert.ok(Array.isArray(value), `${label} must be an array`); +} + +function assertUnique(values, label) { + assert.equal(new Set(values).size, values.length, `${label} must be unique`); +} + +function assertSameMembers(actual, expected, label) { + assert.deepEqual([...actual].sort(), [...expected].sort(), `${label} must match expected members`); +} + +function assertServiceId(serviceId, label) { + assert.ok(SERVICE_IDS.includes(serviceId), `${label} uses unknown serviceId ${JSON.stringify(serviceId)}`); + assert.match(serviceId, /^hwlab-/, `${label} must be an HWLAB service id`); + assertNoForbiddenSubstitute(serviceId, label); +} + +function assertDevOnlyEnvironment(value, label) { + assert.equal(value, ENVIRONMENT_DEV, `${label} must be dev`); +} + +function assertDevNamespace(value, label) { + assert.equal(value, namespace, `${label} must be ${namespace}`); +} + +function assertForbiddenList(actual, label) { + assertArray(actual, label); + assertSameMembers(actual, forbiddenRuntimeSubstitutes, label); +} + +function splitCsv(value) { + assert.equal(typeof value, "string", "CSV value must be a string"); + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function assertNoForbiddenSubstitute(value, label) { + if (typeof value !== "string") { + return; + } + const lower = value.toLowerCase(); + for (const forbidden of forbiddenRuntimeSubstitutes) { + assert.equal( + lower.includes(forbidden), + false, + `${label} must not use forbidden runtime substitute ${forbidden}` + ); + } +} + +function assertNoForbiddenSubstitutesDeep(value, label) { + if ( + label.endsWith(".HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN") || + label.endsWith(".forbiddenRuntimeSubstitutes") + ) { + return; + } + + if (typeof value === "string") { + assertNoForbiddenSubstitute(value, label); + return; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => assertNoForbiddenSubstitutesDeep(item, `${label}[${index}]`)); + return; + } + + if (value && typeof value === "object") { + if (value.name === "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN") { + return; + } + + for (const [key, item] of Object.entries(value)) { + assertNoForbiddenSubstitutesDeep(item, `${label}.${key}`); + } + } +} + +function containerList(workload, label) { + const template = workload.kind === "Job" ? workload.spec?.template : workload.spec?.template; + const containers = template?.spec?.containers; + assertArray(containers, `${label}.containers`); + return containers; +} + +function labelsFor(resource, label) { + const labels = resource.metadata?.labels; + assertObject(labels, `${label}.metadata.labels`); + return labels; +} + +function assertMetadataServiceId(resource, expectedServiceId, label) { + assert.equal(resource.metadata?.name, expectedServiceId, `${label}.metadata.name must match service id`); + assertDevNamespace(resource.metadata?.namespace, `${label}.metadata.namespace`); + const labels = labelsFor(resource, label); + assert.equal(labels[serviceIdLabel], expectedServiceId, `${label} service-id label`); + assert.equal(labels["app.kubernetes.io/name"], expectedServiceId, `${label} app name label`); +} + +function assertTemplateServiceId(workload, expectedServiceId, label) { + const templateLabels = workload.spec?.template?.metadata?.labels; + assertObject(templateLabels, `${label}.spec.template.metadata.labels`); + assert.equal(templateLabels[serviceIdLabel], expectedServiceId, `${label} template service-id label`); + assert.equal(templateLabels["app.kubernetes.io/name"], expectedServiceId, `${label} template app name label`); +} + +function assertHwlabImage(image, serviceId, label) { + assert.equal( + typeof image, + "string", + `${label}.image must be a string` + ); + assert.match( + image, + new RegExp(`^ghcr\\.io/pikastech/${serviceId}:[a-f0-9]{7,40}$`), + `${label}.image must be the HWLAB image for ${serviceId}` + ); +} + +function assertHealthProbe(container, serviceId, label) { + if (serviceId === "hwlab-agent-worker" || serviceId === "hwlab-cli") { + return; + } + + for (const probeName of ["readinessProbe", "livenessProbe"]) { + assert.equal( + container[probeName]?.httpGet?.path, + "/health/live", + `${label}.${probeName} must use /health/live` + ); + } +} + +function assertNoExternalNameSubstitute(service, label) { + assert.notEqual(service.spec?.type, "ExternalName", `${label} must not be an ExternalName substitute`); + assert.equal(service.spec?.type, "ClusterIP", `${label} must be a ClusterIP skeleton service`); +} + +function assertKustomizationDevOnly(kustomization, label) { + assert.equal(kustomization.kind, "Kustomization", `${label}.kind`); + assertDevNamespace(kustomization.namespace, `${label}.namespace`); + assert.equal(kustomization.commonLabels?.["hwlab.pikastech.local/profile"], "dev", `${label} profile label`); + assert.ok(kustomization.resources?.includes("../base"), `${label} must include ../base`); +} + +function assertRuntimePolicyText(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + assert.ok(value.includes("HWLAB runtime"), `${label} must name HWLAB runtime`); + assert.ok(value.includes("UniDesk backend"), `${label} must name UniDesk backend`); + assert.ok(value.includes("provider-gateway"), `${label} must name provider-gateway`); + assert.ok(value.includes("microservice proxy"), `${label} must name microservice proxy`); +} + +function assertGuardFixture(guard) { + assert.equal(guard.guard, "hwlab-runtime-boundary"); + assert.equal(guard.schemaVersion, "v1"); + assertDevOnlyEnvironment(guard.environment, "guard.environment"); + assert.equal(guard.endpoint, DEV_ENDPOINT, "guard.endpoint"); + assertDevNamespace(guard.namespace, "guard.namespace"); + assert.equal(guard.policy?.runtimeSubstitution, "forbidden", "guard runtime substitution policy"); + assert.equal(guard.policy?.prodDeployment, "forbidden", "guard prod policy"); + assertForbiddenList(guard.policy?.forbiddenRuntimeSubstitutes, "guard forbidden substitutes"); + assertSameMembers(guard.requiredManifest?.requiredServiceIds, SERVICE_IDS, "guard manifest service ids"); + assertSameMembers(guard.requiredK3sSkeleton?.requiredWorkloadServiceIds, SERVICE_IDS, "guard workload service ids"); + assert.ok( + guard.validation?.commands?.includes("node scripts/validate-runtime-boundary.mjs"), + "guard must record executable validation command" + ); +} + +function assertDeployManifest(manifest, guard) { + assert.equal(manifest.manifestVersion, "v1"); + assertDevOnlyEnvironment(manifest.environment, "deploy manifest environment"); + assertDevNamespace(manifest.namespace, "deploy manifest namespace"); + assert.equal(manifest.endpoint, DEV_ENDPOINT, "deploy manifest endpoint"); + assert.ok(manifest.profiles?.dev?.enabled, "deploy manifest dev profile must be enabled"); + assertDevNamespace(manifest.profiles.dev.namespace, "deploy manifest dev profile namespace"); + assert.equal(manifest.profiles.dev.endpoint, DEV_ENDPOINT, "deploy manifest dev profile endpoint"); + assert.equal(manifest.profiles?.prod?.enabled, false, "deploy manifest prod profile must stay disabled"); + + assertArray(manifest.services, "deploy manifest services"); + const serviceIds = manifest.services.map((service) => service.serviceId); + assertUnique(serviceIds, "deploy manifest service ids"); + assertSameMembers(serviceIds, guard.requiredManifest.requiredServiceIds, "deploy manifest service ids"); + assertSameMembers(serviceIds, SERVICE_IDS, "deploy manifest service ids"); + + for (const service of manifest.services) { + assertServiceId(service.serviceId, `deploy service ${service.serviceId}`); + assertHwlabImage(service.image, service.serviceId, `deploy service ${service.serviceId}`); + assertDevNamespace(service.namespace, `${service.serviceId}.namespace`); + assert.equal(service.profile, "dev", `${service.serviceId}.profile must be dev`); + assert.match(service.healthPath, /^\//, `${service.serviceId}.healthPath must be absolute`); + assertNoForbiddenSubstitutesDeep(service.env ?? {}, `${service.serviceId}.env`); + } + + const cloudApi = manifest.services.find((service) => service.serviceId === "hwlab-cloud-api"); + assertForbiddenList( + splitCsv(cloudApi?.env?.HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN), + "hwlab-cloud-api HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN" + ); +} + +function assertMasterHealthContract(contract, guard) { + assertDevOnlyEnvironment(contract.environment, "master health contract environment"); + assert.equal(contract.endpoint, DEV_ENDPOINT, "master health contract endpoint"); + assert.equal(contract.prodAcceptance, false, "master health contract prodAcceptance"); + assert.equal(contract.reverseLink?.mode, guard.requiredHealthContract.reverseLink.mode, "reverse link mode"); + assert.equal( + contract.reverseLink?.direction, + guard.requiredHealthContract.reverseLink.direction, + "reverse link direction" + ); + assert.equal(contract.reverseLink?.client, "hwlab-tunnel-client", "reverse link client"); + assert.equal(contract.reverseLink?.publicPort, 6667, "reverse link public port"); + assertForbiddenList(contract.forbiddenRuntimeSubstitutes, "master forbidden substitutes"); + + assertArray(contract.contracts, "master health contracts"); + const serviceIds = contract.contracts.map((item) => item.serviceId); + assertUnique(serviceIds, "master health contract service ids"); + assertSameMembers( + serviceIds, + guard.requiredHealthContract.requiredContractServiceIds, + "master health contract service ids" + ); + for (const item of contract.contracts) { + assertServiceId(item.serviceId, `master health contract ${item.serviceId}`); + } +} + +function assertWorkloads(workloads, guard) { + assert.equal(workloads.kind, "List", "workloads kind"); + assertArray(workloads.items, "workloads items"); + + const serviceIds = workloads.items.map((item) => item.metadata?.labels?.[serviceIdLabel]); + assertUnique(serviceIds, "workload service ids"); + assertSameMembers(serviceIds, guard.requiredK3sSkeleton.requiredWorkloadServiceIds, "workload service ids"); + + for (const workload of workloads.items) { + const serviceId = workload.metadata?.labels?.[serviceIdLabel]; + assertServiceId(serviceId, `workload ${workload.metadata?.name}`); + const expectedName = workload.kind === "Job" ? `${serviceId}-template` : serviceId; + assert.equal(workload.metadata?.name, expectedName, `${serviceId} workload name`); + assertDevNamespace(workload.metadata?.namespace, `${serviceId} workload namespace`); + assert.equal(labelsFor(workload, serviceId)["app.kubernetes.io/name"], serviceId, `${serviceId} workload app label`); + assertTemplateServiceId(workload, serviceId, `workload ${serviceId}`); + + const containers = containerList(workload, `workload ${serviceId}`); + assert.equal(containers.length, 1, `${serviceId} workload must have one container`); + const container = containers[0]; + assert.equal(container.name, serviceId, `${serviceId} container name`); + assertHwlabImage(container.image, serviceId, `${serviceId} container`); + assertHealthProbe(container, serviceId, `${serviceId} container`); + assertNoForbiddenSubstitutesDeep(container.env ?? [], `${serviceId} container env`); + } +} + +function assertServices(services, guard) { + assert.equal(services.kind, "List", "services kind"); + assertArray(services.items, "services items"); + + const serviceIds = services.items.map((item) => item.metadata?.labels?.[serviceIdLabel]); + assertUnique(serviceIds, "k8s service ids"); + assertSameMembers(serviceIds, guard.requiredK3sSkeleton.requiredServiceServiceIds, "k8s service ids"); + + for (const service of services.items) { + const serviceId = service.metadata?.labels?.[serviceIdLabel]; + assertServiceId(serviceId, `k8s service ${service.metadata?.name}`); + assertMetadataServiceId(service, serviceId, `k8s service ${serviceId}`); + assertNoExternalNameSubstitute(service, `k8s service ${serviceId}`); + assert.equal(service.spec?.selector?.["app.kubernetes.io/name"], serviceId, `${serviceId} selector`); + } +} + +function assertDevHealthConfig(configMap) { + assert.equal(configMap.kind, "ConfigMap", "dev health contract kind"); + assertDevNamespace(configMap.metadata?.namespace, "dev health contract namespace"); + assert.equal(configMap.data?.endpoint, DEV_ENDPOINT, "dev health contract endpoint"); + assertRuntimePolicyText(configMap.data?.["runtime-substitute-policy"], "dev runtime substitute policy"); +} + +function assertProdDisabled(prodKustomization, prodDisabled) { + assert.equal(prodKustomization.kind, "Kustomization", "prod kustomization kind"); + assert.deepEqual(prodKustomization.resources, ["prod-disabled.yaml"], "prod kustomization resources"); + assert.equal(prodDisabled.kind, "ConfigMap", "prod disabled kind"); + assert.equal(prodDisabled.metadata?.labels?.["hwlab.pikastech.local/profile"], "prod", "prod disabled profile"); + assert.equal( + prodDisabled.metadata?.labels?.["hwlab.pikastech.local/acceptance"], + "disabled", + "prod disabled acceptance" + ); + assert.equal(prodDisabled.data?.status, "disabled", "prod disabled status"); +} + +const guard = await readJSON("protocol/examples/runtime-boundary/guard.dev.json"); +assertGuardFixture(guard); + +assertDeployManifest(await readJSON(guard.requiredManifest.path), guard); +assertMasterHealthContract(await readJSON(guard.requiredHealthContract.path), guard); +assertWorkloads(await readJSON(guard.requiredK3sSkeleton.workloadsPath), guard); +assertServices(await readJSON(guard.requiredK3sSkeleton.servicesPath), guard); +assertKustomizationDevOnly(await readJSON(guard.requiredK3sSkeleton.devKustomizationPath), "dev kustomization"); +assertDevHealthConfig(await readJSON(guard.requiredK3sSkeleton.devHealthContractPath)); +assertProdDisabled( + await readJSON(guard.requiredK3sSkeleton.prodKustomizationPath), + await readJSON(guard.requiredK3sSkeleton.prodDisabledPath) +); + +console.log( + `validated HWLAB runtime boundary guard for ${SERVICE_IDS.length} services; forbidden substitutes: ${forbiddenRuntimeSubstitutes.join(", ")}` +);