diff --git a/cmd/hi/cleanup.go b/cmd/hi/cleanup.go index 26db49ae..5ad624ce 100644 --- a/cmd/hi/cleanup.go +++ b/cmd/hi/cleanup.go @@ -71,26 +71,8 @@ func killTestContainers(ctx context.Context) error { removed := 0 for _, cont := range containers { - shouldRemove := false - - for _, name := range cont.Names { - if strings.Contains(name, "headscale-test-suite") || - strings.Contains(name, "hs-") || - strings.Contains(name, "ts-") || - strings.Contains(name, "derp-") { - shouldRemove = true - break - } - } - - if shouldRemove { - // First kill the container if it's running - if cont.State == "running" { - _ = cli.ContainerKill(ctx, cont.ID, "KILL") - } - - // Then remove the container with retry logic - if removeContainerWithRetry(ctx, cli, cont.ID) { + if isTestContainerName(cont.Names) { + if killAndRemove(ctx, cli, cont) { removed++ } } @@ -129,13 +111,7 @@ func killTestContainersByRunID(ctx context.Context, runID string) error { removed := 0 for _, cont := range containers { - // Kill the container if it's running - if cont.State == "running" { - _ = cli.ContainerKill(ctx, cont.ID, "KILL") - } - - // Remove the container with retry logic - if removeContainerWithRetry(ctx, cli, cont.ID) { + if killAndRemove(ctx, cli, cont) { removed++ } } @@ -173,20 +149,8 @@ func cleanupStaleTestContainers(ctx context.Context) error { for _, cont := range containers { // Only remove containers that look like test containers - shouldRemove := false - - for _, name := range cont.Names { - if strings.Contains(name, "headscale-test-suite") || - strings.Contains(name, "hs-") || - strings.Contains(name, "ts-") || - strings.Contains(name, "derp-") { - shouldRemove = true - break - } - } - - if shouldRemove { - if removeContainerWithRetry(ctx, cli, cont.ID) { + if isTestContainerName(cont.Names) { + if killAndRemove(ctx, cli, cont) { removed++ } } @@ -223,6 +187,31 @@ func removeContainerWithRetry(ctx context.Context, cli *client.Client, container return err == nil } +// isTestContainerName reports whether any of the container names belong to an +// integration test container. +func isTestContainerName(names []string) bool { + for _, name := range names { + if strings.Contains(name, "headscale-test-suite") || + strings.Contains(name, "hs-") || + strings.Contains(name, "ts-") || + strings.Contains(name, "derp-") { + return true + } + } + + return false +} + +// killAndRemove kills a running container then removes it with retry logic, +// reporting whether the removal succeeded. +func killAndRemove(ctx context.Context, cli *client.Client, cont container.Summary) bool { + if cont.State == "running" { + _ = cli.ContainerKill(ctx, cont.ID, "KILL") + } + + return removeContainerWithRetry(ctx, cli, cont.ID) +} + // pruneDockerNetworks removes unused Docker networks. func pruneDockerNetworks(ctx context.Context) error { cli, err := createDockerClient(ctx) diff --git a/cmd/hi/doctor.go b/cmd/hi/doctor.go index 9291674d..f09b642e 100644 --- a/cmd/hi/doctor.go +++ b/cmd/hi/doctor.go @@ -33,6 +33,21 @@ type DoctorResult struct { Suggestions []string } +// pass builds a passing DoctorResult. +func pass(name, message string) DoctorResult { + return DoctorResult{Name: name, Status: statusPass, Message: message} +} + +// warn builds a warning DoctorResult with optional suggestions. +func warn(name, message string, suggestions ...string) DoctorResult { + return DoctorResult{Name: name, Status: statusWarn, Message: message, Suggestions: suggestions} +} + +// fail builds a failing DoctorResult with optional suggestions. +func fail(name, message string, suggestions ...string) DoctorResult { + return DoctorResult{Name: name, Status: statusFail, Message: message, Suggestions: suggestions} +} + // runDoctorCheck performs comprehensive pre-flight checks for integration testing. func runDoctorCheck(ctx context.Context) error { results := []DoctorResult{} @@ -80,129 +95,91 @@ func runDoctorCheck(ctx context.Context) error { func checkDockerBinary() DoctorResult { _, err := exec.LookPath("docker") if err != nil { - return DoctorResult{ - Name: "Docker Binary", - Status: statusFail, - Message: "Docker binary not found in PATH", - Suggestions: []string{ - "Install Docker: https://docs.docker.com/get-docker/", - "For macOS: consider using colima or Docker Desktop", - "Ensure docker is in your PATH", - }, - } + return fail( + "Docker Binary", + "Docker binary not found in PATH", + "Install Docker: https://docs.docker.com/get-docker/", + "For macOS: consider using colima or Docker Desktop", + "Ensure docker is in your PATH", + ) } - return DoctorResult{ - Name: "Docker Binary", - Status: statusPass, - Message: "Docker binary found", - } + return pass("Docker Binary", "Docker binary found") } // checkDockerDaemon verifies Docker daemon is running and accessible. func checkDockerDaemon(ctx context.Context) DoctorResult { cli, err := createDockerClient(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerDaemon, - Status: statusFail, - Message: fmt.Sprintf("Cannot create Docker client: %v", err), - Suggestions: []string{ - "Start Docker daemon/service", - "Check Docker Desktop is running (if using Docker Desktop)", - "For colima: run 'colima start'", - "Verify DOCKER_HOST environment variable if set", - }, - } + return fail( + nameDockerDaemon, + fmt.Sprintf("Cannot create Docker client: %v", err), + "Start Docker daemon/service", + "Check Docker Desktop is running (if using Docker Desktop)", + "For colima: run 'colima start'", + "Verify DOCKER_HOST environment variable if set", + ) } defer cli.Close() _, err = cli.Ping(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerDaemon, - Status: statusFail, - Message: fmt.Sprintf("Cannot ping Docker daemon: %v", err), - Suggestions: []string{ - "Ensure Docker daemon is running", - "Check Docker socket permissions", - "Try: docker info", - }, - } + return fail( + nameDockerDaemon, + fmt.Sprintf("Cannot ping Docker daemon: %v", err), + "Ensure Docker daemon is running", + "Check Docker socket permissions", + "Try: docker info", + ) } - return DoctorResult{ - Name: nameDockerDaemon, - Status: statusPass, - Message: "Docker daemon is running and accessible", - } + return pass(nameDockerDaemon, "Docker daemon is running and accessible") } // checkDockerContext verifies Docker context configuration. func checkDockerContext(ctx context.Context) DoctorResult { contextInfo, err := getCurrentDockerContext(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerContext, - Status: statusWarn, - Message: "Could not detect Docker context, using default settings", - Suggestions: []string{ - "Check: docker context ls", - "Consider setting up a specific context if needed", - }, - } + return warn( + nameDockerContext, + "Could not detect Docker context, using default settings", + "Check: docker context ls", + "Consider setting up a specific context if needed", + ) } if contextInfo == nil { - return DoctorResult{ - Name: nameDockerContext, - Status: statusPass, - Message: "Using default Docker context", - } + return pass(nameDockerContext, "Using default Docker context") } - return DoctorResult{ - Name: nameDockerContext, - Status: statusPass, - Message: "Using Docker context: " + contextInfo.Name, - } + return pass(nameDockerContext, "Using Docker context: "+contextInfo.Name) } // checkDockerSocket verifies Docker socket accessibility. func checkDockerSocket(ctx context.Context) DoctorResult { cli, err := createDockerClient(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerSocket, - Status: statusFail, - Message: fmt.Sprintf("Cannot access Docker socket: %v", err), - Suggestions: []string{ - "Check Docker socket permissions", - "Add user to docker group: sudo usermod -aG docker $USER", - "For colima: ensure socket is accessible", - }, - } + return fail( + nameDockerSocket, + fmt.Sprintf("Cannot access Docker socket: %v", err), + "Check Docker socket permissions", + "Add user to docker group: sudo usermod -aG docker $USER", + "For colima: ensure socket is accessible", + ) } defer cli.Close() info, err := cli.Info(ctx) if err != nil { - return DoctorResult{ - Name: nameDockerSocket, - Status: statusFail, - Message: fmt.Sprintf("Cannot get Docker info: %v", err), - Suggestions: []string{ - "Check Docker daemon status", - "Verify socket permissions", - }, - } + return fail( + nameDockerSocket, + fmt.Sprintf("Cannot get Docker info: %v", err), + "Check Docker daemon status", + "Verify socket permissions", + ) } - return DoctorResult{ - Name: nameDockerSocket, - Status: statusPass, - Message: fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion), - } + return pass(nameDockerSocket, fmt.Sprintf("Docker socket accessible (Server: %s)", info.ServerVersion)) } // checkDockerHubCredentials warns when pulls would be anonymous and @@ -210,34 +187,23 @@ func checkDockerSocket(ctx context.Context) DoctorResult { func checkDockerHubCredentials() DoctorResult { _, _, source := dockertestutil.Credentials() if source == dockertestutil.CredentialSourceAnonymous { - return DoctorResult{ - Name: "Docker Hub Credentials", - Status: "WARN", - Message: "No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)", - Suggestions: []string{ - "Run: docker login", - "Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN", - "In CI: ensure the docker/login-action step is configured with secrets", - }, - } + return warn( + "Docker Hub Credentials", + "No Docker Hub credentials found — pulls will be rate-limited (100/6h per IP)", + "Run: docker login", + "Or export DOCKERHUB_USERNAME and DOCKERHUB_TOKEN", + "In CI: ensure the docker/login-action step is configured with secrets", + ) } - return DoctorResult{ - Name: "Docker Hub Credentials", - Status: "PASS", - Message: fmt.Sprintf("Credentials available (source: %s)", source), - } + return pass("Docker Hub Credentials", fmt.Sprintf("Credentials available (source: %s)", source)) } // checkGolangImage verifies the golang Docker image is available locally or can be pulled. func checkGolangImage(ctx context.Context) DoctorResult { cli, err := createDockerClient(ctx) if err != nil { - return DoctorResult{ - Name: nameGolangImage, - Status: statusFail, - Message: "Cannot create Docker client for image check", - } + return fail(nameGolangImage, "Cannot create Docker client for image check") } defer cli.Close() @@ -247,81 +213,56 @@ func checkGolangImage(ctx context.Context) DoctorResult { // First check if image is available locally available, err := checkImageAvailableLocally(ctx, cli, imageName) if err != nil { - return DoctorResult{ - Name: nameGolangImage, - Status: statusFail, - Message: fmt.Sprintf("Cannot check golang image %s: %v", imageName, err), - Suggestions: []string{ - "Check Docker daemon status", - "Try: docker images | grep golang", - }, - } + return fail( + nameGolangImage, + fmt.Sprintf("Cannot check golang image %s: %v", imageName, err), + "Check Docker daemon status", + "Try: docker images | grep golang", + ) } if available { - return DoctorResult{ - Name: nameGolangImage, - Status: statusPass, - Message: fmt.Sprintf("Golang image %s is available locally", imageName), - } + return pass(nameGolangImage, fmt.Sprintf("Golang image %s is available locally", imageName)) } // Image not available locally, try to pull it err = ensureImageAvailable(ctx, cli, imageName, false) if err != nil { - return DoctorResult{ - Name: nameGolangImage, - Status: statusFail, - Message: fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err), - Suggestions: []string{ - "Check internet connectivity", - "Verify Docker Hub access", - "Try: docker pull " + imageName, - "Or run tests offline if image was pulled previously", - }, - } + return fail( + nameGolangImage, + fmt.Sprintf("Golang image %s not available locally and cannot pull: %v", imageName, err), + "Check internet connectivity", + "Verify Docker Hub access", + "Try: docker pull "+imageName, + "Or run tests offline if image was pulled previously", + ) } - return DoctorResult{ - Name: nameGolangImage, - Status: statusPass, - Message: fmt.Sprintf("Golang image %s is now available", imageName), - } + return pass(nameGolangImage, fmt.Sprintf("Golang image %s is now available", imageName)) } // checkGoInstallation verifies Go is installed and working. func checkGoInstallation(ctx context.Context) DoctorResult { _, err := exec.LookPath("go") if err != nil { - return DoctorResult{ - Name: nameGoInstall, - Status: statusFail, - Message: "Go binary not found in PATH", - Suggestions: []string{ - "Install Go: https://golang.org/dl/", - "Ensure go is in your PATH", - }, - } + return fail( + nameGoInstall, + "Go binary not found in PATH", + "Install Go: https://golang.org/dl/", + "Ensure go is in your PATH", + ) } cmd := exec.CommandContext(ctx, "go", "version") output, err := cmd.Output() if err != nil { - return DoctorResult{ - Name: nameGoInstall, - Status: statusFail, - Message: fmt.Sprintf("Cannot get Go version: %v", err), - } + return fail(nameGoInstall, fmt.Sprintf("Cannot get Go version: %v", err)) } version := strings.TrimSpace(string(output)) - return DoctorResult{ - Name: nameGoInstall, - Status: statusPass, - Message: version, - } + return pass(nameGoInstall, version) } // checkGitRepository verifies we're in a git repository. @@ -330,22 +271,15 @@ func checkGitRepository(ctx context.Context) DoctorResult { err := cmd.Run() if err != nil { - return DoctorResult{ - Name: "Git Repository", - Status: statusFail, - Message: "Not in a Git repository", - Suggestions: []string{ - "Run from within the headscale git repository", - "Clone the repository: git clone https://github.com/juanfont/headscale.git", - }, - } + return fail( + "Git Repository", + "Not in a Git repository", + "Run from within the headscale git repository", + "Clone the repository: git clone https://github.com/juanfont/headscale.git", + ) } - return DoctorResult{ - Name: "Git Repository", - Status: statusPass, - Message: "Running in Git repository", - } + return pass("Git Repository", "Running in Git repository") } // checkRequiredFiles verifies required files exist. @@ -368,23 +302,16 @@ func checkRequiredFiles(ctx context.Context) DoctorResult { } if len(missingFiles) > 0 { - return DoctorResult{ - Name: "Required Files", - Status: statusFail, - Message: "Missing required files: " + strings.Join(missingFiles, ", "), - Suggestions: []string{ - "Ensure you're in the headscale project root directory", - "Check that integration/ directory exists", - "Verify this is a complete headscale repository", - }, - } + return fail( + "Required Files", + "Missing required files: "+strings.Join(missingFiles, ", "), + "Ensure you're in the headscale project root directory", + "Check that integration/ directory exists", + "Verify this is a complete headscale repository", + ) } - return DoctorResult{ - Name: "Required Files", - Status: statusPass, - Message: "All required files found", - } + return pass("Required Files", "All required files found") } // displayDoctorResults shows the results in a formatted way. diff --git a/cmd/hi/main.go b/cmd/hi/main.go index 39d4c410..903d77fa 100644 --- a/cmd/hi/main.go +++ b/cmd/hi/main.go @@ -86,20 +86,17 @@ func main() { } func cleanAll(ctx context.Context) error { - err := killTestContainers(ctx) - if err != nil { - return err + for _, step := range []func(context.Context) error{ + killTestContainers, + pruneDockerNetworks, + cleanOldImages, + cleanCacheVolume, + } { + err := step(ctx) + if err != nil { + return err + } } - err = pruneDockerNetworks(ctx) - if err != nil { - return err - } - - err = cleanOldImages(ctx) - if err != nil { - return err - } - - return cleanCacheVolume(ctx) + return nil } diff --git a/cmd/hi/run.go b/cmd/hi/run.go index 30002208..35214e3c 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" @@ -66,64 +67,21 @@ func runIntegrationTest(env *command.Env) error { // detectGoVersion reads the Go version from go.mod file. func detectGoVersion() string { - goModPath := filepath.Join("..", "..", "go.mod") - - if _, err := os.Stat("go.mod"); err == nil { //nolint:noinlineerr - goModPath = "go.mod" - } else if _, err := os.Stat("../../go.mod"); err == nil { //nolint:noinlineerr - goModPath = "../../go.mod" - } - - content, err := os.ReadFile(goModPath) + content, err := os.ReadFile("go.mod") if err != nil { - return "1.26.1" + content, err = os.ReadFile(filepath.Join("..", "..", "go.mod")) + if err != nil { + return "1.26.1" + } } - lines := splitLines(string(content)) - for _, line := range lines { - if len(line) > 3 && line[:3] == "go " { - version := line[3:] - if idx := indexOf(version, " "); idx != -1 { - version = version[:idx] + for line := range strings.Lines(string(content)) { + if rest, ok := strings.CutPrefix(line, "go "); ok { + if f := strings.Fields(rest); len(f) > 0 { + return f[0] } - - return version } } return "1.26.1" } - -// splitLines splits a string into lines without using [strings.Split]. -func splitLines(s string) []string { - var ( - lines []string - current string - ) - - for _, char := range s { - if char == '\n' { - lines = append(lines, current) - current = "" - } else { - current += string(char) - } - } - - if current != "" { - lines = append(lines, current) - } - - return lines -} - -// indexOf finds the first occurrence of substr in s. -func indexOf(s, substr string) int { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return i - } - } - - return -1 -} diff --git a/cmd/hi/stats.go b/cmd/hi/stats.go index e8b8e28c..252b4eb2 100644 --- a/cmd/hi/stats.go +++ b/cmd/hi/stats.go @@ -374,17 +374,17 @@ func (sc *StatsCollector) GetSummary() []ContainerStatsSummary { SampleCount: len(stats), } - // Calculate CPU stats - cpuValues := make([]float64, len(stats)) - memoryValues := make([]float64, len(stats)) + extract := func(get func(StatsSample) float64) []float64 { + values := make([]float64, len(stats)) + for i, sample := range stats { + values[i] = get(sample) + } - for i, sample := range stats { - cpuValues[i] = sample.CPUUsage - memoryValues[i] = sample.MemoryMB + return values } - summary.CPU = calculateStatsSummary(cpuValues) - summary.Memory = calculateStatsSummary(memoryValues) + summary.CPU = calculateStatsSummary(extract(func(s StatsSample) float64 { return s.CPUUsage })) + summary.Memory = calculateStatsSummary(extract(func(s StatsSample) float64 { return s.MemoryMB })) summaries = append(summaries, summary) }