refactor(cli): rename operator command to tht
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user