// Package config loads the non-secret, local installation descriptor used by thothctl. package config import ( "bytes" "crypto/sha256" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings" "sync" "github.com/aritmolab/thothii/tools/thothctl/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"` 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 } } 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 } // 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, ".thothctl", 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") } // 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 thothctl 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 }