Skip to content

Commit 5344dcc

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

19 files changed

Lines changed: 2533 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: 109 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,109 @@ 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+
Triggers []TriggerSpec `json:"triggers,omitempty"`
76+
}
77+
78+
// TriggerOperation is the object lifecycle event a trigger reacts to.
79+
// +kubebuilder:validation:Enum=CREATE;UPDATE;DELETE
80+
type TriggerOperation string
81+
82+
const (
83+
// TriggerOperationCreate reacts to creations of matching objects.
84+
TriggerOperationCreate TriggerOperation = "CREATE"
85+
// TriggerOperationUpdate reacts to updates of matching objects.
86+
TriggerOperationUpdate TriggerOperation = "UPDATE"
87+
// TriggerOperationDelete reacts to deletions of matching objects.
88+
TriggerOperationDelete TriggerOperation = "DELETE"
89+
)
90+
91+
// TriggerSpec declares the cluster object kinds whose changes cause the owning
92+
// TenantResource / GlobalTenantResource to be re-rendered.
93+
//
94+
// Wildcards are rejected: every kind selected by a trigger is armed as a
95+
// dedicated watch, so the selection must be a bounded, concrete set.
96+
//
97+
// +kubebuilder:validation:XValidation:rule="!self.kinds.exists(k, k.contains('*'))",message="wildcard kinds are not supported in triggers"
98+
// +kubebuilder:validation:XValidation:rule="!has(self.apiGroups) || !self.apiGroups.exists(g, g.contains('*'))",message="wildcard apiGroups are not supported in triggers"
99+
type TriggerSpec struct {
100+
capruntime.VersionKinds `json:",inline"`
101+
102+
// Operations that cause a re-render. When empty, all operations
103+
// (CREATE, UPDATE and DELETE) are considered.
104+
// +optional
105+
Operations []TriggerOperation `json:"operations,omitempty"`
106+
// Selector narrows the trigger to objects whose labels match. When omitted,
107+
// every object of the referenced kind matches.
108+
// +optional
109+
Selector *metav1.LabelSelector `json:"selector,omitempty"`
110+
// NamespaceSelector narrows the trigger to objects living in namespaces whose
111+
// labels match. It is only honored for the cluster-scoped GlobalTenantResource;
112+
// for the namespaced TenantResource it is ignored, as the trigger is always
113+
// scoped to the namespaces of the owning Tenant.
114+
// +optional
115+
NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
116+
}
117+
118+
// MatchesOperation reports whether the trigger reacts to the given operation.
119+
// An empty operation list matches every operation.
120+
func (t TriggerSpec) MatchesOperation(op TriggerOperation) bool {
121+
if len(t.Operations) == 0 {
122+
return true
123+
}
124+
125+
return slices.Contains(t.Operations, op)
126+
}
127+
128+
// Matches reports whether the trigger reacts to a change of the given kind and
129+
// operation whose object carries the given labels. Namespace scoping
130+
// (NamespaceSelector, tenant scoping) is consumer policy and not evaluated here.
131+
func (t TriggerSpec) Matches(gvk schema.GroupVersionKind, op TriggerOperation, lbls map[string]string) bool {
132+
if !t.MatchesGroupVersionKind(gvk) || !t.MatchesOperation(op) {
133+
return false
134+
}
135+
136+
if t.Selector == nil {
137+
return true
138+
}
139+
140+
ok, err := selectors.MatchesSelector(labels.Set(lbls), *t.Selector)
141+
142+
return err == nil && ok
143+
}
144+
145+
// TriggerVersionKinds returns the de-duplicated set of kind selectors
146+
// referenced by the resource's triggers. Selectors without a concrete version
147+
// (e.g. apiGroups: ["apps"]) are resolved to a watchable GroupVersionKind by
148+
// the trigger watch manager via the REST mapper.
149+
func (s *TenantResourceCommonSpec) TriggerVersionKinds() []capruntime.VersionKind {
150+
seen := make(map[capruntime.VersionKind]struct{}, len(s.Triggers))
151+
out := make([]capruntime.VersionKind, 0, len(s.Triggers))
152+
153+
for _, t := range s.Triggers {
154+
for _, vk := range t.VersionKinds.VersionKinds() {
155+
if vk.Kind == "" {
156+
continue
157+
}
158+
159+
if _, ok := seen[vk]; ok {
160+
continue
161+
}
162+
163+
seen[vk] = struct{}{}
164+
165+
out = append(out, vk)
166+
}
167+
}
168+
169+
return out
61170
}
62171

63172
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)