feat: configure Git workspace registry
This commit is contained in:
Generated
+76
-1
@@ -7,10 +7,15 @@
|
||||
"name": "thothii-backend",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.0.0"
|
||||
"fastify": "^5.0.0",
|
||||
"yaml": "^2.9.0",
|
||||
"yauzl": "^3.4.0",
|
||||
"yazl": "^3.3.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/yauzl": "^3.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
@@ -969,6 +974,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
|
||||
"integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
@@ -1160,6 +1175,15 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
@@ -1604,6 +1628,12 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2607,6 +2637,51 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
|
||||
"integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yazl": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz",
|
||||
"integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"fastify": "^5.0.0"
|
||||
"fastify": "^5.0.0",
|
||||
"yaml": "^2.9.0",
|
||||
"yauzl": "^3.4.0",
|
||||
"yazl": "^3.3.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/yauzl": "^3.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import type { WorkspaceRegistryConfig } from "./workspaces/types.js";
|
||||
|
||||
export interface AppConfig {
|
||||
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
|
||||
@@ -22,7 +23,32 @@ export interface AppConfig {
|
||||
* pay the probe; the local dev launcher (run-stack.sh) opts in via THT_DWH_PRECHECK.
|
||||
*/
|
||||
dwhPrecheck: boolean;
|
||||
workspaceRegistry: WorkspaceRegistryConfig;
|
||||
}
|
||||
|
||||
function requiredRegistryValue(value: string, label: string): string {
|
||||
if (value.length === 0 || value.trim() !== value || value.includes("\0")) {
|
||||
throw new Error(`workspace registry ${label} configuration is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function absoluteRegistryPath(value: string, label: string): string {
|
||||
const pathValue = requiredRegistryValue(value, label);
|
||||
if (!path.isAbsolute(pathValue)) {
|
||||
throw new Error(`workspace registry ${label} must be absolute`);
|
||||
}
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
function positiveImportLimit(value: string | undefined, fallback: number): number {
|
||||
const limit = Number(value ?? fallback);
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0) {
|
||||
throw new Error("workspace import limit configuration is invalid");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
const authMode = env.AUTH_MODE ?? "none";
|
||||
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) {
|
||||
@@ -89,6 +115,39 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
"THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE", "THT_VECTOR_READER_PASSWORD_SECRET_FILE",
|
||||
"THT_VECTOR_WRITER_PASSWORD_SECRET_FILE",
|
||||
]) secretFiles[name] = env[name];
|
||||
const registryRoot = absoluteRegistryPath(
|
||||
env.THT_WORKSPACE_REGISTRY_ROOT ?? "/data/workspace-registry",
|
||||
"root",
|
||||
);
|
||||
const registryBranch = requiredRegistryValue(env.THT_WORKSPACE_GIT_BRANCH ?? "main", "branch");
|
||||
const installationId = requiredRegistryValue(
|
||||
env.THT_WORKSPACE_INSTALLATION_ID ?? "local",
|
||||
"installation ID",
|
||||
);
|
||||
const remoteUrl = env.THT_WORKSPACE_GIT_REMOTE === undefined
|
||||
? undefined
|
||||
: requiredRegistryValue(env.THT_WORKSPACE_GIT_REMOTE, "remote");
|
||||
const secretRoots = (env.THT_WORKSPACE_SECRET_ROOTS ?? "")
|
||||
.split(",")
|
||||
.filter((root) => root.length > 0)
|
||||
.map((root) => absoluteRegistryPath(root, "secret root"));
|
||||
const workspaceRegistry: WorkspaceRegistryConfig = {
|
||||
root: registryRoot,
|
||||
remoteUrl,
|
||||
branch: registryBranch,
|
||||
gitAuthorName: requiredRegistryValue(
|
||||
env.THT_WORKSPACE_GIT_AUTHOR_NAME ?? "Thoth Workspace Registry",
|
||||
"Git author name",
|
||||
),
|
||||
gitAuthorEmail: requiredRegistryValue(
|
||||
env.THT_WORKSPACE_GIT_AUTHOR_EMAIL ?? "thoth-workspace-registry@localhost",
|
||||
"Git author email",
|
||||
),
|
||||
installationId,
|
||||
secretRoots,
|
||||
maxImportBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_BYTES, 10 * 1024 * 1024),
|
||||
maxImportEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_ENTRIES, 32),
|
||||
};
|
||||
return {
|
||||
host: env.HOST ?? "127.0.0.1",
|
||||
port: Number(env.PORT ?? 8787),
|
||||
@@ -106,5 +165,6 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
|
||||
secretFiles,
|
||||
modelApiKeyFile,
|
||||
dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1",
|
||||
workspaceRegistry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface WorkspaceRegistryConfig {
|
||||
root: string;
|
||||
remoteUrl?: string;
|
||||
branch: string;
|
||||
gitAuthorName: string;
|
||||
gitAuthorEmail: string;
|
||||
installationId: string;
|
||||
secretRoots: readonly string[];
|
||||
maxImportBytes: number;
|
||||
maxImportEntries: number;
|
||||
}
|
||||
|
||||
export type WorkspaceErrorCode =
|
||||
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
|
||||
| "workspace_stale" | "workspace_conflict" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
|
||||
| "connector_unavailable" | "semantic_index_incompatible";
|
||||
@@ -29,6 +29,12 @@ test("loadConfig keeps local development defaults", () => {
|
||||
thtBin: "tht",
|
||||
piBin: "pi",
|
||||
settingsFile: "data/settings.json",
|
||||
workspaceRegistry: {
|
||||
root: "/data/workspace-registry",
|
||||
branch: "main",
|
||||
maxImportBytes: 10 * 1024 * 1024,
|
||||
maxImportEntries: 32,
|
||||
},
|
||||
});
|
||||
expect(loadConfig({}).dataRoot).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
|
||||
test("loads a safe Git workspace registry configuration", () => {
|
||||
const cfg = loadConfig({
|
||||
THT_WORKSPACE_REGISTRY_ROOT: "/data/workspace-registry",
|
||||
THT_WORKSPACE_GIT_REMOTE: "ssh://git@gitea.example/thoth/workspaces.git",
|
||||
THT_WORKSPACE_GIT_BRANCH: "main",
|
||||
THT_WORKSPACE_INSTALLATION_ID: "server-psd-1",
|
||||
THT_WORKSPACE_SECRET_ROOTS: "/run/secrets,/data/secrets",
|
||||
});
|
||||
|
||||
expect(cfg.workspaceRegistry).toMatchObject({
|
||||
root: "/data/workspace-registry",
|
||||
remoteUrl: "ssh://git@gitea.example/thoth/workspaces.git",
|
||||
branch: "main",
|
||||
installationId: "server-psd-1",
|
||||
secretRoots: ["/run/secrets", "/data/secrets"],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses safe workspace registry defaults", () => {
|
||||
expect(loadConfig({}).workspaceRegistry).toMatchObject({
|
||||
root: "/data/workspace-registry",
|
||||
branch: "main",
|
||||
maxImportBytes: 10 * 1024 * 1024,
|
||||
maxImportEntries: 32,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a relative registry root and invalid import limits", () => {
|
||||
expect(() => loadConfig({ THT_WORKSPACE_REGISTRY_ROOT: "registry" })).toThrow(/registry/i);
|
||||
expect(() => loadConfig({
|
||||
THT_WORKSPACE_REGISTRY_ROOT: "/data/registry",
|
||||
THT_WORKSPACE_MAX_IMPORT_BYTES: "0",
|
||||
})).toThrow(/import/i);
|
||||
expect(() => loadConfig({
|
||||
THT_WORKSPACE_REGISTRY_ROOT: "/data/registry",
|
||||
THT_WORKSPACE_MAX_IMPORT_ENTRIES: "1.5",
|
||||
})).toThrow(/import/i);
|
||||
});
|
||||
|
||||
test("rejects unsafe registry branch, installation ID, and secret roots", () => {
|
||||
expect(() => loadConfig({ THT_WORKSPACE_GIT_BRANCH: "" })).toThrow(/branch/i);
|
||||
expect(() => loadConfig({ THT_WORKSPACE_INSTALLATION_ID: "" })).toThrow(/installation/i);
|
||||
expect(() => loadConfig({ THT_WORKSPACE_SECRET_ROOTS: "/run/secrets,relative" }))
|
||||
.toThrow(/secret/i);
|
||||
});
|
||||
Reference in New Issue
Block a user