Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions .github/workflows/test-go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@ jobs:
go-version: '1.23'

- name: Make test
uses: nick-fields/retry@v3
env:
PINGDOM_API_TOKEN: ${{ secrets.PINGDOM_API_TOKEN }}
run: |
make test
echo "removing generated code from coverage results"
mv cover.out cover.out.tmp && grep -vP "uptime-operator/(api/v1alpha1|cmd|test/utils)/" cover.out.tmp > cover.out
BETTERSTACK_API_TOKEN: ${{ secrets.BETTERSTACK_API_TOKEN }}
with:
timeout_minutes: 5
max_attempts: 2
retry_on: error
command: |
make test
echo "removing generated code from coverage results"
mv cover.out cover.out.tmp && grep -vP "uptime-operator/(api/v1alpha1|cmd|test/utils)/" cover.out.tmp > cover.out

- name: Update coverage report
uses: ncruces/go-coverage-report@v0
Expand Down
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ linters-settings:
gomoddirectives:
replace-allow-list:
- github.com/abbot/go-http-auth
tagliatelle:
case:
rules:
json: snake # since betterstack uses snake instead of camel case for JSON

linters:
disable-all: true
Expand Down
41 changes: 22 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Kubernetes Operator to watch [Traefik](https://github.com/traefik/traefik) IngressRoute(s) and register these with a (SaaS) uptime monitoring provider.
Currently supported providers are:
- [Pingdom](https://www.pingdom.com/)
- [Better Stack](https://betterstack.com/)
- Mock (for testing purposes)

Submit a PR when you wish to add another provider!
Expand Down Expand Up @@ -61,44 +62,46 @@ USAGE:
<uptime-controller-manager> [OPTIONS]

OPTIONS:
-betterstack-api-token string
The API token to authenticate with Better Stack. Only applies when 'uptime-provider' is 'betterstack'
-enable-deletes
Allow the operator to delete checks from the uptime provider when ingress routes are removed.
Allow the operator to delete checks from the uptime provider when ingress routes are removed.
-enable-http2
If set, HTTP/2 will be enabled for the metrics and webhook servers.
If set, HTTP/2 will be enabled for the metrics and webhook servers.
-health-probe-bind-address string
The address the probe endpoint binds to. (default ":8081")
The address the probe endpoint binds to. (default ":8081")
-kubeconfig string
Paths to a kubeconfig. Only required if out-of-cluster.
Paths to a kubeconfig. Only required if out-of-cluster.
-leader-elect
Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.
Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.
-metrics-bind-address string
The address the metric endpoint binds to. (default ":8080")
The address the metric endpoint binds to. (default ":8080")
-metrics-secure
If set the metrics endpoint is served securely.
If set the metrics endpoint is served securely.
-namespace value
Namespace(s) to watch for changes. Specify this flag multiple times for each namespace to watch. When not provided all namespaces will be watched.
Namespace(s) to watch for changes. Specify this flag multiple times for each namespace to watch. When not provided all namespaces will be watched.
-pingdom-alert-integration-ids value
One or more IDs of Pingdom integrations (like slack channels) to alert. Only applies when 'uptime-provider' is 'pingdom'
One or more IDs of Pingdom integrations (like slack channels) to alert. Only applies when 'uptime-provider' is 'pingdom'
-pingdom-alert-user-ids value
One or more IDs of Pingdom users to alert. Only applies when 'uptime-provider' is 'pingdom'
One or more IDs of Pingdom users to alert. Only applies when 'uptime-provider' is 'pingdom'
-pingdom-api-token string
The API token to authenticate with Pingdom. Only applies when 'uptime-provider' is 'pingdom'
The API token to authenticate with Pingdom. Only applies when 'uptime-provider' is 'pingdom'
-slack-channel string
The Slack Channel ID for posting updates when uptime checks are mutated.
The Slack Channel ID for posting updates when uptime checks are mutated.
-slack-webhook-url string
The webhook URL required to post messages to the given Slack channel.
The webhook URL required to post messages to the given Slack channel.
-uptime-provider string
Name of the (SaaS) uptime monitoring provider to use. (default "mock")
Name of the (SaaS) uptime monitoring provider to use. (default "mock")
-zap-devel
Development Mode defaults(encoder=consoleEncoder,logLevel=Debug,stackTraceLevel=Warn). Production Mode defaults(encoder=jsonEncoder,logLevel=Info,stackTraceLevel=Error) (default true)
Development Mode defaults(encoder=consoleEncoder,logLevel=Debug,stackTraceLevel=Warn). Production Mode defaults(encoder=jsonEncoder,logLevel=Info,stackTraceLevel=Error) (default true)
-zap-encoder value
Zap log encoding (one of 'json' or 'console')
Zap log encoding (one of 'json' or 'console')
-zap-log-level value
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
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
-zap-stacktrace-level value
Zap Level at and above which stacktraces are captured (one of 'info', 'error', 'panic').
Zap Level at and above which stacktraces are captured (one of 'info', 'error', 'panic').
-zap-time-encoding value
Zap time encoding (one of 'epoch', 'millis', 'nano', 'iso8601', 'rfc3339' or 'rfc3339nano'). Defaults to 'epoch'.
Zap time encoding (one of 'epoch', 'millis', 'nano', 'iso8601', 'rfc3339' or 'rfc3339nano'). Defaults to 'epoch'.
```

## Develop
Expand Down
29 changes: 25 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import (
"os"

"github.com/PDOK/uptime-operator/internal/service"
"github.com/PDOK/uptime-operator/internal/service/providers"
p "github.com/PDOK/uptime-operator/internal/service/providers"
"github.com/PDOK/uptime-operator/internal/service/providers/betterstack"
"github.com/PDOK/uptime-operator/internal/service/providers/pingdom"
"github.com/PDOK/uptime-operator/internal/util"
"github.com/peterbourgon/ff"
"sigs.k8s.io/controller-runtime/pkg/cache"
Expand Down Expand Up @@ -73,6 +75,9 @@ func main() {
var pingdomAPIToken string
var pingdomAlertUserIDs util.SliceFlag
var pingdomAlertIntegrationIDs util.SliceFlag
var betterstackAPIToken string

// Default kubebuilder
flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080",
"The address the metric endpoint binds to.")
flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081",
Expand All @@ -86,6 +91,8 @@ func main() {
"If set, HTTP/2 will be enabled for the metrics and webhook servers.")
flag.BoolVar(&enableDeletes, "enable-deletes", false,
"Allow the operator to delete checks from the uptime provider when ingress routes are removed.")

// General uptime-operator
flag.Var(&namespaces, "namespace", "Namespace(s) to watch for changes. "+
"Specify this flag multiple times for each namespace to watch. When not provided all namespaces will be watched.")
flag.StringVar(&slackChannel, "slack-channel", "",
Expand All @@ -94,13 +101,19 @@ func main() {
"The webhook URL required to post messages to the given Slack channel.")
flag.StringVar(&uptimeProvider, "uptime-provider", "mock",
"Name of the (SaaS) uptime monitoring provider to use.")

// Pingdom specific
flag.StringVar(&pingdomAPIToken, "pingdom-api-token", "",
"The API token to authenticate with Pingdom. Only applies when 'uptime-provider' is 'pingdom'")
flag.Var(&pingdomAlertUserIDs, "pingdom-alert-user-ids",
"One or more IDs of Pingdom users to alert. Only applies when 'uptime-provider' is 'pingdom'")
flag.Var(&pingdomAlertIntegrationIDs, "pingdom-alert-integration-ids",
"One or more IDs of Pingdom integrations (like slack channels) to alert. Only applies when 'uptime-provider' is 'pingdom'")

// Better Stack specific
flag.StringVar(&betterstackAPIToken, "betterstack-api-token", "",
"The API token to authenticate with Better Stack. Only applies when 'uptime-provider' is 'betterstack'")

opts := zap.Options{
Development: true,
}
Expand All @@ -119,7 +132,10 @@ func main() {
}

var uptimeProviderSettings any
if uptimeProvider == "pingdom" {
uptimeProviderID := p.UptimeProviderID(uptimeProvider)

// Optional provider specific flag handling
if uptimeProviderID == p.ProviderPingdom {
alertUserIDs, err := util.StringsToInts(pingdomAlertUserIDs)
if err != nil {
setupLog.Error(err, "Unable to parse 'pingdom-alert-user-ids' flag")
Expand All @@ -130,18 +146,23 @@ func main() {
setupLog.Error(err, "Unable to parse 'pingdom-alert-integration-ids' flag")
os.Exit(1)
}
uptimeProviderSettings = providers.PingdomSettings{
uptimeProviderSettings = pingdom.Settings{
APIToken: pingdomAPIToken,
UserIDs: alertUserIDs,
IntegrationIDs: alertIntegrationIDs,
}
} else if uptimeProviderID == p.ProviderBetterStack {
uptimeProviderSettings = betterstack.Settings{
APIToken: betterstackAPIToken,
}
}

// Setup controller
if err = (&controller.IngressRouteReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
UptimeCheckService: service.New(
service.WithProviderAndSettings(uptimeProvider, uptimeProviderSettings),
service.WithProviderAndSettings(uptimeProviderID, uptimeProviderSettings),
service.WithSlack(slackWebhookURL, slackChannel),
service.WithDeletes(enableDeletes),
),
Expand Down
6 changes: 3 additions & 3 deletions internal/model/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ type UptimeCheck struct {
URL string `json:"url"`
Tags []string `json:"tags"`
Interval int `json:"resolution"`
RequestHeaders map[string]string `json:"request_headers"` //nolint:tagliatelle // grandfathered in
StringContains string `json:"string_contains"` //nolint:tagliatelle // grandfathered in
StringNotContains string `json:"string_not_contains"` //nolint:tagliatelle // grandfathered in
RequestHeaders map[string]string `json:"request_headers"`
StringContains string `json:"string_contains"`
StringNotContains string `json:"string_not_contains"`
}

func NewUptimeCheck(ingressName string, annotations map[string]string) (*UptimeCheck, error) {
Expand Down
124 changes: 124 additions & 0 deletions internal/service/providers/betterstack/betterstack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package betterstack

import (
"context"
"fmt"
classiclog "log"
"net/http"
"strconv"
"time"

"github.com/PDOK/uptime-operator/internal/model"
p "github.com/PDOK/uptime-operator/internal/service/providers"
"sigs.k8s.io/controller-runtime/pkg/log"
)

const betterStackBaseURL = "https://uptime.betterstack.com"

type Settings struct {
APIToken string
PageSize int
}

type BetterStack struct {
client Client
}

// New creates a BetterStack
func New(settings Settings) *BetterStack {
if settings.APIToken == "" {
classiclog.Fatal("Better Stack API token is not provided")
}
if settings.PageSize < 1 {
settings.PageSize = 50 // default https://betterstack.com/docs/uptime/api/pagination/
}
return &BetterStack{
Client{
httpClient: &http.Client{Timeout: time.Duration(5) * time.Minute},
settings: settings,
},
}
}

// CreateOrUpdateCheck create the given check with Better Stack, or update an existing check. Needs to be idempotent!
func (b *BetterStack) CreateOrUpdateCheck(ctx context.Context, check model.UptimeCheck) (err error) {
existingCheckID, err := b.findCheck(check)
if err != nil {
return fmt.Errorf("failed to find check %s, error: %w", check.ID, err)
}
if existingCheckID == p.CheckNotFound { //nolint:nestif // clean enough
log.FromContext(ctx).Info("creating check", "check", check)
monitorID, err := b.client.createMonitor(check)
if err != nil {
return fmt.Errorf("failed to create monitor for check %s, error: %w", check.ID, err)
}
if err = b.client.createMetadata(check.ID, monitorID, check.Tags); err != nil {
return fmt.Errorf("failed to create metadata for check %s, error: %w", check.ID, err)
}
} else {
log.FromContext(ctx).Info("updating check", "check", check, "betterstack ID", existingCheckID)
existingMonitor, err := b.client.getMonitor(existingCheckID)
if err != nil {
return fmt.Errorf("failed to get monitor for check %s, error: %w", check.ID, err)
}
if err = b.client.updateMonitor(check, existingMonitor); err != nil {
return fmt.Errorf("failed to update monitor for check %s (betterstack ID: %d), "+
"error: %w", check.ID, existingCheckID, err)
}
if err = b.client.updateMetadata(check.ID, existingCheckID, check.Tags); err != nil {
return fmt.Errorf("failed to update metdata for check %s (betterstack ID: %d), "+
"error: %w", check.ID, existingCheckID, err)
}
}
return err
}

// DeleteCheck deletes the given check from Better Stack
func (b *BetterStack) DeleteCheck(ctx context.Context, check model.UptimeCheck) error {
log.FromContext(ctx).Info("deleting check", "check", check)

existingCheckID, err := b.findCheck(check)
if err != nil {
return fmt.Errorf("failed to find check %s, error: %w", check.ID, err)
}
if existingCheckID == p.CheckNotFound {
log.FromContext(ctx).Info(fmt.Sprintf("check with ID '%s' is already deleted", check.ID))
return nil
}
if err = b.client.deleteMetadata(check.ID, existingCheckID); err != nil {
return fmt.Errorf("failed to delete metadata for check %s (betterstack ID: %d), "+
"error: %w", check.ID, existingCheckID, err)
}
if err = b.client.deleteMonitor(existingCheckID); err != nil {
return fmt.Errorf("failed to delete monitor for check %s (betterstack ID: %d), "+
"error: %w", check.ID, existingCheckID, err)
}
return nil
}

func (b *BetterStack) findCheck(check model.UptimeCheck) (int64, error) {
result := p.CheckNotFound
metadata, err := b.client.listMetadata()
if err != nil {
return result, err
}
for {
for _, md := range metadata.Data {
if md.Attributes != nil && md.Attributes.Key == check.ID {
result, err = strconv.ParseInt(md.Attributes.OwnerID, 10, 64)
if err != nil {
return result, fmt.Errorf("failed to parse monitor ID %s to integer", md.Attributes.OwnerID)
}
return result, nil
}
}
if !metadata.HasNext() {
break // exit infinite loop
}
metadata, err = metadata.Next(b.client)
if err != nil {
return result, err
}
}
return result, nil
}
Loading
Loading