feat(cli): validate prerequisites and seal installation plans
This commit is contained in:
+4
-1
@@ -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,
|
||||||
|
|||||||
Generated
+403
@@ -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",
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user