fix: harden thothctl diagnostics

This commit is contained in:
2026-08-04 17:00:05 +02:00
parent 853a151796
commit 4158990c10
7 changed files with 351 additions and 12 deletions
+17 -7
View File
@@ -47,6 +47,16 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), nil))
return 2
}
secretFiles, err := installation.SecretFiles()
if err != nil {
fmt.Fprintln(stderr, "thothctl: installation secret declarations could not be read")
return 2
}
secretValues, err := output.SecretValuesFromFiles(secretFiles)
if err != nil {
fmt.Fprintln(stderr, "thothctl: declared secret file could not be read")
return 2
}
runner := compose.NewRunner("")
var result compose.Result
@@ -81,11 +91,11 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(commandArgs) != 0 {
return commandUsageError(stderr, "doctor does not accept arguments")
}
return doctor(ctx, installation, runner, stdout, stderr)
return doctor(ctx, installation, runner, secretValues, stdout, stderr)
default:
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
return writeResult(result, err, stdout, stderr)
return writeResult(result, err, secretValues, stdout, stderr)
}
func parseArgs(args []string) (string, string, []string, error) {
@@ -113,12 +123,12 @@ func commandUsageError(stderr io.Writer, message string) int {
return 2
}
func writeResult(result compose.Result, err error, stdout, stderr io.Writer) int {
func writeResult(result compose.Result, err error, secretValues []string, stdout, stderr io.Writer) int {
if result.Stdout != "" {
fmt.Fprint(stdout, output.Sanitize(result.Stdout, nil))
fmt.Fprint(stdout, output.Sanitize(result.Stdout, secretValues))
}
if result.Stderr != "" {
fmt.Fprint(stderr, output.Sanitize(result.Stderr, nil))
fmt.Fprint(stderr, output.Sanitize(result.Stderr, secretValues))
}
if err == nil {
return 0
@@ -132,7 +142,7 @@ func writeResult(result compose.Result, err error, stdout, stderr io.Writer) int
return 1
}
func doctor(ctx context.Context, installation config.Installation, runner compose.Runner, stdout, stderr io.Writer) int {
func doctor(ctx context.Context, installation config.Installation, runner compose.Runner, secretValues []string, stdout, stderr io.Writer) int {
checks := [][]string{
{"version", "--format", "{{.Client.Version}}"},
{"compose", "version", "--short"},
@@ -144,7 +154,7 @@ func doctor(ctx context.Context, installation config.Installation, runner compos
for index, args := range checks {
result, err := runner.Run(ctx, args, nil)
if err != nil {
return writeResult(result, err, stdout, stderr)
return writeResult(result, err, secretValues, stdout, stderr)
}
if index == 3 {
renderedConfig = result.Stdout
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunLogsRedactsAnUnlabelledDeclaredSecret(t *testing.T) {
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
secretPath := filepath.Join(fixture.root, "operator-secret")
if err := os.WriteFile(secretPath, []byte("unlabelled-secret\r\n"), 0o600); err != nil {
t.Fatal(err)
}
fixture.setEnvironment(t, secretPath)
t.Setenv("THOTHCTL_FAKE_LOG", "fake Docker log: unlabelled-secret")
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
}
if strings.Contains(stdout.String(), "unlabelled-secret") {
t.Fatalf("logs exposed an unlabelled secret: %q", stdout.String())
}
if stdout.String() != "fake Docker log: [REDACTED]\n" {
t.Errorf("logs = %q, want redacted output", stdout.String())
}
}
func TestRunStatusUsesStableComposeArguments(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
for range 2 {
var stdout, stderr bytes.Buffer
if exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "status"}, &stdout, &stderr); exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
}
}
invocations := fixture.invocations(t)
if len(invocations) != 2 {
t.Fatalf("docker invocations = %d, want 2", len(invocations))
}
if strings.Join(invocations[0], "\x00") != strings.Join(invocations[1], "\x00") {
t.Errorf("Compose arguments changed between identical status calls: %#v then %#v", invocations[0], invocations[1])
}
wantSuffix := []string{
"--project-directory", fixture.projectDirectory,
"--env-file", fixture.envFile,
"-f", filepath.Join(fixture.projectDirectory, "compose.yaml"),
"-f", filepath.Join(fixture.projectDirectory, "deploy", "compose.local.yaml"),
"ps", "--format", "json",
}
got := invocations[0]
if len(got) != len(wantSuffix)+3 || got[0] != "compose" || got[1] != "--project-name" || !strings.HasPrefix(got[2], "thothii-") {
t.Fatalf("unexpected Compose prefix: %#v", got)
}
for index, want := range wantSuffix {
if got[index+3] != want {
t.Errorf("argument %d = %q, want %q", index+3, got[index+3], want)
}
}
}
func TestRunExplainsWhenDockerIsNotAvailable(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
t.Setenv("PATH", t.TempDir())
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "status"}, &stdout, &stderr)
if exitCode != 127 {
t.Errorf("run() exit code = %d, want 127", exitCode)
}
if !strings.Contains(stderr.String(), "Docker is not installed or is not on PATH") {
t.Errorf("stderr = %q, want Docker-not-found guidance", stderr.String())
}
if strings.Contains(stderr.String(), "executable file") {
t.Errorf("stderr leaked a process implementation detail: %q", stderr.String())
}
}
func TestRunDoctorValidatesTheRenderedInstallation(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
}
if stdout.String() != "Doctor checks passed.\n" {
t.Errorf("stdout = %q, want doctor success", stdout.String())
}
}
func TestRunPreservesChildExitCodes(t *testing.T) {
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
fixture.setEnvironment(t)
t.Setenv("THOTHCTL_FAKE_EXIT", "42")
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "update", "--check-only"}, &stdout, &stderr)
if exitCode != 42 {
t.Errorf("run() exit code = %d, want 42", exitCode)
}
if stderr.String() != "fake Docker failure\n" {
t.Errorf("stderr = %q, want child stderr", stderr.String())
}
}
type cliFixture struct {
root string
installationPath string
projectDirectory string
envFile string
argsFile string
pathDirectory string
envTemplate string
}
func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
t.Helper()
root := t.TempDir()
projectDirectory := filepath.Join(root, "project")
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(projectDirectory, "compose.yaml"), filepath.Join(projectDirectory, "deploy", "compose.local.yaml")} {
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
t.Fatal(err)
}
}
envFile := filepath.Join(root, "installation.env")
installationPath := filepath.Join(root, "thothii-installation.yaml")
contents := "profile: local\nprojectDirectory: " + projectDirectory + "\nenvFile: " + envFile + "\n"
if err := os.WriteFile(installationPath, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
pathDirectory := filepath.Join(root, "bin")
if err := os.Mkdir(pathDirectory, 0o755); err != nil {
t.Fatal(err)
}
argsFile := filepath.Join(root, "docker-args")
fakeDocker := `#!/bin/sh
printf '%s\n' "$@" >> "$THOTHCTL_FAKE_ARGS"
printf '%s\n' -- >> "$THOTHCTL_FAKE_ARGS"
case " $* " in
*" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}}}' ;;
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
esac
if [ "${THOTHCTL_FAKE_EXIT:-0}" -ne 0 ]; then
printf '%s\n' 'fake Docker failure' >&2
fi
exit "${THOTHCTL_FAKE_EXIT:-0}"
`
if err := os.WriteFile(filepath.Join(pathDirectory, "docker"), []byte(fakeDocker), 0o700); err != nil {
t.Fatal(err)
}
return cliFixture{root: root, installationPath: installationPath, projectDirectory: projectDirectory, envFile: envFile, argsFile: argsFile, pathDirectory: pathDirectory, envTemplate: envTemplate}
}
func (f cliFixture) setEnvironment(t *testing.T, values ...string) {
t.Helper()
env := f.envTemplate
if len(values) > 0 {
env = strings.Replace(env, "%s", values[0], 1)
}
if err := os.WriteFile(f.envFile, []byte(env), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", f.pathDirectory)
t.Setenv("THOTHCTL_FAKE_ARGS", f.argsFile)
t.Setenv("THOTHCTL_FAKE_EXIT", "0")
t.Setenv("THOTHCTL_FAKE_LOG", "")
}
func (f cliFixture) invocations(t *testing.T) [][]string {
t.Helper()
contents, err := os.ReadFile(f.argsFile)
if err != nil {
t.Fatal(err)
}
var invocations [][]string
var invocation []string
for _, line := range strings.Split(strings.TrimSuffix(string(contents), "\n"), "\n") {
if line == "--" {
invocations = append(invocations, invocation)
invocation = nil
continue
}
invocation = append(invocation, line)
}
return invocations
}