mirror of
https://github.com/juanfont/headscale.git
synced 2025-06-25 17:48:31 +00:00
365 lines
9.5 KiB
Go
365 lines
9.5 KiB
Go
![]() |
package main
|
||
|
|
||
|
import (
|
||
|
"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"
|
||
|
)
|
||
|
|
||
|
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 := 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)
|
||
|
|
||
|
exitCode, err := streamAndWait(ctx, cli, resp.ID)
|
||
|
|
||
|
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!")
|
||
|
listControlFiles(logsDir)
|
||
|
|
||
|
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)
|
||
|
|
||
|
env := []string{
|
||
|
fmt.Sprintf("HEADSCALE_INTEGRATION_POSTGRES=%d", boolToInt(config.UsePostgres)),
|
||
|
}
|
||
|
|
||
|
containerConfig := &container.Config{
|
||
|
Image: "golang:" + config.GoVersion,
|
||
|
Cmd: goTestCmd,
|
||
|
Env: env,
|
||
|
WorkingDir: projectRoot + "/integration",
|
||
|
Tty: true,
|
||
|
}
|
||
|
|
||
|
hostConfig := &container.HostConfig{
|
||
|
AutoRemove: false, // We'll remove manually for better control
|
||
|
Binds: []string{
|
||
|
fmt.Sprintf("%s:%s", projectRoot, projectRoot),
|
||
|
"/var/run/docker.sock:/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
|
||
|
}
|
||
|
|
||
|
// generateRunID creates a unique timestamp-based run identifier.
|
||
|
func generateRunID() string {
|
||
|
now := time.Now()
|
||
|
timestamp := now.Format("20060102-150405")
|
||
|
return timestamp
|
||
|
}
|
||
|
|
||
|
// 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
|
||
|
}
|
||
|
|
||
|
// ensureImageAvailable pulls the specified Docker image to ensure it's available.
|
||
|
func ensureImageAvailable(ctx context.Context, cli *client.Client, imageName string, verbose bool) error {
|
||
|
if verbose {
|
||
|
log.Printf("Pulling image %s...", 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 tarFiles []string
|
||
|
|
||
|
for _, entry := range entries {
|
||
|
if entry.IsDir() {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
name := entry.Name()
|
||
|
// Only show headscale (hs-*) files
|
||
|
if !strings.HasPrefix(name, "hs-") {
|
||
|
continue
|
||
|
}
|
||
|
|
||
|
switch {
|
||
|
case strings.HasSuffix(name, ".stderr.log") || strings.HasSuffix(name, ".stdout.log"):
|
||
|
logFiles = append(logFiles, name)
|
||
|
case strings.HasSuffix(name, ".pprof.tar") || strings.HasSuffix(name, ".maps.tar") || strings.HasSuffix(name, ".db.tar"):
|
||
|
tarFiles = append(tarFiles, 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(tarFiles) > 0 {
|
||
|
log.Printf("Headscale archives:")
|
||
|
for _, file := range tarFiles {
|
||
|
log.Printf(" %s", file)
|
||
|
}
|
||
|
}
|
||
|
}
|