fix: gate deploy desired-state promotion drift

fix: gate deploy desired-state promotion drift
This commit is contained in:
Lyon
2026-05-22 23:36:02 +08:00
committed by GitHub
11 changed files with 329 additions and 175 deletions
+12 -3
View File
@@ -40,10 +40,11 @@ PROD. Later work can wire the same source fields into `hwlab edge apply` or
Desired-state commit/image convergence review:
```sh
node scripts/deploy-desired-state-plan.mjs --pretty
node scripts/deploy-desired-state-plan.mjs --plan --pretty
node scripts/deploy-desired-state-plan.mjs --check
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty
node scripts/deploy-desired-state-plan.mjs --target-tag <tag> --pretty
node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --check
```
The desired-state plan reads only `deploy/deploy.json`,
@@ -52,8 +53,16 @@ optional artifact report snapshot. It checks that commit IDs, service image
tags, workload images, and present env mirrors such as `HWLAB_COMMIT_ID`,
`HWLAB_IMAGE`, `HWLAB_IMAGE_TAG`, and `HWLAB_SKILLS_COMMIT_ID` converge. With
`--target-ref` or `--target-tag`, a uniform older desired-state is reported as a
read-only promotion plan; `--check` fails only for missing/invalid source files,
internal mirror drift, invalid target tags, or partial target drift.
read-only promotion plan. Use `--promotion-commit <sha>` when reviewing or
automating a concrete promotion; then `--check` also fails if any authoritative
desired-state commit, image tag, workload image, or env mirror still points to a
different commit/tag.
`deploy/deploy.json` is the deploy truth, but artifact catalog and workload
refs are part of the same desired-state contract. Promotion updates must move
all three together by using the refresh command in blocked mode or
publish-report mode, then run the promotion commit check. Report files are
contextual evidence only and must not override these desired-state files.
This is source/dry-run support only. It does not prove a registry image exists,
does not build, pull, push, apply, restart, or touch PROD, and must not be used
+84 -84
View File
@@ -5,13 +5,13 @@
"profile": "dev",
"namespace": "hwlab-dev",
"endpoint": "http://74.48.78.17:16667",
"commitId": "7e29522",
"artifactState": "published",
"commitId": "c7de474",
"artifactState": "contract-skeleton",
"publish": {
"ciPublished": true,
"registryVerified": true,
"provenance": "reports/dev-gate/dev-artifacts.json",
"note": "Digest fields were copied from a successful DEV artifact publish report for this source commit."
"ciPublished": false,
"registryVerified": false,
"provenance": "not_available_until_publish",
"note": "Artifact identity was refreshed to this source commit, but no publish report proved registry digests."
},
"healthContract": {
"method": "GET",
@@ -66,11 +66,11 @@
"services": [
{
"serviceId": "hwlab-cloud-api",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522",
"imageTag": "7e29522",
"digest": "sha256:090b6f919be3b3c7594ca4073b193351689fc525f478b64d54d393725cb01ecf",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -78,15 +78,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-cloud-web",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522",
"imageTag": "7e29522",
"digest": "sha256:bc6e836b2bb796b30562ed3ae93d8534146a3b74a310906481c8641646d1b8f0",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -94,15 +94,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-agent-mgr",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522",
"imageTag": "7e29522",
"digest": "sha256:4d9b8944bc52012bb0a4a3a345f8939381d2804032e544d0c98f49fac0e46bfd",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -110,15 +110,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-agent-worker",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522",
"imageTag": "7e29522",
"digest": "sha256:2582176e2c35a2bfbce4edfd922749397d45f6f1f8c04d0394c501f7d87323ee",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -126,15 +126,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-gateway",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522",
"imageTag": "7e29522",
"digest": "sha256:290341fd6e6f036aa953943072ad58e0d0cfcc0e13950e6090f941b3310755a6",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -142,15 +142,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-gateway-simu",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522",
"imageTag": "7e29522",
"digest": "sha256:02627f880c7ddf2102e2255a43097d1f604cfb7a22215736e328287e27fc5f03",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -158,15 +158,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-box-simu",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522",
"imageTag": "7e29522",
"digest": "sha256:e02c270c71f83a2f4624722bb84616bea8374ef43439f27bdce310e4baa8dcca",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -174,15 +174,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-patch-panel",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522",
"imageTag": "7e29522",
"digest": "sha256:f730323f83c62797f382932582a05df921045c86383dad7951f3d639b32456a9",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -190,15 +190,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-router",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522",
"imageTag": "7e29522",
"digest": "sha256:7c40c573078a9c325402bd47b2eb78a46c0c80564a228cf7c9fcf0cfba5bc6d2",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-router:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -206,15 +206,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-tunnel-client",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522",
"imageTag": "7e29522",
"digest": "sha256:b141dc88557b8f6391aa082fdb37c6f0db5bc3a76c97bb39ba00354a9208abd8",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -222,15 +222,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-edge-proxy",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522",
"imageTag": "7e29522",
"digest": "sha256:792505139793c3f3da7769f9dc9d1cb3203ff92797305b62e8f12207c69a9831",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -238,15 +238,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-cli",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522",
"imageTag": "7e29522",
"digest": "sha256:43d1c175091bfb462eddd7dd22a780d6485d86bdb052c36b78201606f48fadc1",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-cli:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -254,15 +254,15 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
},
{
"serviceId": "hwlab-agent-skills",
"commitId": "7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522",
"imageTag": "7e29522",
"digest": "sha256:c75510038dc33196dc254472e78101d545c0d777a7b33b531f02b39faf2588b5",
"publishState": "published",
"commitId": "c7de474",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c7de474",
"imageTag": "c7de474",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
@@ -270,7 +270,7 @@
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": null
"notPublishedReason": "publish_not_run"
}
],
"serviceInventory": {
+18 -18
View File
@@ -1,7 +1,7 @@
{
"manifestVersion": "v1",
"environment": "dev",
"commitId": "7e29522",
"commitId": "c7de474",
"namespace": "hwlab-dev",
"endpoint": "http://74.48.78.17:16667",
"health": {
@@ -167,7 +167,7 @@
"services": [
{
"serviceId": "hwlab-cloud-api",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -175,9 +175,9 @@
"env": {
"HWLAB_ENVIRONMENT": "dev",
"HWLAB_PUBLIC_ENDPOINT": "http://74.48.78.17:16667",
"HWLAB_COMMIT_ID": "7e29522",
"HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522",
"HWLAB_IMAGE_TAG": "7e29522",
"HWLAB_COMMIT_ID": "c7de474",
"HWLAB_IMAGE": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474",
"HWLAB_IMAGE_TAG": "c7de474",
"HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy",
"HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url",
"HWLAB_CLOUD_DB_SSL_MODE": "require",
@@ -189,7 +189,7 @@
},
{
"serviceId": "hwlab-cloud-web",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -201,7 +201,7 @@
},
{
"serviceId": "hwlab-agent-mgr",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -213,7 +213,7 @@
},
{
"serviceId": "hwlab-agent-worker",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -225,7 +225,7 @@
},
{
"serviceId": "hwlab-gateway",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -237,7 +237,7 @@
},
{
"serviceId": "hwlab-gateway-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -249,7 +249,7 @@
},
{
"serviceId": "hwlab-box-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -261,7 +261,7 @@
},
{
"serviceId": "hwlab-patch-panel",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -273,7 +273,7 @@
},
{
"serviceId": "hwlab-router",
"image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-router:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -285,7 +285,7 @@
},
{
"serviceId": "hwlab-tunnel-client",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -299,7 +299,7 @@
},
{
"serviceId": "hwlab-edge-proxy",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -311,7 +311,7 @@
},
{
"serviceId": "hwlab-cli",
"image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cli:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
@@ -322,13 +322,13 @@
},
{
"serviceId": "hwlab-agent-skills",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c7de474",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
"replicas": 1,
"env": {
"HWLAB_SKILLS_COMMIT_ID": "7e29522"
"HWLAB_SKILLS_COMMIT_ID": "c7de474"
}
}
],
+17 -17
View File
@@ -31,7 +31,7 @@
"containers": [
{
"name": "hwlab-cloud-api",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474",
"ports": [
{
"name": "http",
@@ -49,15 +49,15 @@
},
{
"name": "HWLAB_COMMIT_ID",
"value": "7e29522"
"value": "c7de474"
},
{
"name": "HWLAB_IMAGE",
"value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:7e29522"
"value": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474"
},
{
"name": "HWLAB_IMAGE_TAG",
"value": "7e29522"
"value": "c7de474"
},
{
"name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN",
@@ -147,7 +147,7 @@
"containers": [
{
"name": "hwlab-cloud-web",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474",
"ports": [
{
"name": "http",
@@ -207,7 +207,7 @@
"containers": [
{
"name": "hwlab-agent-mgr",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:c7de474",
"ports": [
{
"name": "http",
@@ -263,7 +263,7 @@
"containers": [
{
"name": "hwlab-agent-worker",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:c7de474",
"env": [
{
"name": "HWLAB_AGENT_SESSION_MODE",
@@ -306,7 +306,7 @@
"containers": [
{
"name": "hwlab-gateway",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway:c7de474",
"ports": [
{
"name": "http",
@@ -366,7 +366,7 @@
"containers": [
{
"name": "hwlab-gateway-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-gateway-simu:c7de474",
"ports": [
{
"name": "http",
@@ -426,7 +426,7 @@
"containers": [
{
"name": "hwlab-box-simu",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-box-simu:c7de474",
"ports": [
{
"name": "http",
@@ -486,7 +486,7 @@
"containers": [
{
"name": "hwlab-patch-panel",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-patch-panel:c7de474",
"ports": [
{
"name": "http",
@@ -540,7 +540,7 @@
"containers": [
{
"name": "hwlab-router",
"image": "127.0.0.1:5000/hwlab/hwlab-router:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-router:c7de474",
"ports": [
{
"name": "http",
@@ -600,7 +600,7 @@
"containers": [
{
"name": "hwlab-tunnel-client",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:c7de474",
"ports": [
{
"name": "http",
@@ -672,7 +672,7 @@
"containers": [
{
"name": "hwlab-edge-proxy",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:c7de474",
"ports": [
{
"name": "http",
@@ -733,7 +733,7 @@
"containers": [
{
"name": "hwlab-cli",
"image": "127.0.0.1:5000/hwlab/hwlab-cli:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-cli:c7de474",
"env": [
{
"name": "HWLAB_CLI_ENDPOINT",
@@ -775,7 +775,7 @@
"containers": [
{
"name": "hwlab-agent-skills",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:7e29522",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-skills:c7de474",
"ports": [
{
"name": "http",
@@ -785,7 +785,7 @@
"env": [
{
"name": "HWLAB_SKILLS_COMMIT_ID",
"value": "7e29522"
"value": "c7de474"
}
],
"readinessProbe": {
+12 -1
View File
@@ -74,10 +74,11 @@ when the base-image blocker is already closed.
Use the read-only planner before refreshing or reviewing deploy desired-state:
```sh
node scripts/deploy-desired-state-plan.mjs --pretty
node scripts/deploy-desired-state-plan.mjs --plan --pretty
node scripts/deploy-desired-state-plan.mjs --check
node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty
node scripts/deploy-desired-state-plan.mjs --target-tag <tag> --pretty
node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --check
```
The planner treats `deploy/deploy.json`,
@@ -94,6 +95,14 @@ not. A uniform older state under `--target-ref` is a promotion review plan, not
failure. The optional report snapshot is contextual evidence only and is not an
authoritative desired-state source.
For PR review or CI/CD promotion handoff, use `--promotion-commit <sha>
--check`. That mode is stricter than a `--target-ref` plan: every top-level
commit, catalog service commit, deploy service image tag, workload image tag,
and present mirror env (`HWLAB_COMMIT_ID`, `HWLAB_IMAGE`,
`HWLAB_IMAGE_TAG`, `HWLAB_SKILLS_COMMIT_ID`, `HWLAB_IMAGE_DIGEST`) must align
with the promotion commit/tag. A stale but internally consistent manifest is a
blocker in promotion mode because applying it would roll DEV back.
The planner does not prove registry existence, publish success, DEV apply, or
M3 DEV-LIVE. Only real DEV observation of
`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` with operation,
@@ -106,6 +115,7 @@ catalog and deploy manifests in blocked mode:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check
```
This updates `deploy/deploy.json`, `deploy/artifact-catalog.dev.json`, and
@@ -118,6 +128,7 @@ report:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json
node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check
```
The publish-report mode refuses to update the catalog unless the report says
+7 -6
View File
@@ -153,12 +153,13 @@ remain `status: "blocked"`. Do not turn a base-image or registry blocker into a
fake digest or published state. Do not treat runner-process loopback HTTP
failure as a publish failure by itself.
Current rebaseline: the committed DEV artifact report is `status: "published"`
for source commit `73b379f`, with `baseImagePreflight.status: "ready"`,
`baseImage: "node:20-bookworm-slim"`, and 13/13 required services carrying
real registry digests. If a later read-only runner cannot fetch catalog
manifests from `127.0.0.1:5000`, treat that as a `#66` registry reachability
dimension, not as a missing base-image or missing-publish blocker.
Committed DEV artifact reports are evidence snapshots and may lag the deploy
desired-state files. Use `scripts/deploy-desired-state-plan.mjs` to see whether
the report source matches the desired-state commit, then refresh the catalog
from a publish report only when that report proves the same target source
commit. If a later read-only runner cannot fetch catalog manifests from
`127.0.0.1:5000`, treat that as a `#66` registry reachability dimension, not as
a missing base-image or missing-publish blocker.
## Known Implementation States
+5 -5
View File
@@ -22,11 +22,11 @@ PROD, read secrets, or use UniDesk runtime services as substitutes.
preflight only decides whether #35 may proceed to build/publish; it does not
prove that any service image was published.
Current rebaseline: `reports/dev-gate/dev-artifacts.json` records
`baseImagePreflight.status: "ready"` for local `node:20-bookworm-slim` and
13/13 published DEV artifacts from source commit `73b379f`. A failed catalog
manifest read from a runner process is a registry reachability question for
`#66`, not proof that the base image or publish digests are missing.
Committed artifact reports may record successful base-image and publish evidence
for an older source commit. Compare their source commit with the desired-state
commit before using them for refresh or apply review. A failed catalog manifest
read from a runner process is a registry reachability question for `#66`, not
proof that the base image or publish digests are missing.
## Command
+6 -9
View File
@@ -133,15 +133,12 @@ node scripts/deploy-desired-state-plan.mjs --target-ref origin/main --pretty
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json
```
Current rebaseline: the committed DEV artifact report proves 13/13 required
DEV artifacts from source `73b379f` with `ciPublished=true`,
`registryVerified=true`, and real `sha256` digests, and the base-image
preflight is `ready` on local `node:20-bookworm-slim`. If the selected
`origin/main` target later drifts onto files that change artifact build inputs,
the preflight can still reopen `artifact-source-commit` or
`dev-artifact-publish` even though the base-image blocker stays closed.
Remaining registry manifest-read failures are #66 reachability evidence, not a
reason to reopen the base-image blocker.
Committed DEV artifact reports can prove required service digests only for the
source commit they name. If the selected `origin/main` target drifts onto files
that change artifact build inputs, the preflight can still reopen
`artifact-source-commit` or `dev-artifact-publish` even though the base-image
blocker stays closed. Remaining registry manifest-read failures are #66
reachability evidence, not a reason to reopen the base-image blocker.
This is M3 virtual hardware trusted-loop support only. It does not claim M3
DEV-LIVE unless a real `DO1 -> patch-panel -> DI1` chain is observed through
+13 -1
View File
@@ -58,14 +58,26 @@ publish report:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json
node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check
```
If publish is blocked, keep the report blocked rather than inventing digests:
```sh
node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked
node scripts/deploy-desired-state-plan.mjs --promotion-commit $(git rev-parse --short=7 origin/main) --check
```
The desired-state files are a single deployment truth: `deploy/deploy.json`,
`deploy/artifact-catalog.dev.json`, and `deploy/k8s/base/workloads.yaml` must
carry the same promotion commit and immutable image tag before they are used as
apply input. A `--target-ref` desired-state plan may report a uniform older
state as `promotion_pending`; PR review and CI/CD promotion gates must use
`--promotion-commit <sha> --check` so stale or partially refreshed commit,
image, workload, and mirror-env fields are blocked before any DEV apply.
Reports under `reports/` are evidence snapshots only and cannot override this
desired-state contract.
## API, Edge, Health, And FRP
The public route is fixed:
@@ -170,7 +182,7 @@ use the repo-owned scripts below instead of ad hoc shell fragments.
| Action | Current controlled entrypoint | Gap to close |
| --- | --- | --- |
| Check deployment contract and FRP/endpoint drift | `node scripts/deploy-contract-plan.mjs --check` | Fold into `hwlab deploy check --env dev`. |
| Plan desired image/workload state | `node scripts/deploy-desired-state-plan.mjs --check` | Fold into `hwlab deploy plan --env dev --file deploy/deploy.json`. |
| Plan desired image/workload state | `node scripts/deploy-desired-state-plan.mjs --plan --pretty` and `node scripts/deploy-desired-state-plan.mjs --promotion-commit <sha> --check` | Fold into `hwlab deploy plan --env dev --file deploy/deploy.json` with a promotion commit gate. |
| Preflight artifact publish | `node scripts/dev-artifact-publish.mjs --preflight` | Keep as backend for `hwlab artifact publish --preflight`. |
| Publish DEV images | `node scripts/dev-artifact-publish.mjs --publish` | CI job should run this and upload digest reports. |
| Refresh catalog/desired state | `node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report reports/dev-gate/dev-artifacts.json` | Make refresh an explicit CLI/CD step after publish. |
+76 -14
View File
@@ -6,11 +6,26 @@ import test from "node:test";
import { buildDesiredStatePlan } from "./src/deploy-desired-state-plan.mjs";
async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workloadEnvTag = commitId } = {}) {
async function makeFixture({
commitId = "abc1234",
catalogCommitId = commitId,
catalogServiceCommitId = catalogCommitId,
catalogImageTag = catalogServiceCommitId.slice(0, 7),
deployEnvCommitId = commitId,
deployEnvImageTag = commitId,
deployEnvImage = null,
deploySkillsCommitId = null,
workloadTag = commitId,
workloadEnvCommitId = commitId,
workloadEnvImageTag = commitId,
workloadEnvImage = null,
workloadSkillsCommitId = null
} = {}) {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-desired-state-"));
await mkdir(path.join(root, "deploy/k8s/base"), { recursive: true });
await mkdir(path.join(root, "reports/dev-gate"), { recursive: true });
const image = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId}`;
const catalogImage = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${catalogImageTag}`;
const workloadImage = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${workloadTag}`;
const deploy = {
manifestVersion: "v1",
@@ -25,9 +40,10 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
profile: "dev",
replicas: 1,
env: {
HWLAB_COMMIT_ID: commitId,
HWLAB_IMAGE: image,
HWLAB_IMAGE_TAG: commitId
HWLAB_COMMIT_ID: deployEnvCommitId,
HWLAB_IMAGE: deployEnvImage ?? image,
HWLAB_IMAGE_TAG: deployEnvImageTag,
...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {})
}
}
]
@@ -39,7 +55,7 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
profile: "dev",
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
commitId,
commitId: catalogCommitId,
artifactState: "contract-skeleton",
publish: {
ciPublished: false,
@@ -49,9 +65,9 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
services: [
{
serviceId: "hwlab-cloud-api",
commitId,
image,
imageTag: commitId,
commitId: catalogServiceCommitId,
image: catalogImage,
imageTag: catalogImageTag,
digest: "not_published",
publishState: "skeleton-only",
artifactRequired: true
@@ -80,9 +96,10 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
name: "hwlab-cloud-api",
image: workloadImage,
env: [
{ name: "HWLAB_COMMIT_ID", value: commitId },
{ name: "HWLAB_IMAGE", value: image },
{ name: "HWLAB_IMAGE_TAG", value: workloadEnvTag }
{ name: "HWLAB_COMMIT_ID", value: workloadEnvCommitId },
{ name: "HWLAB_IMAGE", value: workloadEnvImage ?? image },
{ name: "HWLAB_IMAGE_TAG", value: workloadEnvImageTag },
...(workloadSkillsCommitId ? [{ name: "HWLAB_SKILLS_COMMIT_ID", value: workloadSkillsCommitId }] : [])
]
}
]
@@ -116,8 +133,35 @@ test("target tag review is read-only promotion_pending when current state is uni
assert.match(plan.services[0].promotion.deployImage, /:def5678$/u);
});
test("promotion commit check blocks when current desired-state is uniformly older", async () => {
const repoRoot = await makeFixture();
const plan = await buildDesiredStatePlan({ repoRoot, promotionCommit: "def5678" });
assert.equal(plan.status, "blocked");
assert.equal(plan.target.convergence.state, "promotion_mismatch");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "promotion_commit_mismatch"));
});
test("promotion commit check passes when every desired-state field is converged", async () => {
const repoRoot = await makeFixture({ commitId: "def5678" });
const plan = await buildDesiredStatePlan({ repoRoot, promotionCommit: "def5678" });
assert.equal(plan.status, "pass");
assert.equal(plan.target.convergence.state, "already_promoted");
assert.deepEqual(plan.diagnostics, []);
});
test("promotion commit check blocks an explicit non-matching target tag", async () => {
const repoRoot = await makeFixture({ commitId: "def5678" });
const plan = await buildDesiredStatePlan({
repoRoot,
promotionCommit: "def5678",
targetTag: "abc1234"
});
assert.equal(plan.status, "blocked");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "promotion_tag_mismatch"));
});
test("blocks on mirror drift", async () => {
const repoRoot = await makeFixture({ workloadEnvTag: "badcafe" });
const repoRoot = await makeFixture({ workloadEnvImageTag: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
assert.equal(plan.summary.blockers, 1);
@@ -125,6 +169,16 @@ test("blocks on mirror drift", async () => {
assert.match(plan.diagnostics[0].path, /HWLAB_IMAGE_TAG/u);
});
test("blocks on skills commit mirror drift", async () => {
const repoRoot = await makeFixture({ deploySkillsCommitId: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
assert.ok(plan.diagnostics.some((diagnostic) =>
diagnostic.code === "mirror_mismatch" &&
diagnostic.path.endsWith("HWLAB_SKILLS_COMMIT_ID")
));
});
test("blocks on workload image drift", async () => {
const repoRoot = await makeFixture({ workloadTag: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
@@ -133,8 +187,16 @@ test("blocks on workload image drift", async () => {
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "image_mismatch"));
});
test("reports partial target drift as a blocker", async () => {
const repoRoot = await makeFixture({ workloadTag: "def5678", workloadEnvTag: "def5678" });
test("reports partial target drift when catalog top-level commit moves but services stay old", async () => {
const repoRoot = await makeFixture({ catalogCommitId: "def5678" });
const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" });
assert.equal(plan.status, "blocked");
assert.equal(plan.target.convergence.state, "partial_drift");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "partial_target_drift"));
});
test("reports partial target drift as a blocker", async () => {
const repoRoot = await makeFixture({ workloadTag: "def5678", workloadEnvImageTag: "def5678" });
const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" });
assert.equal(plan.status, "blocked");
assert.equal(plan.target.convergence.state, "partial_drift");
+79 -17
View File
@@ -27,11 +27,21 @@ const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
const commitPattern = /^[a-f0-9]{7,40}$/;
function parseArgs(argv) {
const args = { check: false, pretty: false, targetRef: null, targetTag: null, help: false };
const args = {
check: false,
plan: false,
pretty: false,
targetRef: null,
targetTag: null,
promotionCommit: null,
help: false
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--check") {
args.check = true;
} else if (arg === "--plan") {
args.plan = true;
} else if (arg === "--pretty") {
args.pretty = true;
} else if (arg === "--target-ref") {
@@ -40,6 +50,9 @@ function parseArgs(argv) {
} else if (arg === "--target-tag") {
args.targetTag = requireValue(argv, index, arg);
index += 1;
} else if (arg === "--promotion-commit") {
args.promotionCommit = requireValue(argv, index, arg);
index += 1;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
@@ -59,15 +72,18 @@ function requireValue(argv, index, arg) {
function usage() {
return [
"usage: node scripts/deploy-desired-state-plan.mjs [--check] [--pretty] [--target-ref REF] [--target-tag TAG]",
"usage: node scripts/deploy-desired-state-plan.mjs [--plan] [--check] [--pretty] [--target-ref REF] [--target-tag TAG] [--promotion-commit SHA]",
"",
"Read-only DEV desired-state commit/image planner.",
"",
"Options:",
" --check exit non-zero only when desired-state sources are missing, invalid, or partially drifted",
" --plan explicitly request the default read-only JSON plan",
" --check exit non-zero when desired-state sources are invalid, drifted, or do not match --promotion-commit",
" --pretty print indented JSON",
" --target-ref REF resolve REF with git and review promotion to its short commit tag",
" --target-tag TAG review promotion to an explicit image tag without proving registry existence",
" --promotion-commit SHA",
" require every authoritative desired-state commit/image/tag field to match this promotion commit",
"",
"This command reads repository files only. It does not build, pull, push, kubectl apply, restart services, or touch PROD."
].join("\n");
@@ -285,8 +301,8 @@ function assertTargetTag(ctx, tag, pathName) {
}
}
async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) {
if (!targetRef && !targetTag) return null;
async function resolveTarget(repoRoot, { targetRef, targetTag, promotionCommit }, ctx) {
if (!targetRef && !targetTag && !promotionCommit) return null;
let commitId = null;
let shortCommitId = null;
if (targetRef) {
@@ -304,14 +320,31 @@ async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) {
});
}
}
if (promotionCommit) {
if (!commitPattern.test(String(promotionCommit))) {
addMismatch(ctx, "invalid_promotion_commit", "--promotion-commit", "promotion commit must be a short or full lowercase Git SHA", "7-40 lowercase hex", promotionCommit);
} else {
const promotionShortCommitId = promotionCommit.slice(0, 7);
if (commitId && !commitEquivalent(promotionCommit, commitId)) {
addMismatch(ctx, "promotion_commit_ref_mismatch", "--promotion-commit", "promotion commit must match the resolved target ref", commitId, promotionCommit);
}
commitId = commitId ?? promotionCommit;
shortCommitId = shortCommitId ?? promotionShortCommitId;
}
}
const tag = targetTag ?? shortCommitId;
if (tag) assertTargetTag(ctx, tag, "--target-tag");
if (promotionCommit && commitPattern.test(String(promotionCommit)) && targetTag && targetTag !== promotionCommit.slice(0, 7)) {
addMismatch(ctx, "promotion_tag_mismatch", "--target-tag", "promotion target tag must be the short promotion commit", promotionCommit.slice(0, 7), targetTag);
}
return {
targetRef,
promotionCommit,
commitId,
shortCommitId,
tag,
tagSource: targetTag ? "target-tag" : "target-ref-short-commit"
tagSource: targetTag ? "target-tag" : promotionCommit ? "promotion-commit-short" : "target-ref-short-commit",
requireConvergence: Boolean(promotionCommit)
};
}
@@ -350,6 +383,7 @@ function buildTargetConvergence(ctx, target) {
const matching = comparable.filter((observation) => observation.matchesTarget === true);
const pending = comparable.filter((observation) => observation.matchesTarget === false);
const partial = matching.length > 0 && pending.length > 0;
const promotionRequired = target.requireConvergence === true;
if (partial) {
addDiagnostic(ctx, {
@@ -362,13 +396,28 @@ function buildTargetConvergence(ctx, target) {
pending: pending.length
}
});
} else if (promotionRequired && pending.length > 0) {
addDiagnostic(ctx, {
code: "promotion_commit_mismatch",
path: "desired-state",
message: "desired-state fields do not match the required promotion commit/tag",
expected: {
commitId: target.commitId,
imageTag: target.tag
},
actual: {
pending: pending.length
}
});
}
const state = partial
? "partial_drift"
: pending.length === 0
? "already_promoted"
: "promotion_pending";
: promotionRequired
? "promotion_mismatch"
: "promotion_pending";
return {
state,
comparableFields: comparable.length,
@@ -379,7 +428,7 @@ function buildTargetConvergence(ctx, target) {
};
}
function reportHints(report) {
function reportHints(report, desiredCommitId) {
if (!report) {
return {
path: artifactReportPath,
@@ -389,15 +438,19 @@ function reportHints(report) {
};
}
const artifactPublish = report.artifactPublish ?? {};
const reportCommitId = report.commitId ?? null;
const artifactSourceCommitId = artifactPublish.sourceCommitId ?? null;
return {
path: artifactReportPath,
present: true,
authoritative: false,
note: "report snapshots are contextual evidence only; deploy desired-state remains authoritative in deploy/, not reports/",
commitId: report.commitId ?? null,
commitId: reportCommitId,
matchesDesiredState: reportCommitId ? commitEquivalent(reportCommitId, desiredCommitId) : null,
artifactPublish: {
status: artifactPublish.status ?? null,
sourceCommitId: artifactPublish.sourceCommitId ?? null,
sourceCommitId: artifactSourceCommitId,
sourceMatchesDesiredState: artifactSourceCommitId ? commitEquivalent(artifactSourceCommitId, desiredCommitId) : null,
publishedCount: artifactPublish.publishedCount ?? null,
serviceCount: artifactPublish.serviceCount ?? null,
registryVerified: artifactPublish.registryVerified ?? null,
@@ -422,12 +475,19 @@ function servicePromotion(service, target) {
}
function targetCommands(target) {
if (!target?.targetRef) return [];
return [
if (!target?.targetRef && !target?.promotionCommit) return [];
const commands = [];
if (target.promotionCommit) {
commands.push(`node scripts/deploy-desired-state-plan.mjs --promotion-commit ${target.promotionCommit} --check --pretty`);
}
if (target.targetRef) {
commands.push(
`node scripts/deploy-desired-state-plan.mjs --target-ref ${target.targetRef} --pretty`,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --blocked`,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --publish-report ${artifactReportPath}`
];
);
}
return commands;
}
export async function buildDesiredStatePlan(options = {}) {
@@ -441,7 +501,8 @@ export async function buildDesiredStatePlan(options = {}) {
]);
const target = await resolveTarget(repoRoot, {
targetRef: options.targetRef ?? null,
targetTag: options.targetTag ?? null
targetTag: options.targetTag ?? null,
promotionCommit: options.promotionCommit ?? null
}, ctx);
const deployServices = deploy.services ?? [];
@@ -674,7 +735,7 @@ export async function buildDesiredStatePlan(options = {}) {
prod: false,
devLiveClaim: false
},
checkSemantics: "--check exits non-zero only for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, or partial target drift. A uniform older desired-state under --target-ref is a read-only promotion plan, not a check failure.",
checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, partial target drift, or any mismatch with --promotion-commit. A uniform older desired-state under --target-ref alone is a read-only promotion plan, not a check failure.",
summary: {
desiredCommitId,
desiredImageTag,
@@ -699,7 +760,7 @@ export async function buildDesiredStatePlan(options = {}) {
nonAuthoritativeEvidence: [artifactReportPath],
note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
},
reportHints: reportHints(artifactReport),
reportHints: reportHints(artifactReport, desiredCommitId),
services,
diagnostics: ctx.diagnostics
};
@@ -714,7 +775,8 @@ export async function runDeployDesiredStatePlanCli(argv = process.argv.slice(2),
const plan = await buildDesiredStatePlan({
repoRoot: options.repoRoot,
targetRef: args.targetRef,
targetTag: args.targetTag
targetTag: args.targetTag,
promotionCommit: args.promotionCommit
});
process.stdout.write(`${JSON.stringify(plan, null, args.pretty ? 2 : 0)}\n`);
if (args.check && plan.status === "blocked") {