diff --git a/cmd/hi/docker.go b/cmd/hi/docker.go index 3895fe2a..1a47b7ff 100644 --- a/cmd/hi/docker.go +++ b/cmd/hi/docker.go @@ -26,8 +26,93 @@ var ( ErrTestFailed = errors.New("test failed") ErrUnexpectedContainerWait = errors.New("unexpected end of container wait") ErrNoDockerContext = errors.New("no docker context found") + ErrAnotherRunInProgress = errors.New("another integration test run is already in progress") ) +// RunningTestInfo contains information about a currently running integration test. +type RunningTestInfo struct { + RunID string + ContainerID string + ContainerName string + StartTime time.Time + Duration time.Duration + TestPattern string +} + +// ErrNoRunningTests indicates that no integration test is currently running. +var ErrNoRunningTests = errors.New("no running tests found") + +// checkForRunningTests checks if there's already an integration test running. +// Returns ErrNoRunningTests if no test is running, or RunningTestInfo with details about the running test. +func checkForRunningTests(ctx context.Context) (*RunningTestInfo, error) { + cli, err := createDockerClient() + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + defer cli.Close() + + // List all running containers + containers, err := cli.ContainerList(ctx, container.ListOptions{ + All: false, // Only running containers + }) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %w", err) + } + + // Look for containers with hi.test-type=test-runner label + for _, cont := range containers { + if cont.Labels != nil && cont.Labels["hi.test-type"] == "test-runner" { + // Found a running test runner container + runID := cont.Labels["hi.run-id"] + + containerName := "" + for _, name := range cont.Names { + containerName = strings.TrimPrefix(name, "/") + + break + } + + // Get more details via inspection + inspect, err := cli.ContainerInspect(ctx, cont.ID) + if err != nil { + // Return basic info if inspection fails + return &RunningTestInfo{ + RunID: runID, + ContainerID: cont.ID, + ContainerName: containerName, + }, nil + } + + startTime, _ := time.Parse(time.RFC3339Nano, inspect.State.StartedAt) + duration := time.Since(startTime) + + // Try to extract test pattern from command + testPattern := "" + + if len(inspect.Config.Cmd) > 0 { + for i, arg := range inspect.Config.Cmd { + if arg == "-run" && i+1 < len(inspect.Config.Cmd) { + testPattern = inspect.Config.Cmd[i+1] + + break + } + } + } + + return &RunningTestInfo{ + RunID: runID, + ContainerID: cont.ID, + ContainerName: containerName, + StartTime: startTime, + Duration: duration, + TestPattern: testPattern, + }, nil + } + } + + return nil, ErrNoRunningTests +} + // runTestContainer executes integration tests in a Docker container. func runTestContainer(ctx context.Context, config *RunConfig) error { cli, err := createDockerClient() diff --git a/cmd/hi/run.go b/cmd/hi/run.go index ea43490c..3456b668 100644 --- a/cmd/hi/run.go +++ b/cmd/hi/run.go @@ -6,6 +6,7 @@ import ( "log" "os" "path/filepath" + "strings" "time" "github.com/creachadair/command" @@ -13,6 +14,58 @@ import ( var ErrTestPatternRequired = errors.New("test pattern is required as first argument or use --test flag") +// formatRunningTestError creates a detailed error message about a running test. +func formatRunningTestError(info *RunningTestInfo) error { + var msg strings.Builder + msg.WriteString("\n") + msg.WriteString("╔══════════════════════════════════════════════════════════════════╗\n") + msg.WriteString("║ Another integration test run is already in progress! ║\n") + msg.WriteString("╚══════════════════════════════════════════════════════════════════╝\n") + msg.WriteString("\n") + msg.WriteString("Running test details:\n") + msg.WriteString(fmt.Sprintf(" Run ID: %s\n", info.RunID)) + msg.WriteString(fmt.Sprintf(" Container: %s\n", info.ContainerName)) + + if info.TestPattern != "" { + msg.WriteString(fmt.Sprintf(" Test: %s\n", info.TestPattern)) + } + + if !info.StartTime.IsZero() { + msg.WriteString(fmt.Sprintf(" Started: %s\n", info.StartTime.Format("2006-01-02 15:04:05"))) + msg.WriteString(fmt.Sprintf(" Running for: %s\n", formatDuration(info.Duration))) + } + + msg.WriteString("\n") + msg.WriteString("Please wait for the current test to complete, or stop it with:\n") + msg.WriteString(" go run ./cmd/hi clean containers\n") + msg.WriteString("\n") + msg.WriteString("To monitor the running test:\n") + msg.WriteString(fmt.Sprintf(" docker logs -f %s\n", info.ContainerName)) + + return fmt.Errorf("%w\n%s", ErrAnotherRunInProgress, msg.String()) +} + +const secondsPerMinute = 60 + +// formatDuration formats a duration in a human-readable way. +func formatDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%d seconds", int(d.Seconds())) + } + + if d < time.Hour { + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % secondsPerMinute + + return fmt.Sprintf("%d minutes, %d seconds", minutes, seconds) + } + + hours := int(d.Hours()) + minutes := int(d.Minutes()) % secondsPerMinute + + return fmt.Sprintf("%d hours, %d minutes", hours, minutes) +} + type RunConfig struct { TestPattern string `flag:"test,Test pattern to run"` Timeout time.Duration `flag:"timeout,default=120m,Test timeout"` @@ -27,6 +80,7 @@ type RunConfig struct { Stats bool `flag:"stats,default=false,Collect and display container resource usage statistics"` HSMemoryLimit float64 `flag:"hs-memory-limit,default=0,Fail test if any Headscale container exceeds this memory limit in MB (0 = disabled)"` TSMemoryLimit float64 `flag:"ts-memory-limit,default=0,Fail test if any Tailscale container exceeds this memory limit in MB (0 = disabled)"` + Force bool `flag:"force,default=false,Kill any running test and start a new one"` } // runIntegrationTest executes the integration test workflow. @@ -44,6 +98,23 @@ func runIntegrationTest(env *command.Env) error { runConfig.GoVersion = detectGoVersion() } + // Check if another test run is already in progress + runningTest, err := checkForRunningTests(env.Context()) + if err != nil && !errors.Is(err, ErrNoRunningTests) { + log.Printf("Warning: failed to check for running tests: %v", err) + } else if runningTest != nil { + if runConfig.Force { + log.Printf("Force flag set, killing existing test run: %s", runningTest.RunID) + + err = killTestContainers(env.Context()) + if err != nil { + return fmt.Errorf("failed to kill existing test containers: %w", err) + } + } else { + return formatRunningTestError(runningTest) + } + } + // Run pre-flight checks if runConfig.Verbose { log.Printf("Running pre-flight system checks...")