feat(cli): validate prerequisites and seal installation plans

This commit is contained in:
Codex
2026-09-28 17:03:25 +02:00
parent b9c3369e7b
commit 55f3569e55
30 changed files with 2271 additions and 4 deletions
+4 -1
View File
@@ -13,7 +13,10 @@ and invariants are in [AGENTS.md](AGENTS.md); prior snapshots remain in Git.
explicit `installation credentials`, and `installation validate --workspaces PATH` explicit `installation credentials`, and `installation validate --workspaces PATH`
for protected application documents, canonical model settings and schema-v1 for protected application documents, canonical model settings and schema-v1
database bootstrap inputs. These commands do not start services or import Catalog database bootstrap inputs. These commands do not start services or import Catalog
bindings. The remainder of the installation tickets, bindings. Ticket #45 adds host `installation preflight` and `installation plan`
with release/image checks, canonical external diagnostics and private input seals;
see [the preflight reference](docs/install/installation-preflight.md).
The remainder of the installation tickets,
Docker Hub publication and example databases remain pending. Docker Hub publication and example databases remain pending.
- React supports full/embedded rendering independently of local/OIDC/upstream auth, - React supports full/embedded rendering independently of local/OIDC/upstream auth,
+403
View File
@@ -6,11 +6,13 @@
"": { "": {
"name": "thothii-backend", "name": "thothii-backend",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.1141.0",
"@fastify/cookie": "11.1.2", "@fastify/cookie": "11.1.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "11.2.0", "@fastify/rate-limit": "11.2.0",
"@types/pg": "^8.20.3", "@types/pg": "^8.20.3",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"ipaddr.js": "2.4.0",
"kysely": "^0.29.5", "kysely": "^0.29.5",
"libphonenumber-js": "1.13.12", "libphonenumber-js": "1.13.12",
"openid-client": "6.8.5", "openid-client": "6.8.5",
@@ -29,6 +31,314 @@
"vitest": "^2.1.0" "vitest": "^2.1.0"
} }
}, },
"node_modules/@aws-sdk/checksums": {
"version": "3.1001.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1141.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1001.1",
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-node": "^3.972.84",
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.978.1",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@aws-sdk/xml-builder": "^3.972.41",
"@aws/lambda-invoke-store": "^0.3.0",
"@smithy/core": "^3.35.0",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.74",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.973.17",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-login": "^3.972.79",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.79",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.84",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.72",
"@aws-sdk/credential-provider-http": "^3.972.74",
"@aws-sdk/credential-provider-ini": "^3.973.17",
"@aws-sdk/credential-provider-process": "^3.972.72",
"@aws-sdk/credential-provider-sso": "^3.973.16",
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/credential-provider-imds": "^4.5.2",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.72",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.973.16",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/token-providers": "3.1138.0",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.78",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.77",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.46",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/fetch-http-handler": "^5.8.0",
"@smithy/node-http-handler": "^4.12.1",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.47",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.974.6",
"@smithy/signature-v4": "^5.7.3",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1138.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.978.1",
"@aws-sdk/nested-clients": "^3.997.46",
"@aws-sdk/types": "^3.974.6",
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.974.6",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.41",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@balena/dockerignore": { "node_modules/@balena/dockerignore": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
@@ -1404,6 +1714,87 @@
"win32" "win32"
] ]
}, },
"node_modules/@smithy/core": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.2",
"@smithy/types": "^4.17.2",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.33.3",
"@smithy/types": "^4.18.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.35.0",
"@smithy/types": "^4.19.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.19.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@testcontainers/postgresql": { "node_modules/@testcontainers/postgresql": {
"version": "12.1.0", "version": "12.1.0",
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-12.1.0.tgz", "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-12.1.0.tgz",
@@ -1990,6 +2381,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
@@ -4320,6 +4717,12 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.22.4", "version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+2
View File
@@ -15,11 +15,13 @@
"test:schema-v3-verifier": "npm run test:schema-v4-verifier" "test:schema-v3-verifier": "npm run test:schema-v4-verifier"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.1141.0",
"@fastify/cookie": "11.1.2", "@fastify/cookie": "11.1.2",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "11.2.0", "@fastify/rate-limit": "11.2.0",
"@types/pg": "^8.20.3", "@types/pg": "^8.20.3",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"ipaddr.js": "2.4.0",
"kysely": "^0.29.5", "kysely": "^0.29.5",
"libphonenumber-js": "1.13.12", "libphonenumber-js": "1.13.12",
"openid-client": "6.8.5", "openid-client": "6.8.5",
@@ -13,6 +13,8 @@ const database = databaseConfigurationSchema.extend({
}); });
const bootstrap = z.object({ schemaVersion: z.literal(1), databases: z.array(database).min(1).max(1000) }).strict(); const bootstrap = z.object({ schemaVersion: z.literal(1), databases: z.array(database).min(1).max(1000) }).strict();
export const parseDatabaseBootstrap = (value: unknown) => bootstrap.parse(value);
export interface BootstrapReference { field: string; path: string } export interface BootstrapReference { field: string; path: string }
/** Offline bootstrap boundary: runtime Catalog owns the resulting bindings after import. */ /** Offline bootstrap boundary: runtime Catalog owns the resulting bindings after import. */
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { parseWorkspaceYaml } from "../workspaces/schema.js";
import { evidencePolicy } from "../workspaces/evidence/preprocessing.js";
import type { parseDatabaseBootstrap } from "./bootstrap-documents.js";
import { probePublicEvidenceUrl, publicEvidenceAgent } from "./evidence-probe-http.js";
type Entry = ReturnType<typeof parseDatabaseBootstrap>["databases"][number];
/** Read-only availability probes; domain correctness and materialization remain runtime gates. */
export async function probeEvidence(entry: Entry, root: string): Promise<void> {
const evidence = parseWorkspaceYaml(readFileSync(join(root, entry.workspaceId, "workspace.yaml"), "utf8")).evidence;
if (!evidence || evidence.source.type === "filesystem") return;
if (evidencePolicy(evidence)) throw new Error("Evidence egress policy refused");
const secret = (name: keyof NonNullable<Entry["evidenceSecretFiles"]>) => {
const path = entry.evidenceSecretFiles?.[name];
if (!path) throw new Error("Evidence credential missing");
return readFileSync(path, "utf8").trim();
};
const source = evidence.source;
if (source.type === "http") {
const urls: unknown = source.authentication === "signed_urls_file"
? JSON.parse(secret("evidence.signed_urls")) : source.uris;
if (!Array.isArray(urls) || urls.length !== source.uris.length || urls.length > 1000) throw new Error("Invalid signed URLs");
for (const [index, value] of urls.entries()) {
if (typeof value !== "string") throw new Error("Invalid signed URL");
const url = new URL(value);
const provenance = new URL(source.uris[index]);
// Signed queries may authorize the same identity, never a different host/path.
if (url.origin !== provenance.origin || url.pathname !== provenance.pathname || url.username || url.password || url.hash) throw new Error("Invalid signed URL identity");
await probePublicEvidenceUrl(url);
}
return;
}
// The shared runtime policy currently permits trusted AWS endpoints with explicit file credentials.
const location = new URL(source.uri);
const client = new S3Client({
region: source.region ?? "us-east-1", maxAttempts: 1,
requestHandler: { httpsAgent: publicEvidenceAgent(), connectionTimeout: 5_000, requestTimeout: 5_000 },
credentials: { accessKeyId: secret("evidence.access_key"), secretAccessKey: secret("evidence.secret_key"),
...(entry.evidenceSecretFiles?.["evidence.session_token"] ? { sessionToken: secret("evidence.session_token") } : {}) },
});
try {
await client.send(new ListObjectsV2Command({ Bucket: location.hostname, Prefix: decodeURIComponent(location.pathname.slice(1)), MaxKeys: 1 }), { abortSignal: AbortSignal.timeout(5_000) });
} finally { client.destroy(); }
}
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { parseDatabaseBootstrap } from "./bootstrap-documents.js";
import { runBootstrapValidation } from "./bootstrap-cli.js";
import { createConcreteDiagnosticAdapters, type DiagnosticAdapters } from "../workspaces/diagnostics.js";
import { probeEvidence } from "./bootstrap-evidence-probes.js";
interface ProbeCheck { id: string; outcome: "passed" | "error"; field: string; action: string }
/** Uses the same read-only, authenticated connector diagnostics as the Catalog. */
export async function probeBootstrapDependencies(value: unknown, adapters: DiagnosticAdapters = createConcreteDiagnosticAdapters(), workspaceRoot?: string) {
const document = parseDatabaseBootstrap(value);
const checks: ProbeCheck[] = [];
for (const [index, entry] of document.databases.entries()) {
let outcome: ProbeCheck["outcome"] = "passed";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
try {
if (entry.binding.transport === "ssh_tunnel") throw new Error("session transport unavailable");
await adapters.probeConnector({
role: "dwh", transport: entry.binding.transport,
host: entry.binding.host, port: entry.binding.port, user: entry.binding.username,
baseUrl: entry.binding.baseUrl,
credentialFile: entry.binding.transport === "rest_api" ? entry.secretFiles.apiKey : entry.secretFiles.password,
tlsCaFile: entry.secretFiles.tlsCa, tlsServername: entry.binding.tlsServername,
resource: { database: entry.databaseName, schema: entry.schema },
timeoutMs: 5_000, signal: controller.signal,
diagnostic: { method: "GET", path: entry.binding.restPath ?? "/health", auth: entry.binding.restAuth ?? "bearer" },
});
} catch { outcome = "error"; } finally { clearTimeout(timer); }
checks.push({ id: `database-${index}`, outcome, field: `database-bootstrap.databases.${index}`,
action: entry.binding.transport === "ssh_tunnel"
? "Choose postgres_direct or rest_api for NL-to-SQL practice; SSH diagnostics alone cannot establish session readiness."
: "Require an authenticated read-only connection and access to the configured database/schema; correct endpoint, permissions or protected credentials." });
if (workspaceRoot) {
let evidenceOutcome: ProbeCheck["outcome"] = "passed";
try { await probeEvidence(entry, workspaceRoot); } catch { evidenceOutcome = "error"; }
checks.push({ id: `evidence-${index}`, outcome: evidenceOutcome, field: `workspaces.${index}.evidence`, action: "Require readable local Evidence or authenticated bounded HTTP/S3 access under the canonical egress policy; domain meaning is verified during practice." });
}
}
return { schema_version: 1, ok: checks.every((check) => check.outcome === "passed"), checks };
}
export async function runBootstrapProbes(args: string[]) {
const validation = runBootstrapValidation(args);
if (validation.status !== 0) return validation;
try {
const report = await probeBootstrapDependencies(parse(readFileSync(args[3], "utf8")), undefined, args[1]);
return { status: report.ok ? 0 : 1, output: JSON.stringify(report) };
} catch {
return { status: 1, output: JSON.stringify({ schema_version: 1, ok: false, checks: [{ id: "database-probes", outcome: "error", field: "database-bootstrap", action: "Revalidate prepared documents and protected credential references." }] }) };
}
}
@@ -0,0 +1,57 @@
import { lookup } from "node:dns/promises";
import { request as httpRequest } from "node:http";
import { request as httpsRequest, Agent } from "node:https";
import type { LookupFunction } from "node:net";
import ipaddr from "ipaddr.js";
const refused = () => new Error("Evidence network policy refused");
function normalizedPublicAddress(value: string): string {
const address = ipaddr.process(value);
if (address.range() !== "unicast") throw refused();
return address.toString();
}
/** Reject the entire DNS answer set, then pin the connection to that verified set. */
export async function resolvePublicEvidenceHost(hostname: string) {
const values = await lookup(hostname.replace(/^\[|\]$/g, ""), { all: true });
if (!values.length) throw refused();
values.forEach((value) => normalizedPublicAddress(value.address));
return values;
}
const publicLookup: LookupFunction = (hostname, options, callback) => {
void resolvePublicEvidenceHost(hostname).then((values) => {
if (options.all) callback(null, values);
else callback(null, values[0].address, values[0].family);
}, () => callback(refused(), "", 0));
};
// Node's direct agent does not inherit HTTP proxy environment or ambient credentials.
export const publicEvidenceAgent = () => new Agent({ lookup: publicLookup });
export async function probePublicEvidenceUrl(url: URL): Promise<void> {
if (!['http:', 'https:'].includes(url.protocol)) throw refused();
const signal = AbortSignal.timeout(5_000);
const values = await Promise.race([
resolvePublicEvidenceHost(url.hostname),
new Promise<never>((_, reject) => signal.addEventListener("abort", () => reject(refused()), { once: true })),
]);
signal.throwIfAborted();
const allowed = new Set(values.map((value) => normalizedPublicAddress(value.address)));
const pinned: LookupFunction = (_hostname, options, callback) => {
if (options.all) callback(null, values);
else callback(null, values[0].address, values[0].family);
};
await new Promise<void>((resolve, reject) => {
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
method: "GET", lookup: pinned, signal, agent: false,
}, (response) => {
try {
const peer = response.socket.remoteAddress;
if (!peer || !allowed.has(normalizedPublicAddress(peer)) || !response.statusCode || response.statusCode < 200 || response.statusCode >= 300) throw refused();
resolve();
} catch { reject(refused()); } finally { response.destroy(); }
});
request.on("error", () => reject(refused()));
request.end();
});
}
+3 -1
View File
@@ -1,8 +1,10 @@
/** Compiled with its runtime for the host CLI: no installation, Docker or host Node required. */ /** Compiled with its runtime for the host CLI: no installation, Docker or host Node required. */
import { runWorkspaceDocuments } from "./workspaces/documents.js"; import { runWorkspaceDocuments } from "./workspaces/documents.js";
import { runBootstrapValidation } from "./catalog/bootstrap-cli.js"; import { runBootstrapValidation } from "./catalog/bootstrap-cli.js";
import { runBootstrapProbes } from "./catalog/bootstrap-probes.js";
const args = process.argv.slice(2); const args = process.argv.slice(2);
const result = args[0] === "bootstrap" ? runBootstrapValidation(args.slice(1)) : runWorkspaceDocuments(args); const result = args[0] === "probe" ? await runBootstrapProbes(args.slice(1))
: args[0] === "bootstrap" ? runBootstrapValidation(args.slice(1)) : runWorkspaceDocuments(args);
console.log(result.output); console.log(result.output);
process.exitCode = result.status; process.exitCode = result.status;
@@ -0,0 +1,43 @@
import { createServer } from "node:http";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { probeBootstrapDependencies } from "../src/catalog/bootstrap-probes.js";
import { probePublicEvidenceUrl } from "../src/catalog/evidence-probe-http.js";
it("refuses loopback literals and DNS answers before sending an Evidence GET", async () => {
for (const hostname of ["[::1]", "127.0.0.1", "localhost", "[::ffff:127.0.0.1]"]) {
await expect(probePublicEvidenceUrl(new URL(`http://${hostname}/private`))).rejects.toThrow("Evidence network policy refused");
}
});
it("authenticates a bounded read-only REST probe and blocks unavailable credentials/services", async () => {
const directory = mkdtempSync(join(tmpdir(), "tht-probe-"));
const secret = join(directory, "key");
writeFileSync(secret, "PRIVATE_SENTINEL", { mode: 0o600 });
let status = 200;
const requests: string[] = [];
const server = createServer((req, res) => {
requests.push(`${req.method} ${req.url}`);
res.writeHead(req.headers.authorization === "Bearer PRIVATE_SENTINEL" ? status : 401);
res.end("PRIVATE_SERVER_RESPONSE");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
const document = { schemaVersion: 1, databases: [{ workspaceId: "demo", engine: "postgres", databaseName: "demo", schema: "public", binding: { transport: "rest_api", baseUrl: `http://127.0.0.1:${address.port}`, restPath: "/health", restAuth: "bearer" }, secretFiles: { apiKey: secret } }] };
try {
expect((await probeBootstrapDependencies(document)).ok).toBe(true);
status = 503;
const failed = await probeBootstrapDependencies(document);
expect(failed.ok).toBe(false);
expect(JSON.stringify(failed)).not.toContain("PRIVATE");
status = 200;
writeFileSync(secret, "rotated-but-invalid");
expect((await probeBootstrapDependencies(document)).ok).toBe(false);
expect(requests).toEqual(["GET /health", "GET /health", "GET /health"]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
rmSync(directory, { recursive: true });
}
});
+91
View File
@@ -0,0 +1,91 @@
# Installation preflight and release manifest
The native operator protocol is version 1. `installation preflight` is the independent
step-3 host check. `installation plan` repeats document validation, performs step-5
live checks and writes an owner-only JSON plan plus a separate owner-only `.key` file.
Neither command creates containers, imports bindings, modifies a database or invokes
model generation. Exit codes are 0 (checks passed), 1 (blocking error), and 2 (usage).
JSON stdout contains only the report. Checks carry stable `id`, `outcome`, `field`
and `action`; outcomes are `passed`, `error`, `warning`, `deferred-to-runtime`.
## Release manifest schema 1
The manifest is a local JSON file shipped with the verified operator/release bundle.
Required keys:
| Key | Contract |
| --- | --- |
| `schema_version` | `1` |
| `version` | Semantic release version, optionally prerelease |
| `revision` | 40 lowercase hexadecimal Git commit characters |
| `validator_protocol` | `1`; incompatible consumers refuse the manifest |
| `requirements` | `cpus`, `memory_bytes`, `disk_bytes`; at least 2 CPUs, 4 GiB Docker memory and 10 GiB installation filesystem space |
| `components` | Includes `pi`, `catalog-migrations`, `workspace-maintenance` |
| `images` | Exactly `core`, `frontend`, `catalog`, `qdrant`, `embedding`; each maps `linux/amd64` and/or `linux/arm64` to a `docker.io/...@sha256:...` **single-platform image digest** |
| `files` | Relative packaged resource paths to SHA-256; no traversal, links or absolute paths; maximum 256 files, 32 MiB per resource |
| `compose` | Ordered relative Compose file paths present in `files` for this release configuration |
Include the selected `deploy/compose.git-https.yaml` or `deploy/compose.git-ssh.yaml`
transport overlay in `files`. Standard transport overlays are resolved from the
release while absent in the installation directory; existing authored overrides
remain input files. Compose must resolve all eight services: core, frontend,
catalog-db, catalog-migrate, workspace-maintenance, qdrant, embedding and
embedding-model-init. The two maintenance services share the core digest; embedding
initialization shares the embedding digest. Source builds and undeclared services
are rejected in this prebuilt path. The explicit source path is a separate ticket.
`docker manifest inspect --verbose` checks each selected immutable image and its
platform without pulling layers. `docker compose config --format json` checks the
effective service configuration. Compose receives only Docker connection/trust,
proxy and executable-discovery host variables; application parameters come from
the prepared environment file. Raw Docker output is never copied into reports.
Each Docker command has a 15-second bound. No release is currently certified merely
because controlled manifest tests pass: publication and real pull acceptance belong
to the publication/execution tickets.
## External checks and bounds
- Git HTTPS: authenticated `GET /info/refs?service=git-upload-pack`, configured CA,
no redirects, selected branch advertised, 1 MiB response and 5-second bound.
Git SSH uses its prepared key/known-hosts and `git-upload-pack --advertise-refs`
with the same response/time bounds; no checkout or push occurs.
- PostgreSQL: the existing Catalog diagnostic adapter authenticates and reads
`current_database()` plus schema `USAGE`; no user tables are modified.
- REST database transport: existing Catalog diagnostic `GET` with the configured
bearer/API-key header and status validation. SSH database bindings cannot pass
this NL-to-SQL installation plan because runtime sessions do not support them.
- Evidence: local paths were already validated. HTTP performs bounded GET requests
and cancels response bodies; signed URL identities must match authored provenance.
S3 performs one `ListObjectsV2` request with `MaxKeys: 1`, no retries, explicit
file credentials and the canonical Evidence egress policy. These metadata requests
can incur normal remote-service request charges; they do not run LLM generation.
Each external request has a 5-second bound; the native database/Evidence helper
has a 60-second aggregate bound. Correct unavailable services before repeating.
- Explicit model endpoints: DNS/TCP/TLS origin reachability, without generating
tokens. Built-in endpoint resolution, provider authentication and model smoke
operations use the bundled Pi SDK at runtime; the report never claims those
operations have already passed.
No unreachable configured external dependency is converted to a deferred success.
Runtime obligations have explicit identities: `container-network`,
`catalog-initialization`, `pi-operation`, `local-embedding`,
`workspace-preprocessing`, `workspace-readiness`. These must be discharged by the
execution/readiness tickets before final success. Host disk inspection cannot prove
Docker Desktop VM free storage; its separate storage warning remains explicit.
## Input identity and freshness
The plan records normalized installation configuration, release digests, validator
build identity, verified input paths, local workspace content and its Git HEAD when
available (otherwise a content snapshot). Files are bounded to 32 MiB each and
256 MiB total; workspace/auth trees to 10,000 entries, without links or special files.
Credential contents are never serialized. An HMAC covers the private inputs and
plan using a separate random 32-byte owner-only key; no public unkeyed secret hash
is generated. Credential rotation invalidates the plan. Added/deleted/changed
workspace files invalidate it as well. Changes during the checks abort publication.
Plan output never overwrites an existing plan or key.
Execution and resumption must call `VerifyPlanInputs` **and repeat live checks and
credential reads before mutations**. A valid seal alone does not certify current
network availability, Docker state or runtime readiness. Keep both plan files
private and outside the workspace repository; they are installation-local artifacts.
+40
View File
@@ -152,6 +152,46 @@ must already exist. Release assets, external connectivity, Catalog import and ru
readiness remain explicit deferred checks. Success prepares the next preflight; readiness remain explicit deferred checks. Success prepares the next preflight;
it neither skips those checks nor establishes a completed installation. it neither skips those checks nor establishes a completed installation.
## Check prerequisites and produce the plan
At step 3, before completing all application parameters, check the machine and
the private installation directory already prepared:
```bash
tht installation preflight --directory /path/installation --json
```
This requires a reachable Linux Docker daemon, Compose 2.24 or newer, at least
2 CPUs, 4 GiB allocated to Docker and 10 GiB free on the installation filesystem.
A release may require more resources. On Windows run the Linux executable in
Ubuntu WSL2 with Docker Desktop integration; Pi is bundled in the core image.
At step 5, after `installation validate`, select the published release manifest
with its downloaded bundle resources and produce a new plan:
```bash
tht --installation /path/installation/thothii-installation.yaml installation plan \
--workspaces /path/workspaces \
--release /path/release/release-manifest.json \
--output /path/installation/installation-plan.json --json
```
This repeats document checks, verifies image digests and Compose, Git, available
external databases and Evidence, then saves the plan and its separate private
`.key` file. It does not execute setup. Missing images and unavailable existing
dependencies block the plan. Actual Docker Hub publication remains the next ticket;
an invented manifest cannot bypass publication.
Correct `error` outcomes and read `warning` outcomes. `deferred-to-runtime` entries
are mandatory checks after startup, not readiness already achieved. After changing
documents or rotating credentials, produce a new plan; existing files are never
overwritten. Keep both plan files outside workspace Git. External probes perform
bounded database authentication/schema reads, Git/HTTP/S3 reads and explicit model
endpoint reachability checks. They invoke no LLM generation; HTTP/S3 requests may
incur ordinary service request charges. See the
[preflight reference](installation-preflight.md) for limits, the manifest
format and runtime obligations.
## Before you start: the two repositories ## Before you start: the two repositories
There are two separate repositories: There are two separate repositories:
+41
View File
@@ -158,6 +158,47 @@ differiti: asset del rilascio, connettività esterna, import Catalog e readiness
Un esito positivo prepara il successivo preflight: non autorizza a saltare tali Un esito positivo prepara il successivo preflight: non autorizza a saltare tali
controlli e non equivale a un'installazione completata. controlli e non equivale a un'installazione completata.
## Verificare le precondizioni e produrre il piano
Al passo 3, prima di completare tutti i parametri applicativi, controllare la
macchina e la directory privata già preparata:
```bash
tht installation preflight --directory /percorso/installazione --json
```
Servono Docker Linux raggiungibile, Compose 2.24 o successivo, almeno 2 CPU,
4 GiB assegnati a Docker e 10 GiB liberi nella directory di installazione. Il
rilascio può richiedere risorse maggiori. Su Windows eseguire il binario Linux
in Ubuntu WSL2 con integrazione Docker Desktop; Pi è incluso nell'immagine core.
Al passo 5, dopo `installation validate`, selezionare il manifest del rilascio
pubblicato, con le risorse del bundle già scaricate, e produrre un piano nuovo:
```bash
tht --installation /percorso/installazione/thothii-installation.yaml installation plan \
--workspaces /percorso/workspaces \
--release /percorso/rilascio/release-manifest.json \
--output /percorso/installazione/installation-plan.json --json
```
Il comando ripete i controlli dei documenti, verifica immagini/digest e Compose,
Git, database ed Evidence esterne disponibili, poi salva il piano e il suo file
privato `.key`. Non esegue il setup. Le immagini assenti e le dipendenze esterne
irraggiungibili bloccano il piano. Al momento la pubblicazione reale Docker Hub
è ancora il ticket successivo: un manifest inventato non permette di aggirarla.
Correggere gli esiti `error`; leggere gli `warning`. Gli esiti
`deferred-to-runtime` identificano controlli obbligatori dopo l'avvio, non una
readiness già ottenuta. Un piano valido non sostituisce questi gate. Dopo una
correzione o rotazione di credenziali produrre un nuovo piano; i file esistenti
non vengono sovrascritti. Conservare piano e chiave fuori dal Git dei workspace.
Le prove esterne sono letture limitate: autenticazione/schema del database,
letture Git e HTTP/S3, raggiungibilità degli endpoint modello espliciti. Nessuna
generazione LLM viene invocata; le richieste HTTP/S3 possono avere i normali costi
del servizio. Limiti, manifest e obblighi sono nel
[riferimento di preflight](installation-preflight.md).
## Prima di iniziare: i due repository ## Prima di iniziare: i due repository
Servono due repository distinti: Servono due repository distinti:
@@ -35,7 +35,22 @@ passati e 40 saltati; le regressioni successive della revisione passano nella su
mirata (quattro test, incluso il percorso con binari nativi e `PATH` vuoto). mirata (quattro test, incluso il percorso con binari nativi e `PATH` vuoto).
Typecheck, build rigorosa documentazione e pacchetti Go passati; il pacchetto CLI Typecheck, build rigorosa documentazione e pacchetti Go passati; il pacchetto CLI
è stato ripetuto dopo la correzione rilevata in revisione. Nessun finding residuo è stato ripetuto dopo la correzione rilevata in revisione. Nessun finding residuo
delle revisioni Standards/Spec. Il prossimo incremento sequenziale è T03/#45. delle revisioni Standards/Spec.
T03/#45 implementato: `installation preflight` verifica l'host al passo 3 e
`installation plan` ripete i documenti, verifica rilascio/Compose e dipendenze
esterne, poi sigilla un piano privato legato agli input. Restano espliciti gli
obblighi runtime; nessun container viene creato. Il contratto del manifest è nel
[riferimento pubblico di preflight](../install/installation-preflight.md).
Verifica completa: tutti i pacchetti Go passati, inclusa l'integrazione con la
coppia nativa, Docker controllato e servizi Git HTTPS/database REST locali;
backend Node 24.16 con 112 file passati, uno saltato, 1.423 test passati e 40 saltati.
Typecheck e documentazione rigorosa passati. Bundle macOS arm64, Linux amd64 e
Windows amd64 ricompilati; solo macOS è stato eseguito qui, senza attribuire un
collaudo host alle compilazioni incrociate. Le revisioni Standards/Spec non lasciano
finding aperti dopo le regressioni su rete Evidence, collocazione del piano,
piattaforma Compose e comparsa di override locali. Nessuna pubblicazione reale
effettuata: il prossimo incremento è T04/#46, con namespace e accessi del manutentore.
| Ticket | Issue Gitea | Dipendenze dirette | | Ticket | Issue Gitea | Dipendenze dirette |
| --- | --- | --- | | --- | --- | --- |
+2
View File
@@ -56,6 +56,7 @@ exclude_docs: |
!/install/first-start.md !/install/first-start.md
!/install/standalone-manual-it.md !/install/standalone-manual-it.md
!/install/standalone-manual-en.md !/install/standalone-manual-en.md
!/install/installation-preflight.md
!/install/shell-and-language.md !/install/shell-and-language.md
!/install/authentication-local.md !/install/authentication-local.md
!/install/authentication-oidc.md !/install/authentication-oidc.md
@@ -109,6 +110,7 @@ nav:
- Start here: install/first-start.md - Start here: install/first-start.md
- Mac, Windows, Linux — Italiano: install/standalone-manual-it.md - Mac, Windows, Linux — Italiano: install/standalone-manual-it.md
- Mac, Windows, Linux — English: install/standalone-manual-en.md - Mac, Windows, Linux — English: install/standalone-manual-en.md
- Preflight and release manifest: install/installation-preflight.md
- Display mode and language: install/shell-and-language.md - Display mode and language: install/shell-and-language.md
- Local authentication: install/authentication-local.md - Local authentication: install/authentication-local.md
- OIDC authentication: install/authentication-oidc.md - OIDC authentication: install/authentication-oidc.md
+19
View File
@@ -75,6 +75,25 @@ fixtures and removes host tools from the subprocess PATH. Platform acceptance an
real external credentials remain separate gates. See the IT/EN guides for the real external credentials remain separate gates. See the IT/EN guides for the
complete parameter collection procedure, default `admin` account and local-auth scope. complete parameter collection procedure, default `admin` account and local-auth scope.
## Host preflight and installation plan (issue #45)
`tht installation preflight --directory PATH [--release MANIFEST] [--json]` checks
the prepared directory and host without creating a stack. After completing the
documents, use `tht --installation ABS_PATH installation plan --workspaces PATH
--release MANIFEST --output NEW_PLAN [--bootstrap PATH] [--json]` for the full plan.
The manifest, bounded external reads, private input seal and mandatory runtime
obligations are specified in the
[preflight reference](../../docs/install/installation-preflight.md).
The native end-to-end test supplies a controlled Docker executable and real local
HTTPS Git/HTTP database services. No application container is created:
```sh
cd tools/tht
THT_INSTALLATION_TEST_CLI=/absolute/path/dist/workspace-tools/darwin-arm64/tht \
go test ./cmd/tht -run TestNativeInstallationPlanBeforeContainersExist -count=1
```
## Shell configuration ## Shell configuration
The schema-v2 `thothii-installation.yaml` accepts this optional section: The schema-v2 `thothii-installation.yaml` accepts this optional section:
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/preflight"
"github.com/aritmolab/thothii/tools/tht/internal/preparation"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/version"
)
func installationPreflightCommand(ctx context.Context, installationPath string, args []string, stdout io.Writer) int {
report := preflight.NewReport()
options := map[string]string{}
usage := false
for index := 1; index < len(args); index++ {
key := args[index]
if _, exists := options[key]; exists {
usage = true
break
}
if key == "--json" {
options[key] = "true"
continue
}
if !slices.Contains([]string{"--directory", "--workspaces", "--bootstrap", "--release", "--output"}, key) || index+1 == len(args) {
usage = true
break
}
options[key] = args[index+1]
index++
}
planning := len(args) > 0 && args[0] == "plan"
if usage || len(args) == 0 || (!planning && args[0] != "preflight") || (planning && (installationPath == "" || options["--workspaces"] == "" || options["--release"] == "" || options["--output"] == "" || options["--directory"] != "")) || (!planning && (options["--directory"] == "" || options["--workspaces"] != "" || options["--bootstrap"] != "" || options["--output"] != "")) {
report.Add("usage", "error", "CLI", "Use installation preflight --directory PATH [--release MANIFEST] [--json], or --installation ABS_PATH installation plan --workspaces PATH --release MANIFEST --output NEW_PLAN [--bootstrap PATH] [--json].")
writePreflight(stdout, report, slices.Contains(args, "--json"))
return 2
}
for key, value := range options {
if key != "--json" {
absolute, err := filepath.Abs(value)
if err != nil {
report.Add("path", "error", "CLI", "Supply canonical absolute paths.")
} else {
options[key] = absolute
}
}
}
var installation config.Installation
if planning {
root, err := filepath.EvalSymlinks(options["--workspaces"])
if err == nil {
relative, err := filepath.Rel(root, options["--output"])
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
report.Add("plan-in-workspace", "error", "output", "Keep the private plan and its key outside the shared workspace repository.")
}
}
}
if planning {
bootstrap := options["--bootstrap"]
if bootstrap == "" {
bootstrap = filepath.Join(filepath.Dir(installationPath), "database-bootstrap.yaml")
options["--bootstrap"] = bootstrap
}
var output bytes.Buffer
code := installationDocumentsCommand(ctx, installationPath, []string{"validate", "--workspaces", options["--workspaces"], "--bootstrap", bootstrap, "--json"}, &output)
var documents preparation.Report
if code != 0 || json.Unmarshal(output.Bytes(), &documents) != nil || !documents.OK {
report.Add("application-documents", "error", "documents", "Run installation validate and correct every reported issue before planning.")
} else {
var err error
installation, err = config.LoadPrepared(installationPath)
if err != nil {
report.Add("application-documents", "error", "documents", "Prepared inputs changed; repeat validation.")
}
options["--directory"] = installation.ProjectDirectory
}
}
if report.OK {
if exists, err := safeio.PreflightPrivateDirectory(options["--directory"]); err != nil || !exists {
report.Add("installation-directory", "error", "projectDirectory", "Use an existing private canonical installation directory.")
}
}
minimum := preflight.DefaultRequirements()
var inputs []string
var absent []string
var revision string
var unchanged func() bool
var manifest preflight.Manifest
if options["--release"] != "" {
var err error
manifest, err = preflight.LoadManifest(options["--release"])
if err != nil {
report.Add("release-manifest", "error", "release", "Use a complete supported manifest with all packaged file digests verified; publication must precede planning.")
} else {
minimum = manifest.Requirements
}
}
if report.OK && planning {
var err error
inputs, revision, err = preflight.CollectInputs(installation, options["--workspaces"], options["--bootstrap"], options["--release"])
if err == nil {
absent = preflight.AbsentOverrides(installation)
unchanged, err = preflight.CaptureInputs(inputs, options["--workspaces"], absent...)
}
if err != nil {
report.Add("input-snapshot", "error", "documents", "Keep input trees bounded, readable and free from links; repeat document validation.")
} else {
var discarded bytes.Buffer
if installationDocumentsCommand(ctx, installationPath, []string{"validate", "--workspaces", options["--workspaces"], "--bootstrap", options["--bootstrap"], "--json"}, &discarded) != 0 {
report.Add("changed-documents", "error", "documents", "Documents changed during validation; correct and repeat.")
}
}
}
host, hostErr := preflight.InspectHost(options["--directory"])
if hostErr != nil {
report.Add("host-filesystem", "error", "projectDirectory", "Allow filesystem capacity inspection at the canonical installation path.")
}
// Docker connection/trust and executable discovery remain host-owned. Compose parameters
// are exclusively read from the authored env file, not inherited shell overrides.
dockerEnvironment := []string{}
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if slices.Contains([]string{"PATH", "HOME", "USERPROFILE", "SystemRoot", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"}, key) {
dockerEnvironment = append(dockerEnvironment, entry)
}
}
runner := compose.NewRunnerWithEnvironment("", dockerEnvironment)
if report.OK {
report.Merge(preflight.CheckHost(ctx, runner, host, minimum))
}
platform := "linux/" + host.Arch
if report.OK && options["--release"] != "" {
report.Merge(preflight.CheckImages(ctx, runner, manifest, platform))
}
if report.OK && planning {
report.Merge(preflight.CheckCompose(ctx, runner, installation, manifest, options["--release"], platform))
report.Merge(preflight.CheckExternal(ctx, installation))
var output, discarded bytes.Buffer
bound, cancel := context.WithTimeout(ctx, 60*time.Second)
code := workspaceDocumentsCommand(bound, []string{"probe", "--directory", options["--workspaces"], "--bootstrap", options["--bootstrap"], "--json"}, &output, &discarded)
cancel()
var probes preflight.Report
if json.Unmarshal(output.Bytes(), &probes) != nil || len(probes.Checks) == 0 {
report.Add("database-probes", "error", "database-bootstrap", "Restore the matching validation helper and rerun bounded external dependency checks.")
} else {
report.Merge(probes)
}
if code != 0 && report.OK {
report.Add("database-probes", "error", "database-bootstrap", "A required external dependency is unavailable; correct it before planning.")
}
if report.OK {
if unchanged == nil || !unchanged() {
report.Add("changed-inputs", "error", "documents", "An input or credential changed during checks; repeat planning with stable prepared files.")
} else {
preflight.AddRuntimeObligations(&report)
plan := preflight.Plan{SchemaVersion: 1, ValidatorProtocol: preflight.Protocol, Validator: version.Current(), Installation: installation, Release: manifest, Platform: platform, WorkspaceDirectory: options["--workspaces"], WorkspaceRevision: revision, Inputs: inputs, AbsentInputs: absent, Report: report}
if err := preflight.WritePlan(options["--output"], &plan, unchanged); err != nil {
report.Add("plan-output", "error", "output", "Choose a new filename in a private canonical directory; existing plans and key files are never overwritten.")
}
}
}
}
if !planning {
report.Add("prepared-inputs", "warning", "documents", "Host preflight alone is not an executable plan; complete application validation and release selection at step 5.")
}
writePreflight(stdout, report, options["--json"] != "")
if report.OK {
return 0
}
return 1
}
func writePreflight(stdout io.Writer, report preflight.Report, structured bool) {
if structured {
_ = json.NewEncoder(stdout).Encode(report)
return
}
for _, check := range report.Checks {
fmt.Fprintf(stdout, "%s [%s] %s: %s\n", check.Outcome, check.ID, check.Field, check.Action)
}
}
@@ -0,0 +1,138 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/preflight"
)
func TestNativeInstallationPlanBeforeContainersExist(t *testing.T) {
binary := os.Getenv("THT_INSTALLATION_TEST_CLI")
if binary == "" || runtime.GOOS == "windows" {
t.Skip("set THT_INSTALLATION_TEST_CLI to the compiled sibling bundle")
}
root, _ := filepath.EvalSymlinks(t.TempDir())
_ = os.Chmod(root, 0o700)
workspace := filepath.Join(root, "workspaces")
installation := filepath.Join(root, "installation")
release := filepath.Join(root, "release")
_ = os.Mkdir(release, 0o700)
command := func(args ...string) (int, string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binary, append(args, "--json")...)
cmd.Env = append(os.Environ(), "PATH="+root)
data, err := cmd.CombinedOutput()
if err != nil {
return 1, string(data)
}
return 0, string(data)
}
for _, args := range [][]string{{"workspace", "prepare", "--directory", workspace, "--id", "practice", "--name", "Practice"}, {"installation", "prepare", "--directory", installation}, {"installation", "credentials", "--directory", installation}} {
if code, text := command(args...); code != 0 {
t.Fatal(text)
}
}
git := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("Git mutation")
}
fmt.Fprintln(w, "0044"+strings.Repeat("b", 40)+" refs/heads/main")
}))
defer git.Close()
database := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("DWH mutation")
}
if r.Header.Get("Authorization") != "Bearer PRIVATE_DATABASE_VALUE" {
w.WriteHeader(401)
}
}))
defer database.Close()
write := func(path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
descriptor := filepath.Join(installation, "thothii-installation.yaml")
for _, name := range []string{"thothii-installation.yaml", "operator.env"} {
path := filepath.Join(installation, name)
data, _ := os.ReadFile(path)
write(path, []byte(strings.ReplaceAll(string(data), "https://CHANGE_ME/workspaces.git", git.URL+"/workspaces.git")))
}
for name, data := range map[string][]byte{"secrets.env": []byte("OPENAI_API_KEY=PRIVATE_PROVIDER_VALUE\n"), "database-password": []byte("PRIVATE_DATABASE_VALUE"), "git-credentials": {}, "git-ca.pem": pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: git.Certificate().Raw})} {
write(filepath.Join(installation, "secrets", name), data)
}
write(filepath.Join(installation, "database-bootstrap.yaml"), []byte(fmt.Sprintf("schemaVersion: 1\ndatabases:\n - workspaceId: practice\n engine: postgres\n databaseName: practice\n schema: public\n binding: {transport: rest_api, baseUrl: %q, restPath: /health, restAuth: bearer}\n secretFiles: {apiKey: %q}\n", database.URL, filepath.Join(installation, "secrets/database-password"))))
manifest := preflight.Manifest{SchemaVersion: 1, Version: "1.0.0", Revision: strings.Repeat("b", 40), ValidatorProtocol: 1, Requirements: preflight.DefaultRequirements(), Components: []string{"pi", "catalog-migrations", "workspace-maintenance"}, Images: map[string]map[string]string{}, Files: map[string]string{}, Compose: []string{"compose.yaml"}}
platform := "linux/" + runtime.GOARCH
services := map[string]map[string]string{}
for _, role := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
manifest.Images[role] = map[string]string{platform: "docker.io/example/" + role + "@sha256:" + strings.Repeat("a", 64)}
}
for service, role := range map[string]string{"core": "core", "frontend": "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"} {
services[service] = map[string]string{"image": manifest.Images[role][platform]}
}
_ = os.Mkdir(filepath.Join(release, "deploy"), 0o700)
for name, contents := range map[string]string{"compose.yaml": "services: {}\n", "deploy/compose.git-https.yaml": "services: {}\n"} {
write(filepath.Join(release, filepath.FromSlash(name)), []byte(contents))
sum := sha256.Sum256([]byte(contents))
manifest.Files[name] = hex.EncodeToString(sum[:])
}
manifestPath := filepath.Join(release, "release-manifest.json")
manifestJSON, _ := json.Marshal(manifest)
write(manifestPath, manifestJSON)
effective, _ := json.Marshal(map[string]any{"services": services})
script := fmt.Sprintf("#!/bin/sh\ncase \"$1\" in\ninfo) printf '%%s\\n' '{\"OSType\":\"linux\",\"Architecture\":\"%s\",\"NCPU\":4,\"MemTotal\":17179869184}';;\ncompose) if [ \"$2\" = version ]; then printf '2.39.0\\n'; else printf '%%s\\n' '%s'; fi;;\nmanifest) printf '%%s\\n' '{\"Descriptor\":{\"digest\":\"sha256:%s\",\"platform\":{\"os\":\"linux\",\"architecture\":\"%s\"}}}';;\n*) exit 1;;\nesac\n", runtime.GOARCH, effective, strings.Repeat("a", 64), runtime.GOARCH)
write(filepath.Join(root, "docker"), []byte(script))
_ = os.Chmod(filepath.Join(root, "docker"), 0o700)
planPath := filepath.Join(installation, "plan.json")
if code, text := command("--installation", descriptor, "installation", "validate", "--workspaces", workspace); code != 0 {
t.Fatalf("documents: %s", text)
}
args := []string{"--installation", descriptor, "installation", "plan", "--workspaces", workspace, "--release", manifestPath, "--output", planPath}
args[len(args)-1] = filepath.Join(workspace, "forbidden-plan.json")
if code, _ := command(args...); code == 0 {
t.Fatal("plan written inside workspace")
}
if _, err := os.Stat(args[len(args)-1]); !os.IsNotExist(err) {
t.Fatal("private plan published inside workspace")
}
args[len(args)-1] = planPath
code, text := command(args...)
if code != 0 {
t.Fatalf("plan failed: %s", text)
}
if strings.Contains(text, "PRIVATE_") {
t.Fatal("secret in report")
}
if err := preflight.VerifyPlanInputs(planPath); err != nil {
t.Fatal(err)
}
write(filepath.Join(installation, "secrets/database-password"), []byte("rotated-invalid"))
if preflight.VerifyPlanInputs(planPath) == nil {
t.Fatal("rotation did not invalidate plan")
}
args[len(args)-1] = filepath.Join(installation, "second-plan.json")
if code, text := command(args...); code == 0 || strings.Contains(text, "PRIVATE_") {
t.Fatalf("invalid credential plan: %s", text)
}
if _, err := os.Stat(args[len(args)-1]); !os.IsNotExist(err) {
t.Fatal("failed checks published plan")
}
}
+7
View File
@@ -49,6 +49,10 @@ Commands:
Explicitly generate protected technical credentials before setup. Explicitly generate protected technical credentials before setup.
installation validate --workspaces PATH [--bootstrap PATH] [--json] installation validate --workspaces PATH [--bootstrap PATH] [--json]
Check prepared application documents; requires --installation. Check prepared application documents; requires --installation.
installation preflight --directory PATH [--release MANIFEST] [--json]
Check host prerequisites and optionally the published release, without a stack.
installation plan --workspaces PATH --release MANIFEST --output NEW_PLAN [--bootstrap PATH] [--json]
Validate documents and live dependencies, then seal a private plan; requires --installation.
installation migrate --output PATH --session-default PROVIDER/MODEL installation migrate --output PATH --session-default PROVIDER/MODEL
--embedding-id PROVIDER/MODEL --embedding-dimensions N --embedding-id PROVIDER/MODEL --embedding-dimensions N
Create a review-only schema-v2 candidate from all three legacy model sources. Create a review-only schema-v2 candidate from all three legacy model sources.
@@ -144,6 +148,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return versionCommand(commandArgs, stdout, stderr) return versionCommand(commandArgs, stdout, stderr)
} }
if command == "installation" { if command == "installation" {
if len(commandArgs) > 0 && (commandArgs[0] == "preflight" || commandArgs[0] == "plan") {
return installationPreflightCommand(ctx, installationPath, commandArgs, stdout)
}
if len(commandArgs) > 0 && (commandArgs[0] == "prepare" || commandArgs[0] == "credentials" || commandArgs[0] == "validate") { if len(commandArgs) > 0 && (commandArgs[0] == "prepare" || commandArgs[0] == "credentials" || commandArgs[0] == "validate") {
return installationDocumentsCommand(ctx, installationPath, commandArgs, stdout) return installationDocumentsCommand(ctx, installationPath, commandArgs, stdout)
} }
+14 -1
View File
@@ -59,7 +59,17 @@ type boundedStreamingRunner interface {
// execRunner executes the Docker CLI. It never invokes a shell. // execRunner executes the Docker CLI. It never invokes a shell.
type execRunner struct { type execRunner struct {
binary string binary string
environment []string
}
// NewRunnerWithEnvironment uses an explicit process environment, for document-owned Compose
// configuration that must not inherit unrelated operator shell parameter overrides.
func NewRunnerWithEnvironment(binary string, environment []string) Runner {
if binary == "" {
binary = "docker"
}
return execRunner{binary: binary, environment: append([]string{}, environment...)}
} }
// NewRunner returns a runner for binary. An empty binary selects docker from PATH. // NewRunner returns a runner for binary. An empty binary selects docker from PATH.
@@ -106,6 +116,9 @@ func (r execRunner) runBounded(ctx context.Context, args []string, stdin io.Read
} }
} }
command := exec.Command(r.binary, preparedArgs...) command := exec.Command(r.binary, preparedArgs...)
if r.environment != nil {
command.Env = r.environment
}
configureProcess(command) configureProcess(command)
command.Stdin = stdin command.Stdin = stdin
overflow := make(chan struct{}, 1) overflow := make(chan struct{}, 1)
+122
View File
@@ -0,0 +1,122 @@
// Package preflight checks a prepared installation without creating application state.
package preflight
import (
"context"
"encoding/json"
"io"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
)
const Protocol = 1
type Check struct {
ID string `json:"id"`
Outcome string `json:"outcome"`
Field string `json:"field"`
Action string `json:"action"`
}
type Report struct {
SchemaVersion int `json:"schema_version"`
OK bool `json:"ok"`
Checks []Check `json:"checks"`
}
func NewReport() Report { return Report{SchemaVersion: 1, OK: true, Checks: []Check{}} }
func (r *Report) Add(id, outcome, field, action string) {
r.Checks = append(r.Checks, Check{id, outcome, field, action})
if outcome == "error" {
r.OK = false
}
}
func (r *Report) Merge(other Report) {
r.Checks = append(r.Checks, other.Checks...)
r.OK = r.OK && other.OK
}
func (r Report) JSON() string { data, _ := json.Marshal(r); return string(data) }
type Runner interface {
Run(context.Context, []string, io.Reader) (compose.Result, error)
}
func docker(ctx context.Context, runner Runner, args ...string) (compose.Result, error) {
bound, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
return runner.Run(bound, args, nil)
}
type Requirements struct {
CPUs int `json:"cpus"`
MemoryBytes uint64 `json:"memory_bytes"`
DiskBytes uint64 `json:"disk_bytes"`
}
func DefaultRequirements() Requirements { return Requirements{2, 4 << 30, 10 << 30} }
type Host struct {
OS string
Arch string
Kernel string
Distribution string
FreeBytes uint64
}
func InspectHost(directory string) (Host, error) {
h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH}
kernel, _ := os.ReadFile("/proc/sys/kernel/osrelease")
h.Kernel = strings.ToLower(string(kernel))
distribution, _ := os.ReadFile("/etc/os-release")
h.Distribution = strings.ToLower(string(distribution))
var err error
h.FreeBytes, err = freeBytes(directory)
return h, err
}
func CheckHost(ctx context.Context, runner Runner, host Host, minimum Requirements) Report {
r := NewReport()
check := func(id string, ok bool, action string) {
outcome := "passed"
if !ok {
outcome = "error"
}
r.Add(id, outcome, "host", action)
}
check("host-platform", (host.OS == "linux" || host.OS == "darwin") && (host.Arch == "amd64" || host.Arch == "arm64"), "Use the Linux executable in Ubuntu WSL2/Omarchy, or the matching macOS executable; native Windows is not the installation path.")
if strings.Contains(strings.ToLower(host.Kernel), "microsoft") {
check("wsl2", strings.Contains(strings.ToLower(host.Kernel), "wsl2") && strings.Contains(host.Distribution, "ubuntu"), "Use Ubuntu WSL2 and enable Docker Desktop integration for that distribution.")
}
info, err := docker(ctx, runner, "info", "--format", "{{json .}}")
var parsed struct {
OSType string
Architecture string
NCPU int
MemTotal uint64
}
good := err == nil && json.Unmarshal([]byte(info.Stdout), &parsed) == nil
check("docker-daemon", good && parsed.OSType == "linux", "Start a reachable Linux Docker daemon for this user.")
arch := parsed.Architecture
if arch == "x86_64" {
arch = "amd64"
}
if arch == "aarch64" {
arch = "arm64"
}
check("docker-architecture", good && arch == host.Arch, "Use a Linux Docker daemon matching the host architecture; emulation is not certified.")
check("docker-resources", good && parsed.NCPU >= minimum.CPUs && parsed.MemTotal >= minimum.MemoryBytes, "Allocate at least the release CPU and memory minimum to Docker.")
check("installation-disk", host.FreeBytes >= minimum.DiskBytes && host.FreeBytes > 0, "Provide the release minimum free space on the installation filesystem.")
result, err := docker(ctx, runner, "compose", "version", "--short")
parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(result.Stdout), "v"), ".")
major, _ := strconv.Atoi(parts[0])
minor := 0
if len(parts) > 1 {
minor, _ = strconv.Atoi(parts[1])
}
check("docker-compose", err == nil && (major > 2 || (major == 2 && minor >= 24)), "Install Docker Compose v2.24 or newer.")
r.Add("daemon-storage", "warning", "host", "Host disk capacity does not measure a Docker Desktop VM disk; reserve equivalent Docker storage and verify volume allocation during setup.")
return r
}
+60
View File
@@ -0,0 +1,60 @@
package preflight
import (
"context"
"encoding/json"
"os"
"path/filepath"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
// CheckCompose resolves the distributed release plus authored overrides without starting services.
func CheckCompose(ctx context.Context, runner Runner, installation config.Installation, m Manifest, manifestPath, platform string) Report {
r := NewReport()
args := []string{"compose", "--project-directory", installation.ProjectDirectory, "--env-file", installation.EnvFile}
for _, name := range m.Compose {
args = append(args, "-f", filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(name)))
}
for _, path := range installation.Overrides {
if _, err := os.Stat(path); os.IsNotExist(err) {
// Only the two well-known distribution-owned transport overlays can be relocated.
name := "deploy/" + filepath.Base(path)
if path != filepath.Join(installation.ProjectDirectory, filepath.FromSlash(name)) || (name != "deploy/compose.git-https.yaml" && name != "deploy/compose.git-ssh.yaml") || m.Files[name] == "" {
r.Add("compose-assets", "error", "overrides", "Include the selected Git transport overlay in the verified release.")
return r
}
path = filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(name))
}
args = append(args, "-f", path)
}
args = append(args, "config", "--format", "json")
result, err := docker(ctx, runner, args...)
var effective struct {
Services map[string]struct {
Image string `json:"image"`
Build any `json:"build"`
Platform string `json:"platform"`
} `json:"services"`
}
if err != nil || json.Unmarshal([]byte(result.Stdout), &effective) != nil {
r.Add("compose-configuration", "error", "operator.env", "Correct the effective Compose configuration using the release assets and prepared environment; no raw output is logged.")
return r
}
roles := map[string]string{"core": "core", "catalog-migrate": "core", "workspace-maintenance": "core", "frontend": "frontend", "catalog-db": "catalog", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"}
for service, role := range roles {
entry, exists := effective.Services[service]
if !exists || entry.Build != nil || entry.Image != m.Images[role][platform] || (entry.Platform != "" && entry.Platform != platform) {
r.Add("compose-image-"+service, "error", "release.compose", "Each runtime and maintenance service must use its released immutable image without a source build.")
}
}
for service := range effective.Services {
if _, ok := roles[service]; !ok {
r.Add("compose-service", "error", "release.compose", "Additional services need an explicit release contract before execution.")
}
}
if r.OK {
r.Add("compose-configuration", "passed", "release.compose", "Effective Compose configuration uses the complete released image set.")
}
return r
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package preflight
import "golang.org/x/sys/unix"
func freeBytes(path string) (uint64, error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return 0, err
}
return uint64(stat.Bavail) * uint64(stat.Bsize), nil
}
@@ -0,0 +1,15 @@
//go:build windows
package preflight
import "golang.org/x/sys/windows"
func freeBytes(path string) (uint64, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var available uint64
err = windows.GetDiskFreeSpaceEx(p, &available, nil, nil)
return available, err
}
+216
View File
@@ -0,0 +1,216 @@
package preflight
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
func CheckExternal(ctx context.Context, installation config.Installation) Report {
r := NewReport()
value := func(name string) string { result, _ := installation.EnvironmentValue(name); return result }
err := checkGit(ctx, installation, value)
outcome := "passed"
if err != nil {
outcome = "error"
}
r.Add("workspace-remote", outcome, "workspaceRepository", "Require authenticated read access to the configured Git remote and branch using the prepared trust/credential files.")
for name, provider := range installation.ModelCatalog.Providers {
if provider.Endpoint == nil {
r.Add("provider-"+name, "warning", "modelCatalog.providers", "Built-in provider endpoint resolution belongs to the bundled Pi SDK. The required Pi runtime smoke check verifies model availability and credentials; preflight makes no billable generation requests.")
continue
}
parsed, err := url.Parse(provider.Endpoint.BaseURL)
if err == nil {
err = probeOrigin(ctx, parsed)
}
outcome := "passed"
if err != nil {
outcome = "error"
}
r.Add("provider-"+name, outcome, "modelCatalog.providers."+name+".endpoint", "Require DNS/TCP/TLS reachability of the configured provider origin. Credential/model eligibility still requires the bundled Pi runtime smoke check; no generation request is sent here.")
}
return r
}
func probeOrigin(ctx context.Context, target *url.URL) error {
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
port := target.Port()
if port == "" {
port = "443"
if target.Scheme == "http" {
port = "80"
}
}
address := net.JoinHostPort(target.Hostname(), port)
var connection net.Conn
var err error
if target.Scheme == "https" {
dialer := tls.Dialer{NetDialer: &net.Dialer{Timeout: 5 * time.Second}, Config: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: target.Hostname()}}
connection, err = dialer.DialContext(bound, "tcp", address)
} else {
connection, err = (&net.Dialer{Timeout: 5 * time.Second}).DialContext(bound, "tcp", address)
}
if err == nil {
connection.Close()
}
return err
}
func checkGit(ctx context.Context, installation config.Installation, value func(string) string) error {
remote := installation.WorkspaceRepository.Remote
if installation.WorkspaceRepository.Access == "ssh" && strings.HasPrefix(remote, "git@") {
host, path, found := strings.Cut(strings.TrimPrefix(remote, "git@"), ":")
if !found {
return errors.New("invalid Git remote")
}
return checkSSHGit(ctx, &url.URL{Scheme: "ssh", User: url.User("git"), Host: host, Path: path}, installation.WorkspaceRepository.Branch, value)
}
u, err := url.Parse(remote)
if err != nil {
return errors.New("remote unavailable")
}
if installation.WorkspaceRepository.Access == "ssh" {
return checkSSHGit(ctx, u, installation.WorkspaceRepository.Branch, value)
}
ca, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_CA_FILE"), 64<<10)
if err != nil {
return err
}
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(ca) {
return errors.New("invalid Git trust")
}
credentials, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_CREDENTIALS_FILE"), 64<<10)
if err != nil {
return err
}
var user, password string
for _, line := range strings.Split(string(credentials), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
credential, err := url.Parse(strings.TrimSpace(line))
if err != nil || credential.User == nil {
return errors.New("invalid Git credentials")
}
if credential.Scheme == u.Scheme && credential.Host == u.Host && (credential.Path == "" || credential.Path == u.Path) {
user = credential.User.Username()
password, _ = credential.User.Password()
}
}
u.Path = strings.TrimSuffix(u.Path, "/") + "/info/refs"
u.RawQuery = "service=git-upload-pack"
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(bound, http.MethodGet, u.String(), nil)
if err != nil {
return err
}
if user != "" {
request.SetBasicAuth(user, password)
}
transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, Proxy: http.ProxyFromEnvironment}
defer transport.CloseIdleConnections()
client := http.Client{Transport: transport, Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return errors.New("Git remote refused")
}
data, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if err != nil || len(data) > 1<<20 || !advertisesBranch(data, installation.WorkspaceRepository.Branch) {
return errors.New("Git branch unavailable")
}
return nil
}
func advertisesBranch(data []byte, branch string) bool {
for _, line := range strings.Split(string(data), "\n") {
line = strings.SplitN(line, "\x00", 2)[0]
if strings.HasSuffix(line, " refs/heads/"+branch) {
return true
}
}
return false
}
func checkSSHGit(ctx context.Context, target *url.URL, branch string, value func(string) string) error {
// Git paths and branch names are already validated by the canonical installation loader.
if target.Scheme != "ssh" || target.User == nil || strings.ContainsAny(target.Path, "'\r\n\x00") {
return errors.New("use canonical ssh:// remote")
}
key, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_SSH_KEY_FILE"), 64<<10)
if err != nil {
return err
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
hostKey, err := knownhosts.New(value("THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE"))
if err != nil {
return err
}
port := target.Port()
if port == "" {
port = "22"
}
if _, err := strconv.Atoi(port); err != nil {
return err
}
address := net.JoinHostPort(target.Hostname(), port)
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
connection, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(bound, "tcp", address)
if err != nil {
return err
}
defer connection.Close()
deadline, _ := bound.Deadline()
_ = connection.SetDeadline(deadline)
clientConnection, channels, requests, err := ssh.NewClientConn(connection, address, &ssh.ClientConfig{User: target.User.Username(), Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, HostKeyCallback: hostKey, Timeout: 5 * time.Second})
if err != nil {
return err
}
client := ssh.NewClient(clientConnection, channels, requests)
defer client.Close()
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
stdin, err := session.StdinPipe()
if err != nil {
return err
}
if err = session.Start("git-upload-pack --advertise-refs '" + target.Path + "'"); err != nil {
return err
}
_ = stdin.Close()
data, err := io.ReadAll(io.LimitReader(stdout, (1<<20)+1))
if err != nil || len(data) > 1<<20 || !advertisesBranch(data, branch) {
return errors.New("Git branch unavailable")
}
return nil
}
@@ -0,0 +1,62 @@
package preflight
import (
"context"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
func TestGitProbeAuthenticatesAndRejectsWrongBranchAndCredentials(t *testing.T) {
status := 200
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, password, ok := r.BasicAuth()
if !ok || user != "reader" || password != "PRIVATE_SENTINEL" {
w.WriteHeader(401)
return
}
if r.Method != "GET" || r.URL.Path != "/workspaces.git/info/refs" || r.URL.RawQuery != "service=git-upload-pack" {
t.Error("unexpected Git mutation/request")
w.WriteHeader(400)
return
}
w.WriteHeader(status)
_, _ = w.Write([]byte("0044" + strings.Repeat("a", 40) + " refs/heads/main\n"))
}))
defer server.Close()
root, _ := filepath.EvalSymlinks(t.TempDir())
ca := filepath.Join(root, "ca.pem")
credentials := filepath.Join(root, "credentials")
_ = os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600)
_ = os.WriteFile(credentials, []byte(strings.Replace(server.URL, "https://", "https://reader:PRIVATE_SENTINEL@", 1)), 0o600)
installation := config.Installation{WorkspaceRepository: config.WorkspaceRepository{Remote: server.URL + "/workspaces.git", Branch: "main", Access: "https"}}
value := func(name string) string {
if name == "THT_WORKSPACE_GIT_CA_FILE" {
return ca
}
return credentials
}
if err := checkGit(context.Background(), installation, value); err != nil {
t.Fatal(err)
}
installation.WorkspaceRepository.Branch = "missing"
if checkGit(context.Background(), installation, value) == nil {
t.Fatal("missing branch accepted")
}
installation.WorkspaceRepository.Branch = "main"
status = 503
if checkGit(context.Background(), installation, value) == nil {
t.Fatal("unavailable existing Git accepted")
}
status = 200
_ = os.WriteFile(credentials, []byte(strings.Replace(server.URL, "https://", "https://reader:rotated@", 1)), 0o600)
if checkGit(context.Background(), installation, value) == nil {
t.Fatal("bad credential accepted")
}
}
+287
View File
@@ -0,0 +1,287 @@
package preflight
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/version"
"gopkg.in/yaml.v3"
)
type Plan struct {
SchemaVersion int `json:"schema_version"`
ValidatorProtocol int `json:"validator_protocol"`
Validator version.Info `json:"validator"`
Installation config.Installation `json:"installation"`
Release Manifest `json:"release"`
Platform string `json:"platform"`
WorkspaceDirectory string `json:"workspace_directory"`
WorkspaceRevision string `json:"workspace_revision"`
Inputs []string `json:"inputs"`
AbsentInputs []string `json:"absent_inputs"`
InputSeal string `json:"input_seal"`
Report Report `json:"report"`
}
func invalidPlan() error {
return errors.New("plan inputs changed or protected plan files are unavailable; repeat validation and produce a new plan")
}
// CaptureInputs gives a value-free freshness guard around live probes and document validation.
func CaptureInputs(paths []string, workspace string, absent ...string) (func() bool, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, invalidPlan()
}
probe := Plan{Inputs: paths, AbsentInputs: absent}
before, err := seal(probe, key)
if err != nil {
return nil, err
}
files, err := treeFiles(workspace, true)
if err != nil {
return nil, err
}
return func() bool {
after, err := seal(probe, key)
current, treeErr := treeFiles(workspace, true)
return err == nil && treeErr == nil && hmac.Equal([]byte(before), []byte(after)) && slices.Equal(files, current)
}, nil
}
// A separate owner-only random key prevents public/offline guessing of low-entropy secrets.
// Live prerequisites must still be rechecked immediately before any execution or resumption.
func seal(plan Plan, key []byte) (string, error) {
for _, path := range plan.AbsentInputs {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
return "", invalidPlan()
}
}
mac := hmac.New(sha256.New, key)
plan.InputSeal = ""
data, err := json.Marshal(plan)
if err != nil {
return "", invalidPlan()
}
mac.Write(data)
var total int
for _, path := range plan.Inputs {
contents, err := safeio.ReadCanonicalRegular(path, 32<<20)
if err != nil {
return "", invalidPlan()
}
total += len(contents)
if total > 256<<20 {
return "", invalidPlan()
}
length, _ := json.Marshal([]any{path, len(contents)})
mac.Write(length)
mac.Write(contents)
}
return hex.EncodeToString(mac.Sum(nil)), nil
}
func AbsentOverrides(installation config.Installation) []string {
paths := []string{}
for _, path := range installation.Overrides {
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
paths = append(paths, path)
}
}
return paths
}
func WritePlan(path string, plan *Plan, guards ...func() bool) error {
if !plan.Report.OK || plan.ValidatorProtocol != Protocol || !filepath.IsAbs(path) {
return invalidPlan()
}
if exists, err := safeio.PreflightPrivateDirectory(filepath.Dir(path)); err != nil || !exists {
return invalidPlan()
}
for _, target := range []string{path, path + ".key"} {
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
return invalidPlan()
}
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return invalidPlan()
}
var err error
plan.InputSeal, err = seal(*plan, key)
if err != nil {
return err
}
for _, guard := range guards {
if guard == nil || !guard() {
return invalidPlan()
}
}
data, _ := json.MarshalIndent(plan, "", " ")
if err := safeio.WriteCanonicalNewPrivateFile(path+".key", key, 0o600); err != nil {
return invalidPlan()
}
if err := safeio.WriteCanonicalNewPrivateFile(path, append(data, '\n'), 0o600); err != nil {
_ = safeio.RemoveCanonicalPrivateRegular(path + ".key")
return invalidPlan()
}
return nil
}
func VerifyPlanInputs(path string) error {
data, err := safeio.ReadCanonicalPrivateRegular(path, 4<<20)
if err != nil {
return invalidPlan()
}
key, err := safeio.ReadCanonicalPrivateRegular(path+".key", 32)
if err != nil || len(key) != 32 {
return invalidPlan()
}
var plan Plan
if json.Unmarshal(data, &plan) != nil || plan.SchemaVersion != 1 || plan.ValidatorProtocol != Protocol || !plan.Report.OK || len(plan.Inputs) == 0 || len(plan.Inputs) > 10000 {
return invalidPlan()
}
actual, err := seal(plan, key)
if err != nil || !hmac.Equal([]byte(actual), []byte(plan.InputSeal)) {
return invalidPlan()
}
// Detect added or removed workspace files as well as changes to known file bytes.
if plan.WorkspaceDirectory != "" {
files, err := treeFiles(plan.WorkspaceDirectory, true)
if err != nil {
return invalidPlan()
}
for _, file := range files {
if !slices.Contains(plan.Inputs, file) {
return invalidPlan()
}
}
}
return nil
}
func treeFiles(root string, skipGit bool) ([]string, error) {
paths := []string{}
count := 0
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return invalidPlan()
}
count++
if count > 10000 {
return invalidPlan()
}
if skipGit && entry.Name() == ".git" {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.IsDir() {
return nil
}
if !entry.Type().IsRegular() {
return invalidPlan()
}
paths = append(paths, path)
return nil
})
return paths, err
}
// CollectInputs fingerprints exact prepared contents, with normalized configuration in Plan.
// Referenced credentials are sealed, never copied. Git object stores are excluded.
func CollectInputs(installation config.Installation, workspace, bootstrap, manifest string) ([]string, string, error) {
paths := []string{installation.Path, installation.EnvFile, bootstrap, manifest}
for _, root := range []string{workspace, installation.AuthenticationDirectory()} {
files, err := treeFiles(root, root == workspace)
if err != nil {
return nil, "", err
}
paths = append(paths, files...)
}
secrets, err := installation.SecretFiles()
if err != nil {
return nil, "", invalidPlan()
}
for _, path := range secrets {
paths = append(paths, path)
}
data, err := safeio.ReadCanonicalPrivateRegular(bootstrap, 1<<20)
if err != nil {
return nil, "", invalidPlan()
}
var bindings struct {
Databases []struct {
SecretFiles map[string]string `yaml:"secretFiles"`
EvidenceSecretFiles map[string]string `yaml:"evidenceSecretFiles"`
} `yaml:"databases"`
}
if yaml.Unmarshal(data, &bindings) != nil {
return nil, "", invalidPlan()
}
for _, entry := range bindings.Databases {
for _, values := range []map[string]string{entry.SecretFiles, entry.EvidenceSecretFiles} {
for _, path := range values {
paths = append(paths, path)
}
}
}
m, err := LoadManifest(manifest)
if err != nil {
return nil, "", err
}
for name := range m.Files {
paths = append(paths, filepath.Join(filepath.Dir(manifest), filepath.FromSlash(name)))
}
for _, path := range installation.Overrides {
if _, err := os.Lstat(path); err == nil {
paths = append(paths, path)
}
}
revision := "content-snapshot"
head := filepath.Join(workspace, ".git", "HEAD")
if data, err := safeio.ReadCanonicalRegular(head, 1024); err == nil {
paths = append(paths, head)
value := strings.TrimSpace(string(data))
if strings.HasPrefix(value, "ref: refs/") {
ref := strings.TrimPrefix(value, "ref: ")
if safeRelative(ref) {
path := filepath.Join(workspace, ".git", filepath.FromSlash(ref))
if data, err := safeio.ReadCanonicalRegular(path, 1024); err == nil {
paths = append(paths, path)
value = strings.TrimSpace(string(data))
}
}
}
if len(value) == 40 {
if _, err := hex.DecodeString(value); err == nil {
revision = value
}
}
}
slices.Sort(paths)
paths = slices.Compact(paths)
return paths, revision, nil
}
func AddRuntimeObligations(report *Report) {
for _, check := range []Check{
{"container-network", "deferred-to-runtime", "bindings", "Repeat authenticated DWH and external connectivity checks from the core network."},
{"catalog-initialization", "deferred-to-runtime", "catalog", "Apply migrations and verify Catalog health plus prepared binding import."},
{"pi-operation", "deferred-to-runtime", "release.components.pi", "Verify the bundled Pi version and authenticated provider/model smoke operation inside core; do not install Pi on the host."},
{"local-embedding", "deferred-to-runtime", "modelCatalog.embedding", "Initialize the local embedding model and verify returned vector dimensions."},
{"workspace-preprocessing", "deferred-to-runtime", "workspaces", "Sync the exact verified workspace contents, materialize Evidence, preprocess and verify collections."},
{"workspace-readiness", "deferred-to-runtime", "workspaces", "Complete required administrative and human review gates before claiming final readiness."},
} {
report.Checks = append(report.Checks, check)
}
}
+68
View File
@@ -0,0 +1,68 @@
package preflight
import (
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPlanBindsInputsAndDetectsCredentialRotationWithoutPublicSecretHashes(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
if err := safeio.ProtectPrivateDirectory(root); err != nil {
t.Fatal(err)
}
input := filepath.Join(root, "descriptor.yaml")
secret := filepath.Join(root, "credential")
for path, data := range map[string]string{input: "schemaVersion: 2\n", secret: "PRIVATE_SENTINEL"} {
if err := safeio.WriteCanonicalNewPrivateFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
plan := Plan{SchemaVersion: 1, ValidatorProtocol: Protocol, Inputs: []string{input, secret}, WorkspaceRevision: "content-snapshot", Report: NewReport()}
output := filepath.Join(root, "plan.json")
if err := WritePlan(output, &plan); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(output)
if strings.Contains(string(data), "PRIVATE_SENTINEL") {
t.Fatal("secret in plan")
}
if err := VerifyPlanInputs(output); err != nil {
t.Fatal(err)
}
if err := WritePlan(output, &plan); err == nil {
t.Fatal("existing plan replaced")
}
if err := os.WriteFile(secret, []byte("rotated"), 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyPlanInputs(output); err == nil {
t.Fatal("credential rotation did not invalidate plan")
}
if err := os.WriteFile(secret, []byte("PRIVATE_SENTINEL"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(input, []byte("schemaVersion: 3\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyPlanInputs(output); err == nil {
t.Fatal("document change did not invalidate plan")
}
if err := os.WriteFile(input, []byte("schemaVersion: 2\n"), 0o600); err != nil {
t.Fatal(err)
}
absent := filepath.Join(root, "transport-override.yaml")
plan.AbsentInputs = []string{absent}
second := filepath.Join(root, "second-plan.json")
if err := WritePlan(second, &plan); err != nil {
t.Fatal(err)
}
if err := safeio.WriteCanonicalNewPrivateFile(absent, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
if VerifyPlanInputs(second) == nil {
t.Fatal("newly appearing override did not invalidate plan")
}
}
@@ -0,0 +1,118 @@
package preflight
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
type fakeDocker struct {
calls [][]string
fail string
effective string
}
func (f *fakeDocker) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
f.calls = append(f.calls, args)
if strings.Contains(strings.Join(args, " "), f.fail) && f.fail != "" {
return compose.Result{Stderr: "PRIVATE_SENTINEL"}, errors.New("PRIVATE_SENTINEL")
}
switch args[0] {
case "info":
return compose.Result{Stdout: `{"OSType":"linux","Architecture":"x86_64","NCPU":4,"MemTotal":17179869184}`}, nil
case "compose":
if args[len(args)-1] == "json" {
if f.effective != "" {
return compose.Result{Stdout: f.effective}, nil
}
return compose.Result{Stdout: `{"services":{"core":{"image":"example/core:latest"}}}`}, nil
}
return compose.Result{Stdout: "2.39.0"}, nil
case "manifest":
return compose.Result{Stdout: `{"Descriptor":{"digest":"sha256:` + strings.Repeat("a", 64) + `","platform":{"os":"linux","architecture":"amd64"}}}`}, nil
}
return compose.Result{}, errors.New("unexpected command")
}
func TestComposeRejectsIncompleteMutableServiceSet(t *testing.T) {
r := CheckCompose(context.Background(), &fakeDocker{}, config.Installation{ProjectDirectory: "/private", EnvFile: "/private/operator.env"}, Manifest{Compose: []string{"compose.yaml"}}, "/release/manifest.json", "linux/amd64")
if r.OK {
t.Fatal("unreleased service set accepted")
}
}
func TestComposeRejectsPlatformOverrideAgainstSelectedImage(t *testing.T) {
m := Manifest{Images: map[string]map[string]string{}, Compose: []string{"compose.yaml"}}
services := map[string]map[string]string{}
for service, role := range map[string]string{"core": "core", "frontend": "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"} {
m.Images[role] = map[string]string{"linux/amd64": "docker.io/example/" + role + "@sha256:" + strings.Repeat("a", 64)}
services[service] = map[string]string{"image": m.Images[role]["linux/amd64"]}
}
services["core"]["platform"] = "linux/arm64"
data, _ := json.Marshal(map[string]any{"services": services})
r := CheckCompose(context.Background(), &fakeDocker{effective: string(data)}, config.Installation{}, m, "/release/manifest.json", "linux/amd64")
if r.OK {
t.Fatal("incompatible Compose platform accepted")
}
}
func TestHostChecksAreReadOnlyAndRejectUnavailableDocker(t *testing.T) {
f := &fakeDocker{}
host := Host{OS: "linux", Arch: "amd64", Kernel: "6.6-microsoft-standard-WSL2", Distribution: "ubuntu", FreeBytes: 30 << 30}
r := CheckHost(context.Background(), f, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30})
if !r.OK {
t.Fatalf("host rejected: %+v", r)
}
for _, args := range f.calls {
if args[0] != "info" && !(args[0] == "compose" && args[1] == "version") {
t.Fatalf("mutating call: %v", args)
}
}
f.fail = "info"
r = CheckHost(context.Background(), f, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30})
if r.OK || strings.Contains(r.JSON(), "PRIVATE_SENTINEL") {
t.Fatalf("unsafe success/report: %s", r.JSON())
}
host.Kernel = "4.4-microsoft"
if CheckHost(context.Background(), &fakeDocker{}, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30}).OK {
t.Fatal("WSL1 accepted")
}
host.OS = "windows"
if CheckHost(context.Background(), &fakeDocker{}, host, Requirements{}).OK {
t.Fatal("native Windows accepted instead of WSL2")
}
}
func TestReleaseChecksEveryImmutableImageAndPlatform(t *testing.T) {
m := Manifest{SchemaVersion: 1, Version: "1.0.0", Revision: strings.Repeat("b", 40), ValidatorProtocol: 1, Requirements: Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30}, Components: []string{"pi", "catalog-migrations", "workspace-maintenance"}, Images: map[string]map[string]string{}}
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
m.Images[service] = map[string]string{"linux/amd64": "docker.io/example/" + service + "@sha256:" + strings.Repeat("a", 64)}
}
m.Files = map[string]string{"deploy/compose.yaml": strings.Repeat("c", 64)}
m.Compose = []string{"deploy/compose.yaml"}
if err := m.Validate(); err != nil {
t.Fatal(err)
}
f := &fakeDocker{}
r := CheckImages(context.Background(), f, m, "linux/amd64")
if !r.OK || len(f.calls) != 5 {
t.Fatalf("images not checked: %s calls=%d", r.JSON(), len(f.calls))
}
if CheckImages(context.Background(), f, m, "linux/arm64").OK {
t.Fatal("unsupported release architecture accepted")
}
f.fail = "frontend"
if CheckImages(context.Background(), f, m, "linux/amd64").OK {
t.Fatal("missing image accepted")
}
m.Images["core"]["linux/amd64"] = "example/core:latest"
if m.Validate() == nil {
t.Fatal("mutable tag accepted")
}
}
+131
View File
@@ -0,0 +1,131 @@
package preflight
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
)
// Manifest is the publication/consumer contract. Each platform maps to a single-image digest,
// not a mutable tag or multi-platform index. Maintenance roles use Images["core"].
type Manifest struct {
SchemaVersion int `json:"schema_version"`
Version string `json:"version"`
Revision string `json:"revision"`
ValidatorProtocol int `json:"validator_protocol"`
Requirements Requirements `json:"requirements"`
Components []string `json:"components"`
Images map[string]map[string]string `json:"images"`
Files map[string]string `json:"files"`
Compose []string `json:"compose"`
}
var hex256 = regexp.MustCompile(`^[a-f0-9]{64}$`)
var imageReference = regexp.MustCompile(`^docker\.io/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$`)
func invalidRelease() error {
return errors.New("release manifest or packaged files are incomplete, incompatible or invalid")
}
func (m Manifest) Validate() error {
if m.SchemaVersion != 1 || m.ValidatorProtocol != Protocol || !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$`).MatchString(m.Version) || !regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(m.Revision) {
return invalidRelease()
}
if m.Requirements.CPUs < 2 || m.Requirements.MemoryBytes < 4<<30 || m.Requirements.DiskBytes < 10<<30 {
return invalidRelease()
}
for _, component := range []string{"pi", "catalog-migrations", "workspace-maintenance"} {
if !slices.Contains(m.Components, component) {
return invalidRelease()
}
}
if len(m.Images) != 5 || len(m.Files) == 0 || len(m.Files) > 256 || len(m.Compose) == 0 || len(m.Compose) > 8 {
return invalidRelease()
}
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
if len(m.Images[service]) == 0 {
return invalidRelease()
}
for platform, ref := range m.Images[service] {
if (platform != "linux/amd64" && platform != "linux/arm64") || !imageReference.MatchString(ref) {
return invalidRelease()
}
}
}
for name, digest := range m.Files {
if !safeRelative(name) || !hex256.MatchString(digest) {
return invalidRelease()
}
}
for _, name := range m.Compose {
if _, ok := m.Files[name]; !ok {
return invalidRelease()
}
}
return nil
}
func safeRelative(name string) bool {
return name != "" && !strings.Contains(name, "\\") && !strings.Contains(name, ":") && !strings.HasPrefix(name, "/") && filepath.ToSlash(filepath.Clean(name)) == name && name != ".." && !strings.HasPrefix(name, "../") && name != "."
}
func LoadManifest(path string) (Manifest, error) {
var m Manifest
contents, err := safeio.ReadCanonicalRegular(path, 1<<20)
if err != nil {
return m, invalidRelease()
}
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
if decoder.Decode(&m) != nil || decoder.Decode(new(any)) != io.EOF {
return Manifest{}, invalidRelease()
}
if err = m.Validate(); err != nil {
return Manifest{}, err
}
for name, digest := range m.Files {
contents, err := safeio.ReadCanonicalRegular(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)), 32<<20)
if err != nil {
return Manifest{}, invalidRelease()
}
sum := sha256.Sum256(contents)
if hex.EncodeToString(sum[:]) != digest {
return Manifest{}, invalidRelease()
}
}
return m, nil
}
func CheckImages(ctx context.Context, runner Runner, m Manifest, platform string) Report {
r := NewReport()
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
ref := m.Images[service][platform]
if ref == "" {
r.Add("image-"+service, "error", "release.images."+service, "Publish the selected Linux architecture before producing an executable plan.")
continue
}
result, err := docker(ctx, runner, "manifest", "inspect", "--verbose", ref)
var manifest struct {
Descriptor struct {
Digest string `json:"digest"`
Platform struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
} `json:"platform"`
} `json:"Descriptor"`
}
good := err == nil && json.Unmarshal([]byte(result.Stdout), &manifest) == nil && manifest.Descriptor.Platform.OS+"/"+manifest.Descriptor.Platform.Architecture == platform && strings.HasSuffix(ref, "@"+manifest.Descriptor.Digest) && manifest.Descriptor.Digest != ""
outcome := "passed"
if !good {
outcome = "error"
}
r.Add("image-"+service, outcome, "release.images."+service, "Require the pinned digest to be publicly readable in Docker Hub for the selected Linux architecture.")
}
return r
}
@@ -175,6 +175,11 @@ func Validate(path string) (config.Installation, Report) {
report.Add("operator.env", "$", "secret_references_invalid", "Use canonical absolute paths for all _FILE and _SOURCE references.") report.Add("operator.env", "$", "secret_references_invalid", "Use canonical absolute paths for all _FILE and _SOURCE references.")
} }
for _, file := range files { for _, file := range files {
// The descriptor is a _SOURCE reference too, but its YAML values were already checked.
// Template instructions in comments are not unresolved credential placeholders.
if file == path {
continue
}
allowEmpty := file == value("THT_WORKSPACE_GIT_CREDENTIALS_FILE") allowEmpty := file == value("THT_WORKSPACE_GIT_CREDENTIALS_FILE")
CheckSecret(file, "operator.env", "protected-file-reference", allowEmpty, &report) CheckSecret(file, "operator.env", "protected-file-reference", allowEmpty, &report)
} }