mirror of
https://github.com/juanfont/headscale.git
synced 2026-08-07 15:58:45 +09:00
f6aea642d3
Reword comments and docs that still named the removed gRPC stack, rename the GRPCSortable test interface, and drop the dead gRPC port note from the dev README.
337 lines
11 KiB
Markdown
337 lines
11 KiB
Markdown
# Integration testing
|
|
|
|
Headscale's integration tests start a real Headscale server and run
|
|
scenarios against real Tailscale clients across supported versions, all
|
|
inside Docker. They are the safety net that keeps us honest about
|
|
Tailscale protocol compatibility.
|
|
|
|
This file documents **how to write** integration tests. For **how to
|
|
run** them, see [`../cmd/hi/README.md`](../cmd/hi/README.md).
|
|
|
|
Tests live in files ending with `_test.go`; the framework lives in the
|
|
rest of this directory (`scenario.go`, `tailscale.go`, helpers, and the
|
|
`hsic/`, `tsic/`, `dockertestutil/` packages).
|
|
|
|
## Running tests
|
|
|
|
For local runs, use [`cmd/hi`](../cmd/hi):
|
|
|
|
```bash
|
|
go run ./cmd/hi doctor
|
|
go run ./cmd/hi run "TestPingAllByIP"
|
|
```
|
|
|
|
Alternatively, [`act`](https://github.com/nektos/act) runs the GitHub
|
|
Actions workflow locally:
|
|
|
|
```bash
|
|
act pull_request -W .github/workflows/test-integration.yaml
|
|
```
|
|
|
|
Each test runs as a separate workflow on GitHub Actions. To add a new
|
|
test, run `go generate` inside `../cmd/gh-action-integration-generator/`
|
|
and commit the generated workflow file.
|
|
|
|
## Framework overview
|
|
|
|
The integration framework has four layers:
|
|
|
|
- **`scenario.go`** — `Scenario` orchestrates a test environment: a
|
|
Headscale server, one or more users, and a collection of Tailscale
|
|
clients. `NewScenario(spec)` returns a ready-to-use environment.
|
|
- **`hsic/`** — "Headscale Integration Container": wraps a Headscale
|
|
server in Docker. Options for config, DB backend, DERP, OIDC, etc.
|
|
- **`tsic/`** — "Tailscale Integration Container": wraps a single
|
|
Tailscale client. Options for version, hostname, auth method, etc.
|
|
- **`dockertestutil/`** — low-level Docker helpers (networks, container
|
|
lifecycle, `IsRunningInContainer()` detection).
|
|
|
|
Tests compose these pieces via `ScenarioSpec` and `CreateHeadscaleEnv`
|
|
rather than calling Docker directly.
|
|
|
|
## Required scaffolding
|
|
|
|
### `IntegrationSkip(t)`
|
|
|
|
**Every** integration test function must call `IntegrationSkip(t)` as
|
|
its first statement. Without it, the test runs in the wrong environment
|
|
and fails with confusing errors.
|
|
|
|
```go
|
|
func TestMyScenario(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
// ... rest of the test
|
|
}
|
|
```
|
|
|
|
`IntegrationSkip` is defined in `integration/scenario_test.go:15` and:
|
|
|
|
- skips the test when not running inside the Docker test container
|
|
(`dockertestutil.IsRunningInContainer()`),
|
|
- skips when `-short` is passed to `go test`.
|
|
|
|
### Scenario setup
|
|
|
|
The canonical setup creates users, clients, and the Headscale server in
|
|
one shot:
|
|
|
|
```go
|
|
func TestMyScenario(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
t.Parallel()
|
|
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: 2,
|
|
Users: []string{"alice", "bob"},
|
|
}
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
err = scenario.CreateHeadscaleEnv(
|
|
[]tsic.Option{tsic.WithSSH()},
|
|
hsic.WithTestName("myscenario"),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
require.NoError(t, err)
|
|
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
// ... assertions
|
|
}
|
|
```
|
|
|
|
Review `scenario.go` and `hsic/options.go` / `tsic/options.go` for the
|
|
full option set (DERP, OIDC, policy files, DB backend, ACL grants,
|
|
exit-node config, etc.).
|
|
|
|
## The `EventuallyWithT` pattern
|
|
|
|
Integration tests operate on a distributed system with real async
|
|
propagation: clients advertise state, the server processes it, updates
|
|
stream to peers. Direct assertions after state changes fail
|
|
intermittently. Wrap external calls in `assert.EventuallyWithT`:
|
|
|
|
```go
|
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
status, err := client.Status()
|
|
assert.NoError(c, err)
|
|
for _, peerKey := range status.Peers() {
|
|
peerStatus := status.Peer[peerKey]
|
|
requirePeerSubnetRoutesWithCollect(c, peerStatus, expectedRoutes)
|
|
}
|
|
}, 10*time.Second, 500*time.Millisecond, "client should see expected routes")
|
|
```
|
|
|
|
### External calls that need wrapping
|
|
|
|
These read distributed state and may reflect stale data until
|
|
propagation completes:
|
|
|
|
- `headscale.ListNodes()`
|
|
- `client.Status()`
|
|
- `client.Curl()`
|
|
- `client.Traceroute()`
|
|
- `client.Execute()` when the command reads state
|
|
|
|
### Blocking operations that must NOT be wrapped
|
|
|
|
State-mutating commands run exactly once and either succeed or fail
|
|
immediately — not eventually. Wrapping them in `EventuallyWithT` hides
|
|
real failures behind retry.
|
|
|
|
Use `client.MustStatus()` when you only need an ID for a blocking call:
|
|
|
|
```go
|
|
// CORRECT — mutation runs once
|
|
for _, client := range allClients {
|
|
status := client.MustStatus()
|
|
_, _, err := client.Execute([]string{
|
|
"tailscale", "set",
|
|
"--advertise-routes=" + expectedRoutes[string(status.Self.ID)],
|
|
})
|
|
require.NoErrorf(t, err, "failed to advertise route: %s", err)
|
|
}
|
|
```
|
|
|
|
Typical blocking operations: any `tailscale set` (routes, exit node,
|
|
accept-routes, ssh), node registration via the CLI, user creation via
|
|
the CLI.
|
|
|
|
### The four rules
|
|
|
|
1. **One external call per `EventuallyWithT` block.** Related assertions
|
|
on the result of a single call go together in the same block.
|
|
|
|
**Loop exception**: iterating over a collection of clients (or peers)
|
|
and calling `Status()` on each inside a single block is allowed — it
|
|
is the same logical "check all clients" operation. The rule applies
|
|
to distinct calls like `ListNodes()` + `Status()`, which must be
|
|
split into separate blocks.
|
|
|
|
2. **Never nest `EventuallyWithT` calls.** A nested retry loop
|
|
multiplies timing windows and makes failures impossible to diagnose.
|
|
|
|
3. **Use `*WithCollect` helper variants** inside the block. Regular
|
|
helpers use `require` and abort on the first failed assertion,
|
|
preventing retry.
|
|
|
|
4. **Always provide a descriptive final message** — it appears on
|
|
failure and is your only clue about what the test was waiting for.
|
|
|
|
### Variable scoping
|
|
|
|
Variables used across multiple `EventuallyWithT` blocks must be declared
|
|
at function scope. Inside the block, assign with `=`, not `:=` — `:=`
|
|
creates a shadow invisible to the outer scope:
|
|
|
|
```go
|
|
var nodes []*v1.Node
|
|
var err error
|
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
nodes, err = headscale.ListNodes() // = not :=
|
|
assert.NoError(c, err)
|
|
assert.Len(c, nodes, 2)
|
|
requireNodeRouteCountWithCollect(c, nodes[0], 2, 2, 2)
|
|
}, 10*time.Second, 500*time.Millisecond, "nodes should have expected routes")
|
|
|
|
// nodes is usable here because it was declared at function scope
|
|
```
|
|
|
|
### Helper functions
|
|
|
|
Inside `EventuallyWithT` blocks, use the `*WithCollect` variants so
|
|
assertion failures restart the wait loop instead of failing the test
|
|
immediately:
|
|
|
|
- `requirePeerSubnetRoutesWithCollect(c, status, expected)` —
|
|
`integration/route_test.go:2941`
|
|
- `requireNodeRouteCountWithCollect(c, node, announced, approved, subnet)` —
|
|
`integration/route_test.go:2958`
|
|
- `assertTracerouteViaIPWithCollect(c, traceroute, ip)` —
|
|
`integration/route_test.go:2898`
|
|
|
|
When you write a new helper to be called inside `EventuallyWithT`, it
|
|
must accept `*assert.CollectT` as its first parameter, not `*testing.T`.
|
|
|
|
## Identifying nodes by property, not position
|
|
|
|
The order of `headscale.ListNodes()` is not stable. Tests that index
|
|
`nodes[0]` will break when node ordering changes. Look nodes up by ID,
|
|
hostname, or tag:
|
|
|
|
```go
|
|
// WRONG — relies on array position
|
|
require.Len(t, nodes[0].GetAvailableRoutes(), 1)
|
|
|
|
// CORRECT — find the node that should have the route
|
|
expectedRoutes := map[string]string{"1": "10.33.0.0/16"}
|
|
for _, node := range nodes {
|
|
nodeIDStr := fmt.Sprintf("%d", node.GetId())
|
|
if route, shouldHaveRoute := expectedRoutes[nodeIDStr]; shouldHaveRoute {
|
|
assert.Contains(t, node.GetAvailableRoutes(), route)
|
|
}
|
|
}
|
|
```
|
|
|
|
## Full example: advertising and approving a route
|
|
|
|
```go
|
|
func TestRouteAdvertisementBasic(t *testing.T) {
|
|
IntegrationSkip(t)
|
|
t.Parallel()
|
|
|
|
spec := ScenarioSpec{
|
|
NodesPerUser: 2,
|
|
Users: []string{"user1"},
|
|
}
|
|
scenario, err := NewScenario(spec)
|
|
require.NoError(t, err)
|
|
defer scenario.ShutdownAssertNoPanics(t)
|
|
|
|
err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("route"))
|
|
require.NoError(t, err)
|
|
|
|
allClients, err := scenario.ListTailscaleClients()
|
|
require.NoError(t, err)
|
|
|
|
headscale, err := scenario.Headscale()
|
|
require.NoError(t, err)
|
|
|
|
// --- Blocking: advertise the route on one client ---
|
|
router := allClients[0]
|
|
_, _, err = router.Execute([]string{
|
|
"tailscale", "set",
|
|
"--advertise-routes=10.33.0.0/16",
|
|
})
|
|
require.NoErrorf(t, err, "advertising route: %s", err)
|
|
|
|
// --- Eventually: headscale should see the announced route ---
|
|
var nodes []*v1.Node
|
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
nodes, err = headscale.ListNodes()
|
|
assert.NoError(c, err)
|
|
assert.Len(c, nodes, 2)
|
|
|
|
for _, node := range nodes {
|
|
if node.GetName() == router.Hostname() {
|
|
requireNodeRouteCountWithCollect(c, node, 1, 0, 0)
|
|
}
|
|
}
|
|
}, 10*time.Second, 500*time.Millisecond, "route should be announced")
|
|
|
|
// --- Blocking: approve the route via headscale CLI ---
|
|
var routerNode *v1.Node
|
|
for _, node := range nodes {
|
|
if node.GetName() == router.Hostname() {
|
|
routerNode = node
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, routerNode)
|
|
|
|
_, err = headscale.ApproveRoutes(routerNode.GetId(), []string{"10.33.0.0/16"})
|
|
require.NoError(t, err)
|
|
|
|
// --- Eventually: a peer should see the approved route ---
|
|
peer := allClients[1]
|
|
assert.EventuallyWithT(t, func(c *assert.CollectT) {
|
|
status, err := peer.Status()
|
|
assert.NoError(c, err)
|
|
for _, peerKey := range status.Peers() {
|
|
if peerKey == router.PublicKey() {
|
|
requirePeerSubnetRoutesWithCollect(c,
|
|
status.Peer[peerKey],
|
|
[]netip.Prefix{netip.MustParsePrefix("10.33.0.0/16")})
|
|
}
|
|
}
|
|
}, 10*time.Second, 500*time.Millisecond, "peer should see approved route")
|
|
}
|
|
```
|
|
|
|
## Common pitfalls
|
|
|
|
- **Forgetting `IntegrationSkip(t)`**: the test runs outside Docker and
|
|
fails in confusing ways. Always the first line.
|
|
- **Using `require` inside `EventuallyWithT`**: aborts after the first
|
|
iteration instead of retrying. Use `assert.*` + the `*WithCollect`
|
|
helpers.
|
|
- **Mixing mutation and query in one `EventuallyWithT`**: hides real
|
|
failures. Keep mutation outside, query inside.
|
|
- **Assuming node ordering**: look up by property.
|
|
- **Ignoring `err` from `client.Status()`**: retry only retries the
|
|
whole block; don't silently drop errors from mid-block calls.
|
|
- **Timeouts too tight**: 5s is reasonable for local state, 10s for
|
|
state that must propagate through the map poll cycle. Don't go lower
|
|
to "speed up the test" — you just make it flaky.
|
|
|
|
## Debugging failing tests
|
|
|
|
Tests save comprehensive artefacts to `control_logs/{runID}/`. Read them
|
|
in this order: server stderr, client stderr, MapResponse JSON, database
|
|
snapshot. The full debugging workflow, heuristics, and failure patterns
|
|
are documented in [`../cmd/hi/README.md`](../cmd/hi/README.md).
|