Skip to content

Commit 82133c0

Browse files
committed
feat: add event-source for TenantResource and GlobalTenantResource
1 parent f7c5523 commit 82133c0

25 files changed

Lines changed: 3474 additions & 0 deletions
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright 2020-2026 Project Capsule Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package v1beta2_test
5+
6+
import (
7+
"testing"
8+
9+
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
10+
capruntime "github.com/projectcapsule/capsule/pkg/api/runtime"
11+
)
12+
13+
func trigger(apiGroups []string, kinds []string, ops ...capsulev1beta2.TriggerOperation) capsulev1beta2.TriggerSpec {
14+
return capsulev1beta2.TriggerSpec{
15+
VersionKinds: capruntime.VersionKinds{APIGroups: apiGroups, Kinds: kinds},
16+
Operations: ops,
17+
}
18+
}
19+
20+
func TestTriggerSpec_MatchesOperation(t *testing.T) {
21+
tests := []struct {
22+
name string
23+
spec capsulev1beta2.TriggerSpec
24+
op capsulev1beta2.TriggerOperation
25+
want bool
26+
}{
27+
{
28+
name: "empty operations matches every op",
29+
spec: trigger(nil, []string{"Secret"}),
30+
op: capsulev1beta2.TriggerOperationDelete,
31+
want: true,
32+
},
33+
{
34+
name: "listed operation matches",
35+
spec: trigger(nil, []string{"Secret"}, capsulev1beta2.TriggerOperationCreate, capsulev1beta2.TriggerOperationUpdate),
36+
op: capsulev1beta2.TriggerOperationUpdate,
37+
want: true,
38+
},
39+
{
40+
name: "unlisted operation does not match",
41+
spec: trigger(nil, []string{"Secret"}, capsulev1beta2.TriggerOperationCreate),
42+
op: capsulev1beta2.TriggerOperationDelete,
43+
want: false,
44+
},
45+
}
46+
47+
for _, tc := range tests {
48+
t.Run(tc.name, func(t *testing.T) {
49+
if got := tc.spec.MatchesOperation(tc.op); got != tc.want {
50+
t.Fatalf("MatchesOperation(%q) = %v, want %v", tc.op, got, tc.want)
51+
}
52+
})
53+
}
54+
}
55+
56+
func TestTriggerVersionKinds(t *testing.T) {
57+
spec := capsulev1beta2.TenantResourceCommonSpec{
58+
Triggers: []capsulev1beta2.TriggerSpec{
59+
// Empty apiGroups means core v1.
60+
trigger(nil, []string{"Secret"}),
61+
// Duplicate selector must be de-duplicated.
62+
trigger([]string{"v1"}, []string{"Secret"}, capsulev1beta2.TriggerOperationUpdate),
63+
// Concrete group/version.
64+
trigger([]string{"apps/v1"}, []string{"Deployment"}),
65+
// Bare group expands to a version-less selector.
66+
trigger([]string{"batch"}, []string{"Job", "CronJob"}),
67+
// Empty kind must be dropped.
68+
trigger([]string{"v1"}, []string{""}),
69+
},
70+
}
71+
72+
got := spec.TriggerVersionKinds()
73+
74+
want := map[capruntime.VersionKind]struct{}{
75+
{APIVersion: "", Kind: "Secret"}: {},
76+
{APIVersion: "apps/v1", Kind: "Deployment"}: {},
77+
{APIVersion: "batch/*", Kind: "Job"}: {},
78+
{APIVersion: "batch/*", Kind: "CronJob"}: {},
79+
}
80+
81+
if len(got) != len(want) {
82+
t.Fatalf("expected %d selectors, got %d: %v", len(want), len(got), got)
83+
}
84+
85+
for _, vk := range got {
86+
if _, ok := want[vk]; !ok {
87+
t.Fatalf("unexpected selector %+v", vk)
88+
}
89+
}
90+
}

api/v1beta2/tenantresource_types.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44
package v1beta2
55

66
import (
7+
"slices"
8+
79
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
"k8s.io/apimachinery/pkg/labels"
811
"k8s.io/apimachinery/pkg/runtime"
12+
"k8s.io/apimachinery/pkg/runtime/schema"
913

1014
"github.com/projectcapsule/capsule/pkg/api"
1115
"github.com/projectcapsule/capsule/pkg/api/meta"
16+
capruntime "github.com/projectcapsule/capsule/pkg/api/runtime"
17+
"github.com/projectcapsule/capsule/pkg/runtime/selectors"
1218
tpl "github.com/projectcapsule/capsule/pkg/template"
1319
)
1420

