mirror of
				https://github.com/juanfont/headscale.git
				synced 2025-10-28 10:51:44 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			764 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			764 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package main
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"io"
 | |
| 	"log"
 | |
| 	"os"
 | |
| 	"os/exec"
 | |
| 	"path/filepath"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"github.com/docker/docker/api/types/container"
 | |
| 	"github.com/docker/docker/api/types/image"
 | |
| 	"github.com/docker/docker/api/types/mount"
 | |
| 	"github.com/docker/docker/client"
 | |
| 	"github.com/docker/docker/pkg/stdcopy"
 | |
| 	"github.com/juanfont/headscale/integration/dockertestutil"
 | |
| )
 | |
| 
 | |
| var (
 | |
| 	ErrTestFailed              = errors.New("test failed")
 | |
| 	ErrUnexpectedContainerWait = errors.New("unexpected end of container wait")
 | |
| 	ErrNoDockerContext         = errors.New("no docker context found")
 | |
| )
 | |
| 
 | |
| // runTestContainer executes integration tests in a Docker container.
 | |
| func runTestContainer(ctx context.Context, config *RunConfig) error {
 | |
| 	cli, err := createDockerClient()
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to create Docker client: %w", err)
 | |
| 	}
 | |
| 	defer cli.Close()
 | |
| 
 | |
| 	runID := dockertestutil.GenerateRunID()
 | |
| 	containerName := "headscale-test-suite-" + runID
 | |
| 	logsDir := filepath.Join(config.LogsDir, runID)
 | |
| 
 | |
| 	if config.Verbose {
 | |
| 		log.Printf("Run ID: %s", runID)
 | |
| 		log.Printf("Container name: %s", containerName)
 | |
| 		log.Printf("Logs directory: %s", logsDir)
 | |
| 	}
 | |
| 
 | |
| 	absLogsDir, err := filepath.Abs(logsDir)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to get absolute path for logs directory: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	const dirPerm = 0o755
 | |
