feat: deploy portable workspace registry
This commit is contained in:
@@ -14,6 +14,17 @@ PI_MODEL=
|
|||||||
PI_THINKING=
|
PI_THINKING=
|
||||||
PI_AUTH_FILE=${HOME}/.pi/agent/auth.json
|
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.
|
# Set these for the selected DWH/vector/embedding adapters.
|
||||||
THT_DB_NAME=
|
THT_DB_NAME=
|
||||||
THT_DWH_REST_URL=
|
THT_DWH_REST_URL=
|
||||||
|
|||||||
@@ -91,10 +91,29 @@ export function sessionRoutes(
|
|||||||
const locateSession = async (principal: PrincipalContext, id: string): Promise<LocatedSession | undefined> => {
|
const locateSession = async (principal: PrincipalContext, id: string): Promise<LocatedSession | undefined> => {
|
||||||
const runner = runnerFor(principal);
|
const runner = runnerFor(principal);
|
||||||
// Dependency-injected runners in legacy route tests may model only the mutation under test.
|
// Dependency-injected runners in legacy route tests may model only the mutation under test.
|
||||||
if (typeof runner.sessionShow !== "function") return {
|
if (typeof runner.sessionShow !== "function") return { manifest: {}, workspaceConfigPath: "" };
|
||||||
manifest: {}, workspaceConfigPath: (await d.workspaceRegistry.list())[0]?.snapshotPath ?? "",
|
const legacySession = async (): Promise<LocatedSession | undefined> => {
|
||||||
|
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<ReturnType<typeof d.workspaceRegistry.list>>;
|
||||||
|
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) {
|
for (const revision of revisions) {
|
||||||
if (revision.state !== "operational") continue;
|
if (revision.state !== "operational") continue;
|
||||||
try {
|
try {
|
||||||
@@ -105,7 +124,7 @@ export function sessionRoutes(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return await legacySession();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Read the durable pinned descriptor only after the owner-visible manifest is located. */
|
/** 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) => {
|
app.post("/sessions", async (req, reply) => {
|
||||||
const b = req.body as {
|
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;
|
provider?: string; model?: string; thinking?: string;
|
||||||
};
|
};
|
||||||
const principal = getPrincipal(req);
|
const principal = getPrincipal(req);
|
||||||
let s: Settings;
|
let s: Settings;
|
||||||
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||||
const runner = runnerFor(principal);
|
const runner = runnerFor(principal);
|
||||||
const requestedWorkspaceId = b.workspaceId ?? s.workspace;
|
// `workspace` was the legacy request field before browser-local registry preferences.
|
||||||
if (!requestedWorkspaceId) {
|
// 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({
|
return reply.code(409).send({
|
||||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||||
code: "workspace_revision_unavailable",
|
code: "workspace_revision_unavailable",
|
||||||
@@ -258,23 +281,25 @@ export function sessionRoutes(
|
|||||||
let workspaceId: string | undefined;
|
let workspaceId: string | undefined;
|
||||||
let workspaceRevision: string | undefined;
|
let workspaceRevision: string | undefined;
|
||||||
let allowedModels: readonly string[] | undefined;
|
let allowedModels: readonly string[] | undefined;
|
||||||
try {
|
if (requestedWorkspaceId) {
|
||||||
const resolved = await d.workspaceRegistry.read(requestedWorkspaceId);
|
try {
|
||||||
if (resolved.revision.state !== "operational") {
|
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({
|
return reply.code(409).send({
|
||||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||||
code: "workspace_revision_unavailable",
|
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 provider = b.provider ?? s.provider;
|
||||||
const model = b.model ?? s.model;
|
const model = b.model ?? s.model;
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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 <legacy-workspace.yaml> --output <repository-root>");
|
||||||
|
}
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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}`;
|
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
|
// 1. Create session — spawns fake-pi, sends prompt, fake-pi emits widget
|
||||||
await fetch(`${base}/sessions`, {
|
const created = await fetch(`${base}/sessions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ workspace: "w", question: "q" }),
|
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
|
// 2. Small delay to let fake-pi process the prompt and fill pendingWidget
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
+23
-1
@@ -17,7 +17,9 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: docker/core.Dockerfile
|
dockerfile: docker/core.Dockerfile
|
||||||
image: thothii-core:local
|
image: thothii-core:local
|
||||||
env_file: [deploy/thothii.env]
|
env_file:
|
||||||
|
- path: deploy/thothii.env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: "8787"
|
PORT: "8787"
|
||||||
@@ -26,15 +28,32 @@ services:
|
|||||||
SETTINGS_FILE: /data/settings/settings.json
|
SETTINGS_FILE: /data/settings/settings.json
|
||||||
THT_CONFIG: /app/harness/workspaces/local.yaml # configPath di default per i route tht senza workspace esplicito
|
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_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}
|
AUTH_MODE: ${AUTH_MODE:-none}
|
||||||
MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4}
|
MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4}
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway" # Supabase :5438 + Ollama :11434 sull'host
|
- "host.docker.internal:host-gateway" # Supabase :5438 + Ollama :11434 sull'host
|
||||||
volumes:
|
volumes:
|
||||||
- /home/chirone/thothii-data:/data
|
- /home/chirone/thothii-data:/data
|
||||||
|
- workspace-registry:/data/workspace-registry
|
||||||
- /home/chirone/thothii-data/pi-config:/home/thoth/.pi
|
- /home/chirone/thothii-data/pi-config:/home/thoth/.pi
|
||||||
- ${PI_AUTH_FILE:?set PI_AUTH_FILE}:/home/thoth/.pi/agent/auth.json:ro
|
- ${PI_AUTH_FILE:?set PI_AUTH_FILE}:/home/thoth/.pi/agent/auth.json:ro
|
||||||
- /home/chirone/chirone/etl/docs/evidence:/data/evidence: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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
omics_portal_omics_network:
|
omics_portal_omics_network:
|
||||||
@@ -59,3 +78,6 @@ networks:
|
|||||||
external: true
|
external: true
|
||||||
localllm_default:
|
localllm_default:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
workspace-registry:
|
||||||
|
|||||||
@@ -20,3 +20,13 @@ THT_OLLAMA_URL=http://host.docker.internal:11434
|
|||||||
# --- Backend ---
|
# --- Backend ---
|
||||||
AUTH_MODE=none # none | mock | oidc (in embedded l'auth è al bordo del portale)
|
AUTH_MODE=none # none | mock | oidc (in embedded l'auth è al bordo del portale)
|
||||||
MAX_PI_PROCESSES=4
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
+23
-1
@@ -10,7 +10,9 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: docker/core.Dockerfile
|
dockerfile: docker/core.Dockerfile
|
||||||
image: thothii-core:local
|
image: thothii-core:local
|
||||||
env_file: [deploy/thothii.env]
|
env_file:
|
||||||
|
- path: deploy/thothii.env
|
||||||
|
required: false
|
||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: "8787"
|
PORT: "8787"
|
||||||
@@ -21,13 +23,30 @@ services:
|
|||||||
THT_SESSION_STORAGE: local
|
THT_SESSION_STORAGE: local
|
||||||
THT_HOME: /data/local-home
|
THT_HOME: /data/local-home
|
||||||
SETTINGS_FILE: /data/settings/settings.json
|
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}
|
MAX_PI_PROCESSES: ${MAX_PI_PROCESSES:-4}
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- /home/chirone/thothii-data:/data
|
- /home/chirone/thothii-data:/data
|
||||||
|
- workspace-registry:/data/workspace-registry
|
||||||
- /home/chirone/thothii-data/pi-config:/home/thoth/.pi
|
- /home/chirone/thothii-data/pi-config:/home/thoth/.pi
|
||||||
- /home/chirone/chirone/etl/docs/evidence:/data/evidence: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
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8787:8787"
|
- "127.0.0.1:8787:8787"
|
||||||
restart: "no"
|
restart: "no"
|
||||||
@@ -51,3 +70,6 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
thothii-net:
|
thothii-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
workspace-registry:
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
|||||||
# Utente non-root
|
# Utente non-root
|
||||||
RUN useradd --create-home --uid 10001 --shell /bin/bash thoth
|
RUN useradd --create-home --uid 10001 --shell /bin/bash thoth
|
||||||
RUN mkdir -p /home/thoth/.pi/agent && chown -R thoth:thoth /home/thoth/.pi
|
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/
|
COPY harness/ /app/harness/
|
||||||
# Pi scrive lock/settings in .pi: ownership thoth per sopravvivere al rebuild
|
# Pi scrive lock/settings in .pi: ownership thoth per sopravvivere al rebuild
|
||||||
|
|||||||
Executable
+114
@@ -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 - "$@" <<EOF
|
||||||
|
services:
|
||||||
|
core:
|
||||||
|
build:
|
||||||
|
context: $root
|
||||||
|
dockerfile: docker/core.Dockerfile
|
||||||
|
image: thothii-workspace-registry-smoke:local
|
||||||
|
environment:
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: "8787"
|
||||||
|
AUTH_MODE: none
|
||||||
|
THT_HARNESS_DIR: /app/harness
|
||||||
|
THT_BIN: /opt/venv/bin/tht
|
||||||
|
SETTINGS_FILE: /tmp/settings.json
|
||||||
|
THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry
|
||||||
|
THT_WORKSPACE_GIT_REMOTE: /fixtures/remote.git
|
||||||
|
THT_WORKSPACE_GIT_BRANCH: $branch
|
||||||
|
THT_WORKSPACE_INSTALLATION_ID: smoke
|
||||||
|
THT_WORKSPACE_SECRET_ROOTS: /run/secrets
|
||||||
|
volumes:
|
||||||
|
- workspace-registry:/data/workspace-registry
|
||||||
|
- $remote:/fixtures/remote.git:ro
|
||||||
|
volumes:
|
||||||
|
workspace-registry: {}
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_core() {
|
||||||
|
local attempt
|
||||||
|
for attempt in $(seq 1 30); do
|
||||||
|
if compose exec -T core curl -fsS http://127.0.0.1:8787/health >/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"
|
||||||
Reference in New Issue
Block a user