policy/v2: resolve an ACL's sources once, not once per destination

An ACL compiles to one grant per destination, and each resolved and
merged the same sources again. A wide CI-style ACL took 3s and 674MB
per compile; 88ms and 23MB now.
This commit is contained in:
Kristoffer Dalby authored and Kristoffer Dalby committed 2026-10-30 14:36:19 +01:00
1 parent 8e45958341
commit 526daf0841
3 files changed
+164 -10

No files matched your search

+73 -8
View File
@@ -244,9 +244,10 @@ func (pol *Policy) compileGrants(
} }
compiled := make([]compiledGrant, 0, len(grants)) compiled := make([]compiledGrant, 0, len(grants))
srcs := sourceCache{}
for _, grant := range grants { for _, grant := range grants {
cg, err := pol.compileOneGrant(grant, users, nodes) cg, err := pol.compileOneGrant(grant, users, nodes, srcs)
if err != nil { if err != nil {
log.Trace().Err(err).Msg("compiling grant") log.Trace().Err(err).Msg("compiling grant")
@@ -271,6 +272,7 @@ func (pol *Policy) compileOneGrant(
grant Grant, grant Grant,
users types.Users, users types.Users,
nodes views.Slice[types.NodeView], nodes views.Slice[types.NodeView],
srcs sourceCache,
) (*compiledGrant, error) { ) (*compiledGrant, error) {
// Via grants: resolve sources, store deferred data. // Via grants: resolve sources, store deferred data.
if len(grant.Via) > 0 { if len(grant.Via) > 0 {
@@ -291,9 +293,9 @@ func (pol *Policy) compileOneGrant(
// Resolve sources per-alias, tracking non-wildcard sources // Resolve sources per-alias, tracking non-wildcard sources
// separately so we can preserve their IPs alongside the // separately so we can preserve their IPs alongside the
// wildcard CGNAT ranges (matching Tailscale SaaS behavior). // wildcard CGNAT ranges (matching Tailscale SaaS behavior).
resolvedSrcs, nonWildcardSrcs, err := resolveSources( sources := srcs.get(pol, grant.Sources, users, nodes)
pol, grant.Sources, users, nodes,
) resolvedSrcs, err := sources.all, sources.err
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -309,10 +311,7 @@ func (pol *Policy) compileOneGrant(
hasWildcard := sourcesHaveWildcard(grant.Sources) hasWildcard := sourcesHaveWildcard(grant.Sources)
hasDangerAll := sourcesHaveDangerAll(grant.Sources) hasDangerAll := sourcesHaveDangerAll(grant.Sources)
srcIPStrings := buildSrcIPStrings( srcIPStrings := sources.strings(hasWildcard, hasDangerAll, nodes)
resolvedSrcs, nonWildcardSrcs,
hasWildcard, hasDangerAll, nodes,
)
cg := &compiledGrant{ cg := &compiledGrant{
srcIPStrings: srcIPStrings, srcIPStrings: srcIPStrings,
@@ -348,6 +347,12 @@ func mergeResolvedSrcs(resolvedSrcs []ResolvedAddresses) (resolved, error) {
var b netipx.IPSetBuilder var b netipx.IPSetBuilder
for _, ips := range resolvedSrcs { for _, ips := range resolvedSrcs {
if r, ok := ips.(resolved); ok {
b.AddSet(&r.ips)
continue
}
for _, pref := range ips.Prefixes() { for _, pref := range ips.Prefixes() {
b.AddPrefix(pref) b.AddPrefix(pref)
} }
@@ -356,6 +361,66 @@ func mergeResolvedSrcs(resolvedSrcs []ResolvedAddresses) (resolved, error) {
return newResolved(&b) return newResolved(&b)
} }
// sourceCache holds each source list's resolution for one compile: an ACL
// compiles to one grant per destination, all sharing its sources, and
// resolving and merging them is most of a wide ACL's compile.
type sourceCache map[sourcesKey]*grantSources
// sourcesKey names a source list by its backing array, which the grants
// of one ACL share.
type sourcesKey struct {
first *Alias
n int
}
// grantSources is a source list resolved per alias, all and without
// wildcards, and its SrcIPs once built.
type grantSources struct {
all, nonWild []ResolvedAddresses
err error
built bool
strs []string
}
// get resolves sources, once per source list; c may be nil.
func (c sourceCache) get(
pol *Policy,
sources Aliases,
users types.Users,
nodes views.Slice[types.NodeView],
) *grantSources {
key := sourcesKey{n: len(sources)}
if len(sources) > 0 {
key.first = &sources[0]
}
if s, ok := c[key]; ok {
return s
}
s := &grantSources{}
s.all, s.nonWild, s.err = resolveSources(pol, sources, users, nodes)
if c != nil {
c[key] = s
}
return s
}
// strings returns the SrcIPs of the sources. Both flags follow from the
// sources, so every grant sharing them asks the same. The slice is shared
// and clipped, so a caller appending to it copies.
func (s *grantSources) strings(hasWildcard, hasDangerAll bool, nodes views.Slice[types.NodeView]) []string {
if !s.built {
s.strs = slices.Clip(buildSrcIPStrings(s.all, s.nonWild, hasWildcard, hasDangerAll, nodes))
s.built = true
}
return s.strs
}
// compileOneViaGrant resolves sources for a via grant and stores the // compileOneViaGrant resolves sources for a via grant and stores the
// deferred per-node data. The actual via-node matching and route // deferred per-node data. The actual via-node matching and route
// intersection happens in [compileViaForNode]. // intersection happens in [compileViaForNode].
+2 -2
View File
@@ -3956,7 +3956,7 @@ func TestCompileViaGrant(t *testing.T) {
nodeView := tt.node.View() nodeView := tt.node.View()
nodesSlice := tt.nodes.ViewSlice() nodesSlice := tt.nodes.ViewSlice()
cg, err := tt.pol.compileOneGrant(tt.grant, users, nodesSlice) cg, err := tt.pol.compileOneGrant(tt.grant, users, nodesSlice, nil)
if tt.wantErr != nil { if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr) require.ErrorIs(t, err, tt.wantErr)
@@ -4129,7 +4129,7 @@ func TestCompileGrantWithAutogroupSelf_GrantPaths(t *testing.T) {
userIdx := buildUserNodeIndex(nodesSlice) userIdx := buildUserNodeIndex(nodesSlice)
cg, err := tt.pol.compileOneGrant( cg, err := tt.pol.compileOneGrant(
tt.grant, users, nodesSlice, tt.grant, users, nodesSlice, nil,
) )
if tt.wantErr != nil { if tt.wantErr != nil {
+89
View File
@@ -1,8 +1,10 @@
package v2 package v2
import ( import (
"encoding/json"
"fmt" "fmt"
"maps" "maps"
"math/rand/v2"
"net/netip" "net/netip"
"slices" "slices"
"testing" "testing"
@@ -4202,3 +4204,90 @@ func BenchmarkNodeCapMapsConstruction(b *testing.B) {
} }
} }
} }
// BenchmarkCompileWideACL compiles a CI-shaped policy: one rule per CI tag
// to its own service, and one wide rule from every CI tag to the shared
// services, which compiles to one grant per destination. new compiles from
// scratch; set-nodes recompiles after a batch of tagged joins.
func BenchmarkCompileWideACL(b *testing.B) {
const (
ciTags = 300
common = 100
ci = 5000
joins = 50
)
r := rand.New(rand.NewPCG(1, 2)) //nolint:gosec // reproducible benchmark input
owners := map[string][]string{}
tag := func(kind string, i int) string {
t := fmt.Sprintf("tag:%s%d", kind, i)
owners[t] = []string{}
return t
}
acls := make([]map[string]any, 0, ciTags+1)
wide := make([]string, 0, ciTags)
dsts := make([]string, 0, common)
for i := range ciTags {
acls = append(acls, map[string]any{"action": "accept", "src": []string{tag("ci-app", i)}, "dst": []string{tag("svc-app", i) + ":80"}})
wide = append(wide, tag("ci-app", i))
}
for i := range common {
dsts = append(dsts, tag("svc-common", i)+":80")
}
acls = append(acls, map[string]any{"action": "accept", "src": wide, "dst": dsts})
pol := must(json.Marshal(map[string]any{"tagOwners": owners, "acls": acls}))
node := func(i int, t string) *types.Node {
v4 := netip.AddrFrom4([4]byte{100, 64 + byte(i>>16), byte(i >> 8), byte(i)}) //nolint:gosec
v6 := netip.AddrFrom16([16]byte{0xfd, 0x7a, 0x11, 0x5c, 0xa1, 0xe0, 13: byte(i >> 16), 14: byte(i >> 8), 15: byte(i)}) //nolint:gosec
return &types.Node{ID: types.NodeID(i + 1), Hostname: fmt.Sprintf("n%d", i), IPv4: &v4, IPv6: &v6, Tags: []string{t}} //nolint:gosec
}
before := make(types.Nodes, 0, ciTags+common+ci)
for i := range ciTags {
before = append(before, node(len(before), fmt.Sprintf("tag:svc-app%d", i)))
}
for i := range common {
before = append(before, node(len(before), fmt.Sprintf("tag:svc-common%d", i)))
}
for range ci {
before = append(before, node(len(before), fmt.Sprintf("tag:ci-app%d", r.IntN(ciTags))))
}
after := slices.Clone(before)
for range joins {
after = append(after, node(len(after), fmt.Sprintf("tag:ci-app%d", r.IntN(ciTags))))
}
b.Run("new", func(b *testing.B) {
for b.Loop() {
_, err := NewPolicyManager(pol, nil, after.ViewSlice())
require.NoError(b, err)
}
})
b.Run("set-nodes", func(b *testing.B) {
pm, err := NewPolicyManager(pol, nil, before.ViewSlice())
require.NoError(b, err)
sets := []types.Nodes{after, before}
i := 0
for b.Loop() {
changed, err := pm.SetNodes(sets[i%2].ViewSlice())
require.NoError(b, err)
require.True(b, changed)
i++
}
})
}