Skip to content

Commit 92fe673

Browse files
committed
feat: heartbeat crd
1 parent 7e80eae commit 92fe673

12 files changed

Lines changed: 490 additions & 49 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,4 @@ jobs:
149149
echo "BETTERSTACK_TOKEN secret not set; failing"
150150
exit 1
151151
fi
152-
go test -tags=e2e ./test/e2e -run TestBetterStackMonitorLifecycle -timeout 20m
152+
go test -tags=e2e ./test/e2e -run TestBetterStackOperatorLifecycle -timeout 20m

config/crd/bases/monitoring.betterstack.io_betterstackheartbeats.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ spec:
1717
- name: v1alpha1
1818
served: true
1919
storage: true
20+
subresources:
21+
status: {}
2022
additionalPrinterColumns:
2123
- name: Name
2224
type: string

controllers/betterstackheartbeat_controller.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package controllers
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net/http"
8+
"strings"
79

810
"k8s.io/utils/ptr"
911

@@ -41,6 +43,11 @@ type BetterStackHeartbeatReconciler struct {
4143
Clients BetterStackHeartbeatClientFactory
4244
}
4345

46+
const (
47+
// ReasonHeartbeatQuotaExceeded marks a reconciliation failure caused by Better Stack heartbeat quota limits.
48+
ReasonHeartbeatQuotaExceeded = "HeartbeatQuotaExceeded"
49+
)
50+
4451
//+kubebuilder:rbac:groups=monitoring.betterstack.io,resources=betterstackheartbeats,verbs=get;list;watch;create;update;patch;delete
4552
//+kubebuilder:rbac:groups=monitoring.betterstack.io,resources=betterstackheartbeats/status,verbs=get;update;patch
4653
//+kubebuilder:rbac:groups=monitoring.betterstack.io,resources=betterstackheartbeats/finalizers,verbs=update
@@ -104,10 +111,18 @@ func (r *BetterStackHeartbeatReconciler) Reconcile(ctx context.Context, req ctrl
104111

105112
if err != nil {
106113
logger.Error(err, "unable to reconcile Better Stack heartbeat")
114+
syncReason := "SyncFailed"
115+
syncMessage := err.Error()
116+
readyMessage := "Heartbeat reconciliation failed"
117+
if isHeartbeatQuotaExceeded(err) {
118+
syncReason = ReasonHeartbeatQuotaExceeded
119+
syncMessage = "Better Stack heartbeat quota reached"
120+
readyMessage = "Better Stack heartbeat quota reached"
121+
}
107122
_ = r.patchStatus(ctx, heartbeat, func(status *monitoringv1alpha1.BetterStackHeartbeatStatus) {
108123
now := metav1.Now()
109-
status.SetCondition(conditions.New(monitoringv1alpha1.ConditionSync, metav1.ConditionFalse, "SyncFailed", err.Error(), &now))
110-
status.SetCondition(conditions.New(monitoringv1alpha1.ConditionReady, metav1.ConditionFalse, "SyncFailed", "Heartbeat reconciliation failed", &now))
124+
status.SetCondition(conditions.New(monitoringv1alpha1.ConditionSync, metav1.ConditionFalse, syncReason, syncMessage, &now))
125+
status.SetCondition(conditions.New(monitoringv1alpha1.ConditionReady, metav1.ConditionFalse, syncReason, readyMessage, &now))
111126
})
112127
return ctrl.Result{RequeueAfter: requeueIntervalOnError}, nil
113128
}
@@ -233,3 +248,14 @@ func (r *BetterStackHeartbeatReconciler) heartbeatService(baseURL, token string)
233248
}
234249
return factory.Heartbeat(baseURL, token, r.HTTPClient)
235250
}
251+
252+
func isHeartbeatQuotaExceeded(err error) bool {
253+
var apiErr *betterstack.APIError
254+
if !errors.As(err, &apiErr) {
255+
return false
256+
}
257+
if apiErr.StatusCode != http.StatusForbidden {
258+
return false
259+
}
260+
return strings.Contains(strings.ToLower(apiErr.Message), "quota")
261+
}

controllers/betterstackheartbeat_controller_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,66 @@ func TestHeartbeatReconcileHandlesCreateError(t *testing.T) {
403403
assert.String(t, "sync reason", syncCond.Reason, "SyncFailed")
404404
}
405405

