From 853a15179602965d8dd4f11fc536ae98bbae0a5d Mon Sep 17 00:00:00 2001 From: mptyl Date: Tue, 4 Aug 2026 16:48:40 +0200 Subject: [PATCH] feat: add cross-platform thothctl --- docker/thothctl.Dockerfile | 16 ++ scripts/build-thothctl.sh | 8 + tools/thothctl/cmd/thothctl/main.go | 241 ++++++++++++++++++ tools/thothctl/go.mod | 5 + tools/thothctl/go.sum | 4 + tools/thothctl/internal/compose/runner.go | 56 ++++ .../thothctl/internal/compose/runner_test.go | 66 +++++ .../thothctl/internal/config/installation.go | 157 ++++++++++++ .../internal/config/installation_test.go | 102 ++++++++ tools/thothctl/internal/output/sanitize.go | 40 +++ .../thothctl/internal/output/sanitize_test.go | 35 +++ 11 files changed, 730 insertions(+) create mode 100644 docker/thothctl.Dockerfile create mode 100755 scripts/build-thothctl.sh create mode 100644 tools/thothctl/cmd/thothctl/main.go create mode 100644 tools/thothctl/go.mod create mode 100644 tools/thothctl/go.sum create mode 100644 tools/thothctl/internal/compose/runner.go create mode 100644 tools/thothctl/internal/compose/runner_test.go create mode 100644 tools/thothctl/internal/config/installation.go create mode 100644 tools/thothctl/internal/config/installation_test.go create mode 100644 tools/thothctl/internal/output/sanitize.go create mode 100644 tools/thothctl/internal/output/sanitize_test.go diff --git a/docker/thothctl.Dockerfile b/docker/thothctl.Dockerfile new file mode 100644 index 00000000..84acc4b7 --- /dev/null +++ b/docker/thothctl.Dockerfile @@ -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/ / diff --git a/scripts/build-thothctl.sh b/scripts/build-thothctl.sh new file mode 100755 index 00000000..cd03a16d --- /dev/null +++ b/scripts/build-thothctl.sh @@ -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" diff --git a/tools/thothctl/cmd/thothctl/main.go b/tools/thothctl/cmd/thothctl/main.go new file mode 100644 index 00000000..eab2a388 --- /dev/null +++ b/tools/thothctl/cmd/thothctl/main.go @@ -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 /thothii-installation.yaml + +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 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 +} diff --git a/tools/thothctl/go.mod b/tools/thothctl/go.mod new file mode 100644 index 00000000..33346ed5 --- /dev/null +++ b/tools/thothctl/go.mod @@ -0,0 +1,5 @@ +module github.com/aritmolab/thothii/tools/thothctl + +go 1.24 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/tools/thothctl/go.sum b/tools/thothctl/go.sum new file mode 100644 index 00000000..a62c313c --- /dev/null +++ b/tools/thothctl/go.sum @@ -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= diff --git a/tools/thothctl/internal/compose/runner.go b/tools/thothctl/internal/compose/runner.go new file mode 100644 index 00000000..5d34e273 --- /dev/null +++ b/tools/thothctl/internal/compose/runner.go @@ -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 +} diff --git a/tools/thothctl/internal/compose/runner_test.go b/tools/thothctl/internal/compose/runner_test.go new file mode 100644 index 00000000..f1467e9c --- /dev/null +++ b/tools/thothctl/internal/compose/runner_test.go @@ -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 := "\n<--project-directory>\n\n\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 +} diff --git a/tools/thothctl/internal/config/installation.go b/tools/thothctl/internal/config/installation.go new file mode 100644 index 00000000..505c6378 --- /dev/null +++ b/tools/thothctl/internal/config/installation.go @@ -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 +} diff --git a/tools/thothctl/internal/config/installation_test.go b/tools/thothctl/internal/config/installation_test.go new file mode 100644 index 00000000..93b20e44 --- /dev/null +++ b/tools/thothctl/internal/config/installation_test.go @@ -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]) + } + } +} diff --git a/tools/thothctl/internal/output/sanitize.go b/tools/thothctl/internal/output/sanitize.go new file mode 100644 index 00000000..11c988ae --- /dev/null +++ b/tools/thothctl/internal/output/sanitize.go @@ -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 +} diff --git a/tools/thothctl/internal/output/sanitize_test.go b/tools/thothctl/internal/output/sanitize_test.go new file mode 100644 index 00000000..c67a2887 --- /dev/null +++ b/tools/thothctl/internal/output/sanitize_test.go @@ -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) + } +}