// Package config loads the non-secret, local installation descriptor used by thothctl. package config import ( "crypto/sha256" "errors" "fmt" "io" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) const installationFileName = "thothii-installation.yaml" const maxEnvironmentFileBytes = 1 << 20 type descriptor struct { Profile string `yaml:"profile"` ProjectDirectory string `yaml:"projectDirectory"` EnvFile string `yaml:"envFile"` Overrides []string `yaml:"overrides"` } // 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 } // 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), 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 } } return installation, 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"), } return append(files, i.Overrides...) } // 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 { args := []string{"compose", "--project-name", i.ProjectName(), "--project-directory", i.ProjectDirectory, "--env-file", i.EnvFile} for _, composeFile := range i.ComposeFiles() { args = append(args, "-f", composeFile) } return append(args, command...) } // SecretFiles returns only existing, absolute regular files declared in the installation env file // through *_FILE or *_SOURCE variables. Missing paths are allowed because /run/secrets paths are // container-local declarations, not host files thothctl can read. func (i Installation) SecretFiles() ([]string, error) { info, err := os.Stat(i.EnvFile) if err != nil || info.Size() > maxEnvironmentFileBytes { return nil, errors.New("installation secret declarations could not be read") } contents, err := os.ReadFile(i.EnvFile) if err != nil || len(contents) > maxEnvironmentFileBytes { return nil, errors.New("installation secret declarations could not be read") } files := make([]string, 0) seen := make(map[string]struct{}) for _, line := range strings.Split(string(contents), "\n") { key, value, ok := environmentAssignment(line) if !ok || (!strings.HasSuffix(key, "_FILE") && !strings.HasSuffix(key, "_SOURCE")) || !filepath.IsAbs(value) { continue } fileInfo, err := os.Lstat(value) if errors.Is(err, os.ErrNotExist) { continue } if err != nil || !fileInfo.Mode().IsRegular() { return nil, errors.New("installation secret declarations could not be read") } if _, exists := seen[value]; !exists { files = append(files, value) seen[value] = struct{}{} } } return files, nil } func environmentAssignment(line string) (string, string, bool) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { return "", "", false } line = strings.TrimPrefix(line, "export ") key, value, found := strings.Cut(line, "=") if !found { return "", "", false } key = strings.TrimSpace(key) if key == "" { return "", "", false } value = strings.TrimSpace(value) if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) { value = value[1 : len(value)-1] } return strings.ToUpper(key), value, true } 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 }