Files
ThothII/tools/thothctl/internal/config/installation.go
T

236 lines
6.9 KiB
Go

// Package config loads the non-secret, local installation descriptor used by thothctl.
package config
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"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
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
}
}
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 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{}{}
}
}
return files, 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
}