| 	if err := os.MkdirAll(absLogsDir, dirPerm); err != nil {
 | |
| 		return fmt.Errorf("failed to create logs directory: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if config.CleanBefore {
 | |
| 		if config.Verbose {
 | |
| 			log.Printf("Running pre-test cleanup...")
 | |
| 		}
 | |
| 		if err := cleanupBeforeTest(ctx); err != nil && config.Verbose {
 | |
| 			log.Printf("Warning: pre-test cleanup failed: %v", err)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	goTestCmd := buildGoTestCommand(config)
 | |
| 	if config.Verbose {
 | |
| 		log.Printf("Command: %s", strings.Join(goTestCmd, " "))
 | |
| 	}
 | |
| 
 | |
| 	imageName := "golang:" + config.GoVersion
 | |
| 	if err := ensureImageAvailable(ctx, cli, imageName, config.Verbose); err != nil {
 | |
| 		return fmt.Errorf("failed to ensure image availability: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	resp, err := createGoTestContainer(ctx, cli, config, containerName, absLogsDir, goTestCmd)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to create container: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if config.Verbose {
 | |
| 		log.Printf("Created container: %s", resp.ID)
 | |
| 	}
 | |
| 
 | |
| 	if err := cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
 | |
| 		return fmt.Errorf("failed to start container: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	log.Printf("Starting test: %s", config.TestPattern)
 | |
| 
 | |
| 	// Start stats collection for container resource monitoring (if enabled)
 | |
| 	var statsCollector *StatsCollector
 | |
| 	if config.Stats {
 | |
| 		var err error
 | |
| 		statsCollector, err = NewStatsCollector()
 | |
| 		if err != nil {
 | |
| 			if config.Verbose {
 | |
| 				log.Printf("Warning: failed to create stats collector: %v", err)
 | |
| 			}
 | |
| 			statsCollector = nil
 | |
| 		}
 | |
| 
 | |
| 		if statsCollector != nil {
 | |
| 			defer statsCollector.Close()
 | |
| 
 | |
| 			// Start stats collection immediately - no need for complex retry logic
 | |
| 			// The new implementation monitors Docker events and will catch containers as they start
 | |
| 			if err := statsCollector.StartCollection(ctx, runID, config.Verbose); err != nil {
 | |
| 				if config.Verbose {
 | |
| 					log.Printf("Warning: failed to start stats collection: %v", err)
 | |
| 				}
 | |
| 			}
 | |
| 			defer statsCollector.StopCollection()
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	exitCode, err := streamAndWait(ctx, cli, resp.ID)
 | |
| 
 | |
| 	// Ensure all containers have finished and logs are flushed before extracting artifacts
 | |
| 	if waitErr := waitForContainerFinalization(ctx, cli, resp.ID, config.Verbose); waitErr != nil && config.Verbose {
 | |
| 		log.Printf("Warning: failed to wait for container finalization: %v", waitErr)
 | |
| 	}
 | |
| 
 | |
| 	// Extract artifacts from test containers before cleanup
 | |
| 	if err := extractArtifactsFromContainers(ctx, resp.ID, logsDir, config.Verbose); err != nil && config.Verbose {
 | |
| 		log.Printf("Warning: failed to extract artifacts from containers: %v", err)
 | |
| 	}
 | |
| 
 | |
| 	// Always list control files regardless of test outcome
 | |
| 	listControlFiles(logsDir)
 | |
| 
 | |
| 	// Print stats summary and check memory limits if enabled
 | |
| 	if config.Stats && statsCollector != nil {
 | |
| 		violations := statsCollector.PrintSummaryAndCheckLimits(config.HSMemoryLimit, config.TSMemoryLimit)
 | |
| 		if len(violations) > 0 {
 | |
| 			log.Printf("MEMORY LIMIT VIOLATIONS DETECTED:")
 | |
| 			log.Printf("=================================")
 | |
| 			for _, violation := range violations {
 | |
| 				log.Printf("Container %s exceeded memory limit: %.1f MB > %.1f MB",
 | |
| 					violation.ContainerName, violation.MaxMemoryMB, violation.LimitMB)
 | |
| 			}
 | |
| 
 | |
| 			return fmt.Errorf("test failed: %d container(s) exceeded memory limits", len(violations))
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	shouldCleanup := config.CleanAfter && (!config.KeepOnFailure || exitCode == 0)
 | |
| 	if shouldCleanup {
 | |
| 		if config.Verbose {
 | |
| 			log.Printf("Running post-test cleanup...")
 | |
| 		}
 | |
| 		if cleanErr := cleanupAfterTest(ctx, cli, resp.ID); cleanErr != nil && config.Verbose {
 | |
| 			log.Printf("Warning: post-test cleanup failed: %v", cleanErr)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("test execution failed: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if exitCode != 0 {
 | |
| 		return fmt.Errorf("%w: exit code %d", ErrTestFailed, exitCode)
 | |
| 	}
 | |
| 
 | |
| 	log.Printf("Test completed successfully!")
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // buildGoTestCommand constructs the go test command arguments.
 | |
| func buildGoTestCommand(config *RunConfig) []string {
 | |
| 	cmd := []string{"go", "test", "./..."}
 | |
| 
 | |
| 	if config.TestPattern != "" {
 | |
| 		cmd = append(cmd, "-run", config.TestPattern)
 | |
| 	}
 | |
| 
 | |
| 	if config.FailFast {
 | |
| 		cmd = append(cmd, "-failfast")
 | |
| 	}
 | |
| 
 | |
| 	cmd = append(cmd, "-timeout", config.Timeout.String())
 | |
| 	cmd = append(cmd, "-v")
 | |
| 
 | |
| 	return cmd
 | |
| }
 | |
| 
 | |
| // createGoTestContainer creates a Docker container configured for running integration tests.
 | |
| func createGoTestContainer(ctx context.Context, cli *client.Client, config *RunConfig, containerName, logsDir string, goTestCmd []string) (container.CreateResponse, error) {
 | |
| 	pwd, err := os.Getwd()
 | |
| 	if err != nil {
 | |
| 		return container.CreateResponse{}, fmt.Errorf("failed to get working directory: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	projectRoot := findProjectRoot(pwd)
 | |
| 
 | |
| 	runID := dockertestutil.ExtractRunIDFromContainerName(containerName)
 | |
| 
 | |
| 	env := []string{
 | |
| 		fmt.Sprintf("HEADSCALE_INTEGRATION_POSTGRES=%d", boolToInt(config.UsePostgres)),
 | |
| 		"HEADSCALE_INTEGRATION_RUN_ID=" + runID,
 | |
| 	}
 | |
| 	containerConfig := &container.Config{
 | |
| 		Image:      "golang:" + config.GoVersion,
 | |
| 		Cmd:        goTestCmd,
 | |
| 		Env:        env,
 | |
| 		WorkingDir: projectRoot + "/integration",
 | |
| 		Tty:        true,
 | |
| 		Labels: map[string]string{
 | |
| 			"hi.run-id":    runID,
 | |
| 			"hi.test-type": "test-runner",
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	// Get the correct Docker socket path from the current context
 | |
| 	dockerSocketPath := getDockerSocketPath()
 | |
| 
 | |
| 	if config.Verbose {
 | |
| 		log.Printf("Using Docker socket: %s", dockerSocketPath)
 | |
| 	}
 | |
| 
 | |
| 	hostConfig := &container.HostConfig{
 | |
| 		AutoRemove: false, // We'll remove manually for better control
 | |
| 		Binds: []string{
 | |
| 			fmt.Sprintf("%s:%s", projectRoot, projectRoot),
 | |
| 			dockerSocketPath + ":/var/run/docker.sock",
 | |
| 			logsDir + ":/tmp/control",
 | |
| 		},
 | |
| 		Mounts: []mount.Mount{
 | |
| 			{
 | |
| 				Type:   mount.TypeVolume,
 | |
| 				Source: "hs-integration-go-cache",
 | |
| 				Target: "/go",
 | |
| 			},
 | |
| 		},
 | |
| 	}
 | |
| 
 | |
| 	return cli.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, containerName)
 | |
| }
 | |
| 
 | |
| // streamAndWait streams container output and waits for completion.
 | |
| func streamAndWait(ctx context.Context, cli *client.Client, containerID string) (int, error) {
 | |
| 	out, err := cli.ContainerLogs(ctx, containerID, container.LogsOptions{
 | |
| 		ShowStdout: true,
 | |
| 		ShowStderr: true,
 | |
| 		Follow:     true,
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return -1, fmt.Errorf("failed to get container logs: %w", err)
 | |
| 	}
 | |
| 	defer out.Close()
 | |
| 
 | |
| 	go func() {
 | |
| 		_, _ = io.Copy(os.Stdout, out)
 | |
| 	}()
 | |
| 
 | |
| 	statusCh, errCh := cli.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
 | |
| 	select {
 | |
| 	case err := <-errCh:
 | |
| 		if err != nil {
 | |
| 			return -1, fmt.Errorf("error waiting for container: %w", err)
 | |
| 		}
 | |
| 	case status := <-statusCh:
 | |
| 		return int(status.StatusCode), nil
 | |
| 	}
 | |
| 
 | |
| 	return -1, ErrUnexpectedContainerWait
 | |
| }
 | |
| 
 | |
| // waitForContainerFinalization ensures all test containers have properly finished and flushed their output.
 | |
| func waitForContainerFinalization(ctx context.Context, cli *client.Client, testContainerID string, verbose bool) error {
 | |
| 	// First, get all related test containers
 | |
| 	containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to list containers: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	testContainers := getCurrentTestContainers(containers, testContainerID, verbose)
 | |
| 
 | |
| 	// Wait for all test containers to reach a final state
 | |
| 	maxWaitTime := 10 * time.Second
 | |
| 	checkInterval := 500 * time.Millisecond
 | |
| 	timeout := time.After(maxWaitTime)
 | |
| 	ticker := time.NewTicker(checkInterval)
 | |
| 	defer ticker.Stop()
 | |
| 
 | |
| 	for {
 | |
| 		select {
 | |
| 		case <-timeout:
 | |
| 			if verbose {
 | |
| 				log.Printf("Timeout waiting for container finalization, proceeding with artifact extraction")
 | |
| 			}
 | |
| 			return nil
 | |
| 		case <-ticker.C:
 | |
| 			allFinalized := true
 | |
| 
 | |
| 			for _, testCont := range testContainers {
 | |
| 				inspect, err := cli.ContainerInspect(ctx, testCont.ID)
 | |
| 				if err != nil {
 | |
| 					if verbose {
 | |
| 						log.Printf("Warning: failed to inspect container %s: %v", testCont.name, err)
 | |
| 					}
 | |
| 					continue
 | |
| 				}
 | |
| 
 | |
| 				// Check if container is in a final state
 | |
| 				if !isContainerFinalized(inspect.State) {
 | |
| 					allFinalized = false
 | |
| 					if verbose {
 | |
| 						log.Printf("Container %s still finalizing (state: %s)", testCont.name, inspect.State.Status)
 | |
| 					}
 | |
| 
 | |
| 					break
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 			if allFinalized {
 | |
| 				if verbose {
 | |
| 					log.Printf("All test containers finalized, ready for artifact extraction")
 | |
| 				}
 | |
| 				return nil
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // isContainerFinalized checks if a container has reached a final state where logs are flushed.
 | |
| func isContainerFinalized(state *container.State) bool {
 | |
| 	// Container is finalized if it's not running and has a finish time
 | |
| 	return !state.Running && state.FinishedAt != ""
 | |
| }
 | |
| 
 | |
| // findProjectRoot locates the project root by finding the directory containing go.mod.
 | |
| func findProjectRoot(startPath string) string {
 | |
| 	current := startPath
 | |
| 	for {
 | |
| 		if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
 | |
| 			return current
 | |
| 		}
 | |
| 		parent := filepath.Dir(current)
 | |
| 		if parent == current {
 | |
| 			return startPath
 | |
| 		}
 | |
| 		current = parent
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // boolToInt converts a boolean to an integer for environment variables.
 | |
| func boolToInt(b bool) int {
 | |
| 	if b {
 | |
| 		return 1
 | |
| 	}
 | |
| 	return 0
 | |
| }
 | |
| 
 | |
| // DockerContext represents Docker context information.
 | |
| type DockerContext struct {
 | |
| 	Name      string                 `json:"Name"`
 | |
| 	Metadata  map[string]interface{} `json:"Metadata"`
 | |
| 	Endpoints map[string]interface{} `json:"Endpoints"`
 | |
| 	Current   bool                   `json:"Current"`
 | |
| }
 | |
| 
 | |
| // createDockerClient creates a Docker client with context detection.
 | |
| func createDockerClient() (*client.Client, error) {
 | |
| 	contextInfo, err := getCurrentDockerContext()
 | |
| 	if err != nil {
 | |
| 		return client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
 | |
| 	}
 | |
| 
 | |
| 	var clientOpts []client.Opt
 | |
| 	clientOpts = append(clientOpts, client.WithAPIVersionNegotiation())
 | |
| 
 | |
| 	if contextInfo != nil {
 | |
| 		if endpoints, ok := contextInfo.Endpoints["docker"]; ok {
 | |
| 			if endpointMap, ok := endpoints.(map[string]interface{}); ok {
 | |
| 				if host, ok := endpointMap["Host"].(string); ok {
 | |
| 					if runConfig.Verbose {
 | |
| 						log.Printf("Using Docker host from context '%s': %s", contextInfo.Name, host)
 | |
| 					}
 | |
| 					clientOpts = append(clientOpts, client.WithHost(host))
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if len(clientOpts) == 1 {
 | |
| 		clientOpts = append(clientOpts, client.FromEnv)
 | |
| 	}
 | |
| 
 | |
| 	return client.NewClientWithOpts(clientOpts...)
 | |
| }
 | |
| 
 | |
| // getCurrentDockerContext retrieves the current Docker context information.
 | |
| func getCurrentDockerContext() (*DockerContext, error) {
 | |
| 	cmd := exec.Command("docker", "context", "inspect")
 | |
| 	output, err := cmd.Output()
 | |
| 	if err != nil {
 | |
| 		return nil, fmt.Errorf("failed to get docker context: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	var contexts []DockerContext
 | |
| 	if err := json.Unmarshal(output, &contexts); err != nil {
 | |
| 		return nil, fmt.Errorf("failed to parse docker context: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if len(contexts) > 0 {
 | |
| 		return &contexts[0], nil
 | |
| 	}
 | |
| 
 | |
| 	return nil, ErrNoDockerContext
 | |
| }
 | |
| 
 | |
| // getDockerSocketPath returns the correct Docker socket path for the current context.
 | |
| func getDockerSocketPath() string {
 | |
| 	// Always use the default socket path for mounting since Docker handles
 | |
| 	// the translation to the actual socket (e.g., colima socket) internally
 | |
| 	return "/var/run/docker.sock"
 | |
| }
 | |
| 
 | |
| // checkImageAvailableLocally checks if the specified Docker image is available locally.
 | |
| func checkImageAvailableLocally(ctx context.Context, cli *client.Client, imageName string) (bool, error) {
 | |
| 	_, _, err := cli.ImageInspectWithRaw(ctx, imageName)
 | |
| 	if err != nil {
 | |
| 		if client.IsErrNotFound(err) {
 | |
| 			return false, nil
 | |
| 		}
 | |
| 		return false, fmt.Errorf("failed to inspect image %s: %w", imageName, err)
 | |
| 	}
 | |
| 
 | |
| 	return true, nil
 | |
| }
 | |
| 
 | |
| // ensureImageAvailable checks if the image is available locally first, then pulls if needed.
 | |
| func ensureImageAvailable(ctx context.Context, cli *client.Client, imageName string, verbose bool) error {
 | |
| 	// First check if image is available locally
 | |
| 	available, err := checkImageAvailableLocally(ctx, cli, imageName)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to check local image availability: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if available {
 | |
| 		if verbose {
 | |
| 			log.Printf("Image %s is available locally", imageName)
 | |
| 		}
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	// Image not available locally, try to pull it
 | |
| 	if verbose {
 | |
| 		log.Printf("Image %s not found locally, pulling...", imageName)
 | |
| 	}
 | |
| 
 | |
| 	reader, err := cli.ImagePull(ctx, imageName, image.PullOptions{})
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to pull image %s: %w", imageName, err)
 | |
| 	}
 | |
| 	defer reader.Close()
 | |
| 
 | |
| 	if verbose {
 | |
| 		_, err = io.Copy(os.Stdout, reader)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("failed to read pull output: %w", err)
 | |
| 		}
 | |
| 	} else {
 | |
| 		_, err = io.Copy(io.Discard, reader)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("failed to read pull output: %w", err)
 | |
| 		}
 | |
| 		log.Printf("Image %s pulled successfully", imageName)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // listControlFiles displays the headscale test artifacts created in the control logs directory.
 | |
| func listControlFiles(logsDir string) {
 | |
| 	entries, err := os.ReadDir(logsDir)
 | |
| 	if err != nil {
 | |
| 		log.Printf("Logs directory: %s", logsDir)
 | |
| 		return
 | |
| 	}
 | |
| 
 | |
| 	var logFiles []string
 | |
| 	var dataFiles []string
 | |
| 	var dataDirs []string
 | |
| 
 | |
| 	for _, entry := range entries {
 | |
| 		name := entry.Name()
 | |
| 		// Only show headscale (hs-*) files and directories
 | |
| 		if !strings.HasPrefix(name, "hs-") {
 | |
| 			continue
 | |
| 		}
 | |
| 
 | |
| 		if entry.IsDir() {
 | |
| 			// Include directories (pprof, mapresponses)
 | |
| 			if strings.Contains(name, "-pprof") || strings.Contains(name, "-mapresponses") {
 | |
| 				dataDirs = append(dataDirs, name)
 | |
| 			}
 | |
| 		} else {
 | |
| 			// Include files
 | |
| 			switch {
 | |
| 			case strings.HasSuffix(name, ".stderr.log") || strings.HasSuffix(name, ".stdout.log"):
 | |
| 				logFiles = append(logFiles, name)
 | |
| 			case strings.HasSuffix(name, ".db"):
 | |
| 				dataFiles = append(dataFiles, name)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	log.Printf("Test artifacts saved to: %s", logsDir)
 | |
| 
 | |
| 	if len(logFiles) > 0 {
 | |
| 		log.Printf("Headscale logs:")
 | |
| 		for _, file := range logFiles {
 | |
| 			log.Printf("  %s", file)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if len(dataFiles) > 0 || len(dataDirs) > 0 {
 | |
| 		log.Printf("Headscale data:")
 | |
| 		for _, file := range dataFiles {
 | |
| 			log.Printf("  %s", file)
 | |
| 		}
 | |
| 		for _, dir := range dataDirs {
 | |
| 			log.Printf("  %s/", dir)
 | |
| 		}
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // extractArtifactsFromContainers collects container logs and files from the specific test run.
 | |
| func extractArtifactsFromContainers(ctx context.Context, testContainerID, logsDir string, verbose bool) error {
 | |
| 	cli, err := createDockerClient()
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to create Docker client: %w", err)
 | |
| 	}
 | |
| 	defer cli.Close()
 | |
| 
 | |
| 	// List all containers
 | |
| 	containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to list containers: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Get containers from the specific test run
 | |
| 	currentTestContainers := getCurrentTestContainers(containers, testContainerID, verbose)
 | |
| 
 | |
| 	extractedCount := 0
 | |
| 	for _, cont := range currentTestContainers {
 | |
| 		// Extract container logs and tar files
 | |
| 		if err := extractContainerArtifacts(ctx, cli, cont.ID, cont.name, logsDir, verbose); err != nil {
 | |
| 			if verbose {
 | |
| 				log.Printf("Warning: failed to extract artifacts from container %s (%s): %v", cont.name, cont.ID[:12], err)
 | |
| 			}
 | |
| 		} else {
 | |
| 			if verbose {
 | |
| 				log.Printf("Extracted artifacts from container %s (%s)", cont.name, cont.ID[:12])
 | |
| 			}
 | |
| 			extractedCount++
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if verbose && extractedCount > 0 {
 | |
| 		log.Printf("Extracted artifacts from %d containers", extractedCount)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // testContainer represents a container from the current test run.
 | |
| type testContainer struct {
 | |
| 	ID   string
 | |
| 	name string
 | |
| }
 | |
| 
 | |
| // getCurrentTestContainers filters containers to only include those from the current test run.
 | |
| func getCurrentTestContainers(containers []container.Summary, testContainerID string, verbose bool) []testContainer {
 | |
| 	var testRunContainers []testContainer
 | |
| 
 | |
| 	// Find the test container to get its run ID label
 | |
| 	var runID string
 | |
| 	for _, cont := range containers {
 | |
| 		if cont.ID == testContainerID {
 | |
| 			if cont.Labels != nil {
 | |
| 				runID = cont.Labels["hi.run-id"]
 | |
| 			}
 | |
| 			break
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if runID == "" {
 | |
| 		log.Printf("Error: test container %s missing required hi.run-id label", testContainerID[:12])
 | |
| 		return testRunContainers
 | |
| 	}
 | |
| 
 | |
| 	if verbose {
 | |
| 		log.Printf("Looking for containers with run ID: %s", runID)
 | |
| 	}
 | |
| 
 | |
| 	// Find all containers with the same run ID
 | |
| 	for _, cont := range containers {
 | |
| 		for _, name := range cont.Names {
 | |
| 			containerName := strings.TrimPrefix(name, "/")
 | |
| 			if strings.HasPrefix(containerName, "hs-") || strings.HasPrefix(containerName, "ts-") {
 | |
| 				// Check if container has matching run ID label
 | |
| 				if cont.Labels != nil && cont.Labels["hi.run-id"] == runID {
 | |
| 					testRunContainers = append(testRunContainers, testContainer{
 | |
| 						ID:   cont.ID,
 | |
| 						name: containerName,
 | |
| 					})
 | |
| 					if verbose {
 | |
| 						log.Printf("Including container %s (run ID: %s)", containerName, runID)
 | |
| 					}
 | |
| 				}
 | |
| 
 | |
| 				break
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return testRunContainers
 | |
| }
 | |
| 
 | |
| // extractContainerArtifacts saves logs and tar files from a container.
 | |
| func extractContainerArtifacts(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
 | |
| 	// Ensure the logs directory exists
 | |
| 	if err := os.MkdirAll(logsDir, 0o755); err != nil {
 | |
| 		return fmt.Errorf("failed to create logs directory: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Extract container logs
 | |
| 	if err := extractContainerLogs(ctx, cli, containerID, containerName, logsDir, verbose); err != nil {
 | |
| 		return fmt.Errorf("failed to extract logs: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Extract tar files for headscale containers only
 | |
| 	if strings.HasPrefix(containerName, "hs-") {
 | |
| 		if err := extractContainerFiles(ctx, cli, containerID, containerName, logsDir, verbose); err != nil {
 | |
| 			if verbose {
 | |
| 				log.Printf("Warning: failed to extract files from %s: %v", containerName, err)
 | |
| 			}
 | |
| 			// Don't fail the whole extraction if files are missing
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // extractContainerLogs saves the stdout and stderr logs from a container to files.
 | |
| func extractContainerLogs(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
 | |
| 	// Get container logs
 | |
| 	logReader, err := cli.ContainerLogs(ctx, containerID, container.LogsOptions{
 | |
| 		ShowStdout: true,
 | |
| 		ShowStderr: true,
 | |
| 		Timestamps: false,
 | |
| 		Follow:     false,
 | |
| 		Tail:       "all",
 | |
| 	})
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to get container logs: %w", err)
 | |
| 	}
 | |
| 	defer logReader.Close()
 | |
| 
 | |
| 	// Create log files following the headscale naming convention
 | |
| 	stdoutPath := filepath.Join(logsDir, containerName+".stdout.log")
 | |
| 	stderrPath := filepath.Join(logsDir, containerName+".stderr.log")
 | |
| 
 | |
| 	// Create buffers to capture stdout and stderr separately
 | |
| 	var stdoutBuf, stderrBuf bytes.Buffer
 | |
| 
 | |
| 	// Demultiplex the Docker logs stream to separate stdout and stderr
 | |
| 	_, err = stdcopy.StdCopy(&stdoutBuf, &stderrBuf, logReader)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to demultiplex container logs: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Write stdout logs
 | |
| 	if err := os.WriteFile(stdoutPath, stdoutBuf.Bytes(), 0o644); err != nil {
 | |
| 		return fmt.Errorf("failed to write stdout log: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	// Write stderr logs
 | |
| 	if err := os.WriteFile(stderrPath, stderrBuf.Bytes(), 0o644); err != nil {
 | |
| 		return fmt.Errorf("failed to write stderr log: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if verbose {
 | |
| 		log.Printf("Saved logs for %s: %s, %s", containerName, stdoutPath, stderrPath)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // extractContainerFiles extracts database file and directories from headscale containers.
 | |
| // Note: The actual file extraction is now handled by the integration tests themselves
 | |
| // via SaveProfile, SaveMapResponses, and SaveDatabase functions in hsic.go.
 | |
| func extractContainerFiles(ctx context.Context, cli *client.Client, containerID, containerName, logsDir string, verbose bool) error {
 | |
| 	// Files are now extracted directly by the integration tests
 | |
| 	// This function is kept for potential future use or other file types
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // logExtractionError logs extraction errors with appropriate level based on error type.
 | |
| func logExtractionError(artifactType, containerName string, err error, verbose bool) {
 | |
| 	if errors.Is(err, ErrFileNotFoundInTar) {
 | |
| 		// File not found is expected and only logged in verbose mode
 | |
| 		if verbose {
 | |
| 			log.Printf("No %s found in container %s", artifactType, containerName)
 | |
| 		}
 | |
| 	} else {
 | |
| 		// Other errors are actual failures and should be logged as warnings
 | |
| 		log.Printf("Warning: failed to extract %s from %s: %v", artifactType, containerName, err)
 | |
| 	}
 | |
| }
 | |
| 
 | |
| // extractSingleFile copies a single file from a container.
 | |
| func extractSingleFile(ctx context.Context, cli *client.Client, containerID, sourcePath, fileName, logsDir string, verbose bool) error {
 | |
| 	tarReader, _, err := cli.CopyFromContainer(ctx, containerID, sourcePath)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to copy %s from container: %w", sourcePath, err)
 | |
| 	}
 | |
| 	defer tarReader.Close()
 | |
| 
 | |
| 	// Extract the single file from the tar
 | |
| 	filePath := filepath.Join(logsDir, fileName)
 | |
| 	if err := extractFileFromTar(tarReader, filepath.Base(sourcePath), filePath); err != nil {
 | |
| 		return fmt.Errorf("failed to extract file from tar: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if verbose {
 | |
| 		log.Printf("Extracted %s from %s", fileName, containerID[:12])
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // extractDirectory copies a directory from a container and extracts its contents.
 | |
| func extractDirectory(ctx context.Context, cli *client.Client, containerID, sourcePath, dirName, logsDir string, verbose bool) error {
 | |
| 	tarReader, _, err := cli.CopyFromContainer(ctx, containerID, sourcePath)
 | |
| 	if err != nil {
 | |
| 		return fmt.Errorf("failed to copy %s from container: %w", sourcePath, err)
 | |
| 	}
 | |
| 	defer tarReader.Close()
 | |
| 
 | |
| 	// Create target directory
 | |
| 	targetDir := filepath.Join(logsDir, dirName)
 | |
| 	if err := os.MkdirAll(targetDir, 0o755); err != nil {
 | |
| 		return fmt.Errorf("failed to create directory %s: %w", targetDir, err)
 | |
| 	}
 | |
| 
 | |
| 	// Extract the directory from the tar
 | |
| 	if err := extractDirectoryFromTar(tarReader, targetDir); err != nil {
 | |
| 		return fmt.Errorf("failed to extract directory from tar: %w", err)
 | |
| 	}
 | |
| 
 | |
| 	if verbose {
 | |
| 		log.Printf("Extracted %s/ from %s", dirName, containerID[:12])
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 |