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:
Kristoffer Dalby 2026-05-11 14:51:09 +00:00
parent 3f73ed5404
commit 078b9e308f
72 changed files with 463447 additions and 183 deletions

View file

@ -16,7 +16,7 @@ import (
// Nodes with filter rules: <X> of <Y> ← for non-SSH captures
// Nodes with SSH rules: <X> of <Y> ← for SSH captures
// Captured at: <RFC3339 UTC>
// tscap version: <ToolVersion>
// tool version: <ToolVersion>
// schema version: <SchemaVersion>
//
// Both `tool_version` and `schema_version` are also stored as
@ -53,7 +53,7 @@ func CommentHeader(c *Capture) string {
}
if c.ToolVersion != "" {
fmt.Fprintf(&b, "tscap version: %s\n", c.ToolVersion)
fmt.Fprintf(&b, "tool version: %s\n", c.ToolVersion)
}
if c.SchemaVersion != 0 {

View file

@ -1,16 +1,16 @@
// Package testcapture defines the on-disk format used by Headscale's
// policy v2 compatibility tests for golden data captured from
// Tailscale SaaS by the tscap tool.
// policy v2 compatibility tests for golden data captured from a
// Tailscale-hosted control plane by an external capture tool.
//
// Files are HuJSON. Wire-format Tailscale data (filter rules, netmap,
// whois, SSH rules) is stored as proper tailcfg/netmap/filtertype/
// apitype values rather than json.RawMessage so that schema drift
// between tscap and headscale becomes a compile error rather than a
// silent test failure, and so that consumers don't have to repeat
// json.Unmarshal at every read site. Storing data as json.RawMessage
// previously hid a serious capture-pipeline bug (the IPN bus initial
// notification returns a stale Peers slice — see the comment on
// Node.Netmap below) for months.
// between the capture tool and headscale becomes a compile error
// rather than a silent test failure, and so that consumers don't
// have to repeat json.Unmarshal at every read site. Storing data as
// json.RawMessage previously hid a serious capture-pipeline bug (the
// IPN bus initial notification returns a stale Peers slice — see the
// comment on Node.Netmap below) for months.
//
// All four capture types (acl, routes, grant, ssh) use the same Capture
// shape. SSH scenarios populate Captures[name].SSHRules; the others
@ -31,7 +31,7 @@ import (
// SchemaVersion identifies the on-disk format. Bumped on breaking changes.
//
// Files written before SchemaVersion existed do not have this field; new
// captures from tscap always set it to the current value.
// captures always set it to the current value.
const SchemaVersion = 1
// Capture is one captured run of one scenario.
@ -41,7 +41,7 @@ const SchemaVersion = 1
// Captures[name].PacketFilterRules + Captures[name].Netmap.
type Capture struct {
// SchemaVersion identifies the on-disk format version. Always set
// to testcapture.SchemaVersion when written by tscap.
// to testcapture.SchemaVersion when written.
SchemaVersion int `json:"schema_version"`
// TestID is the stable identifier of the scenario, derived from
@ -83,7 +83,7 @@ type Capture struct {
Input Input `json:"input"`
// Topology is the users and nodes present in the tailnet at
// capture time. Always populated by tscap.
// capture time. Always populated by the capture tool.
Topology Topology `json:"topology"`
// Captures holds the per-node captured data, keyed by node
@ -112,7 +112,7 @@ type Input struct {
// APIResponseBody is only populated when APIResponseCode != 200.
APIResponseBody *APIResponseBody `json:"api_response_body,omitempty"`
// Tailnet describes the tailnet-wide settings tscap applied
// Tailnet describes the tailnet-wide settings the capture tool applied
// before pushing the policy.
Tailnet TailnetInput `json:"tailnet"`
@ -209,7 +209,7 @@ type APIResponseBody struct {
Message string `json:"message,omitempty"`
}
// TailnetInput captures tailnet-wide settings tscap applied before
// TailnetInput captures tailnet-wide settings the capture tool applied before
// pushing the policy.
type TailnetInput struct {
DNS DNSInput `json:"dns"`
@ -227,18 +227,25 @@ type DNSInput struct {
// SettingsInput describes tailnet settings applied via the API.
//
// Pointer fields are nil when the scenario does not override the
// reset default for that setting.
// reset default for that setting. The fields mirror the
// PATCH /tailnet/{tailnet}/settings request shape exposed by
// tailscale.com/client/tailscale/v2 — in practice the headscale
// compatibility tests use the subset that observably affects the
// captured netmap CapMap or DNSConfig.
type SettingsInput struct {
DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"`
DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"`
DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"`
DevicesApprovalOn *bool `json:"devices_approval_on,omitempty"`
DevicesAutoUpdatesOn *bool `json:"devices_auto_updates_on,omitempty"`
DevicesKeyDurationDays *int `json:"devices_key_duration_days,omitempty"`
NetworkFlowLoggingOn *bool `json:"network_flow_logging_on,omitempty"`
RegionalRoutingOn *bool `json:"regional_routing_on,omitempty"`
PostureIdentityCollectionOn *bool `json:"posture_identity_collection_on,omitempty"`
}
// Topology describes the users and nodes present in the tailnet at
// capture time. Headscale's compat tests use this to construct
// equivalent types.User and types.Node objects.
type Topology struct {
// Users in the tailnet. Always populated by tscap.
// Users in the tailnet. Always populated by the capture tool.
Users []TopologyUser `json:"users"`
// Nodes in the tailnet, keyed by GivenName.
@ -300,16 +307,16 @@ type Node struct {
// Netmap is the full netmap as observed by the local tailscaled.
// NEVER trimmed. Consumers extract whatever fields they need.
//
// IMPORTANT: tscap captures this by waiting for the IPN bus to
// IMPORTANT: the capture tool captures this by waiting for the IPN bus to
// settle on a fresh delta-triggered notification, NOT by reading
// the WatchIPNBus(NotifyInitialNetMap) initial notification.
// The initial notification carries cn.NetMap() which returns
// nb.netMap as-is — the netmap.NetworkMap whose Peers slice was
// set at full-sync time and never re-synchronized from the
// authoritative nb.peers map. tscap previously used the initial
// authoritative nb.peers map. The capture tool previously used the initial
// notification and silently captured netmaps with mostly-empty
// Peers, which corrupted every via-grant compat test against the
// stale data. See tscap/tsdaemon/capture.go:NetMap for the
// stale data. See the capture tool source for the for the
// stability-wait pattern, and tailscale.com/ipn/ipnlocal/c2n.go
// :handleC2NDebugNetMap which uses netMapWithPeers() for the
// same reason.

View file

@ -34,7 +34,7 @@ func sampleACLCapture() *testcapture.Capture {
Description: "wildcard ACL: every node sees every other node",
Category: "acl",
CapturedAt: time.Date(2026, 4, 7, 12, 34, 56, 0, time.UTC),
ToolVersion: "tscap-test-0.0.0",
ToolVersion: "capture-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
@ -102,7 +102,7 @@ func sampleSSHCapture() *testcapture.Capture {
Description: "ssh accept autogroup:member to autogroup:self",
Category: "ssh",
CapturedAt: time.Date(2026, 4, 7, 13, 0, 0, 0, time.UTC),
ToolVersion: "tscap-test-0.0.0",
ToolVersion: "capture-test-0.0.0",
Tailnet: "kratail2tid@passkey",
Input: testcapture.Input{
FullPolicy: `{"ssh":[{"action":"accept","src":["autogroup:member"],"dst":["autogroup:self"],"users":["root"]}]}`,
@ -238,7 +238,7 @@ func TestWrite_ProducesCommentHeader(t *testing.T) {
t.Errorf("header missing capture timestamp; got:\n%s", header)
}
if !strings.Contains(header, "tscap version:") || !strings.Contains(header, "tscap-test-0.0.0") {
if !strings.Contains(header, "tool version:") || !strings.Contains(header, "capture-test-0.0.0") {
t.Errorf("header missing tool version; got:\n%s", header)
}