Skip to content

Commit a1d5f3e

Browse files
authored
Merge pull request #12 from PDOK/betterstack
feat: add Better Stack as provider
2 parents 89b2ff9 + 0ca2348 commit a1d5f3e

File tree

15 files changed

+883
-87
lines changed

15 files changed

+883
-87
lines changed

.github/workflows/test-go.yml

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@ jobs:
1919
go-version: '1.23'
2020

2121
- name: Make test
22+
uses: nick-fields/retry@v3
2223
env:
2324
PINGDOM_API_TOKEN: ${{ secrets.PINGDOM_API_TOKEN }}
24-
run: |
25-
make test
26-
echo "removing generated code from coverage results"
27-
mv cover.out cover.out.tmp && grep -vP "uptime-operator/(api/v1alpha1|cmd|test/utils)/" cover.out.tmp > cover.out
25+
BETTERSTACK_API_TOKEN: ${{ secrets.BETTERSTACK_API_TOKEN }}
26+
with:
27+
timeout_minutes: 5
28+
max_attempts: 2
29+
retry_on: error
30+
command: |
31+
make test
32+
echo "removing generated code from coverage results"
33+
mv cover.out cover.out.tmp && grep -vP "uptime-operator/(api/v1alpha1|cmd|test/utils)/" cover.out.tmp > cover.out
2834
2935
- name: Update coverage report
3036
uses: ncruces/go-coverage-report@v0

.golangci.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ linters-settings:
4747
gomoddirectives:
4848
replace-allow-list:
4949
- github.com/abbot/go-http-auth
50+
tagliatelle:
51+
case:
52+
rules:
53+
json: snake # since betterstack uses snake instead of camel case for JSON
5054

5155
linters:
5256
disable-all: true

