Visitar URL original
feat: Add ServiceMonitor auto-generation for Prometheus discovery · feast-dev/feast@ab3e933 · GitHub
Skip to content

Commit ab3e933

Browse files
committed
feat: Add ServiceMonitor auto-generation for Prometheus discovery
Signed-off-by: ntkathole <nikhilkathole2683@gmail.com>
1 parent 18ede88 commit ab3e933

8 files changed

Lines changed: 307 additions & 3 deletions

File tree

‎.secrets.baseline‎

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

‎infra/feast-operator/config/rbac/role.yaml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ rules:
101101
- get
102102
- patch
103103
- update
104+
- apiGroups:
105+
- monitoring.coreos.com
106+
resources:
107+
- servicemonitors
108+
verbs:
109+
- create
110+
- delete
111+
- get
112+
- list
113+
- update
114+
- watch
104115
- apiGroups:
105116
- policy
106117
resources:

‎infra/feast-operator/internal/controller/featurestore_controller.go‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ import (
3030
apierrors "k8s.io/apimachinery/pkg/api/errors"
3131
apimeta "k8s.io/apimachinery/pkg/api/meta"
3232
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
33+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
3334
"k8s.io/apimachinery/pkg/runtime"
35+
"k8s.io/apimachinery/pkg/runtime/schema"
3436
"k8s.io/apimachinery/pkg/types"
3537
ctrl "sigs.k8s.io/controller-runtime"
3638
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -69,6 +71,7 @@ type FeatureStoreReconciler struct {
6971
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
7072
// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete
7173
// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete
74+
// +kubebuilder:rbac:groups=monitoring.coreos.com,resources=servicemonitors,verbs=get;list;watch;create;update;delete
7275

7376
// Reconcile is part of the main kubernetes reconciliation loop which aims to
7477
// move the current state of the cluster closer to the desired state.
@@ -244,6 +247,15 @@ func (r *FeatureStoreReconciler) SetupWithManager(mgr ctrl.Manager) error {
244247
if services.IsOpenShift() {
245248
bldr = bldr.Owns(&routev1.Route{})
246249
}
250+
if services.HasServiceMonitorCRD() {
251+
sm := &unstructured.Unstructured{}
252+
sm.SetGroupVersionKind(schema.GroupVersionKind{
253+
Group: "monitoring.coreos.com",
254+
Version: "v1",
255+
Kind: "ServiceMonitor",
256+
})
257+
bldr = bldr.Owns(sm)
258+
}
247259

248260
return bldr.Complete(r)
249261

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
Copyright 2026 Feast Community.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package services
18+
19+
import (
20+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
21+
"k8s.io/apimachinery/pkg/runtime/schema"
22+
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
23+
"sigs.k8s.io/controller-runtime/pkg/log"
24+
)
25+
26+
var serviceMonitorGVK = schema.GroupVersionKind{
27+
Group: "monitoring.coreos.com",
28+
Version: "v1",
29+
Kind: "ServiceMonitor",
30+
}
31+
32+
// createOrDeleteServiceMonitor reconciles the ServiceMonitor for the
33+
// FeatureStore's online store metrics endpoint. When the Prometheus Operator
34+
// CRD is not present in the cluster, this is a no-op. When metrics are enabled
35+
// on the online store, a ServiceMonitor is created; otherwise any existing
36+
// ServiceMonitor is deleted.
37+
func (feast *FeastServices) createOrDeleteServiceMonitor() error {
38+
if !hasServiceMonitorCRD {
39+
return nil
40+
}
41+
42+
if feast.isOnlineStore() && feast.isMetricsEnabled(OnlineFeastType) {
43+
return feast.createServiceMonitor()
44+
}
45+
46+
return feast.deleteServiceMonitor()
47+
}
48+
49+
func (feast *FeastServices) createServiceMonitor() error {
50+
logger := log.FromContext(feast.Handler.Context)
51+
sm := feast.initServiceMonitor()
52+
if op, err := controllerutil.CreateOrUpdate(feast.Handler.Context, feast.Handler.Client, sm, controllerutil.MutateFn(func() error {
53+
return feast.setServiceMonitor(sm)
54+
})); err != nil {
55+
return err
56+
} else if op == controllerutil.OperationResultCreated || op == controllerutil.OperationResultUpdated {
57+
logger.Info("Successfully reconciled", "ServiceMonitor", sm.GetName(), "operation", op)
58+
}
59+
return nil
60+
}
61+
62+
func (feast *FeastServices) deleteServiceMonitor() error {
63+
sm := feast.initServiceMonitor()
64+
return feast.Handler.DeleteOwnedFeastObj(sm)
65+
}
66+
67+
func (feast *FeastServices) initServiceMonitor() *unstructured.Unstructured {
68+
sm := &unstructured.Unstructured{}
69+
sm.SetGroupVersionKind(serviceMonitorGVK)
70+
sm.SetName(feast.GetFeastServiceName(OnlineFeastType))
71+
sm.SetNamespace(feast.Handler.FeatureStore.Namespace)
72+
return sm
73+
}
74+
75+
func (feast *FeastServices) setServiceMonitor(sm *unstructured.Unstructured) error {
76+
cr := feast.Handler.FeatureStore
77+
78+
sm.SetLabels(feast.getFeastTypeLabels(OnlineFeastType))
79+
80+
sm.Object["spec"] = map[string]interface{}{
81+
"endpoints": []interface{}{
82+
map[string]interface{}{
83+
"port": "metrics",
84+
"path": "/metrics",
85+
},
86+
},
87+
"selector": map[string]interface{}{
88+
"matchLabels": map[string]interface{}{
89+
NameLabelKey: cr.Name,
90+
ServiceTypeLabelKey: string(OnlineFeastType),
91+
},
92+
},
93+
}
94+
95+
return controllerutil.SetControllerReference(cr, sm, feast.Handler.Scheme)
96+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
/*
2+
Copyright 2026 Feast Community.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package services
18+
19+
import (
20+
"context"
21+
22+
feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1"
23+
"github.com/feast-dev/feast/infra/feast-operator/internal/controller/handler"
24+
. "github.com/onsi/ginkgo/v2"
25+
. "github.com/onsi/gomega"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/types"
28+
"k8s.io/utils/ptr"
29+
)
30+
31+
var _ = Describe("ServiceMonitor", func() {
32+
var (
33+
featureStore *feastdevv1.FeatureStore
34+
feast *FeastServices
35+
typeNamespacedName types.NamespacedName
36+
ctx context.Context
37+
)
38+
39+
BeforeEach(func() {
40+
ctx = context.Background()
41+
typeNamespacedName = types.NamespacedName{
42+
Name: "sm-test-fs",
43+
Namespace: "default",
44+
}
45+
46+
featureStore = &feastdevv1.FeatureStore{
47+
ObjectMeta: metav1.ObjectMeta{
48+
Name: typeNamespacedName.Name,
49+
Namespace: typeNamespacedName.Namespace,
50+
},
51+
Spec: feastdevv1.FeatureStoreSpec{
52+
FeastProject: "smtestproject",
53+
Services: &feastdevv1.FeatureStoreServices{
54+
OnlineStore: &feastdevv1.OnlineStore{
55+
Server: &feastdevv1.ServerConfigs{
56+
ContainerConfigs: feastdevv1.ContainerConfigs{
57+
DefaultCtrConfigs: feastdevv1.DefaultCtrConfigs{
58+
Image: ptr.To("test-image"),
59+
},
60+
},
61+
},
62+
},
63+
Registry: &feastdevv1.Registry{
64+
Local: &feastdevv1.LocalRegistryConfig{
65+
Server: &feastdevv1.RegistryServerConfigs{
66+
ServerConfigs: feastdevv1.ServerConfigs{
67+
ContainerConfigs: feastdevv1.ContainerConfigs{
68+
DefaultCtrConfigs: feastdevv1.DefaultCtrConfigs{
69+
Image: ptr.To("test-image"),
70+
},
71+
},
72+
},
73+
GRPC: ptr.To(true),
74+
},
75+
},
76+
},
77+
},
78+
},
79+
}
80+
81+
Expect(k8sClient.Create(ctx, featureStore)).To(Succeed())
82+
83+
feast = &FeastServices{
84+
Handler: handler.FeastHandler{
85+
Client: k8sClient,
86+
Context: ctx,
87+
Scheme: k8sClient.Scheme(),
88+
FeatureStore: featureStore,
89+
},
90+
}
91+
92+
Expect(feast.ApplyDefaults()).To(Succeed())
93+
applySpecToStatus(featureStore)
94+
feast.refreshFeatureStore(ctx, typeNamespacedName)
95+
})
96+
97+
AfterEach(func() {
98+
testSetHasServiceMonitorCRD(false)
99+
Expect(k8sClient.Delete(ctx, featureStore)).To(Succeed())
100+
})
101+
102+
Describe("initServiceMonitor", func() {
103+
It("should create an unstructured ServiceMonitor with correct GVK and name", func() {
104+
sm := feast.initServiceMonitor()
105+
Expect(sm).NotTo(BeNil())
106+
Expect(sm.GetKind()).To(Equal("ServiceMonitor"))
107+
Expect(sm.GetAPIVersion()).To(Equal("monitoring.coreos.com/v1"))
108+
Expect(sm.GetName()).To(Equal(feast.GetFeastServiceName(OnlineFeastType)))
109+
Expect(sm.GetNamespace()).To(Equal(featureStore.Namespace))
110+
})
111+
})
112+
113+
Describe("setServiceMonitor", func() {
114+
It("should populate the ServiceMonitor spec with correct selector and endpoint", func() {
115+
sm := feast.initServiceMonitor()
116+
Expect(feast.setServiceMonitor(sm)).To(Succeed())
117+
118+
labels := sm.GetLabels()
119+
Expect(labels).To(HaveKeyWithValue(NameLabelKey, featureStore.Name))
120+
Expect(labels).To(HaveKeyWithValue(ServiceTypeLabelKey, string(OnlineFeastType)))
121+
122+
spec, ok := sm.Object["spec"].(map[string]interface{})
123+
Expect(ok).To(BeTrue())
124+
125+
endpoints, ok := spec["endpoints"].([]interface{})
126+
Expect(ok).To(BeTrue())
127+
Expect(endpoints).To(HaveLen(1))
128+
ep := endpoints[0].(map[string]interface{})
129+
Expect(ep["port"]).To(Equal("metrics"))
130+
Expect(ep["path"]).To(Equal("/metrics"))
131+
132+
selector, ok := spec["selector"].(map[string]interface{})
133+
Expect(ok).To(BeTrue())
134+
matchLabels := selector["matchLabels"].(map[string]interface{})
135+
Expect(matchLabels[NameLabelKey]).To(Equal(featureStore.Name))
136+
Expect(matchLabels[ServiceTypeLabelKey]).To(Equal(string(OnlineFeastType)))
137+
138+
ownerRefs := sm.GetOwnerReferences()
139+
Expect(ownerRefs).To(HaveLen(1))
140+
Expect(ownerRefs[0].Name).To(Equal(featureStore.Name))
141+
Expect(*ownerRefs[0].Controller).To(BeTrue())
142+
})
143+
})
144+
145+
Describe("createOrDeleteServiceMonitor", func() {
146+
It("should be a no-op when ServiceMonitor CRD is not available", func() {
147+
testSetHasServiceMonitorCRD(false)
148+
Expect(feast.createOrDeleteServiceMonitor()).To(Succeed())
149+
})
150+
151+
It("should not error when metrics is not enabled and CRD is unavailable", func() {
152+
testSetHasServiceMonitorCRD(false)
153+
featureStore.Status.Applied.Services.OnlineStore.Server.Metrics = ptr.To(false)
154+
Expect(feast.createOrDeleteServiceMonitor()).To(Succeed())
155+
})
156+
})
157+
158+
Describe("HasServiceMonitorCRD", func() {
159+
It("should return false by default", func() {
160+
testSetHasServiceMonitorCRD(false)
161+
Expect(HasServiceMonitorCRD()).To(BeFalse())
162+
})
163+
164+
It("should return true when set", func() {
165+
testSetHasServiceMonitorCRD(true)
166+
Expect(HasServiceMonitorCRD()).To(BeTrue())
167+
})
168+
})
169+
})

‎infra/feast-operator/internal/controller/services/services.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ func (feast *FeastServices) Deploy() error {
9696
if err := feast.deployCronJob(); err != nil {
9797
return err
9898
}
99+
if err := feast.createOrDeleteServiceMonitor(); err != nil {
100+
return err
101+
}
99102

100103
return nil
101104
}

‎infra/feast-operator/internal/controller/services/suite_test.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,7 @@ var _ = AfterSuite(func() {
8888
func testSetIsOpenShift() {
8989
isOpenShift = true
9090
}
91+
92+
func testSetHasServiceMonitorCRD(val bool) {
93+
hasServiceMonitorCRD = val
94+
}

‎infra/feast-operator/internal/controller/services/util.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
)
2222

2323
var isOpenShift = false
24+
var hasServiceMonitorCRD = false
2425

2526
func IsRegistryServer(featureStore *feastdevv1.FeatureStore) bool {
2627
return IsLocalRegistry(featureStore) && featureStore.Status.Applied.Services.Registry.Local.Server != nil
@@ -374,6 +375,12 @@ func IsOpenShift() bool {
374375
return isOpenShift
375376
}
376377

378+
// HasServiceMonitorCRD returns whether the monitoring.coreos.com API group
379+
// (Prometheus Operator) is available in the cluster.
380+
func HasServiceMonitorCRD() bool {
381+
return hasServiceMonitorCRD
382+
}
383+
377384
// SetIsOpenShift sets the global flag isOpenShift by the controller manager.
378385
// We don't need to keep fetching the API every reconciliation cycle that we need to know about the platform.
379386
func SetIsOpenShift(cfg *rest.Config) {
@@ -393,7 +400,9 @@ func SetIsOpenShift(cfg *rest.Config) {
393400
for _, v := range apiList.Groups {
394401
if v.Name == "route.openshift.io" {
395402
isOpenShift = true
396-
break
403+
}
404+
if v.Name == "monitoring.coreos.com" {
405+
hasServiceMonitorCRD = true
397406
}
398407
}
399408
}

0 commit comments

Comments
 (0)