hi: consolidate doctor and stats helpers

This commit is contained in:
Kristoffer Dalby
2026-06-15 09:57:00 +00:00
parent 722a2f1e18
commit ea5e52f662
5 changed files with 168 additions and 297 deletions
+30 -41
View File
@@ -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)
+109 -182
View File
@@ -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.
+11 -14
View File
@@ -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
}
+10 -52
View File
@@ -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
}
+8 -8
View File
@@ -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)
}