README.md

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
Kubernetes Operator to watch [Traefik](https://github.com/traefik/traefik) IngressRoute(s) and register these with a (SaaS) uptime monitoring provider.
1111
Currently supported providers are:
1212
- [Pingdom](https://www.pingdom.com/)
13+
- [Better Stack](https://betterstack.com/)
1314
- Mock (for testing purposes)
1415

1516
Submit a PR when you wish to add another provider!
@@ -61,44 +62,46 @@ USAGE:
6162
<uptime-controller-manager> [OPTIONS]
6263
6364
OPTIONS:
65+
-betterstack-api-token string
66+
The API token to authenticate with Better Stack. Only applies when 'uptime-provider' is 'betterstack'
6467
-enable-deletes
65-
Allow the operator to delete checks from the uptime provider when ingress routes are removed.
68+
Allow the operator to delete checks from the uptime provider when ingress routes are removed.
6669
-enable-http2
67-
If set, HTTP/2 will be enabled for the metrics and webhook servers.
70+
If set, HTTP/2 will be enabled for the metrics and webhook servers.
6871
-health-probe-bind-address string
69-
The address the probe endpoint binds to. (default ":8081")
72+
The address the probe endpoint binds to. (default ":8081")
7073
-kubeconfig string
71-
Paths to a kubeconfig. Only required if out-of-cluster.
74+
Paths to a kubeconfig. Only required if out-of-cluster.
7275
-leader-elect
73-
Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.
76+
Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.
7477
-metrics-bind-address string
75-
The address the metric endpoint binds to. (default ":8080")
78+
The address the metric endpoint binds to. (default ":8080")
7679
-metrics-secure
77-
If set the metrics endpoint is served securely.
80+
If set the metrics endpoint is served securely.
7881
-namespace value
79-
Namespace(s) to watch for changes. Specify this flag multiple times for each namespace to watch. When not provided all namespaces will be watched.
82+
Namespace(s) to watch for changes. Specify this flag multiple times for each namespace to watch. When not provided all namespaces will be watched.
8083
-pingdom-alert-integration-ids value
81-
One or more IDs of Pingdom integrations (like slack channels) to alert. Only applies when 'uptime-provider' is 'pingdom'
84+
One or more IDs of Pingdom integrations (like slack channels) to alert. Only applies when 'uptime-provider' is 'pingdom'
8285
-pingdom-alert-user-ids value
83-
One or more IDs of Pingdom users to alert. Only applies when 'uptime-provider' is 'pingdom'
86+
One or more IDs of Pingdom users to alert. Only applies when 'uptime-provider' is 'pingdom'
8487
-pingdom-api-token string
85-
The API token to authenticate with Pingdom. Only applies when 'uptime-provider' is 'pingdom'
88+
The API token to authenticate with Pingdom. Only applies when 'uptime-provider' is 'pingdom'
8689
-slack-channel string
87-
The Slack Channel ID for posting updates when uptime checks are mutated.
90+
The Slack Channel ID for posting updates when uptime checks are mutated.
8891
-slack-webhook-url string
89-
The webhook URL required to post messages to the given Slack channel.
92+
The webhook URL required to post messages to the given Slack channel.
9093
-uptime-provider string
91-
Name of the (SaaS) uptime monitoring provider to use. (default "mock")
94+
Name of the (SaaS) uptime monitoring provider to use. (default "mock")
9295
-zap-devel
93-
Development Mode defaults(encoder=consoleEncoder,logLevel=Debug,stackTraceLevel=Warn). Production Mode defaults(encoder=jsonEncoder,logLevel=Info,stackTraceLevel=Error) (default true)
96+
Development Mode defaults(encoder=consoleEncoder,logLevel=Debug,stackTraceLevel=Warn). Production Mode defaults(encoder=jsonEncoder,logLevel=Info,stackTraceLevel=Error) (default true)
9497
-zap-encoder value
95-
Zap log encoding (one of 'json' or 'console')
98+
Zap log encoding (one of 'json' or 'console')
9699
-zap-log-level value
97-
Zap Level to configure the verbosity of logging. Can be one of 'debug', 'info', 'error', or any integer value > 0 which corresponds to custom debug levels of increasing verbosity
100+
Zap Level to configure the verbosity of logging. Can be one of 'debug', 'info', 'error', or any integer value > 0 which corresponds to custom debug levels of increasing verbosity
98101
-zap-stacktrace-level value
99-
Zap Level at and above which stacktraces are captured (one of 'info', 'error', 'panic').
102+
Zap Level at and above which stacktraces are captured (one of 'info', 'error', 'panic').
100103
-zap-time-encoding value
101-
Zap time encoding (one of 'epoch', 'millis', 'nano', 'iso8601', 'rfc3339' or 'rfc3339nano'). Defaults to 'epoch'.
104+
Zap time encoding (one of 'epoch', 'millis', 'nano', 'iso8601', 'rfc3339' or 'rfc3339nano'). Defaults to 'epoch'.
102105
```
103106

104107
## Develop

cmd/main.go

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import (
2222
"os"
2323

2424
"github.com/PDOK/uptime-operator/internal/service"
25-
"github.com/PDOK/uptime-operator/internal/service/providers"
25+
p "github.com/PDOK/uptime-operator/internal/service/providers"
26+
"github.com/PDOK/uptime-operator/internal/service/providers/betterstack"
27+
"github.com/PDOK/uptime-operator/internal/service/providers/pingdom"
2628
"github.com/PDOK/uptime-operator/internal/util"
2729
"github.com/peterbourgon/ff"
2830
"sigs.k8s.io/controller-runtime/pkg/cache"
@@ -73,6 +75,9 @@ func main() {
7375
var pingdomAPIToken string
7476
var pingdomAlertUserIDs util.SliceFlag
7577
var pingdomAlertIntegrationIDs util.SliceFlag
78+
var betterstackAPIToken string
79+
80+
// Default kubebuilder
7681
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080",
7782
"The address the metric endpoint binds to.")
7883
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081",
@@ -86,6 +91,8 @@ func main() {
8691
"If set, HTTP/2 will be enabled for the metrics and webhook servers.")
8792
flag.BoolVar(&enableDeletes, "enable-deletes", false,
8893
"Allow the operator to delete checks from the uptime provider when ingress routes are removed.")
94+
95+
// General uptime-operator
8996
flag.Var(&namespaces, "namespace", "Namespace(s) to watch for changes. "+
9097
"Specify this flag multiple times for each namespace to watch. When not provided all namespaces will be watched.")
9198
flag.StringVar(&slackChannel, "slack-channel", "",
@@ -94,13 +101,19 @@ func main() {
94101
"The webhook URL required to post messages to the given Slack channel.")
95102
flag.StringVar(&uptimeProvider, "uptime-provider", "mock",
96103
"Name of the (SaaS) uptime monitoring provider to use.")
104+
105+
// Pingdom specific
97106
flag.StringVar(&pingdomAPIToken, "pingdom-api-token", "",
98107
"The API token to authenticate with Pingdom. Only applies when 'uptime-provider' is 'pingdom'")
99108
flag.Var(&pingdomAlertUserIDs, "pingdom-alert-user-ids",
100109
"One or more IDs of Pingdom users to alert. Only applies when 'uptime-provider' is 'pingdom'")
101110
flag.Var(&pingdomAlertIntegrationIDs, "pingdom-alert-integration-ids",
102111
"One or more IDs of Pingdom integrations (like slack channels) to alert. Only applies when 'uptime-provider' is 'pingdom'")
103112

113+
// Better Stack specific
114+
flag.StringVar(&betterstackAPIToken, "betterstack-api-token", "",
115+
"The API token to authenticate with Better Stack. Only applies when 'uptime-provider' is 'betterstack'")
116+
104117
opts := zap.Options{
105118
Development: true,
106119
}
@@ -119,7 +132,10 @@ func main() {
119132
}
120133

121134
var uptimeProviderSettings any
122-
if uptimeProvider == "pingdom" {
135+
uptimeProviderID := p.UptimeProviderID(uptimeProvider)
136+
137+
// Optional provider specific flag handling
138+
if uptimeProviderID == p.ProviderPingdom {
123139
alertUserIDs, err := util.StringsToInts(pingdomAlertUserIDs)
124140
if err != nil {
125141
setupLog.Error(err, "Unable to parse 'pingdom-alert-user-ids' flag")
@@ -130,18 +146,23 @@ func main() {
130146
setupLog.Error(err, "Unable to parse 'pingdom-alert-integration-ids' flag")
131147
os.Exit(1)
132148
}
133-
uptimeProviderSettings = providers.PingdomSettings{
149+
uptimeProviderSettings = pingdom.Settings{
134150
APIToken: pingdomAPIToken,
135151
UserIDs: alertUserIDs,
136152
IntegrationIDs: alertIntegrationIDs,
137153
}
154+
} else if uptimeProviderID == p.ProviderBetterStack {
155+
uptimeProviderSettings = betterstack.Settings{
156+
APIToken: betterstackAPIToken,
157+
}
138158
}
139159

160+
// Setup controller
140161
if err = (&controller.IngressRouteReconciler{
141162
Client: mgr.GetClient(),
142163
Scheme: mgr.GetScheme(),
143164
UptimeCheckService: service.New(
144-
service.WithProviderAndSettings(uptimeProvider, uptimeProviderSettings),
165+
service.WithProviderAndSettings(uptimeProviderID, uptimeProviderSettings),
145166
service.WithSlack(slackWebhookURL, slackChannel),
146167
service.WithDeletes(enableDeletes),
147168
),

internal/model/check.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ type UptimeCheck struct {
3232
URL string `json:"url"`
3333
Tags []string `json:"tags"`
3434
Interval int `json:"resolution"`
35-
RequestHeaders map[string]string `json:"request_headers"` //nolint:tagliatelle // grandfathered in
36-
StringContains string `json:"string_contains"` //nolint:tagliatelle // grandfathered in
37-
StringNotContains string `json:"string_not_contains"` //nolint:tagliatelle // grandfathered in
35+
RequestHeaders map[string]string `json:"request_headers"`
36+
StringContains string `json:"string_contains"`
37+
StringNotContains string `json:"string_not_contains"`
3838
}
3939

4040
func NewUptimeCheck(ingressName string, annotations map[string]string) (*UptimeCheck, error) {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package betterstack
2+
3+
import (
4+
"context"
5+
"fmt"
6+
classiclog "log"
7+
"net/http"
8+
"strconv"
9+
"time"
10+
11+
"github.com/PDOK/uptime-operator/internal/model"
12+
p "github.com/PDOK/uptime-operator/internal/service/providers"
13+
"sigs.k8s.io/controller-runtime/pkg/log"
14+
)
15+
16+
const betterStackBaseURL = "https://uptime.betterstack.com"
17+
18+
type Settings struct {
19+
APIToken string
20+
PageSize int
21+
}
22+
23+
type BetterStack struct {
24+
client Client
25+
}
26+
27+
// New creates a BetterStack
28+
func New(settings Settings) *BetterStack {
29+
if settings.APIToken == "" {
30+
classiclog.Fatal("Better Stack API token is not provided")
31+
}
32+
if settings.PageSize < 1 {
33+
settings.PageSize = 50 // default https://betterstack.com/docs/uptime/api/pagination/
34+
}
35+
return &BetterStack{
36+
Client{
37+
httpClient: &http.Client{Timeout: time.Duration(5) * time.Minute},
38+
settings: settings,
39+
},
40+
}
41+
}
42+
43+
// CreateOrUpdateCheck create the given check with Better Stack, or update an existing check. Needs to be idempotent!
44+
func (b *BetterStack) CreateOrUpdateCheck(ctx context.Context, check model.UptimeCheck) (err error) {
45+
existingCheckID, err := b.findCheck(check)
46+
if err != nil {
47+
return fmt.Errorf("failed to find check %s, error: %w", check.ID, err)
48+
}
49+
if existingCheckID == p.CheckNotFound { //nolint:nestif // clean enough
50+
log.FromContext(ctx).Info("creating check", "check", check)
51+
monitorID, err := b.client.createMonitor(check)
52+
if err != nil {
53+
return fmt.Errorf("failed to create monitor for check %s, error: %w", check.ID, err)
54+
}
55+
if err = b.client.createMetadata(check.ID, monitorID, check.Tags); err != nil {
56+
return fmt.Errorf("failed to create metadata for check %s, error: %w", check.ID, err)
57+
}
58+
} else {
59+
log.FromContext(ctx).Info("updating check", "check", check, "betterstack ID", existingCheckID)
60+
existingMonitor, err := b.client.getMonitor(existingCheckID)
61+
if err != nil {
62+
return fmt.Errorf("failed to get monitor for check %s, error: %w", check.ID, err)
63+
}
64+
if err = b.client.updateMonitor(check, existingMonitor); err != nil {
65+
return fmt.Errorf("failed to update monitor for check %s (betterstack ID: %d), "+
66+
"error: %w", check.ID, existingCheckID, err)
67+
}
68+
if err = b.client.updateMetadata(check.ID, existingCheckID, check.Tags); err != nil {
69+
return fmt.Errorf("failed to update metdata for check %s (betterstack ID: %d), "+
70+
"error: %w", check.ID, existingCheckID, err)
71+
}
72+
}
73+
return err
74+
}
75+
76+
// DeleteCheck deletes the given check from Better Stack
77+
func (b *BetterStack) DeleteCheck(ctx context.Context, check model.UptimeCheck) error {
78+
log.FromContext(ctx).Info("deleting check", "check", check)
79+
80+
existingCheckID, err := b.findCheck(check)
81+
if err != nil {
82+
return fmt.Errorf("failed to find check %s, error: %w", check.ID, err)
83+
}
84+
if existingCheckID == p.CheckNotFound {
85+
log.FromContext(ctx).Info(fmt.Sprintf("check with ID '%s' is already deleted", check.ID))
86+
return nil
87+
}
88+
if err = b.client.deleteMetadata(check.ID, existingCheckID); err != nil {
89+
return fmt.Errorf("failed to delete metadata for check %s (betterstack ID: %d), "+
90+
"error: %w", check.ID, existingCheckID, err)
91+
}
92+
if err = b.client.deleteMonitor(existingCheckID); err != nil {
93+
return fmt.Errorf("failed to delete monitor for check %s (betterstack ID: %d), "+
94+
"error: %w", check.ID, existingCheckID, err)
95+
}
96+
return nil
97+
}
98+
99+
func (b *BetterStack) findCheck(check model.UptimeCheck) (int64, error) {
100+
result := p.CheckNotFound
101+
metadata, err := b.client.listMetadata()
102+
if err != nil {
103+
return result, err
104+
}
105+
for {
106+
for _, md := range metadata.Data {
107+
if md.Attributes != nil && md.Attributes.Key == check.ID {
108+
result, err = strconv.ParseInt(md.Attributes.OwnerID, 10, 64)
109+
if err != nil {
110+
return result, fmt.Errorf("failed to parse monitor ID %s to integer", md.Attributes.OwnerID)
111+
}
112+
return result, nil
113+
}
114+
}
115+
if !metadata.HasNext() {
116+
break // exit infinite loop
117+
}
118+
metadata, err = metadata.Next(b.client)
119+
if err != nil {
120+
return result, err
121+
}
122+
}
123+
return result, nil
124+
}

0 commit comments

Comments
 (0)