fix: harden workspace registry deployment
This commit is contained in:
@@ -87,6 +87,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
||||
dwhPrecheck: config.dwhPrecheck,
|
||||
legacyWorkspaceMode: config.legacyWorkspaceMode,
|
||||
});
|
||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface AppConfig {
|
||||
* pay the probe; the local dev launcher (run-stack.sh) opts in via THT_DWH_PRECHECK.
|
||||
*/
|
||||
dwhPrecheck: boolean;
|
||||
/** Explicit compatibility mode for old loopback clients that send `workspace` in POST /sessions. */
|
||||
legacyWorkspaceMode: boolean;
|
||||
workspaceDiagnosticTimeoutMs: number;
|
||||
workspaceRegistry: WorkspaceRegistryConfig;
|
||||
}
|
||||
@@ -103,6 +105,13 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
if (sessionStorageMode === "local" && env.THOTH_PUBLIC_EXPOSURE === "true") {
|
||||
throw new Error("local session storage requires loopback-only deployment");
|
||||
}
|
||||
const legacyWorkspaceMode = env.THT_LEGACY_WORKSPACE_MODE;
|
||||
if (legacyWorkspaceMode !== undefined && legacyWorkspaceMode !== "local") {
|
||||
throw new Error("legacy workspace mode configuration is invalid");
|
||||
}
|
||||
if (legacyWorkspaceMode === "local" && sessionStorageMode !== "local") {
|
||||
throw new Error("legacy workspace mode requires local session storage");
|
||||
}
|
||||
const sessionStorage: AppConfig["sessionStorage"] = { mode: sessionStorageMode };
|
||||
if (sessionStorageMode === "postgres") {
|
||||
const host = env.THT_SESSION_DB_HOST;
|
||||
@@ -203,6 +212,7 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
secretFiles,
|
||||
modelApiKeyFile,
|
||||
dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1",
|
||||
legacyWorkspaceMode: legacyWorkspaceMode === "local",
|
||||
workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS),
|
||||
workspaceRegistry,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,8 @@ export function sessionRoutes(
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
/** Local-only guard: probe DWH reachability before creating a session (run-stack.sh). */
|
||||
dwhPrecheck?: boolean;
|
||||
/** Explicit loopback-only compatibility path for old clients that send `workspace`. */
|
||||
legacyWorkspaceMode?: boolean;
|
||||
},
|
||||
) {
|
||||
const lifecycleTails = new Map<string, Promise<void>>();
|
||||
@@ -267,9 +269,10 @@ export function sessionRoutes(
|
||||
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||
const runner = runnerFor(principal);
|
||||
// `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;
|
||||
// It is available only through the explicit loopback-only compatibility mode; every normal
|
||||
// new session must resolve and pin an immutable registry revision.
|
||||
const legacyWorkspaceRequest = d.legacyWorkspaceMode
|
||||
&& typeof b.workspace === "string" && b.workspace.length > 0;
|
||||
const requestedWorkspaceId = b.workspaceId ?? (legacyWorkspaceRequest ? undefined : s.workspace);
|
||||
if (!requestedWorkspaceId && !legacyWorkspaceRequest) {
|
||||
return reply.code(409).send({
|
||||
|
||||
@@ -175,19 +175,35 @@ export async function writeMigratedWorkspace(result: LegacyMigrationResult, repo
|
||||
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>");
|
||||
function parseCliArguments(argv: readonly string[]): { input: string; output: string; id?: string } {
|
||||
if (argv.length !== 4 && argv.length !== 6) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> [--id <workspace-id>]");
|
||||
}
|
||||
if (!isAbsolute(argv[1]) || !isAbsolute(argv[3])) {
|
||||
const options = new Map<string, string>();
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const flag = argv[index];
|
||||
const value = argv[index + 1];
|
||||
if ((flag !== "--input" && flag !== "--output" && flag !== "--id") || value === undefined || options.has(flag)) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> [--id <workspace-id>]");
|
||||
}
|
||||
options.set(flag, value);
|
||||
}
|
||||
const input = options.get("--input");
|
||||
const output = options.get("--output");
|
||||
const id = options.get("--id");
|
||||
if (input === undefined || output === undefined) {
|
||||
throw new Error("usage: migrate-legacy --input <legacy-workspace.yaml> --output <repository-root> [--id <workspace-id>]");
|
||||
}
|
||||
if (!isAbsolute(input) || !isAbsolute(output)) {
|
||||
throw new Error("migration input and output paths must be absolute");
|
||||
}
|
||||
return { input: argv[1], output: argv[3] };
|
||||
if (id !== undefined && !workspaceId.test(id)) throw new Error("legacy workspace ID is invalid");
|
||||
return { input, output, id };
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
||||
const { input, output } = parseCliArguments(argv);
|
||||
const id = basename(input, ".yaml");
|
||||
const { input, output, id: explicitId } = parseCliArguments(argv);
|
||||
const id = explicitId ?? basename(input, ".yaml");
|
||||
const result = migrateLegacyWorkspace(await readFile(input, "utf8"), { id });
|
||||
const destination = await writeMigratedWorkspace(result, output);
|
||||
process.stdout.write(`${destination}\n`);
|
||||
|
||||
@@ -39,6 +39,24 @@ test("loadConfig keeps local development defaults", () => {
|
||||
expect(loadConfig({}).dataRoot).toBeUndefined();
|
||||
});
|
||||
|
||||
test("loadConfig enables the legacy workspace request only through explicit local mode", () => {
|
||||
expect(loadConfig({ THT_LEGACY_WORKSPACE_MODE: "local" }).legacyWorkspaceMode).toBe(true);
|
||||
|
||||
expect(() => loadConfig({
|
||||
THT_LEGACY_WORKSPACE_MODE: "local",
|
||||
AUTH_MODE: "upstream",
|
||||
THT_SESSION_STORAGE: "postgres",
|
||||
THT_SESSION_DB_HOST: "db.internal",
|
||||
THT_SESSION_DB_NAME: "thoth",
|
||||
THT_SESSION_RUNTIME_USER: "thoth_sessions_app",
|
||||
THT_SESSION_RUNTIME_PASSWORD_FILE: "/run/secrets/session_runtime_password",
|
||||
THT_SESSION_DB_SSLMODE: "verify-full",
|
||||
THT_SESSION_DB_SSLROOTCERT: "/run/secrets/session_ca.pem",
|
||||
})).toThrow(/legacy workspace mode requires local session storage/);
|
||||
expect(() => loadConfig({ THT_LEGACY_WORKSPACE_MODE: "true" }))
|
||||
.toThrow(/legacy workspace mode configuration is invalid/);
|
||||
});
|
||||
|
||||
test("loadConfig rejects unauthenticated public exposure", () => {
|
||||
expect(() => loadConfig({
|
||||
THOTH_PUBLIC_EXPOSURE: "true",
|
||||
|
||||
@@ -29,7 +29,9 @@ async function readUntil(
|
||||
}
|
||||
|
||||
test("loop F1: crea sessione → SSE riceve il widget → risponde → il modello riparte (follow-up)", async () => {
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
const app = buildApp(loadConfig({
|
||||
THT_HARNESS_DIR: "../harness", THT_LEGACY_WORKSPACE_MODE: "local",
|
||||
}), {
|
||||
thtRunner: {
|
||||
ollamaEnsure: async () => ({ ok: true }),
|
||||
searchPack: async () => {},
|
||||
|
||||
@@ -173,6 +173,45 @@ test("new sessions are created through the authenticated principal, not a client
|
||||
expect(principal).toMatchObject({ issuer: "portal", subject: "alice" });
|
||||
});
|
||||
|
||||
test("new sessions reject the client legacy workspace field unless local legacy mode is explicit", async () => {
|
||||
const sessionNew = vi.fn(async () => ({ id: "legacy" }));
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
||||
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||
mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any,
|
||||
getSettings: () => ({}) as any,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json()).toMatchObject({ code: "workspace_revision_unavailable" });
|
||||
expect(sessionNew).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("explicit local legacy mode permits the unpinned client workspace request", async () => {
|
||||
const sessionNew = vi.fn(async () => ({ id: "legacy" }));
|
||||
const app = buildApp(loadConfig({
|
||||
THT_HARNESS_DIR: "../harness", THT_LEGACY_WORKSPACE_MODE: "local",
|
||||
}), {
|
||||
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
||||
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||
mgr: { get: () => undefined, createFor: () => ({ bridge: { onClientEvent: () => {} } }), configure: async () => {}, start: () => {} } as any,
|
||||
getSettings: () => ({}) as any,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST", url: "/sessions", payload: { question: "q", workspace: "legacy" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(sessionNew).toHaveBeenCalledWith(expect.objectContaining({
|
||||
workspaceConfigPath: undefined, workspaceId: undefined, workspaceRevision: undefined,
|
||||
}));
|
||||
});
|
||||
|
||||
test("creates a session from the active immutable workspace revision", async () => {
|
||||
const sessionNew = vi.fn(async () => ({ id: "pinned" }));
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } 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 {
|
||||
main,
|
||||
migrateLegacyWorkspace,
|
||||
writeMigratedWorkspace,
|
||||
} from "../src/workspaces/migrate-legacy.js";
|
||||
@@ -48,13 +49,29 @@ test("writes versioned repository artifacts atomically without replacing a prior
|
||||
expect(existsSync(destination)).toBe(true);
|
||||
});
|
||||
|
||||
test("CLI accepts an explicit valid ID when a legacy filename contains dots", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "thoth-workspace-migrate-"));
|
||||
temporaryRoots.push(root);
|
||||
const input = join(root, "psd.clinical.yaml");
|
||||
writeFileSync(input, readFixture("local.yaml"));
|
||||
|
||||
await main(["--input", input, "--output", root, "--id", "psd-clinical"]);
|
||||
|
||||
const destination = join(root, "workspaces", "psd-clinical.yaml");
|
||||
expect(parseWorkspaceYaml(readFileSync(destination, "utf8"))).toMatchObject({
|
||||
workspace: { id: "psd-clinical", schema_version: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
const smoke = readFileSync(new URL("../../scripts/workspace-registry-smoke.sh", import.meta.url), "utf8");
|
||||
|
||||
for (const source of [compose, development]) {
|
||||
expect(source).toContain("THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry");
|
||||
expect(source).toContain("THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE}");
|
||||
expect(source).toContain("workspace-registry:/data/workspace-registry");
|
||||
expect(source).toMatch(/workspace-registry-git-credentials:ro/);
|
||||
expect(source).toMatch(/workspace-registry-git-ca:ro/);
|
||||
@@ -62,4 +79,7 @@ test("declares a durable isolated registry volume and only read-only Git credent
|
||||
expect(source).toMatch(/workspace-registry-git-known-hosts:ro/);
|
||||
}
|
||||
expect(dockerfile).toMatch(/mkdir -p \/data\/workspace-registry && chown -R thoth:thoth \/data\/workspace-registry/);
|
||||
expect(smoke).toContain('core_remote="/fixtures/offline.git"');
|
||||
expect(smoke).toContain('"degraded":true');
|
||||
expect(smoke).toContain('core_remote="/fixtures/remote.git"');
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ services:
|
||||
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_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE}
|
||||
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}
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
THT_HOME: /data/local-home
|
||||
SETTINGS_FILE: /data/settings/settings.json
|
||||
THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry
|
||||
THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE}
|
||||
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}
|
||||
|
||||
@@ -10,6 +10,7 @@ project="thoth-workspace-registry-smoke-$$"
|
||||
remote="$tmp/remote.git"
|
||||
seed="$tmp/seed"
|
||||
branch="workspace-registry-smoke"
|
||||
core_remote="/fixtures/remote.git"
|
||||
|
||||
cleanup() {
|
||||
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
@@ -33,7 +34,7 @@ services:
|
||||
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_REMOTE: $core_remote
|
||||
THT_WORKSPACE_GIT_BRANCH: $branch
|
||||
THT_WORKSPACE_INSTALLATION_ID: smoke
|
||||
THT_WORKSPACE_SECRET_ROOTS: /run/secrets
|
||||
@@ -81,13 +82,16 @@ initial_status="$(compose exec -T core curl -fsS http://127.0.0.1:8787/workspace
|
||||
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 =="
|
||||
echo "== Recreate offline and prove registry-volume fallback =="
|
||||
core_remote="/fixtures/offline.git"
|
||||
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"
|
||||
printf '%s' "$recreated_status" | grep -Fq '"degraded":true'
|
||||
compose exec -T core curl -fsS http://127.0.0.1:8787/workspaces | grep -Fq 'Local'
|
||||
|
||||
echo "== Pull a valid remote update =="
|
||||
sed -i.bak 's/name: Local/name: Local Updated/' "$seed/workspaces/local.yaml"
|
||||
@@ -96,6 +100,9 @@ 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
|
||||
core_remote="/fixtures/remote.git"
|
||||
compose up -d --force-recreate
|
||||
wait_for_core
|
||||
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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user