This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-informer-watcher.git
The following commit(s) were added to refs/heads/master by this push:
new 0e911cf feat: support incremental policy updates from CRD events
0e911cf is described below
commit 0e911cfad7396bc2f285a83b0f5213d967a97f7c
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 9 22:39:31 2026 +0800
feat: support incremental policy updates from CRD events
---
README.md | 71 +++++++-
event_test.go | 549 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
options.go | 7 +
policy.go | 196 ++++++++++++++++++++
policy_test.go | 269 +++++++++++++++++++++++++++
watcher.go | 213 +++++++++++++++++++---
watcher_test.go | 4 +-
7 files changed, 1272 insertions(+), 37 deletions(-)
diff --git a/README.md b/README.md
index eb84f35..20a958a 100644
--- a/README.md
+++ b/README.md
@@ -130,9 +130,10 @@ func main() {
// Create watcher with custom options
options := informerwatcher.WatcherOptions{
- LocalID: "instance-1", // Custom instance
identifier
- IgnoreSelf: true, // Ignore updates from
this instance
- ResyncPeriod: 30 * time.Second, // Resync period with API
server
+ LocalID: "instance-1", // Custom instance
identifier
+ IgnoreSelf: true, // Ignore updates from
this instance
+ ResyncPeriod: 30 * time.Second, // Resync period with API
server
+ IncrementalUpdate: true, // Report the rules that
changed instead of reloading
}
watcher, err := informerwatcher.NewWatcher(client, gvr, "default",
options)
@@ -221,16 +222,68 @@ func main() {
- **LocalID** (string): Unique identifier for this watcher instance.
Auto-generated if not provided.
- **IgnoreSelf** (bool): If true, ignores updates triggered by this watcher
instance. Default: false.
- **ResyncPeriod** (time.Duration): Period for the informer to resync with the
API server. Default: 30 seconds.
-- **OptionalUpdateCallback** (func(string)): Optional callback function set
during initialization.
+- **OptionalUpdateCallback** (func(string)): Optional callback function set
during initialization. Equivalent to calling `SetUpdateCallback` right after
`NewWatcher`, but it is in place before the first event arrives.
+- **IncrementalUpdate** (bool): If true, the watcher reports the exact rules
that changed instead of asking the enforcer to reload its whole policy.
Default: false. See [Update Modes](#update-modes).
## How It Works
1. The watcher uses Kubernetes informers to monitor Custom Resource
Definitions (CRDs) that represent Casbin policies.
-2. When a CRD is created, updated, or deleted, the informer triggers the
corresponding event handler.
-3. The event handler processes the change and invokes the registered callback
function.
-4. The callback typically triggers the enforcer to reload its policy or apply
incremental updates.
+2. When a policy resource is created, updated, or deleted, the informer
triggers the corresponding event handler.
+3. The event handler turns the change into one or more watcher messages and
invokes the registered callback function.
+4. `DefaultUpdateCallback` applies each message to the enforcer, either by
reloading the policy or by applying the incremental change.
5. All running instances with the same watcher configuration receive the same
updates, keeping policies synchronized.
+Three kinds of event carry no change and are dropped rather than reported:
+
+- The resources that already exist when the watcher starts. The informer
replays them as create events while it builds its cache, but they describe the
state the enforcer loaded through its own adapter.
+- The periodic resync, which re-delivers every cached resource. Kubernetes
only bumps `resourceVersion` on a real write, so a resource that comes back
with the same one has not changed.
+- Updates from this instance itself, when `IgnoreSelf` is enabled and the
resource carries the `casbin.org/source-id` annotation with this watcher's
`LocalID`.
+
+Deletions missed while the watch was down arrive as a tombstone rather than
the resource itself; the watcher unwraps it and reports the deletion normally.
+
+## Update Modes
+
+By default, every observed change asks the enforcer for a full reload (the
`Update` message). Reloading is idempotent, so instances cannot drift apart no
matter how a resource was edited. This is the recommended mode when the
enforcer's adapter reads the same resources the watcher observes.
+
+With `IncrementalUpdate` enabled, the watcher reads the rules out of the
resource and reports only what changed:
+
+- A create or delete becomes an add or remove of the rules the resource
carries, batched per section and policy type.
+- An edit is diffed: the rules that disappeared are removed and the rules that
appeared are added. A resource that gains or loses a line is handled the same
way as one whose lines were rewritten.
+- Anything that cannot be described this way — a resource that does not follow
the layout below, or a malformed policy line — falls back to a full reload, so
an unrecognized change is never silently dropped.
+
+Incremental mode requires policy resources to follow the [policy resource
layout](#policy-resource-layout). Note that Casbin applies these messages
through its `Self*` APIs, which write through to the adapter when `AutoSave` is
enabled: if your adapter is backed by the same resources the watcher observes,
keep the default reload mode so the observed change is not written straight
back.
+
+## Policy Resource Layout
+
+In incremental mode the watcher reads rules from two `spec` fields. Both hold
Casbin policy lines in the same format used in a policy CSV file, so the policy
type comes first and the section is its first character (`p` and `p2` belong to
section `p`, `g` and `g2` to section `g`). Blank lines and `#` comments are
skipped.
+
+A single rule, or several newline-separated ones:
+
+```yaml
+apiVersion: casbin.org/v1
+kind: Policy
+metadata:
+ name: alice-can-read
+spec:
+ policy: "p, alice, data1, read"
+```
+
+Or a list:
+
+```yaml
+apiVersion: casbin.org/v1
+kind: Policy
+metadata:
+ name: team-policies
+spec:
+ policies:
+ - "p, alice, data1, read"
+ - "p, bob, data2, write"
+ - "g, alice, admin"
+```
+
+The default reload mode does not read these fields, and works with any
resource layout.
+
## Supported Update Types
The watcher supports all standard Casbin policy update operations:
@@ -269,6 +322,10 @@ spec:
properties:
policy:
type: string
+ policies:
+ type: array
+ items:
+ type: string
scope: Namespaced
names:
plural: policies
diff --git a/event_test.go b/event_test.go
new file mode 100644
index 0000000..2a7771a
--- /dev/null
+++ b/event_test.go
@@ -0,0 +1,549 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package informerwatcher
+
+import (
+ "reflect"
+ "sync"
+ "testing"
+
+ "github.com/casbin/casbin/v2"
+ "github.com/casbin/casbin/v2/model"
+ "github.com/casbin/casbin/v2/persist"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/client-go/tools/cache"
+)
+
+const testModel = `
+[request_definition]
+r = sub, obj, act
+
+[policy_definition]
+p = sub, obj, act
+
+[role_definition]
+g = _, _
+
+[policy_effect]
+e = some(where (p.eft == allow))
+
+[matchers]
+m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
+`
+
+// collector records the messages a watcher hands to its callback.
+type collector struct {
+ mu sync.Mutex
+ msgs []*MSG
+}
+
+func (c *collector) callback(msg string) {
+ decoded := &MSG{}
+ if err := decoded.UnmarshalBinary([]byte(msg)); err != nil {
+ panic(err)
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.msgs = append(c.msgs, decoded)
+}
+
+func (c *collector) take() []*MSG {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ msgs := c.msgs
+ c.msgs = nil
+
+ return msgs
+}
+
+func newTestWatcher(t *testing.T, options WatcherOptions) (*Watcher,
*collector) {
+ t.Helper()
+
+ msgs := &collector{}
+ options.OptionalUpdateCallback = msgs.callback
+
+ watcher, err := NewWatcher(createTestClient(), testGVR, "", options)
+ if err != nil {
+ t.Fatalf("Failed to create watcher: %v", err)
+ }
+
+ t.Cleanup(watcher.Close)
+
+ return watcher.(*Watcher), msgs
+}
+
+func withVersion(obj *unstructured.Unstructured, version string)
*unstructured.Unstructured {
+ obj.SetResourceVersion(version)
+ return obj
+}
+
+// TestOptionalUpdateCallback tests that a callback passed through the options
+// is used without a separate SetUpdateCallback call.
+func TestOptionalUpdateCallback(t *testing.T) {
+ w, msgs := newTestWatcher(t, WatcherOptions{})
+
+ w.handleEvent(eventAdd, nil, newPolicyResource(map[string]interface{}{
+ "policy": "p, alice, data1, read",
+ }))
+
+ if got := msgs.take(); len(got) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(got))
+ }
+}
+
+// TestNewWatcherWithNilClient tests that an unusable configuration is
rejected.
+func TestNewWatcherWithNilClient(t *testing.T) {
+ if _, err := NewWatcher(nil, testGVR, "", WatcherOptions{}); err == nil
{
+ t.Error("Expected an error when the dynamic client is nil")
+ }
+}
+
+// TestReloadOnChange tests that, by default, every change asks the enforcer
for
+// a full reload.
+func TestReloadOnChange(t *testing.T) {
+ w, msgs := newTestWatcher(t, WatcherOptions{})
+
+ obj := withVersion(newPolicyResource(map[string]interface{}{
+ "policy": "p, alice, data1, read",
+ }), "1")
+ updated := withVersion(newPolicyResource(map[string]interface{}{
+ "policy": "p, alice, data1, write",
+ }), "2")
+
+ w.handleEvent(eventAdd, nil, obj)
+ w.handleEvent(eventUpdate, obj, updated)
+ w.handleEvent(eventDelete, updated, nil)
+
+ got := msgs.take()
+ if len(got) != 3 {
+ t.Fatalf("Expected 3 messages, got %d", len(got))
+ }
+
+ for i, msg := range got {
+ if msg.Method != Update {
+ t.Errorf("Message %d: expected method %q, got %q", i,
Update, msg.Method)
+ }
+ }
+}
+
+// TestIncrementalAddAndDelete tests that a created or removed resource is
+// translated into the matching policy operations.
+func TestIncrementalAddAndDelete(t *testing.T) {
+ tests := []struct {
+ name string
+ wantSingle UpdateType
+ wantBatch UpdateType
+ }{
+ {"add", UpdateForAddPolicy, UpdateForAddPolicies},
+ {"delete", UpdateForRemovePolicy, UpdateForRemovePolicies},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w, msgs := newTestWatcher(t,
WatcherOptions{IncrementalUpdate: true})
+
+ single := newPolicyResource(map[string]interface{}{
+ "policy": "p, alice, data1, read",
+ })
+ batch := newPolicyResource(map[string]interface{}{
+ "policies": []interface{}{
+ "p, alice, data1, read",
+ "p, bob, data2, write",
+ "g, alice, admin",
+ },
+ })
+
+ fire := func(obj *unstructured.Unstructured) {
+ if tt.name == "add" {
+ w.handleEvent(eventAdd, nil, obj)
+ return
+ }
+ w.handleEvent(eventDelete, obj, nil)
+ }
+
+ fire(single)
+
+ got := msgs.take()
+ if len(got) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(got))
+ }
+ if got[0].Method != tt.wantSingle {
+ t.Errorf("Expected method %q, got %q",
tt.wantSingle, got[0].Method)
+ }
+ if !reflect.DeepEqual(got[0].NewRule, []string{"alice",
"data1", "read"}) {
+ t.Errorf("Unexpected rule: %v", got[0].NewRule)
+ }
+
+ fire(batch)
+
+ // The p rules batch together; the g rule is its own
section.
+ got = msgs.take()
+ if len(got) != 2 {
+ t.Fatalf("Expected 2 messages, got %d",
len(got))
+ }
+
+ if got[0].Method != tt.wantBatch || got[0].Sec != "p"
|| got[0].Ptype != "p" {
+ t.Errorf("Unexpected p message: %+v", got[0])
+ }
+ if len(got[0].NewRules) != 2 {
+ t.Errorf("Expected 2 batched p rules, got %v",
got[0].NewRules)
+ }
+
+ if got[1].Method != tt.wantSingle || got[1].Sec != "g"
|| got[1].Ptype != "g" {
+ t.Errorf("Unexpected g message: %+v", got[1])
+ }
+ })
+ }
+}
+
+// TestIncrementalUpdate tests that an edited resource is translated into the
+// rules that were actually removed and added.
+func TestIncrementalUpdate(t *testing.T) {
+ tests := []struct {
+ name string
+ old []interface{}
+ new []interface{}
+ want []*MSG
+ }{
+ {
+ name: "rule replaced",
+ old: []interface{}{"p, alice, data1, read"},
+ new: []interface{}{"p, alice, data1, write"},
+ want: []*MSG{
+ {Method: UpdateForRemovePolicy, Sec: "p",
Ptype: "p", NewRule: []string{"alice", "data1", "read"}},
+ {Method: UpdateForAddPolicy, Sec: "p", Ptype:
"p", NewRule: []string{"alice", "data1", "write"}},
+ },
+ },
+ {
+ name: "rule added",
+ old: []interface{}{"p, alice, data1, read"},
+ new: []interface{}{"p, alice, data1, read", "p, bob,
data2, write"},
+ want: []*MSG{
+ {Method: UpdateForAddPolicy, Sec: "p", Ptype:
"p", NewRule: []string{"bob", "data2", "write"}},
+ },
+ },
+ {
+ name: "rule removed",
+ old: []interface{}{"p, alice, data1, read", "p, bob,
data2, write"},
+ new: []interface{}{"p, alice, data1, read"},
+ want: []*MSG{
+ {Method: UpdateForRemovePolicy, Sec: "p",
Ptype: "p", NewRule: []string{"bob", "data2", "write"}},
+ },
+ },
+ {
+ name: "resource emptied",
+ old: []interface{}{"p, alice, data1, read", "p, bob,
data2, write"},
+ new: []interface{}{},
+ want: []*MSG{
+ {Method: UpdateForRemovePolicies, Sec: "p",
Ptype: "p", NewRules: [][]string{
+ {"alice", "data1", "read"},
+ {"bob", "data2", "write"},
+ }},
+ },
+ },
+ {
+ name: "rules reordered only",
+ old: []interface{}{"p, alice, data1, read", "p, bob,
data2, write"},
+ new: []interface{}{"p, bob, data2, write", "p, alice,
data1, read"},
+ want: nil,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w, msgs := newTestWatcher(t,
WatcherOptions{IncrementalUpdate: true})
+
+ oldObj :=
withVersion(newPolicyResource(map[string]interface{}{"policies": tt.old}), "1")
+ newObj :=
withVersion(newPolicyResource(map[string]interface{}{"policies": tt.new}), "2")
+
+ w.handleEvent(eventUpdate, oldObj, newObj)
+
+ got := msgs.take()
+ if len(got) != len(tt.want) {
+ t.Fatalf("Expected %d messages, got %d: %+v",
len(tt.want), len(got), got)
+ }
+
+ for i, want := range tt.want {
+ want.ID = w.localID
+
+ if !reflect.DeepEqual(got[i], want) {
+ t.Errorf("Message %d: expected %+v, got
%+v", i, want, got[i])
+ }
+ }
+ })
+ }
+}
+
+// TestResyncIsIgnored tests that the periodic resync, which re-delivers every
+// cached resource unchanged, produces no messages.
+func TestResyncIsIgnored(t *testing.T) {
+ for _, incremental := range []bool{false, true} {
+ w, msgs := newTestWatcher(t, WatcherOptions{IncrementalUpdate:
incremental})
+
+ obj := withVersion(newPolicyResource(map[string]interface{}{
+ "policy": "p, alice, data1, read",
+ }), "42")
+
+ w.handleEvent(eventUpdate, obj, obj.DeepCopy())
+
+ if got := msgs.take(); len(got) != 0 {
+ t.Errorf("IncrementalUpdate=%v: expected no messages
for a resync, got %+v", incremental, got)
+ }
+ }
+}
+
+// TestDeleteTombstone tests the tombstone Kubernetes delivers when a deletion
+// was missed while the watch was down.
+func TestDeleteTombstone(t *testing.T) {
+ w, msgs := newTestWatcher(t, WatcherOptions{IncrementalUpdate: true})
+
+ tombstone := cache.DeletedFinalStateUnknown{
+ Key: "default/test-policy",
+ Obj: newPolicyResource(map[string]interface{}{"policy": "p,
alice, data1, read"}),
+ }
+
+ w.handleEvent(eventDelete, tombstone, nil)
+
+ got := msgs.take()
+ if len(got) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(got))
+ }
+
+ if got[0].Method != UpdateForRemovePolicy {
+ t.Errorf("Expected method %q, got %q", UpdateForRemovePolicy,
got[0].Method)
+ }
+
+ w.handleEvent(eventDelete, cache.DeletedFinalStateUnknown{Key:
"default/gone"}, nil)
+
+ if got := msgs.take(); len(got) != 0 {
+ t.Errorf("Expected no messages for an empty tombstone, got
%+v", got)
+ }
+}
+
+// TestIncrementalFallsBackToReload tests that a change which cannot be
+// described incrementally still reaches the enforcer as a full reload.
+func TestIncrementalFallsBackToReload(t *testing.T) {
+ tests := []struct {
+ name string
+ spec map[string]interface{}
+ }{
+ {"unknown layout", map[string]interface{}{"unexpected":
"field"}},
+ {"malformed policy line", map[string]interface{}{"policy":
"p"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ w, msgs := newTestWatcher(t,
WatcherOptions{IncrementalUpdate: true})
+
+ w.handleEvent(eventAdd, nil, newPolicyResource(tt.spec))
+
+ got := msgs.take()
+ if len(got) != 1 {
+ t.Fatalf("Expected 1 message, got %d", len(got))
+ }
+
+ if got[0].Method != Update {
+ t.Errorf("Expected method %q, got %q", Update,
got[0].Method)
+ }
+ })
+ }
+}
+
+// TestNoEventsAfterClose tests that a closed watcher stops reporting changes.
+func TestNoEventsAfterClose(t *testing.T) {
+ w, msgs := newTestWatcher(t, WatcherOptions{})
+
+ w.Close()
+ w.handleEvent(eventAdd, nil, newPolicyResource(map[string]interface{}{
+ "policy": "p, alice, data1, read",
+ }))
+
+ if got := msgs.take(); len(got) != 0 {
+ t.Errorf("Expected no messages after Close, got %+v", got)
+ }
+}
+
+// memoryAdapter stands in for an adapter backed by the same resources the
+// watcher observes: a test changes its contents to mimic a GitOps apply, then
+// delivers the matching informer event.
+type memoryAdapter struct {
+ mu sync.Mutex
+ lines []string
+}
+
+func (a *memoryAdapter) setLines(lines ...string) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ a.lines = lines
+}
+
+func (a *memoryAdapter) LoadPolicy(m model.Model) error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ for _, line := range a.lines {
+ if err := persist.LoadPolicyLine(line, m); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (a *memoryAdapter) SavePolicy(model.Model) error { return
nil }
+func (a *memoryAdapter) AddPolicy(string, string, []string) error { return
nil }
+func (a *memoryAdapter) RemovePolicy(string, string, []string) error { return
nil }
+func (a *memoryAdapter) RemoveFilteredPolicy(string, string, int, ...string)
error {
+ return nil
+}
+
+// TestEnforcementFollowsReload tests that create, update and delete events
+// reach enforcement decisions in the default reload mode.
+func TestEnforcementFollowsReload(t *testing.T) {
+ adapter := &memoryAdapter{lines: []string{"p, alice, data1, read"}}
+
+ m, err := model.NewModelFromString(testModel)
+ if err != nil {
+ t.Fatalf("Failed to build model: %v", err)
+ }
+
+ e, err := casbin.NewEnforcer(m, adapter)
+ if err != nil {
+ t.Fatalf("Failed to create enforcer: %v", err)
+ }
+
+ w, _ := newTestWatcher(t, WatcherOptions{})
+ if err := w.SetUpdateCallback(DefaultUpdateCallback(e)); err != nil {
+ t.Fatalf("Failed to set callback: %v", err)
+ }
+
+ assertEnforce(t, e, true, "alice", "data1", "read")
+
+ adapter.setLines("p, alice, data1, write")
+ w.handleEvent(eventUpdate,
+ withVersion(newPolicyResource(map[string]interface{}{"policy":
"p, alice, data1, read"}), "1"),
+ withVersion(newPolicyResource(map[string]interface{}{"policy":
"p, alice, data1, write"}), "2"))
+
+ assertEnforce(t, e, false, "alice", "data1", "read")
+ assertEnforce(t, e, true, "alice", "data1", "write")
+
+ adapter.setLines()
+ w.handleEvent(eventDelete,
+ withVersion(newPolicyResource(map[string]interface{}{"policy":
"p, alice, data1, write"}), "2"), nil)
+
+ assertEnforce(t, e, false, "alice", "data1", "write")
+}
+
+// TestEnforcementFollowsIncrementalUpdates tests that create, update and
delete
+// events reach enforcement decisions in incremental mode, including the role
+// links a grouping rule brings with it.
+func TestEnforcementFollowsIncrementalUpdates(t *testing.T) {
+ m, err := model.NewModelFromString(testModel)
+ if err != nil {
+ t.Fatalf("Failed to build model: %v", err)
+ }
+
+ e, err := casbin.NewEnforcer(m)
+ if err != nil {
+ t.Fatalf("Failed to create enforcer: %v", err)
+ }
+
+ w, _ := newTestWatcher(t, WatcherOptions{IncrementalUpdate: true})
+ if err := w.SetUpdateCallback(DefaultUpdateCallback(e)); err != nil {
+ t.Fatalf("Failed to set callback: %v", err)
+ }
+
+ direct := map[string]interface{}{"policy": "p, alice, data1, read"}
+ w.handleEvent(eventAdd, nil, withVersion(newPolicyResource(direct),
"1"))
+ assertEnforce(t, e, true, "alice", "data1", "read")
+
+ // Access granted indirectly, so the grouping rule has to rebuild the
+ // enforcer's role links.
+ roles := map[string]interface{}{
+ "policies": []interface{}{"g, alice, admin", "p, admin, data2,
write"},
+ }
+ w.handleEvent(eventAdd, nil, withVersion(newPolicyResource(roles), "1"))
+ assertEnforce(t, e, true, "alice", "data2", "write")
+
+ swapped := map[string]interface{}{"policy": "p, alice, data1, write"}
+ w.handleEvent(eventUpdate,
+ withVersion(newPolicyResource(direct), "1"),
+ withVersion(newPolicyResource(swapped), "2"))
+
+ assertEnforce(t, e, false, "alice", "data1", "read")
+ assertEnforce(t, e, true, "alice", "data1", "write")
+
+ w.handleEvent(eventDelete, withVersion(newPolicyResource(roles), "1"),
nil)
+ assertEnforce(t, e, false, "alice", "data2", "write")
+ assertEnforce(t, e, true, "alice", "data1", "write")
+}
+
+// TestConcurrentEventsWithSyncedEnforcer tests that concurrent events stay
safe
+// when the callback drives a SyncedEnforcer. Run with -race.
+func TestConcurrentEventsWithSyncedEnforcer(t *testing.T) {
+ m, err := model.NewModelFromString(testModel)
+ if err != nil {
+ t.Fatalf("Failed to build model: %v", err)
+ }
+
+ e, err := casbin.NewSyncedEnforcer(m)
+ if err != nil {
+ t.Fatalf("Failed to create enforcer: %v", err)
+ }
+
+ w, _ := newTestWatcher(t, WatcherOptions{IncrementalUpdate: true})
+ if err := w.SetUpdateCallback(DefaultUpdateCallback(e)); err != nil {
+ t.Fatalf("Failed to set callback: %v", err)
+ }
+
+ const users = 20
+
+ var wg sync.WaitGroup
+ for i := 0; i < users; i++ {
+ wg.Add(1)
+
+ go func(i int) {
+ defer wg.Done()
+
+ obj := newPolicyResource(map[string]interface{}{
+ "policy": "p, user" + string(rune('a'+i%26)) +
", data1, read",
+ })
+
+ w.handleEvent(eventAdd, nil, withVersion(obj, "1"))
+ _, _ = e.Enforce("alice", "data1", "read")
+ }(i)
+ }
+
+ wg.Wait()
+
+ assertEnforce(t, e, true, "usera", "data1", "read")
+}
+
+func assertEnforce(t *testing.T, e casbin.IEnforcer, want bool, request
...interface{}) {
+ t.Helper()
+
+ got, err := e.Enforce(request...)
+ if err != nil {
+ t.Fatalf("Enforce%v failed: %v", request, err)
+ }
+
+ if got != want {
+ t.Errorf("Enforce%v: expected %v, got %v", request, want, got)
+ }
+}
diff --git a/options.go b/options.go
index 0bc3637..2ed32bf 100644
--- a/options.go
+++ b/options.go
@@ -33,6 +33,13 @@ type WatcherOptions struct {
// Default is 30 seconds.
ResyncPeriod time.Duration
+ // IncrementalUpdate makes the watcher report the exact rules that
changed
+ // instead of asking the enforcer to reload its whole policy. Leave it
off
+ // when the enforcer's adapter is backed by the same resources the
watcher
+ // observes: Casbin applies these messages through its Self* APIs, which
+ // write through to the adapter when AutoSave is enabled.
+ IncrementalUpdate bool
+
// OptionalUpdateCallback is an optional callback function that can be
set during initialization.
OptionalUpdateCallback func(string)
}
diff --git a/policy.go b/policy.go
new file mode 100644
index 0000000..6826b6b
--- /dev/null
+++ b/policy.go
@@ -0,0 +1,196 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package informerwatcher
+
+import (
+ "encoding/csv"
+ "errors"
+ "fmt"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// fieldSeparator joins rule fields into a comparable key. It cannot appear in
a
+// policy line, so two rules share a key only if they are the same rule.
+const fieldSeparator = "\x00"
+
+// rule is a single Casbin policy rule carried by a policy resource.
+type rule struct {
+ sec string
+ ptype string
+ fields []string
+}
+
+func (r rule) key() string {
+ return strings.Join(append([]string{r.ptype}, r.fields...),
fieldSeparator)
+}
+
+// extractRules reads the rules held by spec.policy and spec.policies. An error
+// means the resource does not follow that layout, and the caller falls back to
+// a full policy reload.
+func extractRules(obj *unstructured.Unstructured) ([]rule, error) {
+ if obj == nil {
+ return nil, errors.New("policy resource is nil")
+ }
+
+ spec, ok := obj.Object["spec"].(map[string]interface{})
+ if !ok {
+ return nil, errors.New("policy resource has no spec object")
+ }
+
+ lines, err := policyLines(spec)
+ if err != nil {
+ return nil, err
+ }
+
+ rules := make([]rule, 0, len(lines))
+ for _, line := range lines {
+ r, ok, err := parsePolicyLine(line)
+ if err != nil {
+ return nil, err
+ }
+ if !ok {
+ continue
+ }
+
+ rules = append(rules, r)
+ }
+
+ return rules, nil
+}
+
+// policyLines collects the raw policy lines held by spec.policy and
+// spec.policies. At least one of the two must be present.
+func policyLines(spec map[string]interface{}) ([]string, error) {
+ var lines []string
+ found := false
+
+ if raw, ok := spec["policy"]; ok {
+ text, ok := raw.(string)
+ if !ok {
+ return nil, fmt.Errorf("spec.policy must be a string,
got %T", raw)
+ }
+
+ lines = append(lines, strings.Split(text, "\n")...)
+ found = true
+ }
+
+ if raw, ok := spec["policies"]; ok {
+ items, ok := raw.([]interface{})
+ if !ok {
+ return nil, fmt.Errorf("spec.policies must be a list,
got %T", raw)
+ }
+
+ for _, item := range items {
+ text, ok := item.(string)
+ if !ok {
+ return nil, fmt.Errorf("spec.policies must hold
strings, got %T", item)
+ }
+
+ lines = append(lines, text)
+ }
+
+ found = true
+ }
+
+ if !found {
+ return nil, errors.New("policy resource has neither spec.policy
nor spec.policies")
+ }
+
+ return lines, nil
+}
+
+// parsePolicyLine parses one line such as "p, alice, data1, read". It mirrors
+// persist.LoadPolicyLine so a resource and a policy CSV file are read the same
+// way. A blank line or a comment yields ok == false and no error.
+func parsePolicyLine(line string) (rule, bool, error) {
+ line = strings.TrimSpace(line)
+ if line == "" || strings.HasPrefix(line, "#") {
+ return rule{}, false, nil
+ }
+
+ reader := csv.NewReader(strings.NewReader(line))
+ reader.Comma = ','
+ reader.Comment = '#'
+ reader.TrimLeadingSpace = true
+
+ tokens, err := reader.Read()
+ if err != nil {
+ return rule{}, false, fmt.Errorf("parse policy line %q: %w",
line, err)
+ }
+
+ for i := range tokens {
+ tokens[i] = strings.TrimSpace(tokens[i])
+ }
+
+ ptype := tokens[0]
+ if ptype == "" || len(tokens) < 2 {
+ return rule{}, false, fmt.Errorf("policy line %q carries no
rule fields", line)
+ }
+
+ // Casbin takes the section from the first character of the policy
type, so
+ // "p2" belongs to section "p" and "g2" to section "g".
+ return rule{sec: ptype[:1], ptype: ptype, fields: tokens[1:]}, true, nil
+}
+
+// groupRules collects rules by section and policy type, preserving the order
in
+// which each group first appears.
+func groupRules(rules []rule) [][]rule {
+ groups := make([][]rule, 0, len(rules))
+ index := make(map[string]int, len(rules))
+
+ for _, r := range rules {
+ key := r.sec + fieldSeparator + r.ptype
+
+ if i, ok := index[key]; ok {
+ groups[i] = append(groups[i], r)
+ continue
+ }
+
+ index[key] = len(groups)
+ groups = append(groups, []rule{r})
+ }
+
+ return groups
+}
+
+// diffRules returns the rules present in want but missing from have.
+func diffRules(have, want []rule) []rule {
+ seen := make(map[string]struct{}, len(have))
+ for _, r := range have {
+ seen[r.key()] = struct{}{}
+ }
+
+ var missing []rule
+ for _, r := range want {
+ if _, ok := seen[r.key()]; ok {
+ continue
+ }
+
+ missing = append(missing, r)
+ }
+
+ return missing
+}
+
+func ruleFields(rules []rule) [][]string {
+ fields := make([][]string, 0, len(rules))
+ for _, r := range rules {
+ fields = append(fields, r.fields)
+ }
+
+ return fields
+}
diff --git a/policy_test.go b/policy_test.go
new file mode 100644
index 0000000..aae1d0b
--- /dev/null
+++ b/policy_test.go
@@ -0,0 +1,269 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package informerwatcher
+
+import (
+ "reflect"
+ "testing"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+// newPolicyResource builds a policy resource carrying the given spec.
+func newPolicyResource(spec map[string]interface{}) *unstructured.Unstructured
{
+ return &unstructured.Unstructured{
+ Object: map[string]interface{}{
+ "apiVersion": "casbin.org/v1",
+ "kind": "Policy",
+ "metadata": map[string]interface{}{
+ "name": "test-policy",
+ "namespace": "default",
+ },
+ "spec": spec,
+ },
+ }
+}
+
+// TestParsePolicyLine tests parsing of individual Casbin policy lines.
+func TestParsePolicyLine(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ want rule
+ wantOK bool
+ wantErr bool
+ }{
+ {
+ name: "policy rule",
+ line: "p, alice, data1, read",
+ want: rule{sec: "p", ptype: "p", fields:
[]string{"alice", "data1", "read"}},
+ wantOK: true,
+ },
+ {
+ name: "grouping rule",
+ line: "g, alice, admin",
+ want: rule{sec: "g", ptype: "g", fields:
[]string{"alice", "admin"}},
+ wantOK: true,
+ },
+ {
+ // The section is the first character of the policy
type.
+ name: "numbered grouping rule",
+ line: "g2, alice, admin, domain1",
+ want: rule{sec: "g", ptype: "g2", fields:
[]string{"alice", "admin", "domain1"}},
+ wantOK: true,
+ },
+ {
+ name: "numbered policy rule",
+ line: "p2, alice, data1, read",
+ want: rule{sec: "p", ptype: "p2", fields:
[]string{"alice", "data1", "read"}},
+ wantOK: true,
+ },
+ {
+ name: "quoted field holding a comma",
+ line: `p, alice, "data1,data2", read`,
+ want: rule{sec: "p", ptype: "p", fields:
[]string{"alice", "data1,data2", "read"}},
+ wantOK: true,
+ },
+ {
+ name: "untrimmed line",
+ line: " p , alice , data1 , read ",
+ want: rule{sec: "p", ptype: "p", fields:
[]string{"alice", "data1", "read"}},
+ wantOK: true,
+ },
+ {
+ name: "blank line",
+ line: " ",
+ wantOK: false,
+ },
+ {
+ name: "comment",
+ line: "# p, alice, data1, read",
+ wantOK: false,
+ },
+ {
+ name: "policy type without fields",
+ line: "p",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok, err := parsePolicyLine(tt.line)
+
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("Expected an error for %q",
tt.line)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("Unexpected error for %q: %v",
tt.line, err)
+ }
+
+ if ok != tt.wantOK {
+ t.Fatalf("Expected ok %v, got %v", tt.wantOK,
ok)
+ }
+
+ if ok && !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Expected rule %+v, got %+v", tt.want,
got)
+ }
+ })
+ }
+}
+
+// TestExtractRules tests reading rules out of a policy resource.
+func TestExtractRules(t *testing.T) {
+ tests := []struct {
+ name string
+ spec map[string]interface{}
+ want []rule
+ wantErr bool
+ }{
+ {
+ name: "single policy",
+ spec: map[string]interface{}{"policy": "p, alice,
data1, read"},
+ want: []rule{{sec: "p", ptype: "p", fields:
[]string{"alice", "data1", "read"}}},
+ },
+ {
+ name: "multi-line policy",
+ spec: map[string]interface{}{"policy": "p, alice,
data1, read\ng, alice, admin\n"},
+ want: []rule{
+ {sec: "p", ptype: "p", fields:
[]string{"alice", "data1", "read"}},
+ {sec: "g", ptype: "g", fields:
[]string{"alice", "admin"}},
+ },
+ },
+ {
+ name: "policy list",
+ spec: map[string]interface{}{
+ "policies": []interface{}{"p, alice, data1,
read", "p, bob, data2, write"},
+ },
+ want: []rule{
+ {sec: "p", ptype: "p", fields:
[]string{"alice", "data1", "read"}},
+ {sec: "p", ptype: "p", fields: []string{"bob",
"data2", "write"}},
+ },
+ },
+ {
+ name: "empty policy list",
+ spec: map[string]interface{}{"policies":
[]interface{}{}},
+ want: []rule{},
+ },
+ {
+ name: "unknown layout",
+ spec: map[string]interface{}{"unexpected": "field"},
+ wantErr: true,
+ },
+ {
+ name: "policy of the wrong type",
+ spec: map[string]interface{}{"policy": 42},
+ wantErr: true,
+ },
+ {
+ name: "policy list holding a non-string",
+ spec: map[string]interface{}{"policies":
[]interface{}{42}},
+ wantErr: true,
+ },
+ {
+ name: "malformed policy line",
+ spec: map[string]interface{}{"policy": "p"},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := extractRules(newPolicyResource(tt.spec))
+
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("Expected an error, got rules
%+v", got)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("Unexpected error: %v", err)
+ }
+
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Expected rules %+v, got %+v",
tt.want, got)
+ }
+ })
+ }
+}
+
+// TestExtractRulesWithoutSpec tests a resource that carries no spec at all.
+func TestExtractRulesWithoutSpec(t *testing.T) {
+ obj := &unstructured.Unstructured{Object:
map[string]interface{}{"kind": "Policy"}}
+
+ if _, err := extractRules(obj); err == nil {
+ t.Error("Expected an error for a resource without a spec")
+ }
+
+ if _, err := extractRules(nil); err == nil {
+ t.Error("Expected an error for a nil resource")
+ }
+}
+
+// TestGroupRules tests that rules are grouped by section and policy type.
+func TestGroupRules(t *testing.T) {
+ rules := []rule{
+ {sec: "p", ptype: "p", fields: []string{"alice", "data1",
"read"}},
+ {sec: "g", ptype: "g", fields: []string{"alice", "admin"}},
+ {sec: "p", ptype: "p", fields: []string{"bob", "data2",
"write"}},
+ {sec: "g", ptype: "g2", fields: []string{"admin", "root"}},
+ }
+
+ groups := groupRules(rules)
+
+ if len(groups) != 3 {
+ t.Fatalf("Expected 3 groups, got %d", len(groups))
+ }
+
+ if len(groups[0]) != 2 || groups[0][0].ptype != "p" {
+ t.Errorf("Expected the two p rules in the first group, got
%+v", groups[0])
+ }
+
+ if len(groups[1]) != 1 || groups[1][0].ptype != "g" {
+ t.Errorf("Expected the g rule in the second group, got %+v",
groups[1])
+ }
+
+ if len(groups[2]) != 1 || groups[2][0].ptype != "g2" {
+ t.Errorf("Expected the g2 rule in the third group, got %+v",
groups[2])
+ }
+}
+
+// TestDiffRules tests the rule diff used to translate update events.
+func TestDiffRules(t *testing.T) {
+ alice := rule{sec: "p", ptype: "p", fields: []string{"alice", "data1",
"read"}}
+ bob := rule{sec: "p", ptype: "p", fields: []string{"bob", "data2",
"write"}}
+ // Same fields as alice but a different policy type.
+ alice2 := rule{sec: "p", ptype: "p2", fields: []string{"alice",
"data1", "read"}}
+
+ missing := diffRules([]rule{alice}, []rule{alice, bob, alice2})
+
+ if len(missing) != 2 {
+ t.Fatalf("Expected 2 missing rules, got %d: %+v", len(missing),
missing)
+ }
+
+ if !reflect.DeepEqual(missing[0], bob) ||
!reflect.DeepEqual(missing[1], alice2) {
+ t.Errorf("Unexpected missing rules: %+v", missing)
+ }
+
+ if got := diffRules([]rule{alice, bob}, []rule{alice, bob}); len(got)
!= 0 {
+ t.Errorf("Expected no missing rules, got %+v", got)
+ }
+}
diff --git a/watcher.go b/watcher.go
index 0f94eb4..0984f37 100644
--- a/watcher.go
+++ b/watcher.go
@@ -18,6 +18,7 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"log"
"runtime"
"sync"
@@ -42,8 +43,24 @@ type Watcher struct {
stopCh chan struct{}
ctx context.Context
cancel context.CancelFunc
+
+ // callbackMu serializes callback invocations so the enforcer sees
changes
+ // in the order the informer reported them.
+ callbackMu sync.Mutex
}
+// sourceIDAnnotation marks the watcher instance that wrote a policy resource.
+const sourceIDAnnotation = "casbin.org/source-id"
+
+// eventKind identifies the informer event being translated.
+type eventKind int
+
+const (
+ eventAdd eventKind = iota
+ eventUpdate
+ eventDelete
+)
+
// UpdateType represents the type of policy update.
type UpdateType string
@@ -97,7 +114,7 @@ func DefaultUpdateCallback(e casbin.IEnforcer) func(string) {
switch msgStruct.Method {
case Update, UpdateForSavePolicy:
err = e.LoadPolicy()
- res = true
+ res = err == nil
case UpdateForAddPolicy:
res, err = e.SelfAddPolicy(msgStruct.Sec,
msgStruct.Ptype, msgStruct.NewRule)
case UpdateForAddPolicies:
@@ -118,9 +135,10 @@ func DefaultUpdateCallback(e casbin.IEnforcer)
func(string) {
if err != nil {
log.Println("Error updating policy:", err)
+ return
}
if !res {
- log.Println("Callback update policy failed")
+ log.Println("Callback update policy had no effect")
}
}
}
@@ -132,16 +150,21 @@ func finalizer(w *Watcher) {
// NewWatcher creates a new Watcher instance.
func NewWatcher(client dynamic.Interface, gvr schema.GroupVersionResource,
namespace string, options WatcherOptions) (persist.Watcher, error) {
+ if client == nil {
+ return nil, errors.New("dynamic client cannot be nil")
+ }
+
initConfig(&options)
ctx, cancel := context.WithCancel(context.Background())
w := &Watcher{
- running: true,
- localID: options.LocalID,
- options: options,
- stopCh: make(chan struct{}),
- ctx: ctx,
- cancel: cancel,
+ running: true,
+ localID: options.LocalID,
+ options: options,
+ callback: options.OptionalUpdateCallback,
+ stopCh: make(chan struct{}),
+ ctx: ctx,
+ cancel: cancel,
}
// Create dynamic informer factory
@@ -158,13 +181,21 @@ func NewWatcher(client dynamic.Interface, gvr
schema.GroupVersionResource, names
// Add event handlers
_, err := w.informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
- w.handleEvent(obj, "add")
+ // Resources that already exist are replayed as adds
while the
+ // cache is built; they are state the enforcer already
loaded, not
+ // a change. HasSynced turns true just before the last
replay, so a
+ // stray one may slip through - cheaper than gating any
later and
+ // dropping a resource created moments after startup.
+ if !w.informer.HasSynced() {
+ return
+ }
+ w.handleEvent(eventAdd, nil, obj)
},
UpdateFunc: func(oldObj, newObj interface{}) {
- w.handleEvent(newObj, "update")
+ w.handleEvent(eventUpdate, oldObj, newObj)
},
DeleteFunc: func(obj interface{}) {
- w.handleEvent(obj, "delete")
+ w.handleEvent(eventDelete, obj, nil)
},
})
if err != nil {
@@ -187,45 +218,171 @@ func NewWatcher(client dynamic.Interface, gvr
schema.GroupVersionResource, names
return w, nil
}
-// handleEvent processes CRD events and triggers the callback.
-func (w *Watcher) handleEvent(obj interface{}, eventType string) {
+// handleEvent processes CRD events and triggers the callback. oldObj holds the
+// resource before the event and newObj after it, so an add has no oldObj and a
+// delete has no newObj.
+func (w *Watcher) handleEvent(kind eventKind, oldObj, newObj interface{}) {
w.lock.RLock()
+ running := w.running
callback := w.callback
w.lock.RUnlock()
- if callback == nil {
+ if !running || callback == nil {
return
}
- unstructuredObj, ok := obj.(*unstructured.Unstructured)
- if !ok {
- log.Println("Failed to convert object to unstructured")
+ old, err := asPolicyResource(oldObj)
+ if err != nil {
+ log.Println("Skipping event:", err)
return
}
- // Extract policy update information from the CRD
- msg := &MSG{
- Method: Update,
- ID: w.localID,
+ current, err := asPolicyResource(newObj)
+ if err != nil {
+ log.Println("Skipping event:", err)
+ return
+ }
+
+ // A delete carries only the previous state of the resource.
+ subject := current
+ if subject == nil {
+ subject = old
+ }
+ if subject == nil {
+ return
}
// Check if this is our own update (if IgnoreSelf is enabled)
- if w.options.IgnoreSelf {
- annotations := unstructuredObj.GetAnnotations()
- if annotations != nil {
- if sourceID, ok := annotations["casbin.org/source-id"];
ok && sourceID == w.localID {
- return
- }
+ if w.options.IgnoreSelf && subject.GetAnnotations()[sourceIDAnnotation]
== w.localID {
+ return
+ }
+
+ // A resync re-delivers every cached resource as an update, and
Kubernetes
+ // only bumps resourceVersion on a real write.
+ if kind == eventUpdate && old != nil && current != nil &&
+ old.GetResourceVersion() == current.GetResourceVersion() {
+ return
+ }
+
+ for _, msg := range w.buildMessages(kind, old, current) {
+ w.notify(callback, msg)
+ }
+}
+
+// asPolicyResource converts an object delivered by the informer, unwrapping
the
+// tombstone Kubernetes sends when a deletion was missed while the watch was
+// down. A nil object stays nil: that side of the event does not exist.
+func asPolicyResource(obj interface{}) (*unstructured.Unstructured, error) {
+ if obj == nil {
+ return nil, nil
+ }
+
+ if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok {
+ obj = tombstone.Obj
+ }
+
+ resource, ok := obj.(*unstructured.Unstructured)
+ if !ok {
+ return nil, fmt.Errorf("unexpected object type %T in informer
event", obj)
+ }
+
+ return resource, nil
+}
+
+// buildMessages translates an observed change into the messages that describe
+// it: a full reload unless IncrementalUpdate is enabled, otherwise the rules
+// that actually changed.
+func (w *Watcher) buildMessages(kind eventKind, oldObj, newObj
*unstructured.Unstructured) []*MSG {
+ if !w.options.IncrementalUpdate {
+ return []*MSG{w.newMessage(Update)}
+ }
+
+ switch kind {
+ case eventAdd:
+ rules, err := extractRules(newObj)
+ if err != nil {
+ return w.reload(err)
}
+
+ return w.ruleMessages(rules, UpdateForAddPolicy,
UpdateForAddPolicies)
+
+ case eventDelete:
+ rules, err := extractRules(oldObj)
+ if err != nil {
+ return w.reload(err)
+ }
+
+ return w.ruleMessages(rules, UpdateForRemovePolicy,
UpdateForRemovePolicies)
+
+ case eventUpdate:
+ oldRules, err := extractRules(oldObj)
+ if err != nil {
+ return w.reload(err)
+ }
+
+ newRules, err := extractRules(newObj)
+ if err != nil {
+ return w.reload(err)
+ }
+
+ // Diffed rather than paired up: SelfUpdatePolicies needs both
sides to
+ // hold the same number of rules, which a resource that gained
or lost
+ // a line does not.
+ removed := w.ruleMessages(diffRules(newRules, oldRules),
UpdateForRemovePolicy, UpdateForRemovePolicies)
+ added := w.ruleMessages(diffRules(oldRules, newRules),
UpdateForAddPolicy, UpdateForAddPolicies)
+
+ return append(removed, added...)
+
+ default:
+ return nil
}
+}
+
+// reload reports why the change could not be described incrementally.
+func (w *Watcher) reload(err error) []*MSG {
+ log.Println("Falling back to a full policy reload:", err)
+ return []*MSG{w.newMessage(Update)}
+}
- // Marshal and send message
+// ruleMessages builds one message per section and policy type.
+func (w *Watcher) ruleMessages(rules []rule, single, batch UpdateType) []*MSG {
+ groups := groupRules(rules)
+ msgs := make([]*MSG, 0, len(groups))
+
+ for _, group := range groups {
+ msg := w.newMessage(single)
+ msg.Sec = group[0].sec
+ msg.Ptype = group[0].ptype
+
+ if len(group) == 1 {
+ msg.NewRule = group[0].fields
+ } else {
+ msg.Method = batch
+ msg.NewRules = ruleFields(group)
+ }
+
+ msgs = append(msgs, msg)
+ }
+
+ return msgs
+}
+
+func (w *Watcher) newMessage(method UpdateType) *MSG {
+ return &MSG{Method: method, ID: w.localID}
+}
+
+// notify hands one message to the callback. Calls are serialized so a change
+// made of several messages, such as a rule replacement, is applied in order.
+func (w *Watcher) notify(callback func(string), msg *MSG) {
msgBytes, err := msg.MarshalBinary()
if err != nil {
log.Println("Error marshaling message:", err)
return
}
+ w.callbackMu.Lock()
+ defer w.callbackMu.Unlock()
+
callback(string(msgBytes))
}
diff --git a/watcher_test.go b/watcher_test.go
index 496a110..5715c65 100644
--- a/watcher_test.go
+++ b/watcher_test.go
@@ -263,7 +263,7 @@ func TestHandleEvent(t *testing.T) {
}
// Handle the event
- w.handleEvent(obj, "add")
+ w.handleEvent(eventAdd, nil, obj)
time.Sleep(100 * time.Millisecond)
@@ -318,7 +318,7 @@ func TestIgnoreSelf(t *testing.T) {
},
}
- w.handleEvent(obj, "add")
+ w.handleEvent(eventAdd, nil, obj)
time.Sleep(100 * time.Millisecond)
if callbackCalled {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]