hi: consolidate doctor and stats helpers

This commit is contained in:
Kristoffer Dalby committed 2026-06-17 16:12:19 +02:00
1 parent 722a2f1e18
commit ea5e52f662
5 files changed
+168 -297

No files matched your search

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