// Package config loads the non-secret, local installation descriptor used by thothctl. package config import ( "crypto/sha256" "errors" "fmt" "io" "os" "path/filepath" "gopkg.in/yaml.v3" ) const installationFileName = "thothii-installation.yaml" 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...) } 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 }