ci: reuse compiled integration tests and prebuilt images

Updates #3542

(cherry picked from commit c9852c43da)
This commit is contained in:
Kristoffer Dalby committed 2026-10-13 14:35:52 +02:00
1 parent 8106636c7f
commit 52e01807db
13 files changed
+1125 -446

No files matched your search

+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@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
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' }}
@@ -87,7 +87,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
@@ -95,33 +95,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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
+5 -12
View File
@@ -43,15 +43,13 @@ jobs:
with: with:
primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }} primary-key: nix-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('**/*.nix', '**/flake.lock') }}
restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }} restore-prefixes-first-match: nix-${{ runner.os }}-${{ runner.arch }}
- name: Build binaries and warm Go cache - name: Build binaries
if: steps.changed-files.outputs.files == 'true' if: steps.changed-files.outputs.files == 'true'
run: | run: |
# Build all Go binaries in one nix shell to maximize cache reuse
nix develop --command -- bash -c ' nix develop --command -- bash -c '
go build -o hi ./cmd/hi go build -o hi ./cmd/hi
CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale CGO_ENABLED=0 GOOS=linux go build -o headscale ./cmd/headscale
# Build integration test binary to warm the cache with all dependencies CGO_ENABLED=0 GOOS=linux go test -c ./integration -o integration.test
go test -c ./integration -o /dev/null 2>/dev/null || true
' '
- name: Upload hi binary - name: Upload hi binary
if: steps.changed-files.outputs.files == 'true' if: steps.changed-files.outputs.files == 'true'
@@ -60,17 +58,12 @@ jobs:
name: hi-binary name: hi-binary
path: hi path: hi
retention-days: 10 retention-days: 10
- name: Package Go cache - name: Upload integration test binary
if: steps.changed-files.outputs.files == 'true'
run: |
# Package Go module cache and build cache
tar -czf go-cache.tar.gz -C ~ go .cache/go-build
- name: Upload Go cache
if: steps.changed-files.outputs.files == 'true' if: steps.changed-files.outputs.files == 'true'
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: go-cache name: integration-test-binary
path: go-cache.tar.gz path: integration.test
retention-days: 10 retention-days: 10
- name: Force overlay2 storage driver - name: Force overlay2 storage driver
if: steps.changed-files.outputs.files == 'true' if: steps.changed-files.outputs.files == 'true'
+25 -1
View File
@@ -55,7 +55,8 @@ changing.
| `--timeout` | `120m` | Total test timeout. Use the built-in flag — never wrap with bash `timeout`. | | `--timeout` | `120m` | Total test timeout. Use the built-in flag — never wrap with bash `timeout`. |
| `--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.26.1) | | `--go-version` | auto | Detected from `go.mod` (currently 1.26.5) |
| `--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,
+81 -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,
@@ -269,6 +289,12 @@ func createGoTestContainer(ctx context.Context, cli *client.Client, config *RunC
}, },
} }
if config.TestBinary != "" {
// A custom server image may have its own entrypoint; run the tests directly.
containerConfig.Entrypoint = testCmd[:1]
containerConfig.Cmd = testCmd[1:]
}
// Get the correct Docker socket path from the current context // Get the correct Docker socket path from the current context
dockerSocketPath := getDockerSocketPath() dockerSocketPath := getDockerSocketPath()
@@ -286,27 +312,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 +365,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 +380,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
} }
+13 -2
View File
@@ -35,6 +35,12 @@ type DoctorResult struct {
// 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
@@ -49,11 +55,16 @@ 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))
if full {
results = append(results, checkGolangImage(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
@@ -11,10 +11,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"`
@@ -29,17 +34,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()
} }
@@ -49,7 +84,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)
} }
@@ -61,6 +96,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)
} }
+249
View File
@@ -0,0 +1,249 @@
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)
// A custom server image may already have a headscale entrypoint.
assert.Equal(t, []string{"/integration.test"}, []string(request.Entrypoint))
assert.Equal(t, []string{
"-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))
}
+250 -279
View File
@@ -1,7 +1,6 @@
package integration package integration
import ( import (
"fmt"
"net/netip" "net/netip"
"slices" "slices"
"strconv" "strconv"
@@ -23,196 +22,182 @@ import (
func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) { func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
for _, https := range []bool{true, false} { spec := ScenarioSpec{
t.Run(fmt.Sprintf("with-https-%t", https), func(t *testing.T) { NodesPerUser: len(MustTestVersions),
spec := ScenarioSpec{ Users: []string{"user1", "user2"},
NodesPerUser: len(MustTestVersions),
Users: []string{"user1", "user2"},
}
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
opts := []hsic.Option{
hsic.WithTestName("authkey-relogsame"),
}
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, opts...)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
allIps, err := scenario.ListTailscaleClientsIPs()
requireNoErrListClientIPs(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
expectedNodes := collectExpectedNodeIDs(t, allClients)
requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected", integrationutil.ScaledTimeout(120*time.Second))
// Validate that all nodes have [tailcfg.NetInfo] and DERP servers before logout
requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP before logout", 3*time.Minute)
// assertClientsState(t, allClients)
clientIPs := make(map[TailscaleClient][]netip.Addr)
for _, client := range allClients {
ips, err := client.IPs()
if err != nil {
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
}
clientIPs[client] = ips
}
var (
listNodes []*v1.Node
nodeCountBeforeLogout int
)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, listNodes, len(allClients))
for _, node := range listNodes {
assertLastSeenSetWithCollect(c, node)
}
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for expected node list before logout")
nodeCountBeforeLogout = len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout)
for _, client := range allClients {
err := client.Logout()
if err != nil {
t.Fatalf("failed to logout client %s: %s", client.Hostname(), err)
}
}
err = scenario.WaitForTailscaleLogout()
requireNoErrLogout(t, err)
// After taking down all nodes, verify all systems show nodes offline
requireAllClientsOnline(t, headscale, expectedNodes, false, "all nodes should have logged out", integrationutil.ScaledTimeout(120*time.Second))
t.Logf("all clients logged out")
t.Logf("Validating node persistence after logout at %s", time.Now().Format(TimestampFormat))
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(ct, err, "Failed to list nodes after logout")
assert.Len(ct, listNodes, nodeCountBeforeLogout, "Node count should match before logout count - expected %d nodes, got %d", nodeCountBeforeLogout, len(listNodes))
}, integrationutil.StatusReadyTimeout, 2*time.Second, "validating node persistence after logout (nodes should remain in database)")
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
// if the server is not running with HTTPS, we have to wait a bit before
// reconnection as the newest Tailscale client has a measure that will only
// reconnect over HTTPS if they saw a noise connection previously.
// https://github.com/tailscale/tailscale/commit/1eaad7d3deb0815e8932e913ca1a862afa34db38
// https://github.com/juanfont/headscale/issues/2164
if !https {
//nolint:forbidigo // Intentional delay: Tailscale client requires 5 min wait before reconnecting over non-HTTPS
time.Sleep(5 * time.Minute)
}
userMap, err := headscale.MapUsers()
require.NoError(t, err)
for _, userName := range spec.Users {
key, err := scenario.CreatePreAuthKey(userMap[userName].GetId(), true, false)
if err != nil {
t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err)
}
err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey())
if err != nil {
t.Fatalf("failed to run tailscale up for user %s: %s", userName, err)
}
}
t.Logf("Validating node persistence after relogin at %s", time.Now().Format(TimestampFormat))
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(ct, err, "Failed to list nodes after relogin")
assert.Len(ct, listNodes, nodeCountBeforeLogout, "Node count should remain unchanged after relogin - expected %d nodes, got %d", nodeCountBeforeLogout, len(listNodes))
}, integrationutil.HAConvergeTimeout, 2*time.Second, "validating node count stability after same-user auth key relogin")
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected to batcher", integrationutil.ScaledTimeout(120*time.Second))
// Wait for Tailscale sync before validating [tailcfg.NetInfo] to ensure proper state propagation
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
// Validate that all nodes have [tailcfg.NetInfo] and DERP servers after reconnection
requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP after reconnection", 3*time.Minute)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
return x.String()
})
assertPingAll(t, allClients, allAddrs)
for _, client := range allClients {
ips, err := client.IPs()
if err != nil {
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
}
// lets check if the IPs are the same
if len(ips) != len(clientIPs[client]) {
t.Fatalf("IPs changed for client %s", client.Hostname())
}
for _, ip := range ips {
if !slices.Contains(clientIPs[client], ip) {
t.Fatalf(
"IPs changed for client %s. Used to be %v now %v",
client.Hostname(),
clientIPs[client],
ips,
)
}
}
}
assert.EventuallyWithT(t, func(c *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, listNodes, nodeCountBeforeLogout)
for _, node := range listNodes {
assertLastSeenSetWithCollect(c, node)
}
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node list after relogin")
})
} }
scenario, err := NewScenario(spec)
require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t)
opts := []hsic.Option{
hsic.WithTestName("authkey-relogsame"),
}
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, opts...)
requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err)
allIps, err := scenario.ListTailscaleClientsIPs()
requireNoErrListClientIPs(t, err)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err)
expectedNodes := collectExpectedNodeIDs(t, allClients)
requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected", integrationutil.ScaledTimeout(120*time.Second))
// Validate that all nodes have [tailcfg.NetInfo] and DERP servers before logout
requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP before logout", 3*time.Minute)
// assertClientsState(t, allClients)
clientIPs := make(map[TailscaleClient][]netip.Addr)
for _, client := range allClients {
ips, err := client.IPs()
if err != nil {
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
}
clientIPs[client] = ips
}
var (
listNodes []*v1.Node
nodeCountBeforeLogout int
)
assert.EventuallyWithT(t, func(c *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, listNodes, len(allClients))
for _, node := range listNodes {
assertLastSeenSetWithCollect(c, node)
}
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for expected node list before logout")
nodeCountBeforeLogout = len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout)
for _, client := range allClients {
err := client.Logout()
if err != nil {
t.Fatalf("failed to logout client %s: %s", client.Hostname(), err)
}
}
err = scenario.WaitForTailscaleLogout()
requireNoErrLogout(t, err)
// After taking down all nodes, verify all systems show nodes offline
requireAllClientsOnline(t, headscale, expectedNodes, false, "all nodes should have logged out", integrationutil.ScaledTimeout(120*time.Second))
t.Logf("all clients logged out")
t.Logf("Validating node persistence after logout at %s", time.Now().Format(TimestampFormat))
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(ct, err, "Failed to list nodes after logout")
assert.Len(ct, listNodes, nodeCountBeforeLogout, "Node count should match before logout count - expected %d nodes, got %d", nodeCountBeforeLogout, len(listNodes))
}, integrationutil.StatusReadyTimeout, 2*time.Second, "validating node persistence after logout (nodes should remain in database)")
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
userMap, err := headscale.MapUsers()
require.NoError(t, err)
for _, userName := range spec.Users {
key, err := scenario.CreatePreAuthKey(userMap[userName].GetId(), true, false)
if err != nil {
t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err)
}
err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey())
if err != nil {
t.Fatalf("failed to run tailscale up for user %s: %s", userName, err)
}
}
t.Logf("Validating node persistence after relogin at %s", time.Now().Format(TimestampFormat))
assert.EventuallyWithT(t, func(ct *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(ct, err, "Failed to list nodes after relogin")
assert.Len(ct, listNodes, nodeCountBeforeLogout, "Node count should remain unchanged after relogin - expected %d nodes, got %d", nodeCountBeforeLogout, len(listNodes))
}, integrationutil.HAConvergeTimeout, 2*time.Second, "validating node count stability after same-user auth key relogin")
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected to batcher", integrationutil.ScaledTimeout(120*time.Second))
// Wait for Tailscale sync before validating [tailcfg.NetInfo] to ensure proper state propagation
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
// Validate that all nodes have [tailcfg.NetInfo] and DERP servers after reconnection
requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP after reconnection", 3*time.Minute)
err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err)
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
return x.String()
})
assertPingAll(t, allClients, allAddrs)
for _, client := range allClients {
ips, err := client.IPs()
if err != nil {
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
}
// lets check if the IPs are the same
if len(ips) != len(clientIPs[client]) {
t.Fatalf("IPs changed for client %s", client.Hostname())
}
for _, ip := range ips {
if !slices.Contains(clientIPs[client], ip) {
t.Fatalf(
"IPs changed for client %s. Used to be %v now %v",
client.Hostname(),
clientIPs[client],
ips,
)
}
}
}
assert.EventuallyWithT(t, func(c *assert.CollectT) {
var err error
listNodes, err = headscale.ListNodes()
assert.NoError(c, err)
assert.Len(c, listNodes, nodeCountBeforeLogout)
for _, node := range listNodes {
assertLastSeenSetWithCollect(c, node)
}
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for node list after relogin")
} }
// This test will first log in two sets of nodes to two sets of users, then // This test will first log in two sets of nodes to two sets of users, then
@@ -353,120 +338,106 @@ func TestAuthKeyLogoutAndReloginNewUser(t *testing.T) {
func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) { func TestAuthKeyLogoutAndReloginSameUserExpiredKey(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
for _, https := range []bool{true, false} { spec := ScenarioSpec{
t.Run(fmt.Sprintf("with-https-%t", https), func(t *testing.T) { NodesPerUser: len(MustTestVersions),
spec := ScenarioSpec{ Users: []string{"user1", "user2"},
NodesPerUser: len(MustTestVersions), }
Users: []string{"user1", "user2"},
}
scenario, err := NewScenario(spec) scenario, err := NewScenario(spec)
require.NoError(t, err) require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t) defer scenario.ShutdownAssertNoPanics(t)
opts := []hsic.Option{ opts := []hsic.Option{
hsic.WithTestName("authkey-rlogexpired"), hsic.WithTestName("authkey-rlogexpired"),
} }
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, opts...) err = scenario.CreateHeadscaleEnv([]tsic.Option{}, opts...)
requireNoErrHeadscaleEnv(t, err) requireNoErrHeadscaleEnv(t, err)
allClients, err := scenario.ListTailscaleClients() allClients, err := scenario.ListTailscaleClients()
requireNoErrListClients(t, err) requireNoErrListClients(t, err)
err = scenario.WaitForTailscaleSync() err = scenario.WaitForTailscaleSync()
requireNoErrSync(t, err) requireNoErrSync(t, err)
// assertClientsState(t, allClients) // assertClientsState(t, allClients)
clientIPs := make(map[TailscaleClient][]netip.Addr) clientIPs := make(map[TailscaleClient][]netip.Addr)
for _, client := range allClients { for _, client := range allClients {
ips, err := client.IPs() ips, err := client.IPs()
if err != nil { if err != nil {
t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err) t.Fatalf("failed to get IPs for client %s: %s", client.Hostname(), err)
} }
clientIPs[client] = ips clientIPs[client] = ips
} }
headscale, err := scenario.Headscale() headscale, err := scenario.Headscale()
requireNoErrGetHeadscale(t, err) requireNoErrGetHeadscale(t, err)
// Collect expected node IDs for validation // Collect expected node IDs for validation
expectedNodes := collectExpectedNodeIDs(t, allClients) expectedNodes := collectExpectedNodeIDs(t, allClients)
// Validate initial connection state // Validate initial connection state
requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected after initial login", integrationutil.ScaledTimeout(120*time.Second)) requireAllClientsOnline(t, headscale, expectedNodes, true, "all clients should be connected after initial login", integrationutil.ScaledTimeout(120*time.Second))
requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP after initial login", 3*time.Minute) requireAllClientsNetInfoAndDERP(t, headscale, expectedNodes, "all clients should have NetInfo and DERP after initial login", 3*time.Minute)
var ( var (
listNodes []*v1.Node listNodes []*v1.Node
nodeCountBeforeLogout int nodeCountBeforeLogout int
) )
assert.EventuallyWithT(t, func(c *assert.CollectT) { assert.EventuallyWithT(t, func(c *assert.CollectT) {
var err error var err error
listNodes, err = headscale.ListNodes() listNodes, err = headscale.ListNodes()
assert.NoError(c, err) assert.NoError(c, err)
assert.Len(c, listNodes, len(allClients)) assert.Len(c, listNodes, len(allClients))
}, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for expected node list before logout") }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "Waiting for expected node list before logout")
nodeCountBeforeLogout = len(listNodes) nodeCountBeforeLogout = len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout) t.Logf("node count before logout: %d", nodeCountBeforeLogout)
for _, client := range allClients { for _, client := range allClients {
err := client.Logout() err := client.Logout()
if err != nil { if err != nil {
t.Fatalf("failed to logout client %s: %s", client.Hostname(), err) t.Fatalf("failed to logout client %s: %s", client.Hostname(), err)
} }
} }
err = scenario.WaitForTailscaleLogout() err = scenario.WaitForTailscaleLogout()
requireNoErrLogout(t, err) requireNoErrLogout(t, err)
// Validate that all nodes are offline after logout // Validate that all nodes are offline after logout
requireAllClientsOnline(t, headscale, expectedNodes, false, "all nodes should be offline after logout", integrationutil.ScaledTimeout(120*time.Second)) requireAllClientsOnline(t, headscale, expectedNodes, false, "all nodes should be offline after logout", integrationutil.ScaledTimeout(120*time.Second))
t.Logf("all clients logged out") t.Logf("all clients logged out")
// if the server is not running with HTTPS, we have to wait a bit before userMap, err := headscale.MapUsers()
// reconnection as the newest Tailscale client has a measure that will only require.NoError(t, err)
// reconnect over HTTPS if they saw a noise connection previously.
// https://github.com/tailscale/tailscale/commit/1eaad7d3deb0815e8932e913ca1a862afa34db38
// https://github.com/juanfont/headscale/issues/2164
if !https {
//nolint:forbidigo // Intentional delay: Tailscale client requires 5 min wait before reconnecting over non-HTTPS
time.Sleep(5 * time.Minute)
}
userMap, err := headscale.MapUsers() for _, userName := range spec.Users {
require.NoError(t, err) key, err := scenario.CreatePreAuthKey(userMap[userName].GetId(), true, false)
if err != nil {
t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err)
}
for _, userName := range spec.Users { // Expire the key so it can't be used
key, err := scenario.CreatePreAuthKey(userMap[userName].GetId(), true, false) _, err = headscale.Execute(
if err != nil { []string{
t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) "headscale",
} "preauthkeys",
"expire",
"--id",
strconv.FormatUint(key.GetId(), 10),
})
require.NoError(t, err)
require.NoError(t, err)
// Expire the key so it can't be used err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey())
_, err = headscale.Execute( assert.ErrorContains(t, err, "authkey expired")
[]string{
"headscale",
"preauthkeys",
"expire",
"--id",
strconv.FormatUint(key.GetId(), 10),
})
require.NoError(t, err)
require.NoError(t, err)
err = scenario.RunTailscaleUp(userName, headscale.GetEndpoint(), key.GetKey())
assert.ErrorContains(t, err, "authkey expired")
}
})
} }
} }
+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)
+87 -55
View File
@@ -7,7 +7,9 @@ import (
policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/integrationutil"
"github.com/juanfont/headscale/integration/tsic" "github.com/juanfont/headscale/integration/tsic"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@@ -22,9 +24,10 @@ import (
// - bypass: no-bypass talks to the server over gRPC; bypass opens the // - bypass: no-bypass talks to the server over gRPC; bypass opens the
// database directly. // database directly.
// //
// Each row spins up its own scenario because policy_mode is fixed at boot // Each policy mode shares one scenario across its sequential checks because
// via `HEADSCALE_POLICY_MODE`. The two users + two nodes give the tests // policy_mode is fixed at boot. The two users + two nodes give the tests
// block real `user@` aliases to resolve against. // block real `user@` aliases to resolve against. Check inputs are separate
// from the live policy, which must remain unchanged after every check.
func TestPolicyCheckCommand(t *testing.T) { func TestPolicyCheckCommand(t *testing.T) {
IntegrationSkip(t) IntegrationSkip(t)
@@ -77,44 +80,56 @@ func TestPolicyCheckCommand(t *testing.T) {
type row struct { type row struct {
name string name string
policyMode string
fixture fixture fixture fixture
bypass bool bypass bool
wantErr string wantErr string
wantStdout string wantStdout string
} }
modes := []string{"file", "database"} //nolint:goconst // axis labels match HEADSCALE_POLICY_MODE values modes := []types.PolicyMode{types.PolicyModeFile, types.PolicyModeDB}
bypasses := []bool{false, true} bypasses := []bool{false, true}
rows := make([]row, 0, len(modes)*len(fixtures)*len(bypasses)) rows := make([]row, 0, len(fixtures)*len(bypasses))
for _, mode := range modes { for _, f := range fixtures {
for _, f := range fixtures { for _, bypass := range bypasses {
for _, bypass := range bypasses { suffix := "no-bypass"
suffix := "no-bypass" if bypass {
if bypass { suffix = "bypass"
suffix = "bypass"
}
r := row{
name: mode + "-" + f.name + "-" + suffix,
policyMode: mode,
fixture: f,
bypass: bypass,
wantStdout: "Policy is valid",
}
if f.name == "acl-plus-failing-tests" {
r.wantErr = "test(s) failed"
r.wantStdout = ""
}
rows = append(rows, r)
} }
r := row{
name: f.name + "-" + suffix,
fixture: f,
bypass: bypass,
wantStdout: "Policy is valid",
}
if f.name == "acl-plus-failing-tests" {
r.wantErr = "test(s) failed"
r.wantStdout = ""
}
rows = append(rows, r)
} }
} }
for _, tt := range rows { // Use a live policy distinct from every check fixture so an accidental
t.Run(tt.name, func(t *testing.T) { // policy update is observable, including for the ACL-only fixture.
livePolicy := policyv2.Policy{
ACLs: []policyv2.ACL{
{
Action: policyv2.ActionAccept,
Sources: []policyv2.Alias{wildcard()},
Destinations: []policyv2.AliasWithPorts{
aliasWithPorts(wildcard(), tailcfg.PortRangeAny),
},
},
},
}
livePolicyBytes, err := json.Marshal(livePolicy)
require.NoError(t, err)
for _, mode := range modes {
t.Run(string(mode), func(t *testing.T) {
spec := ScenarioSpec{ spec := ScenarioSpec{
NodesPerUser: 1, NodesPerUser: 1,
Users: []string{"user1", "user2"}, //nolint:goconst // matches usernamep("user1@")/("user2@") above Users: []string{"user1", "user2"}, //nolint:goconst // matches usernamep("user1@")/("user2@") above
@@ -123,45 +138,62 @@ func TestPolicyCheckCommand(t *testing.T) {
scenario, err := NewScenario(spec) scenario, err := NewScenario(spec)
require.NoError(t, err) require.NoError(t, err)
defer scenario.ShutdownAssertNoPanics(t) t.Cleanup(func() { scenario.ShutdownAssertNoPanics(t) })
err = scenario.CreateHeadscaleEnv( err = scenario.CreateHeadscaleEnv(
[]tsic.Option{}, []tsic.Option{},
hsic.WithTestName("cli-policycheck"), hsic.WithTestName("cli-policycheck"),
hsic.WithConfigEnv(map[string]string{ hsic.WithPolicyMode(mode),
"HEADSCALE_POLICY_MODE": tt.policyMode, //nolint:goconst // env var name from hscontrol/types/config.go hsic.WithACLPolicy(&livePolicy),
}),
) )
require.NoError(t, err) require.NoError(t, err)
headscale, err := scenario.Headscale() headscale, err := scenario.Headscale()
require.NoError(t, err) require.NoError(t, err)
pBytes, err := json.Marshal(tt.fixture.policy) require.EventuallyWithT(t, func(c *assert.CollectT) {
require.NoError(t, err) stdout, err := headscale.Execute([]string{"headscale", "policy", "get"})
assert.NoError(c, err)
assert.JSONEq(c, string(livePolicyBytes), stdout)
}, integrationutil.StatusReadyTimeout, integrationutil.FastPoll, "live policy should be loaded before checks")
policyFilePath := "/etc/headscale/policy.json" //nolint:goconst // standard headscale policy path for _, tt := range rows {
err = headscale.WriteFile(policyFilePath, pBytes) t.Run(tt.name, func(t *testing.T) {
require.NoError(t, err) pBytes, err := json.Marshal(tt.fixture.policy)
require.NoError(t, err)
cmd := []string{"headscale", "policy", "check", "-f", policyFilePath} //nolint:goconst // CLI invocation policyFilePath := "/etc/headscale/policy-check-" + tt.name + ".json"
if tt.bypass { err = headscale.WriteFile(policyFilePath, pBytes)
// --force suppresses the "is the server running?" require.NoError(t, err)
// confirmation prompt so the command can run
// non-interactively under the test harness. t.Cleanup(func() {
cmd = append(cmd, "--bypass-grpc-and-access-database-directly", "--force") assert.EventuallyWithT(t, func(c *assert.CollectT) {
stdout, err := headscale.Execute([]string{"headscale", "policy", "get"})
assert.NoError(c, err)
assert.JSONEq(c, string(livePolicyBytes), stdout)
}, integrationutil.StatusReadyTimeout, integrationutil.FastPoll, "policy check must not alter the live policy")
})
cmd := []string{"headscale", "policy", "check", "-f", policyFilePath} //nolint:goconst // CLI invocation
if tt.bypass {
// --force suppresses the "is the server running?"
// confirmation prompt so the command can run
// non-interactively under the test harness.
cmd = append(cmd, "--bypass-grpc-and-access-database-directly", "--force")
}
stdout, err := headscale.Execute(cmd)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.Contains(t, stdout, tt.wantStdout)
})
} }
stdout, err := headscale.Execute(cmd)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
require.Contains(t, stdout, tt.wantStdout)
}) })
} }
} }
+64 -15
View File
@@ -51,9 +51,12 @@ const (
var usePostgresForTest = envknob.Bool("HEADSCALE_INTEGRATION_POSTGRES") var usePostgresForTest = envknob.Bool("HEADSCALE_INTEGRATION_POSTGRES")
var ( var (
errNoHeadscaleAvailable = errors.New("no headscale available") errNoHeadscaleAvailable = errors.New("no headscale available")
errNoUserAvailable = errors.New("no user available") errNoUserAvailable = errors.New("no user available")
errNoClientFound = errors.New("client not found") errNoClientFound = errors.New("client not found")
errInvalidHeadscaleImageFormat = errors.New("invalid HEADSCALE_INTEGRATION_HEADSCALE_IMAGE format, expected repository:tag")
errInvalidMockOIDCImage = errors.New("invalid HEADSCALE_INTEGRATION_HEADSCALE_IMAGE format, expected repository:tag")
errMockOIDCImageRequiredInCI = errors.New("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE must be set for mock OIDC in CI")
// AllVersions represents a list of Tailscale versions the suite // AllVersions represents a list of Tailscale versions the suite
// uses to test compatibility with the [ControlServer]. // uses to test compatibility with the [ControlServer].
@@ -684,7 +687,8 @@ func (s *Scenario) CreateTailscaleNodesInUser(
s.mu.Lock() s.mu.Lock()
opts = append(opts, clientOpts := append(
slices.Clone(opts),
tsic.WithCACert(cert), tsic.WithCACert(cert),
tsic.WithHeadscaleName(hostname), tsic.WithHeadscaleName(hostname),
tsic.WithExtraHosts(extraHosts), tsic.WithExtraHosts(extraHosts),
@@ -697,7 +701,7 @@ func (s *Scenario) CreateTailscaleNodesInUser(
tsClient, err := tsic.New( tsClient, err := tsic.New(
s.pool, s.pool,
version, version,
opts..., clientOpts...,
) )
s.mu.Unlock() s.mu.Unlock()
@@ -1660,15 +1664,38 @@ func (s *Scenario) runMockOIDC(accessTTL time.Duration, users []mockoidc.MockUse
// Add integration test labels if running under hi tool // Add integration test labels if running under hi tool
dockertestutil.DockerAddIntegrationLabels(mockOidcOptions, "oidc") dockertestutil.DockerAddIntegrationLabels(mockOidcOptions, "oidc")
if pmockoidc, err := s.pool.BuildAndRunWithBuildOptions( //nolint:noinlineerr var container *dockertest.Resource
headscaleBuildOptions,
mockOidcOptions, // CI already built and loaded the Headscale image, including its mockoidc command.
dockertestutil.DockerRestartPolicy); err == nil { // Reuse it instead of downloading dependencies and rebuilding for every OIDC test.
s.mockOIDC.r = pmockoidc if prebuiltImage := os.Getenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE"); prebuiltImage != "" {
repo, tag, ok := strings.Cut(prebuiltImage, ":")
if !ok || repo == "" || tag == "" {
return errInvalidMockOIDCImage
}
mockOidcOptions.Repository = repo
mockOidcOptions.Tag = tag
container, err = s.pool.RunWithOptions(
mockOidcOptions,
dockertestutil.DockerRestartPolicy,
)
} else if util.IsCI() {
return errMockOIDCImageRequiredInCI
} else { } else {
return err container, err = s.pool.BuildAndRunWithBuildOptions(
headscaleBuildOptions,
mockOidcOptions,
dockertestutil.DockerRestartPolicy,
)
} }
if err != nil {
return fmt.Errorf("starting mock OIDC container: %w", err)
}
s.mockOIDC.r = container
// headscale needs to set up the provider with a specific // headscale needs to set up the provider with a specific
// IP addr to ensure we get the correct config from the well-known // IP addr to ensure we get the correct config from the well-known
// endpoint. // endpoint.
@@ -1753,10 +1780,32 @@ func Webservice(s *Scenario, networkName string) (*dockertest.Resource, error) {
ContextDir: dockerContextPath, ContextDir: dockerContextPath,
} }
web, err := s.pool.BuildAndRunWithBuildOptions( var (
webBOpts, web *dockertest.Resource
webOpts, err error
dockertestutil.DockerRestartPolicy) )
if prebuiltImage := os.Getenv("HEADSCALE_INTEGRATION_HEADSCALE_IMAGE"); prebuiltImage != "" {
repo, tag, ok := strings.Cut(prebuiltImage, ":")
if !ok {
return nil, errInvalidHeadscaleImageFormat
}
webOpts.Repository = repo
webOpts.Tag = tag
web, err = s.pool.RunWithOptions(
webOpts,
dockertestutil.DockerRestartPolicy,
)
} else {
web, err = s.pool.BuildAndRunWithBuildOptions(
webBOpts,
webOpts,
dockertestutil.DockerRestartPolicy,
)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
+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"
@@ -43,7 +44,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.
@@ -58,17 +61,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 (
@@ -93,6 +95,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
@@ -698,6 +704,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
@@ -712,34 +719,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
@@ -1240,7 +1319,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)
} }
@@ -1252,6 +1331,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():