mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-13 03:52:02 +09:00
dd9f0a3f4f
Exercises the user data sources via the Go SDK, tscli, and OpenTofu, and backfills tailscale_4via6 (provider-local compute), each with the no-drift gate.
579 lines
19 KiB
Go
579 lines
19 KiB
Go
package servertest_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
|
"github.com/juanfont/headscale/hscontrol/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
tsclient "tailscale.com/client/tailscale/v2"
|
|
)
|
|
|
|
// TestAPIv2 proves the v2 API's Tailscale-compatible (ported) endpoints against the three real
|
|
// clients it exists to support: the official Go SDK, tscli, and the Tailscale
|
|
// Terraform provider (via OpenTofu). All three run against one Headscale bound
|
|
// to a real loopback port, authenticating with a user-owned API key.
|
|
//
|
|
// Every mutation is validated three ways — the tool's own get-after-set, the
|
|
// server-side NodeStore/state, and (for Terraform) a no-change plan proving no
|
|
// drift — so a server/provider read-write mismatch fails loudly.
|
|
//
|
|
// tscli and tofu are required, not optional: they ship in the nix dev shell, so
|
|
// a missing binary means a broken environment and the test fails rather than
|
|
// silently skipping.
|
|
func TestAPIv2(t *testing.T) {
|
|
srv := servertest.NewServer(t, servertest.WithRealListener())
|
|
owner := srv.CreateUser(t, "apiv2")
|
|
apiKey := srv.CreateAPIKey(t, owner)
|
|
|
|
// 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
|
|
// ACL teardown does not strand the others.
|
|
setBaselinePolicy(t, srv)
|
|
|
|
t.Run("GoClient", func(t *testing.T) {
|
|
apiv2GoClient(t, srv, srv.URL, apiKey, owner)
|
|
apiv2UsersGoClient(t, srv, srv.URL, apiKey, owner)
|
|
|
|
node := srv.CreateRegisteredNode(t, owner, "dut-go")
|
|
apiv2DevicesGoClient(t, srv, srv.URL, apiKey, node.ID())
|
|
apiv2ACLGoClient(t, srv.URL, apiKey)
|
|
apiv2SettingsGoClient(t, srv.URL, apiKey)
|
|
})
|
|
|
|
t.Run("TSCLI", func(t *testing.T) {
|
|
apiv2TSCLI(t, srv, srv.URL, apiKey)
|
|
apiv2UsersTSCLI(t, srv.URL, apiKey, owner)
|
|
|
|
node := srv.CreateRegisteredNode(t, owner, "dut-tscli")
|
|
apiv2DevicesTSCLI(t, srv, srv.URL, apiKey, node.ID())
|
|
apiv2ACLTSCLI(t, srv.URL, apiKey)
|
|
apiv2SettingsTSCLI(t, srv.URL, apiKey)
|
|
})
|
|
|
|
t.Run("Terraform", func(t *testing.T) {
|
|
apiv2Terraform(t, srv, srv.URL, apiKey, owner)
|
|
apiv2UsersTerraform(t, srv, srv.URL, apiKey, owner)
|
|
|
|
node := srv.CreateRegisteredNode(t, owner, "dut-tf")
|
|
apiv2DevicesACLTerraform(t, srv, srv.URL, apiKey, node.Hostname(), node.ID())
|
|
})
|
|
}
|
|
|
|
// apiv2GoClient exercises the official SDK with untagged (user-owned) keys — the
|
|
// default Terraform/tscli path — validating each operation against the server's
|
|
// stored PreAuthKey, plus ephemeral and default-expiry permutations.
|
|
func apiv2GoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
|
|
t.Helper()
|
|
|
|
ctx := t.Context()
|
|
keys := goClient(t, baseURL, apiKey).Keys()
|
|
wantOwner := strconv.FormatUint(uint64(owner.ID), 10)
|
|
|
|
var req tsclient.CreateKeyRequest
|
|
|
|
req.Description = "go-client"
|
|
req.ExpirySeconds = 3600
|
|
req.Capabilities.Devices.Create.Reusable = true
|
|
|
|
created, err := keys.CreateAuthKey(ctx, req)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, created.ID)
|
|
assert.NotEmpty(t, created.Key, "secret returned on create")
|
|
assert.Equal(t, "go-client", created.Description)
|
|
assert.Equal(t, wantOwner, created.UserID, "user-owned key reports its owner")
|
|
|
|
// Server-side: the stored key matches the request and is owned by the user.
|
|
pak := srvPreAuthKey(t, srv, created.ID)
|
|
assert.True(t, pak.Reusable)
|
|
assert.False(t, pak.Ephemeral)
|
|
assert.Empty(t, pak.Tags, "no tags -> user-owned")
|
|
require.NotNil(t, pak.User)
|
|
assert.Equal(t, owner.ID, pak.User.ID)
|
|
assert.Equal(t, "go-client", pak.Description)
|
|
require.NotNil(t, pak.CreatedAt)
|
|
require.NotNil(t, pak.Expiration)
|
|
assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5)
|
|
|
|
got, err := keys.Get(ctx, created.ID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, created.ID, got.ID)
|
|
assert.Empty(t, got.Key, "secret omitted on get")
|
|
assert.Equal(t, "go-client", got.Description)
|
|
assert.False(t, got.Invalid)
|
|
// The SDK decodes our integer expirySeconds into a Duration of nanoseconds,
|
|
// so never assert it numerically; the lifetime rides on Expires-Created.
|
|
assert.InDelta(t, 3600, got.Expires.Sub(got.Created).Seconds(), 5)
|
|
|
|
list, err := keys.List(ctx, true)
|
|
require.NoError(t, err)
|
|
assert.True(t, containsKeyID(list, created.ID), "created key present in list")
|
|
|
|
// DELETE soft-revokes (Tailscale-faithful): the key stays retrievable, now
|
|
// invalid, until the collector reaps it.
|
|
require.NoError(t, keys.Delete(ctx, created.ID))
|
|
revoked, err := keys.Get(ctx, created.ID)
|
|
require.NoError(t, err, "revoked key stays retrievable")
|
|
assert.True(t, revoked.Invalid, "revoked key reports invalid")
|
|
require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side")
|
|
|
|
// Permutation — ephemeral key.
|
|
var ephReq tsclient.CreateKeyRequest
|
|
|
|
ephReq.Capabilities.Devices.Create.Ephemeral = true
|
|
eph, err := keys.CreateAuthKey(ctx, ephReq)
|
|
require.NoError(t, err)
|
|
assert.True(t, srvPreAuthKey(t, srv, eph.ID).Ephemeral)
|
|
require.NoError(t, keys.Delete(ctx, eph.ID))
|
|
|
|
// Permutation — default expiry (omit ExpirySeconds -> 90 days).
|
|
def, err := keys.CreateAuthKey(ctx, tsclient.CreateKeyRequest{})
|
|
require.NoError(t, err)
|
|
defKey := srvPreAuthKey(t, srv, def.ID)
|
|
require.NotNil(t, defKey.CreatedAt)
|
|
require.NotNil(t, defKey.Expiration)
|
|
assert.InDelta(t, 7776000, defKey.Expiration.Sub(*defKey.CreatedAt).Seconds(), 5)
|
|
require.NoError(t, keys.Delete(ctx, def.ID))
|
|
}
|
|
|
|
func containsKeyID(keys []tsclient.Key, id string) bool {
|
|
for _, k := range keys {
|
|
if k.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func containsUserID(users []tsclient.User, id string) bool {
|
|
for _, u := range users {
|
|
if u.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// srvUserCount is the server-side ground truth for the number of users.
|
|
func srvUserCount(t *testing.T, srv *servertest.TestServer) int {
|
|
t.Helper()
|
|
|
|
users, err := srv.State().ListAllUsers()
|
|
require.NoError(t, err)
|
|
|
|
return len(users)
|
|
}
|
|
|
|
// apiv2UsersGoClient exercises the Users data sources through the official SDK:
|
|
// get-by-id, list, the type/role filters (member matches all, anything else
|
|
// matches nothing), and a typed 404 — each cross-checked against server truth.
|
|
func apiv2UsersGoClient(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
|
|
t.Helper()
|
|
|
|
ctx := t.Context()
|
|
ur := goClient(t, baseURL, apiKey).Users()
|
|
ownerID := strconv.FormatUint(uint64(owner.ID), 10)
|
|
|
|
got, err := ur.Get(ctx, ownerID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, ownerID, got.ID)
|
|
assert.Equal(t, owner.Username(), got.LoginName)
|
|
assert.Equal(t, tsclient.UserTypeMember, got.Type)
|
|
assert.Equal(t, tsclient.UserStatusActive, got.Status)
|
|
assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(), got.DeviceCount,
|
|
"deviceCount matches the server's node count for the user")
|
|
|
|
all, err := ur.List(ctx, nil, nil)
|
|
require.NoError(t, err)
|
|
assert.True(t, containsUserID(all, ownerID), "owner present in user list")
|
|
assert.Len(t, all, srvUserCount(t, srv))
|
|
|
|
// member matches every Headscale user; shared/admin match nothing.
|
|
members, err := ur.List(ctx, new(tsclient.UserTypeMember), nil)
|
|
require.NoError(t, err)
|
|
assert.Len(t, members, len(all))
|
|
|
|
shared, err := ur.List(ctx, new(tsclient.UserTypeShared), nil)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, shared, "Headscale has no shared users")
|
|
|
|
admins, err := ur.List(ctx, nil, new(tsclient.UserRoleAdmin))
|
|
require.NoError(t, err)
|
|
assert.Empty(t, admins, "Headscale has no admin-role users")
|
|
|
|
_, err = ur.Get(ctx, "999999")
|
|
require.Error(t, err)
|
|
assert.True(t, tsclient.IsNotFound(err), "unknown user id is a typed 404")
|
|
}
|
|
|
|
// apiv2UsersTSCLI exercises the user verbs through tscli, asserting the owner is
|
|
// present in the list and retrievable by id.
|
|
func apiv2UsersTSCLI(t *testing.T, baseURL, apiKey string, owner *types.User) {
|
|
t.Helper()
|
|
|
|
run, _ := tscliRunner(t, baseURL, apiKey)
|
|
ownerID := strconv.FormatUint(uint64(owner.ID), 10)
|
|
|
|
listOut := run("list", "users", "-o", "json")
|
|
assert.Contains(t, listOut, ownerID)
|
|
assert.Contains(t, listOut, `"member"`)
|
|
|
|
getOut := run("get", "user", "--user", ownerID, "-o", "json")
|
|
assert.Contains(t, getOut, ownerID)
|
|
assert.Contains(t, getOut, owner.Username())
|
|
}
|
|
|
|
// srvPreAuthKey is the server-side ground truth for a key id; it fails the test
|
|
// if the key is absent.
|
|
func srvPreAuthKey(t *testing.T, srv *servertest.TestServer, id string) types.PreAuthKey {
|
|
t.Helper()
|
|
|
|
pak := findPAKByID(t, srv, id)
|
|
require.NotNilf(t, pak, "pre-auth key %s not found server-side", id)
|
|
|
|
return *pak
|
|
}
|
|
|
|
// findPAKByID returns the stored key with the given stringified id, or nil.
|
|
func findPAKByID(t *testing.T, srv *servertest.TestServer, id string) *types.PreAuthKey {
|
|
t.Helper()
|
|
|
|
want, err := strconv.ParseUint(id, 10, 64)
|
|
require.NoError(t, err)
|
|
|
|
keys, err := srv.State().ListPreAuthKeys()
|
|
require.NoError(t, err)
|
|
|
|
for i := range keys {
|
|
if keys[i].ID == want {
|
|
return &keys[i]
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// apiv2TSCLI exercises tscli with a tagged key, validating server-side that the
|
|
// stored key carries the requested tags and metadata.
|
|
func apiv2TSCLI(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string) {
|
|
t.Helper()
|
|
|
|
run, _ := tscliRunner(t, baseURL, apiKey)
|
|
|
|
out := run(
|
|
"create", "key",
|
|
"--type", "authkey",
|
|
"--description", "tscli",
|
|
"--expiry", "1h",
|
|
"--reusable",
|
|
"--tags", "tag:ci",
|
|
"-o", "json",
|
|
)
|
|
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
Key string `json:"key"`
|
|
}
|
|
require.NoErrorf(t, json.Unmarshal([]byte(out), &created), "tscli create output: %s", out)
|
|
assert.NotEmpty(t, created.ID)
|
|
assert.NotEmpty(t, created.Key, "secret returned on create")
|
|
|
|
// Server-side: tagged, reusable, described, no owner.
|
|
pak := srvPreAuthKey(t, srv, created.ID)
|
|
assert.Equal(t, []string{"tag:ci"}, pak.Tags)
|
|
assert.True(t, pak.Reusable)
|
|
assert.Equal(t, "tscli", pak.Description)
|
|
assert.Nil(t, pak.User, "tagged key has no owning user")
|
|
|
|
getOut := run("get", "key", "--key", created.ID, "-o", "json")
|
|
|
|
var got struct {
|
|
Key string `json:"key"`
|
|
}
|
|
require.NoError(t, json.Unmarshal([]byte(getOut), &got))
|
|
assert.Empty(t, got.Key, "secret omitted on get")
|
|
|
|
assert.Contains(t, run("list", "keys", "--all", "-o", "json"), created.ID)
|
|
|
|
// DELETE soft-revokes: the key stays retrievable (invalid) server-side until
|
|
// the collector reaps it.
|
|
run("delete", "key", "--key", created.ID)
|
|
assert.Contains(t, run("get", "key", "--key", created.ID, "-o", "json"), `"invalid": true`)
|
|
require.NotNil(t, srvPreAuthKey(t, srv, created.ID).Revoked, "key soft-revoked server-side")
|
|
}
|
|
|
|
// terraformConfig drives the tailscale_tailnet_key resource against the local
|
|
// server. No tags, so the key is owned by the API key's user — the default
|
|
// Terraform path. Outputs expose the provider's read-back for value + drift
|
|
// checks.
|
|
const terraformConfig = `
|
|
terraform {
|
|
required_providers {
|
|
tailscale = {
|
|
source = "tailscale/tailscale"
|
|
version = "~> 0.21"
|
|
}
|
|
}
|
|
}
|
|
|
|
provider "tailscale" {}
|
|
|
|
resource "tailscale_tailnet_key" "test" {
|
|
reusable = true
|
|
ephemeral = false
|
|
preauthorized = true
|
|
expiry = 3600
|
|
description = "tofu-roundtrip"
|
|
}
|
|
|
|
output "key_id" { value = tailscale_tailnet_key.test.id }
|
|
output "key_reusable" { value = tailscale_tailnet_key.test.reusable }
|
|
output "key_ephemeral" { value = tailscale_tailnet_key.test.ephemeral }
|
|
output "key_description" { value = tailscale_tailnet_key.test.description }
|
|
`
|
|
|
|
// 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.
|
|
func apiv2Terraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
|
|
t.Helper()
|
|
|
|
tf := newTofu(t, baseURL, apiKey, terraformConfig)
|
|
|
|
tf.run("init", "-no-color", "-input=false")
|
|
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
|
|
|
outputs := tf.outputs()
|
|
keyID := outputs.str(t, "key_id")
|
|
require.NotEmpty(t, keyID)
|
|
outputs.jsonEq(t, "key_reusable", true)
|
|
outputs.jsonEq(t, "key_ephemeral", false)
|
|
outputs.jsonEq(t, "key_description", "tofu-roundtrip")
|
|
|
|
// Server-side: the key exists, user-owned, with the requested attributes.
|
|
pak := srvPreAuthKey(t, srv, keyID)
|
|
assert.Equal(t, "tofu-roundtrip", pak.Description)
|
|
assert.True(t, pak.Reusable)
|
|
assert.False(t, pak.Ephemeral)
|
|
assert.Empty(t, pak.Tags, "no tags -> user-owned key")
|
|
require.NotNil(t, pak.User)
|
|
assert.Equal(t, owner.ID, pak.User.ID)
|
|
require.NotNil(t, pak.CreatedAt)
|
|
require.NotNil(t, pak.Expiration)
|
|
assert.InDelta(t, 3600, pak.Expiration.Sub(*pak.CreatedAt).Seconds(), 5)
|
|
|
|
// A converged config must produce an empty plan — drift is a read/write bug.
|
|
tf.assertNoDrift()
|
|
|
|
// destroy DELETEs the key, which soft-revokes it: the row is kept (revoked)
|
|
// until the collector reaps it.
|
|
tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
|
require.NotNil(t, srvPreAuthKey(t, srv, keyID).Revoked, "key revoked after destroy")
|
|
}
|
|
|
|
// usersTFConfig drives the tailscale_user (by login name) and tailscale_users
|
|
// data sources, plus tailscale_4via6 (provider-local compute, no server call) to
|
|
// prove that data source resolves against Headscale unchanged. %s is the owner's
|
|
// login name. Data sources create nothing, so the assertions are value
|
|
// correctness plus no drift on re-read.
|
|
const usersTFConfig = `
|
|
terraform {
|
|
required_providers {
|
|
tailscale = {
|
|
source = "tailscale/tailscale"
|
|
version = "~> 0.21"
|
|
}
|
|
}
|
|
}
|
|
|
|
provider "tailscale" {}
|
|
|
|
data "tailscale_user" "owner" {
|
|
login_name = "%s"
|
|
}
|
|
|
|
data "tailscale_users" "all" {}
|
|
|
|
data "tailscale_4via6" "site" {
|
|
site = 7
|
|
cidr = "10.1.1.0/24"
|
|
}
|
|
|
|
output "user_id" { value = data.tailscale_user.owner.id }
|
|
output "user_login_name" { value = data.tailscale_user.owner.login_name }
|
|
output "user_type" { value = data.tailscale_user.owner.type }
|
|
output "user_device_count" { value = data.tailscale_user.owner.device_count }
|
|
output "users_count" { value = length(data.tailscale_users.all.users) }
|
|
output "via6" { value = data.tailscale_4via6.site.ipv6 }
|
|
`
|
|
|
|
// apiv2UsersTerraform runs a tofu init/apply/(no-drift)/destroy over the
|
|
// tailscale_user + tailscale_users data sources (and the provider-local
|
|
// tailscale_4via6), cross-checking the data-source outputs against the server's
|
|
// stored users.
|
|
func apiv2UsersTerraform(t *testing.T, srv *servertest.TestServer, baseURL, apiKey string, owner *types.User) {
|
|
t.Helper()
|
|
|
|
tf := newTofu(t, baseURL, apiKey, fmt.Sprintf(usersTFConfig, owner.Username()))
|
|
|
|
tf.run("init", "-no-color", "-input=false")
|
|
tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
|
|
|
outputs := tf.outputs()
|
|
assert.Equal(t, strconv.FormatUint(uint64(owner.ID), 10), outputs.str(t, "user_id"))
|
|
assert.Equal(t, owner.Username(), outputs.str(t, "user_login_name"))
|
|
assert.Equal(t, "member", outputs.str(t, "user_type"))
|
|
assert.Equal(t, srv.State().ListNodesByUser(types.UserID(owner.ID)).Len(),
|
|
int(outputs.num(t, "user_device_count")))
|
|
assert.Equal(t, srvUserCount(t, srv), int(outputs.num(t, "users_count")))
|
|
|
|
// tailscale_4via6 is computed by the provider with no server call; assert it
|
|
// resolved to a Tailscale 4via6 address.
|
|
assert.Contains(t, outputs.str(t, "via6"), "fd7a:115c:a1e0", "4via6 mapped address")
|
|
|
|
// A converged data-source read must produce an empty plan — drift is a read bug.
|
|
tf.assertNoDrift()
|
|
|
|
// destroy removes only TF state; the users persist (they are data sources).
|
|
tf.run("destroy", "-auto-approve", "-no-color", "-input=false", "-parallelism=1")
|
|
assert.GreaterOrEqual(t, srvUserCount(t, srv), 1, "users persist across data-source destroy")
|
|
}
|
|
|
|
// 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
|
|
// one place.
|
|
type tofu struct {
|
|
t *testing.T
|
|
cmd func(args ...string) *exec.Cmd
|
|
}
|
|
|
|
func newTofu(t *testing.T, baseURL, apiKey, config string) *tofu {
|
|
t.Helper()
|
|
|
|
bin, err := exec.LookPath("tofu")
|
|
require.NoErrorf(t, err, "tofu is required for TestAPIv2 (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_API_KEY="+apiKey,
|
|
"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}
|
|
}
|
|
|
|
func (tf *tofu) run(args ...string) string {
|
|
tf.t.Helper()
|
|
|
|
out, err := tf.cmd(args...).CombinedOutput()
|
|
require.NoErrorf(tf.t, err, "tofu %s\n%s", strings.Join(args, " "), out)
|
|
|
|
return string(out)
|
|
}
|
|
|
|
// assertNoDrift fails if a no-change plan reports changes. plan
|
|
// -detailed-exitcode returns 0 = no changes, 1 = error, 2 = drift.
|
|
func (tf *tofu) assertNoDrift() {
|
|
tf.t.Helper()
|
|
|
|
out, err := tf.cmd("plan", "-detailed-exitcode", "-no-color", "-input=false", "-parallelism=1").CombinedOutput()
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
var exit *exec.ExitError
|
|
require.ErrorAsf(tf.t, err, &exit, "tofu plan\n%s", out)
|
|
require.Equalf(tf.t, 0, exit.ExitCode(),
|
|
"no-change plan after apply must be empty; drift means a provider read disagrees with desired state:\n%s", out)
|
|
}
|
|
|
|
func (tf *tofu) outputs() tofuOutputs {
|
|
tf.t.Helper()
|
|
|
|
out := tf.run("output", "-json", "-no-color")
|
|
|
|
var raw map[string]struct {
|
|
Value json.RawMessage `json:"value"`
|
|
}
|
|
require.NoError(tf.t, json.Unmarshal([]byte(out), &raw))
|
|
|
|
o := make(tofuOutputs, len(raw))
|
|
for k, v := range raw {
|
|
o[k] = v.Value
|
|
}
|
|
|
|
return o
|
|
}
|
|
|
|
// tofuOutputs is the decoded `tofu output -json`, keyed by output name.
|
|
type tofuOutputs map[string]json.RawMessage
|
|
|
|
func (o tofuOutputs) raw(t *testing.T, key string) json.RawMessage {
|
|
t.Helper()
|
|
|
|
v, ok := o[key]
|
|
require.Truef(t, ok, "output %q missing", key)
|
|
|
|
return v
|
|
}
|
|
|
|
func (o tofuOutputs) str(t *testing.T, key string) string {
|
|
t.Helper()
|
|
|
|
var s string
|
|
require.NoError(t, json.Unmarshal(o.raw(t, key), &s))
|
|
|
|
return s
|
|
}
|
|
|
|
func (o tofuOutputs) num(t *testing.T, key string) float64 {
|
|
t.Helper()
|
|
|
|
var n float64
|
|
require.NoError(t, json.Unmarshal(o.raw(t, key), &n))
|
|
|
|
return n
|
|
}
|
|
|
|
func (o tofuOutputs) strSlice(t *testing.T, key string) []string {
|
|
t.Helper()
|
|
|
|
var s []string
|
|
require.NoError(t, json.Unmarshal(o.raw(t, key), &s))
|
|
|
|
return s
|
|
}
|
|
|
|
// jsonEq asserts the output decodes equal to want (handles bools/strings/numbers).
|
|
func (o tofuOutputs) jsonEq(t *testing.T, key string, want any) {
|
|
t.Helper()
|
|
|
|
wantJSON, err := json.Marshal(want)
|
|
require.NoError(t, err)
|
|
require.JSONEq(t, string(wantJSON), string(o.raw(t, key)))
|
|
}
|