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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue