diff --git a/.github/workflows/test-go.yml b/.github/workflows/test-go.yml index 115688a..8afff03 100644 --- a/.github/workflows/test-go.yml +++ b/.github/workflows/test-go.yml @@ -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 diff --git a/.golangci.yml b/.golangci.yml index 2903bb3..611f2a1 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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 diff --git a/README.md b/README.md index 7d99273..5a6f6d9 100644 --- a/README.md +++ b/README.md @@ -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! @@ -61,44 +62,46 @@ USAGE: [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 diff --git a/cmd/main.go b/cmd/main.go index 948810f..8d96cd8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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" @@ -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", @@ -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", "", @@ -94,6 +101,8 @@ 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", @@ -101,6 +110,10 @@ func main() { 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, } @@ -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") @@ -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), ), diff --git a/internal/model/check.go b/internal/model/check.go index f5b1bf6..b842507 100644 --- a/internal/model/check.go +++ b/internal/model/check.go @@ -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) { diff --git a/internal/service/providers/betterstack/betterstack.go b/internal/service/providers/betterstack/betterstack.go new file mode 100644 index 0000000..0ac0de5 --- /dev/null +++ b/internal/service/providers/betterstack/betterstack.go @@ -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 +} diff --git a/internal/service/providers/betterstack/betterstack_test.go b/internal/service/providers/betterstack/betterstack_test.go new file mode 100644 index 0000000..3a2d4b7 --- /dev/null +++ b/internal/service/providers/betterstack/betterstack_test.go @@ -0,0 +1,170 @@ +package betterstack + +import ( + "context" + "os" + "testing" + "time" + + "github.com/PDOK/uptime-operator/internal/model" + "github.com/PDOK/uptime-operator/internal/service/providers" + "github.com/stretchr/testify/assert" +) + +// Test against production better stack API. Please supply BETTERSTACK_API_TOKEN. +// This test creates one check, updates it and then deletes the check. +func TestAgainstREALBetterStackAPI(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + wantErr bool + wantDelete bool + }{ + { + name: "Create check", + annotations: map[string]string{ + "uptime.pdok.nl/id": "3w2e9d804b2cd6bf18b8c0a6e1c04e46ac62b98c", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + "uptime.pdok.nl/tags": "tag1, tag2, TooLongTagOvEtj8xOzZmGPJNf5ZcGikHzTjAG55xvcWVymItA0O8Us9tq6fEAfRYeN6AODj2gwRRi5l", + "uptime.pdok.nl/request-headers": "key1:value1, key2:value2", + "uptime.pdok.nl/response-check-for-string-contains": "bla", + "uptime.pdok.nl/response-check-for-string-not-contains": "", + }, + }, + { + name: "Update check", + annotations: map[string]string{ + "uptime.pdok.nl/id": "3w2e9d804b2cd6bf18b8c0a6e1c04e46ac62b98c", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck - Updated", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + "uptime.pdok.nl/tags": "tag1", + "uptime.pdok.nl/request-headers": "key1:value1, key2:value2, key3:value3", + "uptime.pdok.nl/response-check-for-string-contains": "", + "uptime.pdok.nl/response-check-for-string-not-contains": "", + }, + }, + { + name: "Update check again (test for idempotency)", + annotations: map[string]string{ + "uptime.pdok.nl/id": "3w2e9d804b2cd6bf18b8c0a6e1c04e46ac62b98c", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck - Updated", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + "uptime.pdok.nl/tags": "tag1", + "uptime.pdok.nl/request-headers": "key1:value1, key2:value2, key3:value3", + "uptime.pdok.nl/response-check-for-string-contains": "", + "uptime.pdok.nl/response-check-for-string-not-contains": "", + }, + }, + { + name: "Delete check", + annotations: map[string]string{ + "uptime.pdok.nl/id": "3w2e9d804b2cd6bf18b8c0a6e1c04e46ac62b98c", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck - Updated", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + "uptime.pdok.nl/tags": "tag1", + "uptime.pdok.nl/request-headers": "key1:value1, key2:value2, key3:value3", + "uptime.pdok.nl/response-check-for-string-contains": "bladiebla", + "uptime.pdok.nl/response-check-for-string-not-contains": "", + }, + wantDelete: true, + }, + } + for _, tt := range tests { + runIntegrationTest(t, tt) + } +} + +// Test against production better stack API. Please supply BETTERSTACK_API_TOKEN. +// This test creates one check, updates it and then deletes the check. +func TestAgainstREALBetterStackAPI_WithPagination(t *testing.T) { + tests := []struct { + name string + annotations map[string]string + wantErr bool + wantDelete bool + }{ + { + name: "Create check 1", + annotations: map[string]string{ + "uptime.pdok.nl/id": "cf67b916-b752-47f8-a7b9-16b75267f8ca", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck_1", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + }, + }, + { + name: "Create check 2", + annotations: map[string]string{ + "uptime.pdok.nl/id": "fd3316cc-b0fc-4304-b316-ccb0fc23046f", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck_2", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + }, + }, + { + name: "Update check 2", + annotations: map[string]string{ + "uptime.pdok.nl/id": "fd3316cc-b0fc-4304-b316-ccb0fc23046f", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck_2_Updated", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wms/v1_0?request=GetCapabilities&service=WMS", + }, + }, + { + name: "Delete check 1", + annotations: map[string]string{ + "uptime.pdok.nl/id": "cf67b916-b752-47f8-a7b9-16b75267f8ca", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck_1", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + }, + wantDelete: true, + }, + { + name: "Delete check 2", + annotations: map[string]string{ + "uptime.pdok.nl/id": "fd3316cc-b0fc-4304-b316-ccb0fc23046f", + "uptime.pdok.nl/name": "UptimeOperatorBetterStackTestCheck_2", + "uptime.pdok.nl/url": "https://service.pdok.nl/cbs/landuse/wfs/v1_0?request=GetCapabilities&service=WFS", + }, + wantDelete: true, + }, + } + for _, tt := range tests { + runIntegrationTest(t, tt) + } +} + +func runIntegrationTest(t *testing.T, tt struct { + name string + annotations map[string]string + wantErr bool + wantDelete bool +}) { + if os.Getenv("BETTERSTACK_API_TOKEN") == "" { + t.Skip("skipping test. BETTERSTACK_API_TOKEN is required to run this " + + "integration test against the REAL Better Stack API.") + } + t.Run(tt.name, func(t *testing.T) { + settings := Settings{APIToken: os.Getenv("BETTERSTACK_API_TOKEN"), PageSize: 1} + + // create/update/delete actual check with REAL Better Stack API. + m := New(settings) + check, err := model.NewUptimeCheck("foo", tt.annotations) + assert.NoError(t, err) + if tt.wantDelete { + if err := m.DeleteCheck(context.TODO(), *check); (err != nil) != tt.wantErr { + t.Errorf("DeleteCheck() error = %v, wantErr %v", err, tt.wantErr) + } + // give Better Stack some time to process the api call, just in case + time.Sleep(5 * time.Second) + + existingCheckID, err := m.findCheck(*check) + assert.NoError(t, err) + assert.Equal(t, providers.CheckNotFound, existingCheckID) + } else { + if err := m.CreateOrUpdateCheck(context.TODO(), *check); (err != nil) != tt.wantErr { + t.Errorf("CreateOrUpdateCheck() error = %v, wantErr %v", err, tt.wantErr) + } + // give Better Stack some time to process the api call, just in case + time.Sleep(5 * time.Second) + } + }) +} diff --git a/internal/service/providers/betterstack/client.go b/internal/service/providers/betterstack/client.go new file mode 100644 index 0000000..62f3c25 --- /dev/null +++ b/internal/service/providers/betterstack/client.go @@ -0,0 +1,376 @@ +package betterstack + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/PDOK/uptime-operator/internal/model" + p "github.com/PDOK/uptime-operator/internal/service/providers" +) + +const typeMonitor = "Monitor" + +type Client struct { + httpClient *http.Client + settings Settings +} + +func (h Client) execRequest(req *http.Request, expectedStatus int) (*http.Response, error) { + req.Header.Set(p.HeaderAuthorization, "Bearer "+h.settings.APIToken) + req.Header.Set(p.HeaderAccept, p.MediaTypeJSON) + req.Header.Set(p.HeaderContentType, p.MediaTypeJSON) + req.Header.Add(p.HeaderUserAgent, model.OperatorName) + + resp, err := h.httpClient.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode != expectedStatus { + defer resp.Body.Close() + result, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("got status %d, expected %d. Body: %b", resp.StatusCode, expectedStatus, result) + } + return resp, nil // caller should close resp.Body! +} + +func (h Client) execRequestIgnoreResponseBody(req *http.Request, expectedStatus int) error { + resp, err := h.execRequest(req, expectedStatus) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} + +type MetadataListResponse struct { + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes *struct { + Key string `json:"key"` + Values []struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"values"` + TeamName string `json:"team_name"` + OwnerID string `json:"owner_id"` + OwnerType string `json:"owner_type"` + } `json:"attributes"` + } `json:"data"` + Pagination *struct { + First string `json:"first"` + Last string `json:"last"` + Prev string `json:"prev"` + Next string `json:"next"` + } `json:"pagination"` +} + +// listMetadata https://betterstack.com/docs/uptime/api/list-all-existing-metadata/ +func (h Client) listMetadata() (*MetadataListResponse, error) { + url := fmt.Sprintf("%s/api/v3/metadata?owner_type=Monitor&per_page=%d", betterStackBaseURL, h.settings.PageSize) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + // Make initial HTTP request + resp, err := h.execRequest(req, http.StatusOK) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Parse response + var metadata MetadataListResponse + err = json.NewDecoder(resp.Body).Decode(&metadata) + if err != nil { + return nil, err + } + return &metadata, nil +} + +func (m MetadataListResponse) HasNext() bool { + return m.Pagination != nil && m.Pagination.Next != "" +} + +// Next paginate though metadata, see https://betterstack.com/docs/uptime/api/pagination/ +func (m MetadataListResponse) Next(client Client) (*MetadataListResponse, error) { + if !m.HasNext() { + return nil, nil + } + + // Make HTTP request to the next URL + req, err := http.NewRequest(http.MethodGet, m.Pagination.Next, nil) + if err != nil { + return nil, err + } + resp, err := client.execRequest(req, http.StatusOK) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Parse response + var nextPage MetadataListResponse + err = json.NewDecoder(resp.Body).Decode(&nextPage) + if err != nil { + return nil, err + } + return &nextPage, nil +} + +type MetadataValue struct { + Value string `json:"value"` +} + +type MetadataUpdateRequest struct { + Key string `json:"key"` + Values []MetadataValue `json:"values"` + OwnerID string `json:"owner_id"` + OwnerType string `json:"owner_type"` +} + +// createMetadata https://betterstack.com/docs/uptime/api/update-an-existing-metadata-record/ +func (h Client) createMetadata(key string, monitorID int64, tags []string) error { + metadataUpdateRequest := MetadataUpdateRequest{ + Key: key, + OwnerID: strconv.FormatInt(monitorID, 10), + OwnerType: typeMonitor, + } + for _, tag := range tags { + metadataUpdateRequest.Values = append(metadataUpdateRequest.Values, MetadataValue{tag}) + } + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&metadataUpdateRequest) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, betterStackBaseURL+"/api/v3/metadata", body) + if err != nil { + return err + } + if err := h.execRequestIgnoreResponseBody(req, http.StatusCreated); err != nil { + return err + } + return nil +} + +// updateMetadata https://betterstack.com/docs/uptime/api/update-an-existing-metadata-record/ +func (h Client) updateMetadata(key string, monitorID int64, tags []string) error { + metadataUpdateRequest := MetadataUpdateRequest{ + Key: key, + OwnerID: strconv.FormatInt(monitorID, 10), + OwnerType: typeMonitor, + } + for _, tag := range tags { + metadataUpdateRequest.Values = append(metadataUpdateRequest.Values, MetadataValue{tag}) + } + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&metadataUpdateRequest) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, betterStackBaseURL+"/api/v3/metadata", body) + if err != nil { + return err + } + if err = h.execRequestIgnoreResponseBody(req, http.StatusOK); err != nil { + return err + } + return nil +} + +// deleteMetadata https://betterstack.com/docs/uptime/api/update-an-existing-metadata-record/ +func (h Client) deleteMetadata(key string, monitorID int64) error { + metadataDeleteRequest := MetadataUpdateRequest{ + Key: key, + OwnerID: strconv.FormatInt(monitorID, 10), + OwnerType: typeMonitor, + Values: []MetadataValue{}, // empty values will result in delete of metadata record + } + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&metadataDeleteRequest) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, betterStackBaseURL+"/api/v3/metadata", body) + if err != nil { + return err + } + if err = h.execRequestIgnoreResponseBody(req, http.StatusNoContent); err != nil { + return err + } + return nil +} + +type MonitorRequestHeader struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Value string `json:"value"` + Destroy bool `json:"_destroy"` //nolint:tagliatelle +} + +type MonitorCreateOrUpdateRequest struct { + MonitorType string `json:"monitor_type"` + URL string `json:"url"` + PronounceableName string `json:"pronounceable_name"` + Port int `json:"port"` + Email bool `json:"email"` + Sms bool `json:"sms"` + Call bool `json:"call"` + RequiredKeyword string `json:"required_keyword"` + CheckFrequency int `json:"check_frequency"` + RequestHeaders []MonitorRequestHeader `json:"request_headers"` +} + +type MonitorCreateResponse struct { + Data struct { + ID string `json:"id"` + Type string `json:"type"` + } `json:"data"` +} + +// createMonitor https://betterstack.com/docs/uptime/api/create-a-new-monitor/ +func (h Client) createMonitor(check model.UptimeCheck) (int64, error) { + createRequest := checkToMonitor(check) + + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(createRequest) + if err != nil { + return -1, err + } + req, err := http.NewRequest(http.MethodPost, betterStackBaseURL+"/api/v2/monitors", body) + if err != nil { + return -1, err + } + resp, err := h.execRequest(req, http.StatusCreated) + if err != nil { + return -1, err + } + defer resp.Body.Close() + + var createResponse *MonitorCreateResponse + err = json.NewDecoder(resp.Body).Decode(&createResponse) + if err != nil { + return -1, err + } + monitorID, err := strconv.ParseInt(createResponse.Data.ID, 10, 64) + if err != nil { + return -1, err + } + return monitorID, nil +} + +// updateMonitor https://betterstack.com/docs/uptime/api/update-an-existing-monitor/ +func (h Client) updateMonitor(check model.UptimeCheck, existingMonitor *MonitorGetResponse) error { + updateRequest := checkToMonitor(check) + + if existingMonitor == nil || existingMonitor.Data == nil || existingMonitor.Data.Attributes == nil { + return fmt.Errorf("invalid monitor response, expected values are nil: %v", existingMonitor) + } + // Remove all existing headers (since the API works with HTTP PATCH, to avoid duplicate headers). + for _, existingHeader := range existingMonitor.Data.Attributes.RequestHeaders { + updateRequest.RequestHeaders = append(updateRequest.RequestHeaders, MonitorRequestHeader{ + ID: existingHeader.ID, + Destroy: true, + }) + } + body := &bytes.Buffer{} + err := json.NewEncoder(body).Encode(&updateRequest) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%s/api/v2/monitors/%s", betterStackBaseURL, existingMonitor.Data.ID), body) + if err != nil { + return err + } + if err = h.execRequestIgnoreResponseBody(req, http.StatusOK); err != nil { + return err + } + return nil +} + +// deleteMonitor https://betterstack.com/docs/uptime/api/delete-an-existing-monitor/ +func (h Client) deleteMonitor(monitorID int64) error { + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%s/api/v2/monitors/%d", betterStackBaseURL, monitorID), nil) + if err != nil { + return err + } + if err = h.execRequestIgnoreResponseBody(req, http.StatusNoContent); err != nil { + return err + } + return nil +} + +type MonitorGetResponse struct { + Data *struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes *struct { + URL string `json:"url"` + PronounceableName string `json:"pronounceable_name"` + MonitorType string `json:"monitor_type"` + RequiredKeyword string `json:"required_keyword"` + CheckFrequency int `json:"check_frequency"` + RequestHeaders []MonitorRequestHeader `json:"request_headers"` + } `json:"attributes"` + } `json:"data"` +} + +func (h Client) getMonitor(monitorID int64) (*MonitorGetResponse, error) { + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v2/monitors/%d", betterStackBaseURL, monitorID), nil) + if err != nil { + return nil, err + } + + resp, err := h.execRequest(req, http.StatusOK) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var existingMonitor *MonitorGetResponse + err = json.NewDecoder(resp.Body).Decode(&existingMonitor) + if err != nil { + return nil, err + } + return existingMonitor, nil +} + +func checkToMonitor(check model.UptimeCheck) MonitorCreateOrUpdateRequest { + var request MonitorCreateOrUpdateRequest + switch { + case check.StringContains != "": + request = MonitorCreateOrUpdateRequest{ + MonitorType: "keyword", + RequiredKeyword: check.StringContains, + } + case check.StringNotContains != "": + request = MonitorCreateOrUpdateRequest{ + MonitorType: "keyword_absence", + RequiredKeyword: check.StringNotContains, + } + default: + request = MonitorCreateOrUpdateRequest{ + MonitorType: "status", + } + } + request.URL = check.URL + request.PronounceableName = check.Name + request.Port = 443 + request.CheckFrequency = toSupportedInterval(check.Interval) + request.Email = false + request.Sms = false + request.Call = false + for name, value := range check.RequestHeaders { + request.RequestHeaders = append(request.RequestHeaders, MonitorRequestHeader{ + Name: name, + Value: value, + }) + } + return request +} diff --git a/internal/service/providers/betterstack/interval.go b/internal/service/providers/betterstack/interval.go new file mode 100644 index 0000000..d60b464 --- /dev/null +++ b/internal/service/providers/betterstack/interval.go @@ -0,0 +1,29 @@ +package betterstack + +import "math" + +func toSupportedInterval(intervalInMin int) int { + // Better Stack only accepts a specific sets of intervals + supportedIntervals := []int{30, 45, 60, 120, 180, 300, 600, 900, 1800} + + intervalInSec := intervalInMin * 60 + if intervalInSec <= 0 { + return supportedIntervals[0] // use the smallest supported interval + } + if intervalInSec > supportedIntervals[len(supportedIntervals)-1] { + return supportedIntervals[len(supportedIntervals)-1] // use the largest supported interval + } + + nearestInterval := supportedIntervals[0] + prevDiff := math.MaxInt + + // use nearest supported interval + for _, si := range supportedIntervals { + diff := int(math.Abs(float64(intervalInSec - si))) + if diff < prevDiff { + prevDiff = diff + nearestInterval = si + } + } + return nearestInterval +} diff --git a/internal/service/providers/betterstack/interval_test.go b/internal/service/providers/betterstack/interval_test.go new file mode 100644 index 0000000..39d41b3 --- /dev/null +++ b/internal/service/providers/betterstack/interval_test.go @@ -0,0 +1,39 @@ +package betterstack + +import ( + "testing" +) + +func TestToSupportedInterval(t *testing.T) { + tests := []struct { + name string + input int + expected int + }{ + {name: "ZeroInput", input: 0, expected: 30}, + {name: "GreaterThanMaxSupported", input: 31, expected: 1800}, + {name: "MuchGreaterThanMaxSupported", input: 1000, expected: 1800}, + {name: "ExactMatch_60s", input: 1, expected: 60}, + {name: "ExactMatch_120s", input: 2, expected: 120}, + {name: "ExactMatch_180s", input: 3, expected: 180}, + {name: "ExactMatch_300s", input: 5, expected: 300}, + {name: "ExactMatch_600s", input: 10, expected: 600}, + {name: "ExactMatch_900s", input: 15, expected: 900}, + {name: "ExactMatch_1800s", input: 30, expected: 1800}, + {name: "Rounding_240s_roundsTo_180s", input: 4, expected: 180}, + {name: "Rounding_360s_roundsTo_300s", input: 6, expected: 300}, + {name: "Rounding_420s_roundsTo_300s", input: 7, expected: 300}, + {name: "Rounding_480s_roundsTo_600s", input: 8, expected: 600}, + {name: "Rounding_960s_roundsTo_900s", input: 16, expected: 900}, + {name: "Rounding_1320s_roundsTo_900s", input: 22, expected: 900}, + {name: "Rounding_1380s_roundsTo_1800s", input: 23, expected: 1800}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := toSupportedInterval(tt.input) + if actual != tt.expected { + t.Errorf("toSupportedInterval(%d) => expected %d, got %d", tt.input, tt.expected, actual) + } + }) + } +} diff --git a/internal/service/providers/const.go b/internal/service/providers/const.go new file mode 100644 index 0000000..fd59243 --- /dev/null +++ b/internal/service/providers/const.go @@ -0,0 +1,20 @@ +package providers + +const ( + CheckNotFound = int64(-1) + MediaTypeJSON = "application/json" + + HeaderAuthorization = "Authorization" + HeaderAccept = "Accept" + HeaderContentType = "Content-Type" + HeaderUserAgent = "User-Agent" +) + +// UptimeProviderID enum of supported providers +type UptimeProviderID string + +const ( + ProviderPingdom UptimeProviderID = "pingdom" + ProviderBetterStack UptimeProviderID = "betterstack" + ProviderMock UptimeProviderID = "mock" +) diff --git a/internal/service/providers/mock.go b/internal/service/providers/mock/mock.go similarity index 63% rename from internal/service/providers/mock.go rename to internal/service/providers/mock/mock.go index 83f7890..28aea7d 100644 --- a/internal/service/providers/mock.go +++ b/internal/service/providers/mock/mock.go @@ -1,4 +1,4 @@ -package providers +package mock import ( "context" @@ -9,17 +9,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" ) -type MockUptimeProvider struct { +type Mock struct { checks map[string]model.UptimeCheck } -func NewMockUptimeProvider() *MockUptimeProvider { - return &MockUptimeProvider{ +func New() *Mock { + return &Mock{ checks: make(map[string]model.UptimeCheck), } } -func (m *MockUptimeProvider) CreateOrUpdateCheck(ctx context.Context, check model.UptimeCheck) error { +func (m *Mock) CreateOrUpdateCheck(ctx context.Context, check model.UptimeCheck) error { m.checks[check.ID] = check checkJSON, _ := json.Marshal(check) @@ -28,7 +28,7 @@ func (m *MockUptimeProvider) CreateOrUpdateCheck(ctx context.Context, check mode return nil } -func (m *MockUptimeProvider) DeleteCheck(ctx context.Context, check model.UptimeCheck) error { +func (m *Mock) DeleteCheck(ctx context.Context, check model.UptimeCheck) error { delete(m.checks, check.ID) checkJSON, _ := json.Marshal(check) diff --git a/internal/service/providers/pingdom.go b/internal/service/providers/pingdom/pingdom.go similarity index 74% rename from internal/service/providers/pingdom.go rename to internal/service/providers/pingdom/pingdom.go index 61efabc..cb94736 100644 --- a/internal/service/providers/pingdom.go +++ b/internal/service/providers/pingdom/pingdom.go @@ -1,4 +1,4 @@ -package providers +package pingdom import ( "bytes" @@ -16,64 +16,61 @@ import ( "time" "github.com/PDOK/uptime-operator/internal/model" + "github.com/PDOK/uptime-operator/internal/service/providers" "sigs.k8s.io/controller-runtime/pkg/log" ) const pingdomURL = "https://api.pingdom.com/api/3.1/checks" -const checkNotFound = int64(-1) const customIDPrefix = "id:" -const headerAuthorization = "Authorization" -const headerAccept = "Accept" -const headerContentType = "Content-Type" const headerReqLimitShort = "Req-Limit-Short" const headerReqLimitLong = "Req-Limit-Long" -type PingdomSettings struct { +type Settings struct { APIToken string UserIDs []int IntegrationIDs []int } -type PingdomUptimeProvider struct { - settings PingdomSettings +type Pingdom struct { + settings Settings httpClient *http.Client } -// NewPingdomUptimeProvider creates a PingdomUptimeProvider -func NewPingdomUptimeProvider(settings PingdomSettings) *PingdomUptimeProvider { +// New creates a Pingdom +func New(settings Settings) *Pingdom { if settings.APIToken == "" { classiclog.Fatal("Pingdom API token is not provided") } - return &PingdomUptimeProvider{ + return &Pingdom{ settings: settings, httpClient: &http.Client{Timeout: time.Duration(5) * time.Minute}, } } // CreateOrUpdateCheck create the given check with Pingdom, or update an existing check. Needs to be idempotent! -func (m *PingdomUptimeProvider) CreateOrUpdateCheck(ctx context.Context, check model.UptimeCheck) (err error) { - existingCheckID, err := m.findCheck(ctx, check) +func (p *Pingdom) CreateOrUpdateCheck(ctx context.Context, check model.UptimeCheck) (err error) { + existingCheckID, err := p.findCheck(ctx, check) if err != nil { return err } - if existingCheckID == checkNotFound { - err = m.createCheck(ctx, check) + if existingCheckID == providers.CheckNotFound { + err = p.createCheck(ctx, check) } else { - err = m.updateCheck(ctx, existingCheckID, check) + err = p.updateCheck(ctx, existingCheckID, check) } return err } // DeleteCheck deletes the given check from Pingdom -func (m *PingdomUptimeProvider) DeleteCheck(ctx context.Context, check model.UptimeCheck) error { +func (p *Pingdom) DeleteCheck(ctx context.Context, check model.UptimeCheck) error { log.FromContext(ctx).Info("deleting check", "check", check) - existingCheckID, err := m.findCheck(ctx, check) + existingCheckID, err := p.findCheck(ctx, check) if err != nil { return err } - if existingCheckID == checkNotFound { + if existingCheckID == providers.CheckNotFound { log.FromContext(ctx).Info(fmt.Sprintf("check with ID '%s' is already deleted", check.ID)) return nil } @@ -82,7 +79,7 @@ func (m *PingdomUptimeProvider) DeleteCheck(ctx context.Context, check model.Upt if err != nil { return err } - resp, err := m.execRequest(ctx, req) + resp, err := p.execRequest(ctx, req) if err != nil { return err } @@ -94,16 +91,16 @@ func (m *PingdomUptimeProvider) DeleteCheck(ctx context.Context, check model.Upt return nil } -func (m *PingdomUptimeProvider) findCheck(ctx context.Context, check model.UptimeCheck) (int64, error) { - result := checkNotFound +func (p *Pingdom) findCheck(ctx context.Context, check model.UptimeCheck) (int64, error) { + result := providers.CheckNotFound // list all checks managed by uptime-operator. Can be at most 25.000, which is probably sufficient. req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s?include_tags=true&limit=25000&tags=%s", pingdomURL, model.TagManagedBy), nil) if err != nil { return result, err } - req.Header.Add(headerAccept, "application/json") - resp, err := m.execRequest(ctx, req) + req.Header.Add(providers.HeaderAccept, providers.MediaTypeJSON) + resp, err := p.execRequest(ctx, req) if err != nil { return result, err } @@ -144,10 +141,10 @@ func (m *PingdomUptimeProvider) findCheck(ctx context.Context, check model.Uptim return result, nil } -func (m *PingdomUptimeProvider) createCheck(ctx context.Context, check model.UptimeCheck) error { +func (p *Pingdom) createCheck(ctx context.Context, check model.UptimeCheck) error { log.FromContext(ctx).Info("creating check", "check", check) - message, err := m.checkToJSON(check, true) + message, err := p.checkToJSON(check, true) if err != nil { return err } @@ -155,17 +152,17 @@ func (m *PingdomUptimeProvider) createCheck(ctx context.Context, check model.Upt if err != nil { return err } - err = m.execRequestWithBody(ctx, req) + err = p.execRequestWithBody(ctx, req) if err != nil { return err } return nil } -func (m *PingdomUptimeProvider) updateCheck(ctx context.Context, existingPingdomID int64, check model.UptimeCheck) error { +func (p *Pingdom) updateCheck(ctx context.Context, existingPingdomID int64, check model.UptimeCheck) error { log.FromContext(ctx).Info("updating check", "check", check, "pingdom ID", existingPingdomID) - message, err := m.checkToJSON(check, false) + message, err := p.checkToJSON(check, false) if err != nil { return err } @@ -173,14 +170,14 @@ func (m *PingdomUptimeProvider) updateCheck(ctx context.Context, existingPingdom if err != nil { return err } - err = m.execRequestWithBody(ctx, req) + err = p.execRequestWithBody(ctx, req) if err != nil { return err } return nil } -func (m *PingdomUptimeProvider) checkToJSON(check model.UptimeCheck, includeType bool) ([]byte, error) { +func (p *Pingdom) checkToJSON(check model.UptimeCheck, includeType bool) ([]byte, error) { checkURL, err := url.ParseRequestURI(check.URL) if err != nil { return nil, err @@ -220,11 +217,11 @@ func (m *PingdomUptimeProvider) checkToJSON(check model.UptimeCheck, includeType // update messages shouldn't include 'type', since the type of check can't be modified in Pingdom. message["type"] = "http" } - if len(m.settings.UserIDs) > 0 { - message["userids"] = m.settings.UserIDs + if len(p.settings.UserIDs) > 0 { + message["userids"] = p.settings.UserIDs } - if len(m.settings.IntegrationIDs) > 0 { - message["integrationids"] = m.settings.IntegrationIDs + if len(p.settings.IntegrationIDs) > 0 { + message["integrationids"] = p.settings.IntegrationIDs } // request header need to be submitted in numbered JSON keys @@ -248,9 +245,9 @@ func (m *PingdomUptimeProvider) checkToJSON(check model.UptimeCheck, includeType return json.Marshal(message) } -func (m *PingdomUptimeProvider) execRequestWithBody(ctx context.Context, req *http.Request) error { - req.Header.Add(headerContentType, "application/json") - resp, err := m.execRequest(ctx, req) +func (p *Pingdom) execRequestWithBody(ctx context.Context, req *http.Request) error { + req.Header.Add(providers.HeaderContentType, providers.MediaTypeJSON) + resp, err := p.execRequest(ctx, req) if err != nil { return err } @@ -262,9 +259,10 @@ func (m *PingdomUptimeProvider) execRequestWithBody(ctx context.Context, req *ht return nil } -func (m *PingdomUptimeProvider) execRequest(ctx context.Context, req *http.Request) (*http.Response, error) { - req.Header.Add(headerAuthorization, "Bearer "+m.settings.APIToken) - resp, err := m.httpClient.Do(req) +func (p *Pingdom) execRequest(ctx context.Context, req *http.Request) (*http.Response, error) { + req.Header.Add(providers.HeaderAuthorization, "Bearer "+p.settings.APIToken) + req.Header.Add(providers.HeaderUserAgent, model.OperatorName) + resp, err := p.httpClient.Do(req) if err != nil { return resp, err } diff --git a/internal/service/providers/pingdom_test.go b/internal/service/providers/pingdom/pingdom_test.go similarity index 95% rename from internal/service/providers/pingdom_test.go rename to internal/service/providers/pingdom/pingdom_test.go index bc438ab..320b468 100644 --- a/internal/service/providers/pingdom_test.go +++ b/internal/service/providers/pingdom/pingdom_test.go @@ -1,4 +1,4 @@ -package providers +package pingdom import ( "context" @@ -8,6 +8,7 @@ import ( "time" "github.com/PDOK/uptime-operator/internal/model" + "github.com/PDOK/uptime-operator/internal/service/providers" "github.com/stretchr/testify/assert" ) @@ -76,7 +77,7 @@ func TestAgainstREALPingdomAPI(t *testing.T) { "integration test against the REAL pingdom API.") } t.Run(tt.name, func(t *testing.T) { - settings := PingdomSettings{APIToken: os.Getenv("PINGDOM_API_TOKEN")} + settings := Settings{APIToken: os.Getenv("PINGDOM_API_TOKEN")} if os.Getenv("PINGDOM_USER_ID") != "" { userID, _ := strconv.Atoi(os.Getenv("PINGDOM_USER_ID")) @@ -88,7 +89,7 @@ func TestAgainstREALPingdomAPI(t *testing.T) { } // create/update/delete actual check with REAL pingdom API. - m := NewPingdomUptimeProvider(settings) + m := New(settings) check, err := model.NewUptimeCheck("foo", tt.annotations) assert.NoError(t, err) if tt.wantDelete { @@ -100,7 +101,7 @@ func TestAgainstREALPingdomAPI(t *testing.T) { existingCheckID, err := m.findCheck(context.TODO(), *check) assert.NoError(t, err) - assert.Equal(t, checkNotFound, existingCheckID) + assert.Equal(t, providers.CheckNotFound, existingCheckID) } else { if err := m.CreateOrUpdateCheck(context.TODO(), *check); (err != nil) != tt.wantErr { t.Errorf("CreateOrUpdateCheck() error = %v, wantErr %v", err, tt.wantErr) diff --git a/internal/service/service.go b/internal/service/service.go index b137222..1684ba9 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -6,7 +6,10 @@ import ( classiclog "log" m "github.com/PDOK/uptime-operator/internal/model" - "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/mock" + "github.com/PDOK/uptime-operator/internal/service/providers/pingdom" "sigs.k8s.io/controller-runtime/pkg/log" ) @@ -33,13 +36,15 @@ func WithProvider(provider UptimeProvider) UptimeCheckOption { } } -func WithProviderAndSettings(provider string, settings any) UptimeCheckOption { +func WithProviderAndSettings(provider p.UptimeProviderID, settings any) UptimeCheckOption { return func(service *UptimeCheckService) *UptimeCheckService { switch provider { - case "mock": - service.provider = providers.NewMockUptimeProvider() - case "pingdom": - service.provider = providers.NewPingdomUptimeProvider(settings.(providers.PingdomSettings)) + case p.ProviderMock: + service.provider = mock.New() + case p.ProviderPingdom: + service.provider = pingdom.New(settings.(pingdom.Settings)) + case p.ProviderBetterStack: + service.provider = betterstack.New(settings.(betterstack.Settings)) default: classiclog.Fatalf("unsupported provider specified: %s", provider) }