From 747020a330f6b9affe0a2521fc461df189da264b Mon Sep 17 00:00:00 2001 From: mptyl Date: Fri, 14 Aug 2026 16:32:58 +0200 Subject: [PATCH] feat: declare workspace repository in installation config --- backend/src/workspaces/git-repository.ts | 68 ++++++++++ backend/src/workspaces/registry.ts | 4 + backend/test/workspace-registry.test.ts | 6 +- .../test/workspaces-git-repository.test.ts | 28 +++- deploy/psd/thothii-installation.yaml.example | 6 +- .../examples/thothii-installation.local.yaml | 4 + .../examples/thothii-installation.server.yaml | 4 + .../thothctl/internal/config/installation.go | 126 ++++++++++++++++-- .../internal/config/installation_test.go | 61 +++++++++ 9 files changed, 294 insertions(+), 13 deletions(-) diff --git a/backend/src/workspaces/git-repository.ts b/backend/src/workspaces/git-repository.ts index b23a3ad1..a01203c1 100644 --- a/backend/src/workspaces/git-repository.ts +++ b/backend/src/workspaces/git-repository.ts @@ -9,6 +9,7 @@ const execFileAsync = promisify(execFile); export interface GitStatus { branch: string; + repository?: WorkspaceRepositoryIdentity; head?: string; ahead: number; behind: number; @@ -16,6 +17,12 @@ export interface GitStatus { lastError?: WorkspaceErrorCode; } +export interface WorkspaceRepositoryIdentity { + host: string; + repository: string; + transport: "https" | "ssh" | "local"; +} + export interface EvidenceTreeObject { mode: "100644" | "100755"; oid: string; @@ -29,6 +36,62 @@ export class WorkspaceRegistryError extends Error { } } +function invalidRemote(): never { + throw new WorkspaceRegistryError("workspace_invalid", "Workspace Git remote is invalid"); +} + +function safeRepositoryPath(raw: string): string { + let decoded: string; + try { + decoded = decodeURIComponent(raw).replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/, ""); + } catch { + return invalidRemote(); + } + if ( + decoded.length === 0 + || decoded.includes("\\") + || decoded.split("/").some((part) => part === "" || part === "." || part === "..") + || /[\p{Cc}\s?#]/u.test(decoded) + ) return invalidRemote(); + return decoded; +} + +/** Convert a configured remote to the only repository identity safe for API/UI responses. */ +export function normalizeRepositoryIdentity(remote: string): WorkspaceRepositoryIdentity { + if (remote.length === 0 || remote.trim() !== remote || remote.includes("\0")) return invalidRemote(); + if (isAbsolute(remote) || remote.startsWith("file://")) { + return { host: "local", repository: "configured-repository", transport: "local" }; + } + const scp = /^git@([^:/\s]+):(.+)$/.exec(remote); + if (scp) { + return { host: scp[1].toLowerCase(), repository: safeRepositoryPath(scp[2]), transport: "ssh" }; + } + let parsed: URL; + try { + parsed = new URL(remote); + } catch { + return invalidRemote(); + } + if (parsed.search || parsed.hash || !parsed.hostname || parsed.port) return invalidRemote(); + if (parsed.protocol === "https:") { + if (parsed.username || parsed.password) return invalidRemote(); + return { + host: parsed.hostname.toLowerCase(), + repository: safeRepositoryPath(parsed.pathname), + transport: "https", + }; + } + if (parsed.protocol === "ssh:") { + if (parsed.password || (parsed.username !== "" && parsed.username !== "git")) return invalidRemote(); + return { + host: parsed.hostname.toLowerCase(), + repository: safeRepositoryPath(parsed.pathname), + transport: "ssh", + }; + } + return invalidRemote(); +} + function isMissing(path: string): boolean { try { lstatSync(path); @@ -81,6 +144,7 @@ export class GitWorkspaceRepository { readonly statePath: string; readonly locksPath: string; private readonly hooksPath: string; + private readonly identity?: WorkspaceRepositoryIdentity; constructor(private readonly config: WorkspaceRegistryConfig) { if (!isAbsolute(config.root)) { @@ -92,6 +156,9 @@ export class GitWorkspaceRepository { this.statePath = join(this.root, "state"); this.locksPath = join(this.root, "locks"); this.hooksPath = join(this.locksPath, "empty-hooks"); + this.identity = config.remoteUrl === undefined + ? undefined + : normalizeRepositoryIdentity(config.remoteUrl); } async ensureLayout(): Promise { @@ -134,6 +201,7 @@ export class GitWorkspaceRepository { const [ahead = "0", behind = "0"] = tracking ? tracking.trim().split(/\s+/) : []; return { branch: this.config.branch, + ...(this.identity ? { repository: this.identity } : {}), head, ahead: Number(ahead), behind: Number(behind), diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 31def9fb..ea466ff9 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -11,6 +11,7 @@ import { GitWorkspaceRepository, WorkspaceRegistryError, WorkspaceRepositoryLock, + normalizeRepositoryIdentity, type GitStatus, } from "./git-repository.js"; import { @@ -492,6 +493,9 @@ export class WorkspaceRegistry { if (!active) throw safeError; return { branch: this.config.branch, + ...(this.config.remoteUrl + ? { repository: normalizeRepositoryIdentity(this.config.remoteUrl) } + : {}), head: active.head, ahead: 0, behind: 0, diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index 283a6450..58b1db10 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -632,7 +632,11 @@ test("normalizes historical operational state during offline fallback after rest rmSync(remote.remote, { recursive: true, force: true }); const restored = new WorkspaceRegistry(config(root, remote.remote)); - await expect(restored.pull()).resolves.toMatchObject({ degraded: true, head: remote.initialCommit }); + await expect(restored.pull()).resolves.toMatchObject({ + degraded: true, + head: remote.initialCommit, + repository: { host: "local", repository: "configured-repository", transport: "local" }, + }); const listed = await restored.list(); const read = await restored.read("psd-clinical"); expect(listed[0]).not.toHaveProperty("state"); diff --git a/backend/test/workspaces-git-repository.test.ts b/backend/test/workspaces-git-repository.test.ts index 696ae52f..22004a00 100644 --- a/backend/test/workspaces-git-repository.test.ts +++ b/backend/test/workspaces-git-repository.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterEach, expect, test } from "vitest"; -import { GitWorkspaceRepository, WorkspaceRepositoryLock } from "../src/workspaces/git-repository.js"; +import { + GitWorkspaceRepository, + WorkspaceRepositoryLock, + normalizeRepositoryIdentity, +} from "../src/workspaces/git-repository.js"; import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js"; const validYaml = `workspace: @@ -102,6 +106,28 @@ test("does not expose repository mutation or publication operations", async () = expect(repository).not.toHaveProperty("commitAndPush"); }); +test.each([ + ["https://github.com/aritmolab/workspaces.git", { + host: "github.com", repository: "aritmolab/workspaces", transport: "https", + }], + ["ssh://git@gitlab.example.org/clinical/workspaces.git", { + host: "gitlab.example.org", repository: "clinical/workspaces", transport: "ssh", + }], + ["git@gitea.example.org:clinical/workspaces.git", { + host: "gitea.example.org", repository: "clinical/workspaces", transport: "ssh", + }], +] as const)("normalizes the safe repository identity for %s", (remote, expected) => { + expect(normalizeRepositoryIdentity(remote)).toEqual(expected); +}); + +test.each([ + "https://user:secret@github.com/aritmolab/workspaces.git", + "https://github.com/aritmolab/workspaces.git?token=secret", + "ssh://git:secret@gitlab.example.org/clinical/workspaces.git", +])("rejects a remote that could expose embedded credentials: %s", (remote) => { + expect(() => normalizeRepositoryIdentity(remote)).toThrow("Workspace Git remote is invalid"); +}); + test("bootstraps a persistent checkout from a local bare repository", async () => { const fixture = await temporaryRemote(); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); diff --git a/deploy/psd/thothii-installation.yaml.example b/deploy/psd/thothii-installation.yaml.example index dc833d5f..4102c3f7 100644 --- a/deploy/psd/thothii-installation.yaml.example +++ b/deploy/psd/thothii-installation.yaml.example @@ -3,6 +3,10 @@ profile: local projectDirectory: "/projects/ThothII" envFile: "/projects/ThothII/deploy/psd/operator.env" +workspaceRepository: + remote: git@github.com:mptyl/tht-workspace-psd.git + branch: main + access: ssh overrides: - - "/projects/ThothII/deploy/compose.git-https.yaml" + - "/projects/ThothII/deploy/compose.git-ssh.yaml" - "/projects/ThothII/deploy/psd/connector-secrets.yaml" diff --git a/docs/install/examples/thothii-installation.local.yaml b/docs/install/examples/thothii-installation.local.yaml index 60715e5d..39ca15f0 100644 --- a/docs/install/examples/thothii-installation.local.yaml +++ b/docs/install/examples/thothii-installation.local.yaml @@ -3,6 +3,10 @@ profile: local projectDirectory: "/absolute/path/to/ThothII" envFile: "/absolute/path/to/ThothII/deploy/env/local.env" +workspaceRepository: + remote: git@git.example.com:organization/workspaces.git + branch: main + access: ssh overrides: - "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml" - "/absolute/path/to/thothii-operator/connector-secrets.local.yaml" diff --git a/docs/install/examples/thothii-installation.server.yaml b/docs/install/examples/thothii-installation.server.yaml index 0b6e86c9..5992cdaa 100644 --- a/docs/install/examples/thothii-installation.server.yaml +++ b/docs/install/examples/thothii-installation.server.yaml @@ -3,6 +3,10 @@ profile: server projectDirectory: "/absolute/path/to/ThothII" envFile: "/absolute/path/to/thothii-server-operator/server.env" +workspaceRepository: + remote: git@git.example.com:organization/workspaces.git + branch: main + access: ssh overrides: - "/absolute/path/to/ThothII/deploy/compose.session-server.yaml.example" - "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml" diff --git a/tools/thothctl/internal/config/installation.go b/tools/thothctl/internal/config/installation.go index c1de64ee..c843ee5c 100644 --- a/tools/thothctl/internal/config/installation.go +++ b/tools/thothctl/internal/config/installation.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "io" + "net/url" "os" "path/filepath" + "regexp" "sort" "strings" "sync" @@ -28,20 +30,35 @@ const maxSecretSources = 32 var dotenvParseMu sync.Mutex type descriptor struct { - Profile string `yaml:"profile"` - ProjectDirectory string `yaml:"projectDirectory"` - EnvFile string `yaml:"envFile"` - Overrides []string `yaml:"overrides"` + Profile string `yaml:"profile"` + ProjectDirectory string `yaml:"projectDirectory"` + EnvFile string `yaml:"envFile"` + WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"` + Overrides []string `yaml:"overrides"` +} + +type workspaceRepositoryDescriptor struct { + Remote string `yaml:"remote"` + Branch string `yaml:"branch"` + Access string `yaml:"access"` +} + +// WorkspaceRepository is the non-secret Git source identity declared by one installation. +type WorkspaceRepository struct { + Remote string + Branch string + Access string } // Installation is a validated local Compose installation. It intentionally contains paths, not // environment values or secret content. type Installation struct { - Path string - Profile string - ProjectDirectory string - EnvFile string - Overrides []string + Path string + Profile string + ProjectDirectory string + EnvFile string + WorkspaceRepository WorkspaceRepository + Overrides []string } // Load reads and validates an installation descriptor at an absolute path. @@ -88,7 +105,12 @@ func Load(path string) (Installation, error) { Profile: raw.Profile, ProjectDirectory: filepath.Clean(raw.ProjectDirectory), EnvFile: filepath.Clean(raw.EnvFile), - Overrides: make([]string, 0, len(raw.Overrides)), + WorkspaceRepository: WorkspaceRepository{ + Remote: raw.WorkspaceRepository.Remote, + Branch: raw.WorkspaceRepository.Branch, + Access: raw.WorkspaceRepository.Access, + }, + Overrides: make([]string, 0, len(raw.Overrides)), } for _, override := range raw.Overrides { if err := requireRegularFile(override, "override"); err != nil { @@ -101,6 +123,9 @@ func Load(path string) (Installation, error) { return Installation{}, err } } + if err := installation.validateWorkspaceRepository(); err != nil { + return Installation{}, err + } if info, err := os.Lstat(installation.CurrentImageOverridePath()); err == nil { if !info.Mode().IsRegular() { return Installation{}, errors.New("installation current-image override must be a regular file") @@ -111,6 +136,87 @@ func Load(path string) (Installation, error) { return installation, nil } +var safeGitBranch = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`) +var scpSSHRemote = regexp.MustCompile(`^git@[^:/\s]+:[^\s]+$`) + +func (i Installation) validateWorkspaceRepository() error { + gitAccess := "" + for _, override := range i.Overrides { + switch filepath.Base(override) { + case "compose.git-ssh.yaml": + if gitAccess != "" { + return errors.New("installation must select exactly one Git transport override") + } + gitAccess = "ssh" + case "compose.git-https.yaml": + if gitAccess != "" { + return errors.New("installation must select exactly one Git transport override") + } + gitAccess = "https" + } + } + declared := i.WorkspaceRepository + if gitAccess == "" { + if declared.Remote != "" || declared.Branch != "" || declared.Access != "" { + return errors.New("workspaceRepository requires one Git transport override") + } + return nil + } + if declared.Remote == "" || declared.Branch == "" || declared.Access == "" { + return errors.New("workspaceRepository is required for a Git installation") + } + if declared.Access != gitAccess { + return errors.New("workspaceRepository access does not match the Git transport override") + } + if !safeGitBranch.MatchString(declared.Branch) || strings.Contains(declared.Branch, "..") || + strings.Contains(declared.Branch, "@{") || strings.HasPrefix(declared.Branch, "-") || + strings.HasSuffix(declared.Branch, ".lock") { + return errors.New("workspaceRepository branch is invalid") + } + if err := validateRepositoryRemote(declared.Remote, declared.Access); err != nil { + return err + } + values, err := i.environmentValues() + if err != nil { + return err + } + if values["THT_WORKSPACE_GIT_REMOTE"] != declared.Remote || + values["THT_WORKSPACE_GIT_BRANCH"] != declared.Branch { + return errors.New("workspaceRepository does not match the installation environment") + } + required := []string{"THT_WORKSPACE_GIT_CREDENTIALS_FILE", "THT_WORKSPACE_GIT_CA_FILE"} + if gitAccess == "ssh" { + required = []string{"THT_WORKSPACE_GIT_SSH_KEY_FILE", "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE"} + } + for _, name := range required { + if err := requireRegularFile(values[name], "workspaceRepository credential"); err != nil { + return errors.New("workspaceRepository credentials are unavailable") + } + } + return nil +} + +func validateRepositoryRemote(remote, access string) error { + if remote == "" || strings.TrimSpace(remote) != remote || strings.ContainsRune(remote, '\x00') { + return errors.New("workspaceRepository remote is invalid") + } + if access == "ssh" && scpSSHRemote.MatchString(remote) { + return nil + } + parsed, err := url.Parse(remote) + if err != nil || parsed.Hostname() == "" || parsed.RawQuery != "" || parsed.Fragment != "" || + parsed.User != nil && access == "https" || parsed.User != nil && strings.Contains(parsed.User.String(), ":") { + return errors.New("workspaceRepository remote is invalid") + } + if access == "https" && parsed.Scheme != "https" { + return errors.New("workspaceRepository remote does not match HTTPS access") + } + if access == "ssh" && parsed.Scheme != "ssh" { + return errors.New("workspaceRepository remote does not match SSH access") + } + return nil +} + // ComposeFiles returns the base file, selected profile file, and declared optional overrides in // the exact order Compose applies them. func (i Installation) ComposeFiles() []string { diff --git a/tools/thothctl/internal/config/installation_test.go b/tools/thothctl/internal/config/installation_test.go index 4be55207..d1844dee 100644 --- a/tools/thothctl/internal/config/installation_test.go +++ b/tools/thothctl/internal/config/installation_test.go @@ -51,6 +51,67 @@ func TestLoadSelectsServerComposeFiles(t *testing.T) { assertStringsEqual(t, installation.ComposeFiles(), want) } +func TestLoadRequiresAndReturnsTypedWorkspaceRepositoryForGitInstallations(t *testing.T) { + installationPath, projectDirectory, envFile, _ := writeInstallation(t, "local") + gitOverride := filepath.Join(projectDirectory, "deploy", "compose.git-ssh.yaml") + if err := os.WriteFile(gitOverride, []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + secretRoot := filepath.Dir(envFile) + privateKey := filepath.Join(secretRoot, "git-key") + knownHosts := filepath.Join(secretRoot, "known-hosts") + for _, file := range []string{privateKey, knownHosts} { + if err := os.WriteFile(file, []byte("fixture\n"), 0o600); err != nil { + t.Fatal(err) + } + } + remote := "git@gitea.example.org:clinical/workspaces.git" + environment := strings.Join([]string{ + "THT_WORKSPACE_GIT_REMOTE=" + remote, + "THT_WORKSPACE_GIT_BRANCH=main", + "THT_WORKSPACE_GIT_SSH_KEY_FILE=" + privateKey, + "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE=" + knownHosts, + }, "\n") + "\n" + if err := os.WriteFile(envFile, []byte(environment), 0o600); err != nil { + t.Fatal(err) + } + contents := "profile: local\nprojectDirectory: " + projectDirectory + + "\nenvFile: " + envFile + + "\nworkspaceRepository:\n remote: " + remote + + "\n branch: main\n access: ssh\noverrides:\n - " + gitOverride + "\n" + if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + installation, err := Load(installationPath) + if err != nil { + t.Fatal(err) + } + if installation.WorkspaceRepository.Remote != remote || + installation.WorkspaceRepository.Branch != "main" || + installation.WorkspaceRepository.Access != "ssh" { + t.Fatalf("WorkspaceRepository = %#v", installation.WorkspaceRepository) + } +} + +func TestLoadRejectsGitOverrideWithoutTypedWorkspaceRepository(t *testing.T) { + installationPath, projectDirectory, envFile, _ := writeInstallation(t, "local") + gitOverride := filepath.Join(projectDirectory, "deploy", "compose.git-https.yaml") + if err := os.WriteFile(gitOverride, []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + contents := "profile: local\nprojectDirectory: " + projectDirectory + + "\nenvFile: " + envFile + "\noverrides:\n - " + gitOverride + "\n" + if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + _, err := Load(installationPath) + if err == nil || !strings.Contains(err.Error(), "workspaceRepository") { + t.Fatalf("Load() error = %v, want workspaceRepository error", err) + } +} + func TestComposeArgsAutomaticallyIncludeTheInstallationCurrentImageOverride(t *testing.T) { t.Parallel()