feat: add cross-platform thothctl
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
FROM golang:1.24 AS build
|
||||||
|
|
||||||
|
WORKDIR /src/tools/thothctl
|
||||||
|
COPY tools/thothctl/go.mod tools/thothctl/go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY tools/thothctl ./
|
||||||
|
|
||||||
|
RUN mkdir -p /out \
|
||||||
|
&& CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/thothctl-windows-amd64.exe ./cmd/thothctl \
|
||||||
|
&& CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/thothctl-darwin-amd64 ./cmd/thothctl \
|
||||||
|
&& CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o /out/thothctl-darwin-arm64 ./cmd/thothctl \
|
||||||
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o /out/thothctl-linux-amd64 ./cmd/thothctl \
|
||||||
|
&& CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o /out/thothctl-linux-arm64 ./cmd/thothctl
|
||||||
|
|
||||||
|
FROM scratch AS export
|
||||||
|
COPY --from=build /out/ /
|
||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repository_root=$(cd "$(dirname "$0")/.." && pwd)
|
||||||
|
output_directory="$repository_root/dist/thothctl"
|
||||||
|
|
||||||
|
mkdir -p "$output_directory"
|
||||||
|
docker build --file "$repository_root/docker/thothctl.Dockerfile" --output "type=local,dest=$output_directory" "$repository_root"
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// thothctl is the host-side operator command for a local ThothII installation.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
|
||||||
|
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
||||||
|
"github.com/aritmolab/thothii/tools/thothctl/internal/output"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
status Show the Compose service state.
|
||||||
|
doctor Validate Docker, Compose, rendered configuration, line endings, volumes, and health.
|
||||||
|
logs [--follow] Show sanitized service logs (the default is the latest 200 lines).
|
||||||
|
start Start the installation in the background.
|
||||||
|
stop Stop the installation.
|
||||||
|
update --check-only Validate the current installation without changing containers.
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||||
|
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
|
||||||
|
fmt.Fprint(stdout, usage)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
installationPath, command, commandArgs, err := parseArgs(args)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "thothctl: %s\n\n%s", err, usage)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
installation, err := config.Load(installationPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), nil))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
runner := compose.NewRunner("")
|
||||||
|
var result compose.Result
|
||||||
|
switch command {
|
||||||
|
case "status":
|
||||||
|
if len(commandArgs) != 0 {
|
||||||
|
return commandUsageError(stderr, "status does not accept arguments")
|
||||||
|
}
|
||||||
|
result, err = runner.Run(ctx, installation.ComposeArgs("ps", "--format", "json"), nil)
|
||||||
|
case "logs":
|
||||||
|
logArgs, argumentError := logsArgs(commandArgs)
|
||||||
|
if argumentError != nil {
|
||||||
|
return commandUsageError(stderr, argumentError.Error())
|
||||||
|
}
|
||||||
|
result, err = runner.Run(ctx, installation.ComposeArgs(logArgs...), nil)
|
||||||
|
case "start":
|
||||||
|
if len(commandArgs) != 0 {
|
||||||
|
return commandUsageError(stderr, "start does not accept arguments")
|
||||||
|
}
|
||||||
|
result, err = runner.Run(ctx, installation.ComposeArgs("up", "--detach", "--remove-orphans"), nil)
|
||||||
|
case "stop":
|
||||||
|
if len(commandArgs) != 0 {
|
||||||
|
return commandUsageError(stderr, "stop does not accept arguments")
|
||||||
|
}
|
||||||
|
result, err = runner.Run(ctx, installation.ComposeArgs("stop"), nil)
|
||||||
|
case "update":
|
||||||
|
if len(commandArgs) != 1 || commandArgs[0] != "--check-only" {
|
||||||
|
return commandUsageError(stderr, "update currently requires --check-only")
|
||||||
|
}
|
||||||
|
result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
|
||||||
|
case "doctor":
|
||||||
|
if len(commandArgs) != 0 {
|
||||||
|
return commandUsageError(stderr, "doctor does not accept arguments")
|
||||||
|
}
|
||||||
|
return doctor(ctx, installation, runner, stdout, stderr)
|
||||||
|
default:
|
||||||
|
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
||||||
|
}
|
||||||
|
return writeResult(result, err, stdout, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArgs(args []string) (string, string, []string, error) {
|
||||||
|
if len(args) < 3 || args[0] != "--installation" {
|
||||||
|
return "", "", nil, errors.New("--installation <absolute-path> is required before the command")
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(args[1]) {
|
||||||
|
return "", "", nil, errors.New("--installation must be an absolute path")
|
||||||
|
}
|
||||||
|
return args[1], args[2], args[3:], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logsArgs(args []string) ([]string, error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return []string{"logs", "--tail", "200"}, nil
|
||||||
|
}
|
||||||
|
if len(args) == 1 && args[0] == "--follow" {
|
||||||
|
return []string{"logs", "--tail", "200", "--follow"}, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("logs accepts only --follow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandUsageError(stderr io.Writer, message string) int {
|
||||||
|
fmt.Fprintf(stderr, "thothctl: %s\n", message)
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResult(result compose.Result, err error, stdout, stderr io.Writer) int {
|
||||||
|
if result.Stdout != "" {
|
||||||
|
fmt.Fprint(stdout, output.Sanitize(result.Stdout, nil))
|
||||||
|
}
|
||||||
|
if result.Stderr != "" {
|
||||||
|
fmt.Fprint(stderr, output.Sanitize(result.Stderr, nil))
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if errors.Is(err, exec.ErrNotFound) {
|
||||||
|
fmt.Fprintln(stderr, "thothctl: Docker is not installed or is not on PATH")
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return result.ExitCode
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func doctor(ctx context.Context, installation config.Installation, runner compose.Runner, stdout, stderr io.Writer) int {
|
||||||
|
checks := [][]string{
|
||||||
|
{"version", "--format", "{{.Client.Version}}"},
|
||||||
|
{"compose", "version", "--short"},
|
||||||
|
installation.ComposeArgs("config", "--quiet"),
|
||||||
|
installation.ComposeArgs("config", "--format", "json"),
|
||||||
|
installation.ComposeArgs("ps", "--format", "json"),
|
||||||
|
}
|
||||||
|
var renderedConfig, status string
|
||||||
|
for index, args := range checks {
|
||||||
|
result, err := runner.Run(ctx, args, nil)
|
||||||
|
if err != nil {
|
||||||
|
return writeResult(result, err, stdout, stderr)
|
||||||
|
}
|
||||||
|
if index == 3 {
|
||||||
|
renderedConfig = result.Stdout
|
||||||
|
}
|
||||||
|
if index == 4 {
|
||||||
|
status = result.Stdout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := requireLF(installation.ProjectDirectory); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "thothctl: %s\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := requireVolumes(renderedConfig); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "thothctl: %s\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := requireHealthyServices(status); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "thothctl: %s\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stdout, "Doctor checks passed.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireLF(root string) error {
|
||||||
|
return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() || !requiresLF(entry.Name()) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Contains(string(contents), "\r\n") {
|
||||||
|
return fmt.Errorf("CRLF line endings found in %s", filepath.Base(path))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresLF(name string) bool {
|
||||||
|
if name == "Dockerfile" || strings.HasPrefix(name, "Dockerfile.") || strings.HasSuffix(name, ".Dockerfile") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{".sh", ".yml", ".yaml"} {
|
||||||
|
if strings.HasSuffix(name, suffix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireVolumes(renderedConfig string) error {
|
||||||
|
var document struct {
|
||||||
|
Volumes map[string]json.RawMessage `json:"volumes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(renderedConfig), &document); err != nil {
|
||||||
|
return fmt.Errorf("Compose returned invalid rendered configuration")
|
||||||
|
}
|
||||||
|
if len(document.Volumes) == 0 {
|
||||||
|
return errors.New("rendered Compose configuration declares no volumes")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireHealthyServices(status string) error {
|
||||||
|
var services []struct {
|
||||||
|
Service string `json:"Service"`
|
||||||
|
State string `json:"State"`
|
||||||
|
Health string `json:"Health"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(status), &services); err != nil {
|
||||||
|
return errors.New("Compose returned invalid service status")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, service := range services {
|
||||||
|
if service.Service != "core" && service.Service != "frontend" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if service.State != "running" || service.Health != "healthy" {
|
||||||
|
return fmt.Errorf("%s is not healthy", service.Service)
|
||||||
|
}
|
||||||
|
seen[service.Service] = true
|
||||||
|
}
|
||||||
|
for _, service := range []string{"core", "frontend"} {
|
||||||
|
if !seen[service] {
|
||||||
|
return fmt.Errorf("%s service is not running", service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module github.com/aritmolab/thothii/tools/thothctl
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Package compose executes Docker Compose through a fixed executable and argument arrays.
|
||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result is the captured output and process exit code for one Docker invocation.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
ExitCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner executes the Docker CLI. It never invokes a shell.
|
||||||
|
type Runner struct {
|
||||||
|
binary string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner returns a runner for binary. An empty binary selects docker from PATH.
|
||||||
|
func NewRunner(binary string) Runner {
|
||||||
|
if binary == "" {
|
||||||
|
binary = "docker"
|
||||||
|
}
|
||||||
|
return Runner{binary: binary}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run invokes Docker with the supplied argument array and optional standard input.
|
||||||
|
func (r Runner) Run(ctx context.Context, args []string, stdin io.Reader) (Result, error) {
|
||||||
|
command := exec.CommandContext(ctx, r.binary, args...)
|
||||||
|
command.Stdin = stdin
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
command.Stdout = &stdout
|
||||||
|
command.Stderr = &stderr
|
||||||
|
err := command.Run()
|
||||||
|
result := Result{Stdout: stdout.String(), Stderr: stderr.String()}
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if errors.As(err, &exitError) {
|
||||||
|
result.ExitCode = exitError.ExitCode()
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
|
||||||
|
result.ExitCode = 127
|
||||||
|
return result, fmt.Errorf("%w: %w", exec.ErrNotFound, err)
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunnerPassesEachArgumentWithoutShellSplitting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runner := NewRunner(writeExecutable(t, "#!/bin/sh\nprintf '<%s>\\n' \"$@\"\ncat\n"))
|
||||||
|
result, err := runner.Run(context.Background(), []string{"compose", "--project-directory", "/tmp/a project with spaces", "config"}, strings.NewReader("stdin value\n"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run() error = %v", err)
|
||||||
|
}
|
||||||
|
want := "<compose>\n<--project-directory>\n</tmp/a project with spaces>\n<config>\nstdin value\n"
|
||||||
|
if result.Stdout != want {
|
||||||
|
t.Errorf("stdout = %q, want %q", result.Stdout, want)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
t.Errorf("ExitCode = %d, want 0", result.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerReturnsTheChildExitCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runner := NewRunner(writeExecutable(t, "#!/bin/sh\necho unavailable >&2\nexit 42\n"))
|
||||||
|
result, err := runner.Run(context.Background(), []string{"compose", "ps"}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run() error = nil, want child exit error")
|
||||||
|
}
|
||||||
|
if result.ExitCode != 42 {
|
||||||
|
t.Errorf("ExitCode = %d, want 42", result.ExitCode)
|
||||||
|
}
|
||||||
|
if result.Stderr != "unavailable\n" {
|
||||||
|
t.Errorf("stderr = %q, want unavailable output", result.Stderr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunnerReportsMissingDocker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
runner := NewRunner(filepath.Join(t.TempDir(), "docker-does-not-exist"))
|
||||||
|
result, err := runner.Run(context.Background(), []string{"compose", "version"}, nil)
|
||||||
|
if !errors.Is(err, exec.ErrNotFound) {
|
||||||
|
t.Fatalf("Run() error = %v, want exec.ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if result.ExitCode != 127 {
|
||||||
|
t.Errorf("ExitCode = %d, want 127", result.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeExecutable(t *testing.T, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "fake-docker")
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Package output removes credentials from diagnostics before they reach an operator terminal.
|
||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var credentialField = regexp.MustCompile(`(?im)(\b[\w.-]*(?:password|token|key)[\w.-]*\s*[:=]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)`)
|
||||||
|
|
||||||
|
// Sanitize redacts common credential fields and every supplied secret value.
|
||||||
|
func Sanitize(text string, secretValues []string) string {
|
||||||
|
text = credentialField.ReplaceAllString(text, "${1}[REDACTED]")
|
||||||
|
values := append([]string(nil), secretValues...)
|
||||||
|
sort.Slice(values, func(i, j int) bool { return len(values[i]) > len(values[j]) })
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
text = strings.ReplaceAll(text, value, "[REDACTED]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretValuesFromFiles reads non-empty secret-file contents without exposing them to callers.
|
||||||
|
func SecretValuesFromFiles(paths []string) ([]string, error) {
|
||||||
|
values := make([]string, 0, len(paths))
|
||||||
|
for _, path := range paths {
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read secret file: %w", err)
|
||||||
|
}
|
||||||
|
if value := strings.TrimSpace(string(contents)); value != "" {
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package output
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSanitizeRedactsPasswordTokenAndKeyFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := Sanitize("DB_PASSWORD=hunter2\naccess_token: abc123\napi-key = quoted-value\nplain=safe\n", nil)
|
||||||
|
want := "DB_PASSWORD=[REDACTED]\naccess_token: [REDACTED]\napi-key = [REDACTED]\nplain=safe\n"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Sanitize() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeRedactsSecretFileContents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
secretFile := filepath.Join(t.TempDir(), "provider-token")
|
||||||
|
if err := os.WriteFile(secretFile, []byte("top-secret-value\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets, err := SecretValuesFromFiles([]string{secretFile})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SecretValuesFromFiles() error = %v", err)
|
||||||
|
}
|
||||||
|
got := Sanitize("request failed for top-secret-value", secrets)
|
||||||
|
if got != "request failed for [REDACTED]" {
|
||||||
|
t.Errorf("Sanitize() = %q, want redacted secret", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user