Compare commits

Author SHA1 Message Date
Codex ec0421e9fd feat: publish verified installation images and native release bundles 2026-09-28 17:46:09 +02:00
Codex 55f3569e55 feat(cli): validate prerequisites and seal installation plans 2026-09-28 17:03:25 +02:00
Codex b9c3369e7b feat(cli): prepare and validate application documents offline 2026-09-28 16:33:07 +02:00
Codex 64e6b9664a feat(cli): prepare and validate workspace documents offline
Reuse the runtime catalog and workspace parsers in a standalone helper paired with tht. Add document templates, safe diagnostics, local Evidence checks, native bundle builds, shared CLI fixtures and IT/EN preparation guides. Record the approved document-first specification and ticket breakdown. Refs #43.
2026-09-28 15:35:25 +02:00
Codex 67ee52624c wip: guided standalone installation and workspace checks 2026-09-26 16:41:15 +02:00
74 changed files with 7196 additions and 610 deletions
+11
View File
@@ -638,6 +638,17 @@ procedura non implica che DWH o provider LLM siano locali o disponibili offline.
installazione manuale: verifica dell'host, generazione della configurazione, predisposizione
delle credenziali protette e avvio dei servizi. Non è un installer dell'applicazione.
**Installation preparation** — La predisposizione dei documenti che descrivono i workspace
e i parametri dell'installazione, prima di applicarli. Permette all'operatore di raccogliere
e correggere le informazioni senza avviare l'applicazione.
**Installation validation** — La verifica ripetibile della completezza e coerenza dei
documenti e delle precondizioni di un'installazione. Distingue ciò che è stato verificato
da ciò che richiede un'applicazione già avviata.
**Installation execution** — L'applicazione dei documenti verificati per predisporre e
avviare ThothII. Non raccoglie nuovi parametri dall'operatore durante l'esecuzione.
**Platform acceptance** — La verifica che una Manual standalone installation possa essere
predisposta e avviata su una specifica combinazione di sistema operativo, architettura e runtime,
distinta dalla verifica funzionale del collegamento a DWH e provider LLM.
+15 -1
View File
@@ -1,10 +1,24 @@
# Project state
Updated: 2026-09-15. This is a current snapshot, not a release diary. Stable commands
Updated: 2026-09-28. This is a current snapshot, not a release diary. Stable commands
and invariants are in [AGENTS.md](AGENTS.md); prior snapshots remain in Git.
## Current contracts
- Document-first installation ticket #43 provides offline `tht workspace prepare`
and `tht workspace validate` through a native two-executable bundle. Build and
test instructions are in [the host CLI guide](tools/tht/README.md). Local validation
reuses runtime workspace/catalog parsers and explicitly defers runtime Evidence,
database binding and readiness checks. Ticket #44 adds `tht installation prepare`,
explicit `installation credentials`, and `installation validate --workspaces PATH`
for protected application documents, canonical model settings and schema-v1
database bootstrap inputs. These commands do not start services or import Catalog
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.
- React supports full/embedded rendering independently of local/OIDC/upstream auth,
with EN/IT UI and immutable session interaction language. See
[application shell](docs/architecture/application-shell.md) and
+5 -4
View File
@@ -34,11 +34,12 @@ for the executed consolidation and the inventory of historical sources retained
## Docker Compose and installation
For a fresh installation, follow the complete manual procedure in
For a fresh installation, follow the guided terminal procedure in
[Italian](docs/install/standalone-manual-it.md) or
[English](docs/install/standalone-manual-en.md). Configure protected files first;
then run the documented build, explicit migrations and startup commands with the
same installation descriptor and Compose project. There is no installer or launcher.
[English](docs/install/standalone-manual-en.md). The single
`tht setup --complete` command validates protected files, builds the images, runs
Catalog migration, starts the stack and imports the configured workspace repository.
There is no graphical installer or native launcher.
The mandatory stack includes frontend, core, PostgreSQL catalog, Qdrant, Ollama
and the embedding initializer. DWH and LLM endpoints remain external dependencies.
+609
View File
@@ -6,11 +6,13 @@
"": {
"name": "thothii-backend",
"dependencies": {
"@aws-sdk/client-s3": "3.1141.0",
"@fastify/cookie": "11.1.2",
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "11.2.0",
"@types/pg": "^8.20.3",
"fastify": "^5.0.0",
"ipaddr.js": "2.4.0",
"kysely": "^0.29.5",
"libphonenumber-js": "1.13.12",
"openid-client": "6.8.5",
@@ -23,11 +25,320 @@
"@testcontainers/postgresql": "^12.1.0",
"@types/node": "24.13.3",
"@types/validator": "13.15.10",
"bun": "1.4.2",
"tsx": "^4.19.0",
"typescript": "^5.6.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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
@@ -802,6 +1113,174 @@
"node": ">=8"
}
},
"node_modules/@oven/bun-darwin-aarch64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.4.2.tgz",
"integrity": "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oven/bun-darwin-x64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.4.2.tgz",
"integrity": "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@oven/bun-freebsd-aarch64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.4.2.tgz",
"integrity": "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@oven/bun-freebsd-x64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.4.2.tgz",
"integrity": "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@oven/bun-linux-aarch64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.4.2.tgz",
"integrity": "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oven/bun-linux-aarch64-android": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.4.2.tgz",
"integrity": "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oven/bun-linux-aarch64-musl": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.4.2.tgz",
"integrity": "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oven/bun-linux-x64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.4.2.tgz",
"integrity": "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oven/bun-linux-x64-android": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.4.2.tgz",
"integrity": "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@oven/bun-linux-x64-musl": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.4.2.tgz",
"integrity": "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@oven/bun-windows-aarch64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.4.2.tgz",
"integrity": "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@oven/bun-windows-x64": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.4.2.tgz",
"integrity": "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
@@ -1235,6 +1714,87 @@
"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": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-12.1.0.tgz",
@@ -1821,6 +2381,12 @@
"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": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
@@ -1876,6 +2442,43 @@
"node": ">=10.0.0"
}
},
"node_modules/bun": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/bun/-/bun-1.4.2.tgz",
"integrity": "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA==",
"cpu": [
"arm64",
"x64"
],
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"os": [
"darwin",
"linux",
"android",
"freebsd",
"win32"
],
"bin": {
"bun": "bin/bun.exe",
"bunx": "bin/bunx.exe"
},
"optionalDependencies": {
"@oven/bun-darwin-aarch64": "1.4.2",
"@oven/bun-darwin-x64": "1.4.2",
"@oven/bun-freebsd-aarch64": "1.4.2",
"@oven/bun-freebsd-x64": "1.4.2",
"@oven/bun-linux-aarch64": "1.4.2",
"@oven/bun-linux-aarch64-android": "1.4.2",
"@oven/bun-linux-aarch64-musl": "1.4.2",
"@oven/bun-linux-x64": "1.4.2",
"@oven/bun-linux-x64-android": "1.4.2",
"@oven/bun-linux-x64-musl": "1.4.2",
"@oven/bun-windows-aarch64": "1.4.2",
"@oven/bun-windows-x64": "1.4.2"
}
},
"node_modules/byline": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
@@ -4114,6 +4717,12 @@
"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": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
+4
View File
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"build:workspace-tools": "node scripts/build-workspace-tools.mjs",
"dev": "tsx watch src/server.ts",
"prebuild": "node scripts/clean-dist.mjs",
"build": "tsc -p tsconfig.json",
@@ -14,11 +15,13 @@
"test:schema-v3-verifier": "npm run test:schema-v4-verifier"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1141.0",
"@fastify/cookie": "11.1.2",
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "11.2.0",
"@types/pg": "^8.20.3",
"fastify": "^5.0.0",
"ipaddr.js": "2.4.0",
"kysely": "^0.29.5",
"libphonenumber-js": "1.13.12",
"openid-client": "6.8.5",
@@ -31,6 +34,7 @@
"@testcontainers/postgresql": "^12.1.0",
"@types/node": "24.13.3",
"@types/validator": "13.15.10",
"bun": "1.4.2",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
+49
View File
@@ -0,0 +1,49 @@
// Maintainer-only build. The resulting two-binary bundle needs no extra host runtime.
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repository = process.env.THT_BUILD_SOURCE_ROOT ? resolve(process.env.THT_BUILD_SOURCE_ROOT) : resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const backend = join(repository, "backend");
const native = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch === "x64" ? "amd64" : process.arch}`;
const targets = {
"windows-amd64": ["windows", "amd64", "bun-windows-x64"],
"darwin-amd64": ["darwin", "amd64", "bun-darwin-x64"],
"darwin-arm64": ["darwin", "arm64", "bun-darwin-arm64"],
"linux-amd64": ["linux", "amd64", "bun-linux-x64"],
"linux-arm64": ["linux", "arm64", "bun-linux-arm64"],
};
const requested = process.argv.slice(2);
const selected = requested.length === 1 && requested[0] === "--all" ? Object.keys(targets) : requested.length ? requested : [native];
if (selected.some((target) => !targets[target])) {
console.error(`Usage: npm run build:workspace-tools -- [${Object.keys(targets).join("|")}|--all]`);
process.exit(2);
}
function run(command, args, cwd = backend, env = process.env) {
const result = spawnSync(command, args, { cwd, env, stdio: "inherit" });
if (result.error || result.status !== 0) throw new Error(`Build failed: ${command}`);
}
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository, encoding: "utf8" });
if (revision.status !== 0) throw new Error("Cannot read build revision");
const commit = revision.stdout.trim();
const buildTime = process.env.THT_BUILD_TIME ?? new Date().toISOString();
const releaseVersion = process.env.THT_BUILD_VERSION ?? "0.0.0-dev";
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(releaseVersion) || !Number.isFinite(Date.parse(buildTime))) throw new Error("Invalid build identity");
const module = "github.com/aritmolab/thothii/tools/tht/internal/version";
const bunPackage = JSON.parse(readFileSync(join(backend, "node_modules", "bun", "package.json"), "utf8"));
const bun = join(backend, "node_modules", "bun", bunPackage.bin.bun);
for (const target of selected) {
const [os, arch, bunTarget] = targets[target];
const output = join(repository, "dist", "workspace-tools", target);
mkdirSync(output, { recursive: true });
const extension = os === "windows" ? ".exe" : "";
const names = [`tht${extension}`, `tht-workspace-documents${extension}`];
run(bun, ["build", "src/workspace-documents-cli.ts", "--compile", `--target=${bunTarget}`, "--outfile", join(output, names[1])]);
run("go", ["build", "-trimpath", "-ldflags", `-s -w -X ${module}.semanticVersion=${releaseVersion} -X ${module}.commit=${commit} -X ${module}.buildTime=${buildTime}`, "-o", join(output, names[0]), "./cmd/tht"], join(repository, "tools", "tht"), { ...process.env, CGO_ENABLED: "0", GOOS: os, GOARCH: arch });
const hashes = names.map((name) => `${createHash("sha256").update(readFileSync(join(output, name))).digest("hex")} ${name}\n`).join("");
writeFileSync(join(output, "SHA256SUMS"), hashes);
writeFileSync(join(output, "build.json"), JSON.stringify({ version: releaseVersion, commit, buildTime, target, bun: JSON.parse(readFileSync(join(backend, "package.json"), "utf8")).devDependencies.bun }, null, 2) + "\n");
console.log(output);
}
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env node
// Maintainer-only producer. Consumers download the resulting native bundle.
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync, realpathSync, renameSync, rmSync, statSync, copyFileSync, chmodSync, openSync, closeSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { parse } from "yaml";
import { prepareBundle, sha256 } from "./release-bundle.mjs";
import { dockerCredentials, dockerHub } from "./release-registry.mjs";
import { giteaHosting } from "./release-hosting.mjs";
import { publishVerifiedRelease } from "./release-publication.mjs";
const repositoryRoot = resolve(import.meta.dirname, "../..");
export function optionsFromArgs(args) {
const values = {};
for (let i = 0; i < args.length; i += 2) {
if (!['--revision', '--version', '--namespace', '--platforms', '--output', '--repository'].includes(args[i]) || !args[i + 1] || values[args[i]]) throw new Error("Usage: --revision REF --version VERSION --namespace DOCKER_HUB_NAMESPACE --platforms linux/amd64 --output NEW_OR_MATCHING_DIRECTORY [--repository HTTPS_GITEA_REPO]");
values[args[i]] = args[i + 1];
}
if (!values['--revision'] || values['--revision'].startsWith('-') || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(values['--version'] ?? '') || !/^[a-z0-9][a-z0-9_-]{1,38}$/.test(values['--namespace'] ?? '') || !values['--output']) throw new Error("Supply an explicit source revision, semantic release version, Docker Hub namespace and output directory.");
const platforms = (values['--platforms'] ?? '').split(',');
if (!platforms.length || new Set(platforms).size !== platforms.length || platforms.some((p) => !['linux/amd64', 'linux/arm64'].includes(p))) throw new Error("Select explicit Linux image platforms; begin with linux/amd64 for Windows/WSL2 and Omarchy.");
const repository = values['--repository'] ?? 'https://git.tylconsulting.it/mptyl/ThothII';
const url = new URL(repository);
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname)) throw new Error("Use a credential-free HTTPS Gitea owner/repository URL.");
return { revision: values['--revision'], version: values['--version'], namespace: values['--namespace'], platforms: platforms.sort(), output: resolve(values['--output']), repository };
}
export function commandRunner(logs) {
let sequence = 0;
return async (command, args, { cwd = repositoryRoot, input = '', env = process.env, quiet = false, timeout = 120_000 } = {}) => {
const log = join(logs, `${++sequence}-${basename(command)}.log`);
return new Promise((accept, reject) => {
if (process.platform === 'win32') { reject(new Error('Run the release producer on Linux, WSL2 or macOS.')); return; }
const child = spawn(command, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
const stdout = [], stderr = []; let size = 0, overflow = false, settled = false, reapTimer;
const terminate = () => {
if (overflow) return;
overflow = true;
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
// An escaped descendant must never retain our pipes indefinitely.
reapTimer = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(-1); }, 500);
};
const timer = setTimeout(terminate, timeout);
const collect = (chunks) => (data) => { size += data.length; if (size > 64 * 2 ** 20) terminate(); else chunks.push(data); };
child.stdout.on('data', collect(stdout)); child.stderr.on('data', collect(stderr));
child.stdin.on('error', () => {}); child.stdin.end(input);
child.on('error', () => { settled = true; clearTimeout(timer); clearTimeout(reapTimer); reject(new Error(`Required maintainer command ${basename(command)} is unavailable.`)); });
function finish(code) {
if (settled) return;
settled = true;
clearTimeout(timer); clearTimeout(reapTimer);
if (!quiet) writeFileSync(log, Buffer.concat([...stdout, ...stderr]), { mode: 0o600 });
if (code !== 0 || overflow) reject(new Error(`${basename(command)} failed${quiet ? '.' : `; inspect private log ${log}`}`));
else accept(Buffer.concat(stdout).toString());
}
child.on('close', finish);
});
};
}
function acquireOutput(output) {
if (!existsSync(output)) mkdirSync(output, { mode: 0o700 });
if (realpathSync(output) !== output || !statSync(output).isDirectory() || (statSync(output).mode & 0o077)) throw new Error("Use a canonical owner-only output directory.");
if (!existsSync(join(output, 'publication-state.json')) && readdirSync(output).length) throw new Error("Choose an empty output directory or the matching previous publication directory.");
const lock = join(output, '.publisher.lock');
if (existsSync(lock)) {
const pid = Number(readFileSync(lock, 'utf8'));
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Inspect the incomplete publisher lock before retrying.");
let alive = true;
try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
if (alive) throw new Error("Another publisher owns this output directory.");
rmSync(lock);
}
const fd = openSync(lock, 'wx', 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd);
return () => rmSync(lock, { force: true });
}
export async function publishInstallation(options) {
const unlock = acquireOutput(options.output);
const logs = join(options.output, 'logs'); mkdirSync(logs, { recursive: true, mode: 0o700 });
const run = commandRunner(logs);
let worktree, temporary;
try {
const revision = (await run('git', ['rev-parse', '--verify', `${options.revision}^{commit}`], { quiet: true })).trim();
if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error("Source revision is not a commit.");
const identity = { revision, version: options.version, namespace: options.namespace, platforms: options.platforms, repository: options.repository };
const statePath = join(options.output, 'publication-state.json');
let state = { identity };
if (existsSync(statePath)) {
state = JSON.parse(readFileSync(statePath, 'utf8'));
if (JSON.stringify(state.identity) !== JSON.stringify(identity)) throw new Error("Output directory belongs to a different release; choose a new directory.");
}
const save = () => { const temp = statePath + '.tmp'; writeFileSync(temp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); renameSync(temp, statePath); };
save();
console.log(`Release ${identity.version}: ${identity.namespace}, ${identity.platforms.join(',')}, source ${revision}`);
await run('docker', ['info', '--format', '{{.OSType}}']);
await run('docker', ['buildx', 'version']);
const registry = dockerHub(await dockerCredentials(run));
const hosting = await giteaHosting({ run, repository: options.repository, identity, body: `Installer prerelease for ${identity.platforms.join(', ')}.\n\nImages: docker.io/${identity.namespace}/thothii-core:${identity.version} and docker.io/${identity.namespace}/thothii-frontend:${identity.version}.\n\nDownload the native operator bundle and SHA256SUMS.txt below. This release supplies images, document validation and preflight; complete non-interactive setup and Windows/Omarchy/macOS acceptance are subsequent tickets. No example databases, credentials or user workspace data are included.` });
async function prepare() {
if (state.assets) {
const names = [...identity.platforms.map((platform) => `thothii-${identity.version}-${platform.replace('/', '-')}.tar.gz`), 'SHA256SUMS.txt'];
if (state.assets.length !== names.length) throw new Error("Cached artifact set is incomplete.");
for (const asset of state.assets) if (!names.includes(asset.name) || asset.path !== join(options.output, asset.name) || !existsSync(asset.path) || sha256(readFileSync(asset.path)) !== asset.sha256) throw new Error("Previously built release artifact changed; do not overwrite an immutable version.");
for (const [platform, images] of Object.entries(state.images)) for (const reference of Object.values(images)) {
const [repository, digest] = reference.replace(/^docker.io\//, '').split('@');
await registry.inspect(repository, digest, platform, { anonymous: true });
}
return state.assets;
}
temporary = realpathSync(mkdtempSync(join(tmpdir(), 'thothii-release-')));
worktree = join(temporary, 'source');
await run('git', ['worktree', 'add', '--detach', worktree, revision]);
const sourceCompose = parse(readFileSync(join(worktree, 'compose.yaml'), 'utf8'));
for (const role of ['core', 'frontend']) {
console.log(`Preparing public repository ${identity.namespace}/thothii-${role}`);
await registry.ensurePublic(identity.namespace, `thothii-${role}`);
let existing = null;
for (const platform of identity.platforms) {
const image = await registry.inspect(`${identity.namespace}/thothii-${role}`, identity.version, platform, { allowMissing: true });
if (image && (image.labels['org.opencontainers.image.revision'] !== revision || image.labels['org.opencontainers.image.version'] !== identity.version)) throw new Error("Image tag already belongs to another immutable build; select a new release version.");
existing = existing || image;
}
if (!existing) {
console.log(`Building and publishing ${role} (${identity.platforms.join(', ')})`);
await run('docker', ['buildx', 'build', '--platform', identity.platforms.join(','), '--file', `docker/${role}.Dockerfile`, '--tag', `docker.io/${identity.namespace}/thothii-${role}:${identity.version}`, '--build-arg', `IMAGE_VERSION=${identity.version}`, '--label', `org.opencontainers.image.revision=${revision}`, '--label', `org.opencontainers.image.source=${identity.repository}`, '--provenance=false', '--sbom=false', '--push', '.'], { cwd: worktree, timeout: 45 * 60_000 });
}
}
state.images = {};
for (const platform of identity.platforms) {
const images = {};
for (const role of ['core', 'frontend']) images[role] = (await registry.inspect(`${identity.namespace}/thothii-${role}`, identity.version, platform, { anonymous: true })).reference;
for (const [role, service] of [['catalog', 'catalog-db'], ['qdrant', 'qdrant'], ['embedding', 'embedding']]) {
const [named, digest] = sourceCompose.services[service].image.split('@');
let repository = named.replace(/:[^/:]+$/, '').replace(/^docker.io\//, '');
if (!repository.includes('/')) repository = 'library/' + repository;
images[role] = (await registry.inspect(repository, digest, platform, { anonymous: true })).reference;
}
state.images[platform] = images;
}
save();
console.log('Building native operator bundles from the selected source');
await run('npm', ['ci'], { cwd: join(worktree, 'backend'), timeout: 10 * 60_000 });
const sourceTime = (await run('git', ['show', '-s', '--format=%cI', revision], { quiet: true })).trim();
const targets = identity.platforms.map((platform) => platform.replace('/', '-'));
await run('node', [join(repositoryRoot, 'backend/scripts/build-workspace-tools.mjs'), ...targets], { cwd: join(worktree, 'backend'), env: { ...process.env, THT_BUILD_SOURCE_ROOT: worktree, THT_BUILD_VERSION: identity.version, THT_BUILD_TIME: sourceTime }, timeout: 10 * 60_000 });
const assets = [];
for (const platform of identity.platforms) {
const target = platform.replace('/', '-');
const name = `thothii-${identity.version}-${target}`;
const bundle = join(options.output, name);
if (existsSync(bundle)) rmSync(bundle, { recursive: true }); // owned staging, never an installed runtime
mkdirSync(bundle);
prepareBundle({ source: worktree, destination: bundle, platform, version: identity.version, revision, images: state.images[platform] });
mkdirSync(join(bundle, 'bin'));
for (const executable of ['tht', 'tht-workspace-documents']) {
copyFileSync(join(worktree, 'dist/workspace-tools', target, executable), join(bundle, 'bin', executable));
chmodSync(join(bundle, 'bin', executable), 0o755);
}
// Resolve source-independent resource/config shape without any operator credentials.
await run('docker', ['compose', '-f', join(bundle, 'compose.yaml'), '-f', join(bundle, 'deploy/compose.local.yaml'), 'config', '--no-interpolate', '--no-env-resolution', '--format', 'json']);
const archive = join(options.output, name + '.tar.gz');
await run('tar', ['-czf', archive, '-C', options.output, name]);
assets.push({ name: basename(archive), path: archive, bytes: statSync(archive).size, sha256: sha256(readFileSync(archive)) });
}
const sums = join(options.output, 'SHA256SUMS.txt');
writeFileSync(sums, assets.map((asset) => `${asset.sha256} ${asset.name}\n`).join(''));
assets.push({ name: 'SHA256SUMS.txt', path: sums, bytes: statSync(sums).size, sha256: sha256(readFileSync(sums)) });
// An empty Docker configuration proves the consumer can pull without publisher credentials.
const publicConfig = join(temporary, 'public-docker'); mkdirSync(publicConfig);
for (const [platform, images] of Object.entries(state.images)) {
for (const reference of Object.values(images)) {
console.log(`Verifying anonymous pull ${reference.split('@')[0]} (${platform})`);
await run('docker', ['--config', publicConfig, 'pull', '--platform', platform, reference], { timeout: 20 * 60_000 });
}
console.log(`Smoke checking published images (${platform})`);
await run('docker', ['run', '--rm', '--platform', platform, '--network', 'none', '--entrypoint', '/bin/sh', images.core, '-ec', 'test "$(pi --version)" = "$PI_VERSION"; tht --help >/dev/null; test -f /app/backend/dist/catalog/migrate.js; test -x /app/docker/workspace-maintenance-entrypoint.sh; test -f /app/docker/catalog-migrate.sh; test ! -e /run/secrets/thothii.secrets'], { timeout: 5 * 60_000 });
await run('docker', ['run', '--rm', '--platform', platform, '--network', 'none', '--entrypoint', '/usr/local/bin/frontend-config-smoke', images.frontend], { timeout: 60_000 });
}
state.assets = assets; save();
return assets;
}
const published = await publishVerifiedRelease({ hosting, prepare });
state.releaseURL = published.html_url; state.complete = true; save();
console.log(`Published and verified: ${published.html_url}`);
return published;
} finally {
if (worktree && existsSync(worktree)) await run('git', ['worktree', 'remove', '--force', worktree]).catch(() => {});
if (temporary && !existsSync(worktree ?? '')) rmSync(temporary, { recursive: true, force: true });
unlock();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try { await publishInstallation(optionsFromArgs(process.argv.slice(2))); }
catch (error) { console.error(error instanceof SyntaxError ? 'Invalid release metadata; no secret values are printed.' : error.message); process.exitCode = 1; }
}
+48
View File
@@ -0,0 +1,48 @@
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync, lstatSync } from "node:fs";
import { dirname, join } from "node:path";
import { parse, stringify } from "yaml";
export const serviceRoles = Object.freeze({ core: "core", frontend: "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", qdrant: "qdrant", embedding: "embedding", "embedding-model-init": "embedding" });
export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
/** Only distribution assets enter the bundle: never a checkout, environment file or workspace. */
export function prepareBundle({ source, destination, platform, version, revision, images }) {
const compose = parse(readFileSync(join(source, "compose.yaml"), "utf8"));
if (Object.keys(compose.services).sort().join() !== Object.keys(serviceRoles).sort().join()) throw new Error("Release service contract changed; review packaging before publishing.");
for (const [name, role] of Object.entries(serviceRoles)) {
if (!/^docker\.io\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/.test(images[role] ?? "")) throw new Error("Release requires immutable Docker Hub image references.");
delete compose.services[name].build;
delete compose.services[name].pull_policy;
compose.services[name].image = images[role];
compose.services[name].platform = platform;
}
const files = {};
const write = (name, data) => {
mkdirSync(dirname(join(destination, name)), { recursive: true });
writeFileSync(join(destination, name), data);
files[name] = sha256(data);
};
write("compose.yaml", stringify(compose));
for (const name of ["deploy/compose.local.yaml", "deploy/compose.git-https.yaml", "deploy/compose.git-ssh.yaml", "docker/catalog-db-init.sql", "docker/embedding-model-init.sh"]) {
const path = join(source, name);
if (!lstatSync(path).isFile()) throw new Error("Release asset must be a regular tracked file.");
write(name, readFileSync(path));
}
// Any future source bind mount must be deliberately added to the asset allowlist.
for (const service of Object.values(compose.services)) {
for (const volume of service.volumes ?? []) {
const sourcePath = typeof volume === "string" ? volume.split(":")[0] : volume.type === "bind" ? volume.source : undefined;
if (sourcePath?.startsWith(".") && !files[sourcePath.replace(/^\.\//, "")]) throw new Error("Source bind mount is missing from the release bundle.");
}
}
const manifest = { schema_version: 1, version, revision, validator_protocol: 1,
requirements: { cpus: 2, memory_bytes: 4 * 2 ** 30, disk_bytes: 10 * 2 ** 30 },
components: ["pi", "catalog-migrations", "workspace-maintenance"],
images: Object.fromEntries(Object.entries(images).map(([role, reference]) => [role, { [platform]: reference }])),
files, compose: ["compose.yaml", "deploy/compose.local.yaml"] };
writeFileSync(join(destination, "release-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
writeFileSync(join(destination, "README.md"), `# ThothII ${version} — ${platform}\n\nSource / Sorgente: ${revision}\n\nThis prerelease provides images and document/preflight tools. Non-interactive execution and real-host acceptance are separate follow-up tickets; this is not a certified complete installation.\nQuesta prerelease fornisce immagini e strumenti di preparazione/preflight. Esecuzione non interattiva e collaudi reali sono incrementi successivi: non è ancora un'installazione completa certificata.\n\nUse bin/tht and its sibling bin/tht-workspace-documents together; no Node, Python, Bun or application checkout is required on the consumer host.\nWindows: use the Linux amd64 bundle inside Ubuntu WSL2, not a native Windows shell.\n\n1. bin/tht workspace prepare --directory NEW_WORKSPACE --id practice --name Practice\n2. bin/tht workspace validate --directory WORKSPACE\n3. bin/tht installation prepare --directory NEW_PRIVATE_INSTALLATION\n4. bin/tht installation preflight --directory INSTALLATION --release ABSOLUTE_RELEASE_DIR/release-manifest.json\n5. Complete the commented documents, generate technical credentials explicitly, then run installation validate and installation plan with --installation ABSOLUTE_INSTALLATION_FILE.\n\nImages are pinned by digest; runtime credentials and user workspaces are never bundled.\nConsult the accompanying IT/EN guides for prepared documents and mandatory runtime checks.\n`);
for (const [name, target] of [["standalone-manual-it.md", "GUIDE-IT.md"], ["standalone-manual-en.md", "GUIDE-EN.md"], ["installation-preflight.md", "PREFLIGHT.md"]]) writeFileSync(join(destination, target), readFileSync(join(source, "docs/install", name)));
return manifest;
}
+35
View File
@@ -0,0 +1,35 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve, join } from "node:path";
import { parse } from "yaml";
import { prepareBundle } from "./release-bundle.mjs";
test("consumer bundle pins every service and carries all source bind resources", () => {
const output = mkdtempSync(join(tmpdir(), "thoth-release-test-"));
const images = Object.fromEntries(["core", "frontend", "catalog", "qdrant", "embedding"].map((role) => [role, `docker.io/tylconsulting/${role}@sha256:${"a".repeat(64)}`]));
try {
prepareBundle({ source: resolve(import.meta.dirname, "../.."), destination: output, platform: "linux/amd64", version: "0.1.0-install-preview.1", revision: "b".repeat(40), images });
const manifest = JSON.parse(readFileSync(join(output, "release-manifest.json")));
const compose = parse(readFileSync(join(output, "compose.yaml"), "utf8"));
assert.equal(compose.services.core.image, images.core);
assert.equal(compose.services["catalog-migrate"].image, images.core);
assert.equal(compose.services["workspace-maintenance"].image, images.core);
assert.equal(compose.services["embedding-model-init"].image, images.embedding);
for (const service of Object.values(compose.services)) {
assert.equal(service.build, undefined);
assert.equal(service.pull_policy, undefined);
assert.equal(service.platform, "linux/amd64");
for (const volume of service.volumes ?? []) {
if (typeof volume === "string" && volume.startsWith("./")) assert.ok(existsSync(join(output, volume.split(":")[0])));
}
}
assert.equal(manifest.validator_protocol, 1);
assert.deepEqual(manifest.compose, ["compose.yaml", "deploy/compose.local.yaml"]);
assert.ok(manifest.files["docker/catalog-db-init.sql"]);
assert.ok(manifest.files["docker/embedding-model-init.sh"]);
assert.ok(!existsSync(join(output, "backend")));
assert.ok(!existsSync(join(output, "harness")));
} finally { rmSync(output, { recursive: true, force: true }); }
});
+69
View File
@@ -0,0 +1,69 @@
import { readFileSync } from "node:fs";
import { boundedFetch, readLimited } from "./release-registry.mjs";
import { sha256 } from "./release-bundle.mjs";
export async function giteaHosting({ run, repository, identity, body }) {
const remote = new URL(repository);
const credential = await run("git", ["credential", "fill"], { input: `protocol=https\nhost=${remote.host}\n\n`, quiet: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
const fields = Object.fromEntries(credential.trim().split("\n").map((line) => { const at = line.indexOf("="); return [line.slice(0, at), line.slice(at + 1)]; }));
if (!fields.username || !fields.password) throw new Error("Gitea publishing credentials are unavailable in the Git credential store.");
const authorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString("base64")}`;
const api = `${remote.origin}/api/v1/repos${remote.pathname.replace(/\.git$/, "")}`;
const marker = `<!-- thothii-release:${JSON.stringify(identity)} -->`;
const tag = `installation-v${identity.version}`;
async function request(path, options = {}, allowMissing = false) {
const response = await boundedFetch(api + path, { ...options, headers: { Authorization: authorization, ...options.headers } }, 120_000);
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new Error(`Gitea release operation failed (HTTP ${response.status}).`);
const bytes = await readLimited(response, 4 * 2 ** 20);
try { return JSON.parse(bytes.toString()); } catch { throw new Error("Gitea returned invalid release metadata."); }
}
const json = (method, value) => ({ method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(value) });
async function verifyTag(required = false) {
const existing = await request(`/tags/${encodeURIComponent(tag)}`, {}, true);
if ((!existing && required) || (existing && existing.commit?.sha !== identity.revision)) throw new Error("Release Git tag does not match the requested source revision.");
}
async function assets(release) { return request(`/releases/${release.id}/assets`); }
async function verifyAsset(asset, expected, publicRead) {
const url = new URL(asset.browser_download_url);
if (url.origin !== remote.origin) throw new Error("Unexpected release asset origin.");
const response = await boundedFetch(url, { headers: publicRead ? {} : { Authorization: authorization } }, 120_000);
if (!response.ok || sha256(await readLimited(response, expected.bytes + 1)) !== expected.sha256) throw new Error("Published release asset does not match its verified checksum.");
}
return {
async open() {
const info = await request("");
if (info.private || !info.permissions?.push) throw new Error("Release hosting must be a public Gitea repository with publication rights.");
await verifyTag();
const existing = await request(`/releases/tags/${encodeURIComponent(tag)}`, {}, true);
if (existing) {
if (!existing.body?.includes(marker)) throw new Error("Release version already belongs to another source or publication identity; it will not be overwritten.");
return existing;
}
return request("/releases", json("POST", { tag_name: tag, target_commitish: identity.revision, name: `ThothII ${identity.version}`, body: `${body}\n\n${marker}`, draft: true, prerelease: true }));
},
async upload(release, asset) {
const matching = (await assets(release)).filter((item) => item.name === asset.name);
if (matching.length > 1) throw new Error("Ambiguous release assets; no published files were replaced.");
if (matching.length === 1) { await verifyAsset(matching[0], asset, false); return; }
const data = readFileSync(asset.path);
if (sha256(data) !== asset.sha256) throw new Error("Local release asset changed before upload.");
const form = new FormData(); form.append("attachment", new Blob([data]), asset.name);
await request(`/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, { method: "POST", body: form });
},
async verify(release, expected, publicRead) {
await verifyTag(publicRead);
const uploaded = await assets(release);
if (uploaded.length !== expected.length) throw new Error("Release asset set is incomplete or contains unexpected files.");
for (const asset of expected) {
const matching = uploaded.filter((item) => item.name === asset.name);
if (matching.length !== 1) throw new Error("Release asset missing or ambiguous.");
await verifyAsset(matching[0], asset, publicRead);
}
},
async publish(release) {
await verifyTag();
return request(`/releases/${release.id}`, json("PATCH", { draft: false }));
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { giteaHosting } from './release-hosting.mjs';
test('release hosting refuses a pre-existing Git tag on another commit', async () => {
const fetch = globalThis.fetch;
const revision = 'a'.repeat(40);
let writes = 0;
globalThis.fetch = async (url, options) => {
if (options.method) { writes++; return Response.json({ id: 1, draft: true }); }
if (String(url).includes('/releases/tags/')) return new Response('', { status: 404 });
if (String(url).includes('/tags/installation-v')) return Response.json({ commit: { sha: 'b'.repeat(40) } });
return Response.json({ private: false, permissions: { push: true } });
};
try {
const hosting = await giteaHosting({ run: async () => 'username=test\npassword=test\n', repository: 'https://example.test/owner/repo', identity: { revision, version: '1.0.0' }, body: '' });
await assert.rejects(hosting.open(), /tag.*revision/i);
assert.equal(writes, 0);
} finally { globalThis.fetch = fetch; }
});
test('matching Git tag is accepted and verified again before publishing', async () => {
const fetch = globalThis.fetch;
const identity = { revision: 'a'.repeat(40), version: '1.0.0' };
let sha = identity.revision;
let writes = 0;
globalThis.fetch = async (url, options) => {
if (options.method) { writes++; return Response.json({ id: 1, draft: false }); }
if (String(url).includes('/releases/tags/')) return Response.json({ id: 1, draft: true, body: `<!-- thothii-release:${JSON.stringify(identity)} -->` });
if (String(url).includes('/tags/')) return Response.json({ commit: { sha } });
if (String(url).endsWith('/assets')) return Response.json([]);
return Response.json({ private: false, permissions: { push: true } });
};
try {
const hosting = await giteaHosting({ run: async () => 'username=test\npassword=test\n', repository: 'https://example.test/owner/repo', identity, body: '' });
const release = await hosting.open();
await hosting.verify(release, [], false);
sha = 'b'.repeat(40);
await assert.rejects(hosting.verify(release, [], false), /tag.*revision/i);
await assert.rejects(hosting.publish(release), /tag.*revision/i);
assert.equal(writes, 0);
} finally { globalThis.fetch = fetch; }
});
+18
View File
@@ -0,0 +1,18 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { commandRunner } from './publish-installation.mjs';
test('publisher timeout terminates descendants that retain output pipes', async () => {
const logs = mkdtempSync(join(tmpdir(), 'release-process-test-'));
try {
const started = Date.now();
await assert.rejects(commandRunner(logs)(process.execPath, ['-e', `
require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 2500)'], {stdio: 'inherit'});
setTimeout(() => {}, 2500);
`], { timeout: 250, quiet: true }));
assert.ok(Date.now() - started < 1800, 'timeout must not wait for the descendant to exit naturally');
} finally { rmSync(logs, { recursive: true, force: true }); }
});
+14
View File
@@ -0,0 +1,14 @@
/** Drafts are the publication boundary. A partial build/upload is never a consumer release. */
export async function publishVerifiedRelease({ hosting, prepare }) {
const release = await hosting.open();
const assets = await prepare();
if (!release.draft) {
await hosting.verify(release, assets, true);
return release;
}
for (const asset of assets) await hosting.upload(release, asset);
await hosting.verify(release, assets, false);
const published = await hosting.publish(release);
await hosting.verify(published, assets, true);
return published;
}
@@ -0,0 +1,34 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { publishVerifiedRelease } from "./release-publication.mjs";
test("an interrupted preparation stays draft and retry publishes only after every artifact verifies", async () => {
const events = [];
let failing = true;
const hosting = {
open: async () => ({ id: 7, draft: true }),
upload: async (_draft, asset) => events.push(`upload:${asset.name}`),
verify: async (_draft, assets, publicRead) => events.push(`verify:${publicRead}:${assets.length}`),
publish: async () => { events.push("publish"); return { html_url: "https://example.test/release" }; },
};
const prepare = async () => {
if (failing) throw new Error("frontend build unavailable");
return [{ name: "bundle.tar.gz" }, { name: "SHA256SUMS" }];
};
await assert.rejects(publishVerifiedRelease({ hosting, prepare }), /frontend/);
assert.deepEqual(events, []);
failing = false;
await publishVerifiedRelease({ hosting, prepare });
assert.deepEqual(events, ["upload:bundle.tar.gz", "upload:SHA256SUMS", "verify:false:2", "publish", "verify:true:2"]);
});
test("a published version is verified without replacing any asset", async () => {
const events = [];
await publishVerifiedRelease({ prepare: async () => [{ name: "bundle.tar.gz" }], hosting: {
open: async () => ({ id: 7, draft: false }),
upload: async () => { throw new Error("overwrote a published version"); },
publish: async () => { throw new Error("republished a version"); },
verify: async (_release, _assets, publicRead) => events.push(publicRead),
} });
assert.deepEqual(events, [true]);
});
+86
View File
@@ -0,0 +1,86 @@
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { sha256 } from "./release-bundle.mjs";
const accept = "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json";
export async function boundedFetch(url, options = {}, timeout = 30_000) {
try { return await fetch(url, { ...options, redirect: options.redirect ?? "error", signal: AbortSignal.timeout(timeout) }); }
catch { throw new Error("Release network request failed; retry with the same output directory."); }
}
export async function readLimited(response, maximum) {
const chunks = []; let size = 0;
for await (const chunk of response.body) { size += chunk.length; if (size > maximum) throw new Error("Release response exceeded its bound."); chunks.push(chunk); }
return Buffer.concat(chunks);
}
async function jsonResponse(response, description) {
if (!response.ok) throw new Error(`${description} refused (HTTP ${response.status}).`);
const bytes = await readLimited(response, 4 * 2 ** 20);
try { return { bytes, value: JSON.parse(bytes.toString()) }; }
catch { throw new Error("Release service returned invalid metadata."); }
}
export async function dockerCredentials(run) {
const config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), ".docker"), "config.json"), "utf8"));
const server = "https://index.docker.io/v1/";
const helper = config.credHelpers?.[server] || config.credsStore;
if (!helper || !/^[A-Za-z0-9._-]+$/.test(helper)) throw new Error("Use docker login with an OS credential store before publishing.");
const auth = JSON.parse(await run(`docker-credential-${helper}`, ["get"], { input: server + "\n", quiet: true }));
if (!auth.Username || !auth.Secret) throw new Error("Docker Hub login is unavailable.");
return auth;
}
export function dockerHub(auth) {
async function token(repository, anonymous) {
const url = new URL("https://auth.docker.io/token");
url.searchParams.set("service", "registry.docker.io");
url.searchParams.set("scope", `repository:${repository}:pull`);
const response = await boundedFetch(url, { headers: anonymous ? {} : { Authorization: `Basic ${Buffer.from(`${auth.Username}:${auth.Secret}`).toString("base64")}` } });
return (await jsonResponse(response, "Registry authentication")).value.token;
}
async function registryJSON(repository, route, anonymous, allowMissing = false) {
const bearer = await token(repository, anonymous);
let response = await boundedFetch(`https://registry-1.docker.io/v2/${repository}/${route}`, { redirect: "manual", headers: { Accept: accept, Authorization: `Bearer ${bearer}` } });
if ([302, 307].includes(response.status) && route.startsWith("blobs/")) {
const target = new URL(response.headers.get("location"));
if (target.protocol !== "https:" || target.username || target.password) throw new Error("Invalid registry blob redirect.");
// Signed blob URLs are fetched without forwarding registry credentials.
response = await boundedFetch(target);
}
if (allowMissing && response.status === 404) return null;
const { bytes, value } = await jsonResponse(response, "Registry read");
const digest = `sha256:${sha256(bytes)}`;
const advertised = response.headers.get("docker-content-digest");
if (advertised && advertised !== digest) throw new Error("Registry content digest mismatch.");
return { value, digest };
}
return {
async ensurePublic(namespace, name) {
const response = await boundedFetch("https://hub.docker.com/v2/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: auth.Username, secret: auth.Secret }) });
const bearer = (await jsonResponse(response, "Docker Hub authentication")).value.access_token;
const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" };
const path = `https://hub.docker.com/v2/namespaces/${namespace}/repositories`;
let existing = await boundedFetch(`${path}/${name}`, { headers });
if (existing.status === 404) {
existing = await boundedFetch(path, { method: "POST", headers, body: JSON.stringify({ namespace, name, registry: "docker.io", is_private: false, description: `ThothII ${name.endsWith("core") ? "core with embedded Pi" : "standalone frontend"}` }) });
}
const data = (await jsonResponse(existing, "Public repository preparation")).value;
if (data.is_private !== false) throw new Error("Selected Docker Hub repository is private; make this release repository public before retrying.");
},
async inspect(repository, reference, platform, { anonymous = false, allowMissing = false } = {}) {
let image = await registryJSON(repository, `manifests/${reference}`, anonymous, allowMissing);
if (!image) return null;
if (reference.startsWith("sha256:") && image.digest !== reference) throw new Error("Requested image digest does not match registry content.");
if (image.value.manifests) {
const match = image.value.manifests.find((entry) => `${entry.platform?.os}/${entry.platform?.architecture}` === platform);
if (!match || !/^sha256:[a-f0-9]{64}$/.test(match.digest)) throw new Error("Image does not contain the requested platform.");
image = await registryJSON(repository, `manifests/${match.digest}`, anonymous);
if (image.digest !== match.digest) throw new Error("Image index digest mismatch.");
}
if (reference.startsWith("sha256:") && !image.value.config) throw new Error("Image metadata is incomplete.");
const configDigest = image.value.config?.digest;
if (!/^sha256:[a-f0-9]{64}$/.test(configDigest ?? "")) throw new Error("Image configuration digest is invalid.");
const config = await registryJSON(repository, `blobs/${configDigest}`, anonymous);
if (config.digest !== configDigest || `${config.value.os}/${config.value.architecture}` !== platform) throw new Error("Image configuration or platform mismatch.");
return { reference: `docker.io/${repository}@${image.digest}`, labels: config.value.config?.Labels ?? {} };
},
};
}
+28
View File
@@ -0,0 +1,28 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { dockerHub } from "./release-registry.mjs";
import { sha256 } from "./release-bundle.mjs";
test("registry verifies pinned config/platform and does not forward auth to blob storage", async () => {
const original = globalThis.fetch;
const config = JSON.stringify({ os: "linux", architecture: "amd64", config: { Labels: { "org.opencontainers.image.revision": "b".repeat(40) } } });
const configDigest = "sha256:" + sha256(config);
const manifest = JSON.stringify({ schemaVersion: 2, config: { digest: configDigest } });
const imageDigest = "sha256:" + sha256(manifest);
const auth = [];
globalThis.fetch = async (target, options) => {
const url = new URL(target);
if (url.hostname === "auth.docker.io") return new Response(JSON.stringify({ token: "registry-token" }));
if (url.hostname === "blob.example.test") { auth.push(options.headers?.Authorization); return new Response(config); }
if (url.pathname.includes("/blobs/")) return new Response(null, { status: 307, headers: { location: "https://blob.example.test/config" } });
return new Response(manifest, { headers: { "docker-content-digest": imageDigest } });
};
try {
const registry = dockerHub({ Username: "publisher", Secret: "PRIVATE_TOKEN" });
const image = await registry.inspect("example/core", imageDigest, "linux/amd64", { anonymous: true });
assert.equal(image.reference, `docker.io/example/core@${imageDigest}`);
assert.deepEqual(auth, [undefined]);
await assert.rejects(registry.inspect("example/core", imageDigest, "linux/arm64"), /platform mismatch/);
await assert.rejects(registry.inspect("example/core", "sha256:" + "0".repeat(64), "linux/amd64"), /Requested image digest/);
} finally { globalThis.fetch = original; }
});
+21
View File
@@ -0,0 +1,21 @@
import { readFileSync, lstatSync } from "node:fs";
import { parseAllDocuments } from "yaml";
import { decode, DocumentError, runWorkspaceDocuments } from "../workspaces/documents.js";
import { validateDatabaseBootstrap } from "./bootstrap-documents.js";
/** Internal sibling protocol: only references cross back to Go, never secret contents. */
export function runBootstrapValidation(args: string[]): { status: number; output: string } {
try {
if (args.length !== 5 || args[0] !== "--directory" || args[2] !== "--bootstrap" || args[4] !== "--json") throw new Error("usage");
const checked = runWorkspaceDocuments(["validate", "--directory", args[1], "--json"]);
if (checked.status !== 0) return checked;
const info = lstatSync(args[3]);
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("file");
const validated = decode(readFileSync(args[3], "utf8"), "database-bootstrap.yaml", (source) =>
validateDatabaseBootstrap(parseAllDocuments(source)[0].toJSON(), args[1]), "database bootstrap schema v1 and the Catalog binding contract");
return { status: 0, output: JSON.stringify({ schema_version: 1, ok: true, secret_files: validated.secretFiles, warnings: validated.warnings, issues: [] }) };
} catch (error) {
const issue = error instanceof DocumentError ? error.issue : { document: "database-bootstrap.yaml", field: "$", code: "bootstrap_invalid", correction: "Supply one complete Catalog database configuration per workspace in a readable local bootstrap document." };
return { status: 1, output: JSON.stringify({ schema_version: 1, ok: false, issues: [issue] }) };
}
}
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { z } from "zod";
import { databaseConfigurationSchema } from "./configuration-schema.js";
import { parseWorkspaceCatalogYaml } from "../workspaces/catalog.js";
import { parseWorkspaceYaml } from "../workspaces/schema.js";
import { discoverWorkspaceSecretRequirements } from "../workspaces/secret-requirements.js";
const reference = z.string().min(1).max(4096);
const database = databaseConfigurationSchema.extend({
secretFiles: z.object({ password: reference.optional(), apiKey: reference.optional(), sshPrivateKey: reference.optional(), sshPrivateKeyPassphrase: reference.optional(), sshKnownHosts: reference.optional(), tlsCa: reference.optional() }).strict(),
evidenceSecretFiles: z.object({ "evidence.signed_urls": reference.optional(), "evidence.access_key": reference.optional(), "evidence.secret_key": reference.optional(), "evidence.session_token": reference.optional() }).strict().optional(),
});
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 }
/** Offline bootstrap boundary: runtime Catalog owns the resulting bindings after import. */
export function validateDatabaseBootstrap(value: unknown, workspaceRoot: string): { secretFiles: BootstrapReference[]; warnings: string[] } {
const document = bootstrap.parse(value);
const catalog = parseWorkspaceCatalogYaml(readFileSync(join(workspaceRoot, "thoth-workspaces.yaml"), "utf8"));
const expected = new Set(catalog.workspaces.map((entry) => entry.id));
const seen = new Set<string>();
const secretFiles: BootstrapReference[] = [];
const warnings: string[] = [];
const issue = (path: (string | number)[], message: string): never => { throw new z.ZodError([{ code: "custom", path, message }]); };
document.databases.forEach((entry, index) => {
if (!expected.has(entry.workspaceId) || seen.has(entry.workspaceId)) issue(["databases", index, "workspaceId"], "Declare each catalog workspace exactly once.");
seen.add(entry.workspaceId);
const required = entry.binding.transport === "rest_api"
? entry.binding.restAuth === "none" ? [] : ["apiKey"] as const
: entry.binding.transport === "ssh_tunnel" ? ["password", "sshPrivateKey", "sshKnownHosts"] as const : ["password"] as const;
for (const name of required) {
if (!entry.secretFiles[name]) issue(["databases", index, "secretFiles", name], "Supply a protected file reference for this transport.");
}
if (entry.binding.transport === "ssh_tunnel") warnings.push(`databases.${index}:ssh_tunnel supports Catalog diagnostics, not NL-to-SQL sessions; choose direct or REST for practice.`);
for (const [name, path] of Object.entries(entry.secretFiles)) secretFiles.push({ field: `databases.${index}.secretFiles.${name}`, path });
const workspace = parseWorkspaceYaml(readFileSync(join(workspaceRoot, entry.workspaceId, "workspace.yaml"), "utf8"));
const requirements = discoverWorkspaceSecretRequirements(workspace, {});
for (const requirement of requirements.filter((item) => item.connector === "evidence" && item.required)) {
if (!(entry.evidenceSecretFiles as Record<string, string> | undefined)?.[requirement.id]) issue(["databases", index, "evidenceSecretFiles"], "Supply the configured Evidence authentication file references.");
}
for (const [name, path] of Object.entries(entry.evidenceSecretFiles ?? {})) secretFiles.push({ field: `databases.${index}.evidenceSecretFiles.${name}`, path });
});
if (seen.size !== expected.size) issue(["databases"], "Add a database binding for every catalog workspace.");
return { secretFiles, warnings };
}
@@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { parseWorkspaceYaml } from "../workspaces/schema.js";
import { evidencePolicy } from "../workspaces/evidence/preprocessing.js";
import type { parseDatabaseBootstrap } from "./bootstrap-documents.js";
import { probePublicEvidenceUrl, publicEvidenceAgent } from "./evidence-probe-http.js";
type Entry = ReturnType<typeof parseDatabaseBootstrap>["databases"][number];
/** Read-only availability probes; domain correctness and materialization remain runtime gates. */
export async function probeEvidence(entry: Entry, root: string): Promise<void> {
const evidence = parseWorkspaceYaml(readFileSync(join(root, entry.workspaceId, "workspace.yaml"), "utf8")).evidence;
if (!evidence || evidence.source.type === "filesystem") return;
if (evidencePolicy(evidence)) throw new Error("Evidence egress policy refused");
const secret = (name: keyof NonNullable<Entry["evidenceSecretFiles"]>) => {
const path = entry.evidenceSecretFiles?.[name];
if (!path) throw new Error("Evidence credential missing");
return readFileSync(path, "utf8").trim();
};
const source = evidence.source;
if (source.type === "http") {
const urls: unknown = source.authentication === "signed_urls_file"
? JSON.parse(secret("evidence.signed_urls")) : source.uris;
if (!Array.isArray(urls) || urls.length !== source.uris.length || urls.length > 1000) throw new Error("Invalid signed URLs");
for (const [index, value] of urls.entries()) {
if (typeof value !== "string") throw new Error("Invalid signed URL");
const url = new URL(value);
const provenance = new URL(source.uris[index]);
// Signed queries may authorize the same identity, never a different host/path.
if (url.origin !== provenance.origin || url.pathname !== provenance.pathname || url.username || url.password || url.hash) throw new Error("Invalid signed URL identity");
await probePublicEvidenceUrl(url);
}
return;
}
// The shared runtime policy currently permits trusted AWS endpoints with explicit file credentials.
const location = new URL(source.uri);
const client = new S3Client({
region: source.region ?? "us-east-1", maxAttempts: 1,
requestHandler: { httpsAgent: publicEvidenceAgent(), connectionTimeout: 5_000, requestTimeout: 5_000 },
credentials: { accessKeyId: secret("evidence.access_key"), secretAccessKey: secret("evidence.secret_key"),
...(entry.evidenceSecretFiles?.["evidence.session_token"] ? { sessionToken: secret("evidence.session_token") } : {}) },
});
try {
await client.send(new ListObjectsV2Command({ Bucket: location.hostname, Prefix: decodeURIComponent(location.pathname.slice(1)), MaxKeys: 1 }), { abortSignal: AbortSignal.timeout(5_000) });
} finally { client.destroy(); }
}
+53
View File
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { parseDatabaseBootstrap } from "./bootstrap-documents.js";
import { runBootstrapValidation } from "./bootstrap-cli.js";
import { createConcreteDiagnosticAdapters, type DiagnosticAdapters } from "../workspaces/diagnostics.js";
import { probeEvidence } from "./bootstrap-evidence-probes.js";
interface ProbeCheck { id: string; outcome: "passed" | "error"; field: string; action: string }
/** Uses the same read-only, authenticated connector diagnostics as the Catalog. */
export async function probeBootstrapDependencies(value: unknown, adapters: DiagnosticAdapters = createConcreteDiagnosticAdapters(), workspaceRoot?: string) {
const document = parseDatabaseBootstrap(value);
const checks: ProbeCheck[] = [];
for (const [index, entry] of document.databases.entries()) {
let outcome: ProbeCheck["outcome"] = "passed";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5_000);
try {
if (entry.binding.transport === "ssh_tunnel") throw new Error("session transport unavailable");
await adapters.probeConnector({
role: "dwh", transport: entry.binding.transport,
host: entry.binding.host, port: entry.binding.port, user: entry.binding.username,
baseUrl: entry.binding.baseUrl,
credentialFile: entry.binding.transport === "rest_api" ? entry.secretFiles.apiKey : entry.secretFiles.password,
tlsCaFile: entry.secretFiles.tlsCa, tlsServername: entry.binding.tlsServername,
resource: { database: entry.databaseName, schema: entry.schema },
timeoutMs: 5_000, signal: controller.signal,
diagnostic: { method: "GET", path: entry.binding.restPath ?? "/health", auth: entry.binding.restAuth ?? "bearer" },
});
} catch { outcome = "error"; } finally { clearTimeout(timer); }
checks.push({ id: `database-${index}`, outcome, field: `database-bootstrap.databases.${index}`,
action: entry.binding.transport === "ssh_tunnel"
? "Choose postgres_direct or rest_api for NL-to-SQL practice; SSH diagnostics alone cannot establish session readiness."
: "Require an authenticated read-only connection and access to the configured database/schema; correct endpoint, permissions or protected credentials." });
if (workspaceRoot) {
let evidenceOutcome: ProbeCheck["outcome"] = "passed";
try { await probeEvidence(entry, workspaceRoot); } catch { evidenceOutcome = "error"; }
checks.push({ id: `evidence-${index}`, outcome: evidenceOutcome, field: `workspaces.${index}.evidence`, action: "Require readable local Evidence or authenticated bounded HTTP/S3 access under the canonical egress policy; domain meaning is verified during practice." });
}
}
return { schema_version: 1, ok: checks.every((check) => check.outcome === "passed"), checks };
}
export async function runBootstrapProbes(args: string[]) {
const validation = runBootstrapValidation(args);
if (validation.status !== 0) return validation;
try {
const report = await probeBootstrapDependencies(parse(readFileSync(args[3], "utf8")), undefined, args[1]);
return { status: report.ok ? 0 : 1, output: JSON.stringify(report) };
} catch {
return { status: 1, output: JSON.stringify({ schema_version: 1, ok: false, checks: [{ id: "database-probes", outcome: "error", field: "database-bootstrap", action: "Revalidate prepared documents and protected credential references." }] }) };
}
}
@@ -0,0 +1,44 @@
import { z } from "zod";
import { parseCredentialFreeHttpUrl } from "../auth/url-policy.js";
import { DATABASE_TRANSPORTS } from "./types.js";
const workspaceIdSchema = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
const identifier = z.string().trim().min(1).max(128).regex(/^[A-Za-z_][A-Za-z0-9_$-]*$/);
const nonEmpty = z.string().trim().min(1).max(512);
const port = z.number().int().min(1).max(65_535);
const optionalText = nonEmpty.optional();
const sshHost = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_.:\[\]-]+$/).optional();
const sshUsername = z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional();
const bindingSchema = z.object({
transport: z.enum(DATABASE_TRANSPORTS),
host: optionalText,
port: port.optional(),
username: optionalText,
baseUrl: z.string().max(2048)
.refine((value) => parseCredentialFreeHttpUrl(value) !== undefined)
.optional(),
restPath: z.string().regex(/^\/(?!\/)[^?#\\\u0000-\u001f]*$/).max(512).optional(),
restAuth: z.enum(["none", "bearer", "x-api-key"]).optional(),
tlsServername: optionalText,
sshHost,
sshPort: port.optional(),
sshUsername,
sshTargetHost: sshHost,
sshTargetPort: port.optional(),
}).strict().superRefine((binding, context) => {
const required = binding.transport === "postgres_direct"
? ["host", "port", "username"] as const
: binding.transport === "rest_api"
? ["baseUrl", "restPath", "restAuth"] as const
: ["username", "sshHost", "sshPort", "sshUsername", "sshTargetHost", "sshTargetPort"] as const;
for (const field of required) {
if (binding[field] === undefined) context.addIssue({ code: "custom", path: [field], message: "Required" });
}
});
export const databaseConfigurationSchema = z.object({
workspaceId: workspaceIdSchema,
engine: z.literal("postgres"),
databaseName: identifier,
schema: identifier,
binding: bindingSchema,
}).strict();
@@ -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();
});
}
+121 -1
View File
@@ -16,12 +16,16 @@ import { loadSettings } from "./settings/settings-store.js";
import { ThtRunner, type SessionRow } from "./tht/tht-runner.js";
import { WorkspaceRegistry } from "./workspaces/registry.js";
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
import { resolveCatalogRuntimeBinding } from "./catalog/runtime-binding.js";
import { CatalogService } from "./catalog/service.js";
import { validateOperationalWorkspace } from "./workspaces/schema.js";
import { createCatalogRepository } from "./catalog/repository.js";
import type { CatalogRepository } from "./catalog/types.js";
type OperatorAction = "maintenance-activate" | "maintenance-deactivate" | "maintenance-status"
| "session-inventory" | "workflow-doctor" | "workspace-integrity"
| "pi-test" | "effective-settings";
| "workspace-pull" | "workspace-test" | "pi-test" | "effective-settings";
const lifecyclePrincipal: PrincipalContext = {
issuer: "tht-operator-command",
@@ -119,6 +123,119 @@ async function workspaceIntegrity(config: AppConfig): Promise<{
return { ready: true, ...integrity };
}
async function workspacePull(config: AppConfig): Promise<{
ready: boolean;
status: "succeeded" | "degraded";
branch: string;
head?: string;
degraded: boolean;
}> {
const status = await new WorkspaceRegistry(config.workspaceRegistry).pull();
return {
ready: !status.degraded,
status: status.degraded ? "degraded" : "succeeded",
branch: status.branch,
...(status.head ? { head: status.head } : {}),
degraded: status.degraded,
};
}
interface WorkspaceTestReport {
id: string;
status: "ready" | "failed";
database: "reachable" | "not_configured" | "failed";
diagnostics: string[];
}
async function workspaceTest(config: AppConfig): Promise<{
ready: boolean;
workspaces: WorkspaceTestReport[];
}> {
if (!config.catalogDatabase) throw new Error("Catalog database is not configured");
const registry = new WorkspaceRegistry(config.workspaceRegistry);
const revisions = await registry.list();
const repository = createCatalogRepository(config.catalogDatabase);
try {
const secretStore = new WorkspaceSecretStore({
root: config.workspaceSecretStoreRoot,
runtimeRoot: config.workspaceSecretRuntimeRoot,
installationId: config.workspaceRegistry.installationId,
});
const catalogService = new CatalogService(
repository,
registry,
secretStore,
config.workspaceRegistry.secretRoots,
config.workspaceDiagnosticTimeoutMs,
);
const diagnose = createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
internalQdrantUrl: config.internalQdrantUrl,
internalEmbeddingUrl: config.internalEmbeddingUrl,
internalEmbeddingId: config.internalEmbeddingId,
internalEmbeddingModel: config.internalEmbeddingModel,
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
});
const databases = await repository.list();
const reports: WorkspaceTestReport[] = [];
for (const revision of revisions) {
const diagnostics: string[] = [];
let workspace: ReturnType<typeof validateOperationalWorkspace>;
try {
workspace = validateOperationalWorkspace((await registry.read(revision.id)).workspace);
} catch {
reports.push({ id: revision.id, status: "failed", database: "not_configured", diagnostics: ["workspace_invalid"] });
continue;
}
const database = databases.find((candidate) => candidate.workspaceId === revision.id);
if (!database) {
reports.push({ id: revision.id, status: "failed", database: "not_configured", diagnostics: ["database_binding_missing"] });
continue;
}
let tested;
try {
tested = await catalogService.test(database);
} catch {
reports.push({ id: revision.id, status: "failed", database: "failed", diagnostics: ["database_unreachable"] });
continue;
}
if (!tested || tested.connectionStatus !== "reachable") {
reports.push({ id: revision.id, status: "failed", database: "failed", diagnostics: ["database_unreachable"] });
continue;
}
let lease: ReturnType<typeof resolveCatalogRuntimeBinding>;
try {
lease = resolveCatalogRuntimeBinding({
workspace,
database: tested,
environment: process.env,
secretRoots: config.workspaceRegistry.secretRoots,
secretStore,
});
} catch {
reports.push({ id: revision.id, status: "failed", database: "reachable", diagnostics: ["binding_missing"] });
continue;
}
try {
// CatalogService.test() above is the authoritative database probe and records its
// outcome. The remaining diagnoser pass checks Evidence and internal semantic services;
// skipping its legacy DWH probe avoids requiring a second response-shape contract for a
// REST health endpoint.
const result = await diagnose(lease.workspace, lease.bindings, { writeProbe: false, skipDwh: true });
diagnostics.push(...result.diagnostics.map((diagnostic) => diagnostic.code));
const ready = result.activatable;
reports.push({ id: revision.id, status: ready ? "ready" : "failed", database: "reachable", diagnostics });
} catch {
reports.push({ id: revision.id, status: "failed", database: "reachable", diagnostics: ["connector_unavailable"] });
} finally {
lease.release();
}
}
return { ready: reports.length > 0 && reports.every((report) => report.status === "ready"), workspaces: reports };
} finally {
await repository.close?.();
}
}
export async function runOperatorAction(
action: OperatorAction,
config: AppConfig,
@@ -132,6 +249,8 @@ export async function runOperatorAction(
if (action === "session-inventory") return await sessionInventory(config);
if (action === "workflow-doctor") return await workflowDiagnostics(config);
if (action === "workspace-integrity") return await workspaceIntegrity(config);
if (action === "workspace-pull") return await workspacePull(config);
if (action === "workspace-test") return await workspaceTest(config);
const modelCatalog = loadRuntimeModelCatalog(config.modelCatalogFile);
if (action === "effective-settings") {
return effectiveSettings(config, loadSettings(config), modelCatalog);
@@ -146,6 +265,7 @@ async function main(): Promise<void> {
if (!action || ![
"maintenance-activate", "maintenance-deactivate", "maintenance-status", "session-inventory",
"workflow-doctor", "workspace-integrity", "pi-test", "effective-settings",
"workspace-pull", "workspace-test",
].includes(action)) throw new Error("invalid operator action");
const result = await runOperatorAction(action, loadConfig(process.env));
process.stdout.write(`${JSON.stringify(result)}\n`);
+1 -42
View File
@@ -1,60 +1,19 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
import { parseCredentialFreeHttpUrl } from "../auth/url-policy.js";
import { databaseConfigurationSchema as configSchema } from "../catalog/configuration-schema.js";
import { CatalogService, type CatalogSecretName } from "../catalog/service.js";
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
import {
CatalogConflictError,
CatalogOperationInProgressError,
CatalogUnavailableError,
DATABASE_TRANSPORTS,
type CatalogRepository,
type DatabaseConfigurationInput,
} from "../catalog/types.js";
import type { CatalogOperationCoordinator } from "../catalog/operation-coordinator.js";
const idSchema = z.uuid();
const workspaceIdSchema = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
const identifier = z.string().trim().min(1).max(128).regex(/^[A-Za-z_][A-Za-z0-9_$-]*$/);
const nonEmpty = z.string().trim().min(1).max(512);
const port = z.number().int().min(1).max(65_535);
const optionalText = nonEmpty.optional();
const sshHost = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_.:\[\]-]+$/).optional();
const sshUsername = z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional();
const bindingSchema = z.object({
transport: z.enum(DATABASE_TRANSPORTS),
host: optionalText,
port: port.optional(),
username: optionalText,
baseUrl: z.string().max(2048)
.refine((value) => parseCredentialFreeHttpUrl(value) !== undefined)
.optional(),
restPath: z.string().regex(/^\/(?!\/)[^?#\\\u0000-\u001f]*$/).max(512).optional(),
restAuth: z.enum(["none", "bearer", "x-api-key"]).optional(),
tlsServername: optionalText,
sshHost,
sshPort: port.optional(),
sshUsername,
sshTargetHost: sshHost,
sshTargetPort: port.optional(),
}).strict().superRefine((binding, context) => {
const required = binding.transport === "postgres_direct"
? ["host", "port", "username"] as const
: binding.transport === "rest_api"
? ["baseUrl", "restPath", "restAuth"] as const
: ["username", "sshHost", "sshPort", "sshUsername", "sshTargetHost", "sshTargetPort"] as const;
for (const field of required) {
if (binding[field] === undefined) context.addIssue({ code: "custom", path: [field], message: "Required" });
}
});
const configSchema = z.object({
workspaceId: workspaceIdSchema,
engine: z.literal("postgres"),
databaseName: identifier,
schema: identifier,
binding: bindingSchema,
}).strict();
const updateSchema = configSchema.extend({ version: z.number().int().positive() });
const secretNames = [
"password",
+10
View File
@@ -0,0 +1,10 @@
/** Compiled with its runtime for the host CLI: no installation, Docker or host Node required. */
import { runWorkspaceDocuments } from "./workspaces/documents.js";
import { runBootstrapValidation } from "./catalog/bootstrap-cli.js";
import { runBootstrapProbes } from "./catalog/bootstrap-probes.js";
const args = process.argv.slice(2);
const result = args[0] === "probe" ? await runBootstrapProbes(args.slice(1))
: args[0] === "bootstrap" ? runBootstrapValidation(args.slice(1)) : runWorkspaceDocuments(args);
console.log(result.output);
process.exitCode = result.status;
+3 -3
View File
@@ -44,8 +44,8 @@ const catalogSchema = z.object({
});
});
function safeCatalogError(): Error {
return new Error("Workspace catalog is invalid");
function safeCatalogError(cause?: unknown): Error {
return new Error("Workspace catalog is invalid", { cause });
}
export function parseWorkspaceCatalogYaml(source: string): WorkspaceCatalog {
@@ -57,7 +57,7 @@ export function parseWorkspaceCatalogYaml(source: string): WorkspaceCatalog {
return catalogSchema.parse(document.toJSON()) as WorkspaceCatalog;
} catch (error) {
if (error instanceof Error && error.message === "Workspace catalog is invalid") throw error;
throw safeCatalogError();
throw safeCatalogError(error);
}
}
+222
View File
@@ -0,0 +1,222 @@
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { parseAllDocuments, stringify } from "yaml";
import { ZodError } from "zod";
import { CATALOG_PATH, parseWorkspaceCatalogYaml, assertCatalogMatchesDescriptor } from "./catalog.js";
import { parseWorkspaceYaml, type WorkspaceDescriptor } from "./schema.js";
interface Issue { document: string; field: string; code: string; correction: string; line?: number }
interface Report {
schema_version: 1;
scope: "local-documents";
ok: boolean;
workspaces: { id: string; evidence: "absent" | "local-files" | "remote-deferred" }[];
issues: Issue[];
deferred_checks: string[];
}
const MAX_DOCUMENT_BYTES = 1024 * 1024;
const usage = "tht workspace prepare --directory NEW_PATH --id ID --name NAME [--language en|it] [--json]\ntht workspace validate --directory PATH [--json]";
export class DocumentError extends Error {
constructor(readonly issue: Issue) { super(issue.correction); }
}
function fail(document: string, field: string, code: string, correction: string): never {
throw new DocumentError({ document, field, code, correction });
}
/** Never include parser messages or submitted values: YAML and Zod errors can contain secrets. */
export function decode<T>(source: string, document: string, parser: (text: string) => T, contract = "workspace schema v4 or catalog schema v1"): T {
try {
const documents = parseAllDocuments(source, { uniqueKeys: true });
if (documents.length !== 1) fail(document, "$", "yaml_documents", "Keep exactly one YAML document in this file.");
const problem = [...documents[0].errors, ...documents[0].warnings][0];
if (problem) {
throw new DocumentError({ document, field: "$", code: "yaml_syntax", line: problem.linePos?.[0].line,
correction: "Correct YAML syntax, remove duplicate keys and unsupported tags at the indicated line." });
}
return parser(source);
} catch (error) {
if (error instanceof DocumentError) throw error;
const cause = error instanceof Error && error.cause instanceof ZodError ? error.cause : error;
if (cause instanceof ZodError) {
const issue = cause.issues[0];
// Strict schemas produce paths containing schema-defined keys and array indices only.
fail(document, issue.path.join(".") || "$", "schema_invalid",
issue.code === "unrecognized_keys" ? "Remove fields not defined by the current workspace/catalog contract."
: `Correct this field using ${contract}; check type, required value, uniqueness and allowed values.`);
}
fail(document, "$", "schema_invalid", "Use an authored workspace v4 descriptor; remove database configuration and keep it in the PostgreSQL Metadata Catalog.");
}
}
function stat(root: string, document: string, directory: boolean) {
let info;
try { info = lstatSync(join(root, document)); }
catch { fail(document, "$", "missing_reference", "Create the referenced local file or directory and grant read access."); }
if (info.isSymbolicLink() || (directory ? !info.isDirectory() : !info.isFile())) {
fail(document, "$", "unsafe_reference", "Use a regular local file or directory, without symbolic links or special files.");
}
return info;
}
function readDocument(root: string, document: string): string {
if (stat(root, document, false).size > MAX_DOCUMENT_BYTES) {
fail(document, "$", "document_too_large", "Keep YAML documents below the 1 MiB local validation limit.");
}
try { return readFileSync(join(root, document), "utf8"); }
catch { fail(document, "$", "unreadable", "Grant read access to this document and retry validation."); }
}
function directories(root: string, document: string) {
stat(root, document, true);
try { return readdirSync(join(root, document), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
catch { fail(document, "$", "unreadable", "Grant read and traversal access to this directory and retry validation."); }
}
function inspectEvidence(root: string, descriptor: WorkspaceDescriptor): "absent" | "local-files" | "remote-deferred" {
const evidence = descriptor.evidence;
if (!evidence) return "absent";
if (evidence.source.type !== "filesystem") return "remote-deferred";
const base = evidence.source.uri;
stat(root, base, true);
if (evidence.schema_version === 2) stat(root, `${base}/curated`, true);
const patterns = evidence.source.patterns ?? ["**/*.md"];
const literals = patterns.filter((pattern) => !/[?*\[]/.test(pattern));
for (const pattern of literals) {
const parts = pattern.split("/");
for (let index = 1; index < parts.length; index++) stat(root, `${base}/${parts.slice(0, index).join("/")}`, true);
stat(root, `${base}/${pattern}`, false);
}
// Inventory without following links; curated-unit interpretation remains a runtime check.
const pending = [base];
let count = 0;
while (pending.length) {
const current = pending.pop()!;
for (const entry of directories(root, current)) {
if (++count > 100_000) fail(base, "evidence.source", "inventory_limit", "Reduce the Evidence tree below 100,000 entries before local validation.");
const path = `${current}/${entry.name}`;
if (entry.isDirectory()) pending.push(path);
else {
const info = stat(root, path, false);
const relative = path.slice(base.length + 1);
// Only apply content checks to selections whose meaning is unambiguous locally.
// Arbitrary globs are expanded by the canonical Python adapter after startup.
const curated = evidence.schema_version === 2 && relative.startsWith("curated/") && relative.endsWith(".md");
const selected = curated || literals.includes(relative) || (patterns.includes("**/*.md") && relative.endsWith(".md"));
if (selected && info.size > evidence.source.max_bytes) fail(path, "evidence.source.max_bytes", "evidence_too_large", "Reduce the source file or increase the declared max_bytes limit deliberately.");
try { closeSync(openSync(join(root, path), "r")); }
catch { fail(path, "$", "unreadable", "Grant read access to this Evidence file and retry validation."); }
if (curated) {
const text = readDocument(root, path);
const frontmatter = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!frontmatter) fail(path, "$", "evidence_frontmatter", "Add a YAML frontmatter block delimited by --- to the curated Markdown unit; follow the Curated Evidence contract.");
decode(frontmatter[1], path, (source) => parseAllDocuments(source)[0].toJSON());
}
}
}
}
return "local-files";
}
function validate(root: string, report: Report): void {
directories(root, ".");
const catalog = decode(readDocument(root, CATALOG_PATH), CATALOG_PATH, parseWorkspaceCatalogYaml);
const ids = new Set(catalog.workspaces.map((entry) => entry.id));
for (const entry of directories(root, ".")) {
if (entry.name === ".git") continue;
if (entry.isSymbolicLink()) fail(".", "$", "unsafe_reference", "Replace repository-root symbolic links with regular files or directories.");
if (entry.isDirectory() && entry.name !== "workspace-docs" && !ids.has(entry.name)) {
fail(".", "workspaces", "unlisted_directory", "Every root directory except workspace-docs must match a catalog workspace id; remove or register the extra directory.");
}
if (entry.name === "workspace-docs") {
for (const docs of directories(root, "workspace-docs")) {
if (!ids.has(docs.name)) fail("workspace-docs", "$", "unlisted_documentation", "Keep documentation only for workspace ids listed in the catalog.");
for (const file of directories(root, `workspace-docs/${docs.name}`)) {
const path = `workspace-docs/${docs.name}/${file.name}`;
if (!["README.md", "contract.env.example"].includes(file.name)) fail(`workspace-docs/${docs.name}`, "$", "unsupported_documentation", "Keep only README.md and contract.env.example in workspace-docs/<id>. Place Evidence inside the workspace directory.");
stat(root, path, false);
}
}
}
}
for (const entry of catalog.workspaces) {
const document = `${entry.id}/workspace.yaml`;
try {
stat(root, entry.id, true);
const descriptor = decode(readDocument(root, document), document, parseWorkspaceYaml);
try { assertCatalogMatchesDescriptor(entry, descriptor); }
catch { fail(document, "workspace", "catalog_mismatch", "Make id, name and description identical in the catalog, descriptor and workspace directory name."); }
const evidence = inspectEvidence(root, descriptor);
report.workspaces.push({ id: entry.id, evidence });
if (evidence !== "absent") report.deferred_checks.push(`${entry.id}:evidence-source-selection`, `${entry.id}:evidence-content-provenance-and-indexing`);
if (evidence === "remote-deferred") report.deferred_checks.push(`${entry.id}:remote-evidence-access`);
} catch (error) {
if (error instanceof DocumentError) report.issues.push(error.issue);
else throw error;
}
}
}
function prepare(root: string, options: Map<string, string>): void {
const id = options.get("--id");
const name = options.get("--name");
const language = options.get("--language") ?? "en";
const catalog = stringify({ schema_version: 1, workspaces: [{ id, name }] });
const descriptor = stringify({ workspace: { schema_version: 4, id, name, language } });
decode(catalog, CATALOG_PATH, parseWorkspaceCatalogYaml);
decode(descriptor, "workspace.yaml", parseWorkspaceYaml);
try { mkdirSync(root); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") fail(".", "--directory", "destination_exists", "Choose a new directory; preparation never overwrites an existing directory or its documents.");
fail(".", "--directory", "destination_unavailable", "Create the parent directory and grant write access, then choose a new destination.");
}
try {
mkdirSync(join(root, id!));
mkdirSync(join(root, "workspace-docs", id!), { recursive: true });
writeFileSync(join(root, CATALOG_PATH), "# Index of workspace identities. Keep metadata identical to each descriptor.\n" + catalog, { flag: "wx" });
writeFileSync(join(root, id!, "workspace.yaml"), "# Authored workspace v4: optional Evidence; database binding belongs to the Metadata Catalog.\n" + descriptor, { flag: "wx" });
writeFileSync(join(root, "workspace-docs", id!, "README.md"),
"# Workspace documents / Documenti workspace\n\n" +
"EN: Edit thoth-workspaces.yaml and <id>/workspace.yaml together. Evidence is optional and absent by default. Add reviewed source material under <id>/evidence only when configured. Database connections and schema belong to the installation Metadata Catalog. No example databases are downloaded.\n\n" +
"IT: Modificare insieme thoth-workspaces.yaml e <id>/workspace.yaml. Le Evidence sono facoltative e inizialmente assenti. Inserire materiale verificato in <id>/evidence solo quando configurato. Connessioni e schema dei database appartengono al Metadata Catalog dell'installazione. Nessun database di esempio viene scaricato.\n\n" +
"Repeat / Ripetere: `tht workspace validate --directory <repository>`. Local success does not establish runtime readiness or semantic truth / Il successo locale non certifica readiness o verità semantica.\n", { flag: "wx" });
} catch {
rmSync(root, { recursive: true, force: true });
fail(".", "$", "prepare_failed", "Preparation could not write the documents; check disk space and permissions, then retry with a new directory.");
}
}
export function runWorkspaceDocuments(args: string[]): { status: number; output: string } {
const report: Report = { schema_version: 1, scope: "local-documents", ok: false, workspaces: [], issues: [], deferred_checks: ["catalog-database-binding", "database-connectivity", "runtime-preprocessing"] };
const json = args.includes("--json");
let status = 1;
try {
const [command, ...rest] = args;
if ((command === "prepare" || command === "validate") && rest.length === 1 && rest[0] === "--help") return { status: 0, output: usage };
const allowed = command === "prepare" ? ["--directory", "--id", "--name", "--language"] : command === "validate" ? ["--directory"] : [];
const options = new Map<string, string>();
const seen = new Set<string>();
for (let index = 0; index < rest.length; index++) {
const key = rest[index];
if (seen.has(key)) fail("CLI", "$", "usage", usage);
seen.add(key);
if (key === "--json") continue;
if (!allowed.includes(key) || !rest[index + 1] || rest[index + 1].startsWith("--")) fail("CLI", "$", "usage", usage);
options.set(key, rest[++index]);
}
if (!allowed.length || !options.has("--directory") || (command === "prepare" && (!options.has("--id") || !options.has("--name")))) fail("CLI", "$", "usage", usage);
const root = resolve(options.get("--directory")!);
if (command === "prepare") prepare(root, options);
validate(root, report);
report.ok = report.issues.length === 0;
status = report.ok ? 0 : 1;
} catch (error) {
report.issues.push(error instanceof DocumentError ? error.issue : { document: ".", field: "$", code: "io_error", correction: "Check local permissions and regular files, then retry; no services were started." });
if (report.issues[0].code === "usage") status = 2;
}
const output = json ? JSON.stringify(report) : [
report.ok ? "Workspace documents pass local validation." : "Workspace documents require corrections.",
...report.issues.map((issue) => `${issue.document}${issue.line ? `:${issue.line}` : ""} [${issue.field}] ${issue.code}: ${issue.correction}`),
...report.workspaces.map((entry) => `${entry.id}: Evidence ${entry.evidence}`),
`Deferred until runtime: ${report.deferred_checks.join(", ")}.`,
].join("\n");
return { status, output };
}
@@ -0,0 +1,49 @@
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, expect, test } from "vitest";
import { validateDatabaseBootstrap } from "../src/catalog/bootstrap-documents.js";
import { runBootstrapValidation } from "../src/catalog/bootstrap-cli.js";
const roots: string[] = [];
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
function fixture() {
const root = mkdtempSync(join(tmpdir(), "bootstrap-documents-")); roots.push(root);
mkdirSync(join(root, "practice"));
writeFileSync(join(root, "thoth-workspaces.yaml"), "schema_version: 1\nworkspaces: [{id: practice, name: Practice}]\n");
writeFileSync(join(root, "practice/workspace.yaml"), "workspace: {schema_version: 4, id: practice, name: Practice, language: en}\n");
return root;
}
const entry = { workspaceId: "practice", engine: "postgres", databaseName: "practice", schema: "public", binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" }, secretFiles: { password: "/private/operator/db-password" } };
test("bootstrap reuses Catalog configuration and requires one complete binding per workspace", () => {
const root = fixture();
const valid = validateDatabaseBootstrap({ schemaVersion: 1, databases: [entry] }, root);
expect(valid.secretFiles).toContainEqual({ field: "databases.0.secretFiles.password", path: "/private/operator/db-password" });
for (const databases of [[], [entry, entry], [{ ...entry, workspaceId: "unknown" }], [{ ...entry, secretFiles: {} }], [{ ...entry, engine: "mysql" }], [{ ...entry, binding: { ...entry.binding, port: 70000 } }]]) {
expect(() => validateDatabaseBootstrap({ schemaVersion: 1, databases }, root)).toThrow();
}
});
test("REST authentication, SSH credentials and optional Evidence use their declared contracts", () => {
const root = fixture();
const rest = { ...entry, binding: { transport: "rest_api", baseUrl: "https://data.internal", restPath: "/query", restAuth: "none" }, secretFiles: {} };
expect(validateDatabaseBootstrap({ schemaVersion: 1, databases: [rest] }, root).secretFiles).toEqual([]);
expect(() => validateDatabaseBootstrap({ schemaVersion: 1, databases: [{ ...rest, binding: { ...rest.binding, restAuth: "bearer" } }] }, root)).toThrow();
const ssh = { ...entry, binding: { transport: "ssh_tunnel", username: "reader", sshHost: "bastion", sshPort: 22, sshUsername: "tunnel", sshTargetHost: "database", sshTargetPort: 5432 }, secretFiles: { password: "/password", sshPrivateKey: "/key", sshKnownHosts: "/hosts" } };
expect(validateDatabaseBootstrap({ schemaVersion: 1, databases: [ssh] }, root).warnings[0]).toContain("not NL-to-SQL");
writeFileSync(join(root, "practice/workspace.yaml"), "workspace: {schema_version: 4, id: practice, name: Practice, language: en}\nevidence:\n source:\n type: http\n uris: [https://docs.internal/manual.md]\n authentication: signed_urls_file\n");
expect(() => validateDatabaseBootstrap({ schemaVersion: 1, databases: [entry] }, root)).toThrow();
expect(validateDatabaseBootstrap({ schemaVersion: 1, databases: [{ ...entry, evidenceSecretFiles: { "evidence.signed_urls": "/urls.json" } }] }, root).secretFiles).toContainEqual({ field: "databases.0.evidenceSecretFiles.evidence.signed_urls", path: "/urls.json" });
});
test("bootstrap CLI never echoes arbitrary keys from submitted secret references", () => {
const root = fixture();
const path = join(root, "bootstrap.yaml");
for (const field of ["secretFiles", "evidenceSecretFiles"]) {
writeFileSync(path, JSON.stringify({ schemaVersion: 1, databases: [{ ...entry, [field]: { PRIVATE_CREDENTIAL_SENTINEL: "/path" } }] }));
const result = runBootstrapValidation(["--directory", root, "--bootstrap", path, "--json"]);
expect(result.status).toBe(1);
expect(result.output).not.toContain("PRIVATE_CREDENTIAL_SENTINEL");
}
});
@@ -0,0 +1,67 @@
import { spawnSync } from "node:child_process";
import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { rootCertificates } from "node:tls";
import { afterEach, expect, test } from "vitest";
const binary = process.env.THT_INSTALLATION_TEST_CLI;
const roots: string[] = [];
afterEach(() => roots.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true })));
function run(...args: string[]) {
return spawnSync(binary!, [...args, "--json"], { encoding: "utf8", env: { ...process.env, PATH: "" }, input: "" });
}
test.skipIf(!binary)("operator prepares, completes and repeatedly validates before any stack exists", () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "application-documents-"))); roots.push(root);
const workspace = join(root, "workspaces"), directory = join(root, "installation");
expect(run("workspace", "prepare", "--directory", workspace, "--id", "practice", "--name", "Practice").status).toBe(0);
expect(run("installation", "prepare", "--directory", directory).status).toBe(0);
const installation = join(directory, "thothii-installation.yaml");
const validate = () => run("--installation", installation, "installation", "validate", "--workspaces", workspace);
expect(validate().status).toBe(1); // Visible placeholders cannot be approved.
expect(run("installation", "credentials", "--directory", directory).status).toBe(0);
for (const name of ["thothii-installation.yaml", "operator.env", "database-bootstrap.yaml"]) {
const path = join(directory, name);
writeFileSync(path, readFileSync(path, "utf8").replaceAll("CHANGE_ME", "practice"));
}
for (const [name, contents] of Object.entries({ "secrets.env": "OPENAI_API_KEY=PRIVATE_PROVIDER_VALUE\n", "database-password": "PRIVATE_DATABASE_VALUE", "git-credentials": "", "git-ca.pem": rootCertificates[0] })) {
writeFileSync(join(directory, "secrets", name), contents, { mode: 0o600 });
}
const before = readFileSync(installation, "utf8");
for (let index = 0; index < 2; index++) {
const checked = validate();
expect(checked.stderr).toBe("");
expect(checked.stdout).not.toContain("PRIVATE_");
expect(checked.status, checked.stdout).toBe(0);
expect(JSON.parse(checked.stdout).deferred_checks).toContain("release-assets");
}
expect(readFileSync(installation, "utf8")).toBe(before);
writeFileSync(installation, before.replace("interaction: openai/gpt-4.1-mini", "interaction: openai/nonexistent"));
expect(validate().status).toBe(1);
writeFileSync(installation, before);
const authFile = join(directory, "secrets/pi-auth.json");
writeFileSync(authFile, "not-json");
expect(validate().status).toBe(1);
writeFileSync(installation, before.replace("{mode: secret_env, apiKeyEnv: OPENAI_API_KEY}", "{mode: pi_auth}"));
writeFileSync(authFile, JSON.stringify({ openai: { unrelated: true } }));
expect(validate().status).toBe(1);
writeFileSync(authFile, JSON.stringify({ " OpenAI ": { type: "api_key", key: "PRIVATE_PI_KEY" } }));
expect(validate().status).toBe(1);
writeFileSync(authFile, JSON.stringify({ openai: { type: "api_key", key: "PRIVATE_PI_KEY" } }));
expect(validate().status).toBe(0);
writeFileSync(installation, before);
writeFileSync(authFile, "{}");
const bootstrap = join(directory, "database-bootstrap.yaml");
const originalBootstrap = readFileSync(bootstrap, "utf8");
writeFileSync(join(workspace, "practice", "private-password"), "PRIVATE_DATABASE_VALUE", { mode: 0o600 });
writeFileSync(bootstrap, originalBootstrap.replace(join(directory, "secrets/database-password"), join(workspace, "practice/private-password")));
expect(validate().status).toBe(1);
writeFileSync(bootstrap, originalBootstrap);
chmodSync(join(directory, "secrets/database-password"), 0o644);
expect(validate().status).toBe(1);
chmodSync(join(directory, "secrets/database-password"), 0o600);
const env = join(directory, "operator.env");
writeFileSync(env, readFileSync(env, "utf8") + "THOTH_HTTP_PORT=8081\n");
expect(validate().status).toBe(1);
}, 15_000);
@@ -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 });
}
});
+14
View File
@@ -36,6 +36,10 @@ vi.mock("../src/tht/tht-runner.js", () => ({
vi.mock("../src/workspaces/registry.js", () => ({
WorkspaceRegistry: class {
async pull() {
return { branch: "main", head: "a".repeat(40), ahead: 0, behind: 0, degraded: false };
}
async listRetainedSnapshots() {
return [{ snapshotPath: "/data/workspace-registry/snapshots/revision/workspace.yaml" }];
}
@@ -98,3 +102,13 @@ test("workflow doctor gives schema-v4 runtime rendering a live Catalog repositor
expect(fakes.releaseWorkspaceRuntime).toHaveBeenCalledOnce();
expect(fakes.catalogRepository.close).toHaveBeenCalledOnce();
});
test("workspace pull exposes only safe Git status", async () => {
await expect(runOperatorAction("workspace-pull", config)).resolves.toEqual({
ready: true,
status: "succeeded",
branch: "main",
head: "a".repeat(40),
degraded: false,
});
});
@@ -0,0 +1,119 @@
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, expect, test } from "vitest";
import { parseWorkspaceCatalogYaml } from "../src/workspaces/catalog.js";
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
const roots: string[] = [];
function directory() {
const root = mkdtempSync(join(tmpdir(), "tht-documents-"));
roots.push(root);
return join(root, "workspaces");
}
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
function cli(...args: string[]) {
const packaged = process.env.THT_WORKSPACE_TEST_CLI;
const result = spawnSync(packaged ?? process.execPath, [...(packaged ? ["workspace"] : ["--import", "tsx", resolve("src/workspace-documents-cli.ts")]), ...args, "--json"], {
encoding: "utf8", env: { ...process.env, PATH: "" },
});
return { ...result, report: result.stdout.trim() ? JSON.parse(result.stdout) : null };
}
const descriptor = "workspace:\n schema_version: 4\n id: practice\n name: Practice\n language: en\n";
const catalog = "schema_version: 1\nworkspaces:\n - id: practice\n name: Practice\n";
const corpus = [
{ name: "minimal", descriptor, catalog, valid: true },
{ name: "optional Evidence", descriptor: descriptor + "evidence:\n source:\n type: http\n uris: [https://example.com/manual.md]\n", catalog, valid: true },
{ name: "duplicate YAML key", descriptor: descriptor + " id: practice\n", catalog, valid: false },
{ name: "ambiguous document", descriptor: descriptor + "---\n" + descriptor, catalog, valid: false },
{ name: "unknown workspace field", descriptor: descriptor + " secret: VERY_SECRET_VALUE\n", catalog, valid: false },
{ name: "database binding in authored descriptor", descriptor: descriptor + "dwh: {password: VERY_SECRET_VALUE}\n", catalog, valid: false },
{ name: "duplicate catalog id", descriptor, catalog: catalog + " - id: practice\n name: Practice\n", valid: false },
{ name: "unknown catalog field", descriptor, catalog: catalog + "secret: VERY_SECRET_VALUE\n", valid: false },
{ name: "invalid catalog YAML", descriptor, catalog: catalog + "schema_version: 1\n", valid: false },
{ name: "unsafe Evidence URI", descriptor: descriptor + "evidence:\n source:\n type: http\n uris: [https://user:VERY_SECRET_VALUE@example.com/file]\n", catalog, valid: false },
];
test.each(corpus)("CLI and runtime agree: $name", (fixture) => {
const root = directory();
mkdirSync(join(root, "practice"), { recursive: true });
writeFileSync(join(root, "thoth-workspaces.yaml"), fixture.catalog);
writeFileSync(join(root, "practice/workspace.yaml"), fixture.descriptor);
let accepted = true;
try { parseWorkspaceCatalogYaml(fixture.catalog); parseWorkspaceYaml(fixture.descriptor); }
catch { accepted = false; }
expect(accepted).toBe(fixture.valid);
const checked = cli("validate", "--directory", root);
expect(checked.status).toBe(fixture.valid ? 0 : 1);
expect(checked.report.ok).toBe(accepted);
expect(checked.stdout + checked.stderr).not.toContain("VERY_SECRET_VALUE");
if (!fixture.valid) {
expect(checked.report.issues[0].document).not.toBe(".");
expect(checked.report.issues[0].correction.length).toBeGreaterThan(10);
}
});
test("prepare refuses existing directories and invalid options without changing documents", () => {
const root = directory();
expect(cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice").status).toBe(0);
const before = readFileSync(join(root, "thoth-workspaces.yaml"), "utf8");
expect(cli("prepare", "--directory", root, "--id", "other", "--name", "Other").report.issues[0].code).toBe("destination_exists");
expect(readFileSync(join(root, "thoth-workspaces.yaml"), "utf8")).toBe(before);
expect(cli("validate", "--directory", root, "--typo", "VERY_SECRET_VALUE").status).toBe(2);
});
test("validation rejects directory mismatches, missing references and symlinks without following them", () => {
const root = directory();
cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice");
mkdirSync(join(root, "unlisted"));
expect(cli("validate", "--directory", root).report.issues.some((i: {code: string}) => i.code === "unlisted_directory")).toBe(true);
rmSync(join(root, "unlisted"), { recursive: true });
writeFileSync(join(root, "practice/workspace.yaml"), descriptor.replace("name: Practice", "name: Different"));
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("catalog_mismatch");
writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n source:\n type: filesystem\n uri: practice/evidence\n");
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("missing_reference");
symlinkSync(roots[roots.length - 1], join(root, "practice/evidence"), "dir");
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("unsafe_reference");
});
test("local Evidence checks references and YAML frontmatter, and explicitly defers canonical content validation", () => {
const root = directory();
cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice");
writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n schema_version: 2\n source:\n type: filesystem\n uri: practice/evidence\n patterns: ['curated/**/*.md']\n");
mkdirSync(join(root, "practice/evidence/curated/domain"), { recursive: true });
const evidence = join(root, "practice/evidence/curated/domain/rule.md");
writeFileSync(evidence, "---\nschema_version: 4\nid: evidence:rule\nkind: domain\nlanguage: en\npurposes: [sql_generation]\n---\n# Rule\n\n## Rule\nUse the order number.\n");
const checked = cli("validate", "--directory", root);
expect(checked.status).toBe(0);
expect(checked.report.workspaces).toEqual([{ id: "practice", evidence: "local-files" }]);
expect(checked.report.deferred_checks).toContain("practice:evidence-content-provenance-and-indexing");
writeFileSync(evidence, "---\nid: first\nid: VERY_SECRET_VALUE\n---\n# Rule\n");
const invalid = cli("validate", "--directory", root);
expect(invalid.status).toBe(1);
expect(invalid.report.issues[0].document).toBe("practice/evidence/curated/domain/rule.md");
expect(invalid.stdout).not.toContain("VERY_SECRET_VALUE");
writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n source:\n type: filesystem\n uri: practice/evidence\n patterns: [missing.md]\n");
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("missing_reference");
});
test("prepare creates documents accepted by the runtime and validate is repeatable without installation or services", () => {
const root = directory();
const prepared = cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice");
expect(prepared.stderr).toBe("");
expect(prepared.status).toBe(0);
expect(prepared.report.ok).toBe(true);
const catalog = readFileSync(join(root, "thoth-workspaces.yaml"), "utf8");
const descriptor = readFileSync(join(root, "practice/workspace.yaml"), "utf8");
expect(parseWorkspaceCatalogYaml(catalog).workspaces[0].id).toBe("practice");
expect(parseWorkspaceYaml(descriptor).evidence).toBeUndefined();
for (let index = 0; index < 2; index++) {
const checked = cli("validate", "--directory", root);
expect(checked.status).toBe(0);
expect(checked.report.workspaces).toEqual([{ id: "practice", evidence: "absent" }]);
}
expect(readFileSync(join(root, "thoth-workspaces.yaml"), "utf8")).toBe(catalog);
expect(readFileSync(join(root, "practice/workspace.yaml"), "utf8")).toBe(descriptor);
});
+10
View File
@@ -8,6 +8,11 @@ cp deploy/secrets/thothii.secrets.example deploy/secrets/thothii.secrets
chmod 600 deploy/secrets/thothii.secrets
```
For a normal local installation, `tht setup --complete` creates the active bundle at
`deploy/local/secrets/thothii.secrets` and creates the two Catalog password files beside it. The
generated `deploy/local/operator.env` contains only absolute paths to those files; never copy
secret values into `operator.env`.
The file uses strict `KEY=VALUE` lines (comments and blank lines are allowed). The supported
installation keys are `THT_MODEL_API_KEY`, `THT_DWH_API_KEY`, `THT_CA`, `THT_SSL_CA`,
`THT_OIDC_CLIENT_SECRET`, and `THT_AUTHENTIK_API_TOKEN`. Installation Model Catalog providers may
@@ -29,6 +34,11 @@ Session and metadata-generation runtimes read only the provider key named by
credential reference, not their execution lifecycle. Pi-owned authentication remains available only
to session-only built-in providers through `authentication.mode: pi_auth`.
Workspace database credentials are intentionally not part of this global bundle. Configure each
workspace's database binding, password/token, tunnel key and CA in Database Management; the
installation stores those values in its encrypted workspace secret store. The workspace Git
repository may declare database identity and Evidence, but must never contain these credentials.
Do not add vector or embedding endpoint credentials to the bundle. Active operator manuals use
internal Qdrant and Ollama services, so vector/embedding runtime endpoint secrets are not part of
the supported installation contract.
+41 -42
View File
@@ -1,64 +1,63 @@
# Install and first start
Use one complete procedure for a fresh installation:
Use the guided procedure for a fresh installation:
- [Italian manual installation](standalone-manual-it.md)
- [English manual installation](standalone-manual-en.md)
- [Italian guided installation](standalone-manual-it.md)
- [English guided installation](standalone-manual-en.md)
Both cover macOS, Windows through Ubuntu WSL2, and Linux. They use a Gitea clone,
protected local configuration and manual terminal commands, without an application
installer or launcher. See their verification matrix for tests still pending.
The procedure covers Windows through Ubuntu WSL2, macOS, and Linux including an Omarchy/Arch-like
host. It uses the application clone, one installation secret bundle, protected repository
credentials when needed, and a terminal command. No host Node.js, Python or Pi installation is
required.
## What must be ready
You need Docker with Compose, the host operator command `tht`, access to the workspace
repository, and the credentials and network routes for the configured DWH and model
providers. Pi runs inside the application runtime; no host Pi installation is needed.
You need Docker with Compose v2, Git, Bash, curl, OpenSSL and shasum. You also need access to the
workspace repository and the values supplied by its owner: repository URL/branch, DWH endpoint,
database/schema, transport, credentials or certificates, Evidence credentials when applicable,
and LLM provider/API-key information.
The stack includes `frontend`, `core`, `catalog-db`, `qdrant`, `embedding`, plus the
one-shot `embedding-model-init` and `catalog-migrate` services. DWH and generative-model
endpoints remain separate installation settings.
The workspace repository and the THothII application repository are different. A workspace
descriptor may declare Evidence, but database passwords and installation bindings are stored in
the installation Catalog, not in Git.
Secrets, certificates, Pi authentication and endpoint bindings are protected local files.
Do not commit them or copy the configuration of another machine unchanged.
## One guided command
## Follow the ordered procedure
After cloning THothII, checking prerequisites and installing tht, run:
The bilingual guides provide the exact commands for:
~~~
tht setup --complete --profile local --shell-mode full --shell-default-locale en
~~~
1. Cloning the selected revision and checking prerequisites.
2. Bootstrapping the native host command.
3. Preparing catalog passwords and using
`tht setup --profile local --shell-mode full --shell-default-locale en --configure-only`.
4. Completing model, authentication and workspace credentials.
5. Generating configuration, building images and explicitly running `catalog-migrate`.
6. Starting the installation and checking health and readiness.
Do not run setup alone as a substitute for that sequence. Migrations are not an
implicit effect of backend startup or `tht start`. Do not mix this installation's
descriptor/project with a different low-level Compose environment.
The first run creates protected placeholders under deploy/local/secrets/. Fill the required
credential files and rerun the same command. The command validates the local files and paths,
renders Compose, builds the images, starts catalog-db, runs catalog-migrate, starts the full
stack, and pulls/activates the workspace repository. Evidence source files declared by the
workspace are imported during activation.
For an already configured installation:
```sh
~~~
tht --installation /absolute/path/thothii-installation.yaml status
tht --installation /absolute/path/thothii-installation.yaml doctor --json
```
tht --installation /absolute/path/thothii-installation.yaml workspace test --json
~~~
`/health` checks application-process readiness. Doctor also checks configuration,
workspace, workflow and Pi prerequisites; a healthy web page alone does not prove
that a real database question can complete.
doctor --json is the non-destructive general core test. workspace test also probes the configured
database, Evidence, Qdrant and embedding service for every active workspace. It requires the
workspace database to have been configured in Database Management first.
## After startup
## Installer-only completion
Prepare [workspaces](../operations/workspaces.md), configure a database in
[Database Management](../operations/database-management.md), and complete the functional
checks in the installation guide before using real data.
The installer must still decide which LLMs and API keys are approved, configure and test each
workspace database, synchronize its schema, generate and consolidate descriptions, create Qdrant
entries, review naming-based FK suggestions alongside schema FKs, and load the approved
relationships. The final declaration of completeness requires green doctor and workspace test
results plus one real natural-language question completed through final SQL.
See [display mode and language](shell-and-language.md), [local authentication](authentication-local.md),
[OIDC](authentication-oidc.md) and [model configuration](../general/pi-configuration.md)
for later changes. Embedded portal integration is separate from a fresh standalone setup.
Migrations are part of setup --complete. Do not mix this installation’s descriptor or volumes with
a different Compose environment. Preserve the descriptor, credentials, Catalog and persistent
volumes; do not use docker compose down --volumes as a routine stop.
Use the installation's normal `tht start`, `tht stop` and diagnostic commands.
Preserve its descriptor, credentials, database and persistent volumes; do not use
`down --volumes` as a routine stop or upgrade.
See the guides for the Windows/macOS/Linux prerequisite matrix, workspace repository explanation,
secret layout and Gate A/Gate B acceptance checks.
+91
View File
@@ -0,0 +1,91 @@
# Installation preflight and release manifest
The native operator protocol is version 1. `installation preflight` is the independent
step-3 host check. `installation plan` repeats document validation, performs step-5
live checks and writes an owner-only JSON plan plus a separate owner-only `.key` file.
Neither command creates containers, imports bindings, modifies a database or invokes
model generation. Exit codes are 0 (checks passed), 1 (blocking error), and 2 (usage).
JSON stdout contains only the report. Checks carry stable `id`, `outcome`, `field`
and `action`; outcomes are `passed`, `error`, `warning`, `deferred-to-runtime`.
## Release manifest schema 1
The manifest is a local JSON file shipped with the verified operator/release bundle.
Required keys:
| Key | Contract |
| --- | --- |
| `schema_version` | `1` |
| `version` | Semantic release version, optionally prerelease |
| `revision` | 40 lowercase hexadecimal Git commit characters |
| `validator_protocol` | `1`; incompatible consumers refuse the manifest |
| `requirements` | `cpus`, `memory_bytes`, `disk_bytes`; at least 2 CPUs, 4 GiB Docker memory and 10 GiB installation filesystem space |
| `components` | Includes `pi`, `catalog-migrations`, `workspace-maintenance` |
| `images` | Exactly `core`, `frontend`, `catalog`, `qdrant`, `embedding`; each maps `linux/amd64` and/or `linux/arm64` to a `docker.io/...@sha256:...` **single-platform image digest** |
| `files` | Relative packaged resource paths to SHA-256; no traversal, links or absolute paths; maximum 256 files, 32 MiB per resource |
| `compose` | Ordered relative Compose file paths present in `files` for this release configuration |
Include the selected `deploy/compose.git-https.yaml` or `deploy/compose.git-ssh.yaml`
transport overlay in `files`. Standard transport overlays are resolved from the
release while absent in the installation directory; existing authored overrides
remain input files. Compose must resolve all eight services: core, frontend,
catalog-db, catalog-migrate, workspace-maintenance, qdrant, embedding and
embedding-model-init. The two maintenance services share the core digest; embedding
initialization shares the embedding digest. Source builds and undeclared services
are rejected in this prebuilt path. The explicit source path is a separate ticket.
`docker manifest inspect --verbose` checks each selected immutable image and its
platform without pulling layers. `docker compose config --format json` checks the
effective service configuration. Compose receives only Docker connection/trust,
proxy and executable-discovery host variables; application parameters come from
the prepared environment file. Raw Docker output is never copied into reports.
Each Docker command has a 15-second bound. No release is currently certified merely
because controlled manifest tests pass: publication and real pull acceptance belong
to the publication/execution tickets.
## External checks and bounds
- Git HTTPS: authenticated `GET /info/refs?service=git-upload-pack`, configured CA,
no redirects, selected branch advertised, 1 MiB response and 5-second bound.
Git SSH uses its prepared key/known-hosts and `git-upload-pack --advertise-refs`
with the same response/time bounds; no checkout or push occurs.
- PostgreSQL: the existing Catalog diagnostic adapter authenticates and reads
`current_database()` plus schema `USAGE`; no user tables are modified.
- REST database transport: existing Catalog diagnostic `GET` with the configured
bearer/API-key header and status validation. SSH database bindings cannot pass
this NL-to-SQL installation plan because runtime sessions do not support them.
- Evidence: local paths were already validated. HTTP performs bounded GET requests
and cancels response bodies; signed URL identities must match authored provenance.
S3 performs one `ListObjectsV2` request with `MaxKeys: 1`, no retries, explicit
file credentials and the canonical Evidence egress policy. These metadata requests
can incur normal remote-service request charges; they do not run LLM generation.
Each external request has a 5-second bound; the native database/Evidence helper
has a 60-second aggregate bound. Correct unavailable services before repeating.
- Explicit model endpoints: DNS/TCP/TLS origin reachability, without generating
tokens. Built-in endpoint resolution, provider authentication and model smoke
operations use the bundled Pi SDK at runtime; the report never claims those
operations have already passed.
No unreachable configured external dependency is converted to a deferred success.
Runtime obligations have explicit identities: `container-network`,
`catalog-initialization`, `pi-operation`, `local-embedding`,
`workspace-preprocessing`, `workspace-readiness`. These must be discharged by the
execution/readiness tickets before final success. Host disk inspection cannot prove
Docker Desktop VM free storage; its separate storage warning remains explicit.
## Input identity and freshness
The plan records normalized installation configuration, release digests, validator
build identity, verified input paths, local workspace content and its Git HEAD when
available (otherwise a content snapshot). Files are bounded to 32 MiB each and
256 MiB total; workspace/auth trees to 10,000 entries, without links or special files.
Credential contents are never serialized. An HMAC covers the private inputs and
plan using a separate random 32-byte owner-only key; no public unkeyed secret hash
is generated. Credential rotation invalidates the plan. Added/deleted/changed
workspace files invalidate it as well. Changes during the checks abort publication.
Plan output never overwrites an existing plan or key.
Execution and resumption must call `VerifyPlanInputs` **and repeat live checks and
credential reads before mutations**. A valid seal alone does not certify current
network availability, Docker state or runtime readiness. Keep both plan files
private and outside the workspace repository; they are installation-local artifacts.
+94
View File
@@ -0,0 +1,94 @@
# Pubblicare immagini e pacchetto operatore / Publish images and operator bundle
## Italiano
Questo comando è riservato al manutentore. L'utente finale scarica il pacchetto e
le immagini già compilati. La prima prerelease riguarda **Linux amd64**, utilizzato
da Ubuntu WSL2 su Windows e successivamente da Omarchy; non certifica ancora il
percorso completo di installazione o i collaudi manuali.
Prerequisiti del manutentore: Git, Node 24/npm, Go indicato in `tools/tht/go.mod`,
Docker con Buildx e capacità di eseguire Linux amd64, `tar`. Bun viene installato
dal lock npm e serve soltanto per compilare il pacchetto. Il commit da pubblicare
deve essere già disponibile sul repository Gitea pubblico.
1. Eseguire `docker login` sul computer di pubblicazione con un account autorizzato
a creare repository pubblici e pubblicare immagini nel namespace scelto.
Usare un credential store Docker: il token non va passato sulla riga di comando,
scritto nel repository o copiato nel pacchetto.
2. Predisporre nel credential helper Git l'accesso al repository Gitea con diritto
di creare rilasci e allegati. Il comando riusa quelle credenziali senza stamparle.
3. Installare le dipendenze del produttore con `cd backend && npm ci`, poi eseguire:
```bash
node scripts/publish-installation.mjs \
--revision COMMIT_GIA_PUBBLICATO \
--version 0.1.0-install-preview.1 \
--namespace tylconsulting \
--platforms linux/amd64 \
--output /percorso/privato/rilascio-0.1.0-install-preview.1
```
La directory di output deve essere nuova o vuota e avere un genitore esistente.
Diventa privata e contiene stato di ripresa, log del produttore, archivi e checksum.
Non è una directory di installazione. Il produttore usa un worktree temporaneo al
commit richiesto, così modifiche locali, segreti e workspace non entrano nelle build.
Il comando prepara `tylconsulting/thothii-core` e `tylconsulting/thothii-frontend`
come repository pubblici, costruisce e pubblica le immagini versionate, risolve i
digest di tutte le immagini e crea gli archivi del comando nativo con Compose e
risorse di inizializzazione. Catalog migration e workspace maintenance usano lo
stesso digest core. PostgreSQL, Qdrant e Ollama restano immagini upstream.
Prima di rendere pubblico il rilascio Gitea, vengono verificati pull anonimi delle
immagini, smoke test senza rete di core/frontend, checksum e download degli allegati.
Una build o un upload incompleto lascia il rilascio **in bozza**. Per riprovare,
rieseguire lo stesso comando con gli stessi parametri e la stessa directory.
Immagini già presenti devono appartenere allo stesso commit/versione; allegati
esistenti devono avere lo stesso checksum. Il produttore non sostituisce versioni
pubblicate con contenuti diversi. Conservare la directory fino al completamento.
Il risultato pubblico comprende `thothii-VERSION-linux-amd64.tar.gz` e
`SHA256SUMS.txt`. L'archivio contiene `bin/tht`, il validatore affiancato,
`release-manifest.json`, Compose, SQL/script di inizializzazione e guide. Non
contiene credenziali, dati dei workspace o database di esempio. La verifica su
questa macchina di pubblicazione non sostituisce il successivo collaudo Windows.
## English
This is a maintainer command. Consumers download precompiled images and the native
operator bundle. The first prerelease targets **Linux amd64** for Ubuntu WSL2 and
later Omarchy; full installation and real-host acceptance remain separate work.
The maintainer needs Git, Node 24/npm, the Go toolchain from `tools/tht/go.mod`,
Docker Buildx with Linux amd64 execution support, and `tar`. The npm lock supplies
Bun for producer builds only. Push the selected source commit to the public Gitea
repository before publication. Use Docker's credential store for `docker login`
and Git's credential helper for Gitea release/attachment permissions. Never pass
tokens as command arguments or include them in a checkout or archive.
From `backend`, run `npm ci`, then the command above with an explicit revision,
version, namespace, platforms and a new private output directory. A temporary Git
worktree isolates the selected commit. The producer publishes core/frontend to
Docker Hub and retains PostgreSQL, Qdrant and Ollama upstream. All runtime and
maintenance services use resolved immutable platform digests.
The Gitea release stays a draft until images, anonymous pulls, network-isolated
smoke checks and uploaded bundle checksums pass. An interrupted run can be retried
with the same arguments and output directory. Existing images must match the
source/version, and existing attachments must match their checksum; published
versions are not overwritten. Producer logs and retry state stay local.
Download the matching `.tar.gz` and `SHA256SUMS.txt` from the public Gitea prerelease,
verify the archive checksum, then extract it. Keep the two executables together.
The bundle needs no application checkout, Node, Bun, Python or compiler on the
consumer host. It carries the manifest, Compose and initialization assets, but no
installation credentials, workspace data or example databases. Linux arm64 can be
selected explicitly for later release work; it does not imply macOS acceptance.
Developer regression checks:
```bash
cd backend
node --test scripts/release-*.test.mjs
```
+362 -245
View File
@@ -1,324 +1,441 @@
# Manual standalone installation
# Guided standalone installation
[Versione italiana](standalone-manual-it.md)
This is the verification procedure for preparing THothII as a standalone application in `full`
mode on macOS, Windows, and Linux.
This is the fresh-machine installation procedure for THothII. THothII receives a natural-language
question, queries an enterprise database read-only, and guides the user through SQL review. The
core, PostgreSQL catalog, Qdrant, embedding service, and Pi run in Docker; Node.js, Python, and Pi
are not required on the host.
In this document, “standalone” means that the user does not need to install Node.js, Python or Pi
on the host: the application services and local semantic
services run through Docker. DWH and LLM providers remain external endpoints configured by the
installation; this is not an offline package.
## Prepare and validate workspace documents before starting the stack
This path uses no graphical installer, native launcher, or Docker Hub image. It starts from a Gitea
clone and uses explicit terminal commands. Publishing pre-built images is a later step.
The first two steps of the new flow work without Docker, Node, Python, Pi or an
installation descriptor. Use the platform bundle with **both** `tht` and
`tht-workspace-documents` in the same directory (`.exe` on Windows). Put that directory
on `PATH`, or invoke the absolute executable path. Older packages containing only
`tht` do not provide this capability. Maintainers can currently build the bundle;
publishing assets and Docker Hub images belongs to a later delivery step. The rest
of this guide still describes the existing installation path.
## Verification matrix
1. Choose a new directory outside the application checkout, with an existing parent:
| System | Recommended terminal | Runtime | Test architecture |
| --- | --- | --- | --- |
| macOS supported by the installed Docker Desktop version | Bash in Terminal | Docker Desktop | Apple Silicon (`arm64`) |
| Windows 11 | Ubuntu inside WSL2 | Docker Desktop with WSL2 integration | x64 (`amd64`) |
| Ubuntu Linux 22.04 or 24.04 | Bash | Docker Engine + Compose v2 | x64 (`amd64`) |
```sh
tht workspace prepare --directory ./my-workspaces --id practice --name "Practice" --language en
```
Intel macOS is excluded from the first verification campaign. ARM Linux may be tested when the
machine’s Docker runtime reports `arm64`, but it is not part of the minimum matrix.
This creates `thoth-workspaces.yaml`, `practice/workspace.yaml` and
`workspace-docs/practice/README.md`. Existing destinations, even empty ones, are
refused. No services start, Git is not initialized and no remote is contacted.
Example databases remain a deferred subproject. For a curator-supplied repository,
use a separate local copy and go straight to step 3; read access to the origin is
sufficient.
2. Edit the catalog (schema v1) and workspace descriptor (schema v4) at your own pace.
Keep `id`, `name` and optional `description` identical in both; the id must match
the directory name. Root directories must match catalog entries, except
`workspace-docs` and the local `.git` directory. Database connections, schema and
credentials belong to the installation Metadata Catalog. Evidence is optional
and initially absent.
3. Validate, correct the reported document/field, and repeat:
Documentation and Mac prerequisite checks have passed. Complete fresh installations on all three
systems remain pending; this matrix describes the tests to perform, not completed certification.
```sh
tht workspace validate --directory ./my-workspaces
tht workspace validate --directory ./my-workspaces --json
```
## Before you start
PowerShell uses the same arguments, for example
`C:\ThothII\bin\tht.exe workspace validate --directory C:\ThothII\my-workspaces`.
Validation changes no files. It rejects multiple/malformed YAML documents,
duplicate keys/ids, unknown fields, catalog/directory/descriptor mismatches,
missing local references and symbolic links. Fix the first error in each document
and repeat to reveal any subsequent errors.
You need:
Evidence `absent` is valid. Filesystem checks cover directories, accessibility and
literal references; standard Markdown selections also check declared size limits.
Evidence v2 requires `curated/` and syntactically valid YAML frontmatter. Local limits
are 1 MiB per document read and 100,000 entries per Evidence tree. Arbitrary patterns,
the complete curated-unit contract, provenance, HTTP/S3 access and indexing remain
explicit runtime checks. See the [Evidence guide](../evidence.md).
- access to the THothII Gitea repository and the workspace Git repository;
- Git;
- Docker Desktop on macOS and Windows, or Docker Engine with the Compose v2 plugin on Linux;
- Bash, `curl`, OpenSSL and `shasum` (Ubuntu package: `libdigest-sha-perl`);
- enough disk space to build the images and download the embedding model;
- the DWH and LLM endpoints, plus the credentials required by the installation.
JSON includes `schema_version`, `scope: local-documents`, `ok`, `workspaces`, `issues`
and `deferred_checks`. Issues identify document, field, code, correction and YAML
line where available, without printing document values. Exit statuses: `0` local
success, `1` documents/access/bundle need correction, `2` invalid arguments. Local
success does not certify semantic truth, connectivity or readiness. The Git revision
activated later must contain the checked documents; this command does not publish
uncommitted files or empty directories.
On Linux, the current user must be able to run Docker. If the system requires `sudo`, add the user
to the Docker group according to local policy and open a new session before continuing.
## Prepare and validate application documents
On Windows, run every command in this guide from Ubuntu under WSL2. In Docker Desktop, enable WSL2
integration for that distribution. Clone the project inside the WSL2 Linux filesystem, for example
under `~/src`, rather than under `/mnt/c`: this avoids slow builds and path/line-ending issues. Pi
does not need to be installed on the host.
Check the runtime before or immediately after cloning:
After validating workspaces, create a local directory **outside their repository**:
```sh
docker version
docker compose version
docker version --format '{{.Server.Arch}}'
tht installation prepare --directory ./my-installation
```
The last command must return `amd64`, `x86_64`, `arm64`, or `aarch64`.
This creates private, commented `thothii-installation.yaml`, `operator.env`,
`database-bootstrap.yaml` and `README.md`. The destination must be new and its parent
must exist. It starts no services and does not implicitly generate passwords.
## 1. Clone a project revision
1. Choose models and providers in the descriptor. The template proposes
`openai/gpt-4.1-mini` for interaction and `ollama/qwen3-embedding:0.6b` with 1024
dimensions for embedding. Edit these before setup. `modelCatalog.defaults.interaction`
must support sessions and metadata generation when the latter is configured.
The template omits optional metadata generation. See
[model configuration](../general/pi-configuration.md) for custom providers.
2. Replace the Git remote in both the descriptor and `operator.env`; keep branch and
transport consistent. Paths are absolute and machine-local. `operator.env` accepts
one literal `KEY=value` assignment per line, without duplicate keys or shell
interpolation. Credentials belong in referenced protected files.
3. Complete `database-bootstrap.yaml` with exactly one entry per workspace. A complete
direct-connection example is:
Use the project repository on Gitea:
```yaml
schemaVersion: 1
databases:
- workspaceId: practice
engine: postgres
databaseName: sales
schema: public
binding:
transport: postgres_direct
host: db.intranet
port: 5432
username: thoth_reader
secretFiles:
password: /private/path/my-installation/secrets/database-password
```
```sh
Use a read-only DWH account. `rest_api` requires `baseUrl`, `restPath`, `restAuth`
(`none`, `bearer`, `x-api-key`) and `secretFiles.apiKey` when authenticated.
`ssh_tunnel` requires `username`, `sshHost`, `sshPort`, `sshUsername`,
`sshTargetHost`, `sshTargetPort` and files `password`, `sshPrivateKey`,
`sshKnownHosts`; it supports Catalog diagnostics, not NL-to-SQL sessions.
`tlsCa` and `sshPrivateKeyPassphrase` are optional. Signed HTTP Evidence needs
`evidenceSecretFiles` with key `evidence.signed_urls`; static S3 credentials need
`evidence.access_key`, `evidence.secret_key` and optional `evidence.session_token`.
All values are private file paths. Workspace descriptors remain schema v4;
this bootstrap input is not a second runtime Catalog.
4. Explicitly generate technical credentials in the standard layout:
```sh
tht installation credentials --directory ./my-installation
```
This creates separate random Catalog runtime/migrator and administrator passwords,
`auth/auth.yaml`, `auth/users.yaml`, a `secrets/secrets.env` template and
`secrets/pi-auth.json`. Existing files are retained; invalid ones stop the command.
The initial administrator is `admin`; its password stays in private
`secrets/admin-password` and is never printed. The default is local authentication
at `http://localhost:8080`: review and edit `auth/auth.yaml` before validation.
This increment does not validate offline OIDC bootstrap for the existing path.
5. Fill the provider key in `secrets/secrets.env` and create the DWH password file.
For Git HTTPS supply the referenced credentials and CA files; empty credentials
are allowed for a public remote, and the CA file must be available. For SSH supply
a key and known_hosts and select the matching descriptor override. `pi_auth`
providers require prepared Pi credentials. Keep every secret outside workspace
Git with installer-only access (0600 on Unix, equivalent Windows ACLs).
6. Validate and repeat after each correction:
```sh
tht --installation /absolute/path/my-installation/thothii-installation.yaml installation validate --workspaces /absolute/path/my-workspaces --json
```
The default bootstrap is beside the descriptor; `--bootstrap PATH` selects another.
Validation changes no documents, generates no projections, uses no network and
writes no database. It rejects placeholders, inconsistencies, missing/non-private
files and secrets inside workspace Git. Reports identify document, field and
correction without secret values. Exit statuses: 0 local success, 1 corrections
needed, 2 invalid arguments.
Standard release Compose assets may still be absent at this stage; custom overrides
must already exist. Release assets, external connectivity, Catalog import and runtime
readiness remain explicit deferred checks. Success prepares the next preflight;
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
There are two separate repositories:
1. the application repository cloned by the user:
https://git.tylconsulting.it/mptyl/ThothII.git;
2. the workspace repository supplied by the curator/installer. It is not the THothII repository
and must not be cloned inside the application directory.
The workspace repository normally contains:
~~~
thoth-workspaces.yaml
<workspace-id>/workspace.yaml
<workspace-id>/evidence/** # when Evidence is declared
~~~
workspace.yaml contains workspace identity, language and optional Evidence source. By design it does
not contain database passwords. Database identity, transport (PostgreSQL, REST, or tunnel), user,
password, token and certificates are installation-local settings stored encrypted by the Catalog.
This prevents credentials from being committed to the workspace repository.
## 0. Machine prerequisites
### Windows
- Windows 10/11 with Docker Desktop running and the WSL2 backend enabled.
- Ubuntu in WSL2, with Docker Desktop integration enabled for that distribution.
- Git, Bash, curl, OpenSSL and shasum inside WSL2.
- Do not install Node.js, Python or Pi on the host for this procedure.
If WSL2 is not installed, use the company procedure or, in PowerShell:
~~~
wsl --install -d Ubuntu
~~~
Run all commands inside Ubuntu WSL2, in a Linux directory such as $HOME/src, not under /mnt/c.
scripts/install-tht.ps1 exists for advanced native PowerShell scenarios; use WSL2 for the
reproducible test.
### macOS
- Docker Desktop installed and running, with several GB free for images and the embedding model.
- Git, Bash, curl, OpenSSL and shasum.
- Intel and Apple Silicon Macs are supported when Docker Desktop supports the architecture
reported by the Docker server.
- Do not install Node.js, Python or Pi on the host for this procedure.
### Linux, including Omarchy
- Git, Bash, curl, OpenSSL and shasum.
- Docker Engine and the Docker Compose v2 plugin. On Omarchy, check first:
~~~
command -v docker
docker compose version
docker info
~~~
If Docker is missing, install Docker and Compose using the distribution-approved package procedure,
then start the service. On an Arch-like distribution the typical route is:
~~~
sudo pacman -S docker docker-compose
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"
~~~
After adding the group, open a new session and repeat docker info. Node.js, Python and Pi are not
needed on the host: they are in the Docker images.
On every system run:
~~~
bash scripts/check-standalone-prerequisites.sh
docker version --format '{{.Server.Arch}}'
~~~
The architecture must be amd64, x86_64, arm64, or aarch64. You also need access to the
application Gitea repository, the workspace repository URL/branch and credentials, container
reachability to DWH/LLM endpoints, and the credentials, tokens or certificates associated with
the databases.
## 1. What to clone
Clone only the application:
~~~
mkdir -p "$HOME/src"
cd "$HOME/src"
git clone https://git.tylconsulting.it/mptyl/ThothII.git
cd ThothII
git rev-parse --short HEAD
```
~~~
For an SSH clone, when the key is already authorized on Gitea:
Record the revision. tht setup --complete downloads the workspace repository into a persistent
Docker volume using the URL, branch and transport supplied during setup.
```sh
git clone git@git.tylconsulting.it:mptyl/ThothII.git
```
Record the hash printed by `git rev-parse` for a repeatable test. In a later campaign, use the
maintainer-approved revision/tag rather than implicitly following a mutable `main` branch.
## 2. Check prerequisites and install the operator command
## 2. Install the terminal command
From the clone root:
```sh
~~~
bash scripts/check-standalone-prerequisites.sh
export PATH="$HOME/.local/bin:$PATH"
mkdir -p "$HOME/.local/bin"
THT_INSTALL_DIRECTORY="$HOME/.local/bin" bash scripts/install-tht.sh
export PATH="$HOME/.local/bin:$PATH"
tht version
```
~~~
`install-tht.sh` bootstraps only the native `tht` operator command; it does not install a desktop
version of THothII. It uses the repository’s Docker builder, installs the binary for the current
terminal environment, and installs it in the user directory. Persist `$HOME/.local/bin` in your
shell PATH for new terminals too. An existing `tht` in this directory will be updated.
tht is the only native component to install. It builds the binary with Docker and orchestrates
Compose; it is not a second application runtime.
On Windows, run these commands inside WSL2. The installed `tht` binary is the Linux binary inside
WSL2; the application runtime remains Docker Desktop. Do not use `scripts/install-tht.ps1` as the
primary path for this test.
## 3. Prepare a few secrets and run complete setup
## 3. Configure and start the local installation
The first execution creates protected placeholders under deploy/local/secrets/ and stops if a
required credential is missing. Fill in the requested files and rerun the same command; compatible
configuration files are reused.
Run the remaining blocks in one Bash session from the physical clone root (`pwd -P`).
First create two distinct catalog passwords, preserving any existing files:
~~~
tht setup --complete --profile local --shell-mode full --shell-default-locale en
~~~
```bash
umask 077
mkdir -p deploy/local/secrets
for name in catalog-runtime-password catalog-migrator-password; do
target="deploy/local/secrets/$name"
if [ ! -e "$target" ]; then
(set -C; openssl rand -hex 32 > "$target") || exit 1
fi
done
export THT_CATALOG_RUNTIME_PASSWORD_SOURCE="$(pwd -P)/deploy/local/secrets/catalog-runtime-password"
export THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="$(pwd -P)/deploy/local/secrets/catalog-migrator-password"
```
The setup asks only for information the computer cannot know:
Do not regenerate passwords for an initialized catalog. Configure without starting services:
```sh
tht setup --profile local --shell-mode full --shell-default-locale en --configure-only
```
Answer the prompts as follows:
| Prompt | Value or rule |
| Request | What to provide |
| --- | --- |
| Installation ID | `local`, unless one clone hosts multiple installations |
| Deployment profile | `local` |
| DWH API endpoint | An `http(s)` URL without user, password, query, or fragment; may be empty for a smoke-only test |
| LLM API endpoint | An `http(s)` URL without credentials; may be empty for a smoke-only test |
| Workspace repository URL | The workspace repository URL, not the THothII source clone |
| Workspace branch | Normally `main` |
| Workspace access | `ssh` with a deploy key, or `https` with a protected credential file |
| File paths | Accept the default paths under `deploy/local/secrets/` for the first test |
| Secret templates | Answer `yes` when protected files do not exist yet |
| Authentication | Configure the local login required by the installation; never put passwords on a command line |
| Workspace repository | Data/configuration repository URL, not ThothII.git |
| Branch | normally main |
| Access | ssh with key and known_hosts, or https with credential file and CA |
| DWH/LLM URL | endpoint without a token in the URL |
| Local login | initial user and password requested by the prompt |
The generated configuration is local and ignored by Git:
The setup generates random Catalog passwords and writes their paths, never their values, to
operator.env. It runs docker compose config, builds images, starts the Catalog, runs
catalog-migrate, starts the stack, and pulls the workspace repository. The pull also activates
declared Evidence; at minimum source files present in the workspace are materialized locally.
```text
deploy/local/thothii-installation.yaml
deploy/local/operator.env
deploy/local/auth/
deploy/local/secrets/
```
### The file the user fills in
Edit secrets only in protected local files; never commit them. `deploy/env/local.env.example` is a tracked reference; the
generated path `deploy/local/operator.env` is the active path for this installation.
The main file is:
### Complete protected files
~~~
deploy/local/secrets/thothii.secrets
~~~
If setup created blank templates, enter the values with a local editor:
Add only NAME=VALUE lines needed by modelCatalog and installation adapters, such as an LLM API key
(DEEPSEEK_API_KEY, OPENAI_API_KEY, or the key declared by the catalog) and, when applicable,
THT_DWH_API_KEY. Allowed names are documented in deploy/secrets/README.md. Never put tokens in
URLs, the repository, or copied shell commands.
```sh
chmod 600 deploy/local/secrets/*
"${EDITOR:-vi}" deploy/local/secrets/thothii.secrets
```
Two distinctions prevent common errors:
The bundle must contain only `KEY=VALUE` lines for credentials actually used by `modelCatalog`. The
allowed names and credential boundary are documented in the local file
`deploy/secrets/README.md`. Do not put tokens in URLs, the YAML
descriptor, the Git repository, or commands copied into the shell.
- when the catalog uses pi_auth, the LLM token belongs in the Pi pi-auth.json file created by
setup; {} is only a placeholder and does not enable a model;
- workspace database credentials (PostgreSQL password, REST API token, tunnel SSH key,
known_hosts, CA) do not belong in the workspace repository. Enter them per workspace in
Database Management, which stores them encrypted in the Catalog. The workspace declares
database/schema and transport; the installer must obtain the actual values from the database owner.
For SSH workspace access, also provide the private key and `known_hosts` file requested by setup.
For HTTPS access, provide the Git credential file and any required CA. Both must remain protected
and outside version control.
A private workspace repository also needs the Git files required by its transport: an SSH key and
known_hosts, or an HTTPS credential file and CA. These are transport files, not a second bundle to
commit. To minimize manual files, use SSH with an already-authorized deploy key.
Before starting, complete these additional configuration steps:
## 4. Automatic checks and terminal tests
1. Add `THT_CATALOG_RUNTIME_PASSWORD_SOURCE` and `THT_CATALOG_MIGRATOR_PASSWORD_SOURCE` to
`deploy/local/operator.env`, with the same absolute paths exported above. Setup does not persist
these two variables. Store paths, not passwords.
2. Replace the descriptor's generic `modelCatalog` with the approved provider/model configuration.
The generated defaults do not replicate the existing Mac. See [Pi/model configuration](../general/pi-configuration.md)
and the local example `deploy/psd/thothii-installation.yaml.example`.
3. Populate the keys referenced by `authentication.apiKeyEnv` in `thothii.secrets`. Providers using
`pi_auth` need valid credentials at `PI_AUTH_FILE`; the `{}` template is not authentication.
4. Complete the workspace Git files. SSH requires an authorized deploy key and verified known-hosts;
HTTPS requires the credential file and CA bundle expected by the overlay. Blank templates cannot
provide repository access.
Setup verifies files, permissions, descriptor, Compose, Docker, authentication, services, Pi and
the workspace. After startup, run these commands at any time:
After editing generated configuration, do not rerun setup: it rejects different existing content.
Generate the projections and run the explicit migration below. Use `THT_GIT_ACCESS=https` if that
was selected during setup. This block targets the fresh `local` descriptor with only the Git overlay;
custom installations must include their extra descriptor overlays in the same order.
~~~
INSTALLATION="$PWD/deploy/local/thothii-installation.yaml"
tht --installation "$INSTALLATION" doctor --json
tht --installation "$INSTALLATION" workspace pull --json
tht --installation "$INSTALLATION" workspace test --json
~~~
```bash
INSTALLATION="$(pwd -P)/deploy/local/thothii-installation.yaml"
tht --installation "$INSTALLATION" installation generate
THT_PROJECT="thothii-$(printf '%s' "$INSTALLATION" | shasum -a 256 | cut -c 1-12)"
THT_GIT_ACCESS=ssh
compose=(
docker compose --project-name "$THT_PROJECT" --project-directory "$(pwd -P)"
--env-file "$(pwd -P)/deploy/local/operator.env"
-f compose.yaml -f deploy/compose.local.yaml
-f "deploy/compose.git-$THT_GIT_ACCESS.yaml"
-f deploy/local/generated/compose.models.yaml
)
"${compose[@]}" config --quiet
"${compose[@]}" build core frontend
"${compose[@]}" up -d catalog-db
"${compose[@]}" run --rm catalog-migrate
tht --installation "$INSTALLATION" start
```
workspace test checks, for every active workspace, database binding and credentials, Evidence,
Qdrant, and the embedding service. It exits non-zero when the database binding is missing or a
connection is unusable. Before running it, the installer must configure the database in Database
Management: the workspace repository cannot contain the password by itself.
Stop if a command fails. The project name matches the hash used by `tht`, preserving volume
identity. `catalog-migrate` applies Catalog and Memory migrations; `tht start` does not run it
automatically. Initial embedding-model download may take time. Use this installation-specific
sequence, not `run-stack.sh` with a different environment/project name.
doctor --json is the repeatable, non-destructive core verification. The final functional test must
also open http://127.0.0.1:8080, sign in, and complete a real question through final SQL.
## 4. Verify the installation
## Activities only the installer can complete
The descriptor generated for the default ID is:
The procedure automates bootstrap, but it cannot invent enterprise decisions or authorizations.
The installer must complete and record:
```sh
INSTALLATION="$(pwd -P)/deploy/local/thothii-installation.yaml"
test -f "$INSTALLATION"
bash scripts/verify-standalone-install.sh "$INSTALLATION"
```
1. usable LLMs, the modelCatalog, and linked API keys; then run tht pi test and tht doctor;
2. database configuration, connection test, schema synchronization, and description generation;
3. human consolidation of generated descriptions;
4. Qdrant semantic entries through workspace preprocess run;
5. naming-based FK suggestions as a complement to schema FKs, human review, and loading approved
relationships into Qdrant;
6. recurring tht doctor --json and tht workspace test --json checks;
7. one real question completed successfully without connection or model errors.
The verifier is read-only: it runs `tht doctor --json` and `tht status` without restarting the
stack, regenerating configuration, or printing secret contents.
Configuration is complete only when all applicable activities are done, decisions are recorded, and
the two terminal tests are green. The core is usable only after the real question, not merely
because the frontend answers /health.
### Gate A — platform smoke test on all three computers
## Gate A and Gate B
Record the following for each machine:
### Gate A — platform
```sh
~~~
uname -a
docker version --format '{{.Server.Version}} {{.Server.Arch}}'
tht version
bash scripts/check-standalone-prerequisites.sh
bash scripts/verify-standalone-install.sh "$INSTALLATION"
```
~~~
The gate passes when the clone is intact, Docker and Compose are reachable, `tht doctor` is OK, the
stack is running, and the frontend responds at the default local URL `http://127.0.0.1:8080`.
Doctor also checks workspace and Pi: record their failures separately rather than labeling every
failure as a platform problem. Check HTTP readiness with:
### Gate B — usability
```sh
curl --fail --silent --show-error http://127.0.0.1:8080/health
```
### Gate B — functional verification
Run this on at least one machine with available endpoints and credentials:
First follow [Workspace operations](../operations/workspaces.md) to import/prepare the workspace
and configure the Database and local binding. The source clone does not transfer catalog data,
secrets or sessions from the Mac. Connect any VPN required by DWH/model endpoints and verify
that their names are reachable from containers too.
1. open `http://127.0.0.1:8080`;
2. sign in with the configured local account;
3. verify that the configured workspace is readable;
4. start a real question and complete the review gates through final SQL;
5. stop and restart the installation, then run `verify-standalone-install.sh` again.
A Gate B failure involving DWH, the LLM provider, workspace Git, or credentials does not by itself
prove a Docker portability problem: record the failed endpoint or component separately.
## Daily lifecycle
Use the explicit descriptor when more than one installation may be discoverable:
```sh
INSTALLATION="$PWD/deploy/local/thothii-installation.yaml"
tht --installation "$INSTALLATION" status
tht --installation "$INSTALLATION" start
tht --installation "$INSTALLATION" start --build
tht --installation "$INSTALLATION" logs
~~~
tht --installation "$INSTALLATION" doctor --json
tht --installation "$INSTALLATION" stop
```
tht --installation "$INSTALLATION" workspace test --json
curl --fail --silent --show-error http://127.0.0.1:8080/health
~~~
Use `start --build` after source changes or to rebuild images from the current checkout. `stop`
preserves volumes, sessions, the catalog, Pi state, Qdrant data, and the embedding model. Do not
use `docker compose down --volumes` during a normal test: it is destructive and removes local data.
For upgrades requiring migrations, follow the release runbook before starting the new application.
Then run a real question and stop/restart with tht stop and tht start. Do not use docker compose
down --volumes: it deletes the Catalog, sessions, Qdrant data and the embedding model.
## Quick diagnosis
| Symptom | Check |
| Symptom | Action |
| --- | --- |
| `Docker Engine is not reachable` | start Docker Desktop or the Docker service and rerun `docker info` |
| Windows sees Docker but Bash fails | run the guide inside Ubuntu WSL2 and enable that distribution in Docker Desktop |
| `tht: command not found` | open a new shell and check `command -v tht`; rerun the bootstrap if needed |
| line-ending or executable-script errors | use a clone in the WSL2/Linux filesystem and rerun `bash scripts/...` |
| unsupported architecture | check `docker version --format '{{.Server.Arch}}'`; the test requires `amd64` or `arm64` |
| missing descriptor or env file | use `deploy/local/...` generated by `tht setup`, not an arbitrary copied file |
| healthy stack but workflow failure | check external URLs, the credential bundle, workspace Git, and authentication separately |
| data appears missing | check that `down --volumes` was not used; `stop` does not remove volumes |
## Acceptance checklist
- [ ] The clone comes from the expected Gitea repository and the revision is recorded.
- [ ] Docker Desktop/Engine and Compose v2 are available.
- [ ] The runtime reports an allowed architecture.
- [ ] `tht` was built from the repository and responds to `tht version`.
- [ ] Setup uses `profile: local`, `shell.mode: full`, and `shell.defaultLocale: en`.
- [ ] The descriptor, `operator.env`, authentication, and secrets exist only under `deploy/local/`.
- [ ] No secret appears in Git, URLs, public YAML, or recorded commands.
- [ ] Gate A passes on Apple Silicon macOS, x64 Windows WSL2, and x64 Linux.
- [ ] Gate B runs on at least one machine with DWH and LLM available.
- [ ] Stop/start and final verification complete without deleting volumes.
## Out of scope for this release
The following remain future work:
- publishing pre-built images on Docker Hub;
- reducing prompts through a dedicated non-interactive configuration;
- creating DMG, MSI/EXE, AppImage, or other native installers;
- providing an offline runtime or bundling a local DWH/LLM into the application.
| Docker Engine is not reachable | start Docker Desktop or systemctl and repeat docker info |
| Omarchy cannot find docker | install Docker/Compose, enable the service and open a new session |
| Windows sees Docker but Bash fails | use Ubuntu WSL2 and enable its Docker Desktop integration |
| workspace pull fails | check URL, branch, key/credential file and known_hosts from the container |
| workspace test reports a missing binding | configure database, token/password and CA in Database Management |
| Pi is not ready | fill pi-auth.json or the key declared by modelCatalog, then run tht pi test |
## Related documents
- [Install and first start](first-start.md)
- [Shell and localization](shell-and-language.md)
- [Workspace operations](../operations/workspaces.md)
- `deploy/secrets/README.md` (runtime secrets)
- [Database Management](../operations/database-management.md)
- [Model configuration](../general/pi-configuration.md)
- deploy/secrets/README.md
Publishing images on Docker Hub and native DMG/MSI/AppImage installers remain later work: this
procedure starts from the Gitea clone and does not require pre-published Docker Hub images.
+377 -252
View File
@@ -1,329 +1,454 @@
# Installazione manuale standalone
# Installazione standalone guidata
[English version](standalone-manual-en.md)
Questa è la procedura di prova per predisporre THothII come applicazione autonoma in modalità
`full` su macOS, Windows e Linux.
Questa è la procedura per installare THothII da zero. THothII riceve una domanda in linguaggio
naturale, interroga in sola lettura un database aziendale e accompagna l’utente nella revisione
della SQL risultante. Il core, il catalogo PostgreSQL, Qdrant, il servizio di embedding e Pi vengono
eseguiti in Docker; sul computer non servono Node.js, Python o Pi.
In questo documento “autonoma” significa che l’utente non deve installare Node.js, Python o Pi
sull'host: i servizi applicativi e i servizi semantici
locali vengono eseguiti con Docker. DWH e provider LLM restano endpoint esterni configurati
dall’installazione; questa procedura non è un pacchetto offline.
## Preparazione e verifica dei workspace prima dello stack
Il percorso non usa installer grafici, launcher nativi o immagini Docker Hub. Si parte da un clone
Gitea e si usano comandi espliciti da terminale. La pubblicazione di immagini pre-costruite è una
fase successiva.
I primi due passi del nuovo percorso funzionano senza Docker, Node, Python, Pi o
un file di installazione. Usare il bundle della propria piattaforma con **entrambi**
gli eseguibili `tht` e `tht-workspace-documents` nella stessa cartella (`.exe` su
Windows). Aggiungere la cartella al `PATH`, oppure usare il percorso completo.
Il vecchio pacchetto con il solo `tht` non contiene questa capacità. Il bundle è
attualmente producibile dal manutentore; pubblicazione degli asset e immagini
Docker Hub appartengono a una fase successiva. Il resto della guida descrive ancora
il percorso di installazione esistente.
## Matrice di verifica
1. Scegliere una cartella nuova, esterna all'applicazione, con padre già esistente:
| Sistema | Terminale raccomandato | Runtime | Architettura della prova |
| --- | --- | --- | --- |
| macOS supportato dalla versione Docker Desktop installata | Bash nel Terminale | Docker Desktop | Apple Silicon (`arm64`) |
| Windows 11 | Ubuntu dentro WSL2 | Docker Desktop con integrazione WSL2 | x64 (`amd64`) |
| Linux Ubuntu 22.04 o 24.04 | Bash | Docker Engine + Compose v2 | x64 (`amd64`) |
```sh
tht workspace prepare --directory ./miei-workspace --id pratica --name "Pratica" --language it
```
Intel macOS non fa parte della prima campagna di verifica. ARM Linux può essere provato quando il
runtime Docker della macchina restituisce `arm64`, ma non è un requisito della matrice minima.
Sono creati `thoth-workspaces.yaml`, `pratica/workspace.yaml` e
`workspace-docs/pratica/README.md`. Una destinazione esistente, anche vuota, viene
rifiutata. Nessun servizio viene avviato; Git non viene inizializzato, nessun remoto
viene contattato. I database di esempio restano un sottoprogetto differito. Per
un repository fornito dal curatore, usare una copia locale separata e passare al
punto 3: è sufficiente accesso in lettura all'origine.
2. Modificare con calma catalogo (schema v1) e descrittore workspace (schema v4).
Mantenere uguali `id`, `name` e l'eventuale `description` nei due file; l'id deve
coincidere con la cartella. Ogni cartella alla radice deve corrispondere a un
workspace elencato, salvo `workspace-docs` e la directory locale `.git`.
Connessioni, schema e credenziali dei database appartengono al Metadata Catalog
dell'installazione. Le Evidence sono facoltative e inizialmente assenti.
3. Verificare, correggere il documento/campo indicato e ripetere:
Le verifiche documentali e dei prerequisiti Mac sono passate. Le installazioni complete da zero
sui tre sistemi restano da eseguire: la matrice indica le prove previste, non una certificazione.
```sh
tht workspace validate --directory ./miei-workspace
tht workspace validate --directory ./miei-workspace --json
```
## Cosa serve prima di iniziare
Su PowerShell usare gli stessi argomenti, ad esempio
`C:\ThothII\bin\tht.exe workspace validate --directory C:\ThothII\miei-workspace`.
Il controllo non modifica file. Rifiuta YAML multipli o malformati, chiavi/id
duplicati, campi sconosciuti, incoerenze tra catalogo, cartelle e descrittori,
riferimenti locali mancanti e link simbolici. Correggere il primo errore del
documento e ripetere per vedere eventuali errori successivi.
Servono:
Evidence `absent` è valido. Per filesystem si verificano directory, accessibilità e
riferimenti letterali; per le selezioni Markdown standard anche i limiti dichiarati.
Evidence v2 richiede `curated/` e frontmatter con sintassi YAML valida. I limiti locali
sono 1 MiB per documento letto e 100.000 elementi per albero Evidence. Pattern
arbitrari, contratto completo delle unità curate, provenienza, accesso HTTP/S3 e
indicizzazione restano controlli runtime espliciti. Vedere la [guida Evidence](../evidence.md).
- accesso al repository Gitea di THothII e al repository Git dei workspace;
- Git;
- Docker Desktop su macOS e Windows, oppure Docker Engine con il plugin Compose v2 su Linux;
- Bash, `curl`, OpenSSL e `shasum` (su Ubuntu, pacchetto `libdigest-sha-perl`);
- spazio disco sufficiente per compilare le immagini e scaricare il modello di embedding;
- gli endpoint DWH e LLM, più le credenziali che l’installazione deve usare.
Il JSON espone `schema_version`, `scope: local-documents`, `ok`, `workspaces`, `issues`
e `deferred_checks`. I problemi riportano documento, campo, codice, correzione e,
quando disponibile, riga YAML, senza stampare i valori del documento. Exit status:
`0` successo locale, `1` documenti/accesso/bundle da correggere, `2` argomenti errati.
Il successo locale non certifica verità semantica, connettività o readiness. La
revisione Git attivata in seguito deve contenere i documenti verificati; il comando
non pubblica file non committati o directory vuote.
Su Linux l’utente corrente deve poter eseguire Docker. Se il sistema richiede `sudo`, aggiungere
l’utente al gruppo Docker secondo la policy locale e aprire una nuova sessione prima di continuare.
## Predisporre e verificare i documenti applicativi
Su Windows usare Ubuntu in WSL2 per tutti i comandi di questa guida. In Docker Desktop attivare
l’integrazione WSL2 per quella distribuzione. Clonare il progetto nel filesystem Linux di WSL2,
per esempio sotto `~/src`, e non sotto `/mnt/c`: si evitano rallentamenti e problemi di permessi o
line ending. Non è necessario installare Pi sull’host.
Verificare il runtime prima del clone o subito dopo:
Dopo la verifica dei workspace, creare una cartella locale **esterna al loro repository**:
```sh
docker version
docker compose version
docker version --format '{{.Server.Arch}}'
tht installation prepare --directory ./mia-installazione
```
L’ultima istruzione deve restituire `amd64`, `x86_64`, `arm64` o `aarch64`.
Il comando crea file privati commentati: `thothii-installation.yaml`, `operator.env`,
`database-bootstrap.yaml` e `README.md`. La cartella deve essere nuova e il padre
deve esistere. Non avvia servizi e non genera implicitamente password.
## 1. Clonare una revisione del progetto
1. Nel descrittore, scegliere modelli e provider. Il template propone
`openai/gpt-4.1-mini` per l'interazione e `ollama/qwen3-embedding:0.6b` con 1024
dimensioni per l'embedding. Sono valori modificabili, non una selezione richiesta
durante il setup. `modelCatalog.defaults.interaction` deve essere utilizzabile
nelle sessioni e anche nella generazione metadati, se quest'ultima è configurata.
Il template omette la generazione metadati, che è facoltativa. Consultare la
[configurazione dei modelli](../general/pi-configuration.md) per provider personalizzati.
2. Sostituire il remoto Git sia nel descrittore sia in `operator.env`; mantenere
coerenti branch e trasporto. I percorsi sono assoluti e riferiti a questa macchina.
`operator.env` accetta una sola assegnazione letterale `KEY=value` per riga, senza
duplicati o interpolazioni shell. Le credenziali restano nei file referenziati.
3. Compilare `database-bootstrap.yaml`: una voce per ciascun workspace, senza
duplicati. Questo esempio mostra il contratto completo di un collegamento diretto:
Usare il repository di progetto su Gitea:
```yaml
schemaVersion: 1
databases:
- workspaceId: pratica
engine: postgres
databaseName: vendite
schema: public
binding:
transport: postgres_direct
host: db.intranet
port: 5432
username: thoth_reader
secretFiles:
password: /percorso/privato/mia-installazione/secrets/database-password
```
```sh
Usare le credenziali di un utente DWH in sola lettura. Per `rest_api`, il binding
richiede `baseUrl`, `restPath` e `restAuth` (`none`, `bearer`, `x-api-key`); quando
serve autenticazione, aggiungere `secretFiles.apiKey`. `ssh_tunnel` richiede
`username`, `sshHost`, `sshPort`, `sshUsername`, `sshTargetHost`, `sshTargetPort` e
i file `password`, `sshPrivateKey`, `sshKnownHosts`; abilita diagnostica Catalog,
non sessioni NL→SQL. Sono facoltativi `tlsCa` e `sshPrivateKeyPassphrase`.
Le Evidence HTTP firmate richiedono `evidenceSecretFiles` con chiave
`evidence.signed_urls`; S3 con credenziali statiche richiede `evidence.access_key`
e `evidence.secret_key`, con `evidence.session_token` facoltativo. Tutti i valori
sono percorsi di file privati. I workspace rimangono nello schema v4: il bootstrap
è un input iniziale, non un secondo Catalog runtime.
4. Generare esplicitamente le credenziali tecniche nel layout standard:
```sh
tht installation credentials --directory ./mia-installazione
```
Sono creati password casuali separate per Catalog runtime/migrator e amministratore,
il relativo `auth/auth.yaml` con `auth/users.yaml`, un template `secrets/secrets.env`
e `secrets/pi-auth.json`. I file esistenti vengono conservati; se non validi, il
comando si ferma. L'amministratore iniziale è `admin`, la password è nel file
privato `secrets/admin-password` e non viene stampata. Il default è autenticazione
locale con URL pubblico `http://localhost:8080`: verificare e, se necessario,
modificare `auth/auth.yaml` prima del controllo. Questo incremento non valida il
bootstrap OIDC del percorso esistente.
5. Inserire la chiave del provider in `secrets/secrets.env` e creare il file della
password DWH. Per HTTPS Git fornire i file referenziati per credenziali e CA:
credenziali vuote sono ammesse per un remoto pubblico, la CA deve essere disponibile.
Per SSH fornire chiave e known_hosts, scegliendo il relativo override nel descrittore.
I provider `pi_auth` richiedono credenziali Pi già preparate. Conservare tutti
questi file fuori dal repository workspace e proteggere l'accesso al solo utente
installatore (0600 su Unix, ACL equivalenti su Windows). Non committarli.
6. Verificare e ripetere dopo ogni correzione:
```sh
tht --installation /percorso/assoluto/mia-installazione/thothii-installation.yaml installation validate --workspaces /percorso/assoluto/miei-workspace --json
```
Il bootstrap viene cercato accanto al descrittore; `--bootstrap PERCORSO` ne
seleziona uno diverso. Il controllo non cambia documenti, non genera proiezioni,
non usa la rete e non scrive database. Rifiuta placeholder, incoerenze, file
mancanti/non privati e segreti situati nel repository workspace. Il report indica
documento, campo e correzione senza valori riservati. Exit status: 0 successo
locale, 1 correzioni necessarie, 2 argomenti errati.
Gli asset Compose standard del rilascio possono ancora mancare in questa fase;
gli override personalizzati devono già esistere. Il report distingue i controlli
differiti: asset del rilascio, connettività esterna, import Catalog e readiness.
Un esito positivo prepara il successivo preflight: non autorizza a saltare tali
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
Servono due repository distinti:
1. il repository dell’applicazione, che l’utente clona:
https://git.tylconsulting.it/mptyl/ThothII.git;
2. il repository dei workspace, indicato dal curatore/installatore. Non è il repository di
THothII e non va clonato manualmente nella directory dell’applicazione.
Il repository workspace contiene il catalogo e una directory per ogni workspace, normalmente:
~~~
thoth-workspaces.yaml
<workspace-id>/workspace.yaml
<workspace-id>/evidence/** # se il workspace dichiara Evidence
~~~
Il file workspace.yaml descrive identità, lingua e, opzionalmente, la sorgente Evidence. Per scelta
architetturale non contiene password del database. L’identità del database, il trasporto
(PostgreSQL, REST o tunnel), username, password, token e certificati sono configurazione locale
dell’installazione, conservata cifrata dal Catalog. Questo evita di committare credenziali nel
repository workspace.
## 0. Prerequisiti della macchina
### Windows
- Windows 10/11 con Docker Desktop avviato e backend WSL2 abilitato.
- Ubuntu in WSL2, con integrazione Docker Desktop abilitata per quella distribuzione.
- Git, Bash, curl, OpenSSL e shasum nella distribuzione WSL2.
- Non installare Node.js, Python o Pi sull’host per questa procedura.
In PowerShell, se WSL2 non esiste ancora, usare la procedura aziendale oppure:
~~~
wsl --install -d Ubuntu
~~~
Eseguire poi tutti i comandi dentro Ubuntu WSL2, in una directory Linux come $HOME/src, non sotto
/mnt/c. Il percorso nativo scripts/install-tht.ps1 esiste per scenari PowerShell avanzati; per la
prova riproducibile usare WSL2.
### macOS
- Docker Desktop installato, avviato e con alcuni GB liberi per immagini e modello di embedding.
- Git, Bash, curl, OpenSSL e shasum.
- Sono supportati Mac Intel e Apple Silicon se Docker Desktop supporta l’architettura restituita
dal Docker server.
- Non installare Node.js, Python o Pi sull’host per questa procedura.
### Linux, incluso Omarchy
- Git, Bash, curl, OpenSSL e shasum.
- Docker Engine e il plugin Docker Compose v2. Su Omarchy verificare prima:
~~~
command -v docker
docker compose version
docker info
~~~
Se Docker manca, installare Docker e Compose con il gestore pacchetti/procedura approvata dalla
distribuzione, poi avviare il servizio. Su una distribuzione Arch-like il percorso tipico è:
~~~
sudo pacman -S docker docker-compose
sudo systemctl enable --now docker
sudo usermod -aG docker "$USER"
~~~
Dopo l’aggiunta al gruppo aprire una nuova sessione e ripetere docker info. Non installare Node.js,
Python o Pi sull’host: sono dentro le immagini Docker.
Su tutti i sistemi il controllo finale è:
~~~
bash scripts/check-standalone-prerequisites.sh
docker version --format '{{.Server.Arch}}'
~~~
L’architettura deve essere amd64, x86_64, arm64 o aarch64. Servono inoltre accesso al repository
Gitea dell’applicazione, URL/branch e credenziali del repository workspace, raggiungibilità dal
container degli endpoint DWH/LLM e le credenziali, token o certificati associati ai database.
## 1. Cosa clonare
Clonare solo l’applicazione:
~~~
mkdir -p "$HOME/src"
cd "$HOME/src"
git clone https://git.tylconsulting.it/mptyl/ThothII.git
cd ThothII
git rev-parse --short HEAD
```
~~~
Per un clone SSH usare, se la chiave è già autorizzata su Gitea:
Annotare la revisione. Il repository workspace verrà scaricato da tht setup --complete dentro un
volume Docker persistente, usando URL, branch e trasporto indicati durante il setup.
```sh
git clone git@git.tylconsulting.it:mptyl/ThothII.git
```
Per una prova ripetibile annotare l’hash stampato da `git rev-parse`. In una campagna successiva
usare la revisione/tag approvata dal maintainer invece di seguire implicitamente una `main` che
può cambiare.
## 2. Verificare i prerequisiti e installare il comando operatore
## 2. Installare il comando terminale
Dal root del clone:
```sh
~~~
bash scripts/check-standalone-prerequisites.sh
export PATH="$HOME/.local/bin:$PATH"
mkdir -p "$HOME/.local/bin"
THT_INSTALL_DIRECTORY="$HOME/.local/bin" bash scripts/install-tht.sh
export PATH="$HOME/.local/bin:$PATH"
tht version
```
~~~
`install-tht.sh` è un bootstrap del solo comando operatore nativo `tht`; non installa una versione
desktop di THothII. Usa il builder Docker del repository, installa il binario adatto all’ambiente
del terminale e lo installa nella directory utente. Aggiungere `$HOME/.local/bin` al PATH della
shell anche per i terminali successivi. Un `tht` già presente in quella directory viene aggiornato.
Il comando tht è l’unico componente nativo da installare. Costruisce il binario con Docker e
orchestra Compose; non è un secondo runtime dell’applicazione.
Su Windows, eseguire questi comandi dentro WSL2. Il binario `tht` installato è quello Linux di WSL2;
il runtime dell’applicazione rimane Docker Desktop. Non usare `scripts/install-tht.ps1` come percorso
principale di questa prova.
## 3. Preparare pochi segreti e avviare il setup completo
## 3. Configurare e avviare l’installazione locale
La prima esecuzione crea i placeholder protetti sotto deploy/local/secrets/ e si ferma se manca
una credenziale necessaria. Compilare i file indicati e rilanciare lo stesso comando: i file di
configurazione già compatibili vengono riutilizzati.
Eseguire i blocchi successivi nella stessa sessione Bash dal root fisico del clone (`pwd -P`).
Creare prima le due password distinte del catalogo, conservando eventuali file già esistenti:
~~~
tht setup --complete --profile local --shell-mode full --shell-default-locale en
~~~
```bash
umask 077
mkdir -p deploy/local/secrets
for name in catalog-runtime-password catalog-migrator-password; do
target="deploy/local/secrets/$name"
if [ ! -e "$target" ]; then
(set -C; openssl rand -hex 32 > "$target") || exit 1
fi
done
export THT_CATALOG_RUNTIME_PASSWORD_SOURCE="$(pwd -P)/deploy/local/secrets/catalog-runtime-password"
export THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="$(pwd -P)/deploy/local/secrets/catalog-migrator-password"
```
Durante il setup servono solo le informazioni operative che il computer non può conoscere:
Non rigenerare le password di un catalogo già inizializzato. Configurare senza avviare i servizi:
```sh
tht setup --profile local --shell-mode full --shell-default-locale en --configure-only
```
Rispondere ai prompt nel seguente modo:
| Prompt | Valore o regola |
| Richiesta | Cosa inserire |
| --- | --- |
| Installation ID | `local`, salvo necessità di più installazioni nello stesso clone |
| Deployment profile | `local` |
| DWH API endpoint | URL `http(s)` senza user, password, query o fragment; può restare vuoto per il solo smoke test |
| LLM API endpoint | URL `http(s)` senza credenziali; può restare vuoto per il solo smoke test |
| Workspace repository URL | URL del repository dei workspace, non il clone sorgente di THothII |
| Workspace branch | normalmente `main` |
| Workspace access | `ssh` se si usa una chiave deploy; altrimenti `https` con credential file protetto |
| Percorsi dei file | accettare i percorsi predefiniti sotto `deploy/local/secrets/` nella prima prova |
| Secret templates | rispondere `yes` quando i file protetti non esistono ancora |
| Autenticazione | configurare il login locale richiesto dall’installazione; non inserire password in una riga di comando |
| Repository workspace | URL del repository dati/configurazione, non ThothII.git |
| Branch | normalmente main |
| Accesso | ssh con chiave e known_hosts, oppure https con credential file e CA |
| DWH/LLM URL | endpoint senza token nella URL |
| Login locale | utente e password iniziale richiesti dal prompt |
La configurazione generata è locale e ignorata da Git:
Il setup crea automaticamente le password casuali del Catalog e le scrive in operator.env come
percorsi, non come valori. Esegue docker compose config, costruisce le immagini, avvia il Catalog,
esegue catalog-migrate, avvia lo stack e importa il repository workspace. L’import attiva anche
l’Evidence dichiarata: almeno i file source presenti nel workspace vengono materializzati nel
registro locale.
```text
deploy/local/thothii-installation.yaml
deploy/local/operator.env
deploy/local/auth/
deploy/local/secrets/
```
### Il file da compilare
Modificare i segreti solo nei file locali protetti; non committarli. Il file `deploy/env/local.env.example` è un riferimento
tracciato; il percorso generato da `tht setup`, `deploy/local/operator.env`, è quello da usare per
questa installazione.
Il file principale è:
### Completare i file protetti
~~~
deploy/local/secrets/thothii.secrets
~~~
Se il setup ha creato template vuoti, inserire i valori con un editor locale:
Inserire solo righe NOME=VALORE necessarie al modelCatalog e agli adapter, per esempio una API key
LLM (DEEPSEEK_API_KEY, OPENAI_API_KEY o quella dichiarata dal catalogo) ed eventualmente
THT_DWH_API_KEY. I nomi ammessi sono documentati in deploy/secrets/README.md. Non mettere token
nelle URL, nel repository o nei comandi.
```sh
chmod 600 deploy/local/secrets/*
"${EDITOR:-vi}" deploy/local/secrets/thothii.secrets
```
Due precisazioni evitano gli errori più comuni:
Il bundle deve contenere solo righe `KEY=VALUE` per le credenziali effettivamente usate dal
`modelCatalog`. I nomi ammessi e il confine delle credenziali sono descritti nel file locale
`deploy/secrets/README.md`. Non mettere token nelle URL, nel
descriptor YAML, nel repository Git o nei comandi copiati nella shell.
- se il catalogo usa pi_auth, il token LLM va nel file Pi pi-auth.json creato dal setup; {} è solo
un placeholder e non abilita alcun modello;
- le credenziali specifiche di un database workspace (password PostgreSQL, API token REST, chiave
SSH del tunnel, known_hosts, CA) non vanno nel repository workspace: si inseriscono per workspace
in Database Management, che le conserva nel Catalog cifrato. Il workspace indica database/schema
e trasporto; l’installatore deve ottenere dal proprietario il valore corretto.
Per accesso workspace SSH, predisporre anche la chiave privata e il file `known_hosts` indicati dal
setup. Per accesso HTTPS, predisporre il credential file Git e l’eventuale CA. Entrambi devono
restare protetti e fuori dal controllo versione.
Per un repository workspace privato sono inoltre indispensabili i file Git richiesti dal trasporto:
una chiave SSH e known_hosts, oppure credential file HTTPS e CA. Sono file di trasporto, non un
secondo bundle da committare. Per ridurre i file da compilare, usare SSH con una deploy key già
autorizzata.
Prima dell'avvio completare anche questi passaggi:
## 4. Controlli automatici e test da terminale
1. Aggiungere `THT_CATALOG_RUNTIME_PASSWORD_SOURCE` e `THT_CATALOG_MIGRATOR_PASSWORD_SOURCE` a
`deploy/local/operator.env`, con gli stessi percorsi assoluti esportati sopra. Il setup non salva
queste due variabili. Inserire i percorsi, non le password.
2. Sostituire il `modelCatalog` generico nel descriptor con la configurazione provider/modelli
approvata. I default generati non replicano il Mac esistente. Vedere [configurazione Pi/modelli](../general/pi-configuration.md)
e l'esempio locale `deploy/psd/thothii-installation.yaml.example`.
3. Inserire in `thothii.secrets` le chiavi referenziate da `authentication.apiKeyEnv`. I provider
`pi_auth` richiedono credenziali valide nel file `PI_AUTH_FILE`; il template `{}` non autentica.
4. Completare i file Git del workspace. SSH richiede una chiave deploy autorizzata e known-hosts
verificato; HTTPS richiede il credential file e il bundle CA previsti dall'overlay. I template
vuoti non consentono l'accesso al repository.
Il setup verifica file, permessi, descriptor, Compose, Docker, autenticazione, servizi, Pi e
workspace. Dopo l’avvio usare questi comandi in qualunque momento:
Dopo aver modificato la configurazione generata, non rilanciare setup: rifiuta file esistenti con
contenuti diversi. Generare le proiezioni ed eseguire la migrazione esplicita indicata sotto.
Impostare `THT_GIT_ACCESS=https` se scelto nel setup. Il blocco usa il nuovo descriptor `local`
con il solo overlay Git; per installazioni personalizzate includere gli overlay aggiuntivi
nello stesso ordine del descriptor.
~~~
INSTALLATION="$PWD/deploy/local/thothii-installation.yaml"
tht --installation "$INSTALLATION" doctor --json
tht --installation "$INSTALLATION" workspace pull --json
tht --installation "$INSTALLATION" workspace test --json
~~~
```bash
INSTALLATION="$(pwd -P)/deploy/local/thothii-installation.yaml"
tht --installation "$INSTALLATION" installation generate
THT_PROJECT="thothii-$(printf '%s' "$INSTALLATION" | shasum -a 256 | cut -c 1-12)"
THT_GIT_ACCESS=ssh
compose=(
docker compose --project-name "$THT_PROJECT" --project-directory "$(pwd -P)"
--env-file "$(pwd -P)/deploy/local/operator.env"
-f compose.yaml -f deploy/compose.local.yaml
-f "deploy/compose.git-$THT_GIT_ACCESS.yaml"
-f deploy/local/generated/compose.models.yaml
)
"${compose[@]}" config --quiet
"${compose[@]}" build core frontend
"${compose[@]}" up -d catalog-db
"${compose[@]}" run --rm catalog-migrate
tht --installation "$INSTALLATION" start
```
workspace test prova, per ogni workspace attivo, il binding del database e le credenziali, Evidence,
Qdrant e il servizio embedding. Restituisce exit code diverso da zero se manca il binding del
database o una connessione non è utilizzabile. Prima di eseguirlo l’installatore deve aver
configurato il database in Database Management: il workspace repository da solo non può contenere
la password.
Fermarsi se un comando fallisce. Il nome progetto coincide con l'hash usato da `tht`, preservando
l'identità dei volumi. `catalog-migrate` applica le migrazioni Catalog e Memory; `tht start` non
lo esegue automaticamente. Il primo download del modello embedding può richiedere tempo.
Usare questa sequenza legata all'installazione, non `run-stack.sh` con env/progetto diversi.
Per una verifica generale del core, doctor --json è il test ripetibile e non distruttivo. Il test
funzionale finale deve inoltre aprire http://127.0.0.1:8080, autenticarsi e completare una domanda
reale fino alla SQL finale.
## 4. Verificare l’installazione
## Attività che può svolgere solo l’installatore
Il descriptor generato per l’ID predefinito è:
La procedura automatizza il bootstrap, non può inventare decisioni o autorizzazioni aziendali.
L’installatore deve completare e registrare:
```sh
INSTALLATION="$(pwd -P)/deploy/local/thothii-installation.yaml"
test -f "$INSTALLATION"
bash scripts/verify-standalone-install.sh "$INSTALLATION"
```
1. quali LLM sono utilizzabili, il relativo modelCatalog e le API key collegate; poi eseguire
tht pi test e tht doctor;
2. per ogni database: configurazione, test connessione, sincronizzazione dello schema e
generazione delle descrizioni;
3. consolidamento umano delle descrizioni generate;
4. generazione delle entry semantiche in Qdrant tramite workspace preprocess run;
5. generazione delle FK suggerite dal naming, come complemento alle FK lette dallo schema, revisione
umana delle proposte e caricamento delle relazioni approvate in Qdrant;
6. verifica periodica con tht doctor --json e tht workspace test --json;
7. una domanda reale completata con successo, senza errori di connessione o modello.
Il verificatore è read-only: esegue `tht doctor --json` e `tht status`, senza ristartare lo stack,
rigenerare la configurazione o stampare il contenuto dei segreti.
La configurazione è dichiarata completa solo quando tutti i punti applicabili sono stati eseguiti,
le decisioni sono state registrate e i due test terminali sono verdi. Il core è dichiarato usabile
solo dopo la domanda reale, non perché il frontend risponde a /health.
### Gate A — smoke di piattaforma, su tutti e tre i computer
## Gate A e Gate B
Registrare per ogni macchina:
### Gate A — piattaforma
```sh
~~~
uname -a
docker version --format '{{.Server.Version}} {{.Server.Arch}}'
tht version
bash scripts/check-standalone-prerequisites.sh
bash scripts/verify-standalone-install.sh "$INSTALLATION"
```
~~~
Il gate passa quando il clone è integro, Docker e Compose sono raggiungibili, `tht doctor` è OK,
lo stack è avviato e il frontend risponde sulla porta locale predefinita `http://127.0.0.1:8080`.
Il doctor controlla anche workspace e Pi: registrare separatamente i loro errori senza attribuire
ogni fallimento alla piattaforma. Verificare la disponibilità HTTP con:
### Gate B — usabilità
```sh
curl --fail --silent --show-error http://127.0.0.1:8080/health
```
### Gate B — verifica funzionale
Eseguire almeno su una macchina con endpoint e credenziali disponibili:
Seguire prima [Workspace operations](../operations/workspaces.md) per importare/preparare il
workspace e configurare Database e binding locale. Il clone sorgente non trasferisce catalogo,
segreti o sessioni del Mac. Connettere l'eventuale VPN richiesta da DWH/modelli e verificare
che i relativi nomi siano raggiungibili anche dai container.
1. aprire `http://127.0.0.1:8080`;
2. autenticarsi con l’account locale configurato;
3. verificare che il workspace configurato sia leggibile;
4. avviare una domanda reale e completare i gate di revisione fino alla SQL finale;
5. fermare e riavviare l’installazione, poi ripetere `verify-standalone-install.sh`.
Un fallimento del Gate B per DWH, provider LLM, Git workspace o credenziali non dimostra da solo un
problema di portabilità Docker: registrare separatamente l’endpoint o il componente fallito.
## Ciclo di vita quotidiano
Usare il descriptor esplicito quando più installazioni possono essere scoperte:
```sh
INSTALLATION="$PWD/deploy/local/thothii-installation.yaml"
tht --installation "$INSTALLATION" status
tht --installation "$INSTALLATION" start
tht --installation "$INSTALLATION" start --build
tht --installation "$INSTALLATION" logs
~~~
tht --installation "$INSTALLATION" doctor --json
tht --installation "$INSTALLATION" stop
```
tht --installation "$INSTALLATION" workspace test --json
curl --fail --silent --show-error http://127.0.0.1:8080/health
~~~
`start --build` è necessario dopo una modifica al codice o per ricostruire le immagini dal clone
corrente. `stop` conserva volumi, sessioni, catalogo, stato Pi, dati Qdrant e modello embedding.
Non usare `docker compose down --volumes` durante una prova normale: è un’operazione distruttiva
che cancella i dati locali.
Per aggiornamenti che richiedono migrazioni, seguire il runbook della release prima dell'avvio.
Poi eseguire una domanda reale e fermare/riavviare con tht stop e tht start. Non usare docker
compose down --volumes: cancella Catalog, sessioni, Qdrant e il modello embedding.
## Diagnosi rapida
| Sintomo | Controllo |
| Sintomo | Azione |
| --- | --- |
| `Docker Engine is not reachable` | avviare Docker Desktop oppure il servizio Docker e ripetere `docker info` |
| Windows vede Docker ma Bash fallisce | eseguire la guida dentro Ubuntu WSL2 e abilitare l’integrazione della distribuzione in Docker Desktop |
| `tht: command not found` | aprire una nuova shell e verificare `command -v tht`; se necessario ripetere il bootstrap |
| line ending o script non eseguibile | usare un clone nel filesystem WSL2/Linux e rieseguire `bash scripts/...` |
| architettura non supportata | verificare `docker version --format '{{.Server.Arch}}'`; la prova richiede `amd64` o `arm64` |
| descriptor o env file mancanti | usare il percorso `deploy/local/...` generato da `tht setup`, non un file copiato casualmente |
| stack sano ma workflow fallisce | controllare separatamente URL, credential bundle, workspace Git e autenticazione |
| dati apparentemente persi | verificare che non sia stato usato `down --volumes`; `stop` non rimuove i volumi |
## Checklist di accettazione
- [ ] Il clone proviene dal repository Gitea atteso e la revisione è stata annotata.
- [ ] Docker Desktop/Engine e Compose v2 sono disponibili.
- [ ] Il runtime restituisce un’architettura ammessa.
- [ ] `tht` è stato costruito dal repository e risponde a `tht version`.
- [ ] Il setup usa `profile: local`, `shell.mode: full` e `shell.defaultLocale: en`.
- [ ] Descriptor, `operator.env`, autenticazione e segreti sono presenti solo in `deploy/local/`.
- [ ] Nessun segreto compare in Git, URL, YAML pubblico o comandi registrati.
- [ ] Gate A superato su macOS Apple Silicon, Windows WSL2/x64 e Linux x64.
- [ ] Gate B eseguito almeno su una macchina con DWH e LLM disponibili.
- [ ] Stop/start e verifica finale completati senza cancellare i volumi.
## Fuori perimetro di questa release
Restano attività successive:
- pubblicare immagini pre-costruite su Docker Hub;
- ridurre ulteriormente i prompt tramite una configurazione non interattiva dedicata;
- creare pacchetti DMG, MSI/EXE, AppImage o altri installer nativi;
- fornire un runtime offline o un DWH/LLM locale incluso nell’applicazione.
| Docker Engine is not reachable | avviare Docker Desktop o systemctl e ripetere docker info |
| Omarchy non trova docker | installare Docker/Compose, abilitare il servizio e riaprire la sessione |
| Windows vede Docker ma Bash fallisce | usare Ubuntu WSL2 e abilitarne l’integrazione in Docker Desktop |
| pull workspace fallisce | controllare URL, branch, chiave/credential file e known_hosts dal container |
| workspace test segnala binding mancante | configurare database, token/password e CA in Database Management |
| Pi non è pronto | compilare pi-auth.json o la chiave dichiarata dal modelCatalog, poi eseguire tht pi test |
## Documenti collegati
- [Install and first start](first-start.md)
- [Shell and localization](shell-and-language.md)
- [Workspace operations](../operations/workspaces.md)
- `deploy/secrets/README.md` (runtime secrets)
- [Installazione e primo avvio](first-start.md)
- [Operazioni sui workspace](../operations/workspaces.md)
- [Database Management](../operations/database-management.md)
- [Configurazione dei modelli](../general/pi-configuration.md)
- deploy/secrets/README.md
La pubblicazione di immagini su Docker Hub e gli installer nativi DMG/MSI/AppImage restano attività
successive: questa procedura parte dal clone Gitea e non richiede immagini Docker Hub pre-pubblicate.
+1 -1
View File
@@ -54,7 +54,7 @@ Omics. Le preferenze non modificano il descrittore installato.
Per una nuova installazione autonoma, selezionare esplicitamente full:
```bash
tht setup --profile local --shell-mode full --shell-default-locale en
tht setup --complete --profile local --shell-mode full --shell-default-locale en
```
Il setup senza opzioni shell conserva per compatibilità il default embedded.
@@ -3,6 +3,14 @@
**Stato:** design concordato con grill-with-docs il 2026-09-14. Questo worktree definisce e rende
verificabile il percorso manuale; non introduce pacchetti nativi.
**Aggiornamento:** il [PRD del 27 settembre](2026-09-27-guided-installation-prd.md)
rende le immagini precompilate su Docker Hub il percorso ordinario e include la
loro pubblicazione nel progetto. Il percorso con build da sorgente documentato qui
resta un'alternativa; il precedente rinvio di Docker Hub è superato.
La revisione del 28 settembre dello stesso PRD sostituisce inoltre il setup
interattivo con preparazione dei documenti, verifiche ripetibili ed esecuzione
senza richiesta di parametri.
## Obiettivo
Permettere di predisporre una copia di THothII su macOS, Windows e Linux partendo dal clone del
@@ -0,0 +1,386 @@
# PRD — Installazione di ThothII da documenti verificati
Creato: 2026-09-27. Revisione: 2026-09-28. Stato: requisiti e chiarimenti R1/R2
approvati; `grill-with-docs` e `/to-spec` conclusi. Specifica pubblicata su Gitea
con etichetta `ready-for-agent`; implementazione non iniziata.
Branch: `codex/guided-standalone-install`.
La [specifica derivata](2026-09-28-document-first-installation-spec.md) raccoglie
user story, decisioni implementative e collaudi. Il piano di test è stato confermato
dall'utente e la specifica è pubblicata nell'issue
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
La [scomposizione approvata](2026-09-28-installation-ticket-breakdown.md) è pubblicata
nelle issue #43–#54 con dipendenze native. Il prossimo passaggio è `/implement`
sui ticket senza blocchi, inizialmente validazione workspace e rilascio Docker Hub.
Questo documento consolida le decisioni della
[ripresa del progetto](2026-09-27-guided-installation-resumption.md) e aggiorna il
[piano standalone del 14 settembre](2026-09-14-manual-standalone-installation.md)
per l'esperienza guidata. Conserva architettura, protezione dei segreti e contratti
del prodotto; modifica il percorso operativo e l'ordine dei collaudi. La successiva
precisazione dell'utente rende la distribuzione di immagini precompilate su Docker Hub
parte della prima versione del percorso ordinario, superando il precedente rinvio.
La revisione del 28 settembre sostituisce la raccolta interattiva dei parametri:
si preparano prima i documenti, li si verifica anche più volte, poi si esegue il setup.
Prevale sulle precedenti decisioni I1/I2 dove consentivano configurazioni obbligatorie
rinviate o richieste durante l'installazione.
## Obiettivo e destinatario
Una persona capace di installare Docker, clonare un repository e fornire le proprie
credenziali deve poter predisporre con calma i documenti necessari e rendere
utilizzabile almeno un workspace, senza conoscere l'architettura interna.
Template commentati, esempi compilati e documentazione passo per passo spiegano
cosa inserire nei file YAML e nei file protetti `.env` o equivalenti.
DWH e provider LLM possono essere esterni; non si promette un funzionamento offline.
La CLI offre preparazione dei template, verifiche ripetibili e applicazione dei
documenti già verificati. Il setup non raccoglie parametri, non apre questionari
e non completa silenziosamente documenti incompleti. L'utente corregge i documenti
prima di eseguirlo. Le pagine amministrative rimangono disponibili per l'uso e la
manutenzione successivi, senza diventare una scorciatoia per rinviare parametri
obbligatori dell'installazione.
Il percorso predefinito scarica da Docker Hub le immagini applicative già compilate:
il computer dell'utente svolge configurazione, inizializzazione dei servizi e dei
database, avvio e verifiche. L'installazione da sorgente resta disponibile come
scelta esplicita alternativa. Nessuna compilazione dell'applicazione, neppure
all'interno di un container locale, è richiesta dal percorso ordinario.
## Decisioni approvate
| Area | Comportamento richiesto |
| --- | --- |
| Distribuzione predefinita | Creare, collaudare e pubblicare su Docker Hub le immagini applicative precompilate; il setup le scarica e le avvia senza build locale. |
| Alternativa da sorgente | Conservare un percorso esplicito di build dai sorgenti, documentato e verificato, con configurazione e funzionalità equivalenti. |
| Due traguardi | Mostrare separatamente piattaforma installata e workspace pronto. |
| Preparazione anticipata | Repository workspace e documenti applicativi predisposti prima dell'esecuzione; template, esempi e guida alla compilazione. |
| Setup senza domande | Consuma documenti già verificati, mostra avanzamento ed errori e non chiede valori mancanti. |
| Modelli | Provider, modelli, usi, endpoint e riferimenti alle credenziali descritti nei documenti prima del setup; configurazioni di esempio supportate e commentate. |
| Embedding | Configurazione locale precompilata come percorso ordinario. |
| Ripresa | Correggere i documenti, ripetere le verifiche e riprendere l'esecuzione senza questionari né perdita del lavoro completato. |
| Workspace | Preparare prima repository e descriptor conformi; dichiarare separatamente i parametri di collegamento ai database secondo i contratti ThothII. |
| Contenuti | Riutilizzare descrizioni/Evidence curate; nessuna generazione AI implicita per completare un documento. Eventuali attività di curation restano esplicite. |
| Evidence | Opzionali nel contratto; se configurate devono essere valide e utilizzabili. |
| Collaudi | Prima Windows, poi Omarchy su PC Intel, infine macOS, in tre tappe distinte. |
## Rilascio e distribuzione delle immagini
La creazione e pubblicazione delle immagini appartengono al processo di rilascio
del progetto. Il sottoprogetto installazione comprende quindi anche una procedura
riproducibile per costruire, verificare e pubblicare queste immagini su Docker Hub;
non basta aggiungere un'opzione di pull senza fornire immagini utilizzabili.
**Stato iniziale dichiarato dall'utente il 28 settembre:** le immagini applicative
ThothII su Docker Hub non sono disponibili. Prima di collaudare l'installazione
precompilata su un PC Windows o Linux occorre produrle, pubblicarle e verificarne
il download. Questa dipendenza è bloccante per quel collaudo, non viene aggirata
usando immagini costruite soltanto nella cache della macchina di test.
Il progetto deve fornire un comando di produzione e pubblicazione, mantenuto nel
repository e documentato per il manutentore. Il comando riceve revisione/versione,
namespace Docker Hub e architetture, costruisce e verifica le immagini proprietarie,
le pubblica e produce il manifest di rilascio con digest e artefatti compatibili.
Il nome e la sintassi saranno fissati nella specifica; il comando non è ancora
implementato. Le credenziali di pubblicazione appartengono al manutentore e non
entrano nei documenti dell'utente che installerà ThothII.
La sequenza di rilascio è: produzione e controlli, pubblicazione su Docker Hub,
verifica del pull del rilascio pubblicato, quindi collaudo della procedura sui
sistemi destinatari. Questa fase del manutentore precede i sei passi dell'utente.
Il pacchetto distribuito deve comprendere tutto ciò che serve all'installazione:
immagini applicative, manifest Compose/configurazione di avvio, migrazioni e risorse
di inizializzazione, oltre al comando operatore o al suo bootstrap. Il numero delle
immagini segue i servizi dell'architettura corrente: non è richiesto accorpare tutto
in un singolo container. I servizi di terze parti mantengono immagini compatibili
con lo stack, senza ricostruirli sul computer dell'utente.
Nell'architettura corrente le immagini proprietarie da pubblicare sono `core` e
`frontend`; PostgreSQL, Qdrant e Ollama usano immagini upstream. Le attività
`catalog-migrate` e `workspace-maintenance` riusano la stessa immagine release di
`core`. Il pacchetto di installazione include anche le risorse oggi montate dal
checkout, fra cui `docker/catalog-db-init.sql` e `docker/embedding-model-init.sh`.
Il download del modello embedding, la creazione dei volumi/database e le migrazioni
restano attività di inizializzazione locale, distinte dalla compilazione.
Ogni rilascio identifica una versione coerente di immagini, CLI e configurazione;
il manifest registra riferimenti verificabili, inclusi i digest delle immagini.
Il setup riporta la versione installata e non combina automaticamente componenti
incompatibili tramite tag mobili. Nomi e namespace Docker Hub saranno definiti
nella specifica di pubblicazione; non sono presunti già esistenti.
Il percorso ordinario deve poter partire dal pacchetto di rilascio senza richiedere
il checkout dei sorgenti applicativi o strumenti di compilazione. Anche l'eventuale
CLI nativa deve essere distribuita già compilata per gli host supportati; nascondere
una compilazione di `tht` nel bootstrap non soddisfa il requisito.
L'installazione pubblica non richiede credenziali di pubblicazione Docker Hub.
Le immagini non includono credenziali dell'installazione, dati personali, workspace
dell'operatore o i database di esempio preinstallati. Configurazione e dati
persistenti vengono creati localmente nei percorsi e volumi dell'installazione.
Il supporto iniziale Windows/WSL2 e Omarchy richiede immagini Linux amd64; per la
tappa macOS Apple Silicon servono immagini Linux arm64 e un bootstrap host adeguato.
La pubblicazione dichiara solo le architetture effettivamente verificate, rispettando
l'ordine dei collaudi concordato.
La modalità sorgente costruisce gli stessi componenti a partire da una revisione
esplicita, documenta i prerequisiti aggiuntivi e usa gli stessi contratti di
configurazione, persistenza e migrazione. Un errore di download da Docker Hub non
deve attivarla automaticamente: l'utente può correggere il problema e riprovare,
oppure scegliere consapevolmente l'alternativa da sorgente.
## Percorso dell'utente
Prima dei sei passi sono disponibili guida, template e strumenti di verifica già
compilati. Git e gli strumenti minimi necessari per acquisire i documenti sono
esplicitati nella guida; la verifica completa dell'host rimane al passo 3. I primi
controlli documentali non devono richiedere l'avvio di ThothII o dei suoi container.
### 1. Preparazione del repository dei workspace
L'utente prepara una copia del repository predefinito con Financial, European
Football e F1, oppure un repository ad hoc a partire da un template documentato.
Il repository predefinito contiene definizioni, documentazione, Evidence e riferimenti
ai pacchetti dati; il clone non equivale ad aver già creato i database PostgreSQL.
La disponibilità effettiva del percorso predefinito dipende dal sottoprogetto esempi.
Si preservano le scelte già approvate sulla copia autonoma o sull'accesso in sola
lettura all'originale; workspace ed Evidence locali restano modificabili. La
preparazione non richiede diritti di push al repository pubblico e non sostituisce
un repository già configurato senza una scelta esplicita.
### 2. Verifica dei documenti dei workspace
Un comando dedicato verifica sintassi YAML, versione/schema ThothII, campi richiesti,
tipi, identificatori, unicità, corrispondenza fra catalogo e directory, descriptor
referenziati, percorsi e file Evidence dove configurati. La verifica è ripetibile
sui file locali prima che Docker o ThothII siano in esecuzione.
La verifica sostanziale copre ciò che è dimostrabile dai documenti: coerenza dei
riferimenti, esistenza e leggibilità dei contenuti, conformità delle Evidence e
assenza di contraddizioni rilevabili. Non pretende di certificare automaticamente
la verità delle regole di dominio o interrogare database non ancora creati.
Ogni errore identifica documento, campo e, quando disponibile, riga, con indicazione
della correzione. L'utente modifica i documenti e ripete il controllo. Le Evidence
restano opzionali: assenza dichiarata ed Evidence configurate ma invalide sono
condizioni diverse. I controlli incrociati che richiedono i parametri applicativi
vengono completati al passo 5.
### 3. Verifica delle precondizioni
Verificare sistema/architettura, Docker e Compose, accesso al daemon, risorse e spazio
richiesti, percorsi e permessi, porte previste, accesso ai servizi esterni e al registry
per quanto valutabile. Su Windows verificare Ubuntu WSL2 e integrazione Docker
Desktop. I requisiti dipendenti da valori scelti al passo 4 sono ricontrollati al 5.
Distinguere componenti necessari sull'host da componenti inclusi nelle immagini:
Pi appartiene a `core`, non è un prerequisito da installare separatamente sul PC.
Presenza e versione di Pi sono verificate nel rilascio; il funzionamento effettivo
nel container è verificato al passo 6. Lo stesso principio vale per le dipendenze
applicative già incluse. Go, Python, Node e compilatori non sono prerequisiti host
del percorso precompilato.
### 4. Preparazione dei parametri applicativi
L'utente compila i documenti locali usando template commentati ed esempi: descriptor
di installazione, configurazione dei modelli e riferimenti ai file protetti `.env`
o equivalenti. Sono espliciti campi obbligatori, opzionali, default e condizioni in
cui un parametro serve. La preparazione può svolgersi in più sessioni senza avviare
il setup o i servizi applicativi.
I documenti definiscono versione del rilascio, percorsi/volumi, profilo e accesso,
repository/workspace selezionati, provider/modelli per i rispettivi usi, endpoint,
embedding, parametri di collegamento ai database e riferimenti ai segreti. La
configurazione dei modelli deriva dall'Installation Model Catalog, senza un secondo
catalogo del setup. L'embedding locale ha un esempio precompilato.
Il descriptor workspace v4 continua a contenere identità ed Evidence: non vi si
inseriscono campi database o modelli estranei al contratto. I binding database sono
predisposti in un input locale separato, da specificare, e applicati al Metadata
Catalog durante il setup mediante i suoi servizi; non diventano una seconda autorità
runtime. La configurazione server/Omics rimane fuori dal perimetro.
I segreti non entrano nel repository pubblico, nei log o nei rapporti di verifica.
Per le credenziali tecniche interne un comando preparatorio può generare file
protetti prima delle verifiche, senza questionario né richiesta durante il setup.
Le selezioni degli esempi e le eventuali operazioni facoltative sono dichiarate
prima dell'esecuzione. Le pagine amministrative restano disponibili dopo l'avvio
per modifiche e curation, non per raccogliere valori obbligatori dimenticati.
### 5. Verifica dei parametri applicativi
Un comando ripetibile controlla completezza e correttezza dei documenti, sintassi
e compatibilità dei valori, riferimenti ai segreti, modelli/usi/default, collegamenti
workspace/database, configurazione Compose, disponibilità del rilascio nel registry
e compatibilità delle architetture. Completa i controlli incrociati dei passi 2 e 3.
Quando fattibile senza creare lo stack, verifica raggiungibilità, autenticazione
e compatibilità delle dipendenze esterne con operazioni circoscritte; la documentazione
spiega le prove effettuate, inclusi eventuali accessi a provider a consumo.
Non cambia dati applicativi né esegue migrazioni o importazioni.
Il rapporto distingue `superato`, `errore`, `avviso` e `non ancora verificabile`,
senza trasformare assenza di verifica in successo. I controlli che richiedono lo
stack sono elencati prima e obbligatori al passo 6, secondo R2 approvata.
Un errore formale o una configurazione obbligatoria mancante blocca l'esecuzione.
Il risultato identifica i documenti e la revisione del repository esaminati. Una
modifica successiva invalida i controlli dipendenti: il setup non deve applicare
file diversi da quelli verificati senza ricontrollarli. Segreti e loro valori
non vengono copiati nel rapporto.
### 6. Setup esecutivo
Il setup ricontrolla l'ammissibilità del piano verificato, scarica le immagini del
rilascio da Docker Hub, crea reti/volumi/container e inizializza i database applicativi.
Esegue migrazioni, applicazione della configurazione e dei binding, sincronizzazione
e preparazione necessaria dei workspace secondo i contratti esistenti. Quando
disponibili e selezionati, crea e carica i database di esempio dal relativo pacchetto.
Non pone domande su provider, modelli, percorsi, credenziali o altri parametri.
Se trova un valore mancante o incoerente, si ferma con una diagnosi e rimanda alla
correzione dei documenti e alla loro verifica; non apre un wizard di riparazione.
Le conferme dei contratti di dominio non vengono aggirate: la specifica deve
distinguere operazioni predisponibili nel piano da attività umane residue senza
reinserire la raccolta dei parametri durante l'installazione.
Esegue i controlli disponibili solo a runtime: salute dei servizi, Pi, modello
embedding effettivamente caricato, collegamenti dalla rete dei container, Catalog
e preparazione dei workspace. Mostra separatamente piattaforma avviata e workspace
pronto, più eventuali verifiche funzionali ancora da svolgere. Il collaudo completo
include una domanda reale con revisione umana, senza SQL target di benchmark.
## Documentazione di accompagnamento
Le guide italiana e inglese seguono esattamente i sei passi. Per ciascuno indicano
input, file da predisporre, template ed esempio compilato, comando di verifica,
esito atteso, errori comuni e passaggio successivo. Un elenco dei documenti e dei
segreti necessari consente di raccogliere le informazioni in anticipo.
Le istruzioni distinguono manutentore del rilascio e utente installatore, host e
container, controlli locali e runtime. Il percorso sorgente è esplicitamente
alternativo; un download fallito non ne provoca l'attivazione automatica.
## Avanzamento, errori e ripresa
La procedura conserva l'installazione di riferimento, gli input verificati, i
passaggi completati, l'ultimo errore e il prossimo passo, senza conservare segreti
nello stato di avanzamento. Alla ripresa verifica lo stato reale: una vecchia spunta
non prova che servizio, credenziale o indice siano ancora validi.
Correggere un endpoint o una credenziale non impone di rifare tutto il setup. La
specifica deve definire quali verifiche dipendenti vanno ripetute, preservando
configurazioni, workspace, sessioni e contenuti non coinvolti nella modifica.
Le modifiche manuali vengono riconosciute e spiegate, non cancellate implicitamente.
La ripresa riguarda le tappe della procedura: non promette una ripresa interna
di operazioni che non la supportano, come il preprocessing corrente. In questi
casi il passaggio viene rieseguito in modo coerente con il suo contratto.
Un fallimento riporta fase, causa comprensibile, correzione consigliata e modalità
di ripresa. Distinguere problemi dell'host, dei servizi locali e delle dipendenze
esterne. Un provider o DWH indisponibile non annulla una verifica valida della
piattaforma, ma impedisce di dichiarare il percorso complessivo pronto.
## Criteri di accettazione
| Scenario | Esito verificabile |
| --- | --- |
| Rilascio Docker Hub | Immagini costruite e pubblicate con versione, digest e architetture dichiarate; avvio verificato usando quanto effettivamente scaricato dal registry. |
| Installazione precompilata | Su host senza toolchain e senza sorgenti applicativi, il setup scarica le immagini e completa configurazione/avvio senza compilare né invocare build locali. |
| Risorse di avvio | Compose, migrazioni e inizializzazione non dipendono da file presenti soltanto in un checkout dei sorgenti. |
| Download fallito | Errore comprensibile e ripetibile; nessuna build da sorgente avviata implicitamente. |
| Modalità sorgente | Scelta esplicita ancora funzionante, con gli stessi contratti di configurazione e persistenza del rilascio precompilato. |
| Preparazione documentale | Template e guida consentono di predisporre tutti i YAML e file protetti necessari prima dell'esecuzione. |
| Ordine dei passi | Repository, verifica workspace, precondizioni, parametri applicativi, verifica parametri, setup esecutivo. |
| Verifica workspace senza stack | Il passo 2 funziona senza Docker attivo o applicazione installata e senza toolchain host aggiuntive. |
| Controlli ripetibili | Ripetere i controlli non crea container, non migra DB e non modifica i documenti dell'utente. |
| Completezza prima del setup | Un campo obbligatorio mancante blocca l'esecuzione, indicando file/campo e correzione; nessuna domanda interattiva. |
| Input modificati | I controlli dipendenti sono invalidati o ripetuti; nessuna applicazione di input diversi da quelli verificati. |
| Setup non interattivo | Con documenti completi il passo 6 termina senza richiedere input da terminale; un errore non apre un questionario. |
| Verifiche sostanziali | Il rapporto distingue prove eseguite da controlli non ancora possibili; non certifica verità di dominio o servizi non verificati. |
| Modello configurato | Credenziali/endpoint verificati e selezione valida per gli usi richiesti. |
| Credenziale errata | Diagnosi senza esporre il segreto, correzione nel file protetto e nuova verifica prima della ripresa. |
| Interruzione e riavvio | La procedura ricontrolla gli input e riprende dall'avanzamento verificato senza nuove domande sui parametri. |
| Contenuti esistenti | Descrizioni curate ed Evidence locali preservate; nessuna generazione o sovrascrittura silenziosa. |
| Evidence assenti | Nessun blocco dovuto alla sola assenza quando non sono configurate. |
| Workspace pronto | Connessione, schema e preparazione necessaria verificati; domanda reale completata con revisione umana. |
| Riesecuzione | Nessuna duplicazione, azzeramento di dati o perdita delle personalizzazioni. |
| Stop/start | Accesso e workspace utilizzabile persistono; i problemi esterni sono segnalati separatamente. |
| Segreti | Assenti da log, riepiloghi, file pubblici e stato di avanzamento. |
Il dettaglio dei controlli e i test automatici devono rispettare le API e i contratti
attuali; i test di portabilità e il collaudo con servizi reali restano distinti.
La prima tappa usa Windows x64/WSL2, la seconda Linux Omarchy x64, la terza macOS
Apple Silicon, coerentemente con le architetture già previste dal progetto.
Gli esiti di una tappa non valgono come collaudo delle successive.
## Sottoprogetto degli esempi
L'implementazione rimane rinviata, come confermato con R1. I requisiti sono conservati sul branch
`codex/benchmark-examples`, commit `08a5db55`. Il setup non offre come disponibili
CLI, database o modalità di copia del repository non ancora implementati.
Il percorso richiesto ora parte dal repository predefinito oppure da quello ad hoc.
Il primo collaudo utilizza un repository ad hoc; il percorso predefinito completo
arriverà con il sottoprogetto esempi. Quando disponibili e selezionati nei documenti,
creazione e caricamento dei database avvengono nell'installazione
dell'utente a partire dai pacchetti dati verificati, senza compilare l'applicazione
o incorporare quei database nelle immagini. Il percorso ad hoc può essere collaudato
con workspace/database disponibili. La disponibilità di immagini Docker Hub è
invece un prerequisito esplicito di ogni collaudo del percorso precompilato.
## Confini della specifica successiva
La verifica del codice ha individuato questi punti di integrazione concreti:
- `compose.yaml` costruisce oggi `core` e `frontend`; i servizi di manutenzione
usano l'immagine core locale. `setup --complete` esegue una build
(`tools/tht/internal/setup/run.go:77`) e `scripts/install-tht.sh:84` compila la CLI
tramite Docker se non riceve un artefatto già costruito. Il percorso ordinario
deve sostituire entrambi i comportamenti con artefatti del rilascio. La CI
`.github/workflows/container-multiarch.yml` verifica già entrambe le architetture
Linux; occorre aggiungere la pubblicazione Docker Hub e la verifica dei pacchetti
effettivamente distribuiti.
- I parser workspace (`backend/src/workspaces/catalog.ts:52`, `schema.ts:382`)
verificano i documenti senza dipendenze dal runtime, ma non sono oggi un comando
preinstallazione nativo. Devono essere resi disponibili negli strumenti
precompilati preservando gli stessi contratti, senza richiedere Node sul PC.
- La configurazione database è applicata al descriptor runtime dal Catalog
(`backend/src/catalog/runtime-binding.ts:33`). Serve un input locale preparatorio
e un percorso di applicazione al Catalog, senza cambiare il significato del
workspace v4 o creare due autorità persistenti per i binding.
- `config.Load` contiene verifiche riutilizzabili su YAML e file ambiente
(`tools/tht/internal/config/installation.go:100`); il `doctor` corrente combina
controlli documentali e runtime (`tools/tht/internal/doctor/report.go:97`).
La specifica deve separarli per rendere disponibili i gate dei passi 2, 3 e 5.
- L'ammissione della sessione controlla modello, preprocessing e servizi necessari
(`backend/src/routes/sessions.ts:438`); il preprocessing richiede un database
associato e una sincronizzazione corrente. Le descrizioni generate dall'AI
non costituiscono un requisito generale: possono essere già disponibili
descrizioni curate o commenti della sorgente. Riferimento:
[contratto di preprocessing](../contracts/workspace-preprocessing-cli.md).
La specifica tecnica dovrà definire build/pubblicazione Docker Hub, pacchetto di
rilascio e bootstrap precompilato, selezione esplicita del percorso sorgente,
template e documenti locali, validatori senza runtime, applicazione dei parametri
al Catalog, esecuzione senza questionari, stato/ripresa e verifiche dei due traguardi.
Nomi dei comandi di preparazione/verifica/pubblicazione e formato dello stato sono
dettagli da progettare, non funzionalità esistenti attestate da questo PRD.
## Chiarimenti approvati del 28 settembre
| ID | Scelta | Decisione approvata |
| --- | --- | --- |
| R1 | Disponibilità dei tre esempi al primo rilascio | Conservare il rinvio del sottoprogetto e collaudare prima il percorso con repository ad hoc; introdurre il percorso predefinito completo quando gli esempi sono disponibili. |
| R2 | Controlli impossibili prima della creazione dello stack | Bloccare gli errori rilevabili prima; elencare i controlli runtime non ancora eseguibili e renderli obbligatori al passo 6, senza contarli come superati preventivamente. |
Approvazione: «ok per le tue proposte. dopodichè procedi con to-spec».
Il principio dei sei passi resta invariato.
Non rientrano in questo lavoro un nuovo installer grafico, la riscrittura delle
superfici amministrative, il supporto multi-repository, la distribuzione dei
database di esempio o modifiche al deployment server/Omics.
@@ -0,0 +1,155 @@
# Ripresa dell'installazione guidata
Stato al 28 settembre: revisione documentale in sei passi richiesta dall'utente;
raccolta interattiva dei parametri durante il setup superata. R1/R2 confermate,
`grill-with-docs` e `/to-spec` conclusi, piano di test confermato dall'utente.
Il riferimento consolidato è il
[PRD dell'installazione guidata](2026-09-27-guided-installation-prd.md).
La [specifica derivata](2026-09-28-document-first-installation-spec.md) è pubblicata
su Gitea come
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42),
con etichetta `ready-for-agent`. `/to-tickets` è completato: la
[scomposizione approvata](2026-09-28-installation-ticket-breakdown.md) collega le
issue #43–#54, con dipendenze native verificate e parent invariata.
Prossimo passo: `/implement` su #43 o #46, inizialmente senza blocchi.
Nessun codice applicativo è stato implementato in questi passaggi.
## Base di lavoro
Branch `codex/guided-standalone-install`, commit iniziale `67ee5262`.
Il branch conserva il setup guidato e i controlli workspace incompleti, separati dal
rilascio server. Il piano del 14 settembre descrive il precedente percorso manuale;
le decisioni qui registrate aggiornano l'obiettivo del lavoro ripreso.
## Decisioni confermate il 27 settembre 2026
- Destinatario: una persona capace di installare Docker, clonare un repository e
compilare i valori richiesti, senza conoscere l'architettura di ThothII.
- Risultato: almeno un workspace utilizzabile per una domanda reale, attraverso due
traguardi espliciti: piattaforma installata e workspace pronto. La procedura guida
le decisioni umane necessarie e può essere ripresa senza ricominciare.
- Collaudo in tre tappe distinte e ordinate: prima Windows, poi Linux Omarchy su PC
Intel, infine macOS. Non attribuire a una piattaforma gli esiti ottenuti su un'altra.
- Distribuzione ordinaria tramite immagini applicative precompilate pubblicate su
Docker Hub; build e pubblicazione fanno parte del progetto. L'installazione locale
configura e inizializza lo stack senza compilare; l'alternativa da sorgente resta
esplicitamente disponibile. Anche il comando host deve essere fornito precompilato
nel percorso ordinario. La creazione/caricamento degli esempi resta un'integrazione
prevista, con l'implementazione del relativo sottoprogetto ancora rinviata.
## Vincoli già documentati
Il precedente piano prevedeva clone Gitea e build locale; la decisione successiva
li mantiene come alternativa al percorso precompilato Docker Hub. Restano Docker
Compose e, su Windows, Ubuntu WSL2 con integrazione Docker Desktop.
Il workspace descriptor contiene identità ed
Evidence; configurazione database e metadati appartengono al Metadata Catalog.
L'Installation Model Catalog appartiene all'installazione.
## Revisione del 28 settembre — prevale sul percorso interattivo
La preparazione e le verifiche precedono l'esecuzione, nell'ordine richiesto:
1. Preparare il repository workspace predefinito con i tre esempi oppure uno ad hoc.
2. Verificare formalmente e, per quanto possibile, sostanzialmente i documenti workspace.
3. Verificare le precondizioni dell'host e dei componenti previsti.
4. Predisporre i documenti locali YAML/.env con i parametri dell'applicazione.
5. Verificarne completezza, correttezza e coerenza con i workspace.
6. Eseguire il setup dai documenti verificati, scaricando le immagini Docker Hub
e creando lo stack, senza domande sui parametri.
Template ed esempi commentati e guide IT/EN accompagnano ogni fase. Le verifiche
devono essere ripetibili prima di creare i container. Pi è incluso in `core`, non
deve essere installato sull'host; i suoi controlli runtime avvengono dopo l'avvio.
I binding database restano di competenza del Catalog, con un input locale
preparatorio distinto dai descriptor workspace v4.
L'utente conferma che le immagini Docker Hub oggi non esistono: occorre un comando
di produzione/pubblicazione per il manutentore, poi verificare il pull degli
artefatti pubblicati prima di collaudare l'installazione precompilata sui PC.
La preparazione del rilascio non è un compito dell'utente installatore.
R1/R2 confermate: esempi ancora rinviati e primo collaudo con repository ad hoc;
controlli runtime elencati prima ed eseguiti obbligatoriamente dopo la creazione
dello stack. Nessun controllo non eseguito conta come superato.
## Aspetti da tradurre nella specifica tecnica
- Quali template e documenti locali preparare, verificare e applicare, senza
raccogliere parametri durante l'esecuzione.
- Come distinguere validazione documentale, controlli preventivi esterni e controlli
runtime, mantenendo i contratti delle superfici amministrative esistenti.
- Criteri dettagliati di verifica, ripresa dopo errori e accettazione per ogni tappa.
## Sottoprogetto esempi: requisiti definiti, implementazione rinviata
Su richiesta dell'utente l'implementazione dei database di esempio viene rinviata;
la definizione dei requisiti dell'installazione prosegue indipendentemente.
Branch dedicato: `codex/benchmark-examples`, creato da `67ee5262`, con documentazione
consolidata nel commit `08a5db55`. Il PRD approvato è
`docs/plans/2026-09-27-example-databases-prd.md` su quel branch.
Il PRD conserva D1–D8: Financial, European Football e F1, tre workspace separati,
dati PostgreSQL e schema commentato, Evidence curate e domande di accompagnamento
senza SQL target, contenuti italiano/inglese. CLI scaricabile dal repository pubblico
degli esempi Gitea gestito da TYL Consulting, collegato dal repository pubblico
ThothII. Selezione di uno, due o tre esempi dopo il setup, oppure come ultimo passo
facoltativo dello stesso setup; pacchetti PostgreSQL già verificati ove redistribuibili.
La copia del repository potrà essere indipendente, senza storia e collegamenti Git
all'originale, oppure scaricata con accesso al repository pubblico in sola lettura.
Workspace ed Evidence locali restano modificabili. Preparazione e verifiche sono
responsabilità del progetto; all'utente vengono sottoposte solo ambiguità non
risolvibili dalle fonti. Chi installa dovrà trovare gli esempi pronti all'uso.
Il caricatore, il supporto alla cartella `examples/` e alla copia autonoma sono da
implementare. Finché il sottoprogetto è rinviato, il setup non deve offrirli come
funzionalità disponibili. Il requisito di un workspace utilizzabile resta valido:
per il collaudo si dovrà usare un workspace/database realmente disponibile.
## Verifiche da completare
Riesecuzione e fallimenti intermedi del setup; requisiti HTTPS per il repository
workspace; test dedicati dei nuovi comandi; distinzione fra stato della piattaforma
e Workspace Readiness; collaudo reale completo secondo l'ordine concordato.
## Riscontro sul setup corrente
- CLI guidata e pagine amministrative esistenti sono già il percorso previsto dal
piano del 14 settembre; non è richiesto un nuovo installer grafico.
- `setup --complete` prepara autenticazione e modelli, build, migrazioni Catalog,
avvio, workspace pull, test Pi e doctor. Non configura binding DB, sincronizzazione
dei metadati e preprocessing (`tools/tht/internal/setup/run.go:60`). Il messaggio
finale corrente «ThothII is ready» deve essere allineato al traguardo verificato.
- I modelli sono oggi preimpostati, senza selettore del provider nel Request
(`tools/tht/internal/setup/files.go:449`).
- La riesecuzione rifiuta file di configurazione esistenti con contenuto diverso;
non equivale ancora a una ripresa guidata delle tappe tecniche e umane
(`tools/tht/internal/setup/files.go:107`). La specifica deve prevedere stato delle
tappe e gestione delle correzioni, senza sovrascrivere personalizzazioni.
## Round installazione I1–I3 del 27 settembre — storico superato dove indicato
La revisione del 28 settembre sopra prevale su I1/I2: nessun rinvio di parametri
obbligatori al setup e nessuna raccolta tramite questionario. Il testo seguente
conserva il contesto della decisione precedente e non è il comportamento richiesto
per la nuova procedura. I3 resta valido per riuso dei contenuti e curation esplicita.
L'utente conferma «tutto come da te suggerito», dopo il chiarimento sulla CLI locale
interattiva: domande condizionate alle risposte, configurazioni precompilate,
credenziali protette, verifica delle connessioni, possibilità di rinviare una
configurazione e ripresa senza ricominciare. La CLI guida alle pagine amministrative
esistenti per il workspace e ne verifica il completamento.
| ID | Decisione | Scelta approvata |
| --- | --- | --- |
| I1 | Primo avvio senza repository/workspace disponibile | Consentire di completare il solo traguardo «piattaforma installata» e riprendere in seguito la configurazione del workspace. Il percorso complessivo resta incompleto fino al primo workspace utilizzabile e alla domanda reale. Nessuna dipendenza dalla futura disponibilità degli esempi. |
| I2 | Scelta dei modelli durante il setup | Selezione guidata di provider e modello tra configurazioni supportate/precompilate, chiedendo le credenziali necessarie; percorso avanzato per configurazioni personalizzate. Embedding locale preconfigurato come scelta iniziale. |
| I3 | Preparazione di descrizioni ed Evidence | Riutilizzare i contenuti già curati. Proporre la generazione AI delle descrizioni mancanti come scelta esplicita, con revisione umana, invece di avviarla automaticamente. Guidare alle pagine amministrative necessarie e registrare il punto di ripresa. |
Le Evidence sono opzionali nel contratto del workspace: non introdurre un obbligo
generale di crearle per completare l'installazione. La specifica deve rispettare
i controlli di Workspace Readiness su connessione, schema e indicizzazione,
distinguendo assenza lecita di Evidence da configurazione incompleta o incoerente.
Per le decisioni correnti e i criteri di accettazione fa fede il PRD revisionato
al 28 settembre, senza attestare che siano già implementati.
@@ -0,0 +1,387 @@
# Spec: installation from validated documents and Docker Hub releases
## Problem Statement
An operator who understands Docker and can edit a documented configuration should
not have to discover ThothII's architecture while answering an installation wizard.
The operator needs time to prepare workspace and application documents, validate
them repeatedly, and correct errors before applying changes to the machine.
The current setup combines document generation, local builds, startup and runtime
diagnostics. Its success message can precede an actually usable workspace. It does
not provide the complete preinstallation validation and resumable, non-interactive
execution required by this workflow.
The ordinary installation must consume published, prebuilt application images.
As reported by the project owner on 28 September 2026, ThothII images are not yet
available on Docker Hub. Publishing and verifying a real release is therefore a
prerequisite for testing the consumer installation, rather than a later enhancement.
## Solution
Deliver a documented six-step workflow, in this exact order:
1. Prepare a workspace repository: a project-specific repository initially, or the
default examples repository when that separately deferred project is available.
2. Validate workspace documents formally and substantively to the extent possible
from their contents and available references.
3. Verify host and distribution prerequisites.
4. Prepare application parameters in installation-local YAML and protected
environment/secret documents, using commented templates and complete examples.
5. Validate application parameters and their consistency with the selected workspaces.
6. Execute the validated installation without asking configuration questions: pull
the release images, create and initialize the stack, apply declared configuration,
and run the required runtime checks.
Before these consumer steps can be tested against Docker Hub, a maintainer command
must build, check and publish the release and verify that its artifacts can be pulled.
Local source builds remain an explicit alternative, with the same configuration and
persistence contracts. A failed pull never silently switches to source compilation.
Checks that cannot run before container or database creation are enumerated as
deferred obligations and must run after startup. Errors detectable beforehand block
execution. Platform acceptance, Workspace Readiness and functional acceptance are
reported separately. A real question with human review completes functional acceptance.
## User Stories
1. As an installer, I want a six-step guide, so that I can understand the whole process before changing my machine.
2. As an installer, I want commented templates and completed examples, so that I can prepare documents without knowing internal component names.
3. As an installer, I want to pause document preparation, so that I can obtain missing information without restarting an installer.
4. As an installer, I want to prepare a project-specific workspace repository, so that I can use my own database before the example databases are delivered.
5. As an installer, I want the future default repository to be clearly distinguished from available features, so that I am not directed to unavailable examples.
6. As an installer, I want read-only access to the source workspace repository, so that consuming a workspace does not require publication rights.
7. As a workspace author, I want local document validation before Docker starts, so that syntax and contract errors are inexpensive to correct.
8. As a workspace author, I want duplicate identifiers, unsupported fields and inconsistent references reported, so that formally valid YAML does not hide an invalid workspace.
9. As a workspace author, I want errors to identify the document and field, so that I know precisely what to edit.
10. As a workspace author, I want optional Evidence distinguished from invalid configured Evidence, so that an intentionally absent corpus does not block installation.
11. As a workspace author, I want semantic validation limits stated honestly, so that successful validation is not mistaken for certification of domain knowledge.
12. As an installer, I want host prerequisites checked explicitly, so that Docker, architecture, permissions and resource problems are detected before setup.
13. As a Windows installer, I want a verified WSL2 and Docker Desktop path, so that I can follow one supported installation procedure.
14. As an installer, I want Pi supplied with the application image, so that I do not have to install an unnecessary host dependency.
15. As an installer, I want application models, endpoints and credentials prepared before execution, so that I can consult colleagues or provider documentation at my own pace.
16. As an installer, I want one authored model catalog, so that provider and embedding configuration do not disagree across components.
17. As an installer, I want database bindings prepared locally and separately from workspace definitions, so that environment-specific details do not leak into shared workspace repositories.
18. As an installer, I want generated internal credentials prepared in protected files, so that I need not invent technical passwords during execution.
19. As an installer, I want my secrets excluded from logs and validation reports, so that diagnostic output is safe to inspect and share.
20. As an installer, I want repeatable application validation, so that I can correct configuration without creating containers or changing databases.
21. As an installer, I want available external connections checked before startup, so that preventable endpoint or authentication errors are found early.
22. As an installer, I want non-executable checks listed explicitly, so that I know what still needs to be proved after startup.
23. As an installer, I want validation tied to the documents and release I selected, so that execution cannot silently apply different inputs.
24. As an installer, I want setup to run with closed standard input, so that it cannot unexpectedly ask me for a parameter.
25. As an installer, I want missing values to stop setup with a useful diagnosis, so that I can correct the document and validate again.
26. As an installer, I want prebuilt images downloaded from Docker Hub, so that I need no application source checkout or compiler.
27. As an installer, I want the operator tool supplied precompiled, so that its bootstrap does not hide a local build.
28. As an installer, I want release components to be compatible and identifiable, so that my installation is reproducible.
29. As an installer, I want registry failures reported without automatic compilation, so that the selected installation mode remains predictable.
30. As an installer, I want configuration, credentials and data kept outside application images, so that my installation remains local and persistent.
31. As an installer, I want migrations and database binding initialization handled explicitly, so that a running container is not mistaken for an initialized application.
32. As an installer, I want existing curated descriptions and Evidence preserved, so that rerunning setup cannot overwrite human work.
33. As an installer, I want runtime checks performed from the actual container environment, so that host connectivity is not confused with application connectivity.
34. As an installer, I want completed and failed execution stages recorded, so that I can resume after interruption without duplicating data.
35. As an installer, I want configuration corrections to invalidate dependent checks, so that resuming does not trust stale results.
36. As an administrator, I want Catalog changes to retain their existing confirmation and concurrency rules, so that installation automation does not bypass domain safeguards.
37. As an installer, I want separate platform and workspace status, so that I know whether I can already ask a real question.
38. As an installer, I want stop/start behavior checked, so that a successful first run is not the only working state.
39. As a maintainer, I want an explicit release build-and-publish command, so that consumer installation can use actual Docker Hub artifacts.
40. As a maintainer, I want versioned images and release metadata, so that published artifacts can be traced to a source revision.
41. As a maintainer, I want publication credentials isolated from consumer configuration, so that installers need no write access to Docker Hub.
42. As a maintainer, I want the published artifacts tested by pulling them, so that an unpublished local image cannot satisfy release acceptance.
43. As a source-build user, I want an explicit supported build path, so that source customization remains possible.
44. As a maintainer, I want Windows, Omarchy and macOS acceptance recorded separately and in order, so that support claims reflect tests actually performed.
45. As an Italian or English reader, I want matching step-by-step guides, so that language choice does not change the installation contract.
## Implementation Decisions
### Boundaries and authority
- Keep the existing standalone architecture and operator CLI. The application runs
through Compose; the operator CLI owns preparation, validation and execution of
the installation, rather than introducing another installer or backend workflow.
- Preserve Workspace schema v4 and the workspace catalog as the authority for
workspace identity and optional Evidence. Formal validation uses the same rules
as runtime loading, including strict YAML interpretation, duplicate detection,
directory/index consistency and prohibited fields.
- Preserve the Installation Model Catalog as the sole authored source for provider,
model eligibility, defaults and embedding facts. Session and embedding configuration
must be complete before ordinary execution. Metadata generation remains optional
when its catalog configuration is omitted consistently.
- Preserve the PostgreSQL Metadata Catalog as the authority for Workspace Database
identity, schema, metadata and active Database Binding. Respect the existing
one-database-per-workspace boundary. Prepared binding documents are bootstrap
inputs, not a second runtime database catalog.
- Preserve installation-local secret storage, reference-based binding credentials,
editable Evidence authority, read-only DWH access and the separation of reference
preprocessing from Memory. Do not rewrite model projections or runtime snapshots
as independent authored configuration.
### Preparation documents and command surfaces
- Expose distinct operator operations for template preparation, workspace validation,
host checks, application validation, execution, status and resumption. Their exact
CLI spelling can be finalized with the implementation tickets; they must remain
separately invocable and scriptable. Setup execution is never a parameter wizard.
- Template preparation creates explicitly requested sample documents or protected
credential files without starting application services. It never silently replaces
existing user documents. Placeholders are visibly incomplete and cannot pass the
required-field checks.
- Supply a versioned, installation-local database bootstrap document describing the
workspace identity, engine, physical database/schema, transport and endpoint
configuration, and references to secrets. Validate against existing Catalog
capabilities and selected workspace identities. No credentials belong in the
shared workspace descriptors or public repository.
- Binding imports use authenticated, authorized Catalog services and their version
checks. On first execution they create the declared bindings and install secrets
through the existing store. On rerun, equivalent values are a no-op; conflicting
existing administrative changes stop with a reconciliation report. The bootstrap
input does not continuously overwrite a mutable Catalog.
- Initial local administrative authentication and its protected bootstrap material
are prepared before execution. Execution cannot depend on a person answering a
login wizard. Reuse supported operator authentication boundaries and required
permissions, without introducing a privileged unauthenticated installation API.
- Supply a precompiled validation capability with the operator distribution. The
workspace validation step must work without Docker, ThothII, Node or an application
source checkout. Reuse canonical validation rules; if a new packaging boundary is
necessary, prove equivalence with a shared set of valid and invalid documents.
### Validation contract
- Workspace validation covers syntax, strict schema, identity, catalog/descriptor
relationships and locally available Evidence references. It does not claim to
establish the truth of domain rules, database contents or services that do not exist.
- Host checks distinguish host prerequisites from bundled application dependencies.
Pi is checked as a release component and subsequently in the running core image,
never required as a separate host installation.
- Application validation checks required configuration, compatible release and host,
model usages/defaults, protected secret references, database transport capabilities,
workspace links, effective Compose configuration and accessible remote dependencies.
A transport that cannot serve NL-to-SQL sessions cannot pass workspace-readiness
validation merely because it can perform administrative diagnostics.
- Reports expose a stable outcome, check identifier, affected logical input/field,
explanation and next action. The public outcomes are passed, error, warning and
deferred-to-runtime. Human-readable output is accompanied by pristine structured
output for automation; exit status distinguishes success from blocking failure.
- Validation is read-only with respect to user documents, application state and
target databases. Explicit report output is allowed. Remote checks are bounded,
documented and non-mutating; any provider usage incurred by a configured smoke
test is disclosed before invocation, not requested interactively during setup.
- Missing or invalid required configuration, missing release artifacts, unsupported
architecture and failures of available required dependencies block execution.
Unreachable existing external services are errors, not automatically reclassified
as deferred. Only checks intrinsically dependent on the not-yet-created local
stack qualify for the accepted deferred category.
- Maintain an explicit obligation list for deferred checks: container-network
connectivity, Catalog initialization, Pi operation, local embedding availability,
preprocessing and relevant workspace runtime readiness. Each obligation has a
defined runtime check; there is no successful final state while a required
obligation remains unverified or failed.
- Bind the execution plan to normalized non-secret configuration, repository revision
and verified content, selected release digests and validator version. Re-read
protected credentials when checking or executing; do not expose their values or
unkeyed secret-derived fingerprints in reports. Re-run credential checks where
freshness cannot be established safely.
- Revalidate changed dependencies and live prerequisites at execution or resume.
A previously successful report is not blanket authorization to apply changed files
or evidence of current network availability.
### Release production and distribution
- Provide a maintainer command accepting source revision, release version, Docker Hub
namespace and target architectures. It performs preflight checks, reproducible
builds, artifact checks, publication and output of a coherent release manifest.
This command is a deliverable of this feature, not an undocumented manual prerequisite.
- Publish the existing core and frontend application images. Catalog migration and
workspace maintenance use the same released core image. Keep PostgreSQL, Qdrant
and Ollama as compatible upstream images; preserve the existing service boundaries.
- Package the operator executable, validation capability, Compose definitions,
initialization resources and migration support required by the release. No runtime
mount may require a resource that exists only in an application source checkout.
- Pin the release identity and resolved image digests. A published release manifest
binds compatible images and operator/configuration versions. Do not overwrite an
already published immutable release version or declare a partially published
image set installable. An interrupted publication can retry without advertising
an incomplete consumer release.
- Keep publishing credentials outside consumer bundles and logs. Public consumers
pull without publishing rights. Application images contain application software,
not installation secrets, user workspace data or prepopulated example databases.
- The ordinary bootstrap downloads a precompiled operator and release artifacts.
It must not compile the CLI through Docker as a hidden fallback. Windows WSL2
uses the Linux executable; macOS uses an appropriate host executable.
- Deliver and accept Linux amd64 images for Windows/WSL2 and Omarchy first. Add and
accept Linux arm64 for the macOS Apple Silicon stage. Multiarchitecture build
results do not by themselves prove host installation acceptance.
- A maintainer smoke test pulls the published artifacts by their release references.
Consumer acceptance runs must not succeed because of an unpushed locally built
image. Confirm core, frontend and maintenance references all resolve to the release.
- Retain explicit source mode with the same configuration, validation and persistence
rules. It is not the default and is never an automatic recovery action for a pull
failure. Registry recovery is a retry of the selected released artifacts.
### Non-interactive execution and recovery
- Execute only a complete, currently validated plan with no blocking errors. The
ordinary sequence pulls the release, prepares runtime projections and isolated
installation storage, starts required services, applies migrations, registers the
workspace source and imports the prepared Catalog bindings before dependent work.
- Apply configuration and migrations through existing service boundaries. Hold an
installation execution lock to prevent concurrent runs from racing over the same
state, containers or bootstrap imports.
- Reuse durable Catalog Sync Runs and their freshness/locking rules. Fresh additive
synchronization can proceed under the existing contract. A destructive diff or
another domain-required human decision stops at an explicit awaiting-review state;
the operator reviews through the existing administration surface and subsequently
resumes. This is a domain decision, not permission to collect missing setup parameters
or add an automatic confirmation bypass.
- Reuse existing curated metadata and Evidence. Do not generate AI descriptions or
new domain rules as an installation side effect. Optional generation remains a
separate explicit administrative action. Required preprocessing can use supported
source comments or curated descriptions without mandatory AI generation.
- Persist a bounded execution journal with installation identity, plan identity,
stage outcomes, released component versions, deferred-check outcomes and recovery
guidance. Do not persist secret values or raw exception output. Write progress
atomically and check actual state on resume.
- A repeated execution of the same completed plan must not recreate bindings,
duplicate data, clear Memory, overwrite Evidence or erase sessions. Reconcile
already completed stages with their actual persistent state before proceeding.
- After a document correction, revalidate and repeat only affected checks/stages.
Do not infer that an interrupted migration or import failed before inspecting its
durable result. Preprocessing, which has no internal resume contract, may need to
rerun as a whole; report that honestly.
- Never implement recovery by deleting all volumes or reverting user data. Report
the failing stage and safe next action. Failed or interrupted execution is not
advertised as a complete installation.
- Report platform state, Workspace Readiness and functional acceptance separately.
Runtime checks exercise the released Pi, embedding and actual container transport.
Final acceptance includes a real human-reviewed question and stop/start persistence.
The workflow's human review is not replaced by unattended benchmark evaluation.
### Documentation and delivery boundaries
- Keep Italian and English guides aligned with the six steps. Each step states its
inputs, documents, examples, verification operation, expected output and common
corrections. Provide an advance checklist of information and credentials to collect.
- Separate maintainer publication instructions, consumer prebuilt installation and
source-build instructions. State precisely which components are host prerequisites
and which are shipped inside the release.
- First acceptance uses an available project-specific repository and database.
The Financial, European Football and F1 project stays deferred. Do not expose its
unavailable loader, repository-copy support or data bundles as usable features.
- Preserve the future integration boundary: examples will be selected in documents
and loaded locally after application initialization, without rebuilding the
application or embedding the datasets into its Docker images.
## Testing Decisions
### Confirmed test boundaries
Use the public operator workflow as the principal test boundary: prepared documents
in, stable reports/exit statuses and observable installation outcomes out. Exercise
validation and execution through this boundary while replacing external command
execution and remote services with controllable test counterparts. Keep focused
contract tests at existing workspace parsing and Catalog boundaries where they
prevent divergent schemas or authority rules. Add real release and host acceptance
tests for behavior that simulated external services cannot establish.
The project owner confirmed this testing boundary on 28 September 2026, completing
the `/to-spec` checkpoint. The product decisions, six-step workflow and testing
scope are approved for specification publication and subsequent ticket decomposition.
### Existing testing practice to extend
- Operator setup tests already use temporary installation fixtures and a replaceable
command runner to cover sequencing, startup failures, error preservation and recovery
messages. Extend that boundary to document validation, prebuilt execution and resumption.
- Installation configuration tests cover strict schemas, model catalog rules and
incompatible existing files. Extend them with complete/incomplete preparation
documents, protected references and cross-document consistency.
- Workspace tests cover strict catalog/descriptor parsing, immutable Git revisions,
runtime handoff and secret handling. Reuse their document fixtures and validity
rules to demonstrate equivalence of the preinstallation validator.
- Catalog and preprocessing tests already cover durable runs, locks, binding freshness,
failure recording and preservation of authoritative state. Reuse these boundaries
to verify bootstrap import and resumption without bypassing the domain contracts.
- Existing multiarchitecture image checks provide a starting point for released
artifact verification. They do not replace pulling the published artifacts or
testing supported host environments.
### Required behavioral coverage
1. Validate workspace documents with Docker absent and no application runtime;
reject malformed YAML, duplicate keys/identifiers, unsupported fields, broken
references and invalid configured Evidence with actionable locations.
2. Repeated document/host/application checks do not create containers, change source
documents, import data or migrate databases. Only explicitly requested reports
may be written by validation.
3. Complete application documents pass; placeholders, missing required models,
invalid binding transport and unreadable secret references block execution.
4. Existing external-service failures remain errors. Checks genuinely dependent on
newly created local services are listed as deferred and cannot disappear from
final acceptance.
5. Execute with standard input closed. Valid inputs need no responses; missing
values yield an error without waiting for input or prompting for a replacement.
6. Changing documents, workspace revision or release after validation invalidates
dependent results. Credential changes are caught without leaking secret material.
7. A clean prebuilt consumer installation performs pulls and initialization, never
application or operator compilation; absent images fail without a source fallback.
8. Published manifests resolve all required images, platform variants and maintenance
components coherently. Simulated publication interruption does not advertise a
partial release; a real smoke test exercises artifacts pulled from Docker Hub.
9. Prepared database bindings become Catalog state once, use the existing protected
secret store and remain unchanged on equivalent reruns. Administrative divergence
is reported rather than silently overwritten.
10. Interruption after a durable operation but before journal completion resumes by
inspecting state, without duplicating that operation. Concurrent setup runs cannot
mutate the same installation simultaneously.
11. Destructive Catalog synchronization requires its existing review and fresh source
checks. The setup's non-interactive nature does not auto-approve a destructive diff.
12. Existing descriptions, Evidence, sessions and Memory survive validation, rerun,
configuration correction and stop/start. Optional AI generation is not triggered.
13. Runtime Pi, embedding and connectivity checks are executed from the installed
release, and platform success cannot mask failed Workspace Readiness.
14. Source mode remains functional and explicit with equivalent configuration
contracts. A source-mode pass cannot close prebuilt distribution acceptance.
15. Execute real consumer acceptance first on Windows x64/WSL2, then Omarchy x64,
then macOS Apple Silicon. Record each environment, release identity, stage
outcomes, a real reviewed question and a stop/start check separately.
Good tests assert observable contracts, preserved data and required side effects,
not private helper calls or incidental internal ordering. Use real isolated Catalog
instances where transaction and lock behavior matters. Mock external provider
failures for repeatable tests, while keeping actual DWH/model acceptance separate.
## Out of Scope
- Implementing or publishing the three example databases, their curated contents,
example CLI, auxiliary repository layout or autonomous repository-copy mode.
- A new graphical installer, a conversational parameter wizard, or collecting
required parameters in administration pages after an incomplete setup.
- Installing Pi, Python, Node or an application build toolchain on ordinary consumer hosts.
- Changes to server/Omics deployment, upstream authentication or the application's
existing human-in-the-loop workflow.
- Multiple workspace repositories per installation or multiple databases per workspace.
- Automatic release upgrades, destructive reset/uninstall, whole-volume rollback
or backup-policy redesign. Interrupted initial setup recovery remains in scope.
- Automatic semantic certification, generated Evidence without sources, benchmark
SQL targets or automated accuracy scoring.
- Publishing Docker images or executing installations as part of this specification
authoring task. These are implementation and release deliverables described above.
## Further Notes
The project owner approved the six-step revision and both final clarifications on
28 September 2026: examples stay deferred, and runtime-only checks are explicit
post-start obligations. Earlier interactive-wizard and incomplete-configuration
installation proposals are superseded where they conflict with this specification.
This specification follows the existing decisions on PostgreSQL metadata authority,
installation-local bindings, secret references, durable schema synchronization and
the Installation Model Catalog. It does not change those architectural authorities.
Publication of a usable Docker Hub release is a blocking dependency of consumer
prebuilt-installation acceptance. Availability of the deferred examples is not.
Docker Hub namespace, publishing credentials and concrete release versions are
maintainer release inputs, not values to invent or embed into user templates.
After publication to Gitea, `/to-tickets` will split this specification into small
end-to-end increments with explicit blockers. Implementation has not started in
this task, and publishing the specification does not attest that a release exists.
@@ -0,0 +1,420 @@
# Scomposizione della specifica di installazione
Data: 2026-09-28. Stato: scomposizione approvata dall'utente («approvo»);
pubblicazione `/to-tickets` completata. Verificati testi, etichetta `ready-for-agent`
e dipendenze native delle issue #43–#54; specifica parent invariata.
Parent: [Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
Gli identificatori T01–T12 restano riferimenti della scomposizione; le issue reali
sono elencate sotto. Ogni issue usa `ready-for-agent` e dipendenze native Gitea.
La parent non viene modificata né chiusa.
## Issue pubblicate
Avanzamento locale, 2026-09-28: T01/#43 implementato sul branch
`codex/guided-standalone-install`. Disponibili `tht workspace prepare` e
`tht workspace validate`, con helper autonomo che riusa i parser runtime. Guide
IT/EN aggiornate; esempi e pubblicazione degli artefatti restano differiti ai ticket
previsti. Nessuna chiusura o modifica della parent effettuata.
Verifica: 14 test della CLI passano sia da sorgenti sia con il bundle nativo macOS
arm64 e `PATH` vuoto; artefatti Windows amd64/Linux amd64 cross-compilati, senza
attribuire loro un collaudo host. Backend su Node 24.16: 109 file passati, un file
saltato, 1.417 test passati e 40 saltati; typecheck e build rigorosa documentazione
passati. Suite Go: tutti i pacchetti passati, salvo un primo errore intermittente
nel test di concorrenza authstorage; quel test passa in tre ripetizioni e il pacchetto
completo passa nella verifica isolata. Le revisioni Standards e Spec non lasciano
finding aperti.
T02/#44 implementato sullo stesso branch: `tht installation prepare`, generazione
esplicita delle credenziali tecniche e `tht installation validate` preparano e
controllano documenti privati, modelli, autenticazione e bootstrap dei binding,
riusando gli schemi runtime senza avviare servizi. Guide IT/EN aggiornate.
Verifica backend completa su Node 24.16: 111 file passati, uno saltato, 1.420 test
passati e 40 saltati; le regressioni successive della revisione passano nella suite
mirata (quattro test, incluso il percorso con binari nativi e `PATH` vuoto).
Typecheck, build rigorosa documentazione e pacchetti Go passati; il pacchetto CLI
è stato ripetuto dopo la correzione rilevata in revisione. Nessun finding residuo
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 |
| --- | --- | --- |
| T01 | [Preparare e validare un repository workspace senza stack](https://git.tylconsulting.it/mptyl/ThothII/issues/43) | Nessuna |
| T02 | [Preparare e validare i documenti applicativi](https://git.tylconsulting.it/mptyl/ThothII/issues/44) | #43 |
| T03 | [Verificare precondizioni e produrre il piano eseguibile](https://git.tylconsulting.it/mptyl/ThothII/issues/45) | #44 |
| T04 | [Produrre e pubblicare un rilascio Docker Hub installabile](https://git.tylconsulting.it/mptyl/ThothII/issues/46) | Nessuna |
| T05 | [Installare la piattaforma dal rilascio senza domande](https://git.tylconsulting.it/mptyl/ThothII/issues/47) | #45, #46 |
| T06 | [Applicare i binding preparati al Catalog](https://git.tylconsulting.it/mptyl/ThothII/issues/48) | #47 |
| T07 | [Portare il workspace alla readiness con controlli runtime](https://git.tylconsulting.it/mptyl/ThothII/issues/49) | #48 |
| T08 | [Conservare il percorso esplicito da sorgente](https://git.tylconsulting.it/mptyl/ThothII/issues/50) | #47 |
| T09 | [Riprendere dopo correzioni e interruzioni senza perdere stato](https://git.tylconsulting.it/mptyl/ThothII/issues/51) | #49, #50 |
| T10 | [Collaudare l'installazione pubblicata su Windows/WSL2](https://git.tylconsulting.it/mptyl/ThothII/issues/52) | #51 |
| T11 | [Collaudare l'installazione su Omarchy](https://git.tylconsulting.it/mptyl/ThothII/issues/53) | #52 |
| T12 | [Pubblicare e collaudare il percorso macOS Apple Silicon](https://git.tylconsulting.it/mptyl/ThothII/issues/54) | #53 |
Ogni ticket comprende verifiche del comportamento e aggiornamenti pertinenti delle
guide IT/EN. Le dipendenze elencate sono dirette; non si ripetono quelle transitive.
Si riusano il runner dell'operatore e i servizi di dominio esistenti; gli adattamenti
necessari sono inclusi nella prima funzionalità che li usa. Non emerge una necessità
di refactoring trasversale da pubblicare come lavoro orizzontale separato.
| Ticket | Titolo | Bloccato da | Risultato dimostrabile |
| --- | --- | --- | --- |
| T01 | Preparare e validare un repository workspace senza stack | Nessuno | Template e controllo locale conformi ai contratti, senza Docker attivo. |
| T02 | Preparare e validare i documenti applicativi | T01 | Parametri, modelli e binding completi verificati senza avviare servizi. |
| T03 | Verificare precondizioni e produrre il piano eseguibile | T02 | Rapporto con errori bloccanti e obblighi runtime, legato agli input. |
| T04 | Produrre e pubblicare un rilascio Docker Hub installabile | Nessuno | Comando manutentore e pacchetto pubblico verificato tramite pull. |
| T05 | Installare la piattaforma dal rilascio senza domande | T03, T04 | Pull, inizializzazione e avvio da documenti, con stato e ripresa delle fasi. |
| T06 | Applicare i binding preparati al Catalog | T05 | Database dei workspace configurati senza questionari o duplicazioni. |
| T07 | Portare il workspace alla readiness con controlli runtime | T06 | Schema, preprocessing e servizi verificati senza aggirare la revisione umana. |
| T08 | Conservare il percorso esplicito da sorgente | T05 | Stessi input e contratti, con build scelta esplicitamente. |
| T09 | Riprendere dopo correzioni e interruzioni senza perdere stato | T07, T08 | Recupero dell'intero percorso, compresi binding, sync e preprocessing. |
| T10 | Collaudare l'installazione pubblicata su Windows/WSL2 | T09 | Prima accettazione reale senza sorgenti o compilatori. |
| T11 | Collaudare l'installazione su Omarchy | T10 | Seconda accettazione reale su Linux x64, distinta da Windows. |
| T12 | Pubblicare e collaudare il percorso macOS Apple Silicon | T11 | Terza accettazione con immagini arm64 e comando host compatibile. |
## T01 — Preparare e validare un repository workspace senza stack
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Un autore prepara un repository ad hoc usando un template documentato e verifica
i documenti localmente prima di installare ThothII. Il validatore è fornito come
capacità eseguibile senza Node, Docker attivo o checkout dei sorgenti applicativi.
Riusa i contratti del runtime, senza creare uno schema workspace alternativo.
### Acceptance criteria
- [ ] Il template distingue catalogo workspace, descriptor ed Evidence opzionali e non contiene funzionalità degli esempi ancora indisponibili.
- [ ] Preparare un template non avvia servizi, non sovrascrive documenti esistenti e non richiede accesso in scrittura al repository originale.
- [ ] Il controllo respinge YAML ambiguo o invalido, chiavi duplicate, identificatori duplicati, campi estranei, incoerenze catalogo/directory e riferimenti locali mancanti.
- [ ] Le Evidence configurate sono verificate per ciò che è controllabile localmente; assenza lecita e invalidità sono distinte, senza certificare il significato delle regole di dominio.
- [ ] Gli esiti identificano documento/campo e correzione; output strutturato e codici di uscita sono verificabili senza esporre segreti.
- [ ] Una raccolta condivisa di casi validi/invalidi prova equivalenza con i parser runtime, e il controllo passa senza Docker e senza runtime host aggiuntivi.
- [ ] Guide IT/EN mostrano preparazione, correzione e ripetizione del passo 2.
### Blocked by
None (can start immediately).
## T02 — Preparare e validare i documenti applicativi
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
L'operatore compila template locali per installazione, modelli, binding database e
segreti, e ne verifica completezza e coerenza con i workspace già verificati.
Non viene avviata l'applicazione e nessun valore viene richiesto dal futuro setup.
### Acceptance criteria
- [ ] Template commentati ed esempi completi spiegano obblighi, default e riferimenti ai documenti protetti; i placeholder non superano la validazione.
- [ ] Modelli e embedding rispettano l'Installation Model Catalog; generazione metadati opzionale e default sono coerenti con i contratti esistenti.
- [ ] Un input bootstrap locale versionato descrive Workspace Database e Database Binding con riferimenti ai segreti; i descriptor workspace restano conformi allo schema v4.
- [ ] Validazione incrociata di workspace, binding, engine/trasporto, modelli, percorsi e file ambiente, senza migrare o interrogare in scrittura alcun database.
- [ ] La generazione esplicita delle credenziali tecniche produce file protetti prima del setup, senza sovrascritture o segreti nei log/rapporti.
- [ ] I test coprono input completi, mancanti, incompatibili e segreti illeggibili; i documenti dell'utente rimangono invariati durante le verifiche.
- [ ] Guide IT/EN consentono di raccogliere e preparare tutte le informazioni con calma prima dell'esecuzione.
### Blocked by
- T01 — Preparare e validare un repository workspace senza stack.
## T03 — Verificare precondizioni e produrre il piano eseguibile
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
L'operatore verifica host e dipendenze esterne disponibili, quindi ottiene un piano
eseguibile riferito ai documenti e al rilascio scelti. Il rapporto distingue errori,
avvisi e controlli necessariamente rinviati al runtime, senza creare lo stack.
### Acceptance criteria
- [ ] I controlli host sono invocabili al passo 3; quelli dipendenti dai parametri finali sono completati o ripetuti al passo 5.
- [ ] Sono verificati Docker/Compose, architettura, WSL2 quando pertinente, percorsi/permessi, risorse e disponibilità del rilascio e dei suoi componenti nel registry.
- [ ] Le prove sulle dipendenze esterne disponibili sono circoscritte e documentate; un servizio esistente irraggiungibile non viene promosso a semplice controllo differito.
- [ ] Pi non è richiesto sull'host; le dipendenze incluse nelle immagini sono riconosciute nel manifest e associate a controlli runtime precisi.
- [ ] Il piano registra input non segreti, revisione/contenuti workspace, release e versione del validatore; nessun segreto o fingerprint pubblico non protetto di segreti.
- [ ] Ogni controllo differito ha un'identità e un'obbligazione runtime; valori obbligatori mancanti o immagini assenti bloccano il piano.
- [ ] Prove con manifest e servizi controllati coprono cambiamento degli input, credenziali, errori di rete e architetture; nessuna creazione di container o mutazione di dati.
- [ ] Guide IT/EN spiegano rapporto, errori e verifiche ancora da eseguire. La prova con il rilascio reale verrà completata dal ticket di esecuzione, dopo T04.
### Blocked by
- T02 — Preparare e validare i documenti applicativi.
## T04 — Produrre e pubblicare un rilascio Docker Hub installabile
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Il manutentore esegue un comando riproducibile che costruisce, verifica e pubblica
core/frontend su Docker Hub, insieme al pacchetto operatore compatibile, e dimostra
che il rilascio pubblicato è scaricabile. La pubblicazione delle immagini oggi
mancanti è un risultato concreto del ticket, non un prerequisito lasciato a mano.
### Acceptance criteria
- [ ] Il comando riceve revisione, versione, namespace e architetture e mantiene fuori da bundle/log le credenziali di pubblicazione.
- [ ] Pubblica core/frontend Linux amd64 per la prima tappa; Catalog migration e workspace maintenance risolvono alla stessa immagine core del rilascio.
- [ ] Il bundle contiene comando host precompilato, Compose, inizializzazione e risorse di migrazione, senza dipendenze da checkout sorgente durante l'avvio.
- [ ] Il processo può includere la capacità di validazione preinstallazione prodotta da T01 nelle revisioni che la contengono; non serve duplicarne l'implementazione per questo ticket.
- [ ] Il manifest lega versione/revisione e digest compatibili; una pubblicazione parziale non viene esposta come rilascio completo e una versione immutabile non viene sovrascritta.
- [ ] Viene pubblicato un rilascio reale e viene verificato il pull degli artefatti pubblicati, senza affidarsi a immagini presenti soltanto nella cache di build.
- [ ] Test automatici verificano orchestrazione, fallimenti e retry senza richiedere una pubblicazione reale a ogni test; la prova reale del ticket resta distinta e registrata.
- [ ] Documentazione manutentore IT/EN e istruzioni del bundle distinguono pubblicazione, consumo e futura estensione arm64. Namespace e accessi effettivi sono input del manutentore, non valori inventati.
### Blocked by
None (can start immediately).
## T05 — Installare la piattaforma dal rilascio senza domande
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
L'operatore applica un piano verificato, scarica gli artefatti pubblicati e ottiene
una piattaforma inizializzata e accessibile senza compilazione o domande. Lo stato
registrato permette di ritentare le fasi di piattaforma interrotte; non viene ancora
dichiarato pronto un workspace privo delle successive verifiche Catalog.
### Acceptance criteria
- [ ] Avvio da bundle rilasciato e operatore precompilato, senza checkout applicativo o toolchain; la revisione del bundle include i validatori e i comandi effettivamente utilizzati.
- [ ] Il piano viene ricontrollato rispetto a input, release e prerequisiti vivi prima delle mutazioni; un piano mancante o incoerente viene rifiutato.
- [ ] Con standard input chiuso il setup esegue pull, configurazione runtime, reti/volumi/container, inizializzazione Catalog/Memory e migrazioni senza richiedere parametri.
- [ ] L'accesso amministrativo iniziale deriva da materiale protetto preparato prima; non si introduce un endpoint privilegiato senza autenticazione.
- [ ] Un lock impedisce esecuzioni concorrenti; il journal atomico registra le fasi senza segreti e consente di verificare lo stato reale prima di ripetere una fase interrotta.
- [ ] Errori di pull non causano build locali; errori di configurazione rimandano ai documenti e alla nuova verifica, senza prompt di riparazione.
- [ ] La piattaforma accessibile è distinta dalla Workspace Readiness ancora da verificare; nessun messaggio finale prematuro di piena utilizzabilità.
- [ ] Test del runner e prova con artefatti pubblicati coprono successo, stdin chiuso, interruzioni e ripetizione senza cancellare volumi o dati. Guide IT/EN documentano il risultato parziale corretto.
### Blocked by
- T03 — Verificare precondizioni e produrre il piano eseguibile.
- T04 — Produrre e pubblicare un rilascio Docker Hub installabile.
## T06 — Applicare i binding preparati al Catalog
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Il setup rende operativa la configurazione database predisposta nei documenti:
registra il repository, crea i Workspace Database e le Database Binding nel Catalog,
installa i riferimenti segreti e verifica le connessioni. Il risultato è un binding
utilizzabile senza una compilazione manuale dei parametri nell'interfaccia web.
### Acceptance criteria
- [ ] Identità e revisioni dei workspace corrispondono al piano; il consumo del repository non richiede push e non sostituisce implicitamente una sorgente esistente.
- [ ] Creazione e modifica dei binding utilizzano servizi autorizzati, controlli di versione e secret store esistenti; nessuna seconda autorità runtime nei documenti bootstrap.
- [ ] La stessa configurazione applicata due volte non duplica record, credenziali o binding.
- [ ] Una modifica amministrativa incompatibile produce un rapporto di riconciliazione invece di essere sovrascritta dai file preparatori.
- [ ] La connessione viene controllata dall'ambiente applicativo; un esito positivo ottenuto dall'host non basta a dichiararla utilizzabile dai container.
- [ ] Un'interruzione dopo il salvataggio ma prima dell'aggiornamento del journal viene riconosciuta alla ripresa, senza duplicazioni o perdita di segreti.
- [ ] Test di contratto e integrazione Catalog coprono autorizzazioni, concorrenza, versioni e rerun; guide IT/EN illustrano diagnosi e riconciliazione.
### Blocked by
- T05 — Installare la piattaforma dal rilascio senza domande.
## T07 — Portare il workspace alla readiness con controlli runtime
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Da un binding applicato, il percorso completa sincronizzazione dello schema e
preparazione necessaria e rende visibili i risultati dei controlli runtime.
Un workspace è pronto solo quando tutti i requisiti applicabili sono verificati;
le decisioni umane già previste dai contratti rimangono esplicite.
### Acceptance criteria
- [ ] La sincronizzazione usa i Catalog Sync Runs durabili con lock, freschezza e transazioni esistenti; non introduce una seconda implementazione.
- [ ] Diff distruttive fermano il percorso in attesa della revisione di dominio esistente; ripresa successiva senza auto-conferme né domande sui parametri di setup.
- [ ] Preprocessing e consolidamento riusano descrizioni/commenti ed Evidence curate; nessuna generazione AI implicita, nessuna cancellazione di Memory o sovrascrittura di curation.
- [ ] Assenza lecita di Evidence non blocca; Evidence configurate ma invalide e indici necessari non pronti restano blocchi reali.
- [ ] Pi, modello embedding, trasporto DWH e altri obblighi differiti sono eseguiti a runtime e rendicontati; nessun obbligo scompare o viene considerato superato senza prova.
- [ ] Stato piattaforma, Workspace Readiness e collaudo funzionale sono distinti; una domanda reale con revisione rimane la prova funzionale, senza SQL target.
- [ ] Test di servizio e integrazione dimostrano esiti, conservazione dati e ripresa dei run; guide IT/EN spiegano le eventuali revisioni umane residue.
### Blocked by
- T06 — Applicare i binding preparati al Catalog.
## T08 — Conservare il percorso esplicito da sorgente
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Un operatore sceglie esplicitamente la build da una revisione sorgente e usa gli
stessi documenti, validatori, identità d'installazione e servizi del percorso
precompilato. L'alternativa resta praticabile mentre il default diventa Docker Hub.
### Acceptance criteria
- [ ] Modalità sorgente e prerequisiti aggiuntivi sono espliciti; nessun errore del registry la attiva automaticamente.
- [ ] I componenti costruiti sono equivalenti nei contratti di configurazione, migrazione e persistenza; non esistono implementazioni parallele dei binding o della readiness.
- [ ] Il piano identifica modalità e revisione e invalida i controlli dipendenti quando cambiano.
- [ ] L'esecuzione rimane non interattiva e usa journal/lock comuni; i segreti non entrano nelle immagini di sviluppo.
- [ ] Una prova automatizzata dimostra build e avvio espliciti e il mancato fallback da pull; una prova sorgente non chiude l'accettazione del rilascio precompilato.
- [ ] Guide IT/EN separano il percorso avanzato da quello ordinario e rendono visibili i prerequisiti aggiuntivi.
### Blocked by
- T05 — Installare la piattaforma dal rilascio senza domande.
## T09 — Riprendere dopo correzioni e interruzioni senza perdere stato
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
L'operatore corregge un endpoint, una credenziale o un altro documento dopo un errore
e riprende l'intero percorso con verifiche aggiornate. Questo ticket completa il
recupero fra stadi e modalità, oltre ai retry locali già consegnati dai singoli ticket.
### Acceptance criteria
- [ ] Cambiamenti ai documenti, ai contenuti/revisioni workspace o al rilascio invalidano le sole verifiche/fasi dipendenti; le altre vengono riconciliate con lo stato reale.
- [ ] La rotazione di una credenziale viene rilevata senza esporla o pubblicarne fingerprint non protetti; si ripetono le prove necessarie.
- [ ] Ripresa dopo interruzione nei confini fra pull, inizializzazione, importazione Catalog, sync e preprocessing non duplica operazioni già persistite.
- [ ] Il preprocessing interrotto è rieseguito secondo il contratto esistente, senza promettere resume interno; un run in attesa di decisione umana conserva tale stato.
- [ ] Interruzioni, errori e concorrenza non corrompono il journal né attivano reset di volumi; diagnosi e stato rimangono privi di segreti.
- [ ] Test del percorso pubblico, con guasti controllati e integrazione dove conta la persistenza, dimostrano conservazione di sessioni, Evidence, descrizioni e Memory in entrambe le modalità.
- [ ] Le guide IT/EN presentano scenari di correzione/ripresa senza suggerire la cancellazione dei dati come normale rimedio.
### Blocked by
- T07 — Portare il workspace alla readiness con controlli runtime.
- T08 — Conservare il percorso esplicito da sorgente.
## T10 — Collaudare l'installazione pubblicata su Windows/WSL2
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Dimostrare il percorso completo su un PC Windows x64 con Ubuntu WSL2 e Docker
Desktop usando il rilascio realmente pubblicato, repository ad hoc e documenti
predisposti. Il collaudo include le correzioni necessarie a rendere utilizzabile
la prima piattaforma e un rapporto riproducibile.
### Acceptance criteria
- [ ] Un rilascio della revisione integrata viene pubblicato tramite T04 e consumato tramite pull; le immagini costruite soltanto localmente non soddisfano la prova.
- [ ] Il consumer non dispone di sorgenti applicativi o toolchain necessarie a compilare; operatore e validatori sono quelli precompilati nel bundle.
- [ ] Tutti i sei passi sono percorsi nell'ordine documentato, con almeno una correzione documentale e una ripresa dopo errore, senza domande durante il setup.
- [ ] Primo workspace ad hoc realmente utilizzabile, una domanda con revisione umana e stop/start con stato preservato; nessun uso presunto degli esempi rinviati.
- [ ] Rapporto con host/runtime, revisione, digest e risultati distinti di piattaforma/workspace/funzione, senza segreti; problemi esterni non sono nascosti.
- [ ] Guide IT/EN sono verificate rispetto ai comandi e agli esiti reali; eventuale assenza di host o credenziali necessarie lascia il collaudo incompleto, non simulato come riuscito.
### Blocked by
- T09 — Riprendere dopo correzioni e interruzioni senza perdere stato.
## T11 — Collaudare l'installazione su Omarchy
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Dopo la tappa Windows, ripetere e rendere funzionante il percorso su Linux Omarchy
x64, producendo un'evidenza di accettazione propria e mantenendo il comportamento
documentale e non interattivo già consegnato.
### Acceptance criteria
- [ ] Rilascio pubblico compatibile scaricato da Docker Hub e comando host precompilato; nessuna compilazione nel percorso ordinario.
- [ ] Prerequisiti, permessi, percorsi e rete di Omarchy sono verificati su un host reale, senza trasferire automaticamente l'esito Windows.
- [ ] Sei passi, input invalido/corretto, ripresa, workspace ad hoc, domanda reale e stop/start superano il collaudo.
- [ ] Ogni correzione di portabilità include la relativa verifica e non introduce una divergenza dei contratti rispetto al percorso Windows.
- [ ] Rapporto separato con versioni/digest e guide IT/EN coerenti; senza un host disponibile il gate rimane aperto.
### Blocked by
- T10 — Collaudare l'installazione pubblicata su Windows/WSL2.
## T12 — Pubblicare e collaudare il percorso macOS Apple Silicon
### Parent
[Spec: installazione da documenti verificati e distribuzione Docker Hub](https://git.tylconsulting.it/mptyl/ThothII/issues/42).
### What to build
Dopo Omarchy, pubblicare e verificare il set di artefatti compatibile con macOS
Apple Silicon, incluse immagini Linux arm64 e comando nativo, e chiudere la terza
tappa di accettazione su un Mac reale.
### Acceptance criteria
- [ ] Il comando di rilascio pubblica immagini arm64 e bundle host compatibile, con manifest/digest coerenti e senza dichiarare supporto prima del collaudo.
- [ ] Il consumer usa il rilascio pubblico e non compila; gli script e le risorse di inizializzazione sono presenti nel bundle.
- [ ] Tutti i sei passi, correzione/ripresa, workspace ad hoc, domanda reale e stop/start sono verificati sul Mac.
- [ ] Le eventuali correzioni conservano compatibilità e contratti delle tappe precedenti; le prove multiarch di build non sostituiscono il collaudo host.
- [ ] Rapporto macOS separato e guide IT/EN finalizzate per le tre piattaforme; nessun risultato sintetico viene presentato come prova reale.
### Blocked by
- T11 — Collaudare l'installazione su Omarchy.
## Verifiche della scomposizione
- I primi ticket lavorabili sono T01 e T04.
- T03 usa manifest e servizi controllati per i propri contratti; non aspetta la
pubblicazione reale. T05 è il primo punto che richiede insieme validazione e
artefatti realmente pubblicati.
- T06 e T08 possono procedere in parallelo dopo T05. T09 riunisce i percorsi per
verificare correzioni e ripresa dell'intera installazione.
- I tre gate host sono sequenziali per scelta esplicita dell'utente, non per una
dipendenza architetturale inventata.
- Gli esempi restano esclusi. Il comando di pubblicazione Docker Hub e almeno una
pubblicazione reale sono inclusi, non demandati a un futuro progetto.
- Nessun aggiornamento o chiusura della parent è previsto dalla pubblicazione.
+4
View File
@@ -56,6 +56,8 @@ exclude_docs: |
!/install/first-start.md
!/install/standalone-manual-it.md
!/install/standalone-manual-en.md
!/install/installation-preflight.md
!/install/publishing-images.md
!/install/shell-and-language.md
!/install/authentication-local.md
!/install/authentication-oidc.md
@@ -109,6 +111,8 @@ nav:
- Start here: install/first-start.md
- Mac, Windows, Linux — Italiano: install/standalone-manual-it.md
- Mac, Windows, Linux — English: install/standalone-manual-en.md
- Preflight and release manifest: install/installation-preflight.md
- Publishing images and bundles: install/publishing-images.md
- Display mode and language: install/shell-and-language.md
- Local authentication: install/authentication-local.md
- OIDC authentication: install/authentication-oidc.md
+2 -3
View File
@@ -79,7 +79,7 @@ verify_standalone_installation_guides() {
'git clone https://git.tylconsulting.it/mptyl/ThothII.git' \
'scripts/check-standalone-prerequisites.sh' \
'scripts/install-tht.sh' \
'tht setup --profile local --shell-mode full --shell-default-locale en' \
'tht setup --complete --profile local --shell-mode full --shell-default-locale en' \
'scripts/verify-standalone-install.sh' \
'Docker Hub' \
'Gate A' \
@@ -99,8 +99,7 @@ verify_install_and_workspace_guides() {
require_file "$workspace"
for text in \
'tht setup --profile local' \
'--configure-only' \
'tht setup --complete --profile local' \
'catalog-migrate' \
'tht --installation /absolute/path/thothii-installation.yaml doctor --json'; do
require_text "$install" "$text"
+97
View File
@@ -1,5 +1,102 @@
# Native installation CLI
## Offline workspace documents (issue #43)
`tht workspace prepare --directory NEW_PATH --id ID --name NAME [--language en|it]`
creates an ad hoc workspace repository template. `tht workspace validate --directory
PATH [--json]` checks the working documents without an installation descriptor or
services. Both commands invoke the packaged sibling `tht-workspace-documents`, which
compiles the canonical backend YAML/Zod parsers with its runtime. Neither Node nor
Bun is required on the user's computer. Validation never writes documents or Git
configuration. See the [IT](../../docs/install/standalone-manual-it.md) and
[EN](../../docs/install/standalone-manual-en.md) guides for the correction loop and
explicit runtime checks that local validation cannot satisfy.
Maintainer build (Go from `go.mod`, Node/npm for build only):
```sh
cd backend
npm ci
npm run build:workspace-tools
# Cross-compile the complete platform pairs:
npm run build:workspace-tools -- --all
```
The output is `dist/workspace-tools/<os>-<arch>/` containing `tht[.exe]`,
`tht-workspace-documents[.exe]`, `SHA256SUMS` and `build.json`. Individual targets:
`windows-amd64`, `linux-amd64`, `linux-arm64`, `darwin-amd64`, `darwin-arm64`.
Bun is pinned in `backend/package-lock.json`; compilation embeds the runtime, and
cross-compilation may download the selected Bun target. Deliver the complete pair
from one build, verify checksums and keep the executables together. The previous
`build-tht.sh` / `install-tht.sh` single-binary path remains for existing operator
commands; it does not package this helper. Release publication is tracked separately
in issue #46; building a Windows/Linux artifact does not establish acceptance there.
Run the same public CLI fixture corpus against the native pair, with subprocess
`PATH` deliberately empty:
```sh
cd backend
THT_WORKSPACE_TEST_CLI=/absolute/path/dist/workspace-tools/darwin-arm64/tht \
npx vitest run test/workspace-documents-cli.test.ts
```
## Application document preparation (issue #44)
Before installation, use `tht installation prepare --directory NEW_PATH`, then
`tht installation credentials --directory PATH` to explicitly create technical
credentials in the default protected layout. The latter preserves existing files.
Edit the documents and external credentials, then repeat:
```sh
tht --installation /absolute/path/thothii-installation.yaml \
installation validate --workspaces /absolute/path/workspaces --json
```
`database-bootstrap.yaml` beside the descriptor is a schemaVersion-1 bootstrap
input, not a runtime Catalog. Its database entries use the exact Catalog API
configuration schema plus private `secretFiles` and optional `evidenceSecretFiles`
references. `--bootstrap` selects another document. The helper validates workspace
membership, uniqueness, supported transports and required credential references;
Go checks protected files, the canonical Installation Model Catalog, environment
and local administrator. No process contacts a service or creates projections.
Only missing standard release Compose assets are deferred by `config.LoadPrepared`;
normal runtime `config.Load` retains all existing checks. Custom overrides must exist.
The native integration test is opt-in because it requires the built pair:
```sh
THT_INSTALLATION_TEST_CLI=/absolute/path/dist/workspace-tools/darwin-arm64/tht \
npx vitest run test/installation-documents-cli.test.ts
```
Run it from `backend/`, following the bundle build above. It supplies prepared
fixtures and removes host tools from the subprocess PATH. Platform acceptance and
real external credentials remain separate gates. See the IT/EN guides for the
complete parameter collection procedure, default `admin` account and local-auth scope.
## Host preflight and installation plan (issue #45)
For the maintainer release producer and public native archives, see
[publishing images and bundles](../../docs/install/publishing-images.md).
`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
The schema-v2 `thothii-installation.yaml` accepts this optional section:
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"path/filepath"
"slices"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/preparation"
)
func installationDocumentsCommand(ctx context.Context, installationPath string, args []string, stdout io.Writer) int {
report := preparation.NewReport()
status := 0
options := map[string]string{}
valid := len(args) > 0
for index := 1; index < len(args); index++ {
key := args[index]
if _, duplicate := options[key]; duplicate {
valid = false
break
}
if key == "--json" {
options[key] = "true"
continue
}
if (key != "--directory" && key != "--workspaces" && key != "--bootstrap") || index+1 == len(args) {
valid = false
break
}
options[key] = args[index+1]
index++
}
if !valid || (args[0] == "validate" && (installationPath == "" || options["--workspaces"] == "" || options["--directory"] != "")) || (args[0] != "validate" && (options["--directory"] == "" || options["--workspaces"] != "" || options["--bootstrap"] != "")) {
report.Add("CLI", "$", "usage", "Use installation prepare|credentials --directory PATH [--json], or tht --installation ABSOLUTE_PATH installation validate --workspaces PATH [--bootstrap PATH] [--json].")
status = 2
} else if args[0] == "validate" {
installation, checkedReport := preparation.Validate(installationPath)
report = checkedReport
if report.OK {
bootstrap := options["--bootstrap"]
if bootstrap == "" {
bootstrap = filepath.Join(filepath.Dir(installationPath), "database-bootstrap.yaml")
}
bootstrap, _ = filepath.Abs(bootstrap)
workspace, _ := filepath.Abs(options["--workspaces"])
if resolved, err := filepath.EvalSymlinks(workspace); err == nil {
workspace = resolved
}
outsideWorkspace := func(path, document, field string) bool {
relative, err := filepath.Rel(workspace, path)
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
report.Add(document, field, "installation_file_in_workspace", "Keep installation documents and credential files outside the shared workspace repository.")
return false
}
return true
}
for _, path := range []string{installationPath, installation.EnvFile, installation.AuthenticationDirectory(), bootstrap} {
outsideWorkspace(path, "thothii-installation.yaml", "local-files")
}
files, _ := installation.SecretFiles()
for _, path := range files {
outsideWorkspace(path, "operator.env", "protected-file-reference")
}
if preparation.CheckYAML(bootstrap, "database-bootstrap.yaml", &report) {
var output, discarded bytes.Buffer
code := workspaceDocumentsCommand(ctx, []string{"bootstrap", "--directory", workspace, "--bootstrap", bootstrap, "--json"}, &output, &discarded)
var checked struct {
OK bool `json:"ok"`
Issues []preparation.Issue `json:"issues"`
Warnings []string `json:"warnings"`
SecretFiles []struct {
Field string `json:"field"`
Path string `json:"path"`
} `json:"secret_files"`
}
if json.Unmarshal(output.Bytes(), &checked) != nil {
report.Add("CLI", "$", "validator_unavailable", "Reinstall the matching tht and workspace helper pair.")
} else if code != 0 || !checked.OK {
report.Issues = append(report.Issues, checked.Issues...)
if len(checked.Issues) == 0 {
report.Add("database-bootstrap.yaml", "$", "bootstrap_invalid", "Correct workspace and binding documents, then validate again.")
}
} else {
report.Warnings = append(report.Warnings, checked.Warnings...)
for _, file := range checked.SecretFiles {
if outsideWorkspace(file.Path, "database-bootstrap.yaml", file.Field) {
preparation.CheckSecret(file.Path, "database-bootstrap.yaml", file.Field, false, &report)
}
}
}
}
}
} else {
directory, err := filepath.Abs(options["--directory"])
if err == nil {
if args[0] == "prepare" {
err = preparation.Prepare(directory)
} else {
err = preparation.Credentials(ctx, directory)
}
}
if err != nil {
report.Add("preparation", "$", "preparation_refused", err.Error())
}
}
report.OK = len(report.Issues) == 0
if !report.OK && status == 0 {
status = 1
}
if slices.Contains(args, "--json") {
_ = json.NewEncoder(stdout).Encode(report)
} else {
if report.OK {
fmt.Fprintln(stdout, "Document operation completed. Local validation does not establish runtime readiness.")
}
for _, issue := range report.Issues {
fmt.Fprintf(stdout, "%s [%s] %s: %s\n", issue.Document, issue.Field, issue.Code, issue.Correction)
}
for _, warning := range report.Warnings {
fmt.Fprintln(stdout, warning)
}
}
return status
}
@@ -0,0 +1,96 @@
package main
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestInstallationPrepareDocumentsBeforeRuntime(t *testing.T) {
t.Setenv("PATH", "")
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
destination := filepath.Join(root, "installation")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"installation", "prepare", "--directory", destination, "--json"}, &stdout, &stderr)
if status != 0 {
t.Fatalf("status=%d stdout=%s stderr=%s", status, &stdout, &stderr)
}
var report map[string]any
if json.Unmarshal(stdout.Bytes(), &report) != nil || report["ok"] != true {
t.Fatalf("report=%s", &stdout)
}
for _, name := range []string{"thothii-installation.yaml", "operator.env", "database-bootstrap.yaml", "README.md"} {
if _, err := os.Stat(filepath.Join(destination, name)); err != nil {
t.Fatal(err)
}
}
before, _ := os.ReadFile(filepath.Join(destination, "thothii-installation.yaml"))
stdout.Reset()
stderr.Reset()
if run(context.Background(), []string{"installation", "prepare", "--directory", destination, "--json"}, &stdout, &stderr) == 0 {
t.Fatal("overwrote existing documents")
}
after, _ := os.ReadFile(filepath.Join(destination, "thothii-installation.yaml"))
if !bytes.Equal(before, after) {
t.Fatal("existing document changed")
}
}
func TestInstallationCredentialsAreExplicitPrivateAndNeverReplaced(t *testing.T) {
t.Setenv("PATH", "")
root, _ := filepath.EvalSymlinks(t.TempDir())
destination := filepath.Join(root, "installation")
var stdout, stderr bytes.Buffer
if run(context.Background(), []string{"installation", "prepare", "--directory", destination}, &stdout, &stderr) != 0 {
t.Fatal(&stdout, &stderr)
}
secret := filepath.Join(destination, "secrets", "catalog-runtime-password")
if _, err := os.Stat(secret); !os.IsNotExist(err) {
t.Fatal("prepare generated a secret implicitly")
}
stdout.Reset()
stderr.Reset()
if run(context.Background(), []string{"installation", "credentials", "--directory", destination, "--json"}, &stdout, &stderr) != 0 {
t.Fatal(&stdout, &stderr)
}
before, err := os.ReadFile(secret)
if err != nil || len(bytes.TrimSpace(before)) < 32 {
t.Fatal("missing strong technical credential", err)
}
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(before)) || bytes.Contains(stderr.Bytes(), bytes.TrimSpace(before)) {
t.Fatal("secret leaked")
}
info, _ := os.Stat(secret)
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
t.Fatal("credential is not private")
}
stdout.Reset()
stderr.Reset()
if run(context.Background(), []string{"installation", "credentials", "--directory", destination, "--json"}, &stdout, &stderr) != 0 {
t.Fatal(&stdout, &stderr)
}
after, _ := os.ReadFile(secret)
if !bytes.Equal(before, after) {
t.Fatal("credential was replaced")
}
}
func TestInstallationValidateRejectsWrongEnvironmentFieldType(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
path := filepath.Join(root, "thothii-installation.yaml")
if err := os.WriteFile(path, []byte("schemaVersion: 2\nenvFile: []\n"), 0o600); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"--installation", path, "installation", "validate", "--workspaces", root, "--json"}, &stdout, &stderr)
if status != 1 {
t.Fatalf("invalid field accepted: %d %s", status, &stdout)
}
}
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/preflight"
"github.com/aritmolab/thothii/tools/tht/internal/preparation"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/version"
)
func installationPreflightCommand(ctx context.Context, installationPath string, args []string, stdout io.Writer) int {
report := preflight.NewReport()
options := map[string]string{}
usage := false
for index := 1; index < len(args); index++ {
key := args[index]
if _, exists := options[key]; exists {
usage = true
break
}
if key == "--json" {
options[key] = "true"
continue
}
if !slices.Contains([]string{"--directory", "--workspaces", "--bootstrap", "--release", "--output"}, key) || index+1 == len(args) {
usage = true
break
}
options[key] = args[index+1]
index++
}
planning := len(args) > 0 && args[0] == "plan"
if usage || len(args) == 0 || (!planning && args[0] != "preflight") || (planning && (installationPath == "" || options["--workspaces"] == "" || options["--release"] == "" || options["--output"] == "" || options["--directory"] != "")) || (!planning && (options["--directory"] == "" || options["--workspaces"] != "" || options["--bootstrap"] != "" || options["--output"] != "")) {
report.Add("usage", "error", "CLI", "Use installation preflight --directory PATH [--release MANIFEST] [--json], or --installation ABS_PATH installation plan --workspaces PATH --release MANIFEST --output NEW_PLAN [--bootstrap PATH] [--json].")
writePreflight(stdout, report, slices.Contains(args, "--json"))
return 2
}
for key, value := range options {
if key != "--json" {
absolute, err := filepath.Abs(value)
if err != nil {
report.Add("path", "error", "CLI", "Supply canonical absolute paths.")
} else {
options[key] = absolute
}
}
}
var installation config.Installation
if planning {
root, err := filepath.EvalSymlinks(options["--workspaces"])
if err == nil {
relative, err := filepath.Rel(root, options["--output"])
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
report.Add("plan-in-workspace", "error", "output", "Keep the private plan and its key outside the shared workspace repository.")
}
}
}
if planning {
bootstrap := options["--bootstrap"]
if bootstrap == "" {
bootstrap = filepath.Join(filepath.Dir(installationPath), "database-bootstrap.yaml")
options["--bootstrap"] = bootstrap
}
var output bytes.Buffer
code := installationDocumentsCommand(ctx, installationPath, []string{"validate", "--workspaces", options["--workspaces"], "--bootstrap", bootstrap, "--json"}, &output)
var documents preparation.Report
if code != 0 || json.Unmarshal(output.Bytes(), &documents) != nil || !documents.OK {
report.Add("application-documents", "error", "documents", "Run installation validate and correct every reported issue before planning.")
} else {
var err error
installation, err = config.LoadPrepared(installationPath)
if err != nil {
report.Add("application-documents", "error", "documents", "Prepared inputs changed; repeat validation.")
}
options["--directory"] = installation.ProjectDirectory
}
}
if report.OK {
if exists, err := safeio.PreflightPrivateDirectory(options["--directory"]); err != nil || !exists {
report.Add("installation-directory", "error", "projectDirectory", "Use an existing private canonical installation directory.")
}
}
minimum := preflight.DefaultRequirements()
var inputs []string
var absent []string
var revision string
var unchanged func() bool
var manifest preflight.Manifest
if options["--release"] != "" {
var err error
manifest, err = preflight.LoadManifest(options["--release"])
if err != nil {
report.Add("release-manifest", "error", "release", "Use a complete supported manifest with all packaged file digests verified; publication must precede planning.")
} else {
minimum = manifest.Requirements
}
}
if report.OK && planning {
var err error
inputs, revision, err = preflight.CollectInputs(installation, options["--workspaces"], options["--bootstrap"], options["--release"])
if err == nil {
absent = preflight.AbsentOverrides(installation)
unchanged, err = preflight.CaptureInputs(inputs, options["--workspaces"], absent...)
}
if err != nil {
report.Add("input-snapshot", "error", "documents", "Keep input trees bounded, readable and free from links; repeat document validation.")
} else {
var discarded bytes.Buffer
if installationDocumentsCommand(ctx, installationPath, []string{"validate", "--workspaces", options["--workspaces"], "--bootstrap", options["--bootstrap"], "--json"}, &discarded) != 0 {
report.Add("changed-documents", "error", "documents", "Documents changed during validation; correct and repeat.")
}
}
}
host, hostErr := preflight.InspectHost(options["--directory"])
if hostErr != nil {
report.Add("host-filesystem", "error", "projectDirectory", "Allow filesystem capacity inspection at the canonical installation path.")
}
// Docker connection/trust and executable discovery remain host-owned. Compose parameters
// are exclusively read from the authored env file, not inherited shell overrides.
dockerEnvironment := []string{}
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if slices.Contains([]string{"PATH", "HOME", "USERPROFILE", "SystemRoot", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"}, key) {
dockerEnvironment = append(dockerEnvironment, entry)
}
}
runner := compose.NewRunnerWithEnvironment("", dockerEnvironment)
if report.OK {
report.Merge(preflight.CheckHost(ctx, runner, host, minimum))
}
platform := "linux/" + host.Arch
if report.OK && options["--release"] != "" {
report.Merge(preflight.CheckImages(ctx, runner, manifest, platform))
}
if report.OK && planning {
report.Merge(preflight.CheckCompose(ctx, runner, installation, manifest, options["--release"], platform))
report.Merge(preflight.CheckExternal(ctx, installation))
var output, discarded bytes.Buffer
bound, cancel := context.WithTimeout(ctx, 60*time.Second)
code := workspaceDocumentsCommand(bound, []string{"probe", "--directory", options["--workspaces"], "--bootstrap", options["--bootstrap"], "--json"}, &output, &discarded)
cancel()
var probes preflight.Report
if json.Unmarshal(output.Bytes(), &probes) != nil || len(probes.Checks) == 0 {
report.Add("database-probes", "error", "database-bootstrap", "Restore the matching validation helper and rerun bounded external dependency checks.")
} else {
report.Merge(probes)
}
if code != 0 && report.OK {
report.Add("database-probes", "error", "database-bootstrap", "A required external dependency is unavailable; correct it before planning.")
}
if report.OK {
if unchanged == nil || !unchanged() {
report.Add("changed-inputs", "error", "documents", "An input or credential changed during checks; repeat planning with stable prepared files.")
} else {
preflight.AddRuntimeObligations(&report)
plan := preflight.Plan{SchemaVersion: 1, ValidatorProtocol: preflight.Protocol, Validator: version.Current(), Installation: installation, Release: manifest, Platform: platform, WorkspaceDirectory: options["--workspaces"], WorkspaceRevision: revision, Inputs: inputs, AbsentInputs: absent, Report: report}
if err := preflight.WritePlan(options["--output"], &plan, unchanged); err != nil {
report.Add("plan-output", "error", "output", "Choose a new filename in a private canonical directory; existing plans and key files are never overwritten.")
}
}
}
}
if !planning {
report.Add("prepared-inputs", "warning", "documents", "Host preflight alone is not an executable plan; complete application validation and release selection at step 5.")
}
writePreflight(stdout, report, options["--json"] != "")
if report.OK {
return 0
}
return 1
}
func writePreflight(stdout io.Writer, report preflight.Report, structured bool) {
if structured {
_ = json.NewEncoder(stdout).Encode(report)
return
}
for _, check := range report.Checks {
fmt.Fprintf(stdout, "%s [%s] %s: %s\n", check.Outcome, check.ID, check.Field, check.Action)
}
}
@@ -0,0 +1,138 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/preflight"
)
func TestNativeInstallationPlanBeforeContainersExist(t *testing.T) {
binary := os.Getenv("THT_INSTALLATION_TEST_CLI")
if binary == "" || runtime.GOOS == "windows" {
t.Skip("set THT_INSTALLATION_TEST_CLI to the compiled sibling bundle")
}
root, _ := filepath.EvalSymlinks(t.TempDir())
_ = os.Chmod(root, 0o700)
workspace := filepath.Join(root, "workspaces")
installation := filepath.Join(root, "installation")
release := filepath.Join(root, "release")
_ = os.Mkdir(release, 0o700)
command := func(args ...string) (int, string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binary, append(args, "--json")...)
cmd.Env = append(os.Environ(), "PATH="+root)
data, err := cmd.CombinedOutput()
if err != nil {
return 1, string(data)
}
return 0, string(data)
}
for _, args := range [][]string{{"workspace", "prepare", "--directory", workspace, "--id", "practice", "--name", "Practice"}, {"installation", "prepare", "--directory", installation}, {"installation", "credentials", "--directory", installation}} {
if code, text := command(args...); code != 0 {
t.Fatal(text)
}
}
git := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("Git mutation")
}
fmt.Fprintln(w, "0044"+strings.Repeat("b", 40)+" refs/heads/main")
}))
defer git.Close()
database := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("DWH mutation")
}
if r.Header.Get("Authorization") != "Bearer PRIVATE_DATABASE_VALUE" {
w.WriteHeader(401)
}
}))
defer database.Close()
write := func(path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
descriptor := filepath.Join(installation, "thothii-installation.yaml")
for _, name := range []string{"thothii-installation.yaml", "operator.env"} {
path := filepath.Join(installation, name)
data, _ := os.ReadFile(path)
write(path, []byte(strings.ReplaceAll(string(data), "https://CHANGE_ME/workspaces.git", git.URL+"/workspaces.git")))
}
for name, data := range map[string][]byte{"secrets.env": []byte("OPENAI_API_KEY=PRIVATE_PROVIDER_VALUE\n"), "database-password": []byte("PRIVATE_DATABASE_VALUE"), "git-credentials": {}, "git-ca.pem": pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: git.Certificate().Raw})} {
write(filepath.Join(installation, "secrets", name), data)
}
write(filepath.Join(installation, "database-bootstrap.yaml"), []byte(fmt.Sprintf("schemaVersion: 1\ndatabases:\n - workspaceId: practice\n engine: postgres\n databaseName: practice\n schema: public\n binding: {transport: rest_api, baseUrl: %q, restPath: /health, restAuth: bearer}\n secretFiles: {apiKey: %q}\n", database.URL, filepath.Join(installation, "secrets/database-password"))))
manifest := preflight.Manifest{SchemaVersion: 1, Version: "1.0.0", Revision: strings.Repeat("b", 40), ValidatorProtocol: 1, Requirements: preflight.DefaultRequirements(), Components: []string{"pi", "catalog-migrations", "workspace-maintenance"}, Images: map[string]map[string]string{}, Files: map[string]string{}, Compose: []string{"compose.yaml"}}
platform := "linux/" + runtime.GOARCH
services := map[string]map[string]string{}
for _, role := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
manifest.Images[role] = map[string]string{platform: "docker.io/example/" + role + "@sha256:" + strings.Repeat("a", 64)}
}
for service, role := range map[string]string{"core": "core", "frontend": "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"} {
services[service] = map[string]string{"image": manifest.Images[role][platform]}
}
_ = os.Mkdir(filepath.Join(release, "deploy"), 0o700)
for name, contents := range map[string]string{"compose.yaml": "services: {}\n", "deploy/compose.git-https.yaml": "services: {}\n"} {
write(filepath.Join(release, filepath.FromSlash(name)), []byte(contents))
sum := sha256.Sum256([]byte(contents))
manifest.Files[name] = hex.EncodeToString(sum[:])
}
manifestPath := filepath.Join(release, "release-manifest.json")
manifestJSON, _ := json.Marshal(manifest)
write(manifestPath, manifestJSON)
effective, _ := json.Marshal(map[string]any{"services": services})
script := fmt.Sprintf("#!/bin/sh\ncase \"$1\" in\ninfo) printf '%%s\\n' '{\"OSType\":\"linux\",\"Architecture\":\"%s\",\"NCPU\":4,\"MemTotal\":17179869184}';;\ncompose) if [ \"$2\" = version ]; then printf '2.39.0\\n'; else printf '%%s\\n' '%s'; fi;;\nmanifest) printf '%%s\\n' '{\"Descriptor\":{\"digest\":\"sha256:%s\",\"platform\":{\"os\":\"linux\",\"architecture\":\"%s\"}}}';;\n*) exit 1;;\nesac\n", runtime.GOARCH, effective, strings.Repeat("a", 64), runtime.GOARCH)
write(filepath.Join(root, "docker"), []byte(script))
_ = os.Chmod(filepath.Join(root, "docker"), 0o700)
planPath := filepath.Join(installation, "plan.json")
if code, text := command("--installation", descriptor, "installation", "validate", "--workspaces", workspace); code != 0 {
t.Fatalf("documents: %s", text)
}
args := []string{"--installation", descriptor, "installation", "plan", "--workspaces", workspace, "--release", manifestPath, "--output", planPath}
args[len(args)-1] = filepath.Join(workspace, "forbidden-plan.json")
if code, _ := command(args...); code == 0 {
t.Fatal("plan written inside workspace")
}
if _, err := os.Stat(args[len(args)-1]); !os.IsNotExist(err) {
t.Fatal("private plan published inside workspace")
}
args[len(args)-1] = planPath
code, text := command(args...)
if code != 0 {
t.Fatalf("plan failed: %s", text)
}
if strings.Contains(text, "PRIVATE_") {
t.Fatal("secret in report")
}
if err := preflight.VerifyPlanInputs(planPath); err != nil {
t.Fatal(err)
}
write(filepath.Join(installation, "secrets/database-password"), []byte("rotated-invalid"))
if preflight.VerifyPlanInputs(planPath) == nil {
t.Fatal("rotation did not invalidate plan")
}
args[len(args)-1] = filepath.Join(installation, "second-plan.json")
if code, text := command(args...); code == 0 || strings.Contains(text, "PRIVATE_") {
t.Fatalf("invalid credential plan: %s", text)
}
if _, err := os.Stat(args[len(args)-1]); !os.IsNotExist(err) {
t.Fatal("failed checks published plan")
}
}
+85 -2
View File
@@ -40,9 +40,19 @@ When --installation is omitted, tht uses THOTHII_INSTALLATION or discovers one v
descriptor in the current project tree.
Commands:
setup [--configure-only] [--installation-id ID] [--profile local|server]
setup [--complete|--configure-only] [--installation-id ID] [--profile local|server]
[--shell-mode full|embedded] [--shell-default-locale BCP47-TAG] [--shell-adapter omics-portal]
Create or validate the local non-secret installation configuration.
Create, validate, and optionally complete the local installation.
installation prepare --directory NEW_PATH [--json]
Create commented installation, environment and database bootstrap templates.
installation credentials --directory PATH [--json]
Explicitly generate protected technical credentials before setup.
installation validate --workspaces PATH [--bootstrap PATH] [--json]
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
--embedding-id PROVIDER/MODEL --embedding-dimensions N
Create a review-only schema-v2 candidate from all three legacy model sources.
@@ -85,7 +95,15 @@ Commands:
pi maintenance recover --yes
Verify a terminal installation, remove stale lifecycle files, and clear maintenance.
pi logs Show the latest 200 sanitized core log lines (bounded; no follow mode).
workspace prepare --directory NEW_PATH --id ID --name NAME [--language en|it] [--json]
Prepare workspace documents locally, before installation.
workspace validate --directory PATH [--json]
Validate local workspace documents without starting services.
workspace inspect --workspace ID [--json]
workspace pull [--json]
Pull and activate the configured workspace repository.
workspace test [--json]
Test configured database, Evidence, Qdrant, and embedding connectivity.
workspace evidence consolidate --workspace ID [--json]
workspace evidence refresh --workspace ID [--json]
workspace evidence decide --workspace ID --source-id SHA --revision SHA --decision keep|replace [--json]
@@ -130,6 +148,12 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return versionCommand(commandArgs, stdout, stderr)
}
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") {
return installationDocumentsCommand(ctx, installationPath, commandArgs, stdout)
}
if len(commandArgs) > 0 && commandArgs[0] == "generate" {
return installationGenerationCommand(installationPath, commandArgs[1:], stdout, stderr)
}
@@ -137,6 +161,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
if command == "workspace" && len(commandArgs) > 0 && (commandArgs[0] == "prepare" || commandArgs[0] == "validate") {
return workspaceDocumentsCommand(ctx, commandArgs, stdout, stderr)
}
workingDirectory, err := os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "tht: current directory is unavailable: %s\n", output.Sanitize(err.Error(), nil))
@@ -400,6 +427,11 @@ func parseSetupArgs(args []string) (setup.Request, error) {
flag := args[0]
args = args[1:]
switch flag {
case "--complete":
if request.Complete {
return setup.Request{}, errors.New("--complete may be supplied once")
}
request.Complete = true
case "--configure-only":
if request.ConfigureOnly {
return setup.Request{}, errors.New("--configure-only may be supplied once")
@@ -484,6 +516,9 @@ func parseSetupArgs(args []string) (setup.Request, error) {
*target = value
}
}
if request.Complete && request.ConfigureOnly {
return setup.Request{}, errors.New("--complete and --configure-only cannot be combined")
}
return request, nil
}
@@ -499,6 +534,9 @@ func writeRemovalTargets(outputWriter io.Writer, project string, targets []serve
}
func workspaceCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
if len(args) > 0 && (args[0] == "pull" || args[0] == "test") {
return workspaceOperatorCommand(ctx, installation, runner, args, secretValues, stdout, stderr)
}
request, err := workspaceops.Parse(args)
if err != nil {
return commandUsageError(stderr, err.Error())
@@ -527,6 +565,51 @@ func workspaceCommand(ctx context.Context, installation config.Installation, run
}
}
func workspaceOperatorCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
action := "workspace-" + args[0]
jsonMode := false
for _, arg := range args[1:] {
if arg != "--json" || jsonMode {
return commandUsageError(stderr, "workspace pull/test accepts only --json")
}
jsonMode = true
}
result, err := runner.Run(ctx, installation.ComposeArgs("exec", "-T", "core", "node", "dist/operator-command.js", action), nil)
if err != nil {
return writeResult(result, err, secretValues, stdout, stderr)
}
var payload struct {
Ready bool `json:"ready"`
Status string `json:"status"`
}
if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil {
fmt.Fprintln(stderr, "tht: workspace operator returned invalid JSON")
return 1
}
if jsonMode {
fmt.Fprintln(stdout, output.Sanitize(result.Stdout, secretValues))
} else {
fmt.Fprintf(stdout, "workspace %s: %s\n", args[0], output.Sanitize(workspaceOperatorSummary(payload), secretValues))
}
if !payload.Ready {
return 1
}
return 0
}
func workspaceOperatorSummary(payload struct {
Ready bool `json:"ready"`
Status string `json:"status"`
}) string {
if payload.Status != "" {
return payload.Status
}
if payload.Ready {
return "ready"
}
return "failed"
}
func workspaceFailure(stderr io.Writer, err error, secretValues []string) int {
message := output.Sanitize(err.Error(), secretValues)
var operationErr *workspaceops.OperationError
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
)
// Resolve only the packaged sibling, never an executable from the workspace or PATH.
func workspaceDocumentsCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
executable, err := os.Executable()
if err == nil {
executable, err = filepath.EvalSymlinks(executable)
}
if err == nil {
name := "tht-workspace-documents"
if runtime.GOOS == "windows" {
name += ".exe"
}
command := exec.CommandContext(ctx, filepath.Join(filepath.Dir(executable), name), args...)
command.Stdout, command.Stderr = stdout, stderr
err = command.Run()
if err == nil {
return 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) && exitError.ExitCode() >= 0 {
return exitError.ExitCode()
}
}
const correction = "Install tht and tht-workspace-documents from the same platform bundle in the same directory, then retry."
if slices.Contains(args, "--json") {
_ = json.NewEncoder(stdout).Encode(map[string]any{
"schema_version": 1, "scope": "local-documents", "ok": false,
"workspaces": []any{}, "deferred_checks": []string{},
"issues": []map[string]string{{"document": "CLI", "field": "$", "code": "workspace_helper_unavailable", "correction": correction}},
})
} else {
fmt.Fprintln(stderr, correction)
}
return 1
}
@@ -0,0 +1,29 @@
package main
import (
"bytes"
"context"
"encoding/json"
"testing"
)
func TestWorkspaceDocumentsNeedsPackagedHelperNotInstallation(t *testing.T) {
t.Setenv("PATH", "")
t.Setenv("THOTHII_INSTALLATION", "/nonexistent/installation.yaml")
for _, action := range []string{"prepare", "validate"} {
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"workspace", action, "--directory", t.TempDir(), "--json"}, &stdout, &stderr)
var report struct {
OK bool `json:"ok"`
Issues []struct {
Code string `json:"code"`
} `json:"issues"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("missing structured helper error: status=%d stdout=%s stderr=%s", status, &stdout, &stderr)
}
if status != 1 || report.OK || len(report.Issues) != 1 || report.Issues[0].Code != "workspace_helper_unavailable" || stderr.Len() != 0 {
t.Fatalf("unexpected missing-helper result: status=%d stdout=%s stderr=%s", status, &stdout, &stderr)
}
}
}
+14 -1
View File
@@ -59,7 +59,17 @@ type boundedStreamingRunner interface {
// execRunner executes the Docker CLI. It never invokes a shell.
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.
@@ -106,6 +116,9 @@ func (r execRunner) runBounded(ctx context.Context, args []string, stdin io.Read
}
}
command := exec.Command(r.binary, preparedArgs...)
if r.environment != nil {
command.Env = r.environment
}
configureProcess(command)
command.Stdin = stdin
overflow := make(chan struct{}, 1)
+22 -1
View File
@@ -98,6 +98,16 @@ type Installation struct {
// Load reads and validates an installation descriptor at an absolute path.
func Load(path string) (Installation, error) {
return load(path, false)
}
// LoadPrepared applies the same authored configuration rules before release assets exist.
// Only standard distribution-owned Compose assets are deferred, never custom overrides.
func LoadPrepared(path string) (Installation, error) {
return load(path, true)
}
func load(path string, prepared bool) (Installation, error) {
if !filepath.IsAbs(path) {
return Installation{}, fmt.Errorf("installation path must be absolute")
}
@@ -143,6 +153,11 @@ func Load(path string) (Installation, error) {
if err := requireDirectory(raw.ProjectDirectory, "projectDirectory"); err != nil {
return Installation{}, err
}
if prepared {
if err := requireCanonicalDirectory(raw.ProjectDirectory); err != nil {
return Installation{}, errors.New("projectDirectory must be canonical and accessible")
}
}
for _, legacyProjection := range []string{
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "models.json"),
filepath.Join(raw.ProjectDirectory, "deploy", "pi", "settings.json"),
@@ -195,7 +210,8 @@ func Load(path string) (Installation, error) {
return Installation{}, errors.New("authentication.configDirectory must match THT_AUTH_CONFIG_ROOT")
}
for _, override := range raw.Overrides {
if err := requireRegularFile(override, "override"); err != nil {
standardAsset := override == filepath.Join(installation.ProjectDirectory, "deploy", "compose.git-https.yaml") || override == filepath.Join(installation.ProjectDirectory, "deploy", "compose.git-ssh.yaml")
if err := requireRegularFile(override, "override"); err != nil && !(prepared && standardAsset && errors.Is(statPathError(override), os.ErrNotExist)) {
return Installation{}, err
}
installation.Overrides = append(installation.Overrides, filepath.Clean(override))
@@ -209,6 +225,9 @@ func Load(path string) (Installation, error) {
}
}
for _, composeFile := range installation.ComposeFiles()[:2] {
if prepared && errors.Is(statPathError(composeFile), os.ErrNotExist) {
continue
}
if err := requireRegularFile(composeFile, "Compose file"); err != nil {
return Installation{}, err
}
@@ -226,6 +245,8 @@ func Load(path string) (Installation, error) {
return installation, nil
}
func statPathError(path string) error { _, err := os.Lstat(path); return err }
var metadataSecretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
var metadataAPIKeyEnvironments = map[string]struct{}{
"THT_MODEL_API_KEY": {},
+122
View File
@@ -0,0 +1,122 @@
// Package preflight checks a prepared installation without creating application state.
package preflight
import (
"context"
"encoding/json"
"io"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
)
const Protocol = 1
type Check struct {
ID string `json:"id"`
Outcome string `json:"outcome"`
Field string `json:"field"`
Action string `json:"action"`
}
type Report struct {
SchemaVersion int `json:"schema_version"`
OK bool `json:"ok"`
Checks []Check `json:"checks"`
}
func NewReport() Report { return Report{SchemaVersion: 1, OK: true, Checks: []Check{}} }
func (r *Report) Add(id, outcome, field, action string) {
r.Checks = append(r.Checks, Check{id, outcome, field, action})
if outcome == "error" {
r.OK = false
}
}
func (r *Report) Merge(other Report) {
r.Checks = append(r.Checks, other.Checks...)
r.OK = r.OK && other.OK
}
func (r Report) JSON() string { data, _ := json.Marshal(r); return string(data) }
type Runner interface {
Run(context.Context, []string, io.Reader) (compose.Result, error)
}
func docker(ctx context.Context, runner Runner, args ...string) (compose.Result, error) {
bound, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
return runner.Run(bound, args, nil)
}
type Requirements struct {
CPUs int `json:"cpus"`
MemoryBytes uint64 `json:"memory_bytes"`
DiskBytes uint64 `json:"disk_bytes"`
}
func DefaultRequirements() Requirements { return Requirements{2, 4 << 30, 10 << 30} }
type Host struct {
OS string
Arch string
Kernel string
Distribution string
FreeBytes uint64
}
func InspectHost(directory string) (Host, error) {
h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH}
kernel, _ := os.ReadFile("/proc/sys/kernel/osrelease")
h.Kernel = strings.ToLower(string(kernel))
distribution, _ := os.ReadFile("/etc/os-release")
h.Distribution = strings.ToLower(string(distribution))
var err error
h.FreeBytes, err = freeBytes(directory)
return h, err
}
func CheckHost(ctx context.Context, runner Runner, host Host, minimum Requirements) Report {
r := NewReport()
check := func(id string, ok bool, action string) {
outcome := "passed"
if !ok {
outcome = "error"
}
r.Add(id, outcome, "host", action)
}
check("host-platform", (host.OS == "linux" || host.OS == "darwin") && (host.Arch == "amd64" || host.Arch == "arm64"), "Use the Linux executable in Ubuntu WSL2/Omarchy, or the matching macOS executable; native Windows is not the installation path.")
if strings.Contains(strings.ToLower(host.Kernel), "microsoft") {
check("wsl2", strings.Contains(strings.ToLower(host.Kernel), "wsl2") && strings.Contains(host.Distribution, "ubuntu"), "Use Ubuntu WSL2 and enable Docker Desktop integration for that distribution.")
}
info, err := docker(ctx, runner, "info", "--format", "{{json .}}")
var parsed struct {
OSType string
Architecture string
NCPU int
MemTotal uint64
}
good := err == nil && json.Unmarshal([]byte(info.Stdout), &parsed) == nil
check("docker-daemon", good && parsed.OSType == "linux", "Start a reachable Linux Docker daemon for this user.")
arch := parsed.Architecture
if arch == "x86_64" {
arch = "amd64"
}
if arch == "aarch64" {
arch = "arm64"
}
check("docker-architecture", good && arch == host.Arch, "Use a Linux Docker daemon matching the host architecture; emulation is not certified.")
check("docker-resources", good && parsed.NCPU >= minimum.CPUs && parsed.MemTotal >= minimum.MemoryBytes, "Allocate at least the release CPU and memory minimum to Docker.")
check("installation-disk", host.FreeBytes >= minimum.DiskBytes && host.FreeBytes > 0, "Provide the release minimum free space on the installation filesystem.")
result, err := docker(ctx, runner, "compose", "version", "--short")
parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(result.Stdout), "v"), ".")
major, _ := strconv.Atoi(parts[0])
minor := 0
if len(parts) > 1 {
minor, _ = strconv.Atoi(parts[1])
}
check("docker-compose", err == nil && (major > 2 || (major == 2 && minor >= 24)), "Install Docker Compose v2.24 or newer.")
r.Add("daemon-storage", "warning", "host", "Host disk capacity does not measure a Docker Desktop VM disk; reserve equivalent Docker storage and verify volume allocation during setup.")
return r
}
+60
View File
@@ -0,0 +1,60 @@
package preflight
import (
"context"
"encoding/json"
"os"
"path/filepath"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
// CheckCompose resolves the distributed release plus authored overrides without starting services.
func CheckCompose(ctx context.Context, runner Runner, installation config.Installation, m Manifest, manifestPath, platform string) Report {
r := NewReport()
args := []string{"compose", "--project-directory", installation.ProjectDirectory, "--env-file", installation.EnvFile}
for _, name := range m.Compose {
args = append(args, "-f", filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(name)))
}
for _, path := range installation.Overrides {
if _, err := os.Stat(path); os.IsNotExist(err) {
// Only the two well-known distribution-owned transport overlays can be relocated.
name := "deploy/" + filepath.Base(path)
if path != filepath.Join(installation.ProjectDirectory, filepath.FromSlash(name)) || (name != "deploy/compose.git-https.yaml" && name != "deploy/compose.git-ssh.yaml") || m.Files[name] == "" {
r.Add("compose-assets", "error", "overrides", "Include the selected Git transport overlay in the verified release.")
return r
}
path = filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(name))
}
args = append(args, "-f", path)
}
args = append(args, "config", "--format", "json")
result, err := docker(ctx, runner, args...)
var effective struct {
Services map[string]struct {
Image string `json:"image"`
Build any `json:"build"`
Platform string `json:"platform"`
} `json:"services"`
}
if err != nil || json.Unmarshal([]byte(result.Stdout), &effective) != nil {
r.Add("compose-configuration", "error", "operator.env", "Correct the effective Compose configuration using the release assets and prepared environment; no raw output is logged.")
return r
}
roles := map[string]string{"core": "core", "catalog-migrate": "core", "workspace-maintenance": "core", "frontend": "frontend", "catalog-db": "catalog", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"}
for service, role := range roles {
entry, exists := effective.Services[service]
if !exists || entry.Build != nil || entry.Image != m.Images[role][platform] || (entry.Platform != "" && entry.Platform != platform) {
r.Add("compose-image-"+service, "error", "release.compose", "Each runtime and maintenance service must use its released immutable image without a source build.")
}
}
for service := range effective.Services {
if _, ok := roles[service]; !ok {
r.Add("compose-service", "error", "release.compose", "Additional services need an explicit release contract before execution.")
}
}
if r.OK {
r.Add("compose-configuration", "passed", "release.compose", "Effective Compose configuration uses the complete released image set.")
}
return r
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !windows
package preflight
import "golang.org/x/sys/unix"
func freeBytes(path string) (uint64, error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return 0, err
}
return uint64(stat.Bavail) * uint64(stat.Bsize), nil
}
@@ -0,0 +1,15 @@
//go:build windows
package preflight
import "golang.org/x/sys/windows"
func freeBytes(path string) (uint64, error) {
p, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var available uint64
err = windows.GetDiskFreeSpaceEx(p, &available, nil, nil)
return available, err
}
+216
View File
@@ -0,0 +1,216 @@
package preflight
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
)
func CheckExternal(ctx context.Context, installation config.Installation) Report {
r := NewReport()
value := func(name string) string { result, _ := installation.EnvironmentValue(name); return result }
err := checkGit(ctx, installation, value)
outcome := "passed"
if err != nil {
outcome = "error"
}
r.Add("workspace-remote", outcome, "workspaceRepository", "Require authenticated read access to the configured Git remote and branch using the prepared trust/credential files.")
for name, provider := range installation.ModelCatalog.Providers {
if provider.Endpoint == nil {
r.Add("provider-"+name, "warning", "modelCatalog.providers", "Built-in provider endpoint resolution belongs to the bundled Pi SDK. The required Pi runtime smoke check verifies model availability and credentials; preflight makes no billable generation requests.")
continue
}
parsed, err := url.Parse(provider.Endpoint.BaseURL)
if err == nil {
err = probeOrigin(ctx, parsed)
}
outcome := "passed"
if err != nil {
outcome = "error"
}
r.Add("provider-"+name, outcome, "modelCatalog.providers."+name+".endpoint", "Require DNS/TCP/TLS reachability of the configured provider origin. Credential/model eligibility still requires the bundled Pi runtime smoke check; no generation request is sent here.")
}
return r
}
func probeOrigin(ctx context.Context, target *url.URL) error {
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
port := target.Port()
if port == "" {
port = "443"
if target.Scheme == "http" {
port = "80"
}
}
address := net.JoinHostPort(target.Hostname(), port)
var connection net.Conn
var err error
if target.Scheme == "https" {
dialer := tls.Dialer{NetDialer: &net.Dialer{Timeout: 5 * time.Second}, Config: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: target.Hostname()}}
connection, err = dialer.DialContext(bound, "tcp", address)
} else {
connection, err = (&net.Dialer{Timeout: 5 * time.Second}).DialContext(bound, "tcp", address)
}
if err == nil {
connection.Close()
}
return err
}
func checkGit(ctx context.Context, installation config.Installation, value func(string) string) error {
remote := installation.WorkspaceRepository.Remote
if installation.WorkspaceRepository.Access == "ssh" && strings.HasPrefix(remote, "git@") {
host, path, found := strings.Cut(strings.TrimPrefix(remote, "git@"), ":")
if !found {
return errors.New("invalid Git remote")
}
return checkSSHGit(ctx, &url.URL{Scheme: "ssh", User: url.User("git"), Host: host, Path: path}, installation.WorkspaceRepository.Branch, value)
}
u, err := url.Parse(remote)
if err != nil {
return errors.New("remote unavailable")
}
if installation.WorkspaceRepository.Access == "ssh" {
return checkSSHGit(ctx, u, installation.WorkspaceRepository.Branch, value)
}
ca, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_CA_FILE"), 64<<10)
if err != nil {
return err
}
pool, err := x509.SystemCertPool()
if err != nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(ca) {
return errors.New("invalid Git trust")
}
credentials, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_CREDENTIALS_FILE"), 64<<10)
if err != nil {
return err
}
var user, password string
for _, line := range strings.Split(string(credentials), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
credential, err := url.Parse(strings.TrimSpace(line))
if err != nil || credential.User == nil {
return errors.New("invalid Git credentials")
}
if credential.Scheme == u.Scheme && credential.Host == u.Host && (credential.Path == "" || credential.Path == u.Path) {
user = credential.User.Username()
password, _ = credential.User.Password()
}
}
u.Path = strings.TrimSuffix(u.Path, "/") + "/info/refs"
u.RawQuery = "service=git-upload-pack"
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
request, err := http.NewRequestWithContext(bound, http.MethodGet, u.String(), nil)
if err != nil {
return err
}
if user != "" {
request.SetBasicAuth(user, password)
}
transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, Proxy: http.ProxyFromEnvironment}
defer transport.CloseIdleConnections()
client := http.Client{Transport: transport, Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return errors.New("Git remote refused")
}
data, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
if err != nil || len(data) > 1<<20 || !advertisesBranch(data, installation.WorkspaceRepository.Branch) {
return errors.New("Git branch unavailable")
}
return nil
}
func advertisesBranch(data []byte, branch string) bool {
for _, line := range strings.Split(string(data), "\n") {
line = strings.SplitN(line, "\x00", 2)[0]
if strings.HasSuffix(line, " refs/heads/"+branch) {
return true
}
}
return false
}
func checkSSHGit(ctx context.Context, target *url.URL, branch string, value func(string) string) error {
// Git paths and branch names are already validated by the canonical installation loader.
if target.Scheme != "ssh" || target.User == nil || strings.ContainsAny(target.Path, "'\r\n\x00") {
return errors.New("use canonical ssh:// remote")
}
key, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_SSH_KEY_FILE"), 64<<10)
if err != nil {
return err
}
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
hostKey, err := knownhosts.New(value("THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE"))
if err != nil {
return err
}
port := target.Port()
if port == "" {
port = "22"
}
if _, err := strconv.Atoi(port); err != nil {
return err
}
address := net.JoinHostPort(target.Hostname(), port)
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
connection, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(bound, "tcp", address)
if err != nil {
return err
}
defer connection.Close()
deadline, _ := bound.Deadline()
_ = connection.SetDeadline(deadline)
clientConnection, channels, requests, err := ssh.NewClientConn(connection, address, &ssh.ClientConfig{User: target.User.Username(), Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, HostKeyCallback: hostKey, Timeout: 5 * time.Second})
if err != nil {
return err
}
client := ssh.NewClient(clientConnection, channels, requests)
defer client.Close()
session, err := client.NewSession()
if err != nil {
return err
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
stdin, err := session.StdinPipe()
if err != nil {
return err
}
if err = session.Start("git-upload-pack --advertise-refs '" + target.Path + "'"); err != nil {
return err
}
_ = stdin.Close()
data, err := io.ReadAll(io.LimitReader(stdout, (1<<20)+1))
if err != nil || len(data) > 1<<20 || !advertisesBranch(data, branch) {
return errors.New("Git branch unavailable")
}
return nil
}
@@ -0,0 +1,62 @@
package preflight
import (
"context"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
func TestGitProbeAuthenticatesAndRejectsWrongBranchAndCredentials(t *testing.T) {
status := 200
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, password, ok := r.BasicAuth()
if !ok || user != "reader" || password != "PRIVATE_SENTINEL" {
w.WriteHeader(401)
return
}
if r.Method != "GET" || r.URL.Path != "/workspaces.git/info/refs" || r.URL.RawQuery != "service=git-upload-pack" {
t.Error("unexpected Git mutation/request")
w.WriteHeader(400)
return
}
w.WriteHeader(status)
_, _ = w.Write([]byte("0044" + strings.Repeat("a", 40) + " refs/heads/main\n"))
}))
defer server.Close()
root, _ := filepath.EvalSymlinks(t.TempDir())
ca := filepath.Join(root, "ca.pem")
credentials := filepath.Join(root, "credentials")
_ = os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600)
_ = os.WriteFile(credentials, []byte(strings.Replace(server.URL, "https://", "https://reader:PRIVATE_SENTINEL@", 1)), 0o600)
installation := config.Installation{WorkspaceRepository: config.WorkspaceRepository{Remote: server.URL + "/workspaces.git", Branch: "main", Access: "https"}}
value := func(name string) string {
if name == "THT_WORKSPACE_GIT_CA_FILE" {
return ca
}
return credentials
}
if err := checkGit(context.Background(), installation, value); err != nil {
t.Fatal(err)
}
installation.WorkspaceRepository.Branch = "missing"
if checkGit(context.Background(), installation, value) == nil {
t.Fatal("missing branch accepted")
}
installation.WorkspaceRepository.Branch = "main"
status = 503
if checkGit(context.Background(), installation, value) == nil {
t.Fatal("unavailable existing Git accepted")
}
status = 200
_ = os.WriteFile(credentials, []byte(strings.Replace(server.URL, "https://", "https://reader:rotated@", 1)), 0o600)
if checkGit(context.Background(), installation, value) == nil {
t.Fatal("bad credential accepted")
}
}
+287
View File
@@ -0,0 +1,287 @@
package preflight
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/version"
"gopkg.in/yaml.v3"
)
type Plan struct {
SchemaVersion int `json:"schema_version"`
ValidatorProtocol int `json:"validator_protocol"`
Validator version.Info `json:"validator"`
Installation config.Installation `json:"installation"`
Release Manifest `json:"release"`
Platform string `json:"platform"`
WorkspaceDirectory string `json:"workspace_directory"`
WorkspaceRevision string `json:"workspace_revision"`
Inputs []string `json:"inputs"`
AbsentInputs []string `json:"absent_inputs"`
InputSeal string `json:"input_seal"`
Report Report `json:"report"`
}
func invalidPlan() error {
return errors.New("plan inputs changed or protected plan files are unavailable; repeat validation and produce a new plan")
}
// CaptureInputs gives a value-free freshness guard around live probes and document validation.
func CaptureInputs(paths []string, workspace string, absent ...string) (func() bool, error) {
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, invalidPlan()
}
probe := Plan{Inputs: paths, AbsentInputs: absent}
before, err := seal(probe, key)
if err != nil {
return nil, err
}
files, err := treeFiles(workspace, true)
if err != nil {
return nil, err
}
return func() bool {
after, err := seal(probe, key)
current, treeErr := treeFiles(workspace, true)
return err == nil && treeErr == nil && hmac.Equal([]byte(before), []byte(after)) && slices.Equal(files, current)
}, nil
}
// A separate owner-only random key prevents public/offline guessing of low-entropy secrets.
// Live prerequisites must still be rechecked immediately before any execution or resumption.
func seal(plan Plan, key []byte) (string, error) {
for _, path := range plan.AbsentInputs {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
return "", invalidPlan()
}
}
mac := hmac.New(sha256.New, key)
plan.InputSeal = ""
data, err := json.Marshal(plan)
if err != nil {
return "", invalidPlan()
}
mac.Write(data)
var total int
for _, path := range plan.Inputs {
contents, err := safeio.ReadCanonicalRegular(path, 32<<20)
if err != nil {
return "", invalidPlan()
}
total += len(contents)
if total > 256<<20 {
return "", invalidPlan()
}
length, _ := json.Marshal([]any{path, len(contents)})
mac.Write(length)
mac.Write(contents)
}
return hex.EncodeToString(mac.Sum(nil)), nil
}
func AbsentOverrides(installation config.Installation) []string {
paths := []string{}
for _, path := range installation.Overrides {
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
paths = append(paths, path)
}
}
return paths
}
func WritePlan(path string, plan *Plan, guards ...func() bool) error {
if !plan.Report.OK || plan.ValidatorProtocol != Protocol || !filepath.IsAbs(path) {
return invalidPlan()
}
if exists, err := safeio.PreflightPrivateDirectory(filepath.Dir(path)); err != nil || !exists {
return invalidPlan()
}
for _, target := range []string{path, path + ".key"} {
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
return invalidPlan()
}
}
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return invalidPlan()
}
var err error
plan.InputSeal, err = seal(*plan, key)
if err != nil {
return err
}
for _, guard := range guards {
if guard == nil || !guard() {
return invalidPlan()
}
}
data, _ := json.MarshalIndent(plan, "", " ")
if err := safeio.WriteCanonicalNewPrivateFile(path+".key", key, 0o600); err != nil {
return invalidPlan()
}
if err := safeio.WriteCanonicalNewPrivateFile(path, append(data, '\n'), 0o600); err != nil {
_ = safeio.RemoveCanonicalPrivateRegular(path + ".key")
return invalidPlan()
}
return nil
}
func VerifyPlanInputs(path string) error {
data, err := safeio.ReadCanonicalPrivateRegular(path, 4<<20)
if err != nil {
return invalidPlan()
}
key, err := safeio.ReadCanonicalPrivateRegular(path+".key", 32)
if err != nil || len(key) != 32 {
return invalidPlan()
}
var plan Plan
if json.Unmarshal(data, &plan) != nil || plan.SchemaVersion != 1 || plan.ValidatorProtocol != Protocol || !plan.Report.OK || len(plan.Inputs) == 0 || len(plan.Inputs) > 10000 {
return invalidPlan()
}
actual, err := seal(plan, key)
if err != nil || !hmac.Equal([]byte(actual), []byte(plan.InputSeal)) {
return invalidPlan()
}
// Detect added or removed workspace files as well as changes to known file bytes.
if plan.WorkspaceDirectory != "" {
files, err := treeFiles(plan.WorkspaceDirectory, true)
if err != nil {
return invalidPlan()
}
for _, file := range files {
if !slices.Contains(plan.Inputs, file) {
return invalidPlan()
}
}
}
return nil
}
func treeFiles(root string, skipGit bool) ([]string, error) {
paths := []string{}
count := 0
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return invalidPlan()
}
count++
if count > 10000 {
return invalidPlan()
}
if skipGit && entry.Name() == ".git" {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if entry.IsDir() {
return nil
}
if !entry.Type().IsRegular() {
return invalidPlan()
}
paths = append(paths, path)
return nil
})
return paths, err
}
// CollectInputs fingerprints exact prepared contents, with normalized configuration in Plan.
// Referenced credentials are sealed, never copied. Git object stores are excluded.
func CollectInputs(installation config.Installation, workspace, bootstrap, manifest string) ([]string, string, error) {
paths := []string{installation.Path, installation.EnvFile, bootstrap, manifest}
for _, root := range []string{workspace, installation.AuthenticationDirectory()} {
files, err := treeFiles(root, root == workspace)
if err != nil {
return nil, "", err
}
paths = append(paths, files...)
}
secrets, err := installation.SecretFiles()
if err != nil {
return nil, "", invalidPlan()
}
for _, path := range secrets {
paths = append(paths, path)
}
data, err := safeio.ReadCanonicalPrivateRegular(bootstrap, 1<<20)
if err != nil {
return nil, "", invalidPlan()
}
var bindings struct {
Databases []struct {
SecretFiles map[string]string `yaml:"secretFiles"`
EvidenceSecretFiles map[string]string `yaml:"evidenceSecretFiles"`
} `yaml:"databases"`
}
if yaml.Unmarshal(data, &bindings) != nil {
return nil, "", invalidPlan()
}
for _, entry := range bindings.Databases {
for _, values := range []map[string]string{entry.SecretFiles, entry.EvidenceSecretFiles} {
for _, path := range values {
paths = append(paths, path)
}
}
}
m, err := LoadManifest(manifest)
if err != nil {
return nil, "", err
}
for name := range m.Files {
paths = append(paths, filepath.Join(filepath.Dir(manifest), filepath.FromSlash(name)))
}
for _, path := range installation.Overrides {
if _, err := os.Lstat(path); err == nil {
paths = append(paths, path)
}
}
revision := "content-snapshot"
head := filepath.Join(workspace, ".git", "HEAD")
if data, err := safeio.ReadCanonicalRegular(head, 1024); err == nil {
paths = append(paths, head)
value := strings.TrimSpace(string(data))
if strings.HasPrefix(value, "ref: refs/") {
ref := strings.TrimPrefix(value, "ref: ")
if safeRelative(ref) {
path := filepath.Join(workspace, ".git", filepath.FromSlash(ref))
if data, err := safeio.ReadCanonicalRegular(path, 1024); err == nil {
paths = append(paths, path)
value = strings.TrimSpace(string(data))
}
}
}
if len(value) == 40 {
if _, err := hex.DecodeString(value); err == nil {
revision = value
}
}
}
slices.Sort(paths)
paths = slices.Compact(paths)
return paths, revision, nil
}
func AddRuntimeObligations(report *Report) {
for _, check := range []Check{
{"container-network", "deferred-to-runtime", "bindings", "Repeat authenticated DWH and external connectivity checks from the core network."},
{"catalog-initialization", "deferred-to-runtime", "catalog", "Apply migrations and verify Catalog health plus prepared binding import."},
{"pi-operation", "deferred-to-runtime", "release.components.pi", "Verify the bundled Pi version and authenticated provider/model smoke operation inside core; do not install Pi on the host."},
{"local-embedding", "deferred-to-runtime", "modelCatalog.embedding", "Initialize the local embedding model and verify returned vector dimensions."},
{"workspace-preprocessing", "deferred-to-runtime", "workspaces", "Sync the exact verified workspace contents, materialize Evidence, preprocess and verify collections."},
{"workspace-readiness", "deferred-to-runtime", "workspaces", "Complete required administrative and human review gates before claiming final readiness."},
} {
report.Checks = append(report.Checks, check)
}
}
+68
View File
@@ -0,0 +1,68 @@
package preflight
import (
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"os"
"path/filepath"
"strings"
"testing"
)
func TestPlanBindsInputsAndDetectsCredentialRotationWithoutPublicSecretHashes(t *testing.T) {
root, _ := filepath.EvalSymlinks(t.TempDir())
if err := safeio.ProtectPrivateDirectory(root); err != nil {
t.Fatal(err)
}
input := filepath.Join(root, "descriptor.yaml")
secret := filepath.Join(root, "credential")
for path, data := range map[string]string{input: "schemaVersion: 2\n", secret: "PRIVATE_SENTINEL"} {
if err := safeio.WriteCanonicalNewPrivateFile(path, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
}
plan := Plan{SchemaVersion: 1, ValidatorProtocol: Protocol, Inputs: []string{input, secret}, WorkspaceRevision: "content-snapshot", Report: NewReport()}
output := filepath.Join(root, "plan.json")
if err := WritePlan(output, &plan); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(output)
if strings.Contains(string(data), "PRIVATE_SENTINEL") {
t.Fatal("secret in plan")
}
if err := VerifyPlanInputs(output); err != nil {
t.Fatal(err)
}
if err := WritePlan(output, &plan); err == nil {
t.Fatal("existing plan replaced")
}
if err := os.WriteFile(secret, []byte("rotated"), 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyPlanInputs(output); err == nil {
t.Fatal("credential rotation did not invalidate plan")
}
if err := os.WriteFile(secret, []byte("PRIVATE_SENTINEL"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(input, []byte("schemaVersion: 3\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := VerifyPlanInputs(output); err == nil {
t.Fatal("document change did not invalidate plan")
}
if err := os.WriteFile(input, []byte("schemaVersion: 2\n"), 0o600); err != nil {
t.Fatal(err)
}
absent := filepath.Join(root, "transport-override.yaml")
plan.AbsentInputs = []string{absent}
second := filepath.Join(root, "second-plan.json")
if err := WritePlan(second, &plan); err != nil {
t.Fatal(err)
}
if err := safeio.WriteCanonicalNewPrivateFile(absent, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
if VerifyPlanInputs(second) == nil {
t.Fatal("newly appearing override did not invalidate plan")
}
}
@@ -0,0 +1,118 @@
package preflight
import (
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
type fakeDocker struct {
calls [][]string
fail string
effective string
}
func (f *fakeDocker) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
f.calls = append(f.calls, args)
if strings.Contains(strings.Join(args, " "), f.fail) && f.fail != "" {
return compose.Result{Stderr: "PRIVATE_SENTINEL"}, errors.New("PRIVATE_SENTINEL")
}
switch args[0] {
case "info":
return compose.Result{Stdout: `{"OSType":"linux","Architecture":"x86_64","NCPU":4,"MemTotal":17179869184}`}, nil
case "compose":
if args[len(args)-1] == "json" {
if f.effective != "" {
return compose.Result{Stdout: f.effective}, nil
}
return compose.Result{Stdout: `{"services":{"core":{"image":"example/core:latest"}}}`}, nil
}
return compose.Result{Stdout: "2.39.0"}, nil
case "manifest":
return compose.Result{Stdout: `{"Descriptor":{"digest":"sha256:` + strings.Repeat("a", 64) + `","platform":{"os":"linux","architecture":"amd64"}}}`}, nil
}
return compose.Result{}, errors.New("unexpected command")
}
func TestComposeRejectsIncompleteMutableServiceSet(t *testing.T) {
r := CheckCompose(context.Background(), &fakeDocker{}, config.Installation{ProjectDirectory: "/private", EnvFile: "/private/operator.env"}, Manifest{Compose: []string{"compose.yaml"}}, "/release/manifest.json", "linux/amd64")
if r.OK {
t.Fatal("unreleased service set accepted")
}
}
func TestComposeRejectsPlatformOverrideAgainstSelectedImage(t *testing.T) {
m := Manifest{Images: map[string]map[string]string{}, Compose: []string{"compose.yaml"}}
services := map[string]map[string]string{}
for service, role := range map[string]string{"core": "core", "frontend": "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"} {
m.Images[role] = map[string]string{"linux/amd64": "docker.io/example/" + role + "@sha256:" + strings.Repeat("a", 64)}
services[service] = map[string]string{"image": m.Images[role]["linux/amd64"]}
}
services["core"]["platform"] = "linux/arm64"
data, _ := json.Marshal(map[string]any{"services": services})
r := CheckCompose(context.Background(), &fakeDocker{effective: string(data)}, config.Installation{}, m, "/release/manifest.json", "linux/amd64")
if r.OK {
t.Fatal("incompatible Compose platform accepted")
}
}
func TestHostChecksAreReadOnlyAndRejectUnavailableDocker(t *testing.T) {
f := &fakeDocker{}
host := Host{OS: "linux", Arch: "amd64", Kernel: "6.6-microsoft-standard-WSL2", Distribution: "ubuntu", FreeBytes: 30 << 30}
r := CheckHost(context.Background(), f, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30})
if !r.OK {
t.Fatalf("host rejected: %+v", r)
}
for _, args := range f.calls {
if args[0] != "info" && !(args[0] == "compose" && args[1] == "version") {
t.Fatalf("mutating call: %v", args)
}
}
f.fail = "info"
r = CheckHost(context.Background(), f, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30})
if r.OK || strings.Contains(r.JSON(), "PRIVATE_SENTINEL") {
t.Fatalf("unsafe success/report: %s", r.JSON())
}
host.Kernel = "4.4-microsoft"
if CheckHost(context.Background(), &fakeDocker{}, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30}).OK {
t.Fatal("WSL1 accepted")
}
host.OS = "windows"
if CheckHost(context.Background(), &fakeDocker{}, host, Requirements{}).OK {
t.Fatal("native Windows accepted instead of WSL2")
}
}
func TestReleaseChecksEveryImmutableImageAndPlatform(t *testing.T) {
m := Manifest{SchemaVersion: 1, Version: "1.0.0", Revision: strings.Repeat("b", 40), ValidatorProtocol: 1, Requirements: Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30}, Components: []string{"pi", "catalog-migrations", "workspace-maintenance"}, Images: map[string]map[string]string{}}
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
m.Images[service] = map[string]string{"linux/amd64": "docker.io/example/" + service + "@sha256:" + strings.Repeat("a", 64)}
}
m.Files = map[string]string{"deploy/compose.yaml": strings.Repeat("c", 64)}
m.Compose = []string{"deploy/compose.yaml"}
if err := m.Validate(); err != nil {
t.Fatal(err)
}
f := &fakeDocker{}
r := CheckImages(context.Background(), f, m, "linux/amd64")
if !r.OK || len(f.calls) != 5 {
t.Fatalf("images not checked: %s calls=%d", r.JSON(), len(f.calls))
}
if CheckImages(context.Background(), f, m, "linux/arm64").OK {
t.Fatal("unsupported release architecture accepted")
}
f.fail = "frontend"
if CheckImages(context.Background(), f, m, "linux/amd64").OK {
t.Fatal("missing image accepted")
}
m.Images["core"]["linux/amd64"] = "example/core:latest"
if m.Validate() == nil {
t.Fatal("mutable tag accepted")
}
}
+131
View File
@@ -0,0 +1,131 @@
package preflight
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
)
// Manifest is the publication/consumer contract. Each platform maps to a single-image digest,
// not a mutable tag or multi-platform index. Maintenance roles use Images["core"].
type Manifest struct {
SchemaVersion int `json:"schema_version"`
Version string `json:"version"`
Revision string `json:"revision"`
ValidatorProtocol int `json:"validator_protocol"`
Requirements Requirements `json:"requirements"`
Components []string `json:"components"`
Images map[string]map[string]string `json:"images"`
Files map[string]string `json:"files"`
Compose []string `json:"compose"`
}
var hex256 = regexp.MustCompile(`^[a-f0-9]{64}$`)
var imageReference = regexp.MustCompile(`^docker\.io/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$`)
func invalidRelease() error {
return errors.New("release manifest or packaged files are incomplete, incompatible or invalid")
}
func (m Manifest) Validate() error {
if m.SchemaVersion != 1 || m.ValidatorProtocol != Protocol || !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$`).MatchString(m.Version) || !regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(m.Revision) {
return invalidRelease()
}
if m.Requirements.CPUs < 2 || m.Requirements.MemoryBytes < 4<<30 || m.Requirements.DiskBytes < 10<<30 {
return invalidRelease()
}
for _, component := range []string{"pi", "catalog-migrations", "workspace-maintenance"} {
if !slices.Contains(m.Components, component) {
return invalidRelease()
}
}
if len(m.Images) != 5 || len(m.Files) == 0 || len(m.Files) > 256 || len(m.Compose) == 0 || len(m.Compose) > 8 {
return invalidRelease()
}
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
if len(m.Images[service]) == 0 {
return invalidRelease()
}
for platform, ref := range m.Images[service] {
if (platform != "linux/amd64" && platform != "linux/arm64") || !imageReference.MatchString(ref) {
return invalidRelease()
}
}
}
for name, digest := range m.Files {
if !safeRelative(name) || !hex256.MatchString(digest) {
return invalidRelease()
}
}
for _, name := range m.Compose {
if _, ok := m.Files[name]; !ok {
return invalidRelease()
}
}
return nil
}
func safeRelative(name string) bool {
return name != "" && !strings.Contains(name, "\\") && !strings.Contains(name, ":") && !strings.HasPrefix(name, "/") && filepath.ToSlash(filepath.Clean(name)) == name && name != ".." && !strings.HasPrefix(name, "../") && name != "."
}
func LoadManifest(path string) (Manifest, error) {
var m Manifest
contents, err := safeio.ReadCanonicalRegular(path, 1<<20)
if err != nil {
return m, invalidRelease()
}
decoder := json.NewDecoder(bytes.NewReader(contents))
decoder.DisallowUnknownFields()
if decoder.Decode(&m) != nil || decoder.Decode(new(any)) != io.EOF {
return Manifest{}, invalidRelease()
}
if err = m.Validate(); err != nil {
return Manifest{}, err
}
for name, digest := range m.Files {
contents, err := safeio.ReadCanonicalRegular(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)), 32<<20)
if err != nil {
return Manifest{}, invalidRelease()
}
sum := sha256.Sum256(contents)
if hex.EncodeToString(sum[:]) != digest {
return Manifest{}, invalidRelease()
}
}
return m, nil
}
func CheckImages(ctx context.Context, runner Runner, m Manifest, platform string) Report {
r := NewReport()
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
ref := m.Images[service][platform]
if ref == "" {
r.Add("image-"+service, "error", "release.images."+service, "Publish the selected Linux architecture before producing an executable plan.")
continue
}
result, err := docker(ctx, runner, "manifest", "inspect", "--verbose", ref)
var manifest struct {
Descriptor struct {
Digest string `json:"digest"`
Platform struct {
OS string `json:"os"`
Architecture string `json:"architecture"`
} `json:"platform"`
} `json:"Descriptor"`
}
good := err == nil && json.Unmarshal([]byte(result.Stdout), &manifest) == nil && manifest.Descriptor.Platform.OS+"/"+manifest.Descriptor.Platform.Architecture == platform && strings.HasSuffix(ref, "@"+manifest.Descriptor.Digest) && manifest.Descriptor.Digest != ""
outcome := "passed"
if !good {
outcome = "error"
}
r.Add("image-"+service, outcome, "release.images."+service, "Require the pinned digest to be publicly readable in Docker Hub for the selected Linux architecture.")
}
return r
}
@@ -0,0 +1,73 @@
package preparation
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
)
// Credentials creates installation-owned secrets only. External credentials are supplied by
// the operator. Existing files are checked and retained so interrupted preparation can resume.
func Credentials(ctx context.Context, directory string) error {
if err := safeio.ValidatePrivateDirectory(directory); err != nil {
return fmt.Errorf("use an existing private preparation directory")
}
if _, err := safeio.ReadCanonicalPrivateRegular(filepath.Join(directory, "thothii-installation.yaml"), 1<<20); err != nil {
return fmt.Errorf("prepare installation documents first")
}
secrets := filepath.Join(directory, "secrets")
if err := safeio.EnsurePrivateDirectory(secrets); err != nil {
return fmt.Errorf("secrets directory must be private and operator-owned")
}
for _, name := range []string{"catalog-runtime-password", "catalog-migrator-password", "admin-password"} {
path := filepath.Join(secrets, name)
if _, err := os.Lstat(path); err == nil {
value, err := safeio.ReadCanonicalPrivateRegular(path, 64<<10)
if err != nil || len(strings.TrimSpace(string(value))) < 32 {
return fmt.Errorf("existing technical credentials must be private, readable and at least 32 characters; no file was replaced")
}
continue
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cannot inspect technical credentials")
}
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return fmt.Errorf("cannot generate random credentials")
}
if err := safeio.WriteCanonicalNewPrivateFile(path, []byte(hex.EncodeToString(value)+"\n"), 0o600); err != nil {
return fmt.Errorf("cannot create credential; existing files are retained")
}
}
for name, contents := range map[string]string{"secrets.env": "# Supply the provider credential; never commit this file.\nOPENAI_API_KEY=CHANGE_ME\n", "pi-auth.json": "{}\n"} {
path := filepath.Join(secrets, name)
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
if err := safeio.WriteCanonicalNewPrivateFile(path, []byte(contents), 0o600); err != nil {
return fmt.Errorf("cannot create external credential template")
}
} else if _, err := safeio.ReadCanonicalPrivateRegular(path, 64<<10); err != nil {
return fmt.Errorf("existing credential template is not private or readable")
}
}
authDirectory := filepath.Join(directory, "auth")
if _, err := os.Lstat(filepath.Join(authDirectory, "auth.yaml")); err == nil {
if _, _, err := authconfig.Load(authDirectory); err != nil {
return fmt.Errorf("existing authentication documents are invalid; repair them explicitly")
}
return nil
}
installation := config.Installation{Authentication: config.Authentication{ConfigDirectory: authDirectory}}
if authconfig.Run(ctx, installation, []string{"configure", "--mode", "local", "--public-url", "http://localhost:8080", "--admin-user", "admin", "--password-file", filepath.Join(secrets, "admin-password")}, strings.NewReader(""), io.Discard, io.Discard) != 0 {
return fmt.Errorf("cannot prepare local administrator; inspect protected auth files and retry without replacing existing credentials")
}
return nil
}
@@ -0,0 +1,94 @@
// Package preparation owns installation-local documents before any runtime exists.
package preparation
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
)
func Prepare(directory string) error {
if err := safeio.ValidateCanonicalPath(directory); err != nil {
return fmt.Errorf("choose an absolute canonical destination")
}
exists, err := safeio.PreflightPrivateDirectory(directory)
if err != nil || exists {
return fmt.Errorf("choose a new private directory with an existing parent; existing documents are never replaced")
}
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
return fmt.Errorf("cannot create private preparation directory")
}
root := func(name string) string { return strconv.Quote(filepath.Join(directory, name)) }
installation := fmt.Sprintf(`# Installation schema v2; replace CHANGE_ME before validation.
# Paths refer to local preparation files, never to workspace Git.
schemaVersion: 2
profile: local
projectDirectory: %s
envFile: %s
shell: {mode: full, defaultLocale: en}
workspaceRepository:
remote: https://CHANGE_ME/workspaces.git
branch: main
access: https
authentication:
configDirectory: %s
modelCatalog:
defaults: {interaction: openai/gpt-4.1-mini}
embedding: {id: 'ollama/qwen3-embedding:0.6b', dimensions: 1024}
providers:
openai:
authentication: {mode: secret_env, apiKeyEnv: OPENAI_API_KEY}
session: {mode: pi_builtin}
models:
gpt-4.1-mini: {session: {}}
# Metadata generation is optional: omitted here. Configure its eligibility in this catalog.
# This Compose asset will come from the release; no application checkout is required here.
overrides: [%s]
`, strconv.Quote(directory), root("operator.env"), root("auth"), root("deploy/compose.git-https.yaml"))
environment := "# Non-secret paths and parameters; no interpolation or duplicate keys.\n"
for _, entry := range [][2]string{
{"COMPOSE_PROJECT_NAME", "thothii-local"}, {"THT_WORKSPACE_INSTALLATION_ID", "local"},
{"THT_WORKSPACE_GIT_REMOTE", "https://CHANGE_ME/workspaces.git"}, {"THT_WORKSPACE_GIT_BRANCH", "main"},
{"THT_INSTALLATION_CONFIG_SOURCE", filepath.Join(directory, "thothii-installation.yaml")},
{"THT_AUTH_CONFIG_ROOT", filepath.Join(directory, "auth")},
{"THT_SECRETS_FILE", filepath.Join(directory, "secrets", "secrets.env")},
{"PI_AUTH_FILE", filepath.Join(directory, "secrets", "pi-auth.json")},
{"THT_WORKSPACE_GIT_CREDENTIALS_FILE", filepath.Join(directory, "secrets", "git-credentials")},
{"THT_WORKSPACE_GIT_CA_FILE", filepath.Join(directory, "secrets", "git-ca.pem")},
{"THT_CATALOG_RUNTIME_PASSWORD_SOURCE", filepath.Join(directory, "secrets", "catalog-runtime-password")},
{"THT_CATALOG_MIGRATOR_PASSWORD_SOURCE", filepath.Join(directory, "secrets", "catalog-migrator-password")},
{"THOTH_HTTP_PORT", "8080"}, {"THOTH_CORE_HTTP_PORT", "8787"}, {"MAX_PI_PROCESSES", "4"},
} {
environment += entry[0] + "=" + strconv.Quote(entry[1]) + "\n"
}
bootstrap := fmt.Sprintf(`# Bootstrap input only; PostgreSQL Metadata Catalog remains the runtime authority.
schemaVersion: 1
databases:
- workspaceId: CHANGE_ME
engine: postgres
databaseName: CHANGE_ME
schema: public
binding:
transport: postgres_direct
host: CHANGE_ME
port: 5432
username: CHANGE_ME
secretFiles:
password: %s
`, root("secrets/database-password"))
documents := map[string]string{
".gitignore": "*\n",
"thothii-installation.yaml": installation, "operator.env": environment,
"database-bootstrap.yaml": bootstrap,
"README.md": "# Preparation / Preparazione\n\nReplace every CHANGE_ME / Sostituire ogni CHANGE_ME. Keep all files outside workspace Git / Tenere tutti i file fuori dal Git dei workspace.\n\n1. Edit installation models, workspace remote and operator.env consistently. / Modificare modelli, remoto e operator.env in modo coerente.\n2. Add one database bootstrap entry for every workspace; use read-only DWH credentials in protected files. / Una voce database per workspace, credenziali DWH in sola lettura in file protetti.\n3. Run tht installation credentials --directory PATH before setup; fill provider/database secrets yourself. / Generare credenziali tecniche prima del setup; compilare i segreti esterni.\n4. Repeat tht --installation PATH/thothii-installation.yaml installation validate --workspaces WORKSPACES. / Correggere e ripetere.\n\nNo services, network calls or database writes / Nessun servizio, chiamata di rete o scrittura database.\n",
}
for _, name := range []string{".gitignore", "thothii-installation.yaml", "operator.env", "database-bootstrap.yaml", "README.md"} {
if err := safeio.WriteCanonicalNewPrivateFile(filepath.Join(directory, name), []byte(strings.TrimSpace(documents[name])+"\n"), 0o600); err != nil {
return fmt.Errorf("cannot create preparation files; inspect the new directory and retry in a new destination")
}
}
return nil
}
@@ -0,0 +1,279 @@
package preparation
import (
"bytes"
"encoding/json"
"io"
"regexp"
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"gopkg.in/yaml.v3"
)
type Issue struct {
Document string `json:"document"`
Field string `json:"field"`
Code string `json:"code"`
Correction string `json:"correction"`
}
type Report struct {
SchemaVersion int `json:"schema_version"`
Scope string `json:"scope"`
OK bool `json:"ok"`
Issues []Issue `json:"issues"`
Warnings []string `json:"warnings"`
DeferredChecks []string `json:"deferred_checks"`
}
func NewReport() Report {
return Report{SchemaVersion: 1, Scope: "application-documents", Issues: []Issue{}, Warnings: []string{}, DeferredChecks: []string{"release-assets", "external-connectivity", "catalog-import", "runtime-readiness"}}
}
func (r *Report) Add(document, field, code, correction string) {
r.OK = false
r.Issues = append(r.Issues, Issue{document, field, code, correction})
}
func placeholder(value string) bool {
upper := strings.ToUpper(value)
return strings.Contains(upper, "CHANGE_ME") || strings.Contains(upper, "REPLACE_ME") || strings.Contains(upper, "YOUR_API_KEY") || strings.Contains(upper, "<PASSWORD>")
}
// CheckYAML refuses unresolved placeholders and ambiguous authored YAML without returning values.
func CheckYAML(path, document string, report *Report) bool {
content, err := safeio.ReadCanonicalPrivateRegular(path, 1<<20)
if err != nil {
report.Add(document, "$", "file_unavailable", "Use a readable, private regular document at a canonical absolute path (no links).")
return false
}
decoder := yaml.NewDecoder(bytes.NewReader(content))
var node, extra yaml.Node
if err := decoder.Decode(&node); err != nil || decoder.Decode(&extra) != io.EOF {
report.Add(document, "$", "yaml_invalid", "Keep one well-formed YAML document.")
return false
}
var walk func(*yaml.Node) bool
walk = func(n *yaml.Node) bool {
if n.Kind == yaml.AliasNode || n.Tag == "!!merge" {
return false
}
if n.Kind == yaml.ScalarNode && placeholder(n.Value) {
return false
}
if n.Kind == yaml.MappingNode {
seen := map[string]bool{}
for index := 0; index < len(n.Content); index += 2 {
key := n.Content[index]
if key.Kind != yaml.ScalarNode || seen[key.Value] {
return false
}
seen[key.Value] = true
}
}
for _, child := range n.Content {
if !walk(child) {
return false
}
}
return true
}
if !walk(&node) {
report.Add(document, "$", "incomplete_or_ambiguous", "Replace CHANGE_ME/REPLACE_ME placeholders, remove duplicate keys, aliases and YAML merge keys.")
return false
}
return true
}
var environmentKey = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
func checkEnvironment(path string, report *Report) bool {
contents, err := safeio.ReadCanonicalPrivateRegular(path, 1<<20)
if err != nil {
report.Add("operator.env", "$", "environment_unavailable", "Provide a private readable environment file.")
return false
}
seen := map[string]bool{}
for _, raw := range strings.Split(string(contents), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found || !environmentKey.MatchString(key) || seen[key] || placeholder(value) || strings.Contains(value, "$") {
report.Add("operator.env", "$", "environment_invalid", "Use one literal KEY=value per line, unique uppercase keys, and replace placeholders; shell interpolation is not accepted.")
return false
}
if strings.HasSuffix(key, "_PASSWORD") || strings.HasSuffix(key, "_API_KEY") {
report.Add("operator.env", "$", "inline_secret", "Move credential values to protected files and keep only file references in operator.env.")
return false
}
seen[key] = true
}
return true
}
func CheckSecret(path, document, field string, allowEmpty bool, report *Report) bool {
contents, err := safeio.ReadCanonicalPrivateRegular(path, 64<<10)
if err != nil || (!allowEmpty && len(bytes.TrimSpace(contents)) == 0) || placeholder(string(contents)) {
report.Add(document, field, "secret_unavailable", "Supply a non-placeholder private readable regular secret file (owner-only permissions, no links); do not put its contents in YAML or logs.")
return false
}
return true
}
func Validate(path string) (config.Installation, Report) {
report := NewReport()
if !CheckYAML(path, "thothii-installation.yaml", &report) {
return config.Installation{}, report
}
// Read only the location before the canonical loader checks every field.
contents, _ := safeio.ReadCanonicalPrivateRegular(path, 1<<20)
var location struct {
EnvFile string `yaml:"envFile"`
}
if yaml.Unmarshal(contents, &location) != nil {
report.Add("thothii-installation.yaml", "envFile", "schema_invalid", "Set envFile to one absolute path string, then correct the remaining descriptor fields.")
return config.Installation{}, report
}
if !checkEnvironment(location.EnvFile, &report) {
return config.Installation{}, report
}
installation, err := config.LoadPrepared(path)
if err != nil {
field, correction := "$", "Check installation schema v2, absolute paths, workspace remote/branch and matching environment values. Custom overrides and Git credential/CA files must exist."
if strings.Contains(err.Error(), "modelCatalog") {
field, correction = "modelCatalog", "Check interaction default eligibility, provider endpoints/authentication, embedding id/dimensions, and the private provider key bundle."
}
if strings.Contains(err.Error(), "authentication") {
field, correction = "authentication", "Match the authentication directory to operator.env and prepare its protected files."
}
report.Add("thothii-installation.yaml", field, "configuration_invalid", correction)
return config.Installation{}, report
}
value := func(key string) string { result, _ := installation.EnvironmentValue(key); return result }
for _, key := range []string{"COMPOSE_PROJECT_NAME", "THT_WORKSPACE_INSTALLATION_ID", "THT_INSTALLATION_CONFIG_SOURCE", "THT_SECRETS_FILE", "PI_AUTH_FILE", "THT_CATALOG_RUNTIME_PASSWORD_SOURCE", "THT_CATALOG_MIGRATOR_PASSWORD_SOURCE"} {
if value(key) == "" {
report.Add("operator.env", key, "required", "Supply this installation parameter or protected file reference before setup.")
}
}
if value("THT_INSTALLATION_CONFIG_SOURCE") != path {
report.Add("operator.env", "THT_INSTALLATION_CONFIG_SOURCE", "path_mismatch", "Point to the exact installation descriptor being validated.")
}
for _, key := range []string{"THOTH_HTTP_PORT", "THOTH_CORE_HTTP_PORT", "MAX_PI_PROCESSES"} {
number, err := strconv.Atoi(value(key))
if err != nil || number < 1 || number > 65535 {
report.Add("operator.env", key, "invalid_number", "Supply a positive integer; HTTP ports must be within 1..65535.")
}
}
if value("THOTH_HTTP_PORT") == value("THOTH_CORE_HTTP_PORT") {
report.Add("operator.env", "THOTH_CORE_HTTP_PORT", "port_collision", "Choose different frontend and core host ports.")
}
files, err := installation.SecretFiles()
if err != nil {
report.Add("operator.env", "$", "secret_references_invalid", "Use canonical absolute paths for all _FILE and _SOURCE references.")
}
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")
CheckSecret(file, "operator.env", "protected-file-reference", allowEmpty, &report)
}
auth, users, err := authconfig.Load(installation.AuthenticationDirectory())
if err != nil {
report.Add("auth/auth.yaml", "$", "authentication_invalid", "Prepare and validate local authentication documents before setup, including the initial administrator.")
} else if auth.Mode == "local" {
admin := false
for _, user := range users.Users {
for _, role := range user.Roles {
if user.Enabled && role == authconfig.RoleAdmin {
admin = true
}
}
}
if !admin {
report.Add("auth/users.yaml", "users", "administrator_missing", "Enable at least one administrator before setup.")
}
} else {
report.Add("auth/auth.yaml", "mode", "unsupported_bootstrap", "This preparation increment supports local administrative authentication; use the documented existing OIDC preparation path until its offline bootstrap validation is available.")
}
checkPiCredentials(value("PI_AUTH_FILE"), installation.ModelCatalog, &report)
report.OK = len(report.Issues) == 0
return installation, report
}
// Pi stores api_key or oauth records. Require literal prepared material here; model
// environment references belong to the catalog's secret_env path, not host process state.
func checkPiCredentials(path string, catalog config.ModelCatalog, report *Report) {
contents, err := safeio.ReadCanonicalPrivateRegular(path, 64<<10)
var credentials map[string]map[string]any
invalid := err != nil || json.Unmarshal(contents, &credentials) != nil || credentials == nil
literal := func(value any) bool {
text, ok := value.(string)
return ok && strings.TrimSpace(text) != "" && !strings.HasPrefix(text, "!") && !strings.Contains(text, "$") && !placeholder(text)
}
var declarative func(any) bool
declarative = func(value any) bool {
switch v := value.(type) {
case string:
return !strings.HasPrefix(v, "!")
case []any:
for _, item := range v {
if !declarative(item) {
return false
}
}
case map[string]any:
for _, item := range v {
if !declarative(item) {
return false
}
}
}
return true
}
seen := map[string]bool{}
for provider, record := range credentials {
name := strings.ToLower(strings.TrimSpace(provider))
if name == "" || provider != name || seen[name] || !declarative(record) {
invalid = true
}
seen[name] = true
switch record["type"] {
case "api_key":
if !literal(record["key"]) {
invalid = true
}
if env, present := record["env"]; present {
values, ok := env.(map[string]any)
if !ok {
invalid = true
}
for _, value := range values {
if _, ok := value.(string); !ok {
invalid = true
}
}
}
case "oauth":
expires, ok := record["expires"].(float64)
if !literal(record["access"]) || !literal(record["refresh"]) || !ok || expires <= 0 {
invalid = true
}
default:
invalid = true
}
}
for id, provider := range catalog.Providers {
if provider.Authentication.Mode == "pi_auth" && !seen[id] {
invalid = true
}
}
if invalid {
report.Add("pi-auth.json", "providers", "provider_auth_invalid", "Provide a JSON object with literal api_key/type+key or oauth/type+access+refresh+expires records for selected Pi providers; use catalog secret_env for environment-based keys. Commands and unresolved references are not accepted.")
}
}
+157 -8
View File
@@ -3,6 +3,8 @@ package setup
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -13,6 +15,7 @@ import (
"sort"
"strconv"
"strings"
"unicode"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
@@ -26,6 +29,7 @@ const (
)
var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
var secretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
// atomicWriteNewFile is a seam for failure testing. Its implementation never replaces an existing
// file and leaves no final target until all content is synced.
@@ -44,6 +48,7 @@ type answers struct {
secretsFile, piAuthFile string
gitCredentialsFile, gitCAFile string
gitSSHKeyFile, gitKnownHostsFile string
complete bool
createSecretTemplates bool
}
@@ -116,6 +121,11 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul
if err := validateOrCreateSecretFiles(values, output); err != nil {
return FilesResult{}, err
}
if values.complete {
if err := validateCompleteProtectedFiles(values); err != nil {
return FilesResult{}, err
}
}
created := make([]string, 0, 2)
cleanup := func() {
@@ -163,6 +173,27 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
value := answersFromRequest(request)
value.installationID = firstNonEmpty(request.InstallationID, os.Getenv("THT_SETUP_INSTALLATION_ID"), "local")
value.profile = firstNonEmpty(request.Profile, os.Getenv("THT_SETUP_PROFILE"), "local")
value.complete = request.Complete
if request.Complete {
// The complete path has one predictable protected directory. The user only fills the
// bundle and any repository credential that is genuinely required; catalog passwords
// are generated below and never appear in the questionnaire.
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
value.workspaceBranch = firstNonEmpty(value.workspaceBranch, "main")
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
if request.NonInteractive {
value.workspaceAccess = firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote))
if value.workspaceAccess == "ssh" {
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
} else {
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
}
}
value.createSecretTemplates = true
}
if request.NonInteractive {
return requireNonInteractiveAnswers(value)
}
@@ -190,6 +221,18 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
return answers{}, err
}
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
if request.Complete {
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
if value.workspaceAccess == "ssh" {
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
} else {
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
}
return value, nil
}
if value.secretsFile, err = prompt(scanner, output, "Secret file location", firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))); err != nil {
return answers{}, err
}
@@ -216,11 +259,15 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
return answers{}, missingErr
}
if len(missing) > 0 {
answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no")
if promptErr != nil {
return answers{}, promptErr
if request.Complete {
value.createSecretTemplates = true
} else {
answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no")
if promptErr != nil {
return answers{}, promptErr
}
value.createSecretTemplates = strings.EqualFold(answer, "yes")
}
value.createSecretTemplates = strings.EqualFold(answer, "yes")
}
return value, nil
}
@@ -379,6 +426,13 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
if value.llmURL != "" {
lines = append(lines, "THT_LLM_URL="+dotenvValue(value.llmURL))
}
if value.complete {
passwordDirectory := filepath.Dir(value.secretsFile)
lines = append(lines,
"THT_CATALOG_RUNTIME_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-runtime-password")),
"THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-migrator-password")),
)
}
if value.profile == "server" {
installationDirectory := filepath.Dir(descriptorPath)
lines = append(lines,
@@ -474,10 +528,7 @@ func validateOrCreateSecretFiles(value answers, output io.Writer) error {
if err != nil {
return err
}
if len(missing) == 0 {
return nil
}
if !value.createSecretTemplates {
if len(missing) > 0 && !value.createSecretTemplates {
return fmt.Errorf("secret files are missing: %s; create them yourself or explicitly confirm blank secret-file templates", strings.Join(missing, ", "))
}
for _, path := range missing {
@@ -489,6 +540,104 @@ func validateOrCreateSecretFiles(value answers, output io.Writer) error {
}
fmt.Fprintf(output, "Created blank secret-file template: %s\n", path)
}
if value.complete {
for _, path := range catalogPasswordPaths(value) {
exists, err := inspectExistingSecretFile(path)
if err != nil {
return err
}
if exists {
continue
}
contents, err := generatedCatalogPassword()
if err != nil {
return fmt.Errorf("generate catalog password: %w", err)
}
if err := atomicWriteNewFile(path, contents, 0o600); err != nil {
return fmt.Errorf("create catalog password %s: %w", path, err)
}
fmt.Fprintf(output, "Created generated catalog password file: %s\n", path)
}
}
return nil
}
func catalogPasswordPaths(value answers) []string {
directory := filepath.Dir(value.secretsFile)
return []string{
filepath.Join(directory, "catalog-runtime-password"),
filepath.Join(directory, "catalog-migrator-password"),
}
}
func generatedCatalogPassword() ([]byte, error) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return nil, errors.New("secure random source is unavailable")
}
return []byte(hex.EncodeToString(value) + "\n"), nil
}
func validateCompleteProtectedFiles(value answers) error {
if err := validateSecretBundle(value.secretsFile); err != nil {
return err
}
// The generated catalog deliberately uses Pi's built-in provider. A syntactically empty
// auth store would let Docker start only to fail at the first provider check, so catch it
// before any image is built. Other model providers can be selected later in the descriptor.
contents, err := safeio.ReadCanonicalRegular(value.piAuthFile, maxSecretBytes)
if err != nil || strings.TrimSpace(string(contents)) == "" || strings.TrimSpace(string(contents)) == "{}" {
return fmt.Errorf("complete setup requires usable Pi credentials in %s", value.piAuthFile)
}
if value.workspaceAccess == "ssh" {
for name, path := range map[string]string{
"workspace Git SSH key": value.gitSSHKeyFile,
"workspace Git known-hosts": value.gitKnownHostsFile,
} {
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
}
}
} else {
for name, path := range map[string]string{
"workspace Git credentials": value.gitCredentialsFile,
"workspace Git CA": value.gitCAFile,
} {
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
}
}
}
return nil
}
func validateSecretBundle(path string) error {
contents, err := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if err != nil {
return fmt.Errorf("complete setup cannot read the secret bundle %s", path)
}
seen := make(map[string]struct{})
for lineNumber, raw := range strings.Split(string(contents), "\n") {
line := strings.TrimSuffix(raw, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
key, secret, found := strings.Cut(line, "=")
invalid := !found || !secretBundleKeyPattern.MatchString(key) || strings.TrimSpace(key) != key ||
secret == "" || strings.TrimSpace(secret) != secret ||
strings.Contains(strings.ToLower(secret), "replace-me") ||
strings.IndexFunc(secret, unicode.IsSpace) >= 0
if invalid {
return fmt.Errorf("complete setup found an invalid secret bundle entry at line %d", lineNumber+1)
}
if _, duplicate := seen[key]; duplicate {
return fmt.Errorf("complete setup found a duplicate secret bundle key %s", key)
}
seen[key] = struct{}{}
}
return nil
}
+44
View File
@@ -207,6 +207,50 @@ func TestEnsureFilesRequiresExplicitNonInteractiveAnswers(t *testing.T) {
}
}
func TestCompleteSetupCreatesProtectedPlaceholdersThenRequiresUsableCredentials(t *testing.T) {
root := newProject(t, "complete setup")
for name, value := range map[string]string{
"THT_SETUP_WORKSPACE_REMOTE": "git@git.example.invalid:team/workspaces.git",
"THT_SETUP_WORKSPACE_BRANCH": "main",
"THT_SETUP_WORKSPACE_ACCESS": "ssh",
} {
t.Setenv(name, value)
}
request := Request{ProjectRoot: root, InstallationID: "local", Profile: "local", Complete: true, NonInteractive: true}
if _, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{}); err == nil || !strings.Contains(err.Error(), "usable Pi credentials") {
t.Fatalf("first complete setup error = %v, want the placeholder guidance", err)
}
secretRoot := filepath.Join(root, "deploy", "local", "secrets")
for path, contents := range map[string]string{
filepath.Join(secretRoot, "pi-auth.json"): "{\"deepseek\":{\"apiKey\":\"configured\"}}\n",
filepath.Join(secretRoot, "workspace-git-key"): "private-key\n",
filepath.Join(secretRoot, "workspace-git-known-hosts"): "git.example.invalid ssh-ed25519 AAAA\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
result, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
environment, err := os.ReadFile(result.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"THT_CATALOG_RUNTIME_PASSWORD_SOURCE", "THT_CATALOG_MIGRATOR_PASSWORD_SOURCE"} {
if !strings.Contains(string(environment), name+"=") {
t.Fatalf("complete environment misses %s: %s", name, environment)
}
}
for _, name := range []string{"catalog-runtime-password", "catalog-migrator-password"} {
contents, readErr := os.ReadFile(filepath.Join(secretRoot, name))
if readErr != nil || len(strings.TrimSpace(string(contents))) < 32 {
t.Fatalf("generated catalog password %s is unavailable or too short", name)
}
}
}
func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
requireProjectedServerTestHost(t)
root := newProject(t, "server profile")
+5 -2
View File
@@ -1,12 +1,15 @@
// Package setup creates the local, non-secret configuration selected by tht setup.
package setup
// Request contains the stable setup-file inputs. Task 5 will use ConfigureOnly when it adds
// Compose validation and lifecycle orchestration.
// Request contains the stable setup-file inputs.
type Request struct {
ProjectRoot string
InstallationID string
Profile string
// Complete runs the installation-only steps that are safe to automate: catalog migration,
// stack startup, and the initial workspace pull. It intentionally does not invent database
// bindings or credentials that belong to the installation operator.
Complete bool
ConfigureOnly bool
NonInteractive bool
Answers Answers
+46 -2
View File
@@ -3,6 +3,7 @@ package setup
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -35,7 +36,8 @@ type Result struct {
}
// Run validates the host, creates or validates non-secret configuration, and by default builds,
// starts, and verifies the current checkout. ConfigureOnly stops after Compose rendering.
// starts, and verifies the current checkout. Complete additionally migrates the Catalog and
// imports the configured workspace repository. ConfigureOnly stops after Compose rendering.
func Run(ctx context.Context, runner compose.Runner, request Request, input io.Reader, output io.Writer) (Result, error) {
if runner == nil {
return Result{}, errors.New("setup requires a Docker command runner")
@@ -71,13 +73,33 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
fmt.Fprintf(output, "Configuration is ready: %s\n", result.DescriptorPath)
return result, nil
}
if err := service.Start(ctx, installation, runner, true); err != nil {
if request.Complete {
if err := runCompose(ctx, runner, installation, "build"); err != nil {
return Result{}, fmt.Errorf("setup image build: %w", err)
}
if err := runCompose(ctx, runner, installation, "up", "--detach", "catalog-db"); err != nil {
return Result{}, fmt.Errorf("setup Catalog database start: %w", err)
}
if err := runCompose(ctx, runner, installation,
"--profile", "catalog-maintenance", "run", "--rm", "catalog-migrate"); err != nil {
return Result{}, fmt.Errorf("setup Catalog migration: %w", err)
}
}
if err := service.Start(ctx, installation, runner, !request.Complete); err != nil {
if strings.Contains(err.Error(), "image build") {
return Result{}, fmt.Errorf("setup %w", err)
}
return Result{}, withStartupRecovery(fmt.Errorf("setup %w", err), recoveryService(err))
}
result.Built, result.Started, result.Healthy = true, true, true
if request.Complete {
if err := runOperator(ctx, runner, installation, "workspace-pull"); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup workspace import: %w", err), "core")
}
if err := runOperator(ctx, runner, installation, "pi-test"); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup LLM credential test: %w", err), "core")
}
}
report, err := doctor.Run(ctx, installation, runner)
if err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup doctor: %w", err), "core")
@@ -85,6 +107,9 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
if !report.OK {
return Result{}, withStartupRecovery(errors.New("setup doctor reported failed checks"), "core")
}
if request.Complete {
fmt.Fprintln(output, "Workspace repository pulled and activated; run 'tht workspace test' after configuring each workspace database.")
}
fmt.Fprintf(output, "ThothII is ready at %s\nInstallation descriptor: %s\nNext: tht status\n", frontendURL(installation), result.DescriptorPath)
return result, nil
}
@@ -219,6 +244,25 @@ func runCompose(ctx context.Context, runner compose.Runner, installation config.
return nil
}
func runOperator(ctx context.Context, runner compose.Runner, installation config.Installation, action string) error {
result, err := runner.Run(ctx, installation.ComposeArgs(
"exec", "-T", "core", "node", "dist/operator-command.js", action,
), nil)
if err != nil {
if result.ExitCode != 0 {
return fmt.Errorf("Docker exited with status %d", result.ExitCode)
}
return err
}
var payload struct {
Ready *bool `json:"ready"`
}
if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil || payload.Ready == nil || !*payload.Ready {
return fmt.Errorf("operator action %s reported failure", action)
}
return nil
}
func composeFailure(result compose.Result, cause error) error {
if result.ExitCode != 0 {
return fmt.Errorf("Docker exited with status %d", result.ExitCode)
+34
View File
@@ -68,6 +68,34 @@ func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) {
}
}
func TestRunCompleteMigratesCatalogPullsWorkspaceAndTestsLLM(t *testing.T) {
root, request := setupRunFixture(t, false)
request.Complete = true
secretRoot := filepath.Join(root, "deploy", "ci", "secrets")
for path, contents := range map[string]string{
filepath.Join(secretRoot, "pi-auth.json"): "{\"deepseek\":{\"apiKey\":\"configured\"}}\n",
filepath.Join(secretRoot, "workspace-git-key"): "private-key\n",
filepath.Join(secretRoot, "workspace-git-known-hosts"): "git.example.invalid ssh-ed25519 AAAA\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
runner := &setupRunner{health: []string{healthyServicesJSON}}
if _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard); err != nil {
t.Fatal(err)
}
want := []string{
"docker engine", "docker compose", "architecture", "compose config", "compose build",
"catalog db", "catalog migrate", "compose up", "health", "workspace pull", "pi doctor",
"doctor docker", "doctor compose", "compose config", "doctor config", "health",
"authentication", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor",
}
if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") {
t.Fatalf("complete setup stages = %v, want %v", got, want)
}
}
func TestRunConfiguresAndStaticallyValidatesLocalAuthBeforeComposeRender(t *testing.T) {
projectRoot, request := setupRunFixture(t, true)
passwordFile := filepath.Join(projectRoot, "initial-admin-password")
@@ -421,6 +449,10 @@ func setupStage(args []string) (string, compose.Result) {
return "architecture", compose.Result{Stdout: "arm64\n"}
case strings.HasSuffix(joined, " config --quiet"):
return "compose config", compose.Result{}
case strings.HasSuffix(joined, " up --detach catalog-db"):
return "catalog db", compose.Result{}
case strings.HasSuffix(joined, " --profile catalog-maintenance run --rm catalog-migrate"):
return "catalog migrate", compose.Result{}
case strings.HasSuffix(joined, " build"):
return "compose build", compose.Result{}
case strings.HasSuffix(joined, " up --detach --remove-orphans"):
@@ -433,6 +465,8 @@ func setupStage(args []string) (string, compose.Result) {
return "authentication", compose.Result{Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}`}
case strings.Contains(joined, "exec -T core node dist/operator-command.js workflow-doctor"):
return "workflow doctor", compose.Result{Stdout: `{"ready":true,"workspaces":1}`}
case strings.Contains(joined, "operator-command.js workspace-pull"):
return "workspace pull", compose.Result{Stdout: `{"ready":true,"status":"succeeded"}`}
case strings.Contains(joined, "exec -T core curl -fsS --max-time 5 http://127.0.0.1:8787/health"):
return "core HTTP", compose.Result{}
case strings.Contains(joined, "exec -T frontend wget -q -T 5 -O /dev/null http://127.0.0.1:8080/"):