servertest: test the newest three Terraform provider releases

Releases run in parallel, each on its own server, sharing one plugin cache.
A test of a provider feature runs only on releases that have it.
This commit is contained in:
Kristoffer Dalby committed 2026-10-29 11:04:05 +01:00
1 parent a8e20560fc
commit b730a2ccc1
3 files changed
+153 -98

No files matched your search

+2 -13
View File
@@ -353,17 +353,6 @@ func apiv2SettingsTSCLI(t *testing.T, baseURL, apiKey string) {
// devicesACLTFConfig exercises Terraform device + ACL data sources AND resources. // devicesACLTFConfig exercises Terraform device + ACL data sources AND resources.
// %s is the test node's hostname (the tailscale_device data source key). // %s is the test node's hostname (the tailscale_device data source key).
const devicesACLTFConfig = ` const devicesACLTFConfig = `
terraform {
required_providers {
tailscale = {
source = "tailscale/tailscale"
version = "~> 0.21"
}
}
}
provider "tailscale" {}
resource "tailscale_acl" "policy" { resource "tailscale_acl" "policy" {
acl = jsonencode({ acl = jsonencode({
tagOwners = { "tag:ci" = ["apiv2@"] } tagOwners = { "tag:ci" = ["apiv2@"] }
@@ -417,10 +406,10 @@ output "enabled_routes" { value = tailscale_device_subnet_routes.dut.routes }
// outputs (read path) against the server truth, and the resulting server state // outputs (read path) against the server truth, and the resulting server state
// (write path). parallelism=1 avoids racing concurrent mutations on the one // (write path). parallelism=1 avoids racing concurrent mutations on the one
// shared node. // shared node.
func apiv2DevicesACLTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey, hostname string, id types.NodeID) { func apiv2DevicesACLTerraform(t *testing.T, version string, srv *servertest.TestServer, baseURL, apiKey, hostname string, id types.NodeID) {
t.Helper() t.Helper()
tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(devicesACLTFConfig, hostname)) tf := newTofu(t, version, baseURL, apiKey, fmt.Sprintf(devicesACLTFConfig, hostname))
tf.run("init", "-no-color", "-input=false") tf.run("init", "-no-color", "-input=false")
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
+12 -44
View File
@@ -2,9 +2,6 @@ package servertest_test
import ( import (
"context" "context"
"os"
"os/exec"
"path/filepath"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -38,6 +35,15 @@ import (
// tofu is required, not optional: it ships in the nix dev shell, so a missing // tofu is required, not optional: it ships in the nix dev shell, so a missing
// binary means a broken environment and the test fails rather than skipping. // binary means a broken environment and the test fails rather than skipping.
func TestAPIv2OAuthScopes(t *testing.T) { func TestAPIv2OAuthScopes(t *testing.T) {
t.Parallel()
forEachProvider(t, apiv2OAuthScopes)
}
// apiv2OAuthScopes runs the matrix on one provider release. Each release gets
// its own server: the policy_file row rewrites the policy the others rely on.
func apiv2OAuthScopes(t *testing.T, version string) {
t.Helper()
srv := servertest.NewServer(t, servertest.WithRealListener()) srv := servertest.NewServer(t, servertest.WithRealListener())
owner := srv.CreateUser(t, "apiv2-oauth") owner := srv.CreateUser(t, "apiv2-oauth")
creator := owner.ID creator := owner.ID
@@ -288,7 +294,7 @@ resource "tailscale_tailnet_key" "k" {
secret, client, err := srv.State().CreateOAuthClient(tt.scopes, tt.tags, "scope-matrix", &creator) secret, client, err := srv.State().CreateOAuthClient(tt.scopes, tt.tags, "scope-matrix", &creator)
require.NoError(t, err) require.NoError(t, err)
tf := newTofuOAuth(t, srv.URL, client.ClientID, secret, oauthHCL(tt.config)) tf := newTofuOAuth(t, version, srv.URL, client.ClientID, secret, tt.config)
tf.run("init", "-no-color", "-input=false") tf.run("init", "-no-color", "-input=false")
if tt.deny { if tt.deny {
@@ -323,23 +329,6 @@ func setScopeMatrixPolicy(t *testing.T, srv *servertest.TestServer) {
require.NoError(t, err) require.NoError(t, err)
} }
// oauthHCL wraps a single-operation body with the provider block. The provider
// authenticates via the OAuth env vars newTofuOAuth sets, so the block is empty.
func oauthHCL(body string) string {
return `
terraform {
required_providers {
tailscale = {
source = "tailscale/tailscale"
version = "~> 0.21"
}
}
}
provider "tailscale" {}
` + body
}
// newTofuOAuth is a newTofu variant whose provider authenticates with OAuth // newTofuOAuth is a newTofu variant whose provider authenticates with OAuth
// client credentials instead of an API key: it sets TAILSCALE_OAUTH_CLIENT_ID / // client credentials instead of an API key: it sets TAILSCALE_OAUTH_CLIENT_ID /
// TAILSCALE_OAUTH_CLIENT_SECRET (the env vars the tailscale/tailscale provider // TAILSCALE_OAUTH_CLIENT_SECRET (the env vars the tailscale/tailscale provider
@@ -347,35 +336,14 @@ provider "tailscale" {}
// baseURL/api/v2/oauth/token. The client id is embedded in the secret, so the // baseURL/api/v2/oauth/token. The client id is embedded in the secret, so the
// secret embeds the client id (the provider sends both; the server derives the // secret embeds the client id (the provider sends both; the server derives the
// client from the secret alone). // client from the secret alone).
func newTofuOAuth(t *testing.T, baseURL, clientID, clientSecret, config string) *tofu { func newTofuOAuth(t *testing.T, version, baseURL, clientID, clientSecret, config string) *tofu {
t.Helper() t.Helper()
bin, err := exec.LookPath("tofu") return newTofuEnv(t, version, config,
require.NoErrorf(t, err, "tofu is required for TestAPIv2OAuthScopes (provided by the nix dev shell)")
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755))
env := append(
os.Environ(),
"TAILSCALE_BASE_URL="+baseURL, "TAILSCALE_BASE_URL="+baseURL,
"TAILSCALE_OAUTH_CLIENT_ID="+clientID, "TAILSCALE_OAUTH_CLIENT_ID="+clientID,
"TAILSCALE_OAUTH_CLIENT_SECRET="+clientSecret, "TAILSCALE_OAUTH_CLIENT_SECRET="+clientSecret,
"TAILSCALE_TAILNET=-",
// Keep provider plugins inside the temp dir so the run is self-contained.
"TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"),
) )
cmd := func(args ...string) *exec.Cmd {
c := exec.CommandContext(t.Context(), bin, args...)
c.Dir = dir
c.Env = env
return c
}
return &tofu{t: t, cmd: cmd}
} }
// runExpectError runs apply expecting FAILURE, asserting the combined output // runExpectError runs apply expecting FAILURE, asserting the combined output
+139 -41
View File
@@ -8,12 +8,14 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"testing" "testing"
"github.com/juanfont/headscale/hscontrol/servertest" "github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/mod/semver"
tsclient "tailscale.com/client/tailscale/v2" tsclient "tailscale.com/client/tailscale/v2"
) )
@@ -35,8 +37,7 @@ func TestAPIv2(t *testing.T) {
apiKey := srv.CreateAPIKey(t, owner) apiKey := srv.CreateAPIKey(t, owner)
// tag:ci must exist in policy for device SetTags; every policy the tests // tag:ci must exist in policy for device SetTags; every policy the tests
// write keeps it, so subtest order is irrelevant. Terraform runs last so its // write keeps it, so subtest order is irrelevant.
// ACL teardown does not strand the others.
setBaselinePolicy(t, srv) setBaselinePolicy(t, srv)
t.Run("GoClient", func(t *testing.T) { t.Run("GoClient", func(t *testing.T) {
@@ -59,12 +60,28 @@ func TestAPIv2(t *testing.T) {
apiv2SettingsTSCLI(t, srv.URL, apiKey) apiv2SettingsTSCLI(t, srv.URL, apiKey)
}) })
// Each provider release gets its own server: the configs rewrite the
// policy and retag a node, which parallel runs would race on one server.
t.Run("Terraform", func(t *testing.T) { t.Run("Terraform", func(t *testing.T) {
apiv2Terraform(t, srv, srv.URL, apiKey, owner) forEachProvider(t, func(t *testing.T, version string) {
apiv2UsersTerraform(t, srv, srv.URL, apiKey, owner) t.Helper()
srv := servertest.NewServer(t, servertest.WithRealListener())
owner := srv.CreateUser(t, "apiv2")
apiKey := srv.CreateAPIKey(t, owner)
setBaselinePolicy(t, srv)
apiv2Terraform(t, version, srv, srv.URL, apiKey, owner)
apiv2UsersTerraform(t, version, srv, srv.URL, apiKey, owner)
t.Run("ACLValidate", func(t *testing.T) {
requireProvider(t, version, providerACLValidate)
apiv2ACLValidateTerraform(t, version, srv, srv.URL, apiKey)
})
node := srv.CreateRegisteredNode(t, owner, "dut-tf") node := srv.CreateRegisteredNode(t, owner, "dut-tf")
apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID()) apiv2DevicesACLTerraform(t, version, srv, srv.URL, apiKey, node.Hostname(), node.ID())
})
}) })
} }
@@ -317,17 +334,6 @@ func apiv2TSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string
// Terraform path. Outputs expose the provider's read-back for value + drift // Terraform path. Outputs expose the provider's read-back for value + drift
// checks. // checks.
const terraformConfig = ` const terraformConfig = `
terraform {
required_providers {
tailscale = {
source = "tailscale/tailscale"
version = "~> 0.21"
}
}
}
provider "tailscale" {}
resource "tailscale_tailnet_key" "test" { resource "tailscale_tailnet_key" "test" {
reusable = true reusable = true
ephemeral = false ephemeral = false
@@ -344,10 +350,10 @@ output "key_description" { value = tailscale_tailnet_key.test.description }
// apiv2Terraform runs a tofu init→apply→(no-drift)→destroy roundtrip on a // apiv2Terraform runs a tofu init→apply→(no-drift)→destroy roundtrip on a
// tailnet key, cross-checking the provider outputs and the server's stored key. // tailnet key, cross-checking the provider outputs and the server's stored key.
func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { func apiv2Terraform(t *testing.T, version string, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
t.Helper() t.Helper()
tf := newTofu(t, baseURL, apiKey, terraformConfig) tf := newTofu(t, version, baseURL, apiKey, terraformConfig)
tf.run("init", "-no-color", "-input=false") tf.run("init", "-no-color", "-input=false")
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
@@ -386,17 +392,6 @@ func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey st
// login name. Data sources create nothing, so the assertions are value // login name. Data sources create nothing, so the assertions are value
// correctness plus no drift on re-read. // correctness plus no drift on re-read.
const usersTFConfig = ` const usersTFConfig = `
terraform {
required_providers {
tailscale = {
source = "tailscale/tailscale"
version = "~> 0.21"
}
}
}
provider "tailscale" {}
data "tailscale_user" "owner" { data "tailscale_user" "owner" {
login_name = "%s" login_name = "%s"
} }
@@ -420,10 +415,10 @@ output "via6" { value = data.tailscale_4via6.site.ipv6 }
// tailscale_user + tailscale_users data sources (and the provider-local // tailscale_user + tailscale_users data sources (and the provider-local
// tailscale_4via6), cross-checking the data-source outputs against the server's // tailscale_4via6), cross-checking the data-source outputs against the server's
// stored users. // stored users.
func apiv2UsersTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) { func apiv2UsersTerraform(t *testing.T, version string, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
t.Helper() t.Helper()
tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(usersTFConfig, owner.Username())) tf := newTofu(t, version, baseURL, apiKey, fmt.Sprintf(usersTFConfig, owner.Username()))
tf.run("init", "-no-color", "-input=false") tf.run("init", "-no-color", "-input=false")
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
@@ -448,6 +443,99 @@ func apiv2UsersTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiK
assert.GreaterOrEqual(t, srvUserCount(t, srv), 1, "users persist across data-source destroy") assert.GreaterOrEqual(t, srvUserCount(t, srv), 1, "users persist across data-source destroy")
} }
// aclValidateTFConfig is a policy whose own test fails: an allowed flow its
// test asserts is denied.
const aclValidateTFConfig = `
resource "tailscale_acl" "policy" {
acl = jsonencode({
acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }]
tests = [{ src = "100.64.0.9", deny = ["100.64.0.1:22"] }]
})
overwrite_existing_content = true
}
`
// apiv2ACLValidateTerraform proves the provider's plan-time check reaches
// acl/validate: plan refuses a policy whose test fails, with headscale's
// reason, and stores nothing.
func apiv2ACLValidateTerraform(t *testing.T, version string, srv *servertest.TestServer, baseURL, apiKey string) {
t.Helper()
before, err := srv.State().GetPolicy()
require.NoError(t, err)
tf := newTofu(t, version, baseURL, apiKey, aclValidateTFConfig)
tf.run("init", "-no-color", "-input=false")
out, err := tf.cmd("plan", "-no-color", "-input=false").CombinedOutput()
require.Errorf(t, err, "plan must refuse the policy:\n%s", out)
assert.Contains(t, string(out), "test(s) failed")
after, err := srv.State().GetPolicy()
require.NoError(t, err)
assert.Equal(t, before.Data, after.Data, "validate stores nothing")
}
// terraformProviderVersions are the tailscale/tailscale provider releases
// the Terraform tests run against: the newest three, kept by tools/bump.
// Committed rather than resolved at test time, so a new release arrives as a
// bump pull request instead of failing unrelated ones.
var terraformProviderVersions = []string{"0.29.1", "0.29.2", "0.29.3"}
// providerACLValidate is the first provider release that validates
// tailscale_acl at plan time, through acl/validate.
const providerACLValidate = "0.29.3"
// forEachProvider runs fn once per tested provider release, in parallel.
func forEachProvider(t *testing.T, fn func(t *testing.T, version string)) {
t.Helper()
for _, version := range terraformProviderVersions {
t.Run(version, func(t *testing.T) {
t.Parallel()
fn(t, version)
})
}
}
// requireProvider skips t on a provider release older than since, the
// first release with the feature under test.
func requireProvider(t *testing.T, version, since string) {
t.Helper()
if semver.Compare("v"+version, "v"+since) < 0 {
t.Skipf("provider %s predates %s", version, since)
}
}
// tofuProviderBlock pins the provider release. newTofu writes it beside
// every config, so no config names a version.
const tofuProviderBlock = `
terraform {
required_providers {
tailscale = {
source = "tailscale/tailscale"
version = "%s"
}
}
}
provider "tailscale" {}
`
// tofuPluginCache is shared by every workspace, release and run, so each
// release downloads once. tofu locks it against concurrent inits.
var tofuPluginCache = sync.OnceValues(func() (string, error) {
dir, err := os.UserCacheDir()
if err != nil {
return "", err
}
dir = filepath.Join(dir, "headscale", "tofu-plugins")
return dir, os.MkdirAll(dir, 0o755)
})
// tofu binds a tofu binary, working dir, and env for a single workspace. cmd is // tofu binds a tofu binary, working dir, and env for a single workspace. cmd is
// a closure capturing the looked-up binary so subprocess construction stays in // a closure capturing the looked-up binary so subprocess construction stays in
// one place. // one place.
@@ -456,24 +544,34 @@ type tofu struct {
cmd func(args ...string) *exec.Cmd cmd func(args ...string) *exec.Cmd
} }
func newTofu(t *testing.T, baseURL, apiKey, config string) *tofu { func newTofu(t *testing.T, version, baseURL, apiKey, config string) *tofu {
t.Helper()
return newTofuEnv(t, version, config, "TAILSCALE_BASE_URL="+baseURL, "TAILSCALE_API_KEY="+apiKey)
}
// newTofuEnv writes a workspace for config on provider version. env carries
// the provider's endpoint and credentials.
func newTofuEnv(t *testing.T, version, config string, env ...string) *tofu {
t.Helper() t.Helper()
bin, err := exec.LookPath("tofu") bin, err := exec.LookPath("tofu")
require.NoErrorf(t, err, "tofu is required for TestAPIv2 (provided by the nix dev shell)") require.NoErrorf(t, err, "tofu is required (provided by the nix dev shell)")
cache, err := tofuPluginCache()
require.NoError(t, err)
dir := t.TempDir() dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(dir, "provider.tf"), fmt.Appendf(nil, tofuProviderBlock, version), 0o600))
env := append( env = append(os.Environ(), append(env,
os.Environ(),
"TAILSCALE_BASE_URL="+baseURL,
"TAILSCALE_API_KEY="+apiKey,
"TAILSCALE_TAILNET=-", "TAILSCALE_TAILNET=-",
// Keep provider plugins inside the temp dir so the run is self-contained. "TF_PLUGIN_CACHE_DIR="+cache,
"TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"), // Without a lock file tofu otherwise downloads again to record
) // checksums, cache or not. A throwaway workspace has no lock file.
"TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE=true",
)...)
cmd := func(args ...string) *exec.Cmd { cmd := func(args ...string) *exec.Cmd {
c := exec.CommandContext(t.Context(), bin, args...) c := exec.CommandContext(t.Context(), bin, args...)