diff --git a/.env.example b/.env.example index 3dbeb9b9..9b5d3af0 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,17 @@ PI_MODEL= PI_THINKING= PI_AUTH_FILE=${HOME}/.pi/agent/auth.json +# Git-backed workspace registry. Set the remote only in the installation environment; +# credentials and SSH/CA files remain outside this repository and are bind-mounted read-only. +THT_WORKSPACE_REGISTRY_ROOT=/data/workspace-registry +THT_WORKSPACE_GIT_BRANCH=main +THT_WORKSPACE_INSTALLATION_ID=local +# THT_WORKSPACE_GIT_REMOTE=ssh://git@your-git-host/your-org/thoth-workspaces.git +# THT_WORKSPACE_GIT_CREDENTIALS_FILE=/absolute/path/to/git-credentials +# THT_WORKSPACE_GIT_CA_FILE=/absolute/path/to/git-ca.pem +# THT_WORKSPACE_GIT_SSH_KEY_FILE=/absolute/path/to/git-ssh-key +# THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=/absolute/path/to/git-known-hosts + # Set these for the selected DWH/vector/embedding adapters. THT_DB_NAME= THT_DWH_REST_URL= diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 65ffbf2b..d8cca1f2 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -91,10 +91,29 @@ export function sessionRoutes( const locateSession = async (principal: PrincipalContext, id: string): Promise => { const runner = runnerFor(principal); // Dependency-injected runners in legacy route tests may model only the mutation under test. - if (typeof runner.sessionShow !== "function") return { - manifest: {}, workspaceConfigPath: (await d.workspaceRegistry.list())[0]?.snapshotPath ?? "", + if (typeof runner.sessionShow !== "function") return { manifest: {}, workspaceConfigPath: "" }; + const legacySession = async (): Promise => { + try { + const manifest = await runner.sessionShow(id); + return manifest && !manifest.workspace_id && !manifest.workspace_revision + ? { manifest, workspaceConfigPath: "" } + : undefined; + } catch (error) { + if (isNotFound(error)) return undefined; + throw error; + } }; - const revisions = await d.workspaceRegistry.list(); + let revisions: Awaited>; + try { + revisions = await d.workspaceRegistry.list(); + } catch (registryError) { + // Sessions created before revision pinning still live under the installation's legacy + // default config. Keep that compatibility path available when a fresh installation has + // no registry snapshot yet; a pinned session remains fail-closed below. + const legacy = await legacySession(); + if (legacy) return legacy; + throw registryError; + } for (const revision of revisions) { if (revision.state !== "operational") continue; try { @@ -105,7 +124,7 @@ export function sessionRoutes( throw error; } } - return undefined; + return await legacySession(); }; /** Read the durable pinned descriptor only after the owner-visible manifest is located. */ @@ -240,15 +259,19 @@ export function sessionRoutes( app.post("/sessions", async (req, reply) => { const b = req.body as { - question: string; name?: string; workspaceId?: string; + question: string; name?: string; workspace?: string; workspaceId?: string; provider?: string; model?: string; thinking?: string; }; const principal = getPrincipal(req); let s: Settings; try { s = await d.getSettings(principal); } catch { return storageFailure(reply); } const runner = runnerFor(principal); - const requestedWorkspaceId = b.workspaceId ?? s.workspace; - if (!requestedWorkspaceId) { + // `workspace` was the legacy request field before browser-local registry preferences. + // It opts a pre-registry caller into the existing installation-default config only; modern + // `workspaceId` and saved preferences must continue to resolve an immutable snapshot. + const legacyWorkspaceRequest = typeof b.workspace === "string" && b.workspace.length > 0; + const requestedWorkspaceId = b.workspaceId ?? (legacyWorkspaceRequest ? undefined : s.workspace); + if (!requestedWorkspaceId && !legacyWorkspaceRequest) { return reply.code(409).send({ error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, code: "workspace_revision_unavailable", @@ -258,23 +281,25 @@ export function sessionRoutes( let workspaceId: string | undefined; let workspaceRevision: string | undefined; let allowedModels: readonly string[] | undefined; - try { - const resolved = await d.workspaceRegistry.read(requestedWorkspaceId); - if (resolved.revision.state !== "operational") { + if (requestedWorkspaceId) { + try { + const resolved = await d.workspaceRegistry.read(requestedWorkspaceId); + if (resolved.revision.state !== "operational") { + return reply.code(409).send({ + error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, + code: "workspace_revision_unavailable", + }); + } + workspaceConfigPath = resolved.revision.snapshotPath; + workspaceId = resolved.revision.id; + workspaceRevision = resolved.revision.commit; + allowedModels = resolved.workspace.llm_policy.allowed; + } catch { return reply.code(409).send({ error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, code: "workspace_revision_unavailable", }); } - workspaceConfigPath = resolved.revision.snapshotPath; - workspaceId = resolved.revision.id; - workspaceRevision = resolved.revision.commit; - allowedModels = resolved.workspace.llm_policy.allowed; - } catch { - return reply.code(409).send({ - error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE, - code: "workspace_revision_unavailable", - }); } const provider = b.provider ?? s.provider; const model = b.model ?? s.model; diff --git a/backend/src/workspaces/migrate-legacy.ts b/backend/src/workspaces/migrate-legacy.ts new file mode 100644 index 00000000..ef6f29ed --- /dev/null +++ b/backend/src/workspaces/migrate-legacy.ts @@ -0,0 +1,201 @@ +import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseAllDocuments, stringify } from "yaml"; +import { parseWorkspaceYaml, type LegacyWorkspace, type WorkspaceDescriptor } from "./schema.js"; + +export interface LegacyMigrationResult { + state: "migration_required"; + source: string; + workspace: LegacyWorkspace; +} + +export interface LegacyMigrationOptions { + /** Immutable repository identifier, normally derived from the input filename by the CLI. */ + id: string; +} + +type LegacyRecord = Record; + +const workspaceId = /^[a-z][a-z0-9-]{2,62}$/; +const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function record(value: unknown): LegacyRecord | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as LegacyRecord + : undefined; +} + +function literalIdentifier(value: unknown): string | undefined { + return typeof value === "string" && identifier.test(value) ? value : undefined; +} + +function literalText(value: unknown): string | undefined { + return typeof value === "string" && value.trim() === value && value.length > 0 && !value.includes("${") + ? value + : undefined; +} + +function literalPort(value: unknown): number | undefined { + if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= 65_535) return value; + return undefined; +} + +function titleFor(id: string): string { + return id.split("-").map((word) => word[0].toUpperCase() + word.slice(1)).join(" "); +} + +function sourceDocument(source: string): LegacyRecord { + const documents = parseAllDocuments(source, { uniqueKeys: true }); + if (documents.length !== 1 || documents[0].errors.length > 0) { + throw new Error("legacy workspace YAML must contain exactly one valid document"); + } + const parsed = record(documents[0].toJSON()); + if (!parsed) throw new Error("legacy workspace YAML must contain an object"); + return parsed; +} + +function dwhFrom(source: LegacyRecord): { section: LegacyRecord; transport: "postgres_direct" | "rest_api" } { + const dwh = record(source.dwh); + const database = record(source.database); + if (dwh) { + const type = literalText(dwh.type); + return { section: record(dwh.connection) ?? record(dwh.database) ?? dwh, transport: type === "postgres_direct" ? "postgres_direct" : "rest_api" }; + } + if (database) { + return { section: database, transport: literalText(database.transport) === "direct" ? "postgres_direct" : "rest_api" }; + } + return { section: {}, transport: "rest_api" }; +} + +function vectorFrom(source: LegacyRecord): { + section: LegacyRecord; transport: "pgvector_direct" | "rest_api"; writer: boolean; +} { + const vectors = record(source.vectors); + if (vectors) { + const type = literalText(vectors.type); + const direct = record(vectors.direct); + return { + section: type === "pgvector_direct" ? record(vectors.connection) ?? record(vectors.reader) ?? direct ?? {} : direct ?? {}, + transport: type === "pgvector_direct" ? "pgvector_direct" : "rest_api", + writer: record(vectors.writer) !== undefined, + }; + } + const vectorDb = record(source.vector_db); + return { section: vectorDb ?? {}, transport: "pgvector_direct", writer: record(source.vector_write_rest) !== undefined }; +} + +/** + * Converts a legacy runtime descriptor into a versioned, readable v1 registry descriptor. + * Runtime YAMLs mix shared metadata with `${ENV}` bindings and omit semantic-index identity; + * the result therefore always remains `migration_required` until an operator explicitly upgrades + * it with the correct collection/database/schema contract. + */ +export function migrateLegacyWorkspace(source: string, options: LegacyMigrationOptions): LegacyMigrationResult { + if (!workspaceId.test(options.id)) throw new Error("legacy workspace ID is invalid"); + const legacy = sourceDocument(source); + const language = legacy.language === "it" ? "it" : "en"; + const { section: dwh, transport: dwhTransport } = dwhFrom(legacy); + const { section: vector, transport: vectorTransport, writer } = vectorFrom(legacy); + const embedding = record(legacy.embeddings) ?? {}; + const dwhDatabase = literalIdentifier(dwh.database) ?? "legacy_dwh"; + const dwhSchema = literalIdentifier(dwh.schema) ?? "public"; + const vectorDatabase = literalIdentifier(vector.database); + const vectorSchema = literalIdentifier(vector.schema); + const dimensions = typeof embedding.dim === "number" && Number.isInteger(embedding.dim) && embedding.dim > 0 + ? embedding.dim + : 768; + const vectorStore: LegacyWorkspace["semantic_index"]["vector_store"] = { + engine: "pgvector", + collection: `${options.id.replaceAll("-", "_")}_documents`, + dimensions, + distance: "cosine", + supported_transports: [vectorTransport], + ...(literalPort(vector.port) === undefined ? {} : { port: literalPort(vector.port) }), + ...(vectorDatabase === undefined ? {} : { database: vectorDatabase }), + ...(vectorSchema === undefined ? {} : { schema: vectorSchema }), + }; + const workspace: LegacyWorkspace = { + workspace: { + schema_version: 1, + id: options.id, + name: titleFor(options.id), + language, + }, + dwh: { + engine: "postgres", + database: dwhDatabase, + schema: dwhSchema, + supported_transports: [dwhTransport], + ...(literalPort(dwh.port) === undefined ? {} : { port: literalPort(dwh.port) }), + }, + semantic_index: { + vector_store: vectorStore, + ...(writer ? { vector_writer: {} } : {}), + embedding: { + provider: "ollama_compatible", + model: literalText(embedding.model) ?? "legacy-embedding", + dimensions, + }, + }, + llm_policy: { allowed: ["zai/glm-5.2"] }, + }; + const descriptor = parseWorkspaceYaml(stringify(workspace, { lineWidth: 0, sortMapEntries: true })); + if (descriptor.workspace.schema_version !== 1) throw new Error("legacy workspace migration is invalid"); + const migrated = descriptor as LegacyWorkspace; + const rendered = stringify(migrated, { lineWidth: 0, sortMapEntries: true }); + return { state: "migration_required", source: rendered, workspace: migrated }; +} + +function destinationFor(repositoryRoot: string, id: string): string { + if (!isAbsolute(repositoryRoot)) throw new Error("migration output root must be absolute"); + if (!workspaceId.test(id)) throw new Error("legacy workspace ID is invalid"); + return join(repositoryRoot, "workspaces", `${id}.yaml`); +} + +/** Safely adds a migrated descriptor without replacing a previous operator-reviewed migration. */ +export async function writeMigratedWorkspace(result: LegacyMigrationResult, repositoryRoot: string): Promise { + const destination = destinationFor(repositoryRoot, result.workspace.workspace.id); + const directory = dirname(destination); + await mkdir(directory, { recursive: true, mode: 0o700 }); + try { + await lstat(destination); + throw new Error("migrated workspace already exists"); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") throw error; + } + const temporary = join(directory, `.${result.workspace.workspace.id}.${process.pid}.${Date.now()}.tmp`); + try { + await writeFile(temporary, result.source, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await rename(temporary, destination); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } + return destination; +} + +function parseCliArguments(argv: readonly string[]): { input: string; output: string } { + if (argv.length !== 4 || argv[0] !== "--input" || argv[2] !== "--output") { + throw new Error("usage: migrate-legacy --input --output "); + } + if (!isAbsolute(argv[1]) || !isAbsolute(argv[3])) { + throw new Error("migration input and output paths must be absolute"); + } + return { input: argv[1], output: argv[3] }; +} + +export async function main(argv = process.argv.slice(2)): Promise { + const { input, output } = parseCliArguments(argv); + const id = basename(input, ".yaml"); + const result = migrateLegacyWorkspace(await readFile(input, "utf8"), { id }); + const destination = await writeMigratedWorkspace(result, output); + process.stdout.write(`${destination}\n`); +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error: unknown) => { + process.stderr.write(`${error instanceof Error ? error.message : "migration failed"}\n`); + process.exitCode = 1; + }); +} diff --git a/backend/test/e2e-f1.test.ts b/backend/test/e2e-f1.test.ts index daa5f355..80961419 100644 --- a/backend/test/e2e-f1.test.ts +++ b/backend/test/e2e-f1.test.ts @@ -43,11 +43,12 @@ test("loop F1: crea sessione → SSE riceve il widget → risponde → il modell const base = `http://127.0.0.1:${(app.server.address() as any).port}`; // 1. Create session — spawns fake-pi, sends prompt, fake-pi emits widget - await fetch(`${base}/sessions`, { + const created = await fetch(`${base}/sessions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ workspace: "w", question: "q" }), }); + expect(created.status).toBe(200); // 2. Small delay to let fake-pi process the prompt and fill pendingWidget await new Promise((r) => setTimeout(r, 50)); diff --git a/backend/test/workspaces-migrate-legacy.test.ts b/backend/test/workspaces-migrate-legacy.test.ts new file mode 100644 index 00000000..b79e8e6a --- /dev/null +++ b/backend/test/workspaces-migrate-legacy.test.ts @@ -0,0 +1,65 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { + migrateLegacyWorkspace, + writeMigratedWorkspace, +} from "../src/workspaces/migrate-legacy.js"; +import { parseWorkspaceYaml } from "../src/workspaces/schema.js"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })); +}); + +function readFixture(name: string): string { + return readFileSync(new URL(`../../harness/workspaces/${name}`, import.meta.url), "utf8"); +} + +test("migrates the current local PSD descriptor without copying secret values", () => { + const result = migrateLegacyWorkspace(readFixture("local.yaml"), { id: "local" }); + + expect(result.workspace.workspace).toMatchObject({ id: "local", schema_version: 1, language: "it" }); + expect(result.state).toBe("migration_required"); + expect(JSON.stringify(result)).not.toMatch(/password:|api_key:|\$\{THT_/i); +}); + +test("keeps an incomplete legacy vector identity readable and explicitly migration-required", () => { + const result = migrateLegacyWorkspace(readFixture("tht.example.yaml"), { id: "example" }); + + expect(result.state).toBe("migration_required"); + expect(result.workspace.workspace.schema_version).toBe(1); + expect(parseWorkspaceYaml(result.source).workspace.schema_version).toBe(1); +}); + +test("writes versioned repository artifacts atomically without replacing a prior migration", async () => { + const root = await mkdtemp(join(tmpdir(), "thoth-workspace-migrate-")); + temporaryRoots.push(root); + const migration = migrateLegacyWorkspace(readFixture("local.yaml"), { id: "local" }); + + const destination = await writeMigratedWorkspace(migration, root); + + expect(destination).toBe(join(root, "workspaces", "local.yaml")); + expect(parseWorkspaceYaml(readFileSync(destination, "utf8"))).toMatchObject({ workspace: { id: "local" } }); + await expect(writeMigratedWorkspace(migration, root)).rejects.toThrow(/already exists/i); + expect(existsSync(destination)).toBe(true); +}); + +test("declares a durable isolated registry volume and only read-only Git credential mounts", () => { + const compose = readFileSync(new URL("../../compose.yaml", import.meta.url), "utf8"); + const development = readFileSync(new URL("../../docker-compose.dev.yml", import.meta.url), "utf8"); + const dockerfile = readFileSync(new URL("../../docker/core.Dockerfile", import.meta.url), "utf8"); + + for (const source of [compose, development]) { + expect(source).toContain("THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry"); + expect(source).toContain("workspace-registry:/data/workspace-registry"); + expect(source).toMatch(/workspace-registry-git-credentials:ro/); + expect(source).toMatch(/workspace-registry-git-ca:ro/); + expect(source).toMatch(/workspace-registry-git-ssh-key:ro/); + expect(source).toMatch(/workspace-registry-git-known-hosts:ro/); + } + expect(dockerfile).toMatch(/mkdir -p \/data\/workspace-registry && chown -R thoth:thoth \/data\/workspace-registry/); +}); diff --git a/compose.yaml b/compose.yaml index fdbc1e42..0f30225e 100644 --- a/compose.yaml +++ b/compose.yaml @@ -17,7 +17,9 @@ services: context: . dockerfile: docker/core.Dockerfile image: thothii-core:local - env_file: [deploy/thothii.env] + env_file: + - path: deploy/thothii.env + required: false environment: HOST: 0.0.0.0 PORT: "8787" @@ -26,15 +28,32 @@ services: SETTINGS_FILE: /data/settings/settings.json THT_CONFIG: /app/harness/workspaces/local.yaml # configPath di default per i route tht senza workspace esplicito THT_MODEL_API_KEY_FILE: /data/secrets/model_api_key # provider key per buildPiChildEnv (codex) + THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry + THT_WORKSPACE_GIT_BRANCH: ${THT_WORKSPACE_GIT_BRANCH:-main} + THT_WORKSPACE_INSTALLATION_ID: ${THT_WORKSPACE_INSTALLATION_ID:-server} + THT_WORKSPACE_GIT_AUTHOR_NAME: ${THT_WORKSPACE_GIT_AUTHOR_NAME:-Thoth Workspace Registry} + THT_WORKSPACE_GIT_AUTHOR_EMAIL: ${THT_WORKSPACE_GIT_AUTHOR_EMAIL:-thoth-workspace-registry@localhost} + THT_WORKSPACE_SECRET_ROOTS: /run/secrets + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_KEY_0: credential.helper + GIT_CONFIG_VALUE_0: store --file=/run/secrets/workspace-registry-git-credentials + GIT_CONFIG_KEY_1: http.sslCAInfo + GIT_CONFIG_VALUE_1: /run/secrets/workspace-registry-git-ca + GIT_SSH_COMMAND: ssh -i /run/secrets/workspace-registry-git-ssh-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/run/secrets/workspace-registry-git-known-hosts AUTH_MODE: ${AUTH_MODE:-none} MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4} extra_hosts: - "host.docker.internal:host-gateway" # Supabase :5438 + Ollama :11434 sull'host volumes: - /home/chirone/thothii-data:/data + - workspace-registry:/data/workspace-registry - /home/chirone/thothii-data/pi-config:/home/thoth/.pi - ${PI_AUTH_FILE:?set PI_AUTH_FILE}:/home/thoth/.pi/agent/auth.json:ro - /home/chirone/chirone/etl/docs/evidence:/data/evidence:ro + - ${THT_WORKSPACE_GIT_CREDENTIALS_FILE:-/dev/null}:/run/secrets/workspace-registry-git-credentials:ro + - ${THT_WORKSPACE_GIT_CA_FILE:-/etc/ssl/certs/ca-certificates.crt}:/run/secrets/workspace-registry-git-ca:ro + - ${THT_WORKSPACE_GIT_SSH_KEY_FILE:-/dev/null}:/run/secrets/workspace-registry-git-ssh-key:ro + - ${THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE:-/dev/null}:/run/secrets/workspace-registry-git-known-hosts:ro restart: unless-stopped networks: omics_portal_omics_network: @@ -59,3 +78,6 @@ networks: external: true localllm_default: external: true + +volumes: + workspace-registry: diff --git a/deploy/thothii.env.example b/deploy/thothii.env.example index 2abcfeba..255b34d7 100644 --- a/deploy/thothii.env.example +++ b/deploy/thothii.env.example @@ -20,3 +20,13 @@ THT_OLLAMA_URL=http://host.docker.internal:11434 # --- Backend --- AUTH_MODE=none # none | mock | oidc (in embedded l'auth è al bordo del portale) MAX_PI_PROCESSES=4 + +# --- Git-backed workspace registry (no secret values belong in this file) --- +THT_WORKSPACE_REGISTRY_ROOT=/data/workspace-registry +THT_WORKSPACE_GIT_BRANCH=main +THT_WORKSPACE_INSTALLATION_ID=server +# THT_WORKSPACE_GIT_REMOTE=ssh://git@your-git-host/your-org/thoth-workspaces.git +# THT_WORKSPACE_GIT_CREDENTIALS_FILE=/absolute/path/to/git-credentials +# THT_WORKSPACE_GIT_CA_FILE=/absolute/path/to/git-ca.pem +# THT_WORKSPACE_GIT_SSH_KEY_FILE=/absolute/path/to/git-ssh-key +# THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=/absolute/path/to/git-known-hosts diff --git a/deploy/workspace-registry.env.example b/deploy/workspace-registry.env.example new file mode 100644 index 00000000..0625176d --- /dev/null +++ b/deploy/workspace-registry.env.example @@ -0,0 +1,15 @@ +# Copy these non-secret registry settings into the installation environment. +# Create the referenced credential, CA, SSH-key, and known-hosts files locally with restrictive +# permissions. Their contents are never committed, emitted by the API, or stored in the registry. +THT_WORKSPACE_REGISTRY_ROOT=/data/workspace-registry +THT_WORKSPACE_GIT_BRANCH=main +THT_WORKSPACE_INSTALLATION_ID=local +THT_WORKSPACE_GIT_AUTHOR_NAME=Thoth Workspace Registry +THT_WORKSPACE_GIT_AUTHOR_EMAIL=thoth-workspace-registry@localhost + +# Set the remote for this installation; use its own SSH/HTTPS address, never an application endpoint. +# THT_WORKSPACE_GIT_REMOTE=ssh://git@your-git-host/your-org/thoth-workspaces.git +# THT_WORKSPACE_GIT_CREDENTIALS_FILE=/absolute/path/to/git-credentials +# THT_WORKSPACE_GIT_CA_FILE=/absolute/path/to/git-ca.pem +# THT_WORKSPACE_GIT_SSH_KEY_FILE=/absolute/path/to/git-ssh-key +# THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=/absolute/path/to/git-known-hosts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5a51d0f5..65dce639 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,7 +10,9 @@ services: context: . dockerfile: docker/core.Dockerfile image: thothii-core:local - env_file: [deploy/thothii.env] + env_file: + - path: deploy/thothii.env + required: false environment: HOST: 0.0.0.0 PORT: "8787" @@ -21,13 +23,30 @@ services: THT_SESSION_STORAGE: local THT_HOME: /data/local-home SETTINGS_FILE: /data/settings/settings.json + THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry + THT_WORKSPACE_GIT_BRANCH: ${THT_WORKSPACE_GIT_BRANCH:-main} + THT_WORKSPACE_INSTALLATION_ID: ${THT_WORKSPACE_INSTALLATION_ID:-local} + THT_WORKSPACE_GIT_AUTHOR_NAME: ${THT_WORKSPACE_GIT_AUTHOR_NAME:-Thoth Workspace Registry} + THT_WORKSPACE_GIT_AUTHOR_EMAIL: ${THT_WORKSPACE_GIT_AUTHOR_EMAIL:-thoth-workspace-registry@localhost} + THT_WORKSPACE_SECRET_ROOTS: /run/secrets + GIT_CONFIG_COUNT: "2" + GIT_CONFIG_KEY_0: credential.helper + GIT_CONFIG_VALUE_0: store --file=/run/secrets/workspace-registry-git-credentials + GIT_CONFIG_KEY_1: http.sslCAInfo + GIT_CONFIG_VALUE_1: /run/secrets/workspace-registry-git-ca + GIT_SSH_COMMAND: ssh -i /run/secrets/workspace-registry-git-ssh-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/run/secrets/workspace-registry-git-known-hosts MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4} extra_hosts: - "host.docker.internal:host-gateway" volumes: - /home/chirone/thothii-data:/data + - workspace-registry:/data/workspace-registry - /home/chirone/thothii-data/pi-config:/home/thoth/.pi - /home/chirone/chirone/etl/docs/evidence:/data/evidence:ro + - ${THT_WORKSPACE_GIT_CREDENTIALS_FILE:-/dev/null}:/run/secrets/workspace-registry-git-credentials:ro + - ${THT_WORKSPACE_GIT_CA_FILE:-/etc/ssl/certs/ca-certificates.crt}:/run/secrets/workspace-registry-git-ca:ro + - ${THT_WORKSPACE_GIT_SSH_KEY_FILE:-/dev/null}:/run/secrets/workspace-registry-git-ssh-key:ro + - ${THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE:-/dev/null}:/run/secrets/workspace-registry-git-known-hosts:ro ports: - "127.0.0.1:8787:8787" restart: "no" @@ -51,3 +70,6 @@ services: networks: thothii-net: driver: bridge + +volumes: + workspace-registry: diff --git a/docker/core.Dockerfile b/docker/core.Dockerfile index 697d1a63..ba6d6afc 100644 --- a/docker/core.Dockerfile +++ b/docker/core.Dockerfile @@ -30,6 +30,9 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \ # Utente non-root RUN useradd --create-home --uid 10001 --shell /bin/bash thoth RUN mkdir -p /home/thoth/.pi/agent && chown -R thoth:thoth /home/thoth/.pi +# Docker copies this owned directory into a newly-created named volume, allowing the non-root +# runtime user to create the registry checkout, immutable snapshots, state, and locks. +RUN mkdir -p /data/workspace-registry && chown -R thoth:thoth /data/workspace-registry COPY harness/ /app/harness/ # Pi scrive lock/settings in .pi: ownership thoth per sopravvivere al rebuild diff --git a/scripts/workspace-registry-smoke.sh b/scripts/workspace-registry-smoke.sh new file mode 100755 index 00000000..606118b9 --- /dev/null +++ b/scripts/workspace-registry-smoke.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Exercises the registry through an isolated Compose project. An optional WORKSPACE_GIT_REMOTE +# is contacted read-only as a connectivity preflight; all pull/fallback mutations target a fresh +# temporary bare repository so this smoke test can never alter an operator's shared registry. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd -P)" +tmp="$(mktemp -d "${TMPDIR:-/tmp}/thoth-workspace-registry-smoke.XXXXXX")" +project="thoth-workspace-registry-smoke-$$" +remote="$tmp/remote.git" +seed="$tmp/seed" +branch="workspace-registry-smoke" + +cleanup() { + compose down --volumes --remove-orphans >/dev/null 2>&1 || true + rm -rf "$tmp" +} +trap cleanup EXIT HUP INT TERM + +compose() { + docker compose --project-name "$project" -f - "$@" </dev/null 2>&1; then + return 0 + fi + sleep 1 + done + compose logs core >&2 || true + return 1 +} + +if [[ -n "${WORKSPACE_GIT_REMOTE:-}" ]]; then + echo "== Read-only Git remote preflight ==" + git ls-remote --heads "$WORKSPACE_GIT_REMOTE" >/dev/null +fi + +echo "== Seed isolated workspace registry ==" +git init --bare --initial-branch=main "$remote" >/dev/null +git clone "$remote" "$seed" >/dev/null +git -C "$seed" checkout -b "$branch" >/dev/null +npm --prefix "$root/backend" run build >/dev/null +node "$root/backend/dist/workspaces/migrate-legacy.js" \ + --input "$root/harness/workspaces/local.yaml" --output "$seed" >/dev/null +git -C "$seed" add workspaces/local.yaml +git -C "$seed" -c user.name='Workspace Registry Smoke' -c user.email='workspace-registry-smoke@example.invalid' \ + commit -m 'Seed workspace registry smoke' >/dev/null +git -C "$seed" push origin "HEAD:$branch" >/dev/null + +echo "== Build and start isolated Compose core ==" +compose up -d --build +wait_for_core +initial_status="$(compose exec -T core curl -fsS http://127.0.0.1:8787/workspace-registry/status)" +printf '%s' "$initial_status" | grep -Eq '"head":"[0-9a-f]{40}"' +compose exec -T core test -f /data/workspace-registry/state/active.json + +echo "== Recreate core and prove registry volume persistence ==" +compose up -d --force-recreate +wait_for_core +recreated_status="$(compose exec -T core curl -fsS http://127.0.0.1:8787/workspace-registry/status)" +initial_head="$(printf '%s' "$initial_status" | sed -n 's/.*"head":"\([0-9a-f]*\)".*/\1/p')" +recreated_head="$(printf '%s' "$recreated_status" | sed -n 's/.*"head":"\([0-9a-f]*\)".*/\1/p')" +test -n "$initial_head" && test "$initial_head" = "$recreated_head" + +echo "== Pull a valid remote update ==" +sed -i.bak 's/name: Local/name: Local Updated/' "$seed/workspaces/local.yaml" +rm "$seed/workspaces/local.yaml.bak" +git -C "$seed" add workspaces/local.yaml +git -C "$seed" -c user.name='Workspace Registry Smoke' -c user.email='workspace-registry-smoke@example.invalid' \ + commit -m 'Update workspace registry smoke' >/dev/null +git -C "$seed" push origin "HEAD:$branch" >/dev/null +compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull | grep -Eq '"head":"[0-9a-f]{40}"' +compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' + +echo "== Reject invalid remote content and retain the last valid snapshot ==" +printf '%s\n' 'workspace: invalid' >"$seed/workspaces/local.yaml" +git -C "$seed" add workspaces/local.yaml +git -C "$seed" -c user.name='Workspace Registry Smoke' -c user.email='workspace-registry-smoke@example.invalid' \ + commit -m 'Invalid workspace registry smoke fixture' >/dev/null +git -C "$seed" push origin "HEAD:$branch" >/dev/null +if compose exec -T core curl -fsS -X POST http://127.0.0.1:8787/workspace-registry/pull >/dev/null; then + echo "registry accepted invalid remote workspace content" >&2 + exit 1 +fi +compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local Updated' + +echo "workspace registry smoke passed"