From edd613d2082f6a1b7c9a77de24e8fd26bf626522 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 15:26:27 +0000 Subject: [PATCH] docs: add dev artifact catalog contract --- deploy/artifact-catalog.dev.json | 203 ++++++++++++++++++++++++++ docs/artifact-catalog.md | 77 ++++++++++ scripts/validate-artifact-catalog.mjs | 175 ++++++++++++++++++++++ 3 files changed, 455 insertions(+) create mode 100644 deploy/artifact-catalog.dev.json create mode 100644 docs/artifact-catalog.md create mode 100644 scripts/validate-artifact-catalog.mjs diff --git a/deploy/artifact-catalog.dev.json b/deploy/artifact-catalog.dev.json new file mode 100644 index 00000000..041cca4f --- /dev/null +++ b/deploy/artifact-catalog.dev.json @@ -0,0 +1,203 @@ +{ + "catalogVersion": "v1", + "kind": "hwlab-artifact-catalog", + "environment": "dev", + "profile": "dev", + "namespace": "hwlab-dev", + "endpoint": "http://74.48.78.17:6667", + "commitId": "24eb3bf", + "artifactState": "contract-skeleton", + "publish": { + "ciPublished": false, + "registryVerified": false, + "provenance": "not_available_in_mvp_skeleton", + "note": "This catalog is a static DEV contract only. It is not evidence that images were built, pushed, or deployed." + }, + "healthContract": { + "method": "GET", + "path": "/health/live", + "responseFormat": "json", + "requiredFields": ["serviceId", "environment", "status"] + }, + "allowedProfiles": ["dev"], + "forbiddenProfiles": ["prod"], + "forbiddenItems": [ + { + "id": "prod-deploy", + "reason": "MVP acceptance is DEV-only." + }, + { + "id": "prod-profile-enabled", + "reason": "PROD profile may only exist as a disabled placeholder." + }, + { + "id": "prod-namespace", + "reason": "The catalog must not target hwlab-prod." + }, + { + "id": "real-ci-publish-claim", + "reason": "The skeleton catalog must not claim real image publish or registry verification." + }, + { + "id": "real-dev-deploy", + "reason": "This contract does not authorize a real DEV deployment." + }, + { + "id": "secret-material", + "reason": "Validation must not read or require secrets or tokens." + }, + { + "id": "unidesk-runtime-substitute", + "reason": "UniDesk backend, provider-gateway, and microservice proxy are not HWLAB runtime substitutes." + }, + { + "id": "force-push", + "reason": "Mainline updates must not use force push." + } + ], + "services": [ + { + "serviceId": "hwlab-cloud-api", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-cloud-web", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-agent-mgr", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-agent-worker", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-gateway", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-gateway-simu", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-box-simu", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-patch-panel", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-router", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-router:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-tunnel-client", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-edge-proxy", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-cli", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-cli:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + }, + { + "serviceId": "hwlab-agent-skills", + "commitId": "24eb3bf", + "image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf", + "imageTag": "24eb3bf", + "digest": "not_published", + "publishState": "skeleton-only", + "profile": "dev", + "namespace": "hwlab-dev", + "healthPath": "/health/live" + } + ] +} diff --git a/docs/artifact-catalog.md b/docs/artifact-catalog.md new file mode 100644 index 00000000..2768c691 --- /dev/null +++ b/docs/artifact-catalog.md @@ -0,0 +1,77 @@ +# HWLAB Artifact Catalog Contract + +The artifact catalog is the DEV-only MVP contract for interpreting deployment +artifacts. It pins which HWLAB service image, commit, profile, namespace, and +health path a static deploy manifest is claiming for acceptance review. + +This file is not a CI publish record. It does not prove that an image exists in a +registry, that a digest was produced, or that a deployment happened. Real CI +publish evidence must come from the future publishing workflow and registry +metadata. Until that exists, the catalog can only validate that the local +skeleton files agree with each other and do not drift into PROD. + +## Scope + +- Environment: DEV only. +- Namespace: `hwlab-dev`. +- Public DEV endpoint: `http://74.48.78.17:6667`. +- Profile: `dev`. +- Health path: `/health/live`. +- Artifact state: contract skeleton only. +- PROD deployment, PROD smoke, secret reads, force push, real image publish, and + UniDesk runtime substitution are forbidden. + +## Files + +| File | Role | +| --- | --- | +| `deploy/artifact-catalog.dev.json` | Machine-readable DEV artifact catalog sample and static source of truth for the skeleton. | +| `deploy/deploy.json` | DEV deploy manifest skeleton that the catalog explains. | +| `CI.json` | Lightweight command and forbidden-action skeleton. | +| `scripts/validate-artifact-catalog.mjs` | Local static validator for the catalog, deploy manifest, and CI forbidden list. | + +## Pin Rules + +Every service entry in `deploy/artifact-catalog.dev.json` must satisfy these +rules: + +| Field | Rule | +| --- | --- | +| `serviceId` | Must be one of the frozen HWLAB service IDs and must appear exactly once. | +| `commitId` | Must match the catalog and `deploy/deploy.json` commit ID. Short or full lowercase Git SHA is allowed. | +| `image` | Must match `ghcr.io/pikastech/:` and the matching `deploy/deploy.json` service image. | +| `imageTag` | Must be the first seven characters of `commitId`. Mutable tags such as `latest`, `dev`, or `main` are forbidden. | +| `profile` | Must be `dev`. | +| `namespace` | Must be `hwlab-dev`. | +| `healthPath` | Must be `/health/live` and match the DEV health contract. | +| `publishState` | Must be `skeleton-only` until real CI publish evidence exists. | +| `digest` | Must be `not_published` in the skeleton catalog; CI must replace this with a registry digest only after publishing. | + +The current skeleton commit and tag values intentionally mirror +`deploy/deploy.json`. They are traceability placeholders for static acceptance +work, not a statement that GHCR already contains those images. + +## Relationship To `deploy/deploy.json` + +`deploy/deploy.json` remains the deploy manifest skeleton. The artifact catalog +does not replace it and must not be consumed as proof that a release was built. +The catalog explains the artifact identity that `deploy/deploy.json` currently +claims, then adds stricter DEV-only rules around commit pinning, image tags, +health paths, forbidden profiles, and publish state. + +If a future CI workflow publishes real images, it should update or generate a +publish result that includes registry digests and provenance. That publish +result may be compared with this contract, but it must not be inferred from this +contract. + +## Validation + +Run the local static validator: + +```sh +node --check scripts/validate-artifact-catalog.mjs +node scripts/validate-artifact-catalog.mjs +``` + +The validator reads only local repository files. It does not contact a registry, +read secrets, build images, push images, deploy to DEV, or deploy to PROD. diff --git a/scripts/validate-artifact-catalog.mjs b/scripts/validate-artifact-catalog.mjs new file mode 100644 index 00000000..713fdf35 --- /dev/null +++ b/scripts/validate-artifact-catalog.mjs @@ -0,0 +1,175 @@ +#!/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 catalogPath = "deploy/artifact-catalog.dev.json"; +const deployPath = "deploy/deploy.json"; +const ciPath = "CI.json"; +const healthContractPath = "deploy/k8s/dev/health-contract.yaml"; +const commitPattern = /^[a-f0-9]{7,40}$/; +const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]); +const requiredForbiddenItems = [ + "prod-deploy", + "prod-profile-enabled", + "prod-namespace", + "real-ci-publish-claim", + "real-dev-deploy", + "secret-material", + "unidesk-runtime-substitute", + "force-push" +]; + +async function readJSON(relativePath) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + return JSON.parse(raw); +} + +function assertUnique(name, values) { + assert.equal(new Set(values).size, values.length, `${name} must be unique`); +} + +function assertString(value, context) { + assert.equal(typeof value, "string", `${context} must be a string`); + assert.ok(value.length > 0, `${context} must not be empty`); +} + +function assertCommitId(value, context) { + assertString(value, context); + assert.match(value, commitPattern, `${context} must be a short or full lowercase Git SHA`); +} + +function imageParts(image, context) { + assertString(image, context); + const match = image.match(/^(ghcr\.io\/pikastech\/[^:@]+):([^:@]+)$/); + assert.ok(match, `${context} must be a tagged ghcr.io/pikastech image`); + return { + repository: match[1], + tag: match[2] + }; +} + +function assertNoMutableTag(tag, context) { + assert.equal(mutableTags.has(tag), false, `${context} must not use mutable tag ${tag}`); +} + +function assertDevOnlyCatalog(catalog) { + assert.equal(catalog.catalogVersion, "v1", "catalogVersion"); + assert.equal(catalog.kind, "hwlab-artifact-catalog", "catalog kind"); + assert.equal(catalog.environment, ENVIRONMENT_DEV, "catalog environment"); + assert.equal(catalog.profile, ENVIRONMENT_DEV, "catalog profile"); + assert.equal(catalog.namespace, "hwlab-dev", "catalog namespace"); + assert.equal(catalog.endpoint, DEV_ENDPOINT, "catalog endpoint"); + assertCommitId(catalog.commitId, "catalog commitId"); + assert.equal(catalog.artifactState, "contract-skeleton", "catalog artifactState"); + + assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV], "only dev profile is allowed"); + assert.deepEqual(catalog.forbiddenProfiles, ["prod"], "prod profile must be forbidden"); + assert.equal(catalog.publish.ciPublished, false, "catalog must not claim CI publish"); + assert.equal(catalog.publish.registryVerified, false, "catalog must not claim registry verification"); + assert.equal(catalog.publish.provenance, "not_available_in_mvp_skeleton", "catalog provenance"); + + assert.equal(catalog.healthContract.method, "GET", "health method"); + assert.equal(catalog.healthContract.path, "/health/live", "health path"); + assert.equal(catalog.healthContract.responseFormat, "json", "health response format"); + assert.ok(catalog.healthContract.requiredFields.includes("serviceId"), "health requires serviceId"); + assert.ok(catalog.healthContract.requiredFields.includes("environment"), "health requires environment"); + assert.ok(catalog.healthContract.requiredFields.includes("status"), "health requires status"); + + const forbiddenIds = catalog.forbiddenItems.map((item) => item.id); + assertUnique("catalog forbidden item ids", forbiddenIds); + for (const required of requiredForbiddenItems) { + assert.ok(forbiddenIds.includes(required), `catalog forbiddenItems missing ${required}`); + } +} + +function assertDeployManifest(deployManifest, catalog) { + assert.equal(deployManifest.manifestVersion, "v1", "deploy manifestVersion"); + assert.equal(deployManifest.environment, ENVIRONMENT_DEV, "deploy environment"); + assert.equal(deployManifest.commitId, catalog.commitId, "deploy commitId must match catalog"); + assert.equal(deployManifest.namespace, catalog.namespace, "deploy namespace must match catalog"); + assert.equal(deployManifest.endpoint, catalog.endpoint, "deploy endpoint must match catalog"); + assert.equal(deployManifest.profiles.dev.enabled, true, "deploy dev profile must be enabled"); + assert.equal(deployManifest.profiles.dev.namespace, catalog.namespace, "deploy dev namespace must match catalog"); + assert.equal(deployManifest.profiles.dev.endpoint, catalog.endpoint, "deploy dev endpoint must match catalog"); + assert.ok(deployManifest.profiles.prod, "deploy may keep disabled prod placeholder"); + assert.equal(deployManifest.profiles.prod.enabled, false, "deploy prod profile must stay disabled"); +} + +function assertCatalogServices(catalog, deployManifest) { + assert.ok(Array.isArray(catalog.services), "catalog services must be an array"); + assert.ok(Array.isArray(deployManifest.services), "deploy services must be an array"); + + const catalogServiceIds = catalog.services.map((service) => service.serviceId); + const deployServiceIds = deployManifest.services.map((service) => service.serviceId); + assert.deepEqual(catalogServiceIds, SERVICE_IDS, "catalog must cover frozen service ids in order"); + assert.deepEqual(deployServiceIds, SERVICE_IDS, "deploy manifest must cover frozen service ids in order"); + assertUnique("catalog service ids", catalogServiceIds); + assertUnique("deploy service ids", deployServiceIds); + + const deployByServiceId = new Map(deployManifest.services.map((service) => [service.serviceId, service])); + for (const service of catalog.services) { + const context = `service ${service.serviceId}`; + const deployService = deployByServiceId.get(service.serviceId); + assert.ok(deployService, `${context} missing from deploy manifest`); + + assertCommitId(service.commitId, `${context} commitId`); + assert.equal(service.commitId, catalog.commitId, `${context} commitId must match catalog`); + assert.equal(service.imageTag, service.commitId.slice(0, 7), `${context} imageTag must be the short commit`); + assertNoMutableTag(service.imageTag, `${context} imageTag`); + + const image = imageParts(service.image, `${context} image`); + assert.equal(image.repository, `ghcr.io/pikastech/${service.serviceId}`, `${context} image repository`); + assert.equal(image.tag, service.imageTag, `${context} image tag`); + assert.equal(service.image, deployService.image, `${context} image must match deploy manifest`); + + assert.equal(service.profile, ENVIRONMENT_DEV, `${context} profile`); + assert.equal(service.profile, deployService.profile, `${context} profile must match deploy manifest`); + assert.equal(service.namespace, catalog.namespace, `${context} namespace`); + assert.equal(service.namespace, deployService.namespace, `${context} namespace must match deploy manifest`); + assert.equal(service.healthPath, catalog.healthContract.path, `${context} healthPath`); + assert.equal(service.healthPath, deployService.healthPath, `${context} healthPath must match deploy manifest`); + assert.equal(service.publishState, "skeleton-only", `${context} publishState`); + assert.equal(service.digest, "not_published", `${context} digest`); + } +} + +function assertCIForbidden(ci, catalog) { + assert.equal(ci.profile, ENVIRONMENT_DEV, "CI skeleton profile must be dev"); + assert.ok(Array.isArray(ci.forbidden), "CI forbidden must be an array"); + for (const required of ["prod-deploy", "secret-material", "unidesk-runtime-substitute", "force-push"]) { + assert.ok(ci.forbidden.includes(required), `CI forbidden missing ${required}`); + assert.ok(catalog.forbiddenItems.some((item) => item.id === required), `catalog forbiddenItems missing CI item ${required}`); + } +} + +function assertHealthContract(healthContract, catalog) { + assert.equal(healthContract.kind, "ConfigMap", "health contract kind"); + assert.equal(healthContract.metadata.namespace, catalog.namespace, "health contract namespace"); + assert.equal(healthContract.metadata.labels["hwlab.pikastech.local/profile"], ENVIRONMENT_DEV, "health contract profile label"); + assert.equal(healthContract.data.endpoint, catalog.endpoint, "health contract endpoint"); + assert.ok(healthContract.data["cloud-api"].includes(catalog.healthContract.path), "cloud-api health contract path"); + assert.ok(healthContract.data["edge-proxy"].includes(catalog.healthContract.path), "edge-proxy health contract path"); + assert.ok( + healthContract.data["runtime-substitute-policy"].includes("Do not replace HWLAB runtime"), + "health contract must include runtime substitute policy" + ); +} + +const catalog = await readJSON(catalogPath); +const deployManifest = await readJSON(deployPath); +const ci = await readJSON(ciPath); +const healthContract = await readJSON(healthContractPath); + +assertDevOnlyCatalog(catalog); +assertDeployManifest(deployManifest, catalog); +assertCatalogServices(catalog, deployManifest); +assertCIForbidden(ci, catalog); +assertHealthContract(healthContract, catalog); + +console.log(`validated ${catalog.services.length} DEV artifact catalog services at ${catalog.commitId}`);