feat(deploy): add portable external-service stack
This commit is contained in:
@@ -0,0 +1,42 @@
|
|||||||
|
# ThothII
|
||||||
|
|
||||||
|
ThothII is a human-reviewed NL-to-SQL workflow with a React frontend and a Fastify/Pi/`tht`
|
||||||
|
core. The portable deployment runs exactly two application services; data services remain
|
||||||
|
external in this profile.
|
||||||
|
|
||||||
|
## Docker Compose: external services
|
||||||
|
|
||||||
|
Requirements: Docker Engine with Compose v2 and reachable DWH, vector, and embeddings
|
||||||
|
services.
|
||||||
|
|
||||||
|
1. Copy `deploy/env.example` to `deploy/.env` and fill in runtime credentials. The `.env`
|
||||||
|
file is gitignored and is read only when the container starts; secrets are never copied
|
||||||
|
into either image.
|
||||||
|
2. Add or edit YAML workspace descriptors under `deploy/workspaces/`. These files are mounted
|
||||||
|
read-only. Use relative `roots`; they resolve beneath `/data/workspaces/<workspace-name>`.
|
||||||
|
3. Start the external-service profile:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --profile external up --build --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open <http://localhost:8080>. Set `THOTH_HTTP_PORT` before starting to use another host
|
||||||
|
port.
|
||||||
|
|
||||||
|
Application state, including settings, sessions, artifacts, and indexes, lives in the named
|
||||||
|
`thoth_data` volume mounted at `/data`. `docker compose down` keeps that volume. Only an
|
||||||
|
explicit destructive command such as `docker compose down --volumes` removes it.
|
||||||
|
|
||||||
|
The frontend depends on the core health check and proxies `/health` and `/api/*` to it. The
|
||||||
|
application health endpoint intentionally checks process readiness only; external dependency
|
||||||
|
diagnostics are exposed by `tht doctor` and do not prevent the UI from starting.
|
||||||
|
|
||||||
|
Run the end-to-end packaging check with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/docker-smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The smoke script validates Compose, builds and waits for both services, checks health through
|
||||||
|
the frontend, verifies SSE response headers, restarts the core, and confirms `/data` survives.
|
||||||
|
Its cleanup preserves the named volume.
|
||||||
@@ -77,6 +77,9 @@ export function sessionRoutes(
|
|||||||
"Access-Control-Allow-Origin": origin,
|
"Access-Control-Allow-Origin": origin,
|
||||||
"Access-Control-Allow-Credentials": "true",
|
"Access-Control-Allow-Credentials": "true",
|
||||||
});
|
});
|
||||||
|
// Send the handshake immediately. Without this, Node waits for the first event body and
|
||||||
|
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
|
||||||
|
reply.raw.flushHeaders();
|
||||||
const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
const send = (event: string, data: object) => reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
|
const off = d.hub.subscribe(id, send, rt?.bridge.pendingWidget() ?? null);
|
||||||
req.raw.on("close", off);
|
req.raw.on("close", off);
|
||||||
|
|||||||
@@ -2,9 +2,33 @@ import { test, expect } from "vitest";
|
|||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
test("GET /health ritorna ok", async () => {
|
test("GET /health reports process readiness without external services", async () => {
|
||||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
|
||||||
const res = await app.inject({ method: "GET", url: "/health" });
|
const res = await app.inject({ method: "GET", url: "/health" });
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers["content-type"]).toContain("application/json");
|
||||||
expect(res.json()).toEqual({ status: "ok" });
|
expect(res.json()).toEqual({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("SSE response headers are flushed before the first event", async () => {
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/tmp/h" }));
|
||||||
|
await app.listen({ port: 0, host: "127.0.0.1" });
|
||||||
|
const port = (app.server.address() as { port: number }).port;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await Promise.race([
|
||||||
|
fetch(`http://127.0.0.1:${port}/sessions/header-probe/events`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("SSE headers were not flushed")), 250),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
expect(response.headers.get("content-type")).toContain("text/event-stream");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||||
|
} finally {
|
||||||
|
controller.abort();
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: thothii
|
||||||
|
|
||||||
|
services:
|
||||||
|
core:
|
||||||
|
profiles: [external]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/core.Dockerfile
|
||||||
|
env_file:
|
||||||
|
- path: deploy/.env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
THT_DATA_ROOT: /data
|
||||||
|
SETTINGS_FILE: /data/settings/settings.json
|
||||||
|
volumes:
|
||||||
|
- thoth_data:/data
|
||||||
|
- ./deploy/workspaces:/app/harness/workspaces:ro
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, curl, --fail, --silent, http://127.0.0.1:8787/health]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
profiles: [external]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/frontend.Dockerfile
|
||||||
|
environment:
|
||||||
|
BACKEND_BASE_URL: /api
|
||||||
|
ports:
|
||||||
|
- "${THOTH_HTTP_PORT:-8080}:8080"
|
||||||
|
depends_on:
|
||||||
|
core:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, wget, --quiet, --spider, http://127.0.0.1:8080/health]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
thoth_data:
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Copy this file to deploy/.env. Never commit deploy/.env or real credentials.
|
||||||
|
# Compose passes these values to the core container at runtime; images contain no secrets.
|
||||||
|
|
||||||
|
# Optional application defaults
|
||||||
|
PI_PROVIDER=
|
||||||
|
PI_MODEL=
|
||||||
|
PI_THINKING=
|
||||||
|
MAX_PI_PROCESSES=4
|
||||||
|
AUTH_MODE=none
|
||||||
|
|
||||||
|
# External DWH (example workspace uses the HTTP adapters)
|
||||||
|
THT_DB_NAME=
|
||||||
|
THT_DWH_REST_URL=
|
||||||
|
THT_DWH_API_KEY=
|
||||||
|
|
||||||
|
# External vector service. Use a distinct write key where the service supports one.
|
||||||
|
THT_VEC_REST_URL=
|
||||||
|
THT_VEC_API_KEY=
|
||||||
|
THT_VEC_WRITE_API_KEY=
|
||||||
|
|
||||||
|
# External embeddings service
|
||||||
|
THT_OLLAMA_URL=
|
||||||
|
|
||||||
|
# Evidence source visible inside the persistent data volume
|
||||||
|
THT_DOCS_ROOT=/data/workspaces/example/evidence-source
|
||||||
|
|
||||||
|
# Optional CA file mounted separately by an operator, for example via a Compose override.
|
||||||
|
THT_SSL_CA=
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
language: en
|
||||||
|
|
||||||
|
dwh:
|
||||||
|
type: thoth_rest
|
||||||
|
database:
|
||||||
|
database: ${THT_DB_NAME}
|
||||||
|
schema: datawarehouse
|
||||||
|
endpoint:
|
||||||
|
base_url: ${THT_DWH_REST_URL}
|
||||||
|
api_key: ${THT_DWH_API_KEY}
|
||||||
|
ssl_ca: ${THT_SSL_CA}
|
||||||
|
|
||||||
|
# Relative logical roots are resolved beneath /data/workspaces/example.
|
||||||
|
roots:
|
||||||
|
artifacts: artifacts
|
||||||
|
indexes: indexes
|
||||||
|
sessions: sessions
|
||||||
|
|
||||||
|
examples:
|
||||||
|
max_per_column: 10
|
||||||
|
|
||||||
|
lsh:
|
||||||
|
signature_size: 64
|
||||||
|
n_gram: 3
|
||||||
|
threshold: 0.5
|
||||||
|
max_values_per_column: 1000
|
||||||
|
|
||||||
|
eligibility:
|
||||||
|
max_declared_len: 128
|
||||||
|
max_avg_length: 40
|
||||||
|
max_sampled_len: 200
|
||||||
|
ignore_columns: [etl_last_update]
|
||||||
|
|
||||||
|
evidence:
|
||||||
|
source_root: ${THT_DOCS_ROOT}
|
||||||
|
evidence_dir: evidence
|
||||||
|
|
||||||
|
embeddings:
|
||||||
|
base_url: ${THT_OLLAMA_URL}
|
||||||
|
model: nomic-embed-text-v2-moe
|
||||||
|
dim: 768
|
||||||
|
batch_size: 32
|
||||||
|
|
||||||
|
vectors:
|
||||||
|
type: thoth_vector_http
|
||||||
|
reader:
|
||||||
|
base_url: ${THT_VEC_REST_URL}
|
||||||
|
api_key: ${THT_VEC_API_KEY}
|
||||||
|
ssl_ca: ${THT_SSL_CA}
|
||||||
|
writer:
|
||||||
|
base_url: ${THT_VEC_REST_URL}
|
||||||
|
api_key: ${THT_VEC_WRITE_API_KEY}
|
||||||
|
ssl_ca: ${THT_SSL_CA}
|
||||||
|
|
||||||
|
vector:
|
||||||
|
max_chunk_chars: 4000
|
||||||
|
|
||||||
|
search:
|
||||||
|
rrf_k: 60
|
||||||
|
top_schema_tables: 12
|
||||||
|
schema_chunk_pool: 150
|
||||||
|
|
||||||
|
execution:
|
||||||
|
allow: [cte_test, explain, preview, aggregate, export]
|
||||||
|
max_preview_rows: 10
|
||||||
|
max_export_rows: 100000
|
||||||
|
statement_timeout_ms: 30000
|
||||||
|
warn_execution_ms: 5000
|
||||||
|
max_aggregate_cells: 20
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
compose="docker compose --profile external"
|
||||||
|
marker="smoke-$(date +%s)-$$"
|
||||||
|
headers=""
|
||||||
|
# Avoid colliding with a developer's existing service. Production/developer Compose still
|
||||||
|
# defaults to 8080; a published port of 0 asks Docker for a free ephemeral smoke port.
|
||||||
|
export THOTH_HTTP_PORT=${THOTH_HTTP_PORT:-0}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [ -n "$headers" ]; then rm -f "$headers"; fi
|
||||||
|
# Deliberately omit --volumes: an ordinary smoke run must preserve user data.
|
||||||
|
$compose down --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
|
||||||
|
$compose config --quiet
|
||||||
|
$compose up --build --wait core frontend
|
||||||
|
published=$($compose port frontend 8080)
|
||||||
|
http_port=${published##*:}
|
||||||
|
|
||||||
|
curl --fail --silent --show-error "http://127.0.0.1:$http_port/health" >/dev/null
|
||||||
|
|
||||||
|
# The nginx proxy must preserve streaming semantics for the backend SSE endpoint.
|
||||||
|
headers=$(mktemp)
|
||||||
|
curl --fail --silent --show-error --max-time 2 --dump-header "$headers" \
|
||||||
|
"http://127.0.0.1:$http_port/api/sessions/docker-smoke/events" \
|
||||||
|
>/dev/null 2>&1 || status=$?
|
||||||
|
status=${status:-0}
|
||||||
|
if [ "$status" -ne 0 ] && [ "$status" -ne 28 ]; then
|
||||||
|
echo "SSE probe failed with curl status $status" >&2
|
||||||
|
exit "$status"
|
||||||
|
fi
|
||||||
|
grep -qi '^content-type: text/event-stream' "$headers"
|
||||||
|
grep -qi '^cache-control: no-cache' "$headers"
|
||||||
|
rm -f "$headers"
|
||||||
|
headers=""
|
||||||
|
|
||||||
|
# Write through the named volume, restart the application, and prove persistence.
|
||||||
|
$compose exec -T core sh -c 'printf "%s\n" "$1" > /data/.compose-smoke-marker' sh "$marker"
|
||||||
|
$compose restart core
|
||||||
|
$compose up --wait core frontend
|
||||||
|
persisted=$($compose exec -T core sh -c 'cat /data/.compose-smoke-marker')
|
||||||
|
[ "$persisted" = "$marker" ]
|
||||||
|
$compose exec -T core rm -f /data/.compose-smoke-marker
|
||||||
|
|
||||||
|
curl --fail --silent --show-error "http://127.0.0.1:$http_port/health" >/dev/null
|
||||||
|
echo "Compose health, SSE proxy, and restart persistence checks passed."
|
||||||
Reference in New Issue
Block a user