217 lines
6.6 KiB
Go
217 lines
6.6 KiB
Go
// 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
|
|
}
|