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
+116 -10
View File
@@ -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 {