@@ -58,6 +64,110 @@ type TenantResourceCommonSpec struct {
5864
Cordoned *bool `json:"cordoned,omitempty"`
5965
// Defines the rules to select targeting Namespace, along with the objects that must be replicated.
6066
Resources []ResourceSpec `json:"resources"`
67+
// Triggers re-render this resource (near-)immediately when matching cluster
68+
// objects change, instead of only waiting for resyncPeriod. This lets you keep
69+
// a high resyncPeriod while still reacting quickly to changes of the objects
70+
// the rendering depends on (e.g. Secrets or ServiceAccounts referenced through
71+
// a resource's context). Each trigger installs a metadata-only watch per
72+
// referenced kind; a watch is torn down automatically once no resource
73+
// references its kind anymore.
74+
// +optional
75+
// +kubebuilder:validation:MaxItems=100
76+
Triggers []TriggerSpec `json:"triggers,omitempty"`
77+
}
78+
79+
// TriggerOperation is the object lifecycle event a trigger reacts to.
80+
// +kubebuilder:validation:Enum=CREATE;UPDATE;DELETE
81+
type TriggerOperation string
82+
83+
const (
84+
// TriggerOperationCreate reacts to creations of matching objects.
85+
TriggerOperationCreate TriggerOperation = "CREATE"
86+
// TriggerOperationUpdate reacts to updates of matching objects.
87+
TriggerOperationUpdate TriggerOperation = "UPDATE"
88+
// TriggerOperationDelete reacts to deletions of matching objects.
89+
TriggerOperationDelete TriggerOperation = "DELETE"
90+
)
91+
92+
// TriggerSpec declares the cluster object kinds whose changes cause the owning
93+
// TenantResource / GlobalTenantResource to be re-rendered.
94+
//
95+
// Wildcards are rejected: every kind selected by a trigger is armed as a
96+
// dedicated watch, so the selection must be a bounded, concrete set.
97+
//
98+
// +kubebuilder:validation:XValidation:rule="!self.kinds.exists(k, k.contains('*'))",message="wildcard kinds are not supported in triggers"
99+
// +kubebuilder:validation:XValidation:rule="!has(self.apiGroups) || !self.apiGroups.exists(g, g.contains('*'))",message="wildcard apiGroups are not supported in triggers"
100+
type TriggerSpec struct {
101+
capruntime.VersionKinds `json:",inline"`
102+
103+
// Operations that cause a re-render. When empty, all operations
104+
// (CREATE, UPDATE and DELETE) are considered.
105+
// +optional
106+
Operations []TriggerOperation `json:"operations,omitempty"`
107+
// Selector narrows the trigger to objects whose labels match. When omitted,
108+
// every object of the referenced kind matches.
109+
// +optional
110+
Selector *metav1.LabelSelector `json:"selector,omitempty"`
111+
// NamespaceSelector narrows the trigger to objects living in namespaces whose
112+
// labels match. It is only honored for the cluster-scoped GlobalTenantResource;
113+
// for the namespaced TenantResource it is ignored, as the trigger is always
114+
// scoped to the namespaces of the owning Tenant.
115+
// +optional
116+
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
117+
}
118+
119+
// MatchesOperation reports whether the trigger reacts to the given operation.
120+
// An empty operation list matches every operation.
121+
func (t TriggerSpec) MatchesOperation(op TriggerOperation) bool {
122+
if len(t.Operations) == 0 {
123+
return true
124+
}
125+
126+
return slices.Contains(t.Operations, op)
127+
}
128+
129+
// Matches reports whether the trigger reacts to a change of the given kind and
130+
// operation whose object carries the given labels. Namespace scoping
131+
// (NamespaceSelector, tenant scoping) is consumer policy and not evaluated here.
132+
func (t TriggerSpec) Matches(gvk schema.GroupVersionKind, op TriggerOperation, lbls map[string]string) bool {
133+
if !t.MatchesGroupVersionKind(gvk) || !t.MatchesOperation(op) {
134+
return false
135+
}
136+
137+
if t.Selector == nil {
138+
return true
139+
}
140+
141+
ok, err := selectors.MatchesSelector(labels.Set(lbls), *t.Selector)
142+
143+
return err == nil && ok
144+
}
145+
146+
// TriggerVersionKinds returns the de-duplicated set of kind selectors
147+
// referenced by the resource's triggers. Selectors without a concrete version
148+
// (e.g. apiGroups: ["apps"]) are resolved to a watchable GroupVersionKind by
149+
// the trigger watch manager via the REST mapper.
150+
func (s *TenantResourceCommonSpec) TriggerVersionKinds() []capruntime.VersionKind {
151+
seen := make(map[capruntime.VersionKind]struct{}, len(s.Triggers))
152+
out := make([]capruntime.VersionKind, 0, len(s.Triggers))
153+
154+
for _, t := range s.Triggers {
155+
for _, vk := range t.VersionKinds.VersionKinds() {
156+
if vk.Kind == "" {
157+
continue
158+
}
159+
160+
if _, ok := seen[vk]; ok {
161+
continue
162+
}
163+
164+
seen[vk] = struct{}{}
165+
166+
out = append(out, vk)
167+
}
168+
}
169+
170+
return out
61171
}
62172

63173
type TenantResourceCommonSpecSettings struct {

api/v1beta2/zz_generated.deepcopy.go

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)