docs: add dev artifact catalog contract
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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}`);
|
||||
Reference in New Issue
Block a user