feat: declare workspace repository in installation config

This commit is contained in:
2026-08-14 16:32:58 +02:00
parent 9db4463a83
commit 747020a330
9 changed files with 294 additions and 13 deletions
+68
View File
@@ -9,6 +9,7 @@ const execFileAsync = promisify(execFile);
export interface GitStatus { export interface GitStatus {
branch: string; branch: string;
repository?: WorkspaceRepositoryIdentity;
head?: string; head?: string;
ahead: number; ahead: number;
behind: number; behind: number;
@@ -16,6 +17,12 @@ export interface GitStatus {
lastError?: WorkspaceErrorCode; lastError?: WorkspaceErrorCode;
} }
export interface WorkspaceRepositoryIdentity {
host: string;
repository: string;
transport: "https" | "ssh" | "local";
}
export interface EvidenceTreeObject { export interface EvidenceTreeObject {
mode: "100644" | "100755"; mode: "100644" | "100755";
oid: string; 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 { function isMissing(path: string): boolean {
try { try {
lstatSync(path); lstatSync(path);
@@ -81,6 +144,7 @@ export class GitWorkspaceRepository {
readonly statePath: string; readonly statePath: string;
readonly locksPath: string; readonly locksPath: string;
private readonly hooksPath: string; private readonly hooksPath: string;
private readonly identity?: WorkspaceRepositoryIdentity;
constructor(private readonly config: WorkspaceRegistryConfig) { constructor(private readonly config: WorkspaceRegistryConfig) {
if (!isAbsolute(config.root)) { if (!isAbsolute(config.root)) {
@@ -92,6 +156,9 @@ export class GitWorkspaceRepository {
this.statePath = join(this.root, "state"); this.statePath = join(this.root, "state");
this.locksPath = join(this.root, "locks"); this.locksPath = join(this.root, "locks");
this.hooksPath = join(this.locksPath, "empty-hooks"); this.hooksPath = join(this.locksPath, "empty-hooks");
this.identity = config.remoteUrl === undefined
? undefined
: normalizeRepositoryIdentity(config.remoteUrl);
} }
async ensureLayout(): Promise<void> { async ensureLayout(): Promise<void> {
@@ -134,6 +201,7 @@ export class GitWorkspaceRepository {
const [ahead = "0", behind = "0"] = tracking ? tracking.trim().split(/\s+/) : []; const [ahead = "0", behind = "0"] = tracking ? tracking.trim().split(/\s+/) : [];
return { return {
branch: this.config.branch, branch: this.config.branch,
...(this.identity ? { repository: this.identity } : {}),
head, head,
ahead: Number(ahead), ahead: Number(ahead),
behind: Number(behind), behind: Number(behind),
+4
View File
@@ -11,6 +11,7 @@ import {
GitWorkspaceRepository, GitWorkspaceRepository,
WorkspaceRegistryError, WorkspaceRegistryError,
WorkspaceRepositoryLock, WorkspaceRepositoryLock,
normalizeRepositoryIdentity,
type GitStatus, type GitStatus,
} from "./git-repository.js"; } from "./git-repository.js";
import { import {
@@ -492,6 +493,9 @@ export class WorkspaceRegistry {
if (!active) throw safeError; if (!active) throw safeError;
return { return {
branch: this.config.branch, branch: this.config.branch,
...(this.config.remoteUrl
? { repository: normalizeRepositoryIdentity(this.config.remoteUrl) }
: {}),
head: active.head, head: active.head,
ahead: 0, ahead: 0,
behind: 0, behind: 0,
+5 -1
View File
@@ -632,7 +632,11 @@ test("normalizes historical operational state during offline fallback after rest
rmSync(remote.remote, { recursive: true, force: true }); rmSync(remote.remote, { recursive: true, force: true });
const restored = new WorkspaceRegistry(config(root, remote.remote)); 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 listed = await restored.list();
const read = await restored.read("psd-clinical"); const read = await restored.read("psd-clinical");
expect(listed[0]).not.toHaveProperty("state"); expect(listed[0]).not.toHaveProperty("state");
+27 -1
View File
@@ -4,7 +4,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { afterEach, expect, test } from "vitest"; 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"; import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
const validYaml = `workspace: const validYaml = `workspace:
@@ -102,6 +106,28 @@ test("does not expose repository mutation or publication operations", async () =
expect(repository).not.toHaveProperty("commitAndPush"); 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 () => { test("bootstraps a persistent checkout from a local bare repository", async () => {
const fixture = await temporaryRemote(); const fixture = await temporaryRemote();
const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote)); const repository = new GitWorkspaceRepository(config(join(fixture.root, "registry"), fixture.remote));
+5 -1
View File
@@ -3,6 +3,10 @@
profile: local profile: local
projectDirectory: "<abs>/projects/ThothII" projectDirectory: "<abs>/projects/ThothII"
envFile: "<abs>/projects/ThothII/deploy/psd/operator.env" envFile: "<abs>/projects/ThothII/deploy/psd/operator.env"
workspaceRepository:
remote: git@github.com:mptyl/tht-workspace-psd.git
branch: main
access: ssh
overrides: overrides:
- "<abs>/projects/ThothII/deploy/compose.git-https.yaml" - "<abs>/projects/ThothII/deploy/compose.git-ssh.yaml"
- "<abs>/projects/ThothII/deploy/psd/connector-secrets.yaml" - "<abs>/projects/ThothII/deploy/psd/connector-secrets.yaml"
@@ -3,6 +3,10 @@
profile: local profile: local
projectDirectory: "/absolute/path/to/ThothII" projectDirectory: "/absolute/path/to/ThothII"
envFile: "/absolute/path/to/ThothII/deploy/env/local.env" envFile: "/absolute/path/to/ThothII/deploy/env/local.env"
workspaceRepository:
remote: git@git.example.com:organization/workspaces.git
branch: main
access: ssh
overrides: overrides:
- "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml" - "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml"
- "/absolute/path/to/thothii-operator/connector-secrets.local.yaml" - "/absolute/path/to/thothii-operator/connector-secrets.local.yaml"
@@ -3,6 +3,10 @@
profile: server profile: server
projectDirectory: "/absolute/path/to/ThothII" projectDirectory: "/absolute/path/to/ThothII"
envFile: "/absolute/path/to/thothii-server-operator/server.env" envFile: "/absolute/path/to/thothii-server-operator/server.env"
workspaceRepository:
remote: git@git.example.com:organization/workspaces.git
branch: main
access: ssh
overrides: overrides:
- "/absolute/path/to/ThothII/deploy/compose.session-server.yaml.example" - "/absolute/path/to/ThothII/deploy/compose.session-server.yaml.example"
- "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml" - "/absolute/path/to/ThothII/deploy/compose.git-ssh.yaml"
@@ -7,8 +7,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -31,9 +33,23 @@ type descriptor struct {
Profile string `yaml:"profile"` Profile string `yaml:"profile"`
ProjectDirectory string `yaml:"projectDirectory"` ProjectDirectory string `yaml:"projectDirectory"`
EnvFile string `yaml:"envFile"` EnvFile string `yaml:"envFile"`
WorkspaceRepository workspaceRepositoryDescriptor `yaml:"workspaceRepository"`
Overrides []string `yaml:"overrides"` 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 // Installation is a validated local Compose installation. It intentionally contains paths, not
// environment values or secret content. // environment values or secret content.
type Installation struct { type Installation struct {
@@ -41,6 +57,7 @@ type Installation struct {
Profile string Profile string
ProjectDirectory string ProjectDirectory string
EnvFile string EnvFile string
WorkspaceRepository WorkspaceRepository
Overrides []string Overrides []string
} }
@@ -88,6 +105,11 @@ func Load(path string) (Installation, error) {
Profile: raw.Profile, Profile: raw.Profile,
ProjectDirectory: filepath.Clean(raw.ProjectDirectory), ProjectDirectory: filepath.Clean(raw.ProjectDirectory),
EnvFile: filepath.Clean(raw.EnvFile), EnvFile: filepath.Clean(raw.EnvFile),
WorkspaceRepository: WorkspaceRepository{
Remote: raw.WorkspaceRepository.Remote,
Branch: raw.WorkspaceRepository.Branch,
Access: raw.WorkspaceRepository.Access,
},
Overrides: make([]string, 0, len(raw.Overrides)), Overrides: make([]string, 0, len(raw.Overrides)),
} }
for _, override := range raw.Overrides { for _, override := range raw.Overrides {
@@ -101,6 +123,9 @@ func Load(path string) (Installation, error) {
return Installation{}, err return Installation{}, err
} }
} }
if err := installation.validateWorkspaceRepository(); err != nil {
return Installation{}, err
}
if info, err := os.Lstat(installation.CurrentImageOverridePath()); err == nil { if info, err := os.Lstat(installation.CurrentImageOverridePath()); err == nil {
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return Installation{}, errors.New("installation current-image override must be a regular file") 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 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 // ComposeFiles returns the base file, selected profile file, and declared optional overrides in
// the exact order Compose applies them. // the exact order Compose applies them.
func (i Installation) ComposeFiles() []string { func (i Installation) ComposeFiles() []string {
@@ -51,6 +51,67 @@ func TestLoadSelectsServerComposeFiles(t *testing.T) {
assertStringsEqual(t, installation.ComposeFiles(), want) 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) { func TestComposeArgsAutomaticallyIncludeTheInstallationCurrentImageOverride(t *testing.T) {
t.Parallel() t.Parallel()