refactor(cli): rename operator command to tht

This commit is contained in:
2026-08-15 21:56:40 +02:00
parent 460caa550c
commit aa8a2e9278
49 changed files with 303 additions and 261 deletions
+469
View File
@@ -0,0 +1,469 @@
// Package config loads the non-secret, local installation descriptor used by tht.
package config
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
const installationFileName = "thothii-installation.yaml"
const maxEnvironmentFileBytes = 1 << 20
const maxSecretSources = 32
var dotenvParseMu sync.Mutex
type descriptor struct {
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
WorkspaceRepository WorkspaceRepository
Overrides []string
}
// Load reads and validates an installation descriptor at an absolute path.
func Load(path string) (Installation, error) {
if !filepath.IsAbs(path) {
return Installation{}, fmt.Errorf("installation path must be absolute")
}
path = filepath.Clean(path)
if filepath.Base(path) != installationFileName {
return Installation{}, fmt.Errorf("installation file must be named %s", installationFileName)
}
if err := requireRegularFile(path, "installation file"); err != nil {
return Installation{}, err
}
file, err := os.Open(path)
if err != nil {
return Installation{}, fmt.Errorf("open installation file: %w", err)
}
defer file.Close()
var raw descriptor
decoder := yaml.NewDecoder(file)
decoder.KnownFields(true)
if err := decoder.Decode(&raw); err != nil {
return Installation{}, fmt.Errorf("read installation file: %w", err)
}
if err := ensureOnlyOneDocument(decoder); err != nil {
return Installation{}, err
}
if raw.Profile != "local" && raw.Profile != "server" {
return Installation{}, fmt.Errorf("profile must be local or server")
}
if err := requireDirectory(raw.ProjectDirectory, "projectDirectory"); err != nil {
return Installation{}, err
}
if err := requireRegularFile(raw.EnvFile, "envFile"); err != nil {
return Installation{}, err
}
installation := Installation{
Path: path,
Profile: raw.Profile,
ProjectDirectory: filepath.Clean(raw.ProjectDirectory),
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)),
}
for _, override := range raw.Overrides {
if err := requireRegularFile(override, "override"); err != nil {
return Installation{}, err
}
installation.Overrides = append(installation.Overrides, filepath.Clean(override))
}
for _, composeFile := range installation.ComposeFiles()[:2] {
if err := requireRegularFile(composeFile, "Compose file"); err != nil {
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")
}
} else if !errors.Is(err, os.ErrNotExist) {
return Installation{}, errors.New("installation current-image override could not be inspected")
}
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 {
files := []string{
filepath.Join(i.ProjectDirectory, "compose.yaml"),
filepath.Join(i.ProjectDirectory, "deploy", "compose."+i.Profile+".yaml"),
}
files = append(files, i.Overrides...)
currentImage := i.CurrentImageOverridePath()
if info, err := os.Lstat(currentImage); err == nil && info.Mode().IsRegular() {
files = append(files, currentImage)
}
return files
}
// ControlDirectory contains state that is private to one installation descriptor, even when
// multiple installations intentionally share one source checkout.
func (i Installation) ControlDirectory() string {
return filepath.Join(i.ProjectDirectory, ".tht", i.ProjectName())
}
func (i Installation) CurrentImageOverridePath() string {
return filepath.Join(i.ControlDirectory(), "current-image.yaml")
}
func (i Installation) UpdateStatePath() string {
return filepath.Join(i.ControlDirectory(), "update-state.json")
}
func (i Installation) RestartStatePath() string {
return filepath.Join(i.ControlDirectory(), "restart-state.json")
}
// ProjectName is stable for one installation and avoids collisions between different checkouts.
func (i Installation) ProjectName() string {
sum := sha256.Sum256([]byte(i.Path))
return fmt.Sprintf("thothii-%x", sum[:6])
}
// ComposeArgs builds Docker Compose arguments without shell quoting or interpolation.
func (i Installation) ComposeArgs(command ...string) []string {
return i.composeArgs(i.ComposeFiles(), command...)
}
// ComposeArgsWithFinalOverride appends one validated, generated override after every durable
// installation selector and before the Compose command.
func (i Installation) ComposeArgsWithFinalOverride(override string, command ...string) ([]string, error) {
if filepath.Clean(override) != override || !filepath.IsAbs(override) {
return nil, errors.New("final Compose override must be an absolute canonical path")
}
if err := requireRegularFile(override, "final Compose override"); err != nil {
return nil, err
}
files := append(i.ComposeFiles(), override)
return i.composeArgs(files, command...), nil
}
func (i Installation) composeArgs(files []string, command ...string) []string {
args := []string{"compose", "--project-name", i.ProjectName(), "--project-directory", i.ProjectDirectory, "--env-file", i.EnvFile}
for _, composeFile := range files {
args = append(args, "-f", composeFile)
}
return append(args, command...)
}
// SecretFiles returns canonical local secret paths declared through *_FILE or *_SOURCE variables.
// Compose's dotenv parser resolves comments, quotes, escapes, and interpolation. Unsupported or
// unresolved source interpolation is rejected before tht invokes Docker.
func (i Installation) SecretFiles() ([]string, error) {
contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes)
if err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
values, err := parseComposeDotenv(contents)
if err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
files := make([]string, 0, len(values))
seen := make(map[string]struct{})
for key, value := range values {
key = strings.ToUpper(key)
if !strings.HasSuffix(key, "_FILE") && !strings.HasSuffix(key, "_SOURCE") {
continue
}
if err := safeio.ValidateCanonicalPath(value); err != nil {
return nil, errors.New("installation secret declarations could not be read")
}
if _, exists := seen[value]; !exists {
files = append(files, value)
seen[value] = struct{}{}
if len(files) > maxSecretSources {
return nil, errors.New("installation secret declarations could not be read")
}
}
}
sort.Strings(files)
return files, nil
}
// EnvironmentValue returns one declared installation value without exposing dotenv parsing to
// callers. It is used only for operator-visible file locations, never for secret content.
func (i Installation) EnvironmentValue(name string) (string, error) {
values, err := i.environmentValues()
if err != nil {
return "", err
}
return values[name], nil
}
func (i Installation) environmentValues() (map[string]string, error) {
contents, err := safeio.ReadCanonicalRegular(i.EnvFile, maxEnvironmentFileBytes)
if err != nil {
return nil, errors.New("installation environment could not be read")
}
values, err := parseComposeDotenv(contents)
if err != nil {
return nil, errors.New("installation environment could not be read")
}
return values, nil
}
// PreservationPaths returns the server bind roots, backup root, and declared secret files whose
// filesystem identities must survive a data-preserving removal.
func (i Installation) PreservationPaths() ([]string, error) {
if i.Profile != "server" {
return nil, errors.New("data-preserving removal requires a server installation")
}
values, err := i.environmentValues()
if err != nil {
return nil, err
}
paths := make([]string, 0)
seen := make(map[string]struct{})
for _, name := range []string{
"THT_DATA_ROOT", "THT_PI_STATE_ROOT", "THT_WORKSPACE_REGISTRY_ROOT", "THT_BACKUP_ROOT",
} {
path := values[name]
if err := requireCanonicalDirectory(path); err != nil {
return nil, fmt.Errorf("%s must identify an existing canonical directory", name)
}
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
secretFiles, err := i.SecretFiles()
if err != nil {
return nil, err
}
for _, path := range secretFiles {
if _, exists := seen[path]; !exists {
paths = append(paths, path)
seen[path] = struct{}{}
}
}
return paths, nil
}
func requireCanonicalDirectory(path string) error {
if err := safeio.ValidateCanonicalPath(path); err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || resolved != path {
return errors.New("directory path is unavailable or contains a symlink")
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return errors.New("directory path is unavailable")
}
return nil
}
func parseComposeDotenv(contents []byte) (map[string]string, error) {
dotenvParseMu.Lock()
defer dotenvParseMu.Unlock()
logger := logrus.StandardLogger()
previousOutput := logger.Out
previousHooks := logger.ReplaceHooks(make(logrus.LevelHooks))
logger.SetOutput(io.Discard)
warnings := &dotenvWarnings{}
logger.AddHook(warnings)
defer func() {
logger.SetOutput(previousOutput)
logger.ReplaceHooks(previousHooks)
}()
values, err := dotenv.ParseWithLookup(bytes.NewReader(contents), os.LookupEnv)
if err != nil || warnings.seen {
return nil, errors.New("dotenv parsing failed")
}
return values, nil
}
type dotenvWarnings struct {
seen bool
}
func (w *dotenvWarnings) Levels() []logrus.Level {
return logrus.AllLevels
}
func (w *dotenvWarnings) Fire(entry *logrus.Entry) error {
if entry.Level == logrus.WarnLevel {
w.seen = true
}
return nil
}
func ensureOnlyOneDocument(decoder *yaml.Decoder) error {
var extra any
err := decoder.Decode(&extra)
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return fmt.Errorf("read installation file: %w", err)
}
return fmt.Errorf("installation file must contain one YAML document")
}
func requireDirectory(path, field string) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("%s must be an absolute path", field)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s is unavailable: %w", field, err)
}
if !info.IsDir() {
return fmt.Errorf("%s must be a directory", field)
}
return nil
}
func requireRegularFile(path, field string) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("%s must be an absolute path", field)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("%s is unavailable: %w", field, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s must be a regular file", field)
}
return nil
}
@@ -0,0 +1,309 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadSelectsLocalComposeFilesForAnInstallationInPathsWithSpaces(t *testing.T) {
t.Parallel()
installationPath, projectDirectory, envFile, override := writeInstallation(t, "local")
installation, err := Load(installationPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if installation.ProjectDirectory != projectDirectory {
t.Errorf("ProjectDirectory = %q, want %q", installation.ProjectDirectory, projectDirectory)
}
if installation.EnvFile != envFile {
t.Errorf("EnvFile = %q, want %q", installation.EnvFile, envFile)
}
if !strings.Contains(installationPath, "installation folder with spaces") {
t.Fatalf("test setup must exercise a path with spaces: %q", installationPath)
}
want := []string{
filepath.Join(projectDirectory, "compose.yaml"),
filepath.Join(projectDirectory, "deploy", "compose.local.yaml"),
override,
}
assertStringsEqual(t, installation.ComposeFiles(), want)
}
func TestLoadSelectsServerComposeFiles(t *testing.T) {
t.Parallel()
installationPath, projectDirectory, _, override := writeInstallation(t, "server")
installation, err := Load(installationPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
want := []string{
filepath.Join(projectDirectory, "compose.yaml"),
filepath.Join(projectDirectory, "deploy", "compose.server.yaml"),
override,
}
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()
installationPath, _, _, _ := writeInstallation(t, "local")
seed, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
currentImage := seed.CurrentImageOverridePath()
if err := os.MkdirAll(filepath.Dir(currentImage), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(currentImage, []byte("services:\n core:\n image: candidate\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
args := installation.ComposeArgs("up", "--detach")
want := []string{"-f", currentImage, "up", "--detach"}
if !containsSequence(args, want) {
t.Fatalf("ComposeArgs() = %#v, want durable override immediately before command", args)
}
}
func TestComposeArgsWithFinalOverridePreservesCurrentImagePrecedence(t *testing.T) {
installationPath, _, _, _ := writeInstallation(t, "server")
seed, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(seed.ControlDirectory(), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(seed.CurrentImageOverridePath(), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
final := filepath.Join(seed.ControlDirectory(), "migration.yaml")
if err := os.WriteFile(final, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
args, err := installation.ComposeArgsWithFinalOverride(final, "--profile", "session-migrate", "config")
if err != nil {
t.Fatal(err)
}
want := []string{"-f", installation.CurrentImageOverridePath(), "-f", final, "--profile", "session-migrate", "config"}
if !containsSequence(args, want) {
t.Fatalf("ComposeArgsWithFinalOverride() = %#v, want %#v", args, want)
}
}
func TestPreservationPathsReturnsCanonicalBindRootsBackupsAndSecretFiles(t *testing.T) {
installationPath, _, envFile, _ := writeInstallation(t, "server")
root := filepath.Dir(envFile)
var wanted []string
var lines []string
for _, item := range []struct{ key, name string }{
{"THT_DATA_ROOT", "data"},
{"THT_PI_STATE_ROOT", "pi-state"},
{"THT_WORKSPACE_REGISTRY_ROOT", "workspace-registry"},
{"THT_BACKUP_ROOT", "backups"},
} {
path := filepath.Join(root, item.name)
if err := os.Mkdir(path, 0o700); err != nil {
t.Fatal(err)
}
wanted = append(wanted, path)
lines = append(lines, item.key+"="+path)
}
secret := filepath.Join(root, "secret")
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
t.Fatal(err)
}
wanted = append(wanted, secret)
lines = append(lines, "APP_TOKEN_FILE="+secret)
if err := os.WriteFile(envFile, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
installation, err := Load(installationPath)
if err != nil {
t.Fatal(err)
}
got, err := installation.PreservationPaths()
if err != nil {
t.Fatal(err)
}
assertStringsEqual(t, got, wanted)
}
func TestInstallationControlPathsAreIsolatedForDescriptorsSharingOneCheckout(t *testing.T) {
projectDirectory := t.TempDir()
first := Installation{Path: filepath.Join(t.TempDir(), installationFileName), ProjectDirectory: projectDirectory}
second := Installation{Path: filepath.Join(t.TempDir(), installationFileName), ProjectDirectory: projectDirectory}
if first.CurrentImageOverridePath() == second.CurrentImageOverridePath() {
t.Fatalf("shared-checkout installations reused %q", first.CurrentImageOverridePath())
}
for _, installation := range []Installation{first, second} {
if filepath.Dir(filepath.Dir(installation.CurrentImageOverridePath())) != filepath.Join(projectDirectory, ".tht") {
t.Fatalf("current-image path %q is not installation-specific under .tht", installation.CurrentImageOverridePath())
}
if filepath.Dir(installation.UpdateStatePath()) != filepath.Dir(installation.CurrentImageOverridePath()) {
t.Fatalf("state %q and selector %q do not share one installation control directory", installation.UpdateStatePath(), installation.CurrentImageOverridePath())
}
if got, want := installation.RestartStatePath(), filepath.Join(installation.ControlDirectory(), "restart-state.json"); got != want {
t.Fatalf("RestartStatePath() = %q, want %q", got, want)
}
}
}
func TestLoadRejectsRelativeInstallationPaths(t *testing.T) {
t.Parallel()
_, err := Load("thothii-installation.yaml")
if err == nil || !strings.Contains(err.Error(), "absolute") {
t.Fatalf("Load() error = %v, want an absolute-path error", err)
}
}
func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
t.Helper()
temporaryRoot, err := filepath.EvalSymlinks(os.TempDir())
if err != nil {
t.Fatal(err)
}
physicalRoot, err := os.MkdirTemp(temporaryRoot, "tht-config-")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.RemoveAll(physicalRoot) })
root := filepath.Join(physicalRoot, "installation folder with spaces")
projectDirectory := filepath.Join(root, "project directory with spaces")
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"compose.yaml", filepath.Join("deploy", "compose.local.yaml"), filepath.Join("deploy", "compose.server.yaml")} {
if err := os.WriteFile(filepath.Join(projectDirectory, name), []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
}
envFile := filepath.Join(root, "environment file.env")
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\n"), 0o600); err != nil {
t.Fatal(err)
}
override := filepath.Join(root, "extra override.yaml")
if err := os.WriteFile(override, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
installationPath := filepath.Join(root, "thothii-installation.yaml")
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\noverrides:\n - " + override + "\n"
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return installationPath, projectDirectory, envFile, override
}
func assertStringsEqual(t *testing.T, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("length = %d, want %d: got %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("value[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func containsSequence(values, wanted []string) bool {
for start := range values {
if len(values)-start < len(wanted) {
continue
}
matched := true
for offset := range wanted {
if values[start+offset] != wanted[offset] {
matched = false
break
}
}
if matched {
return true
}
}
return false
}