integration: avoid toolchain work and login waits in CI

This commit is contained in:
Kristoffer Dalby
2026-10-07 14:29:29 +00:00
parent 519b4621f3
commit 47653f06fb
10 changed files with 715 additions and 88 deletions
+11 -14
View File
@@ -61,10 +61,10 @@ jobs:
with: with:
name: hi-binary name: hi-binary
path: /tmp/artifacts path: /tmp/artifacts
- name: Download Go cache - name: Download integration test binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with: with:
name: go-cache name: integration-test-binary
path: /tmp/artifacts path: /tmp/artifacts
- name: Download postgres image - name: Download postgres image
if: ${{ inputs.postgres_flag == '--postgres=1' }} if: ${{ inputs.postgres_flag == '--postgres=1' }}
@@ -94,7 +94,7 @@ jobs:
with: with:
username: ${{ env.DOCKERHUB_USERNAME }} username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }} password: ${{ env.DOCKERHUB_TOKEN }}
- name: Load Docker images, Go cache, and prepare binary - name: Load Docker images and prepare binaries
run: | run: |
gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load gunzip -c /tmp/artifacts/headscale-image.tar.gz | docker load
gunzip -c /tmp/artifacts/tailscale-head-image.tar.gz | docker load gunzip -c /tmp/artifacts/tailscale-head-image.tar.gz | docker load
@@ -102,33 +102,30 @@ jobs:
if [ -f /tmp/artifacts/postgres-image.tar.gz ]; then if [ -f /tmp/artifacts/postgres-image.tar.gz ]; then
gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load gunzip -c /tmp/artifacts/postgres-image.tar.gz | docker load
fi fi
chmod +x /tmp/artifacts/hi chmod +x /tmp/artifacts/hi /tmp/artifacts/integration.test
docker images docker images
# Extract Go cache to host directories for bind mounting
mkdir -p /tmp/go-cache
tar -xzf /tmp/artifacts/go-cache.tar.gz -C /tmp/go-cache
ls -la /tmp/go-cache/ /tmp/go-cache/.cache/
- name: Run Integration Test - name: Run Integration Test
env: env:
HEADSCALE_INTEGRATION_HEADSCALE_IMAGE: headscale:${{ github.sha }} HEADSCALE_INTEGRATION_HEADSCALE_IMAGE: headscale:${{ github.sha }}
HEADSCALE_INTEGRATION_TAILSCALE_IMAGE: tailscale-head:${{ github.sha }} HEADSCALE_INTEGRATION_TAILSCALE_IMAGE: tailscale-head:${{ github.sha }}
HEADSCALE_INTEGRATION_POSTGRES_IMAGE: ${{ inputs.postgres_flag == '--postgres=1' && format('postgres:{0}', github.sha) || '' }} HEADSCALE_INTEGRATION_POSTGRES_IMAGE: ${{ inputs.postgres_flag == '--postgres=1' && format('postgres:{0}', github.sha) || '' }}
HEADSCALE_INTEGRATION_GO_CACHE: /tmp/go-cache/go
HEADSCALE_INTEGRATION_GO_BUILD_CACHE: /tmp/go-cache/.cache/go-build
# Mirror the docker/login-action secrets into env so the # Mirror the docker/login-action secrets into env so the
# dockertestutil.Credentials resolver picks them up directly # dockertestutil.Credentials resolver picks them up directly
# (otherwise it falls back to parsing ~/.docker/config.json, # (otherwise it falls back to parsing ~/.docker/config.json,
# which works but is one step further from the source). # which works but is one step further from the source).
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_CI_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_CI_TOKEN }}
run: /tmp/artifacts/hi run --stats --ts-memory-limit=300 --hs-memory-limit=1500 "^${{ inputs.test }}$" \ run: |
--timeout=120m \ /tmp/artifacts/hi run --stats --ts-memory-limit=300 --hs-memory-limit=1500 \
${{ inputs.postgres_flag }} --test-binary=/tmp/artifacts/integration.test \
--timeout=120m \
${{ inputs.postgres_flag }} \
"^${{ inputs.test }}$"
# Sanitize test name for artifact upload (replace invalid characters: " : < > | * ? \ / with -) # Sanitize test name for artifact upload (replace invalid characters: " : < > | * ? \ / with -)
- name: Sanitize test name for artifacts - name: Sanitize test name for artifacts
if: always() if: always()
id: sanitize id: sanitize
run: echo "name=${TEST_NAME//[\":<>|*?\\\/]/-}" >> $GITHUB_OUTPUT run: echo "name=${TEST_NAME//[\":<>|*?\\\/]/-}" >> "$GITHUB_OUTPUT"
env: env:
TEST_NAME: ${{ inputs.test }} TEST_NAME: ${{ inputs.test }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+24
View File
@@ -56,6 +56,7 @@ changing.
| `--postgres` | `false` | Use PostgreSQL instead of SQLite | | `--postgres` | `false` | Use PostgreSQL instead of SQLite |
| `--failfast` | `true` | Stop on first test failure | | `--failfast` | `true` | Stop on first test failure |
| `--go-version` | auto | Detected from `go.mod` (currently 1.27.0) | | `--go-version` | auto | Detected from `go.mod` (currently 1.27.0) |
| `--test-binary` | unset | Execute a precompiled integration test binary instead of running `go test` |
| `--clean-before` | `true` | Clean stale (stopped/exited) containers before starting | | `--clean-before` | `true` | Clean stale (stopped/exited) containers before starting |
| `--clean-after` | `true` | Clean this run's containers after completion | | `--clean-after` | `true` | Clean this run's containers after completion |
| `--keep-on-failure` | `false` | Preserve containers for manual inspection on failure | | `--keep-on-failure` | `false` | Preserve containers for manual inspection on failure |
@@ -65,6 +66,29 @@ changing.
| `--hs-memory-limit` | `0` | Fail if any headscale container exceeds N MB (0 = disabled) | | `--hs-memory-limit` | `0` | Fail if any headscale container exceeds N MB (0 = disabled) |
| `--ts-memory-limit` | `0` | Fail if any tailscale container exceeds N MB | | `--ts-memory-limit` | `0` | Fail if any tailscale container exceeds N MB |
### Precompiled tests in CI
CI builds the Headscale server, `hi`, and the integration test binary once
for the worker architecture. Each test job loads the prebuilt images and
runs its own fresh test container:
```bash
CGO_ENABLED=0 GOOS=linux go test -c ./integration -o integration.test
go run ./cmd/hi run --test-binary=./integration.test "^TestHeadscale$"
```
With `--test-binary`, the runner mounts the executable read-only and uses
`HEADSCALE_INTEGRATION_HEADSCALE_IMAGE` as its runtime image when set.
Otherwise it uses the usual Go image. Build the binary with `CGO_ENABLED=0`
for the runner architecture so it needs no compiler or shared Go libraries.
The checkout, test fixtures, Docker access, per-run logs, resource checks,
and cleanup work the same as in source-based runs. CI does not distribute
Go caches to these workers.
Automatic preflight checks Docker and the checkout. Images are prepared by
their consumers, so ordinary tests do not pull Kubernetes images. The
standalone `doctor` command retains the comprehensive development checks.
### Timeout guidance ### Timeout guidance
The default `120m` is generous for a single test. If you must tune it, The default `120m` is generous for a single test. If you must tune it,
+75 -34
View File
@@ -73,17 +73,17 @@ func runTestContainer(ctx context.Context, config *RunConfig) error {
} }
} }
goTestCmd := buildGoTestCommand(config) testCmd := buildTestCommand(config)
if config.Verbose { if config.Verbose {
log.Printf("Command: %s", strings.Join(goTestCmd, " ")) log.Printf("Command: %s", strings.Join(testCmd, " "))
} }
imageName := "golang:" + config.GoVersion imageName := testRunnerImage(config)
if err := ensureImageAvailable(ctx, cli, imageName, config.Verbose); err != nil { //nolint:noinlineerr if err := ensureImageAvailable(ctx, cli, imageName, config.Verbose); err != nil { //nolint:noinlineerr
return fmt.Errorf("ensuring image availability: %w", err) return fmt.Errorf("ensuring image availability: %w", err)
} }
resp, err := createGoTestContainer(ctx, cli, config, containerName, absLogsDir, goTestCmd) resp, err := createTestContainer(ctx, cli, config, containerName, absLogsDir, testCmd)
if err != nil { if err != nil {
return fmt.Errorf("creating container: %w", err) return fmt.Errorf("creating container: %w", err)
} }
@@ -202,26 +202,44 @@ func runTestContainer(ctx context.Context, config *RunConfig) error {
return nil return nil
} }
// buildGoTestCommand constructs the go test command arguments. // testRunnerImage reuses the CI server image for the statically built test binary.
func buildGoTestCommand(config *RunConfig) []string { // Source-based runs and local runs without a server image use the Go image.
func testRunnerImage(config *RunConfig) string {
if config.TestBinary != "" {
if image := os.Getenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE"); image != "" {
return image
}
}
return "golang:" + config.GoVersion
}
// buildTestCommand supports both go test and its compiled test binary flags.
func buildTestCommand(config *RunConfig) []string {
cmd := []string{"go", "test", "./..."} cmd := []string{"go", "test", "./..."}
flagPrefix := "-"
if config.TestBinary != "" {
cmd = []string{"/integration.test"}
flagPrefix = "-test."
}
if config.TestPattern != "" { if config.TestPattern != "" {
cmd = append(cmd, "-run", config.TestPattern) cmd = append(cmd, flagPrefix+"run", config.TestPattern)
} }
if config.FailFast { if config.FailFast {
cmd = append(cmd, "-failfast") cmd = append(cmd, flagPrefix+"failfast")
} }
cmd = append(cmd, "-timeout", config.Timeout.String()) cmd = append(cmd, flagPrefix+"timeout", config.Timeout.String())
cmd = append(cmd, "-v") cmd = append(cmd, flagPrefix+"v")
return cmd return cmd
} }
// createGoTestContainer creates a Docker container configured for running integration tests. // createTestContainer 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) { func createTestContainer(ctx context.Context, cli *client.Client, config *RunConfig, containerName, logsDir string, testCmd []string) (container.CreateResponse, error) {
pwd, err := os.Getwd() pwd, err := os.Getwd()
if err != nil { if err != nil {
return container.CreateResponse{}, fmt.Errorf("getting working directory: %w", err) return container.CreateResponse{}, fmt.Errorf("getting working directory: %w", err)
@@ -254,12 +272,14 @@ func createGoTestContainer(ctx context.Context, cli *client.Client, config *RunC
} }
} }
// Set GOCACHE to a known location (used by both bind mount and volume cases) if config.TestBinary == "" {
env = append(env, "GOCACHE=/cache/go-build") // Used by both bind mount and volume cases for source-based runs.
env = append(env, "GOCACHE=/cache/go-build")
}
containerConfig := &container.Config{ containerConfig := &container.Config{
Image: "golang:" + config.GoVersion, Image: testRunnerImage(config),
Cmd: goTestCmd, Cmd: testCmd,
Env: env, Env: env,
WorkingDir: projectRoot + "/integration", WorkingDir: projectRoot + "/integration",
Tty: true, Tty: true,
@@ -286,27 +306,36 @@ func createGoTestContainer(ctx context.Context, cli *client.Client, config *RunC
// otherwise fall back to Docker volumes for local development // otherwise fall back to Docker volumes for local development
var mounts []mount.Mount var mounts []mount.Mount
goCache := os.Getenv("HEADSCALE_INTEGRATION_GO_CACHE") if config.TestBinary != "" {
goBuildCache := os.Getenv("HEADSCALE_INTEGRATION_GO_BUILD_CACHE")
if goCache != "" {
binds = append(binds, goCache+":/go")
} else {
mounts = append(mounts, mount.Mount{ mounts = append(mounts, mount.Mount{
Type: mount.TypeVolume, Type: mount.TypeBind,
Source: "hs-integration-go-cache", Source: config.TestBinary,
Target: "/go", Target: "/integration.test",
ReadOnly: true,
}) })
}
if goBuildCache != "" {
binds = append(binds, goBuildCache+":/cache/go-build")
} else { } else {
mounts = append(mounts, mount.Mount{ goCache := os.Getenv("HEADSCALE_INTEGRATION_GO_CACHE")
Type: mount.TypeVolume, goBuildCache := os.Getenv("HEADSCALE_INTEGRATION_GO_BUILD_CACHE")
Source: "hs-integration-go-build-cache",
Target: "/cache/go-build", if goCache != "" {
}) binds = append(binds, goCache+":/go")
} else {
mounts = append(mounts, mount.Mount{
Type: mount.TypeVolume,
Source: "hs-integration-go-cache",
Target: "/go",
})
}
if goBuildCache != "" {
binds = append(binds, goBuildCache+":/cache/go-build")
} else {
mounts = append(mounts, mount.Mount{
Type: mount.TypeVolume,
Source: "hs-integration-go-build-cache",
Target: "/cache/go-build",
})
}
} }
hostConfig := &container.HostConfig{ hostConfig := &container.HostConfig{
@@ -330,8 +359,12 @@ func streamAndWait(ctx context.Context, cli *client.Client, containerID string)
} }
defer out.Close() defer out.Close()
logsDone := make(chan struct{})
go func() { go func() {
_, _ = io.Copy(os.Stdout, out) _, _ = io.Copy(os.Stdout, out)
close(logsDone)
}() }()
statusCh, errCh := cli.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) statusCh, errCh := cli.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
@@ -341,6 +374,14 @@ func streamAndWait(ctx context.Context, cli *client.Client, containerID string)
return -1, fmt.Errorf("waiting for container: %w", err) return -1, fmt.Errorf("waiting for container: %w", err)
} }
case status := <-statusCh: case status := <-statusCh:
// ContainerWait can finish before the log stream delivers its tail.
// Drain through EOF so fast test exits retain their final summary.
select {
case <-logsDone:
case <-ctx.Done():
return int(status.StatusCode), fmt.Errorf("waiting for container logs: %w", ctx.Err())
}
return int(status.StatusCode), nil return int(status.StatusCode), nil
} }
+14 -3
View File
@@ -53,6 +53,12 @@ func fail(name, message string, suggestions ...string) DoctorResult {
// 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 {
return runPreflightChecks(ctx, true)
}
// runPreflightChecks leaves image preparation to the tests that need it.
// The standalone doctor additionally checks development tools and images.
func runPreflightChecks(ctx context.Context, full bool) error {
results := []DoctorResult{} results := []DoctorResult{}
// Check 1: Docker binary availability // Check 1: Docker binary availability
@@ -67,12 +73,17 @@ func runDoctorCheck(ctx context.Context) error {
results = append(results, checkDockerContext(ctx)) results = append(results, checkDockerContext(ctx))
results = append(results, checkDockerSocket(ctx)) results = append(results, checkDockerSocket(ctx))
results = append(results, checkDockerHubCredentials()) results = append(results, checkDockerHubCredentials())
results = append(results, checkGolangImage(ctx))
results = append(results, checkK3sImage(ctx)) if full {
results = append(results, checkGolangImage(ctx))
results = append(results, checkK3sImage(ctx))
}
} }
// Check 3: Go installation // Check 3: Go installation
results = append(results, checkGoInstallation(ctx)) if full {
results = append(results, checkGoInstallation(ctx))
}
// Check 4: Git repository // Check 4: Git repository
results = append(results, checkGitRepository(ctx)) results = append(results, checkGitRepository(ctx))
+50 -8
View File
@@ -12,10 +12,15 @@ import (
"github.com/creachadair/command" "github.com/creachadair/command"
) )
var ErrTestPatternRequired = errors.New("test pattern is required as first argument or use --test flag") var (
ErrTestPatternRequired = errors.New("test pattern is required as first argument or use --test flag")
ErrUnexpectedTestArguments = errors.New("expected a single test pattern; check flag spelling and shell quoting")
ErrInvalidTestBinary = errors.New("test binary must be an executable regular file")
)
type RunConfig struct { type RunConfig struct {
TestPattern string `flag:"test,Test pattern to run"` TestPattern string `flag:"test,Test pattern to run"`
TestBinary string `flag:"test-binary,Path to a precompiled integration test binary"`
Timeout time.Duration `flag:"timeout,default=120m,Test timeout"` Timeout time.Duration `flag:"timeout,default=120m,Test timeout"`
FailFast bool `flag:"failfast,default=true,Stop on first test failure"` FailFast bool `flag:"failfast,default=true,Stop on first test failure"`
UsePostgres bool `flag:"postgres,default=false,Use PostgreSQL instead of SQLite"` UsePostgres bool `flag:"postgres,default=false,Use PostgreSQL instead of SQLite"`
@@ -30,17 +35,47 @@ type RunConfig struct {
TSMemoryLimit float64 `flag:"ts-memory-limit,default=0,Fail test if any Tailscale container exceeds this memory limit in MB (0 = disabled)"` TSMemoryLimit float64 `flag:"ts-memory-limit,default=0,Fail test if any Tailscale container exceeds this memory limit in MB (0 = disabled)"`
} }
// runIntegrationTest executes the integration test workflow. func (c *RunConfig) validate(args []string) error {
func runIntegrationTest(env *command.Env) error { if len(args) > 1 || (len(args) != 0 && c.TestPattern != "") {
args := env.Args return ErrUnexpectedTestArguments
if len(args) > 0 && runConfig.TestPattern == "" {
runConfig.TestPattern = args[0]
} }
if runConfig.TestPattern == "" { if len(args) == 1 {
c.TestPattern = args[0]
}
if c.TestPattern == "" {
return ErrTestPatternRequired return ErrTestPatternRequired
} }
if c.TestBinary != "" {
binary, err := filepath.Abs(c.TestBinary)
if err != nil {
return fmt.Errorf("resolving test binary: %w", err)
}
info, err := os.Stat(binary)
if err != nil {
return fmt.Errorf("reading test binary: %w", err)
}
if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 {
return fmt.Errorf("%s: %w", binary, ErrInvalidTestBinary)
}
c.TestBinary = binary
}
return nil
}
// runIntegrationTest executes the integration test workflow.
func runIntegrationTest(env *command.Env) error {
err := runConfig.validate(env.Args)
if err != nil {
return err
}
if runConfig.GoVersion == "" { if runConfig.GoVersion == "" {
runConfig.GoVersion = detectGoVersion() runConfig.GoVersion = detectGoVersion()
} }
@@ -50,7 +85,7 @@ func runIntegrationTest(env *command.Env) error {
log.Printf("Running pre-flight system checks...") log.Printf("Running pre-flight system checks...")
} }
err := runDoctorCheck(env.Context()) err = runPreflightChecks(env.Context(), false)
if err != nil { if err != nil {
return fmt.Errorf("pre-flight checks failed: %w", err) return fmt.Errorf("pre-flight checks failed: %w", err)
} }
@@ -62,6 +97,13 @@ func runIntegrationTest(env *command.Env) error {
log.Printf("Use PostgreSQL: %t", runConfig.UsePostgres) log.Printf("Use PostgreSQL: %t", runConfig.UsePostgres)
} }
backend := "sqlite"
if runConfig.UsePostgres {
backend = "postgres"
}
log.Printf("Database backend: %s", backend)
return runTestContainer(env.Context(), &runConfig) return runTestContainer(env.Context(), &runConfig)
} }
+247
View File
@@ -0,0 +1,247 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/creachadair/command"
"github.com/creachadair/flax"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Exercise the real flag parser: folded YAML shell continuations used to turn
// --postgres into a positional argument, silently selecting SQLite.
func TestRunArguments(t *testing.T) {
tests := []struct {
name string
args []string
postgres bool
wantErr error
}{
{
name: "postgres before selector",
args: []string{"--postgres=1", "--timeout=15m", "^TestHeadscale$"},
postgres: true,
},
{
name: "postgres after selector",
args: []string{"^TestHeadscale$", "--postgres", "--timeout=15m"},
postgres: true,
},
{
name: "sqlite",
args: []string{"--postgres=0", "--timeout=15m", "--test=^TestHeadscale$"},
},
{
name: "folded shell continuations",
args: []string{"^TestHeadscale$", " --timeout=15m", " --postgres=1"},
wantErr: ErrUnexpectedTestArguments,
},
{
name: "extra selector",
args: []string{"^TestHeadscale$", "TestNodeCommand"},
wantErr: ErrUnexpectedTestArguments,
},
{
name: "positional selector with test flag",
args: []string{"--test=^TestHeadscale$", "TestNodeCommand"},
wantErr: ErrUnexpectedTestArguments,
},
{
name: "missing selector",
wantErr: ErrTestPatternRequired,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var config RunConfig
cmd := command.C{
Name: "run",
SetFlags: command.Flags(flax.MustBind, &config),
Run: func(env *command.Env) error { return config.validate(env.Args) },
}
err := command.Run(cmd.NewEnv(nil).MergeFlags(true), tt.args)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
require.NoError(t, err)
assert.Equal(t, tt.postgres, config.UsePostgres)
assert.Equal(t, "^TestHeadscale$", config.TestPattern)
assert.Equal(t, 15*time.Minute, config.Timeout)
})
}
}
func TestCompiledTestContainer(t *testing.T) {
t.Setenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE", "headscale:ci-test")
// Neither inherited backend settings nor Go cache mounts should override
// the requested backend or leak into a compiled test execution.
t.Setenv("HEADSCALE_INTEGRATION_POSTGRES", "0")
t.Setenv("HEADSCALE_INTEGRATION_GO_CACHE", "/unused/go")
t.Setenv("HEADSCALE_INTEGRATION_GO_BUILD_CACHE", "/unused/build")
binary := filepath.Join(t.TempDir(), "integration.test")
require.NoError(t, os.WriteFile(binary, []byte("test executable"), 0o700)) //nolint:gosec // The fixture must be executable to validate --test-binary.
config := RunConfig{
TestPattern: "^TestAutoApproveMultiNetwork/webauth-user.*$",
TestBinary: binary,
UsePostgres: true,
Timeout: 15 * time.Minute,
FailFast: true,
}
require.NoError(t, config.validate(nil))
type createRequest struct {
container.Config
HostConfig container.HostConfig
}
requests := make(chan createRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var request createRequest
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
requests <- request
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"Id":"test-runner","Warnings":[]}`))
}))
t.Cleanup(server.Close)
cli, err := client.NewClientWithOpts(client.WithHost(server.URL), client.WithVersion("1.44"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, cli.Close()) })
const runID = "20261007-120000-abcdef"
logs := t.TempDir()
_, err = createTestContainer(t.Context(), cli, &config,
"headscale-test-suite-"+runID, logs, buildTestCommand(&config))
require.NoError(t, err)
request := <-requests
assert.Equal(t, "headscale:ci-test", request.Image)
assert.Equal(t, []string{
"/integration.test", "-test.run", config.TestPattern,
"-test.failfast", "-test.timeout", "15m0s", "-test.v",
}, []string(request.Cmd))
assert.Contains(t, request.Env, "HEADSCALE_INTEGRATION_POSTGRES=1")
assert.NotContains(t, request.Env, "HEADSCALE_INTEGRATION_POSTGRES=0")
assert.Contains(t, request.Env, "HEADSCALE_INTEGRATION_RUN_ID="+runID)
assert.NotContains(t, request.Env, "GOCACHE=/cache/go-build")
assert.Equal(t, runID, request.Labels["hi.run-id"])
assert.Equal(t, "integration", filepath.Base(request.WorkingDir))
assert.Contains(t, request.HostConfig.Binds, logs+":/tmp/control")
assert.NotContains(t, request.HostConfig.Binds, "/unused/go:/go")
assert.Equal(t, []mount.Mount{{
Type: mount.TypeBind, Source: binary, Target: "/integration.test", ReadOnly: true,
}}, request.HostConfig.Mounts)
}
func TestSourceTestCommand(t *testing.T) {
t.Setenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE", "headscale:ci-test")
config := RunConfig{
TestPattern: "^TestHeadscale$", GoVersion: "1.27.0", Timeout: time.Minute,
}
assert.Equal(t, []string{"go", "test", "./...", "-run", "^TestHeadscale$", "-timeout", "1m0s", "-v"}, buildTestCommand(&config))
assert.Equal(t, "golang:1.27.0", testRunnerImage(&config))
config.TestBinary = "/integration.test"
t.Setenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE", "")
assert.Equal(t, "golang:1.27.0", testRunnerImage(&config))
}
func TestStreamAndWaitDrainsFinalOutput(t *testing.T) {
output, err := os.CreateTemp(t.TempDir(), "test-output")
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, output.Close()) })
stdout := os.Stdout
os.Stdout = output
t.Cleanup(func() { os.Stdout = stdout })
waitSent := make(chan struct{})
releaseLogs := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/logs"):
w.Header().Set("Content-Type", "application/vnd.docker.raw-stream")
w.WriteHeader(http.StatusOK)
_ = http.NewResponseController(w).Flush()
select {
case <-releaseLogs:
_, _ = w.Write([]byte("--- PASS: TestExample (0.01s)\nPASS\n"))
case <-r.Context().Done():
}
case strings.HasSuffix(r.URL.Path, "/wait"):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"StatusCode":0}`))
_ = http.NewResponseController(w).Flush()
close(waitSent)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(server.Close)
cli, err := client.NewClientWithOpts(client.WithHost(server.URL), client.WithVersion("1.44"))
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, cli.Close()) })
type result struct {
exitCode int
err error
}
done := make(chan result, 1)
go func() {
code, err := streamAndWait(t.Context(), cli, "test-runner")
done <- result{code, err}
}()
<-waitSent
// Hold back the last log packet after Docker has reported the exit code.
select {
case <-done:
close(releaseLogs)
t.Fatal("returned before the final test output was delivered")
case <-time.After(50 * time.Millisecond):
}
close(releaseLogs)
got := <-done
require.NoError(t, got.err)
assert.Zero(t, got.exitCode)
data, err := os.ReadFile(output.Name())
require.NoError(t, err)
assert.Equal(t, "--- PASS: TestExample (0.01s)\nPASS\n", string(data))
}
+4 -3
View File
@@ -198,17 +198,18 @@
goChecks = { goChecks = {
build = fc.goBuild (common // { subPackages = [ "cmd/headscale" ]; }); build = fc.goBuild (common // { subPackages = [ "cmd/headscale" ]; });
# The pure unit subset. ./integration (Docker) and # The pure unit subset. The root ./integration package (Docker) and
# ./hscontrol/servertest (slow: 10s+ convergence plus race/stress/HA # ./hscontrol/servertest (slow: 10s+ convergence plus race/stress/HA
# property tests — run by the servertest workflow instead) are dropped # property tests — run by the servertest workflow instead) are dropped
# from the test set but kept in source so cmd/hi and friends still # from the test set but kept in source so cmd/hi and friends still
# compile; TestPostgres* needs a server (the SQLite equivalents still # compile. Pure integration helper tests remain included.
# TestPostgres* needs a server (the SQLite equivalents still
# run). CGO off matches the build. # run). CGO off matches the build.
gotest = fc.goTest ( gotest = fc.goTest (
common common
// { // {
testExclude = [ testExclude = [
"/integration" "/integration$"
"/hscontrol/servertest" "/hscontrol/servertest"
]; ];
goSkip = [ "TestPostgres" ]; goSkip = [ "TestPostgres" ];
+3
View File
@@ -938,6 +938,9 @@ func TestOIDCFollowUpUrl(t *testing.T) {
_, err = doLoginURL(ts.Hostname(), newUrl) _, err = doLoginURL(ts.Hostname(), newUrl)
require.NoError(t, err) require.NoError(t, err)
err = ts.WaitForRunning(integrationutil.PeerSyncTimeout())
require.NoError(t, err)
listUsers, err = headscale.ListUsers() listUsers, err = headscale.ListUsers()
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, listUsers, 1) assert.Len(t, listUsers, 1)
+171
View File
@@ -0,0 +1,171 @@
package tsic
import (
"context"
"fmt"
"io"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/ory/dockertest/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Simulate Docker's streamed output without requiring Docker or a shell.
func TestLoginReturnsURLBeforeCLIExit(t *testing.T) {
const wantURL = "https://headscale.test/register/complete-token"
for _, stream := range []string{"stdout", "stderr"} {
t.Run(stream, func(t *testing.T) {
partialWritten := make(chan struct{})
completeURL := make(chan struct{})
authenticate := make(chan struct{})
t.Cleanup(func() { close(authenticate) })
login := startLogin([]string{"tailscale", "up"}, func(_ []string, opts dockertest.ExecOptions) (int, error) {
output := opts.StdErr
if stream == "stdout" {
output = opts.StdOut
}
_, _ = io.WriteString(output, "To authenticate, visit:\n\thttps://headscale.test/reg")
close(partialWritten)
<-completeURL
_, _ = io.WriteString(output, "ister/complete-token\n\n")
<-authenticate
// A later URL must not block output draining or replace the first.
_, _ = io.WriteString(output, "https://headscale.test/register/another-token\nSuccess.\n")
return 0, nil
})
<-partialWritten
select {
case u := <-login.url:
t.Fatalf("returned incomplete URL: %s", u)
default:
}
close(completeURL)
u, err := login.waitForURL(time.Second)
require.NoError(t, err)
require.Equal(t, wantURL, u.String())
select {
case <-login.done:
t.Fatal("CLI exited before authentication")
default:
}
// Permit natural completion and verify its exit status is retained.
authenticate <- struct{}{}
select {
case <-login.done:
require.NoError(t, login.err)
assert.Contains(t, login.output.String(), "Success.")
case <-time.After(time.Second):
t.Fatal("CLI did not complete after authentication")
}
})
}
}
func TestLoginCommandFailure(t *testing.T) {
execErr := io.ErrClosedPipe
for _, tt := range []struct {
name string
output string
exitCode int
err error
wantErr error
}{
{name: "exec error", err: execErr, wantErr: execErr},
{name: "CLI failure", output: "invalid login option", exitCode: 7, wantErr: dockertestutil.ErrDockertestCommandFailed},
{name: "no URL", output: "Success.\n", wantErr: util.ErrNoURLFound},
{name: "incomplete URL", output: "https://headscale.test/register/incomplete", wantErr: util.ErrNoURLFound},
} {
t.Run(tt.name, func(t *testing.T) {
login := startLogin(nil, func(_ []string, opts dockertest.ExecOptions) (int, error) {
_, _ = io.WriteString(opts.StdErr, tt.output)
return tt.exitCode, tt.err
})
_, err := login.waitForURL(time.Second)
require.ErrorIs(t, err, tt.wantErr)
assert.Contains(t, err.Error(), tt.output)
})
}
}
func TestLoginFailureAfterURL(t *testing.T) {
finish := make(chan struct{})
t.Cleanup(func() { close(finish) })
login := startLogin(nil, func(_ []string, opts dockertest.ExecOptions) (int, error) {
_, _ = io.WriteString(opts.StdErr, "https://headscale.test/register/token\n")
<-finish
_, _ = io.WriteString(opts.StdErr, "authentication rejected\n")
return 1, nil
})
_, err := login.waitForURL(time.Second)
require.NoError(t, err)
client := &TailscaleInContainer{hostname: "client", login: login}
// A URL alone is not a completed login. The caller's wait stays bounded.
require.ErrorIs(t, client.WaitForRunning(0), context.DeadlineExceeded)
finish <- struct{}{}
select {
case <-login.done:
for range 2 {
// All observers must see the failure, not just the first channel reader.
err = client.WaitForRunning(time.Second)
require.ErrorIs(t, err, dockertestutil.ErrDockertestCommandFailed)
assert.Contains(t, err.Error(), "authentication rejected")
}
case <-time.After(time.Second):
t.Fatal("CLI did not finish")
}
}
func TestLoginURLDeadlineDoesNotStopCLI(t *testing.T) {
finish := make(chan struct{})
t.Cleanup(func() { close(finish) })
login := startLogin(nil, func(_ []string, opts dockertest.ExecOptions) (int, error) {
<-finish
_, _ = fmt.Fprintln(opts.StdErr, "https://headscale.test/register/token")
return 0, nil
})
_, err := login.waitForURL(0)
require.ErrorIs(t, err, dockertestutil.ErrDockertestCommandTimeout)
finish <- struct{}{}
select {
case <-login.done:
require.NoError(t, login.err)
case <-time.After(time.Second):
t.Fatal("CLI did not finish after URL deadline")
}
}
+116 -26
View File
@@ -17,6 +17,7 @@ import (
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/cenkalti/backoff/v5" "github.com/cenkalti/backoff/v5"
@@ -44,7 +45,9 @@ const (
dockerContextPath = "../." dockerContextPath = "../."
caCertRoot = "/usr/local/share/ca-certificates" caCertRoot = "/usr/local/share/ca-certificates"
dockerExecuteTimeout = 60 * time.Second dockerExecuteTimeout = 60 * time.Second
tailscaleBin = "tailscale" // Some OIDC tests deliberately delay authentication for two minutes.
loginTimeout = 5 * time.Minute
tailscaleBin = "tailscale"
) )
// defaultPingTimeoutVal returns the per-attempt timeout for tailscale ping. // defaultPingTimeoutVal returns the per-attempt timeout for tailscale ping.
@@ -59,17 +62,16 @@ func defaultPingTimeoutVal() time.Duration {
} }
var ( var (
errTailscalePingFailed = errors.New("ping failed") errTailscalePingFailed = errors.New("ping failed")
errTailscalePingNotDERP = errors.New("ping not via DERP") errTailscalePingNotDERP = errors.New("ping not via DERP")
errTailscaleNotLoggedIn = errors.New("tailscale not logged in") errTailscaleNotLoggedIn = errors.New("tailscale not logged in")
errTailscaleWrongPeerCount = errors.New("wrong peer count") errTailscaleWrongPeerCount = errors.New("wrong peer count")
errTailscaleCannotUpWithoutAuthkey = errors.New("cannot up without authkey") errInvalidClientConfig = errors.New("verifiably invalid client config requested")
errInvalidClientConfig = errors.New("verifiably invalid client config requested") errInvalidTailscaleImageFormat = errors.New("invalid HEADSCALE_INTEGRATION_TAILSCALE_IMAGE format, expected repository:tag")
errInvalidTailscaleImageFormat = errors.New("invalid HEADSCALE_INTEGRATION_TAILSCALE_IMAGE format, expected repository:tag") errTailscaleImageRequiredInCI = errors.New("HEADSCALE_INTEGRATION_TAILSCALE_IMAGE must be set in CI for HEAD version")
errTailscaleImageRequiredInCI = errors.New("HEADSCALE_INTEGRATION_TAILSCALE_IMAGE must be set in CI for HEAD version") errContainerNotInitialized = errors.New("container not initialized")
errContainerNotInitialized = errors.New("container not initialized") errFQDNNotYetAvailable = errors.New("FQDN not yet available")
errFQDNNotYetAvailable = errors.New("FQDN not yet available") errCurlEmptyResponseBody = errors.New("curl returned empty response body")
errCurlEmptyResponseBody = errors.New("curl returned empty response body")
) )
const ( const (
@@ -94,6 +96,10 @@ type TailscaleInContainer struct {
ips []netip.Addr ips []netip.Addr
fqdn string fqdn string
// Only the current attempt is checked: earlier logins may be deliberately
// abandoned or rejected before the test starts another one.
login *loginAttempt
// optional config // optional config
caCerts [][]byte caCerts [][]byte
headscaleHostname string headscaleHostname string
@@ -718,6 +724,7 @@ func (t *TailscaleInContainer) buildLoginCommand(
func (t *TailscaleInContainer) Login( func (t *TailscaleInContainer) Login(
loginServer, authKey string, loginServer, authKey string,
) error { ) error {
t.login = nil
command := t.buildLoginCommand(loginServer, authKey) command := t.buildLoginCommand(loginServer, authKey)
if _, _, err := t.Execute(command, dockertestutil.ExecuteCommandTimeout(dockerExecuteTimeout)); err != nil { //nolint:noinlineerr if _, _, err := t.Execute(command, dockertestutil.ExecuteCommandTimeout(dockerExecuteTimeout)); err != nil { //nolint:noinlineerr
@@ -732,34 +739,106 @@ func (t *TailscaleInContainer) Login(
return nil return nil
} }
// loginAttempt streams the first complete URL line while retaining all output.
// done closes only after Docker exec returns; err can then be read repeatedly.
type loginAttempt struct {
mu sync.Mutex
output bytes.Buffer
lineStart int
foundURL bool
url chan *url.URL
done chan struct{}
err error
}
func (l *loginAttempt) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
n, _ := l.output.Write(p)
for !l.foundURL {
line, _, complete := bytes.Cut(l.output.Bytes()[l.lineStart:], []byte{'\n'})
if !complete {
break
}
l.lineStart += len(line) + 1
u, err := util.ParseLoginURLFromCLILogin(string(line))
if err == nil {
l.foundURL = true
l.url <- u
}
}
return n, nil
}
func startLogin(
command []string,
exec func([]string, dockertest.ExecOptions) (int, error),
) *loginAttempt {
l := &loginAttempt{url: make(chan *url.URL, 1), done: make(chan struct{})}
go func() {
defer close(l.done)
exitCode, err := exec(command, dockertest.ExecOptions{StdOut: l, StdErr: l})
log.Printf("%v finished (exit %d): %s", command, exitCode, l.output.String())
switch {
case err != nil:
l.err = fmt.Errorf("tailscale up: %s: %w", l.output.String(), err)
case exitCode != 0:
l.err = fmt.Errorf("tailscale up exited %d: %s: %w", exitCode, l.output.String(), dockertestutil.ErrDockertestCommandFailed)
case !l.foundURL:
l.err = fmt.Errorf("tailscale up: %s: %w", l.output.String(), util.ErrNoURLFound)
}
}()
return l
}
func (l *loginAttempt) waitForURL(timeout time.Duration) (*url.URL, error) {
select {
case u := <-l.url:
return u, nil
case <-l.done:
if l.err != nil {
return nil, l.err
}
return <-l.url, nil
case <-time.After(timeout):
return nil, dockertestutil.ErrDockertestCommandTimeout
}
}
// LoginWithURL runs the login routine on the given Tailscale instance. // LoginWithURL runs the login routine on the given Tailscale instance.
// This login mechanism uses web + command line flow for authentication. // This login mechanism uses web + command line flow for authentication.
func (t *TailscaleInContainer) LoginWithURL( func (t *TailscaleInContainer) LoginWithURL(
loginServer string, loginServer string,
) (*url.URL, error) { ) (*url.URL, error) {
command := t.buildLoginCommand(loginServer, "") command := t.buildLoginCommand(loginServer, "")
command = append(command, "--timeout="+loginTimeout.String())
stdout, stderr, err := t.Execute(command) // Return the URL promptly, but let the CLI finish authentication normally.
if errors.Is(err, errTailscaleNotLoggedIn) { // Its own timeout bounds abandoned logins; WaitForRunning checks its exit.
return nil, errTailscaleCannotUpWithoutAuthkey t.login = startLogin(command, t.container.Exec)
}
defer func() { u, err := t.login.waitForURL(dockerExecuteTimeout)
if err != nil {
log.Printf("join command: %q", strings.Join(command, " "))
}
}()
loginURL, err := util.ParseLoginURLFromCLILogin(stdout + stderr)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("%s fetching login URL: %w", t.hostname, err)
} }
return loginURL, nil return u, nil
} }
// Logout runs the logout routine on the given Tailscale instance. // Logout runs the logout routine on the given Tailscale instance.
func (t *TailscaleInContainer) Logout() error { func (t *TailscaleInContainer) Logout() error {
t.login = nil
_, _, err := t.Execute([]string{tailscaleBin, "logout"}) _, _, err := t.Execute([]string{tailscaleBin, "logout"})
if err != nil { if err != nil {
return err return err
@@ -1268,7 +1347,7 @@ func (t *TailscaleInContainer) WaitForNeedsLogin(timeout time.Duration) error {
} }
// WaitForRunning blocks until the Tailscale (tailscaled) instance is logged in // WaitForRunning blocks until the Tailscale (tailscaled) instance is logged in
// and ready to be used. // and ready to be used, and the current web login command has exited successfully.
func (t *TailscaleInContainer) WaitForRunning(timeout time.Duration) error { func (t *TailscaleInContainer) WaitForRunning(timeout time.Duration) error {
return t.waitForBackendState("Running", timeout) return t.waitForBackendState("Running", timeout)
} }
@@ -1280,6 +1359,17 @@ func (t *TailscaleInContainer) waitForBackendState(state string, timeout time.Du
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel() defer cancel()
if state == "Running" && t.login != nil {
select {
case <-t.login.done:
if t.login.err != nil {
return fmt.Errorf("%s login failed: %w", t.hostname, t.login.err)
}
case <-ctx.Done():
return fmt.Errorf("%s waiting for login command: %w", t.hostname, ctx.Err())
}
}
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():