406+
func TestHeartbeatReconcileHandlesQuotaExceeded(t *testing.T) {
407+
scheme := controllertest.NewScheme(t)
408+
409+
heartbeat := &monitoringv1alpha1.BetterStackHeartbeat{
410+
ObjectMeta: metav1.ObjectMeta{
411+
Name: "example",
412+
Namespace: "default",
413+
Generation: 4,
414+
Finalizers: []string{monitoringv1alpha1.BetterStackHeartbeatFinalizer},
415+
},
416+
Spec: monitoringv1alpha1.BetterStackHeartbeatSpec{
417+
Name: "Example",
418+
PeriodSeconds: 60,
419+
BaseURL: "https://api.test",
420+
APITokenSecretRef: corev1.SecretKeySelector{
421+
LocalObjectReference: corev1.LocalObjectReference{Name: "api"},
422+
Key: "token",
423+
},
424+
},
425+
}
426+
427+
secret := &corev1.Secret{
428+
ObjectMeta: metav1.ObjectMeta{Name: "api", Namespace: "default"},
429+
Data: map[string][]byte{"token": []byte("abcd")},
430+
}
431+
432+
service := &fakeHeartbeatService{
433+
createFn: func(ctx context.Context, req betterstack.HeartbeatCreateRequest) (betterstack.Heartbeat, error) {
434+
return betterstack.Heartbeat{}, &betterstack.APIError{StatusCode: http.StatusForbidden, Message: "Heartbeat quota reached. Please upgrade your account."}
435+
},
436+
}
437+
factory := &fakeBetterStackHeartbeatClientFactory{heartbeat: service}
438+
439+
client := fake.NewClientBuilder().
440+
WithScheme(scheme).
441+
WithStatusSubresource(heartbeat).
442+
WithObjects(heartbeat.DeepCopy(), secret.DeepCopy()).
443+
Build()
444+
445+
r := &BetterStackHeartbeatReconciler{Client: client, Scheme: scheme, Clients: factory}
446+
447+
ctx := context.Background()
448+
res, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: types.NamespacedName{Name: heartbeat.Name, Namespace: heartbeat.Namespace}})
449+
assert.NoError(t, err, "reconcile")
450+
assert.Equal(t, "requeueAfter", res.RequeueAfter, requeueIntervalOnError)
451+
452+
updated := &monitoringv1alpha1.BetterStackHeartbeat{}
453+
assert.NoError(t, client.Get(ctx, types.NamespacedName{Name: heartbeat.Name, Namespace: heartbeat.Namespace}, updated), "fetch updated heartbeat")
454+
syncCond := controllertest.FindCondition(updated.Status.Conditions, monitoringv1alpha1.ConditionSync)
455+
assert.NotNil(t, "sync condition", syncCond)
456+
assert.Equal(t, "sync status", syncCond.Status, metav1.ConditionFalse)
457+
assert.String(t, "sync reason", syncCond.Reason, ReasonHeartbeatQuotaExceeded)
458+
assert.String(t, "sync message", syncCond.Message, "Better Stack heartbeat quota reached")
459+
readyCond := controllertest.FindCondition(updated.Status.Conditions, monitoringv1alpha1.ConditionReady)
460+
assert.NotNil(t, "ready condition", readyCond)
461+
assert.Equal(t, "ready status", readyCond.Status, metav1.ConditionFalse)
462+
assert.String(t, "ready reason", readyCond.Reason, ReasonHeartbeatQuotaExceeded)
463+
assert.String(t, "ready message", readyCond.Message, "Better Stack heartbeat quota reached")
464+
}
465+
406466
func TestHeartbeatReconcileHandlesDeletion(t *testing.T) {
407467
scheme := controllertest.NewScheme(t)
408468

controllers/betterstackmonitor_controller_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@ type fakeMonitorService struct {
5252
updateFn func(ctx context.Context, id string, req betterstack.MonitorUpdateRequest) (betterstack.Monitor, error)
5353
createFn func(ctx context.Context, req betterstack.MonitorCreateRequest) (betterstack.Monitor, error)
5454
deleteFn func(ctx context.Context, id string) error
55+
listFn func(ctx context.Context) ([]betterstack.Monitor, error)
5556

5657
getCalls int
5758
updateCalls int
5859
createCalls int
5960
deleteCalls int
61+
listCalls int
6062

6163
lastUpdateReq betterstack.MonitorUpdateRequest
6264
lastCreateReq betterstack.MonitorCreateRequest
@@ -96,6 +98,14 @@ func (s *fakeMonitorService) Delete(ctx context.Context, id string) error {
9698
return nil
9799
}
98100

101+
func (s *fakeMonitorService) List(ctx context.Context) ([]betterstack.Monitor, error) {
102+
s.listCalls++
103+
if s.listFn != nil {
104+
return s.listFn(ctx)
105+
}
106+
return nil, nil
107+
}
108+
99109
var _ betterstack.MonitorClient = (*fakeMonitorService)(nil)
100110

101111
func TestReconcileAddsFinalizer(t *testing.T) {

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
k8s.io/apiextensions-apiserver v0.34.1
88
k8s.io/apimachinery v0.34.1
99
k8s.io/client-go v0.34.1
10+
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
1011
sigs.k8s.io/controller-runtime v0.22.1
1112
)
1213

@@ -60,7 +61,6 @@ require (
6061
gopkg.in/yaml.v3 v3.0.1 // indirect
6162
k8s.io/klog/v2 v2.130.1 // indirect
6263
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
63-
k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
6464
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
6565
sigs.k8s.io/randfill v1.0.0 // indirect
6666
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect

helm/betterstack-operator/files/crds/monitoring.betterstack.io_betterstackheartbeats.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ spec:
1717
- name: v1alpha1
1818
served: true
1919
storage: true
20+
subresources:
21+
status: {}
2022
additionalPrinterColumns:
2123
- name: Name
2224
type: string

internal/testutil/assert/assert.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ func StringSlice(t testing.TB, field string, actual, expected []string) {
130130
EqualSlice[string](t, field, actual, expected)
131131
}
132132

133+
// Failf unconditionally fails the test with the formatted message.
134+
func Failf(t testing.TB, format string, args ...any) {
135+
t.Helper()
136+
t.Fatalf(format, args...)
137+
}
138+
133139
func isNil(v any) bool {
134140
if v == nil {
135141
return true

pkg/betterstack/heartbeats.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/http"
77
"net/url"
8+
"strings"
89
"time"
910
)
1011

@@ -97,6 +98,13 @@ type heartbeatData struct {
9798
Attributes HeartbeatAttributes `json:"attributes"`
9899
}
99100

101+
type heartbeatListEnvelope struct {
102+
Data []heartbeatData `json:"data"`
103+
Links struct {
104+
Next string `json:"next"`
105+
} `json:"links"`
106+
}
107+
100108
// Create creates a heartbeat in Better Stack.
101109
func (s *HeartbeatService) Create(ctx context.Context, req HeartbeatCreateRequest) (Heartbeat, error) {
102110
var respEnvelope heartbeatEnvelope
@@ -136,4 +144,34 @@ func (s *HeartbeatService) Delete(ctx context.Context, id string) error {
136144
return err
137145
}
138146

147+
// List returns the collection of heartbeats. Pagination is followed automatically.
148+
func (s *HeartbeatService) List(ctx context.Context) ([]Heartbeat, error) {
149+
path := "/heartbeats"
150+
var heartbeats []Heartbeat
151+
152+
for path != "" {
153+
var envelope heartbeatListEnvelope
154+
if err := s.client.do(ctx, http.MethodGet, path, nil, &envelope); err != nil {
155+
return nil, err
156+
}
157+
158+
for _, item := range envelope.Data {
159+
heartbeats = append(heartbeats, Heartbeat{ID: item.ID, Attributes: item.Attributes})
160+
}
161+
162+
next := strings.TrimSpace(envelope.Links.Next)
163+
if next == "" {
164+
break
165+
}
166+
167+
// normalise next path relative to base URL when required
168+
if strings.HasPrefix(next, s.client.baseURL) {
169+
next = strings.TrimPrefix(next, s.client.baseURL)
170+
}
171+
path = next
172+
}
173+
174+
return heartbeats, nil
175+
}
176+
139177
var _ HeartbeatClient = (*HeartbeatService)(nil)

pkg/betterstack/monitors.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"net/http"
88
"net/url"
9+
"strings"
910
"time"
1011
)
1112

@@ -15,6 +16,7 @@ type MonitorClient interface {
1516
Get(ctx context.Context, id string) (Monitor, error)
1617
Update(ctx context.Context, id string, req MonitorUpdateRequest) (Monitor, error)
1718
Delete(ctx context.Context, id string) error
19+
List(ctx context.Context) ([]Monitor, error)
1820
}
1921

2022
// MonitorService provides monitor-specific Better Stack operations.
@@ -183,6 +185,13 @@ type monitorData struct {
183185
Attributes MonitorAttributes `json:"attributes"`
184186
}
185187

188+
type monitorListEnvelope struct {
189+
Data []monitorData `json:"data"`
190+
Links struct {
191+
Next string `json:"next"`
192+
} `json:"links"`
193+
}
194+
186195
// Create creates a monitor in Better Stack.
187196
func (s *MonitorService) Create(ctx context.Context, req MonitorCreateRequest) (Monitor, error) {
188197
var respEnvelope monitorEnvelope
@@ -222,4 +231,32 @@ func (s *MonitorService) Delete(ctx context.Context, id string) error {
222231
return err
223232
}
224233

234+
// List returns all monitors, following pagination automatically.
235+
func (s *MonitorService) List(ctx context.Context) ([]Monitor, error) {
236+
path := "/monitors"
237+
var monitors []Monitor
238+
239+
for path != "" {
240+
var envelope monitorListEnvelope
241+
if err := s.client.do(ctx, http.MethodGet, path, nil, &envelope); err != nil {
242+
return nil, err
243+
}
244+
245+
for _, item := range envelope.Data {
246+
monitors = append(monitors, Monitor{ID: item.ID, Attributes: item.Attributes})
247+
}
248+
249+
next := strings.TrimSpace(envelope.Links.Next)
250+
if next == "" {
251+
break
252+
}
253+
if strings.HasPrefix(next, s.client.baseURL) {
254+
next = strings.TrimPrefix(next, s.client.baseURL)
255+
}
256+
path = next
257+
}
258+
259+
return monitors, nil
260+
}
261+
225262
var _ MonitorClient = (*MonitorService)(nil)

0 commit comments

Comments
 (0)