feat: add cross-platform thothctl
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadSelectsLocalComposeFilesForAnInstallationInPathsWithSpaces(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
installationPath, projectDirectory, envFile, override := writeInstallation(t, "local")
|
||||
installation, err := Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if installation.ProjectDirectory != projectDirectory {
|
||||
t.Errorf("ProjectDirectory = %q, want %q", installation.ProjectDirectory, projectDirectory)
|
||||
}
|
||||
if installation.EnvFile != envFile {
|
||||
t.Errorf("EnvFile = %q, want %q", installation.EnvFile, envFile)
|
||||
}
|
||||
if !strings.Contains(installationPath, "installation folder with spaces") {
|
||||
t.Fatalf("test setup must exercise a path with spaces: %q", installationPath)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.local.yaml"),
|
||||
override,
|
||||
}
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
|
||||
func TestLoadSelectsServerComposeFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
installationPath, projectDirectory, _, override := writeInstallation(t, "server")
|
||||
installation, err := Load(installationPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
filepath.Join(projectDirectory, "compose.yaml"),
|
||||
filepath.Join(projectDirectory, "deploy", "compose.server.yaml"),
|
||||
override,
|
||||
}
|
||||
assertStringsEqual(t, installation.ComposeFiles(), want)
|
||||
}
|
||||
|
||||
func TestLoadRejectsRelativeInstallationPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := Load("thothii-installation.yaml")
|
||||
if err == nil || !strings.Contains(err.Error(), "absolute") {
|
||||
t.Fatalf("Load() error = %v, want an absolute-path error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
|
||||
t.Helper()
|
||||
|
||||
root := filepath.Join(t.TempDir(), "installation folder with spaces")
|
||||
projectDirectory := filepath.Join(root, "project directory with spaces")
|
||||
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"compose.yaml", filepath.Join("deploy", "compose.local.yaml"), filepath.Join("deploy", "compose.server.yaml")} {
|
||||
if err := os.WriteFile(filepath.Join(projectDirectory, name), []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
envFile := filepath.Join(root, "environment file.env")
|
||||
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
override := filepath.Join(root, "extra override.yaml")
|
||||
if err := os.WriteFile(override, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installationPath := filepath.Join(root, "thothii-installation.yaml")
|
||||
contents := "profile: " + profile + "\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\noverrides:\n - " + override + "\n"
|
||||
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return installationPath, projectDirectory, envFile, override
|
||||
}
|
||||
|
||||
func assertStringsEqual(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("length = %d, want %d: got %#v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("value[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user