policy/v2: SaaS-derived compat tests for nodeAttrs
Adds a data-driven test that loads testdata/nodeattrs_results/*.hujson and diffs the captured SaaS-rendered netmaps against headscale's compileNodeAttrs output. Each capture is one scenario the SaaS control plane has rendered against the same policy headscale is asked to compile -- the test enforces shape parity per node. tailnet_state_caps.go enumerates the caps SaaS emits where headscale has no equivalent concept yet (user-role admin/owner, tailnet lock, services host, app connectors, internal magicsock and SSH tuning, tailnet-state metadata) plus the always-on baseline (admin, ssh, file-sharing) and the taildrive pair. stripUnmodelledTailnetStateCaps filters both sides of cmp.Diff so the comparison focuses on the policy-driven caps. PeerCapMap encodes which caps the Tailscale client reads from the peer view (suggest-exit-node when exit routes are approved, etc.) for use by the mapper. testcapture switches to typed tailcfg/netmap/filtertype/apitype values so schema drift between the capture tool and headscale becomes a compile error rather than a silent test failure. Existing compat suites (acl, grants, routes, ssh, issue_3212) move to the typed shape. The 53 SelfNode netmap captures and the 7 anonymizer-corrupted suggest-charmander -> suggest-exit-node restorations in routes_results / issue_3212 ride along.
This commit is contained in:
parent
3f73ed5404
commit
078b9e308f
72 changed files with 463447 additions and 183 deletions
311
hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go
Normal file
311
hscontrol/policy/v2/tailscale_nodeattrs_compat_test.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
// This file implements a data-driven test runner for nodeAttrs
|
||||
// compatibility tests. It loads HuJSON golden files from
|
||||
// testdata/nodeattrs_results/nodeattrs-*.hujson, captured from a
|
||||
// Tailscale-hosted control plane, and compares headscale's
|
||||
// `compileNodeAttrs` output against each captured netmap's SelfNode.CapMap.
|
||||
//
|
||||
// Each file is a testcapture.Capture containing:
|
||||
// - A full policy with a `nodeAttrs` block (and optionally `ipPool`)
|
||||
// - The expected per-node netmap from SaaS, including the cap map
|
||||
//
|
||||
// Tests known to fail due to unimplemented features are skipped with a
|
||||
// TODO comment explaining the root cause. As headscale's nodeAttrs
|
||||
// implementation grows, tests should be removed from the skip list.
|
||||
//
|
||||
// Test data source: testdata/nodeattrs_results/nodeattrs-*.hujson
|
||||
// Source format: github.com/juanfont/headscale/hscontrol/types/testcapture
|
||||
|
||||
package v2
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/juanfont/headscale/hscontrol/types/testcapture"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/views"
|
||||
)
|
||||
|
||||
// nodeAttrsCompatUsers returns the three norse-god users the capture
|
||||
// tool's anonymizer rewrites the SaaS users into.
|
||||
func nodeAttrsCompatUsers() types.Users {
|
||||
return types.Users{
|
||||
{Model: gorm.Model{ID: 1}, Name: "odin", Email: "odin@example.com"},
|
||||
{Model: gorm.Model{ID: 2}, Name: "thor", Email: "thor@example.org"},
|
||||
{Model: gorm.Model{ID: 3}, Name: "freya", Email: "freya@example.com"},
|
||||
}
|
||||
}
|
||||
|
||||
// buildNodeAttrsNodesFromCapture mirrors the grants compat helper: each
|
||||
// scenario's clean-slate run produces a different IP for the same
|
||||
// hostname, so the node set comes from the capture's topology rather
|
||||
// than a fixed table.
|
||||
//
|
||||
// [tailcfg.Hostinfo.RoutableIPs] and [types.Node.ApprovedRoutes]
|
||||
// round-trip from the topology so [types.NodeView.IsExitNode] reflects
|
||||
// the captured approval state — the suggest-exit-node peer-cap rule
|
||||
// only fires when a peer's exit routes are approved.
|
||||
func buildNodeAttrsNodesFromCapture(
|
||||
t *testing.T,
|
||||
users types.Users,
|
||||
tf *testcapture.Capture,
|
||||
) types.Nodes {
|
||||
t.Helper()
|
||||
|
||||
nodes := make(types.Nodes, 0, len(tf.Topology.Nodes))
|
||||
autoID := 1
|
||||
|
||||
for _, nodeDef := range tf.Topology.Nodes {
|
||||
node := &types.Node{
|
||||
ID: types.NodeID(autoID), //nolint:gosec
|
||||
GivenName: nodeDef.Hostname,
|
||||
IPv4: ptrAddr(nodeDef.IPv4),
|
||||
IPv6: ptrAddr(nodeDef.IPv6),
|
||||
Tags: nodeDef.Tags,
|
||||
Hostinfo: &tailcfg.Hostinfo{
|
||||
RoutableIPs: parsePrefixes(t, nodeDef.Hostname+".RoutableIPs", nodeDef.RoutableIPs),
|
||||
},
|
||||
ApprovedRoutes: parsePrefixes(t, nodeDef.Hostname+".ApprovedRoutes", nodeDef.ApprovedRoutes),
|
||||
}
|
||||
autoID++
|
||||
|
||||
if len(nodeDef.Tags) == 0 && nodeDef.User != "" {
|
||||
for i := range users {
|
||||
if users[i].Name == nodeDef.User {
|
||||
node.User = &users[i]
|
||||
node.UserID = &users[i].ID
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
// parsePrefixes converts a slice of CIDR strings into [netip.Prefix].
|
||||
// Bad entries fail loud through t.Fatalf — the topology files are
|
||||
// authoritative routing data, so a malformed CIDR is a testdata bug
|
||||
// that should surface, not silently drop the route and corrupt
|
||||
// downstream IsExitNode checks.
|
||||
func parsePrefixes(t *testing.T, name string, s []string) []netip.Prefix {
|
||||
t.Helper()
|
||||
|
||||
if len(s) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]netip.Prefix, 0, len(s))
|
||||
|
||||
for _, p := range s {
|
||||
pre, err := netip.ParsePrefix(p)
|
||||
if err != nil {
|
||||
t.Fatalf("topology %q: malformed CIDR %q: %v", name, p, err)
|
||||
}
|
||||
|
||||
out = append(out, pre)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeAttrsSkipReasons documents the captured scenarios SaaS accepts and
|
||||
// headscale deliberately rejects at validate time. The rejection itself is
|
||||
// covered by TestNodeAttrsValidate; this list keeps the compat diff focused
|
||||
// on shapes both control planes agree on.
|
||||
//
|
||||
// IPPOOL_ALLOCATOR — `ipPool` is parsed but the allocator that
|
||||
// consumes it is not yet implemented.
|
||||
// FUNNEL_NOT_SUPPORTED — `funnel` cap is rejected pending the DNS /
|
||||
// ACME machinery the feature requires.
|
||||
// NO_USER_ROLES — `autogroup:admin` and `autogroup:owner` depend on
|
||||
// user-role and tailnet-ownership concepts headscale does not
|
||||
// model.
|
||||
var nodeAttrsSkipReasons = map[string]string{
|
||||
"nodeattrs-ippool-g1-admin": "IPPOOL_ALLOCATOR",
|
||||
"nodeattrs-ippool-g2-group": "IPPOOL_ALLOCATOR",
|
||||
"nodeattrs-ippool-g3-mixed": "IPPOOL_ALLOCATOR",
|
||||
"nodeattrs-target-a10-autogroup-admin": "NO_USER_ROLES: autogroup:admin",
|
||||
"nodeattrs-target-a11-autogroup-owner": "NO_USER_ROLES: autogroup:owner",
|
||||
"nodeattrs-attr-c1-funnel": "FUNNEL_NOT_SUPPORTED",
|
||||
"nodeattrs-funnel-f1-tag": "FUNNEL_NOT_SUPPORTED",
|
||||
"nodeattrs-funnel-f2-user": "FUNNEL_NOT_SUPPORTED",
|
||||
}
|
||||
|
||||
// TestNodeAttrsCompat is a data-driven test that loads every captured
|
||||
// nodeAttrs scenario and compares headscale's compiled CapMap against
|
||||
// the corresponding SaaS-rendered netmap.
|
||||
func TestNodeAttrsCompat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
files, err := filepath.Glob(
|
||||
filepath.Join("testdata", "nodeattrs_results", "*.hujson"),
|
||||
)
|
||||
require.NoError(t, err, "failed to glob test files")
|
||||
|
||||
if len(files) == 0 {
|
||||
t.Skip(
|
||||
"testdata/nodeattrs_results is empty — re-run the capture " +
|
||||
"tool against the nodeattrs scenario set and copy the " +
|
||||
"anonymized results into " +
|
||||
"hscontrol/policy/v2/testdata/nodeattrs_results/",
|
||||
)
|
||||
}
|
||||
|
||||
t.Logf("Loaded %d nodeAttrs test files", len(files))
|
||||
|
||||
users := nodeAttrsCompatUsers()
|
||||
|
||||
for _, file := range files {
|
||||
tf := loadGrantTestFile(t, file)
|
||||
|
||||
t.Run(tf.TestID, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if reason, ok := nodeAttrsSkipReasons[tf.TestID]; ok {
|
||||
t.Skipf("TODO: %s", reason)
|
||||
}
|
||||
|
||||
nodes := buildNodeAttrsNodesFromCapture(t, users, tf)
|
||||
policyJSON := convertPolicyUserEmails(tf.Input.FullPolicy)
|
||||
|
||||
if tf.Input.APIResponseCode == 400 || tf.Error {
|
||||
testNodeAttrsError(t, policyJSON, tf)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
testNodeAttrsSuccess(t, policyJSON, tf, users, nodes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testNodeAttrsError(t *testing.T, policyJSON []byte, tf *testcapture.Capture) {
|
||||
t.Helper()
|
||||
|
||||
// SaaS error wording is not stable enough to compare exactly — the
|
||||
// e3-autogroup-self capture comes back as "internal server error",
|
||||
// for instance. The contract this test enforces is the weaker but
|
||||
// still-meaningful one: headscale must also refuse the policy at
|
||||
// parse or validate time.
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = pol.validate()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
wantMsg := ""
|
||||
if tf.Input.APIResponseBody != nil {
|
||||
wantMsg = tf.Input.APIResponseBody.Message
|
||||
}
|
||||
|
||||
// The dispatch in TestNodeAttrsCompat fires for either
|
||||
// APIResponseCode==400 or tf.Error==true; reflect the actual
|
||||
// trigger in the diagnostic so a tf.Error scenario doesn't get
|
||||
// reported as "saas code=0".
|
||||
t.Errorf(
|
||||
"%s: expected error (api_code=%d capture_error=%t msg=%q) "+
|
||||
"but policy parsed and validated successfully",
|
||||
tf.TestID, tf.Input.APIResponseCode, tf.Error, wantMsg,
|
||||
)
|
||||
}
|
||||
|
||||
func testNodeAttrsSuccess(
|
||||
t *testing.T,
|
||||
policyJSON []byte,
|
||||
tf *testcapture.Capture,
|
||||
users types.Users,
|
||||
nodes types.Nodes,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pol, err := unmarshalPolicy(policyJSON)
|
||||
require.NoErrorf(t, err, "%s: policy should parse", tf.TestID)
|
||||
require.NoErrorf(t, pol.validate(), "%s: policy should validate", tf.TestID)
|
||||
|
||||
got, err := pol.compileNodeAttrs(users, nodes.ViewSlice())
|
||||
require.NoErrorf(t, err, "%s: compileNodeAttrs", tf.TestID)
|
||||
|
||||
for nodeName, capture := range tf.Captures {
|
||||
if capture.Netmap == nil || !capture.Netmap.SelfNode.Valid() {
|
||||
continue
|
||||
}
|
||||
|
||||
t.Run(nodeName, func(t *testing.T) {
|
||||
node := findNodeByGivenName(nodes, nodeName)
|
||||
require.NotNilf(t, node,
|
||||
"node %q from capture not found in test setup", nodeName)
|
||||
|
||||
gotSelf := stripUnmodelledTailnetStateCaps(got[node.ID])
|
||||
wantSelf := stripUnmodelledTailnetStateCaps(
|
||||
capMapFromView(capture.Netmap.SelfNode.CapMap()),
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(wantSelf, gotSelf, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf(
|
||||
"%s/%s: SelfNode.CapMap mismatch (-tailscale +headscale):\n%s",
|
||||
tf.TestID, nodeName, diff,
|
||||
)
|
||||
}
|
||||
|
||||
for _, peer := range capture.Netmap.Peers {
|
||||
peerName := peer.ComputedName()
|
||||
|
||||
peerNode := findNodeByGivenName(nodes, peerName)
|
||||
if peerNode == nil {
|
||||
// A captured peer with no matching node in the
|
||||
// constructed topology is almost always topology
|
||||
// drift — fail loud so the gap is visible instead
|
||||
// of silently dropping the comparison.
|
||||
t.Errorf("%s/%s: capture peer %q not found in topology",
|
||||
tf.TestID, nodeName, peerName)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
gotPeer := stripUnmodelledTailnetStateCaps(
|
||||
PeerCapMap(peerNode.View(), got[peerNode.ID]),
|
||||
)
|
||||
wantPeer := stripUnmodelledTailnetStateCaps(
|
||||
capMapFromView(peer.CapMap()),
|
||||
)
|
||||
|
||||
if diff := cmp.Diff(wantPeer, gotPeer, cmpopts.EquateEmpty()); diff != "" {
|
||||
t.Errorf(
|
||||
"%s/%s/peer=%s: Peer.CapMap mismatch (-tailscale +headscale):\n%s",
|
||||
tf.TestID, nodeName, peerName, diff,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// capMapFromView materialises a captured CapMap view into the
|
||||
// [tailcfg.NodeCapMap] shape headscale renders, so both sides of the
|
||||
// diff have the same concrete type.
|
||||
func capMapFromView(view views.MapSlice[tailcfg.NodeCapability, tailcfg.RawMessage]) tailcfg.NodeCapMap {
|
||||
if view.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(tailcfg.NodeCapMap, view.Len())
|
||||
for k, v := range view.All() {
|
||||
out[k] = v.AsSlice()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue