docs: add dev artifact catalog contract

This commit is contained in:
HWLAB Code Queue
2026-05-21 15:26:27 +00:00
parent 96631b3036
commit edd613d208
3 changed files with 455 additions and 0 deletions
+203
View File
@@ -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"
}
]
}
+77
View File
@@ -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/<serviceId>:<imageTag>` 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.
+175
View File
@@ -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}`);