package compose import ( "context" "errors" "io" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/aritmolab/thothii/tools/tht/internal/config" ) 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 TestRunnerBoundsFloodingOutputDuringCapture(t *testing.T) { t.Parallel() runner := NewRunner(writeExecutable(t, "#!/bin/sh\nwhile :; do printf '0123456789abcdef'; printf 'fedcba9876543210' >&2; done\n")) started := time.Now() result, err := RunBounded(runner, context.Background(), []string{"compose", "run", "--rm", "core"}, nil, CaptureLimits{ StdoutBytes: 1024, StderrBytes: 1024, }) if !errors.Is(err, ErrOutputLimit) { t.Fatalf("RunBounded() error = %v, want ErrOutputLimit", err) } if len(result.Stdout) > 1024 || len(result.Stderr) > 1024 { t.Fatalf("captured output exceeded limits: stdout=%d stderr=%d", len(result.Stdout), len(result.Stderr)) } if elapsed := time.Since(started); elapsed > 5*time.Second { t.Fatalf("overflow teardown took %s", elapsed) } } func TestRunnerCancelsAndReapsAHangingChildWithinFinalBound(t *testing.T) { t.Parallel() runner := NewRunner(writeExecutable(t, "#!/bin/sh\ntrap '' TERM INT\nwhile :; do sleep 1; done\n")) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() started := time.Now() _, err := RunBounded(runner, ctx, []string{"compose", "run", "--rm", "core"}, nil, CaptureLimits{ StdoutBytes: 1024, StderrBytes: 1024, }) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("RunBounded() error = %v, want deadline exceeded", err) } if elapsed := time.Since(started); elapsed > 5*time.Second { t.Fatalf("cancellation teardown took %s", elapsed) } } func TestRunnerCleansUpNamedComposeContainerAfterOverflow(t *testing.T) { logFile := filepath.Join(t.TempDir(), "calls.log") marker := filepath.Join(t.TempDir(), "container-present") if err := os.WriteFile(marker, []byte("present"), 0o600); err != nil { t.Fatal(err) } t.Setenv("THT_RUNNER_TEST_LOG", logFile) t.Setenv("THT_RUNNER_TEST_MARKER", marker) t.Setenv("THT_RUNNER_TEST_MODE", "flood") runner := NewRunner(writeExecutable(t, cleanupAwareDockerScript)) name := "thothii-cleanup-overflow-sentinel" _, err := RunBounded(runner, context.Background(), []string{ "compose", "run", "--rm", "--name", name, "core", }, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) if !errors.Is(err, ErrOutputLimit) { t.Fatalf("RunBounded() error = %v, want ErrOutputLimit", err) } assertContainerCleanup(t, logFile, marker, name) } func TestRunnerCleansUpNamedComposeContainerAfterCancellation(t *testing.T) { logFile := filepath.Join(t.TempDir(), "calls.log") marker := filepath.Join(t.TempDir(), "container-present") if err := os.WriteFile(marker, []byte("present"), 0o600); err != nil { t.Fatal(err) } t.Setenv("THT_RUNNER_TEST_LOG", logFile) t.Setenv("THT_RUNNER_TEST_MARKER", marker) t.Setenv("THT_RUNNER_TEST_MODE", "hang") runner := NewRunner(writeExecutable(t, cleanupAwareDockerScript)) name := "thothii-cleanup-cancel-sentinel" ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() _, err := RunBounded(runner, ctx, []string{ "compose", "run", "--rm", "--name", name, "core", }, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("RunBounded() error = %v, want deadline exceeded", err) } assertContainerCleanup(t, logFile, marker, name) } func TestRunnerVerifiesAndRemovesNamedComposeContainerAfterNormalExit(t *testing.T) { logFile := filepath.Join(t.TempDir(), "calls.log") marker := filepath.Join(t.TempDir(), "container-present") if err := os.WriteFile(marker, []byte("present"), 0o600); err != nil { t.Fatal(err) } t.Setenv("THT_RUNNER_TEST_LOG", logFile) t.Setenv("THT_RUNNER_TEST_MARKER", marker) t.Setenv("THT_RUNNER_TEST_MODE", "normal") runner := NewRunner(writeExecutable(t, cleanupAwareDockerScript)) name := "thothii-cleanup-normal-sentinel" result, err := RunBounded(runner, context.Background(), []string{ "compose", "run", "--rm", "--name", name, "core", }, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) if err != nil || result.ExitCode != 0 { t.Fatalf("RunBounded() result=%#v error=%v, want clean exit", result, err) } assertContainerCleanup(t, logFile, marker, name) } func TestRunnerVerifiesAndRemovesNamedComposeContainerAfterExpectedExitOne(t *testing.T) { logFile := filepath.Join(t.TempDir(), "calls.log") marker := filepath.Join(t.TempDir(), "container-present") if err := os.WriteFile(marker, []byte("present"), 0o600); err != nil { t.Fatal(err) } t.Setenv("THT_RUNNER_TEST_LOG", logFile) t.Setenv("THT_RUNNER_TEST_MARKER", marker) t.Setenv("THT_RUNNER_TEST_MODE", "exit-one") runner := NewRunner(writeExecutable(t, cleanupAwareDockerScript)) name := "thothii-cleanup-exit-one-sentinel" result, err := RunBounded(runner, context.Background(), []string{ "compose", "run", "--rm", "--name", name, "core", }, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) var exitError *exec.ExitError if !errors.As(err, &exitError) || errors.Is(err, ErrContainerCleanup) || result.ExitCode != 1 { t.Fatalf("RunBounded() result=%#v error=%v, want original exit 1", result, err) } assertContainerCleanup(t, logFile, marker, name) } func TestRunnerAcceptsANamedOneShotAlreadyRemovedByRm(t *testing.T) { logFile := filepath.Join(t.TempDir(), "calls.log") marker := filepath.Join(t.TempDir(), "container-absent") t.Setenv("THT_RUNNER_TEST_LOG", logFile) t.Setenv("THT_RUNNER_TEST_MARKER", marker) t.Setenv("THT_RUNNER_TEST_MODE", "normal") runner := NewRunner(writeExecutable(t, cleanupAwareDockerScript)) name := "thothii-cleanup-already-removed-sentinel" result, err := RunBounded(runner, context.Background(), []string{ "compose", "run", "--rm", "--name", name, "core", }, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) if err != nil || result.ExitCode != 0 { t.Fatalf("RunBounded() result=%#v error=%v, want clean exit", result, err) } calls, readErr := os.ReadFile(logFile) if readErr != nil { t.Fatal(readErr) } if !strings.Contains(string(calls), "container ls --all --quiet --filter name=^/"+name+"$") || strings.Contains(string(calls), "container rm -f "+name) { t.Fatalf("Docker calls = %q, want absence verification without forced removal", calls) } if _, statErr := os.Stat(marker); !os.IsNotExist(statErr) { t.Fatalf("named one-shot unexpectedly left residue: %v", statErr) } } func TestRunnerJoinsCleanupFailureAfterNormalExit(t *testing.T) { logFile := filepath.Join(t.TempDir(), "calls.log") marker := filepath.Join(t.TempDir(), "container-present") if err := os.WriteFile(marker, []byte("present"), 0o600); err != nil { t.Fatal(err) } t.Setenv("THT_RUNNER_TEST_LOG", logFile) t.Setenv("THT_RUNNER_TEST_MARKER", marker) t.Setenv("THT_RUNNER_TEST_MODE", "normal-cleanup-fails") runner := NewRunner(writeExecutable(t, cleanupAwareDockerScript)) name := "thothii-cleanup-failure-sentinel" result, err := RunBounded(runner, context.Background(), []string{ "compose", "run", "--rm", "--name", name, "core", }, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) if result.ExitCode != 0 || !errors.Is(err, ErrContainerCleanup) { t.Fatalf("RunBounded() result=%#v error=%v, want cleanup failure joined to exit 0", result, err) } if strings.Contains(err.Error(), name) { t.Fatalf("RunBounded() exposed the container name: %v", err) } if _, statErr := os.Stat(marker); statErr != nil { t.Fatalf("cleanup failure test unexpectedly removed marker: %v", statErr) } } func TestRunnerSurfacesCleanupFailureWithoutContainerName(t *testing.T) { logFile := filepath.Join(t.TempDir(), "calls.log") marker := filepath.Join(t.TempDir(), "container-present") if err := os.WriteFile(marker, []byte("present"), 0o600); err != nil { t.Fatal(err) } t.Setenv("THT_RUNNER_TEST_LOG", logFile) t.Setenv("THT_RUNNER_TEST_MARKER", marker) t.Setenv("THT_RUNNER_TEST_MODE", "cleanup-fails") runner := NewRunner(writeExecutable(t, cleanupAwareDockerScript)) name := "thothii-cleanup-secret-sentinel" ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() _, err := RunBounded(runner, ctx, []string{ "compose", "run", "--rm", "--name", name, "core", }, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024}) if err == nil || !strings.Contains(err.Error(), "one-shot container cleanup failed") { t.Fatalf("RunBounded() error = %v, want safe cleanup failure", err) } if strings.Contains(err.Error(), name) { t.Fatalf("RunBounded() exposed the container name: %v", err) } } func TestRunnerPropagatesAReapingFailure(t *testing.T) { original := terminateProcessForRunner terminateProcessForRunner = func(process processLifetime, done <-chan error) error { _ = process.terminate(done) return ErrProcessReap } t.Cleanup(func() { terminateProcessForRunner = original }) runner := NewRunner(writeExecutable(t, "#!/bin/sh\ntrap '' TERM INT\nwhile :; do sleep 1; done\n")) ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() _, err := RunBounded(runner, ctx, []string{"version"}, nil, CaptureLimits{ StdoutBytes: 1024, StderrBytes: 1024, }) if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, ErrProcessReap) { t.Fatalf("RunBounded() error = %v, want deadline and reap failure", err) } } 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 TestRunnerRetainsTheExactNormalRmInvocationForAnUnnamedOneShot(t *testing.T) { t.Parallel() runner := NewRunner(writeExecutable(t, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n")) result, err := runner.Run(context.Background(), []string{ "compose", "run", "--rm", "--no-deps", "core", "node", "diagnostic.js", }, nil) if err != nil { t.Fatal(err) } got := strings.Fields(result.Stdout) if strings.Join(got, " ") != "compose run --rm --no-deps core node diagnostic.js" { t.Fatalf("one-shot argv = %#v, want exact original --rm invocation", got) } } 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 TestInstallationRunnerAppliesComposeArgumentsWithoutRewritingHostDockerCommands(t *testing.T) { t.Parallel() recorded := &recordingRunner{} installation := config.Installation{ Path: "/tmp/thothii/deploy/local/thothii-installation.yaml", Profile: "local", ProjectDirectory: "/tmp/thothii", EnvFile: "/tmp/thothii/deploy/local/operator.env", } runner := InstallationRunner{Installation: installation, Runner: recorded} if _, err := runner.Run(context.Background(), []string{"compose", "ps"}, nil); err != nil { t.Fatal(err) } if _, err := runner.Run(context.Background(), []string{"inspect", "core-id"}, nil); err != nil { t.Fatal(err) } if got, want := strings.Join(recorded.calls[0], " "), strings.Join(installation.ComposeArgs("ps"), " "); got != want { t.Fatalf("Compose call = %q, want %q", got, want) } if got, want := strings.Join(recorded.calls[1], " "), "inspect core-id"; got != want { t.Fatalf("host Docker call = %q, want %q", got, want) } } type recordingRunner struct{ calls [][]string } func (r *recordingRunner) Run(_ context.Context, args []string, _ io.Reader) (Result, error) { r.calls = append(r.calls, append([]string(nil), args...)) return Result{}, nil } 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 } func assertContainerCleanup(t *testing.T, logFile, marker, name string) { t.Helper() calls, err := os.ReadFile(logFile) if err != nil { t.Fatal(err) } if !strings.Contains(string(calls), "container rm -f "+name) { t.Fatalf("Docker calls = %q, want bounded container cleanup", calls) } if _, err := os.Stat(marker); !os.IsNotExist(err) { t.Fatalf("one-shot container marker still exists: %v", err) } } const cleanupAwareDockerScript = `#!/bin/sh printf '%s\n' "$*" >> "$THT_RUNNER_TEST_LOG" if [ "$1" = "container" ] && [ "$2" = "ls" ]; then if [ -f "$THT_RUNNER_TEST_MARKER" ]; then printf '%s\n' container-id; fi exit 0 fi if [ "$1" = "container" ] && [ "$2" = "rm" ]; then case "$THT_RUNNER_TEST_MODE" in *cleanup-fails) exit 9 ;; esac rm -f "$THT_RUNNER_TEST_MARKER" exit 0 fi if [ "$THT_RUNNER_TEST_MODE" = "flood" ]; then while :; do printf '0123456789abcdef'; printf 'fedcba9876543210' >&2; done fi if [ "$THT_RUNNER_TEST_MODE" = "normal" ] || [ "$THT_RUNNER_TEST_MODE" = "normal-cleanup-fails" ]; then exit 0 fi if [ "$THT_RUNNER_TEST_MODE" = "exit-one" ]; then exit 1 fi trap '' TERM INT while :; do sleep 1; done `