diff --git a/.goreleaser.yml b/.goreleaser.yml index 2b85537..ea4f8fd 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,8 +1,8 @@ -project_name: external-dns-stackit-webhook +project_name: external-dns-selectel-webhook snapshot: name_template: '{{ .Tag }}-SNAPSHOT' builds: - - id: external-dns-stackit-webhook + - id: external-dns-selectel-webhook goos: - linux - windows @@ -11,7 +11,7 @@ builds: - amd64 - arm64 main: ./cmd/webhook - binary: external-dns-stackit-webhook + binary: external-dns-selectel-webhook env: - CGO_ENABLED=0 ldflags: @@ -36,7 +36,7 @@ archives: - goos: windows format: zip dockers: - - id: external-dns-stackit-webhook + - id: external-dns-selectel-webhook use: buildx image_templates: - "{{ .Env.REGISTRY }}/{{ .Env.IMAGE_NAME }}:{{ .Tag }}" @@ -46,7 +46,7 @@ dockers: goarch: amd64 build_flag_templates: - --label=org.opencontainers.image.title={{ .ProjectName }} - - --label=org.opencontainers.image.description=stackit DNS webhook for external-dns + - --label=org.opencontainers.image.description=selectel DNS webhook for external-dns - --label=org.opencontainers.image.url=https://{{ .Env.GITHUB_SERVER_URL }}/{{ .Env.GITHUB_REPOSITORY}} - --label=org.opencontainers.image.source=https://{{ .Env.GITHUB_SERVER_URL }}/{{ .Env.GITHUB_REPOSITORY}} - --label=org.opencontainers.image.version={{ .Version }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd41e9b..4e13acf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,18 +1,15 @@ -# Contributing to External DNS STACKIT Webhook +# Contributing to External DNS Selectel Webhook -Welcome and thank you for making it this far and considering contributing to external-dns-stackit-webhook. +Welcome and thank you for making it this far and considering contributing to external-dns-selectel-webhook. We always appreciate any contributions by raising issues, improving the documentation, fixing bugs in the CLI or adding new features. Before opening a PR please read through this document. -If you want to contribute but don't know how to start or have any questions feel free to reach out to us on [Github Discussions](https://github.com/stackitcloud/external-dns-stackit-webhook/discussions). Answering any questions or discussions there is also a great way to contribute to the community. ## Process of making an addition -> Please keep in mind to open an issue whenever you plan to make an addition to features to discuss it before implementing it. - To contribute any code to this repository just do the following: -1. Make sure you have Go's latest version installed +1. Make sure you have a Go version installed that is similar to the version of this project 2. Fork this repository 3. Run `make build` to make sure everything's setup correctly 4. Make your changes @@ -28,10 +25,10 @@ To contribute any code to this repository just do the following: > If any linting issues occur please fix them. > Using a nolint directive should only be used as a last resort. 6. Open a PR and make sure the CI pipelines succeed. - > Your PR needs to have a semantic title, which can look like: `type(scope) Short Description` - > All available `scopes` & `types` are defined in [semantic.yml](https://github.com/stackitcloud/external-dns-stackit-webhook/blob/main/.github/semantic.yml) + > Your PR needs to have a semantic title, which can look like: `type(scope) Short description` + > All available `scopes` & `types` are defined in [semantic.yml](https://github.com/selectel/external-dns-selectel-webhook/blob/main/.github/semantic.yml) > - > A example PR tile for adding a new feature for the CLI would looks like: `cli(feat) Add saving output to file` -7. Wait for one of the maintainers to review your code and react to the comments. -8. After approval merge the PR + > An example PR tile for adding a new feature for the CLI would look like: `cli(feat) Add saving output to file` +7. Wait for two of the maintainers to review your code and react to the comments. +8. After two approvals merge the PR 9. Thank you for your contribution! :) diff --git a/Dockerfile b/Dockerfile index 602ced9..928ff73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ FROM gcr.io/distroless/static-debian11:nonroot -COPY external-dns-stackit-webhook /external-dns-stackit-webhook +COPY external-dns-selectel-webhook /external-dns-selectel-webhook -ENTRYPOINT ["/external-dns-stackit-webhook"] +ENTRYPOINT ["/external-dns-selectel-webhook"] diff --git a/Makefile b/Makefile index 30d5f4b..6a27634 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ GOLANGCI_VERSION = 1.53.3 LICENCES_IGNORE_LIST = $(shell cat licenses/licenses-ignore-list.txt) VERSION ?= 0.0.1 -IMAGE_TAG_BASE ?= stackitcloud/external-dns-stackit-webhook +IMAGE_TAG_BASE ?= selectel/external-dns-selectel-webhook IMG ?= $(IMAGE_TAG_BASE):$(VERSION) BUILD_VERSION ?= $(shell git branch --show-current) @@ -17,7 +17,7 @@ download: .PHONY: build build: - CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/external-dns-stackit-webhook -v cmd/webhook/main.go + CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/external-dns-selectel-webhook -v cmd/webhook/main.go .PHONY: docker-build docker-build: @@ -67,7 +67,7 @@ $(GO_RELEASER): .PHONY: release-check release-check: $(GO_RELEASER) ## Check if the release will work - GITHUB_SERVER_URL=github.com GITHUB_REPOSITORY=stackitcloud/external-dns-stackit-webhook REGISTRY=$(REGISTRY) IMAGE_NAME=$(IMAGE_NAME) $(GO_RELEASER) release --snapshot --clean --skip-publish + GITHUB_SERVER_URL=github.com GITHUB_REPOSITORY=selectel/external-dns-selectel-webhook REGISTRY=$(REGISTRY) IMAGE_NAME=$(IMAGE_NAME) $(GO_RELEASER) release --snapshot --clean --skip-publish GO_LICENSES = bin/go-licenses $(GO_LICENSES): diff --git a/README.md b/README.md index 412a8fa..ec96a6b 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,36 @@ -# STACKIT Webhook - ExternalDNS +# Selectel Webhook - ExternalDNS [![GoTemplate](https://img.shields.io/badge/go/template-black?logo=go)](https://github.com/golang-standards/project-layout) -[![CI](https://github.com/stackitcloud/external-dns-stackit-webhook/actions/workflows/main.yml/badge.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/actions/workflows/main.yml) -[![Go Report Card](https://goreportcard.com/badge/github.com/stackitcloud/external-dns-stackit-webhook)](https://goreportcard.com/report/github.com/stackitcloud/external-dns-stackit-webhook) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![GitHub release](https://img.shields.io/github/release/stackitcloud/external-dns-stackit-webhook.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/releases) -[![Last Commit](https://img.shields.io/github/last-commit/stackitcloud/external-dns-stackit-webhook/main.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/commits/main) -[![GitHub issues](https://img.shields.io/github/issues/stackitcloud/external-dns-stackit-webhook.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/issues) -[![GitHub pull requests](https://img.shields.io/github/issues-pr/stackitcloud/external-dns-stackit-webhook.svg)](https://github.com/stackitcloud/external-dns-stackit-webhook/pulls) -[![GitHub stars](https://img.shields.io/github/stars/stackitcloud/external-dns-stackit-webhook.svg?style=social&label=Star&maxAge=2592000)](https://github.com/stackitcloud/external-dns-stackit-webhook/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/stackitcloud/external-dns-stackit-webhook.svg?style=social&label=Fork&maxAge=2592000)](https://github.com/stackitcloud/external-dns-stackit-webhook/network) +[![CI](https://github.com/selectel/external-dns-selectel-webhook/actions/workflows/main.yml/badge.svg)](https://github.com/selectel/external-dns-selectel-webhook/actions/workflows/main.yml) +[![Go Report Card](https://goreportcard.com/badge/github.com/selectel/external-dns-selectel-webhook)](https://goreportcard.com/report/github.com/selectel/external-dns-selectel-webhook) +[![GitHub release](https://img.shields.io/github/release/selectel/external-dns-selectel-webhook.svg)](https://github.com/selectel/external-dns-selectel-webhook/releases) +[![Last Commit](https://img.shields.io/github/last-commit/selectel/external-dns-selectel-webhook/main.svg)](https://github.com/selectel/external-dns-selectel-webhook/commits/main) +[![GitHub issues](https://img.shields.io/github/issues/selectel/external-dns-selectel-webhook.svg)](https://github.com/selectel/external-dns-selectel-webhook/issues) +[![GitHub pull requests](https://img.shields.io/github/issues-pr/selectel/external-dns-selectel-webhook.svg)](https://github.com/selectel/external-dns-selectel-webhook/pulls) +[![GitHub stars](https://img.shields.io/github/stars/selectel/external-dns-selectel-webhook.svg?style=social&label=Star&maxAge=2592000)](https://github.com/selectel/external-dns-selectel-webhook/stargazers) +[![GitHub forks](https://img.shields.io/github/forks/selectel/external-dns-selectel-webhook.svg?style=social&label=Fork&maxAge=2592000)](https://github.com/selectel/external-dns-selectel-webhook/network) ExternalDNS serves as an add-on for Kubernetes designed to automate the management of Domain Name System (DNS) records for Kubernetes services by utilizing various DNS providers. While Kubernetes traditionally manages DNS records internally, ExternalDNS augments this functionality by transferring the responsibility of DNS records -management to an external DNS provider such as STACKIT. Consequently, the STACKIT webhook enables the management -of your STACKIT domains within your Kubernetes cluster using +management to an external DNS provider such as Selectel. Consequently, the Selectel webhook enables the management +of your Selectel domains within your Kubernetes cluster using [ExternalDNS](https://github.com/kubernetes-sigs/external-dns). -For utilizing ExternalDNS with STACKIT, it is mandatory to establish a STACKIT project, a service account -within the project, generate an authentication token for the service account, authorize the service account -to create and read dns zones, and finally, establish a STACKIT zone. +For utilizing ExternalDNS with Selectel, it is mandatory to establish a Selectel project, a service account +within the project, and finally, establish a Selectel zone. ## Kubernetes Deployment -The STACKIT webhook is presented as a standard Open Container Initiative (OCI) image released in the -[GitHub container registry](https://github.com/stackitcloud/external-dns-stackit-webhook/pkgs/container/external-dns-stackit-webhook). +The Selectel webhook is presented as a standard Open Container Initiative (OCI) image released in the +[GitHub container registry](https://github.com/selectel/external-dns-selectel-webhook/pkgs/container/external-dns-selectel-webhook). The deployment is compatible with all Kubernetes-supported methods. The subsequent example demonstrates the deployment as a [sidecar container](https://kubernetes.io/docs/concepts/workloads/pods/#workload-resources-for-managing-pods) within the ExternalDNS pod. -```shell -# We create a Secret from an auth token. Alternatively, you can also -# use keys to authenticate the webhook - see "Authentication" below. -kubectl create secret generic external-dns-stackit-webhook --from-literal=auth-token='' +```shell +kubectl create secret generic external-dns-webhook --from-literal=password='' ``` ```shell @@ -178,10 +174,12 @@ spec: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 65534 - image: ghcr.io/stackitcloud/external-dns-stackit-webhook:v0.2.0 + image: ghcr.io/selectel/external-dns-selectel-webhook:v0.1.0 imagePullPolicy: IfNotPresent args: - --project-id=c158c736-0300-4044-95c4-b7d404279b35 # your project id + - --account-id=000000 # your account id + - --username=Username # your service user's name ports: - name: http protocol: TCP @@ -205,136 +203,46 @@ spec: successThreshold: 1 timeoutSeconds: 5 env: - - name: AUTH_TOKEN + - name: PASSWORD valueFrom: secretKeyRef: - name: external-dns-stackit-webhook - key: auth-token + name: external-dns-webhook + key: password EOF ``` ## Configuration -The configuration of the STACKIT webhook can be accomplished through command line arguments and environment variables. -Below are the options that are available. +The configuration of the Selectel webhook can be accomplished through command line arguments and environment variables. +Below are the options that are available in format `--cli-argument`/`ENVIRONMENT_VARIABLE`. -- `--project-id`/`PROJECT_ID` (required): Specifies the project id of the STACKIT project. -- `--auth-token`/`AUTH_TOKEN` (required if `auth-key-path` is not set): Defines the authentication token for the STACKIT API. Mutually exclusive with 'auth-key-path'. -- `--auth-key-path`/`AUTH_KEY_PATH` (required if `auth-token` is not set): Defines the file path of the service account key for the STACKIT API. Mutually exclusive with 'auth-token'. +- `--project-id`/`PROJECT_ID` (required): Specifies the project id to authorize. +- `--account-id`/`ACCOUNT_ID` (required): Specifies the account id to authorize. +- `--username`/`USERNAME` (required): Specifies the username of your service user to authorize. +- `--password`/`PASSWORD` (required): Specifies the password of your service user to authorize. - `--worker`/`WORKER` (optional): Specifies the number of workers to employ for querying the API. Given that we need to iterate over all zones and records, it can be parallelized. However, it is important to avoid setting this number excessively high to prevent receiving 429 rate limiting from the API (default 10). -- `--base-url`/`BASE_URL` (optional): Identifies the Base URL for utilizing the API ( - default "https://dns.api.stackit.cloud"). +- `--base-url`/`BASE_URL` (optional): Identifies the Base URL for utilizing the API + (default "https://api.selectel.ru/domains/v2"). The full list of Selectel API URLs you can + see [here](https://developers.selectel.ru/docs/control-panel/urls/). +- `--auth-url`/`AUTH_URL` (optional): Identifies the URL for utilizing the API to receive keystone-token + (default "https://cloud.api.selcloud.ru/identity/v3"). - `--api-port`/`API_PORT` (optional): Specifies the port to listen on (default 8888). - `--domain-filter`/`DOMAIN_FILER` (optional): Establishes a filter for DNS zone names (default []). - `--dry-run`/`DRY_RUN` (optional): Specifies whether to perform a dry run (default false). - `--log-level`/`LOG_LEVEL` (optional): Defines the log level (default "info"). Possible values are: debug, info, warn, error. -## FAQ - -### 1. Issue with Creating Service using External DNS Annotation - -If your zone is `example.runs.onstackit.cloud` and you're trying to create a service with the following external DNS -annotation: - - ```yaml - apiVersion: v1 - kind: Service - metadata: - annotations: - external-dns.alpha.kubernetes.io/hostname: example.runs.onstackit.cloud - labels: - app.kubernetes.io/name: ingress-nginx - app.kubernetes.io/instance: nginx - app.kubernetes.io/part-of: ingress-nginx - app.kubernetes.io/component: controller - name: nginx-ingress-controller - namespace: nginx-ingress-controller - spec: - type: LoadBalancer - externalTrafficPolicy: Local - ipFamilyPolicy: SingleStack - ipFamilies: - - IPv4 - ports: - - name: http - port: 80 - protocol: TCP - targetPort: http - - name: https - port: 443 - protocol: TCP - targetPort: https - selector: - app.kubernetes.io/component: controller - app.kubernetes.io/instance: nginx - app.kubernetes.io/name: ingress-nginx - ``` - -Why isn't it working? - -Answer: The External DNS will try to create a TXT record named `a-example.runs.onstackit.cloud`, which will fail -because you can't establish a record outside the zone. The solution is to use a name that's within the zone, such as -`nginx.example.runs.onstackit.cloud`. - -### 2. Issues with Creating Ingresses not in the Zone - -For a project containing the zone `example.runs.onstackit.cloud`, suppose you've created these two ingress: - - ```yaml - apiVersion: networking.k8s.io/v1 - kind: Ingress - metadata: - annotations: - ingress.kubernetes.io/rewrite-target: / - kubernetes.io/ingress.class: nginx - name: example-ingress-external-dns - namespace: default - spec: - rules: - - host: test.example.runs.onstackit.cloud - http: - paths: - - backend: - service: - name: example - port: - number: 80 - path: / - pathType: Prefix - - host: test.example.stackit.rocks - http: - paths: - - backend: - service: - name: example - port: - number: 80 - path: / - pathType: Prefix - ``` - -Why isn't it working? - -Answer: External DNS will attempt to establish a record set for `test.example.stackit.rocks`. As the zone -`example.stackit.rocks` isn't within the project, it'll fail. There are two potential fixes: - -- Incorporate the zone `example.stackit.rocks` into the project. -- Adjust the domain filter to `example.runs.onstackit.cloud` by setting the domain filter - flag `--domain-filter="example.runs.onstackit.cloud"`. This will exclude `test.example.stackit.rocks` and only - generate - the record set for `test.example.runs.onstackit.cloud`. - ## Development Run the app: ```bash -export BASE_URL="https://dns.api.stackit.cloud" export PROJECT_ID="c158c736-0300-4044-95c4-b7d404279b35" -export AUTH_TOKEN="your-auth-token" +export ACCOUNT_ID="123456" +export USERNAME="username" +export PASSWORD ="password" make run ``` diff --git a/cmd/webhook/cmd/root.go b/cmd/webhook/cmd/root.go index 71b2042..6300ed8 100644 --- a/cmd/webhook/cmd/root.go +++ b/cmd/webhook/cmd/root.go @@ -5,67 +5,74 @@ import ( "log" "strings" + "github.com/selectel/external-dns-selectel-webhook/internal/selprovider" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + "github.com/selectel/external-dns-selectel-webhook/pkg/keystone" + "github.com/selectel/external-dns-selectel-webhook/pkg/metrics" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" - "github.com/stackitcloud/external-dns-stackit-webhook/internal/stackitprovider" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/stackit" "go.uber.org/zap" "go.uber.org/zap/zapcore" "sigs.k8s.io/external-dns/endpoint" ) var ( - apiPort string - authBearerToken string - authKeyPath string - baseUrl string - projectID string - worker int - domainFilter []string - dryRun bool - logLevel string + authorizationURL string + accountID string + username string + password string + projectID string + apiPort string + baseURL string + worker int + domainFilter []string + dryRun bool + logLevel string +) + +const ( + // DefaultAuthURL represents default endpoint where keystone-token should be retrieved. + DefaultAuthURL = "https://cloud.api.selcloud.ru/identity/v3" + // DefaultDomainsURL represents default endpoint of Selectel DNS API. + DefaultDomainsURL = "https://api.selectel.ru/domains/v2" ) var rootCmd = &cobra.Command{ - Use: "external-dns-stackit-webhook", - Short: "provider webhook for the STACKIT DNS service", - Long: "provider webhook for the STACKIT DNS service", + Use: "external-dns-selectel-webhook", + Short: "provider webhook for the Selectel DNS service", + Long: "provider webhook for the Selectel DNS service", Run: func(cmd *cobra.Command, args []string) { logger := getLogger() defer func(logger *zap.Logger) { err := logger.Sync() if err != nil { - log.Println(err) + log.Printf("synchronization of logs failed with error: %v", err) } }(logger) endpointDomainFilter := endpoint.DomainFilter{Filters: domainFilter} - stackitConfigOptions, err := stackit.SetConfigOptions(baseUrl, authBearerToken, authKeyPath) - if err != nil { - panic(err) - } - - stackitProvider, err := stackitprovider.NewStackitDNSProvider( - logger.With(zap.String("component", "stackitprovider")), - // ExternalDNS provider config - stackitprovider.Config{ - ProjectId: projectID, - DomainFilter: endpointDomainFilter, - DryRun: dryRun, - Workers: worker, - }, - // STACKIT client SDK config - stackitConfigOptions..., - ) + keystoneProvider := keystone.NewProvider(logger, keystone.Credentials{ + IdentityEndpoint: authorizationURL, + AccountID: accountID, + ProjectID: projectID, + Username: username, + Password: password, + }) + + selProvider, err := selprovider.New(selprovider.Config{ + BaseURL: baseURL, + KeystoneProvider: keystoneProvider, + DomainFilter: endpointDomainFilter, + DryRun: dryRun, + Workers: worker, + }, logger.With(zap.String("component", "selprovider"))) if err != nil { panic(err) } - app := api.New(logger.With(zap.String("component", "api")), metrics.NewHttpApiMetrics(), stackitProvider) + app := api.New(logger.With(zap.String("component", "api")), metrics.NewHttpApiMetrics(), selProvider) err = app.Listen(apiPort) if err != nil { panic(err) @@ -78,6 +85,7 @@ func getLogger() *zap.Logger { Level: zap.NewAtomicLevelAt(getZapLogLevel()), Encoding: "json", // or "console" // ... other zap configuration as needed + EncoderConfig: zap.NewProductionEncoderConfig(), OutputPaths: []string{"stdout"}, ErrorOutputPaths: []string{"stderr"}, } @@ -113,17 +121,19 @@ func init() { cobra.OnInitialize(initConfig) rootCmd.PersistentFlags().StringVar(&apiPort, "api-port", "8888", "Specifies the port to listen on.") - rootCmd.PersistentFlags().StringVar(&authBearerToken, "auth-token", "", "Defines the authentication token for the STACKIT API. Mutually exclusive with 'auth-key-path'.") - rootCmd.PersistentFlags().StringVar(&authKeyPath, "auth-key-path", "", "Defines the file path of the service account key for the STACKIT API. Mutually exclusive with 'auth-token'.") - rootCmd.PersistentFlags().StringVar(&baseUrl, "base-url", "https://dns.api.stackit.cloud", " Identifies the Base URL for utilizing the API.") - rootCmd.PersistentFlags().StringVar(&projectID, "project-id", "", "Specifies the project id of the STACKIT project.") + rootCmd.PersistentFlags().StringVar(&baseURL, "base-url", DefaultDomainsURL, "Identifies the Base URL for utilizing the API.") + rootCmd.PersistentFlags().StringVar(&projectID, "project-id", "", "Specifies the project id to authorize.") + rootCmd.PersistentFlags().StringVar(&accountID, "account-id", "", "Specifies the account id to authorize.") + rootCmd.PersistentFlags().StringVar(&authorizationURL, "auth-url", DefaultAuthURL, "Identifies the URL for utilizing the API to receive keystone-token.") + rootCmd.PersistentFlags().StringVar(&username, "username", "", "Specifies the username of service user to authorize.") + rootCmd.PersistentFlags().StringVar(&password, "password", "", "Specifies the password of service user to authorize.") rootCmd.PersistentFlags().IntVar(&worker, "worker", 10, "Specifies the number "+ "of workers to employ for querying the API. Given that we need to iterate over all zones and "+ "records, it can be parallelized. However, it is important to avoid setting this number "+ "excessively high to prevent receiving 429 rate limiting from the API.") - rootCmd.PersistentFlags().StringArrayVar(&domainFilter, "domain-filter", []string{}, "Establishes a filter for DNS zone names") + rootCmd.PersistentFlags().StringArrayVar(&domainFilter, "domain-filter", []string{}, "Establishes a filter for DNS zone names.") rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Specifies whether to perform a dry run.") - rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Specifies the log level. Possible values are: debug, info, warn, error") + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "Specifies the log level. Possible values are: debug, info, warn, error.") } func initConfig() { diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index 194411f..90fd822 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -1,6 +1,6 @@ package main -import "github.com/stackitcloud/external-dns-stackit-webhook/cmd/webhook/cmd" +import "github.com/selectel/external-dns-selectel-webhook/cmd/webhook/cmd" func main() { err := cmd.Execute() diff --git a/go.mod b/go.mod index 2879129..edfa29c 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ -module github.com/stackitcloud/external-dns-stackit-webhook +module github.com/selectel/external-dns-selectel-webhook go 1.20 require ( github.com/goccy/go-json v0.10.2 github.com/gofiber/adaptor/v2 v2.2.1 - github.com/gofiber/fiber/v2 v2.50.0 + github.com/gofiber/fiber/v2 v2.52.2 + github.com/gophercloud/gophercloud v1.11.0 github.com/prometheus/client_golang v1.17.0 + github.com/selectel/domains-go v1.0.2 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.17.0 - github.com/stackitcloud/stackit-sdk-go/core v0.10.0 - github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.4 github.com/stretchr/testify v1.8.4 go.uber.org/mock v0.3.0 go.uber.org/zap v1.26.0 @@ -27,7 +27,6 @@ require ( github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/go-logr/logr v1.2.4 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.0 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect @@ -39,7 +38,7 @@ require ( github.com/klauspost/compress v1.17.1 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect @@ -60,14 +59,14 @@ require ( github.com/spf13/cast v1.5.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.50.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect + golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.13.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 9be4b2e..a2172de 100644 --- a/go.sum +++ b/go.sum @@ -78,12 +78,10 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/gofiber/adaptor/v2 v2.2.1 h1:givE7iViQWlsTR4Jh7tB4iXzrlKBgiraB/yTdHs9Lv4= github.com/gofiber/adaptor/v2 v2.2.1/go.mod h1:AhR16dEqs25W2FY/l8gSj1b51Azg5dtPDmm+pruNOrc= -github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw= -github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmhpimVHMimw= +github.com/gofiber/fiber/v2 v2.52.2 h1:b0rYH6b06Df+4NyrbdptQL8ifuxw/Tf2DgfkZkDaxEo= +github.com/gofiber/fiber/v2 v2.52.2/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= -github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -150,6 +148,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gophercloud/gophercloud v1.11.0 h1:ls0O747DIq1D8SUHc7r2vI8BFbMLeLFuENaAIfEx7OM= +github.com/gophercloud/gophercloud v1.11.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= @@ -158,6 +158,7 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -181,8 +182,8 @@ github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3v github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= @@ -221,6 +222,8 @@ github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9c github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/selectel/domains-go v1.0.2 h1:Si6iGaMnTFJxwiJVI50DOdZnwcxc87kqaWrVQYW0a4U= +github.com/selectel/domains-go v1.0.2/go.mod h1:SugRKfq4sTpnOHquslCpzda72wV8u0cMBHx0C0l+bzA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= @@ -235,10 +238,6 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= -github.com/stackitcloud/stackit-sdk-go/core v0.10.0 h1:IcY8xa/6wo8EhRE9mpCvz4EtTkkoiIa2ZwPHuc5zGyw= -github.com/stackitcloud/stackit-sdk-go/core v0.10.0/go.mod h1:B5dkVm2HlBRG7liBVIFNqncDb6TUHnJ7t0GsKhAFuRk= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.4 h1:n/X2pVdETDXGHk+vCsg0p3b2zGxSRMJ065to/aAoncg= -github.com/stackitcloud/stackit-sdk-go/services/dns v0.8.4/go.mod h1:PvgUVFLgELRADWk2epZdCryk0fs8b4DN47ghEJjNWhk= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -255,8 +254,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M= -github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -285,6 +284,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -419,8 +419,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -582,8 +582,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/selprovider/apply_changes.go b/internal/selprovider/apply_changes.go new file mode 100644 index 0000000..e90e13f --- /dev/null +++ b/internal/selprovider/apply_changes.go @@ -0,0 +1,252 @@ +package selprovider + +import ( + "context" + "fmt" + + domains "github.com/selectel/domains-go/pkg/v2" + "go.uber.org/zap" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" +) + +// ApplyChanges applies a given set of changes. +func (p *Provider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + client, err := p.getDomainsClient() + if err != nil { + return err + } + + // create rr set. POST /zones/{zoneId}/rrset + err = p.createRRSets(ctx, client, changes.Create) + if err != nil { + return err + } + + // update rr set. PATCH /zones/{zoneId}/rrset/{rrSetId} + err = p.updateRRSets(ctx, client, changes.UpdateNew) + if err != nil { + return err + } + + // delete rr set. DELETE /zones/{zoneId}/rrset/{rrSetId} + err = p.deleteRRSets(ctx, client, changes.Delete) + if err != nil { + return err + } + + return nil +} + +// createRRSets creates new record sets for the given endpoints that are in the creation field. +func (p *Provider) createRRSets( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + endpoints []*endpoint.Endpoint, +) error { + if len(endpoints) == 0 { + return nil + } + + return p.handleRRSetWithWorkers(ctx, client, endpoints, CREATE) +} + +// updateRRSets patches (overrides) contents in the record sets for the given endpoints that are in the update new field. +func (p *Provider) updateRRSets( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + endpoints []*endpoint.Endpoint, +) error { + if len(endpoints) == 0 { + return nil + } + + return p.handleRRSetWithWorkers(ctx, client, endpoints, UPDATE) +} + +// deleteRRSets delete record sets for the given endpoints that are in the deletion field. +func (p *Provider) deleteRRSets( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + endpoints []*endpoint.Endpoint, +) error { + if len(endpoints) == 0 { + return nil + } + + p.logger.Info("records to delete", zap.String("records", fmt.Sprintf("%v", endpoints))) + + return p.handleRRSetWithWorkers(ctx, client, endpoints, DELETE) +} + +// handleRRSetWithWorkers handles the given endpoints with workers to optimize speed. +func (p *Provider) handleRRSetWithWorkers( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + endpoints []*endpoint.Endpoint, + action string, +) error { + zones, err := p.zoneFetcherClient.zones(ctx, client) + if err != nil { + return err + } + + workerChannel := make(chan changeTask, len(endpoints)) + defer close(workerChannel) + errorChannel := make(chan error, len(endpoints)) + + for i := 0; i < p.workers; i++ { + go p.changeWorker(ctx, client, workerChannel, errorChannel, zones) + } + + for _, change := range endpoints { + workerChannel <- changeTask{ + action: action, + change: change, + } + } + + for i := 0; i < len(endpoints); i++ { + err := <-errorChannel + if err != nil { + return err + } + } + + return nil +} + +// createRRSet creates a new record set for the given endpoint. +func (p *Provider) createRRSet( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + change *endpoint.Endpoint, + zones []*domains.Zone, +) error { + resultZone, found := findBestMatchingZone(change.DNSName, zones) + if !found { + return fmt.Errorf("no matching zone found for %s", change.DNSName) + } + + logFields := getLogFields(change, CREATE, resultZone.ID) + p.logger.Info("create record set", logFields...) + + if p.dryRun { + p.logger.Debug("dry run, skipping", logFields...) + + return nil + } + + modifyChange(change) + + rrSet := getRRSetRecord(change) + + // ignore all errors to just retry on next run + _, err := client.CreateRRSet(ctx, resultZone.ID, rrSet) + if err != nil { + p.logger.Error("error creating record set", zap.Error(err)) + + return err + } + + p.logger.Info("create record set successfully", logFields...) + + return nil +} + +// updateRRSet patches (overrides) contents in the record set. +func (p *Provider) updateRRSet( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + change *endpoint.Endpoint, + zones []*domains.Zone, +) error { + modifyChange(change) + + resultZone, resultRRSet, err := p.rrSetFetcherClient.getRRSetForUpdateDeletion(ctx, client, change, zones) + if err != nil { + return err + } + + logFields := getLogFields(change, UPDATE, resultRRSet.ID) + p.logger.Info("update record set", logFields...) + + if p.dryRun { + p.logger.Debug("dry run, skipping", logFields...) + + return nil + } + + rrSet := getRRSetRecord(change) + + err = client.UpdateRRSet(ctx, resultZone.ID, resultRRSet.ID, rrSet) + if err != nil { + p.logger.Error("error updating record set", zap.Error(err)) + + return err + } + + p.logger.Info("record set updated successfully", logFields...) + + return nil +} + +// deleteRRSet deletes a record set for the given endpoint. +func (p *Provider) deleteRRSet( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + change *endpoint.Endpoint, + zones []*domains.Zone, +) error { + modifyChange(change) + + resultZone, resultRRSet, err := p.rrSetFetcherClient.getRRSetForUpdateDeletion(ctx, client, change, zones) + if err != nil { + return err + } + + logFields := getLogFields(change, DELETE, resultRRSet.ID) + p.logger.Info("delete record set", logFields...) + + if p.dryRun { + p.logger.Debug("dry run, skipping", logFields...) + + return nil + } + + err = client.DeleteRRSet(ctx, resultZone.ID, resultRRSet.ID) + if err != nil { + p.logger.Error("error deleting record set", zap.Error(err)) + + return err + } + + p.logger.Info("delete record set successfully", logFields...) + + return nil +} + +// changeWorker is a worker that handles changes passed by a channel. +func (p *Provider) changeWorker( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + changes chan changeTask, + errorChannel chan error, + zones []*domains.Zone, +) { + for change := range changes { + switch change.action { + case CREATE: + err := p.createRRSet(ctx, client, change.change, zones) + errorChannel <- err + case UPDATE: + err := p.updateRRSet(ctx, client, change.change, zones) + errorChannel <- err + case DELETE: + err := p.deleteRRSet(ctx, client, change.change, zones) + errorChannel <- err + } + } + + p.logger.Debug("change worker finished") +} diff --git a/internal/stackitprovider/apply_changes_test.go b/internal/selprovider/apply_changes_test.go similarity index 77% rename from internal/stackitprovider/apply_changes_test.go rename to internal/selprovider/apply_changes_test.go index 769e72e..3279aed 100644 --- a/internal/stackitprovider/apply_changes_test.go +++ b/internal/selprovider/apply_changes_test.go @@ -1,4 +1,4 @@ -package stackitprovider +package selprovider import ( "context" @@ -7,7 +7,7 @@ import ( "net/http/httptest" "testing" - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" + domains "github.com/selectel/domains-go/pkg/v2" "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" @@ -63,13 +63,13 @@ func testApplyChanges(t *testing.T, changeType ChangeType) { defer server.Close() - stackitDnsProvider, err := getDefaultTestProvider(server) + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) assert.NoError(t, err) // Set up the changes according to the change type changes := getChangeTypeChanges(changeType) - err = stackitDnsProvider.ApplyChanges(ctx, changes) + err = dnsProvider.ApplyChanges(ctx, changes) if tt.expectErr { assert.Error(t, err) } else { @@ -92,7 +92,7 @@ func TestNoMatchingZoneFound(t *testing.T) { // Set up common endpoint for all types of changes setUpCommonEndpoints(mux, validZoneResponse, http.StatusOK) - stackitDnsProvider, err := getDefaultTestProvider(server) + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) assert.NoError(t, err) changes := &plan.Changes{ @@ -103,7 +103,7 @@ func TestNoMatchingZoneFound(t *testing.T) { Delete: []*endpoint.Endpoint{}, } - err = stackitDnsProvider.ApplyChanges(ctx, changes) + err = dnsProvider.ApplyChanges(ctx, changes) assert.Error(t, err) } @@ -113,8 +113,7 @@ func TestNoRRSetFound(t *testing.T) { ctx := context.Background() validZoneResponse := getValidResponseZoneAllBytes(t) rrSets := getValidResponseRRSetAll() - rrSet := *rrSets.RrSets - *rrSet[0].Name = "notfound.test.com" + rrSets.GetItems()[0].Name = "notfound.test.com" validRRSetResponse, err := json.Marshal(rrSets) assert.NoError(t, err) @@ -126,11 +125,11 @@ func TestNoRRSetFound(t *testing.T) { setUpCommonEndpoints(mux, validZoneResponse, http.StatusOK) mux.HandleFunc( - "/v1/projects/1234/zones/1234/rrsets", + "/zones/1234/rrset", responseHandler(validRRSetResponse, http.StatusOK), ) - stackitDnsProvider, err := getDefaultTestProvider(server) + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) assert.NoError(t, err) changes := &plan.Changes{ @@ -139,13 +138,13 @@ func TestNoRRSetFound(t *testing.T) { }, } - err = stackitDnsProvider.ApplyChanges(ctx, changes) + err = dnsProvider.ApplyChanges(ctx, changes) assert.Error(t, err) } // setUpCommonEndpoints for all change types. func setUpCommonEndpoints(mux *http.ServeMux, responseZone []byte, responseZoneCode int) { - mux.HandleFunc("/v1/projects/1234/zones", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(responseZoneCode) w.Write(responseZone) @@ -165,24 +164,45 @@ func setUpChangeTypeEndpoints( switch changeType { case Create: mux.HandleFunc( - "/v1/projects/1234/zones/1234/rrsets", + "/zones/1234/rrset", responseHandler(responseRrset, responseRrsetCode), ) - case Update, Delete: + case Update: + mux.HandleFunc( + "/zones/1234/rrset/1234", + responseHandler(responseRrset, responseRrsetCode), + ) + mux.HandleFunc( + "/zones/1234/rrset", + func(w http.ResponseWriter, r *http.Request) { + getRrsetsResponseRecords(t, w, "1234") + }, + ) mux.HandleFunc( - "/v1/projects/1234/zones/1234/rrsets/1234", - responseHandler(nil, responseRrsetCode), + "/zones/5678/rrset", + func(w http.ResponseWriter, r *http.Request) { + getRrsetsResponseRecords(t, w, "5678") + }, + ) + case Delete: + responseCode := responseRrsetCode + if responseCode == http.StatusOK { + responseCode = http.StatusNoContent + } + mux.HandleFunc( + "/zones/1234/rrset/1234", + responseHandler(nil, responseCode), ) mux.HandleFunc( - "/v1/projects/1234/zones/1234/rrsets", + "/zones/1234/rrset", func(w http.ResponseWriter, r *http.Request) { - getRrsetsResponseRecordsNonPaged(t, w, "1234") + getRrsetsResponseRecords(t, w, "1234") }, ) mux.HandleFunc( - "/v1/projects/1234/zones/5678/rrsets", + "/zones/5678/rrset", func(w http.ResponseWriter, r *http.Request) { - getRrsetsResponseRecordsNonPaged(t, w, "5678") + getRrsetsResponseRecords(t, w, "5678") }, ) } @@ -243,7 +263,7 @@ func getApplyChangesBasicTestCases( validZoneResponse, http.StatusOK, validRRSetResponse, - http.StatusAccepted, + http.StatusOK, false, http.MethodPost, }, @@ -317,15 +337,13 @@ func getValidResponseZoneAllBytes(t *testing.T) []byte { return validZoneResponse } -func getValidZoneResponseAll() stackitdnsclient.ListZonesResponse { - return stackitdnsclient.ListZonesResponse{ - ItemsPerPage: pointerTo(int64(10)), - Message: pointerTo("success"), - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(1)), - Zones: &[]stackitdnsclient.Zone{ - {Id: pointerTo("1234"), DnsName: pointerTo("test.com")}, - {Id: pointerTo("5678"), DnsName: pointerTo("test2.com")}, +func getValidZoneResponseAll() domains.List[domains.Zone] { + return domains.List[domains.Zone]{ + Count: 2, + NextOffset: 0, + Items: []*domains.Zone{ + {ID: "1234", Name: "test.com"}, + {ID: "5678", Name: "test2.com"}, }, } } @@ -340,21 +358,20 @@ func getValidResponseRRSetAllBytes(t *testing.T) []byte { return validRRSetResponse } -func getValidResponseRRSetAll() stackitdnsclient.ListRecordSetsResponse { - return stackitdnsclient.ListRecordSetsResponse{ - ItemsPerPage: pointerTo(int64(20)), - Message: pointerTo("success"), - RrSets: &[]stackitdnsclient.RecordSet{ +func getValidResponseRRSetAll() domains.List[domains.RRSet] { + return domains.List[domains.RRSet]{ + Count: 1, + NextOffset: 0, + Items: []*domains.RRSet{ { - Name: pointerTo("test.com"), - Type: pointerTo("A"), - Ttl: pointerTo(int64(300)), - Records: &[]stackitdnsclient.Record{ - {Content: pointerTo("1.2.3.4")}, + ID: "1234", + Name: "test.com.", + Type: "A", + TTL: 300, + Records: []domains.RecordItem{ + {Content: "1.2.3.4"}, }, }, }, - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(1)), } } diff --git a/internal/selprovider/config.go b/internal/selprovider/config.go new file mode 100644 index 0000000..2027557 --- /dev/null +++ b/internal/selprovider/config.go @@ -0,0 +1,24 @@ +package selprovider + +import ( + "sigs.k8s.io/external-dns/endpoint" +) + +// Config is used to configure the creation of the Provider. +type Config struct { + // BaseURL is a Selectel DNS API endpoint for v2.DNSClient + BaseURL string + // KeystoneProvider needed to generate X-Auth-Token header with keystone-header for requests to the DNS API. + KeystoneProvider KeystoneProvider + // DomainFilter is a list with domains that will be affected. If it is empty all available domains will be affected. + DomainFilter endpoint.DomainFilter + // DryRun is a flag specifies user's wish to run without requests to the DNS API + DryRun bool + // Workers is a number of goroutines that will create requests to the DNS API. + Workers int +} + +//go:generate mockgen -destination=./mock/keystone_provider.go -source=./config.go KeystoneProvider +type KeystoneProvider interface { + GetToken() (string, error) +} diff --git a/internal/selprovider/constants.go b/internal/selprovider/constants.go new file mode 100644 index 0000000..b3d6951 --- /dev/null +++ b/internal/selprovider/constants.go @@ -0,0 +1,11 @@ +package selprovider + +const ( + domainsOptionLimit = "limit" + domainsOptionOffset = "offset" + domainsOptionFilter = "filter" + domainsOptionName = "name" + + defaultDomainsLimit = "1000" + defaultDomainsOffset = "0" +) diff --git a/internal/selprovider/domain_filter.go b/internal/selprovider/domain_filter.go new file mode 100644 index 0000000..a1e73f1 --- /dev/null +++ b/internal/selprovider/domain_filter.go @@ -0,0 +1,7 @@ +package selprovider + +import "sigs.k8s.io/external-dns/endpoint" + +func (p *Provider) GetDomainFilter() endpoint.DomainFilter { + return p.domainFilter +} diff --git a/internal/stackitprovider/domain_filter_test.go b/internal/selprovider/domain_filter_test.go similarity index 65% rename from internal/stackitprovider/domain_filter_test.go rename to internal/selprovider/domain_filter_test.go index 0680e0d..a154356 100644 --- a/internal/stackitprovider/domain_filter_test.go +++ b/internal/selprovider/domain_filter_test.go @@ -1,4 +1,4 @@ -package stackitprovider +package selprovider import ( "testing" @@ -13,9 +13,9 @@ func TestGetDomainFilter(t *testing.T) { server := getServerRecords(t) defer server.Close() - stackitDnsProvider, err := getDefaultTestProvider(server) + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 0)) assert.NoError(t, err) - domainFilter := stackitDnsProvider.GetDomainFilter() + domainFilter := dnsProvider.GetDomainFilter() assert.Equal(t, domainFilter, endpoint.DomainFilter{}) } diff --git a/internal/selprovider/helper.go b/internal/selprovider/helper.go new file mode 100644 index 0000000..4ac5108 --- /dev/null +++ b/internal/selprovider/helper.go @@ -0,0 +1,86 @@ +package selprovider + +import ( + "strings" + + domains "github.com/selectel/domains-go/pkg/v2" + "go.uber.org/zap" + "sigs.k8s.io/external-dns/endpoint" +) + +// findBestMatchingZone finds the best matching zone for a given record set name. The criteria are +// that the zone name is contained in the record set name and that the zone name is the longest +// possible match. Eg foo.bar.com. would have prejudice over bar.com. if rr set name is foo.bar.com. +func findBestMatchingZone(rrSetName string, zones []*domains.Zone) (*domains.Zone, bool) { + count := 0 + var domainZone *domains.Zone + for _, zone := range zones { + if len(zone.Name) > count && strings.Contains(appendDotIfNotExists(rrSetName), zone.Name) { + count = len(zone.Name) + domainZone = zone + } + } + + if count == 0 { + return nil, false + } + + return domainZone, true +} + +// findRRSet finds a record set by name and type in a list of record sets. +func findRRSet(rrSetName, recordType string, rrSets []*domains.RRSet) (*domains.RRSet, bool) { + for _, rrSet := range rrSets { + if rrSet.Name == rrSetName && string(rrSet.Type) == recordType { + return rrSet, true + } + } + + return nil, false +} + +// appendDotIfNotExists appends a dot to the end of a string if it doesn't already end with a dot. +func appendDotIfNotExists(s string) string { + if !strings.HasSuffix(s, ".") { + return s + "." + } + + return s +} + +// modifyChange modifies a change to ensure it is valid for this provider. +func modifyChange(change *endpoint.Endpoint) { + change.DNSName = appendDotIfNotExists(change.DNSName) + + if change.RecordTTL == 0 { + change.RecordTTL = 300 + } +} + +// getRRSetRecord returns a v2.RRSet from a change for the api client. +func getRRSetRecord(change *endpoint.Endpoint) *domains.RRSet { + records := make([]domains.RecordItem, len(change.Targets)) + for i, target := range change.Targets { + records[i] = domains.RecordItem{ + Content: target, + } + } + + return &domains.RRSet{ + Name: change.DNSName, + Records: records, + TTL: int(change.RecordTTL), + Type: domains.RecordType(change.RecordType), + } +} + +// getLogFields returns a log.Fields object for a change. +func getLogFields(change *endpoint.Endpoint, action string, id string) []zap.Field { + return []zap.Field{ + zap.String("record", change.DNSName), + zap.String("content", strings.Join(change.Targets, ",")), + zap.String("type", change.RecordType), + zap.String("action", action), + zap.String("id", id), + } +} diff --git a/internal/stackitprovider/helper_test.go b/internal/selprovider/helper_test.go similarity index 65% rename from internal/stackitprovider/helper_test.go rename to internal/selprovider/helper_test.go index 4605cc1..c484268 100644 --- a/internal/stackitprovider/helper_test.go +++ b/internal/selprovider/helper_test.go @@ -1,10 +1,10 @@ -package stackitprovider +package selprovider import ( "reflect" "testing" - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" + domains "github.com/selectel/domains-go/pkg/v2" "go.uber.org/zap" "sigs.k8s.io/external-dns/endpoint" ) @@ -59,7 +59,7 @@ func TestModifyChange(t *testing.T) { } } -func TestGetStackitRRSetRecordPost(t *testing.T) { +func TestGetRRSetRecordPost(t *testing.T) { t.Parallel() change := &endpoint.Endpoint{ @@ -71,41 +71,41 @@ func TestGetStackitRRSetRecordPost(t *testing.T) { "192.0.2.2", }, } - expected := stackitdnsclient.CreateRecordSetPayload{ - Name: pointerTo("test."), - Ttl: pointerTo(int64(300)), - Type: pointerTo("A"), - Records: &[]stackitdnsclient.RecordPayload{ + expected := &domains.RRSet{ + Name: "test.", + TTL: 300, + Type: "A", + Records: []domains.RecordItem{ { - Content: pointerTo("192.0.2.1"), + Content: "192.0.2.1", }, { - Content: pointerTo("192.0.2.2"), + Content: "192.0.2.2", }, }, } - got := getStackitRecordSetPayload(change) + got := getRRSetRecord(change) if !reflect.DeepEqual(got, expected) { - t.Errorf("getStackitRRSetRecordPost() = %v, want %v", got, expected) + t.Errorf("getRRSetRecord() = %v, want %v", got, expected) } } func TestFindBestMatchingZone(t *testing.T) { t.Parallel() - zones := []stackitdnsclient.Zone{ - {DnsName: pointerTo("foo.com")}, - {DnsName: pointerTo("bar.com")}, - {DnsName: pointerTo("baz.com")}, + zones := []*domains.Zone{ + {Name: "foo.com"}, + {Name: "bar.com"}, + {Name: "baz.com"}, } tests := []struct { name string rrSetName string - want *stackitdnsclient.Zone + want *domains.Zone wantFound bool }{ - {"Matching Zone", "www.foo.com", &zones[0], true}, + {"Matching Zone", "www.foo.com", zones[0], true}, {"No Matching Zone", "www.test.com", nil, false}, } @@ -124,20 +124,20 @@ func TestFindBestMatchingZone(t *testing.T) { func TestFindRRSet(t *testing.T) { t.Parallel() - rrSets := []stackitdnsclient.RecordSet{ - {Name: pointerTo("www.foo.com"), Type: pointerTo("A")}, - {Name: pointerTo("www.bar.com"), Type: pointerTo("A")}, - {Name: pointerTo("www.baz.com"), Type: pointerTo("A")}, + rrSets := []*domains.RRSet{ + {Name: "www.foo.com", Type: "A"}, + {Name: "www.bar.com", Type: "A"}, + {Name: "www.baz.com", Type: "A"}, } tests := []struct { name string rrSetName string recordType string - want *stackitdnsclient.RecordSet + want *domains.RRSet wantFound bool }{ - {"Matching RRSet", "www.foo.com", "A", &rrSets[0], true}, + {"Matching RRSet", "www.foo.com", "A", rrSets[0], true}, {"No Matching RRSet", "www.test.com", "A", nil, false}, } @@ -181,36 +181,3 @@ func TestGetLogFields(t *testing.T) { t.Errorf("getLogFields() = %v, want %v", got, expected) } } - -func TestGetStackitRRSetRecordPatch(t *testing.T) { - t.Parallel() - - change := &endpoint.Endpoint{ - DNSName: "test.", - RecordTTL: endpoint.TTL(300), - RecordType: "A", - Targets: endpoint.Targets{ - "192.0.2.1", - "192.0.2.2", - }, - } - - expected := stackitdnsclient.PartialUpdateRecordSetPayload{ - Name: pointerTo("test."), - Ttl: pointerTo(int64(300)), - Records: &[]stackitdnsclient.RecordPayload{ - { - Content: pointerTo("192.0.2.1"), - }, - { - Content: pointerTo("192.0.2.2"), - }, - }, - } - - got := getStackitPartialUpdateRecordSetPayload(change) - - if !reflect.DeepEqual(got, expected) { - t.Errorf("getStackitRRSetRecordPatch() = %v, want %v", got, expected) - } -} diff --git a/internal/selprovider/mock/keystone_provider.go b/internal/selprovider/mock/keystone_provider.go new file mode 100644 index 0000000..aa724d0 --- /dev/null +++ b/internal/selprovider/mock/keystone_provider.go @@ -0,0 +1,54 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./config.go +// +// Generated by this command: +// +// mockgen -destination=./mock/keystone_provider.go -source=./config.go KeystoneProvider +// + +// Package mock_selprovider is a generated GoMock package. +package mock_selprovider + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockKeystoneProvider is a mock of KeystoneProvider interface. +type MockKeystoneProvider struct { + ctrl *gomock.Controller + recorder *MockKeystoneProviderMockRecorder +} + +// MockKeystoneProviderMockRecorder is the mock recorder for MockKeystoneProvider. +type MockKeystoneProviderMockRecorder struct { + mock *MockKeystoneProvider +} + +// NewMockKeystoneProvider creates a new mock instance. +func NewMockKeystoneProvider(ctrl *gomock.Controller) *MockKeystoneProvider { + mock := &MockKeystoneProvider{ctrl: ctrl} + mock.recorder = &MockKeystoneProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockKeystoneProvider) EXPECT() *MockKeystoneProviderMockRecorder { + return m.recorder +} + +// GetToken mocks base method. +func (m *MockKeystoneProvider) GetToken() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetToken") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetToken indicates an expected call of GetToken. +func (mr *MockKeystoneProviderMockRecorder) GetToken() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetToken", reflect.TypeOf((*MockKeystoneProvider)(nil).GetToken)) +} diff --git a/internal/stackitprovider/models.go b/internal/selprovider/models.go similarity index 72% rename from internal/stackitprovider/models.go rename to internal/selprovider/models.go index 07fe33c..645da47 100644 --- a/internal/stackitprovider/models.go +++ b/internal/selprovider/models.go @@ -1,4 +1,4 @@ -package stackitprovider +package selprovider import "sigs.k8s.io/external-dns/endpoint" @@ -8,11 +8,6 @@ const ( DELETE = "DELETE" ) -// ErrorMessage is the error message returned by the API. -type ErrorMessage struct { - Message string `json:"message"` -} - // changeTask is a task that is passed to the worker. type changeTask struct { change *endpoint.Endpoint diff --git a/internal/selprovider/provider.go b/internal/selprovider/provider.go new file mode 100644 index 0000000..0dbe0cb --- /dev/null +++ b/internal/selprovider/provider.go @@ -0,0 +1,52 @@ +package selprovider + +import ( + domains "github.com/selectel/domains-go/pkg/v2" + "github.com/selectel/external-dns-selectel-webhook/pkg/httpdefault" + "go.uber.org/zap" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/provider" +) + +// Provider implements the DNS provider interface for Selectel DNS. +type Provider struct { + provider.BaseProvider + domainFilter endpoint.DomainFilter + keystoneProvider KeystoneProvider + endpoint string + dryRun bool + workers int + logger *zap.Logger + zoneFetcherClient *zoneFetcher + rrSetFetcherClient *rrSetFetcher +} + +// getDomainsClient returns v2.DNSClient with provided keystone and user-agent from httpdefault.UserAgent. +func (p *Provider) getDomainsClient() (domains.DNSClient[domains.Zone, domains.RRSet], error) { + token, err := p.keystoneProvider.GetToken() + if err != nil { + p.logger.Error("failed to get keystone token", zap.Error(err)) + + return nil, err + } + + httpClient := httpdefault.Client() + headers := httpdefault.Headers() + headers.Add("X-Auth-Token", token) + + return domains.NewClient(p.endpoint, &httpClient, headers), nil +} + +// New creates a new Selectel DNS provider. +func New(config Config, logger *zap.Logger) (*Provider, error) { + return &Provider{ + domainFilter: config.DomainFilter, + dryRun: config.DryRun, + workers: config.Workers, + logger: logger, + keystoneProvider: config.KeystoneProvider, + endpoint: config.BaseURL, + zoneFetcherClient: newZoneFetcher(config.DomainFilter), + rrSetFetcherClient: newRRSetFetcher(config.DomainFilter, logger), + }, nil +} diff --git a/internal/selprovider/records.go b/internal/selprovider/records.go new file mode 100644 index 0000000..c10883a --- /dev/null +++ b/internal/selprovider/records.go @@ -0,0 +1,111 @@ +package selprovider + +import ( + "context" + + domains "github.com/selectel/domains-go/pkg/v2" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/provider" +) + +// Records returns resource records. +func (p *Provider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { + client, err := p.getDomainsClient() + if err != nil { + return nil, err + } + + zones, err := p.zoneFetcherClient.zones(ctx, client) + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + endpointsErrorChannel := make(chan endpointError, len(zones)) + zonesChan := make(chan string, len(zones)) + + for i := 0; i < p.workers; i++ { + go p.fetchRecordsWorker(ctx, client, zonesChan, endpointsErrorChannel) + } + + for _, zone := range zones { + zonesChan <- zone.ID + } + + for i := 0; i < len(zones); i++ { + endpointsErrorList := <-endpointsErrorChannel + if endpointsErrorList.err != nil { + close(zonesChan) + + return nil, endpointsErrorList.err + } + endpoints = append(endpoints, endpointsErrorList.endpoints...) + } + + close(zonesChan) + + return endpoints, nil +} + +// fetchRecordsWorker fetches all records from a given zone. +func (p *Provider) fetchRecordsWorker( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + zonesChan chan string, + endpointsErrorChan chan<- endpointError, +) { + for zoneID := range zonesChan { + p.processZoneRRSets(ctx, client, zoneID, endpointsErrorChan) + } + + p.logger.Debug("fetch record set worker finished") +} + +// processZoneRRSets fetches and processes DNS records for a given zone. +func (p *Provider) processZoneRRSets( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + zoneID string, + endpointsErrorChannel chan<- endpointError, +) { + var endpoints []*endpoint.Endpoint + rrSets, err := p.rrSetFetcherClient.fetchRecords(ctx, client, zoneID, map[string]string{}) + if err != nil { + endpointsErrorChannel <- endpointError{ + endpoints: nil, + err: err, + } + + return + } + + endpoints = p.collectEndPoints(rrSets) + endpointsErrorChannel <- endpointError{ + endpoints: endpoints, + err: nil, + } +} + +// collectEndPoints creates a list of Endpoints from the provided rrSets. +func (p *Provider) collectEndPoints( + rrSets []*domains.RRSet, +) []*endpoint.Endpoint { + var endpoints []*endpoint.Endpoint + for _, rrSet := range rrSets { + if provider.SupportedRecordType(string(rrSet.Type)) { + for _, rec := range rrSet.Records { + endpoints = append( + endpoints, + endpoint.NewEndpointWithTTL( + rrSet.Name, + string(rrSet.Type), + endpoint.TTL(rrSet.TTL), + rec.Content, + ), + ) + } + } + } + + return endpoints +} diff --git a/internal/selprovider/records_test.go b/internal/selprovider/records_test.go new file mode 100644 index 0000000..b02ff35 --- /dev/null +++ b/internal/selprovider/records_test.go @@ -0,0 +1,269 @@ +package selprovider + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/goccy/go-json" + domains "github.com/selectel/domains-go/pkg/v2" + mock_selprovider "github.com/selectel/external-dns-selectel-webhook/internal/selprovider/mock" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "go.uber.org/zap" + "sigs.k8s.io/external-dns/endpoint" +) + +func TestRecords(t *testing.T) { + t.Parallel() + + server := getServerRecords(t) + defer server.Close() + + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) + assert.NoError(t, err) + + endpoints, err := dnsProvider.Records(context.Background()) + assert.NoError(t, err) + assert.Equal(t, 2, len(endpoints)) + assert.Equal(t, "test.com", endpoints[0].DNSName) + assert.Equal(t, "A", endpoints[0].RecordType) + assert.Equal(t, "1.2.3.4", endpoints[0].Targets[0]) + assert.Equal(t, int64(300), int64(endpoints[0].RecordTTL)) + + assert.Equal(t, "test2.com", endpoints[1].DNSName) + assert.Equal(t, "A", endpoints[1].RecordType) + assert.Equal(t, "5.6.7.8", endpoints[1].Targets[0]) + assert.Equal(t, int64(300), int64(endpoints[1].RecordTTL)) +} + +// TestWrongJsonResponseRecords tests the scenario where the server returns a wrong JSON response. +func TestWrongJsonResponseRecords(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/zones", + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"invalid:"json"`)) // This is not a valid JSON. + }, + ) + defer server.Close() + + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) + assert.NoError(t, err) + + _, err = dnsProvider.Records(context.Background()) + assert.Error(t, err) +} + +func TestEmptyZonesRouteRecords(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) + assert.NoError(t, err) + + _, err = dnsProvider.Records(context.Background()) + assert.Error(t, err) +} + +func TestEmptyRRSetRouteRecords(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + mux.HandleFunc("/zones", + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + zones := domains.List[domains.Zone]{ + Count: 1, + NextOffset: 0, + Items: []*domains.Zone{{ + ID: "1234", + }}, + } + successResponseBytes, err := json.Marshal(zones) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + w.Write(successResponseBytes) + }, + ) + defer server.Close() + + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) + assert.NoError(t, err) + + _, err = dnsProvider.Records(context.Background()) + fmt.Println(err) + assert.Error(t, err) +} + +func TestZoneEndpoint500Records(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + mux.HandleFunc("/zones", + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusInternalServerError) + }, + ) + defer server.Close() + + dnsProvider, err := getDefaultTestProvider(server, getDefaultKeystoneProvider(t, 1)) + assert.NoError(t, err) + + _, err = dnsProvider.Records(context.Background()) + assert.Error(t, err) +} + +func TestZoneEndpoint403Records(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + mux.HandleFunc("/zones", + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusForbidden) + }, + ) + defer server.Close() + + dnsProvider, err := New(Config{ + BaseURL: server.URL, + DomainFilter: endpoint.DomainFilter{}, + KeystoneProvider: getDefaultKeystoneProvider(t, 1), + DryRun: false, + Workers: 10, + }, zap.NewNop()) + assert.NoError(t, err) + + _, err = dnsProvider.Records(context.Background()) + assert.Error(t, err) +} + +func getDefaultKeystoneProvider(t *testing.T, callTimes int) KeystoneProvider { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + p := mock_selprovider.NewMockKeystoneProvider(ctrl) + p.EXPECT().GetToken().Return("test", nil).Times(callTimes) + + return p +} + +func getDefaultTestProvider(server *httptest.Server, keystoneProvider KeystoneProvider) (*Provider, error) { + dnsProvider, err := New(Config{ + BaseURL: server.URL, + KeystoneProvider: keystoneProvider, + DomainFilter: endpoint.DomainFilter{}, + DryRun: false, + Workers: 1, + }, zap.NewNop()) + + return dnsProvider, err +} + +func getZonesResponseRecords(t *testing.T, w http.ResponseWriter) { + t.Helper() + + w.Header().Set("Content-Type", "application/json") + + zones := domains.List[domains.Zone]{ + Count: 2, + NextOffset: 0, + Items: []*domains.Zone{ + {ID: "1234", Name: "test.com"}, + {ID: "5678", Name: "test2.com"}, + }, + } + successResponseBytes, err := json.Marshal(zones) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + w.Write(successResponseBytes) +} + +func getRrsetsResponseRecords(t *testing.T, w http.ResponseWriter, domain string) { + t.Helper() + + w.Header().Set("Content-Type", "application/json") + + rrSets := domains.List[domains.RRSet]{} + if domain == "1234" { + rrSets = domains.List[domains.RRSet]{ + Count: 1, + NextOffset: 0, + Items: []*domains.RRSet{ + { + ID: "1234", + Name: "test.com.", + Type: "A", + TTL: 300, + Records: []domains.RecordItem{ + {Content: "1.2.3.4"}, + }, + }, + }, + } + } + if domain == "5678" { + rrSets = domains.List[domains.RRSet]{ + Count: 1, + NextOffset: 0, + Items: []*domains.RRSet{ + { + ID: "5678", + Name: "test2.com.", + Type: "A", + TTL: 300, + Records: []domains.RecordItem{ + {Content: "5.6.7.8"}, + }, + }, + }, + } + } + + successResponseBytes, err := json.Marshal(rrSets) + assert.NoError(t, err) + + w.WriteHeader(http.StatusOK) + w.Write(successResponseBytes) +} + +func getServerRecords(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + + mux.HandleFunc("/zones", func(w http.ResponseWriter, r *http.Request) { + getZonesResponseRecords(t, w) + }) + mux.HandleFunc("/zones/1234/rrset", func(w http.ResponseWriter, r *http.Request) { + getRrsetsResponseRecords(t, w, "1234") + }) + mux.HandleFunc("/zones/5678/rrset", func(w http.ResponseWriter, r *http.Request) { + getRrsetsResponseRecords(t, w, "5678") + }) + + return server +} diff --git a/internal/selprovider/rrset_fetcher.go b/internal/selprovider/rrset_fetcher.go new file mode 100644 index 0000000..eaf16a6 --- /dev/null +++ b/internal/selprovider/rrset_fetcher.go @@ -0,0 +1,89 @@ +package selprovider + +import ( + "context" + "fmt" + "strconv" + + domains "github.com/selectel/domains-go/pkg/v2" + "go.uber.org/zap" + "sigs.k8s.io/external-dns/endpoint" +) + +type rrSetFetcher struct { + domainFilter endpoint.DomainFilter + logger *zap.Logger +} + +func newRRSetFetcher( + domainFilter endpoint.DomainFilter, + logger *zap.Logger, +) *rrSetFetcher { + return &rrSetFetcher{ + domainFilter: domainFilter, + logger: logger, + } +} + +// fetchRecords fetches all []v2.RRSet from Selectel DNS API for given zone id in options["filter"]. +func (r *rrSetFetcher) fetchRecords( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + zoneId string, + options map[string]string, +) ([]*domains.RRSet, error) { + options[domainsOptionLimit] = defaultDomainsLimit + options[domainsOptionOffset] = defaultDomainsOffset + + var rrSets []*domains.RRSet + + for { + rrSetsResponse, err := client.ListRRSets(ctx, zoneId, &options) + if err != nil { + return nil, err + } + + rrSets = append(rrSets, rrSetsResponse.GetItems()...) + + options[domainsOptionOffset] = strconv.Itoa(rrSetsResponse.GetNextOffset()) + if rrSetsResponse.GetNextOffset() == 0 { + break + } + } + + return rrSets, nil +} + +// getRRSetForUpdateDeletion returns the record set to be deleted and the zone it belongs to. +func (r *rrSetFetcher) getRRSetForUpdateDeletion( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + change *endpoint.Endpoint, + zones []*domains.Zone, +) (*domains.Zone, *domains.RRSet, error) { + resultZone, found := findBestMatchingZone(change.DNSName, zones) + if !found { + r.logger.Error( + "record set name contains no zone dns name", + zap.String("name", change.DNSName), + ) + + return nil, nil, fmt.Errorf("record set name contains no zone dns name") + } + + domainRrSets, err := r.fetchRecords(ctx, client, resultZone.ID, map[string]string{ + domainsOptionName: change.DNSName, + }) + if err != nil { + return nil, nil, err + } + + resultRRSet, found := findRRSet(change.DNSName, change.RecordType, domainRrSets) + if !found { + r.logger.Info("record not found on record sets", zap.String("name", change.DNSName)) + + return nil, nil, fmt.Errorf("record not found on record sets") + } + + return resultZone, resultRRSet, nil +} diff --git a/internal/selprovider/zone_fetcher.go b/internal/selprovider/zone_fetcher.go new file mode 100644 index 0000000..a11a2d1 --- /dev/null +++ b/internal/selprovider/zone_fetcher.go @@ -0,0 +1,75 @@ +package selprovider + +import ( + "context" + "strconv" + + domains "github.com/selectel/domains-go/pkg/v2" + "sigs.k8s.io/external-dns/endpoint" +) + +type zoneFetcher struct { + domainFilter endpoint.DomainFilter +} + +func newZoneFetcher( + domainFilter endpoint.DomainFilter, +) *zoneFetcher { + return &zoneFetcher{ + domainFilter: domainFilter, + } +} + +// zones returns filtered list of v2.Zone if domainFilter is set. +func (z *zoneFetcher) zones(ctx context.Context, client domains.DNSClient[domains.Zone, domains.RRSet]) ([]*domains.Zone, error) { + if len(z.domainFilter.Filters) == 0 { + zones, err := z.fetchZones(ctx, client, map[string]string{}) + if err != nil { + return nil, err + } + + return zones, nil + } + + var result []*domains.Zone + // send one request per filter + for _, filter := range z.domainFilter.Filters { + zones, err := z.fetchZones(ctx, client, map[string]string{ + domainsOptionFilter: filter, + }) + if err != nil { + return nil, err + } + result = append(result, zones...) + } + + return result, nil +} + +// fetchZones fetches all []v2.Zone from Selectel DNS API. It may be filtered with options["filter"] provided. +func (z *zoneFetcher) fetchZones( + ctx context.Context, + client domains.DNSClient[domains.Zone, domains.RRSet], + options map[string]string, +) ([]*domains.Zone, error) { + options[domainsOptionLimit] = defaultDomainsLimit + options[domainsOptionOffset] = defaultDomainsOffset + + var zones []*domains.Zone + + for { + zonesResponse, err := client.ListZones(ctx, &options) + if err != nil { + return nil, err + } + + zones = append(zones, zonesResponse.GetItems()...) + + options[domainsOptionOffset] = strconv.Itoa(zonesResponse.GetNextOffset()) + if zonesResponse.GetNextOffset() == 0 { + break + } + } + + return zones, nil +} diff --git a/internal/stackitprovider/apply_changes.go b/internal/stackitprovider/apply_changes.go deleted file mode 100644 index 9b89309..0000000 --- a/internal/stackitprovider/apply_changes.go +++ /dev/null @@ -1,258 +0,0 @@ -package stackitprovider - -import ( - "context" - "fmt" - - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" - "go.uber.org/zap" - "sigs.k8s.io/external-dns/endpoint" - "sigs.k8s.io/external-dns/plan" -) - -// ApplyChanges applies a given set of changes in a given zone. -func (d *StackitDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { - // create rr set. POST /v1/projects/{projectId}/zones/{zoneId}/rrsets - err := d.createRRSets(ctx, changes.Create) - if err != nil { - return err - } - - // update rr set. PATCH /v1/projects/{projectId}/zones/{zoneId}/rrsets/{rrSetId} - err = d.updateRRSets(ctx, changes.UpdateNew) - if err != nil { - return err - } - - // delete rr set. DELETE /v1/projects/{projectId}/zones/{zoneId}/rrsets/{rrSetId} - err = d.deleteRRSets(ctx, changes.Delete) - if err != nil { - return err - } - - return nil -} - -// createRRSets creates new record sets in the stackitprovider for the given endpoints that are in the -// creation field. -func (d *StackitDNSProvider) createRRSets( - ctx context.Context, - endpoints []*endpoint.Endpoint, -) error { - if len(endpoints) == 0 { - return nil - } - - zones, err := d.zoneFetcherClient.zones(ctx) - if err != nil { - return err - } - - return d.handleRRSetWithWorkers(ctx, endpoints, zones, CREATE) -} - -// createRRSet creates a new record set in the stackitprovider for the given endpoint. -func (d *StackitDNSProvider) createRRSet( - ctx context.Context, - change *endpoint.Endpoint, - zones []stackitdnsclient.Zone, -) error { - resultZone, found := findBestMatchingZone(change.DNSName, zones) - if !found { - return fmt.Errorf("no matching zone found for %s", change.DNSName) - } - - logFields := getLogFields(change, CREATE, *resultZone.Id) - d.logger.Info("create record set", logFields...) - - if d.dryRun { - d.logger.Debug("dry run, skipping", logFields...) - - return nil - } - - modifyChange(change) - - rrSetPayload := getStackitRecordSetPayload(change) - - // ignore all errors to just retry on next run - _, err := d.apiClient.CreateRecordSet(ctx, d.projectId, *resultZone.Id).CreateRecordSetPayload(rrSetPayload).Execute() - if err != nil { - d.logger.Error("error creating record set", zap.Error(err)) - - return err - } - - d.logger.Info("create record set successfully", logFields...) - - return nil -} - -// updateRRSets patches (overrides) contents in the record sets in the stackitprovider for the given -// endpoints that are in the update new field. -func (d *StackitDNSProvider) updateRRSets( - ctx context.Context, - endpoints []*endpoint.Endpoint, -) error { - if len(endpoints) == 0 { - return nil - } - - zones, err := d.zoneFetcherClient.zones(ctx) - if err != nil { - return err - } - - return d.handleRRSetWithWorkers(ctx, endpoints, zones, UPDATE) -} - -// updateRRSet patches (overrides) contents in the record set in the stackitprovider. -func (d *StackitDNSProvider) updateRRSet( - ctx context.Context, - change *endpoint.Endpoint, - zones []stackitdnsclient.Zone, -) error { - modifyChange(change) - - resultZone, resultRRSet, err := d.rrSetFetcherClient.getRRSetForUpdateDeletion(ctx, change, zones) - if err != nil { - return err - } - - logFields := getLogFields(change, UPDATE, *resultRRSet.Id) - d.logger.Info("update record set", logFields...) - - if d.dryRun { - d.logger.Debug("dry run, skipping", logFields...) - - return nil - } - - rrSet := getStackitPartialUpdateRecordSetPayload(change) - - _, err = d.apiClient.PartialUpdateRecordSet(ctx, d.projectId, *resultZone.Id, *resultRRSet.Id).PartialUpdateRecordSetPayload(rrSet).Execute() - if err != nil { - d.logger.Error("error updating record set", zap.Error(err)) - - return err - } - - d.logger.Info("update record set successfully", logFields...) - - return nil -} - -// deleteRRSets deletes record sets in the stackitprovider for the given endpoints that are in the -// deletion field. -func (d *StackitDNSProvider) deleteRRSets( - ctx context.Context, - endpoints []*endpoint.Endpoint, -) error { - if len(endpoints) == 0 { - d.logger.Debug("no endpoints to delete") - - return nil - } - - d.logger.Info("records to delete", zap.String("records", fmt.Sprintf("%v", endpoints))) - - zones, err := d.zoneFetcherClient.zones(ctx) - if err != nil { - return err - } - - return d.handleRRSetWithWorkers(ctx, endpoints, zones, DELETE) -} - -// deleteRRSet deletes a record set in the stackitprovider for the given endpoint. -func (d *StackitDNSProvider) deleteRRSet( - ctx context.Context, - change *endpoint.Endpoint, - zones []stackitdnsclient.Zone, -) error { - modifyChange(change) - - resultZone, resultRRSet, err := d.rrSetFetcherClient.getRRSetForUpdateDeletion(ctx, change, zones) - if err != nil { - return err - } - - logFields := getLogFields(change, DELETE, *resultRRSet.Id) - d.logger.Info("delete record set", logFields...) - - if d.dryRun { - d.logger.Debug("dry run, skipping", logFields...) - - return nil - } - - _, err = d.apiClient.DeleteRecordSet(ctx, d.projectId, *resultZone.Id, *resultRRSet.Id).Execute() - if err != nil { - d.logger.Error("error deleting record set", zap.Error(err)) - - return err - } - - d.logger.Info("delete record set successfully", logFields...) - - return nil -} - -// handleRRSetWithWorkers handles the given endpoints with workers to optimize speed. -func (d *StackitDNSProvider) handleRRSetWithWorkers( - ctx context.Context, - endpoints []*endpoint.Endpoint, - zones []stackitdnsclient.Zone, - action string, -) error { - workerChannel := make(chan changeTask, len(endpoints)) - errorChannel := make(chan error, len(endpoints)) - - for i := 0; i < d.workers; i++ { - go d.changeWorker(ctx, workerChannel, errorChannel, zones) - } - - for _, change := range endpoints { - workerChannel <- changeTask{ - action: action, - change: change, - } - } - - for i := 0; i < len(endpoints); i++ { - err := <-errorChannel - if err != nil { - close(workerChannel) - - return err - } - } - - close(workerChannel) - - return nil -} - -// changeWorker is a worker that handles changes passed by a channel. -func (d *StackitDNSProvider) changeWorker( - ctx context.Context, - changes chan changeTask, - errorChannel chan error, - zones []stackitdnsclient.Zone, -) { - for change := range changes { - switch change.action { - case CREATE: - err := d.createRRSet(ctx, change.change, zones) - errorChannel <- err - case UPDATE: - err := d.updateRRSet(ctx, change.change, zones) - errorChannel <- err - case DELETE: - err := d.deleteRRSet(ctx, change.change, zones) - errorChannel <- err - } - } - - d.logger.Debug("change worker finished") -} diff --git a/internal/stackitprovider/config.go b/internal/stackitprovider/config.go deleted file mode 100644 index 604c2d9..0000000 --- a/internal/stackitprovider/config.go +++ /dev/null @@ -1,11 +0,0 @@ -package stackitprovider - -import "sigs.k8s.io/external-dns/endpoint" - -// Config is used to configure the creation of the StackitDNSProvider. -type Config struct { - ProjectId string - DomainFilter endpoint.DomainFilter - DryRun bool - Workers int -} diff --git a/internal/stackitprovider/domain_filter.go b/internal/stackitprovider/domain_filter.go deleted file mode 100644 index a0edb25..0000000 --- a/internal/stackitprovider/domain_filter.go +++ /dev/null @@ -1,7 +0,0 @@ -package stackitprovider - -import "sigs.k8s.io/external-dns/endpoint" - -func (d *StackitDNSProvider) GetDomainFilter() endpoint.DomainFilter { - return d.domainFilter -} diff --git a/internal/stackitprovider/helper.go b/internal/stackitprovider/helper.go deleted file mode 100644 index 491fe8c..0000000 --- a/internal/stackitprovider/helper.go +++ /dev/null @@ -1,113 +0,0 @@ -package stackitprovider - -import ( - "strings" - - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" - "go.uber.org/zap" - "sigs.k8s.io/external-dns/endpoint" -) - -// findBestMatchingZone finds the best matching zone for a given record set name. The criteria are -// that the zone name is contained in the record set name and that the zone name is the longest -// possible match. Eg foo.bar.com. would have precedence over bar.com. if rr set name is foo.bar.com. -func findBestMatchingZone( - rrSetName string, - zones []stackitdnsclient.Zone, -) (*stackitdnsclient.Zone, bool) { - count := 0 - var domainZone stackitdnsclient.Zone - for _, zone := range zones { - if len(*zone.DnsName) > count && strings.Contains(rrSetName, *zone.DnsName) { - count = len(*zone.DnsName) - domainZone = zone - } - } - - if count == 0 { - return nil, false - } - - return &domainZone, true -} - -// findRRSet finds a record set by name and type in a list of record sets. -func findRRSet( - rrSetName, recordType string, - rrSets []stackitdnsclient.RecordSet, -) (*stackitdnsclient.RecordSet, bool) { - for _, rrSet := range rrSets { - if *rrSet.Name == rrSetName && *rrSet.Type == recordType { - return &rrSet, true - } - } - - return nil, false -} - -// appendDotIfNotExists appends a dot to the end of a string if it doesn't already end with a dot. -func appendDotIfNotExists(s string) string { - if !strings.HasSuffix(s, ".") { - return s + "." - } - - return s -} - -// modifyChange modifies a change to ensure it is valid for this stackitprovider. -func modifyChange(change *endpoint.Endpoint) { - change.DNSName = appendDotIfNotExists(change.DNSName) - - if change.RecordTTL == 0 { - change.RecordTTL = 300 - } -} - -// getStackitRecordSetPayload returns a stackitdnsclient.RecordSetPayload from a change for the api client. -func getStackitRecordSetPayload(change *endpoint.Endpoint) stackitdnsclient.CreateRecordSetPayload { - records := make([]stackitdnsclient.RecordPayload, len(change.Targets)) - for i := range change.Targets { - records[i] = stackitdnsclient.RecordPayload{ - Content: &change.Targets[i], - } - } - - return stackitdnsclient.CreateRecordSetPayload{ - Name: &change.DNSName, - Records: &records, - Ttl: pointerTo(int64(change.RecordTTL)), - Type: &change.RecordType, - } -} - -// getStackitPartialUpdateRecordSetPayload returns a stackitdnsclient.PartialUpdateRecordSetPayload from a change for the api client. -func getStackitPartialUpdateRecordSetPayload(change *endpoint.Endpoint) stackitdnsclient.PartialUpdateRecordSetPayload { - records := make([]stackitdnsclient.RecordPayload, len(change.Targets)) - for i := range change.Targets { - records[i] = stackitdnsclient.RecordPayload{ - Content: &change.Targets[i], - } - } - - return stackitdnsclient.PartialUpdateRecordSetPayload{ - Name: &change.DNSName, - Records: &records, - Ttl: pointerTo(int64(change.RecordTTL)), - } -} - -// getLogFields returns a log.Fields object for a change. -func getLogFields(change *endpoint.Endpoint, action string, id string) []zap.Field { - return []zap.Field{ - zap.String("record", change.DNSName), - zap.String("content", strings.Join(change.Targets, ",")), - zap.String("type", change.RecordType), - zap.String("action", action), - zap.String("id", id), - } -} - -// pointerTo returns a pointer to the given value. -func pointerTo[T any](v T) *T { - return &v -} diff --git a/internal/stackitprovider/records.go b/internal/stackitprovider/records.go deleted file mode 100644 index 802a140..0000000 --- a/internal/stackitprovider/records.go +++ /dev/null @@ -1,104 +0,0 @@ -package stackitprovider - -import ( - "context" - - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" - "sigs.k8s.io/external-dns/endpoint" - "sigs.k8s.io/external-dns/provider" -) - -// Records returns resource records. -func (d *StackitDNSProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { - zones, err := d.zoneFetcherClient.zones(ctx) - if err != nil { - return nil, err - } - - var endpoints []*endpoint.Endpoint - endpointsErrorChannel := make(chan endpointError, len(zones)) - zoneIdsChannel := make(chan string, len(zones)) - - for i := 0; i < d.workers; i++ { - go d.fetchRecordsWorker(ctx, zoneIdsChannel, endpointsErrorChannel) - } - - for _, zone := range zones { - zoneIdsChannel <- *zone.Id - } - - for i := 0; i < len(zones); i++ { - endpointsErrorList := <-endpointsErrorChannel - if endpointsErrorList.err != nil { - close(zoneIdsChannel) - - return nil, endpointsErrorList.err - } - endpoints = append(endpoints, endpointsErrorList.endpoints...) - } - - close(zoneIdsChannel) - - return endpoints, nil -} - -// fetchRecordsWorker fetches all records from a given zone. -func (d *StackitDNSProvider) fetchRecordsWorker( - ctx context.Context, - zoneIdChannel chan string, - endpointsErrorChannel chan<- endpointError, -) { - for zoneId := range zoneIdChannel { - d.processZoneRRSets(ctx, zoneId, endpointsErrorChannel) - } - - d.logger.Debug("fetch record set worker finished") -} - -// processZoneRRSets fetches and processes DNS records for a given zone. -func (d *StackitDNSProvider) processZoneRRSets( - ctx context.Context, - zoneId string, - endpointsErrorChannel chan<- endpointError, -) { - var endpoints []*endpoint.Endpoint - rrSets, err := d.rrSetFetcherClient.fetchRecords(ctx, zoneId, nil) - if err != nil { - endpointsErrorChannel <- endpointError{ - endpoints: nil, - err: err, - } - - return - } - - endpoints = d.collectEndPoints(rrSets) - endpointsErrorChannel <- endpointError{ - endpoints: endpoints, - err: nil, - } -} - -// collectEndPoints creates a list of Endpoints from the provided rrSets. -func (d *StackitDNSProvider) collectEndPoints( - rrSets []stackitdnsclient.RecordSet, -) []*endpoint.Endpoint { - var endpoints []*endpoint.Endpoint - for _, r := range rrSets { - if provider.SupportedRecordType(*r.Type) { - for _, _r := range *r.Records { - endpoints = append( - endpoints, - endpoint.NewEndpointWithTTL( - *r.Name, - *r.Type, - endpoint.TTL(*r.Ttl), - *_r.Content, - ), - ) - } - } - } - - return endpoints -} diff --git a/internal/stackitprovider/records_test.go b/internal/stackitprovider/records_test.go deleted file mode 100644 index f099081..0000000 --- a/internal/stackitprovider/records_test.go +++ /dev/null @@ -1,395 +0,0 @@ -package stackitprovider - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/goccy/go-json" - stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" - "github.com/stretchr/testify/assert" - "go.uber.org/zap" - "sigs.k8s.io/external-dns/endpoint" -) - -func TestRecords(t *testing.T) { - t.Parallel() - - server := getServerRecords(t) - defer server.Close() - - stackitDnsProvider, err := getDefaultTestProvider(server) - assert.NoError(t, err) - - endpoints, err := stackitDnsProvider.Records(context.Background()) - assert.NoError(t, err) - assert.Equal(t, 2, len(endpoints)) - assert.Equal(t, "test.com", endpoints[0].DNSName) - assert.Equal(t, "A", endpoints[0].RecordType) - assert.Equal(t, "1.2.3.4", endpoints[0].Targets[0]) - assert.Equal(t, int64(300), int64(endpoints[0].RecordTTL)) - - assert.Equal(t, "test2.com", endpoints[1].DNSName) - assert.Equal(t, "A", endpoints[1].RecordType) - assert.Equal(t, "5.6.7.8", endpoints[1].Targets[0]) - assert.Equal(t, int64(300), int64(endpoints[1].RecordTTL)) -} - -// TestWrongJsonResponseRecords tests the scenario where the server returns a wrong JSON response. -func TestWrongJsonResponseRecords(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - - mux.HandleFunc("/v1/projects/1234/zones", - func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"invalid:"json"`)) // This is not a valid JSON. - }, - ) - defer server.Close() - - stackitDnsProvider, err := getDefaultTestProvider(server) - assert.NoError(t, err) - - endpoints, err := stackitDnsProvider.Records(context.Background()) - assert.Error(t, err) - assert.Equal(t, 0, len(endpoints)) -} - -// TestPagedResponseRecords tests the scenario where the server returns a paged response. -func TestPagedResponseRecords(t *testing.T) { - t.Parallel() - - server := getPagedRecordsServer(t) - defer server.Close() - - stackitDnsProvider, err := getDefaultTestProvider(server) - assert.NoError(t, err) - - endpoints, err := stackitDnsProvider.Records(context.Background()) - assert.NoError(t, err) - assert.Equal(t, 2, len(endpoints)) - assert.Equal(t, "test.com", endpoints[0].DNSName) - assert.Equal(t, "A", endpoints[0].RecordType) - assert.Equal(t, "1.2.3.4", endpoints[0].Targets[0]) - assert.Equal(t, int64(300), int64(endpoints[0].RecordTTL)) - - assert.Equal(t, "test2.com", endpoints[1].DNSName) - assert.Equal(t, "A", endpoints[1].RecordType) - assert.Equal(t, "5.6.7.8", endpoints[1].Targets[0]) - assert.Equal(t, int64(300), int64(endpoints[1].RecordTTL)) -} - -func TestEmptyZonesRouteRecords(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - defer server.Close() - - stackitDnsProvider, err := getDefaultTestProvider(server) - assert.NoError(t, err) - - _, err = stackitDnsProvider.Records(context.Background()) - assert.Error(t, err) -} - -func TestEmptyRRSetRouteRecords(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - mux.HandleFunc("/v1/projects/1234/zones", - func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - zones := stackitdnsclient.ListZonesResponse{ - ItemsPerPage: pointerTo(int64(1)), - Message: pointerTo("success"), - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(2)), - Zones: &[]stackitdnsclient.Zone{{Id: pointerTo("1234")}}, - } - successResponseBytes, err := json.Marshal(zones) - assert.NoError(t, err) - - w.WriteHeader(http.StatusOK) - w.Write(successResponseBytes) - }, - ) - defer server.Close() - - stackitDnsProvider, err := getDefaultTestProvider(server) - assert.NoError(t, err) - - _, err = stackitDnsProvider.Records(context.Background()) - assert.Error(t, err) -} - -func TestZoneEndpoint500Records(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - mux.HandleFunc("/v1/projects/1234/zones", - func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - w.WriteHeader(http.StatusInternalServerError) - }, - ) - defer server.Close() - - stackitDnsProvider, err := getDefaultTestProvider(server) - assert.NoError(t, err) - - _, err = stackitDnsProvider.Records(context.Background()) - assert.Error(t, err) -} - -func TestZoneEndpoint403Records(t *testing.T) { - t.Parallel() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - mux.HandleFunc("/v1/projects/1234/zones", - func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - w.WriteHeader(http.StatusForbidden) - }, - ) - defer server.Close() - - stackitDnsProvider, err := NewStackitDNSProvider( - zap.NewNop(), - Config{ - ProjectId: "1234", - DomainFilter: endpoint.DomainFilter{}, - DryRun: false, - Workers: 10, - }, - stackitconfig.WithHTTPClient(server.Client()), - stackitconfig.WithEndpoint(server.URL), - // we need a non-empty token for the bootstrapping not to fail - stackitconfig.WithToken("token"), - ) - assert.NoError(t, err) - - _, err = stackitDnsProvider.Records(context.Background()) - assert.Error(t, err) -} - -func getDefaultTestProvider(server *httptest.Server) (*StackitDNSProvider, error) { - stackitDnsProvider, err := NewStackitDNSProvider( - zap.NewNop(), - Config{ - ProjectId: "1234", - DomainFilter: endpoint.DomainFilter{}, - DryRun: false, - Workers: 1, - }, - stackitconfig.WithHTTPClient(server.Client()), - stackitconfig.WithEndpoint(server.URL), - // we need a non-empty token for the bootstrapping not to fail - stackitconfig.WithToken("token")) - - return stackitDnsProvider, err -} - -func getZonesHandlerRecordsPaged(t *testing.T) http.HandlerFunc { - t.Helper() - - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - zones := stackitdnsclient.ListZonesResponse{} - if r.URL.Query().Get("page") == "1" { - zones = stackitdnsclient.ListZonesResponse{ - ItemsPerPage: pointerTo(int64(1)), - Message: pointerTo("success"), - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(2)), - Zones: &[]stackitdnsclient.Zone{{Id: pointerTo("1234")}}, - } - } - if r.URL.Query().Get("page") == "2" { - zones = stackitdnsclient.ListZonesResponse{ - ItemsPerPage: pointerTo(int64(1)), - Message: pointerTo("success"), - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(2)), - Zones: &[]stackitdnsclient.Zone{{Id: pointerTo("5678")}}, - } - } - successResponseBytes, err := json.Marshal(zones) - assert.NoError(t, err) - - w.WriteHeader(http.StatusOK) - w.Write(successResponseBytes) - } -} - -func getRrsetsHandlerReecodsPaged(t *testing.T, domain string) http.HandlerFunc { - t.Helper() - - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - rrSets := stackitdnsclient.ListRecordSetsResponse{} - if domain == "1234" { - rrSets = stackitdnsclient.ListRecordSetsResponse{ - ItemsPerPage: pointerTo(int64(1)), - Message: pointerTo("success"), - RrSets: &[]stackitdnsclient.RecordSet{ - { - Name: pointerTo("test.com."), - Type: pointerTo("A"), - Ttl: pointerTo(int64(300)), - Records: &[]stackitdnsclient.Record{ - {Content: pointerTo("1.2.3.4")}, - }, - }, - }, - TotalItems: pointerTo(int64(1)), - TotalPages: pointerTo(int64(1)), - } - } - if domain == "5678" { - rrSets = stackitdnsclient.ListRecordSetsResponse{ - ItemsPerPage: pointerTo(int64(1)), - Message: pointerTo("success"), - RrSets: &[]stackitdnsclient.RecordSet{ - { - Name: pointerTo("test2.com."), - Type: pointerTo("A"), - Ttl: pointerTo(int64(300)), - Records: &[]stackitdnsclient.Record{ - {Content: pointerTo("5.6.7.8")}, - }, - }, - }, - TotalItems: pointerTo(int64(1)), - TotalPages: pointerTo(int64(1)), - } - } - - successResponseBytes, err := json.Marshal(rrSets) - assert.NoError(t, err) - - w.WriteHeader(http.StatusOK) - w.Write(successResponseBytes) - } -} - -func getPagedRecordsServer(t *testing.T) *httptest.Server { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - - mux.HandleFunc("/v1/projects/1234/zones", getZonesHandlerRecordsPaged(t)) - mux.HandleFunc("/v1/projects/1234/zones/1234/rrsets", getRrsetsHandlerReecodsPaged(t, "1234")) - mux.HandleFunc("/v1/projects/1234/zones/5678/rrsets", getRrsetsHandlerReecodsPaged(t, "5678")) - - return server -} - -func getZonesResponseRecordsNonPaged(t *testing.T, w http.ResponseWriter) { - t.Helper() - - w.Header().Set("Content-Type", "application/json") - - zones := stackitdnsclient.ListZonesResponse{ - ItemsPerPage: pointerTo(int64(10)), - Message: pointerTo("success"), - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(1)), - Zones: &[]stackitdnsclient.Zone{ - {Id: pointerTo("1234"), DnsName: pointerTo("test.com")}, - {Id: pointerTo("5678"), DnsName: pointerTo("test2.com")}, - }, - } - successResponseBytes, err := json.Marshal(zones) - assert.NoError(t, err) - - w.WriteHeader(http.StatusOK) - w.Write(successResponseBytes) -} - -func getRrsetsResponseRecordsNonPaged(t *testing.T, w http.ResponseWriter, domain string) { - t.Helper() - - w.Header().Set("Content-Type", "application/json") - - var rrSets stackitdnsclient.ListRecordSetsResponse - - if domain == "1234" { - rrSets = stackitdnsclient.ListRecordSetsResponse{ - ItemsPerPage: pointerTo(int64(20)), - Message: pointerTo("success"), - RrSets: &[]stackitdnsclient.RecordSet{ - { - Name: pointerTo("test.com."), - Type: pointerTo("A"), - Ttl: pointerTo(int64(300)), - Records: &[]stackitdnsclient.Record{ - {Content: pointerTo("1.2.3.4")}, - }, - Id: pointerTo("1234"), - }, - }, - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(1)), - } - } else if domain == "5678" { - rrSets = stackitdnsclient.ListRecordSetsResponse{ - ItemsPerPage: pointerTo(int64(20)), - Message: pointerTo("success"), - RrSets: &[]stackitdnsclient.RecordSet{ - { - Name: pointerTo("test2.com."), - Type: pointerTo("A"), - Ttl: pointerTo(int64(300)), - Records: &[]stackitdnsclient.Record{ - {Content: pointerTo("5.6.7.8")}, - }, - Id: pointerTo("5678"), - }, - }, - TotalItems: pointerTo(int64(2)), - TotalPages: pointerTo(int64(1)), - } - } - - successResponseBytes, err := json.Marshal(rrSets) - assert.NoError(t, err) - - w.WriteHeader(http.StatusOK) - w.Write(successResponseBytes) -} - -func getServerRecords(t *testing.T) *httptest.Server { - t.Helper() - - mux := http.NewServeMux() - server := httptest.NewServer(mux) - - mux.HandleFunc("/v1/projects/1234/zones", func(w http.ResponseWriter, r *http.Request) { - getZonesResponseRecordsNonPaged(t, w) - }) - mux.HandleFunc("/v1/projects/1234/zones/1234/rrsets", func(w http.ResponseWriter, r *http.Request) { - getRrsetsResponseRecordsNonPaged(t, w, "1234") - }) - mux.HandleFunc("/v1/projects/1234/zones/5678/rrsets", func(w http.ResponseWriter, r *http.Request) { - getRrsetsResponseRecordsNonPaged(t, w, "5678") - }) - - return server -} diff --git a/internal/stackitprovider/rrset_fetcher.go b/internal/stackitprovider/rrset_fetcher.go deleted file mode 100644 index 4819df1..0000000 --- a/internal/stackitprovider/rrset_fetcher.go +++ /dev/null @@ -1,99 +0,0 @@ -package stackitprovider - -import ( - "context" - "fmt" - - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" - "go.uber.org/zap" - "sigs.k8s.io/external-dns/endpoint" -) - -type rrSetFetcher struct { - apiClient *stackitdnsclient.APIClient - domainFilter endpoint.DomainFilter - projectId string - logger *zap.Logger -} - -func newRRSetFetcher( - apiClient *stackitdnsclient.APIClient, - domainFilter endpoint.DomainFilter, - projectId string, - logger *zap.Logger, -) *rrSetFetcher { - return &rrSetFetcher{ - apiClient: apiClient, - domainFilter: domainFilter, - projectId: projectId, - logger: logger, - } -} - -// fetchRecords fetches all []stackitdnsclient.RecordSet from STACKIT DNS API for given zone id. -func (r *rrSetFetcher) fetchRecords( - ctx context.Context, - zoneId string, - nameFilter *string, -) ([]stackitdnsclient.RecordSet, error) { - var result []stackitdnsclient.RecordSet - var pager int32 = 1 - - listRequest := r.apiClient.ListRecordSets(ctx, r.projectId, zoneId).Page(pager).PageSize(10000).ActiveEq(true) - - if nameFilter != nil { - listRequest = listRequest.NameLike(*nameFilter) - } - - rrSetResponse, err := listRequest.Execute() - if err != nil { - return nil, err - } - - result = append(result, *rrSetResponse.RrSets...) - - // if there is more than one page, we need to loop over the other pages and - // issue another API request for each one of them - pager++ - for int64(pager) <= *rrSetResponse.TotalPages { - rrSetResponse, err := listRequest.Page(pager).Execute() - if err != nil { - return nil, err - } - result = append(result, *rrSetResponse.RrSets...) - pager++ - } - - return result, nil -} - -// getRRSetForUpdateDeletion returns the record set to be deleted and the zone it belongs to. -func (r *rrSetFetcher) getRRSetForUpdateDeletion( - ctx context.Context, - change *endpoint.Endpoint, - zones []stackitdnsclient.Zone, -) (*stackitdnsclient.Zone, *stackitdnsclient.RecordSet, error) { - resultZone, found := findBestMatchingZone(change.DNSName, zones) - if !found { - r.logger.Error( - "record set name contains no zone dns name", - zap.String("name", change.DNSName), - ) - - return nil, nil, fmt.Errorf("record set name contains no zone dns name") - } - - domainRRSets, err := r.fetchRecords(ctx, *resultZone.Id, &change.DNSName) - if err != nil { - return nil, nil, err - } - - resultRRSet, found := findRRSet(change.DNSName, change.RecordType, domainRRSets) - if !found { - r.logger.Info("record not found on record sets", zap.String("name", change.DNSName)) - - return nil, nil, fmt.Errorf("record not found on record sets") - } - - return resultZone, resultRRSet, nil -} diff --git a/internal/stackitprovider/stackit.go b/internal/stackitprovider/stackit.go deleted file mode 100644 index 8cb584d..0000000 --- a/internal/stackitprovider/stackit.go +++ /dev/null @@ -1,47 +0,0 @@ -package stackitprovider - -import ( - stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" - "go.uber.org/zap" - "sigs.k8s.io/external-dns/endpoint" - "sigs.k8s.io/external-dns/provider" -) - -// StackitDNSProvider implements the DNS stackitprovider for STACKIT DNS. -type StackitDNSProvider struct { - provider.BaseProvider - projectId string - domainFilter endpoint.DomainFilter - dryRun bool - workers int - logger *zap.Logger - apiClient *stackitdnsclient.APIClient - zoneFetcherClient *zoneFetcher - rrSetFetcherClient *rrSetFetcher -} - -// NewStackitDNSProvider creates a new STACKIT DNS stackitprovider. -func NewStackitDNSProvider( - logger *zap.Logger, - providerConfig Config, - stackitConfig ...stackitconfig.ConfigurationOption, -) (*StackitDNSProvider, error) { - apiClient, err := stackitdnsclient.NewAPIClient(stackitConfig...) - if err != nil { - return nil, err - } - - provider := &StackitDNSProvider{ - apiClient: apiClient, - domainFilter: providerConfig.DomainFilter, - dryRun: providerConfig.DryRun, - projectId: providerConfig.ProjectId, - workers: providerConfig.Workers, - logger: logger, - zoneFetcherClient: newZoneFetcher(apiClient, providerConfig.DomainFilter, providerConfig.ProjectId), - rrSetFetcherClient: newRRSetFetcher(apiClient, providerConfig.DomainFilter, providerConfig.ProjectId, logger), - } - - return provider, nil -} diff --git a/internal/stackitprovider/stackit_test.go b/internal/stackitprovider/stackit_test.go deleted file mode 100644 index fdbf777..0000000 --- a/internal/stackitprovider/stackit_test.go +++ /dev/null @@ -1 +0,0 @@ -package stackitprovider diff --git a/internal/stackitprovider/zone_fetcher.go b/internal/stackitprovider/zone_fetcher.go deleted file mode 100644 index b2e6c57..0000000 --- a/internal/stackitprovider/zone_fetcher.go +++ /dev/null @@ -1,84 +0,0 @@ -package stackitprovider - -import ( - "context" - - stackitdnsclient "github.com/stackitcloud/stackit-sdk-go/services/dns" - "sigs.k8s.io/external-dns/endpoint" -) - -type zoneFetcher struct { - apiClient *stackitdnsclient.APIClient - domainFilter endpoint.DomainFilter - projectId string -} - -func newZoneFetcher( - apiClient *stackitdnsclient.APIClient, - domainFilter endpoint.DomainFilter, - projectId string, -) *zoneFetcher { - return &zoneFetcher{ - apiClient: apiClient, - domainFilter: domainFilter, - projectId: projectId, - } -} - -// zones returns filtered list of stackitdnsclient.Zone if filter is set. -func (z *zoneFetcher) zones(ctx context.Context) ([]stackitdnsclient.Zone, error) { - if len(z.domainFilter.Filters) == 0 { - // no filters, return all zones - listRequest := z.apiClient.ListZones(ctx, z.projectId).ActiveEq(true) - zones, err := z.fetchZones(listRequest) - if err != nil { - return nil, err - } - - return zones, nil - } - - var result []stackitdnsclient.Zone - // send one request per filter - for _, filter := range z.domainFilter.Filters { - listRequest := z.apiClient.ListZones(ctx, z.projectId).ActiveEq(true).DnsNameLike(filter) - zones, err := z.fetchZones(listRequest) - if err != nil { - return nil, err - } - result = append(result, zones...) - } - - return result, nil -} - -// fetchZones fetches all []stackitdnsclient.Zone from STACKIT DNS API. -func (z *zoneFetcher) fetchZones( - listRequest stackitdnsclient.ApiListZonesRequest, -) ([]stackitdnsclient.Zone, error) { - var result []stackitdnsclient.Zone - var pager int32 = 1 - - listRequest = listRequest.Page(1).PageSize(10000) - - zoneResponse, err := listRequest.Execute() - if err != nil { - return nil, err - } - - result = append(result, *zoneResponse.Zones...) - - // if there is more than one page, we need to loop over the other pages and - // issue another API request for each one of them - pager++ - for int64(pager) <= *zoneResponse.TotalPages { - zoneResponse, err := listRequest.Page(pager).Execute() - if err != nil { - return nil, err - } - result = append(result, *zoneResponse.Zones...) - pager++ - } - - return result, nil -} diff --git a/pkg/api/adjust_endpoints_test.go b/pkg/api/adjust_endpoints_test.go index 2694cd4..92075be 100644 --- a/pkg/api/adjust_endpoints_test.go +++ b/pkg/api/adjust_endpoints_test.go @@ -8,8 +8,8 @@ import ( "github.com/goccy/go-json" "github.com/gofiber/fiber/v2" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + mock_provider "github.com/selectel/external-dns-selectel-webhook/pkg/api/mock" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "go.uber.org/zap" diff --git a/pkg/api/api.go b/pkg/api/api.go index 314dab6..0ef7e46 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -9,13 +9,13 @@ import ( "syscall" "time" - json "github.com/goccy/go-json" + "github.com/goccy/go-json" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/helmet" fiberlogger "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gofiber/fiber/v2/middleware/pprof" fiberrecover "github.com/gofiber/fiber/v2/middleware/recover" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" + "github.com/selectel/external-dns-selectel-webhook/pkg/metrics" "go.uber.org/zap" "sigs.k8s.io/external-dns/provider" ) diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 9a1b5c4..9729a6f 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -8,8 +8,8 @@ import ( "testing" "time" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + mock_provider "github.com/selectel/external-dns-selectel-webhook/pkg/api/mock" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "go.uber.org/zap" diff --git a/pkg/api/apply_changes_test.go b/pkg/api/apply_changes_test.go index 256acd1..4cbc64b 100644 --- a/pkg/api/apply_changes_test.go +++ b/pkg/api/apply_changes_test.go @@ -9,8 +9,8 @@ import ( "testing" "github.com/gofiber/fiber/v2" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + mock_provider "github.com/selectel/external-dns-selectel-webhook/pkg/api/mock" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "go.uber.org/zap" diff --git a/pkg/api/domain_filter_test.go b/pkg/api/domain_filter_test.go index b117f3d..5ef6c39 100644 --- a/pkg/api/domain_filter_test.go +++ b/pkg/api/domain_filter_test.go @@ -8,8 +8,8 @@ import ( "github.com/goccy/go-json" "github.com/gofiber/fiber/v2" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + mock_provider "github.com/selectel/external-dns-selectel-webhook/pkg/api/mock" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "go.uber.org/zap" diff --git a/pkg/api/health_test.go b/pkg/api/health_test.go index b1b00be..629523c 100644 --- a/pkg/api/health_test.go +++ b/pkg/api/health_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/gofiber/fiber/v2" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + mock_provider "github.com/selectel/external-dns-selectel-webhook/pkg/api/mock" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "go.uber.org/zap" diff --git a/pkg/api/metrics.go b/pkg/api/metrics.go index 342a509..7f2a22e 100644 --- a/pkg/api/metrics.go +++ b/pkg/api/metrics.go @@ -7,7 +7,7 @@ import ( "github.com/gofiber/adaptor/v2" "github.com/gofiber/fiber/v2" "github.com/prometheus/client_golang/prometheus/promhttp" - metrics_collector "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" + metrics_collector "github.com/selectel/external-dns-selectel-webhook/pkg/metrics" ) // registerAt registers the metrics endpoint. diff --git a/pkg/api/metrics_test.go b/pkg/api/metrics_test.go index 0b49546..796ed3b 100644 --- a/pkg/api/metrics_test.go +++ b/pkg/api/metrics_test.go @@ -6,9 +6,9 @@ import ( "testing" "github.com/gofiber/fiber/v2" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - metrics_collector "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics" - mock_metrics_collector "github.com/stackitcloud/external-dns-stackit-webhook/pkg/metrics/mock" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + metrics_collector "github.com/selectel/external-dns-selectel-webhook/pkg/metrics" + mock_metrics_collector "github.com/selectel/external-dns-selectel-webhook/pkg/metrics/mock" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" ) diff --git a/pkg/api/mock/api.go b/pkg/api/mock/api.go index 435702d..e2a0f8f 100644 --- a/pkg/api/mock/api.go +++ b/pkg/api/mock/api.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./api.go +// +// Generated by this command: +// +// mockgen -destination=./mock/api.go -source=./api.go Provider +// // Package mock_api is a generated GoMock package. package mock_api @@ -46,7 +51,7 @@ func (m *MockApi) Listen(port string) error { } // Listen indicates an expected call of Listen. -func (mr *MockApiMockRecorder) Listen(port interface{}) *gomock.Call { +func (mr *MockApiMockRecorder) Listen(port any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Listen", reflect.TypeOf((*MockApi)(nil).Listen), port) } @@ -54,7 +59,7 @@ func (mr *MockApiMockRecorder) Listen(port interface{}) *gomock.Call { // Test mocks base method. func (m *MockApi) Test(req *http.Request, msTimeout ...int) (*http.Response, error) { m.ctrl.T.Helper() - varargs := []interface{}{req} + varargs := []any{req} for _, a := range msTimeout { varargs = append(varargs, a) } @@ -65,9 +70,9 @@ func (m *MockApi) Test(req *http.Request, msTimeout ...int) (*http.Response, err } // Test indicates an expected call of Test. -func (mr *MockApiMockRecorder) Test(req interface{}, msTimeout ...interface{}) *gomock.Call { +func (mr *MockApiMockRecorder) Test(req any, msTimeout ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]interface{}{req}, msTimeout...) + varargs := append([]any{req}, msTimeout...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Test", reflect.TypeOf((*MockApi)(nil).Test), varargs...) } @@ -103,7 +108,7 @@ func (m *MockProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) []*endpoi } // AdjustEndpoints indicates an expected call of AdjustEndpoints. -func (mr *MockProviderMockRecorder) AdjustEndpoints(endpoints interface{}) *gomock.Call { +func (mr *MockProviderMockRecorder) AdjustEndpoints(endpoints any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdjustEndpoints", reflect.TypeOf((*MockProvider)(nil).AdjustEndpoints), endpoints) } @@ -117,7 +122,7 @@ func (m *MockProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) } // ApplyChanges indicates an expected call of ApplyChanges. -func (mr *MockProviderMockRecorder) ApplyChanges(ctx, changes interface{}) *gomock.Call { +func (mr *MockProviderMockRecorder) ApplyChanges(ctx, changes any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyChanges", reflect.TypeOf((*MockProvider)(nil).ApplyChanges), ctx, changes) } @@ -146,7 +151,7 @@ func (m *MockProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error } // Records indicates an expected call of Records. -func (mr *MockProviderMockRecorder) Records(ctx interface{}) *gomock.Call { +func (mr *MockProviderMockRecorder) Records(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Records", reflect.TypeOf((*MockProvider)(nil).Records), ctx) } diff --git a/pkg/api/records_test.go b/pkg/api/records_test.go index b5486fe..3740f8f 100644 --- a/pkg/api/records_test.go +++ b/pkg/api/records_test.go @@ -7,8 +7,8 @@ import ( "testing" "github.com/goccy/go-json" - "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api" - mock_provider "github.com/stackitcloud/external-dns-stackit-webhook/pkg/api/mock" + "github.com/selectel/external-dns-selectel-webhook/pkg/api" + mock_provider "github.com/selectel/external-dns-selectel-webhook/pkg/api/mock" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" "go.uber.org/zap" diff --git a/pkg/httpdefault/default.go b/pkg/httpdefault/default.go new file mode 100644 index 0000000..355712b --- /dev/null +++ b/pkg/httpdefault/default.go @@ -0,0 +1,62 @@ +package httpdefault + +import ( + "net" + "net/http" + "time" +) + +// UserAgent represents HTTP User-Agent header that should be added to requests to Selectel API. +// You should add this header by yourself or use Headers function. +const UserAgent = "external-dns-selectel-webhook" + +const ( + // httpTimeout represents the default timeout (in seconds) for HTTP requests. + httpTimeout = 30 + + // dialTimeout represents the default timeout (in seconds) for HTTP connection establishments. + dialTimeout = 60 + + // keepaliveTimeout represents the default keep-alive period for an active network connection. + keepaliveTimeout = 60 + + // maxIdleConns represents the maximum number of idle (keep-alive) connections. + maxIdleConns = 100 + + // idleConnTimeout represents the maximum amount of time an idle (keep-alive) connection will remain + // idle before closing itself. + idleConnTimeout = 100 + + // tlsHandshakeTimeout represents the default timeout (in seconds) for TLS handshake. + tlsHandshakeTimeout = 60 + + // expectContinueTimeout represents the default amount of time to wait for a server's first response headers. + expectContinueTimeout = 5 +) + +// Client returns default HTTP client for requests to Selectel API. It does not add User-Agent header, so +// you should add it by yourself (by default it is UserAgent) or use Headers function. +func Client() http.Client { + return http.Client{ + Timeout: httpTimeout * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: dialTimeout * time.Second, + KeepAlive: keepaliveTimeout * time.Second, + }).DialContext, + MaxIdleConns: maxIdleConns, + IdleConnTimeout: idleConnTimeout * time.Second, + TLSHandshakeTimeout: tlsHandshakeTimeout * time.Second, + ExpectContinueTimeout: expectContinueTimeout * time.Second, + }, + } +} + +// Headers returns default HTTP headers for requests to Selectel API. +func Headers() http.Header { + h := http.Header{} + h.Add("User-Agent", UserAgent) + + return h +} diff --git a/pkg/keystone/provider.go b/pkg/keystone/provider.go new file mode 100644 index 0000000..d5c64d9 --- /dev/null +++ b/pkg/keystone/provider.go @@ -0,0 +1,86 @@ +package keystone + +import ( + "fmt" + + "github.com/gophercloud/gophercloud" + "github.com/gophercloud/gophercloud/openstack" + "github.com/selectel/external-dns-selectel-webhook/pkg/httpdefault" + "go.uber.org/zap" +) + +var ( + errFailedCreateClientFmt = "failed to create default openstack client: %w" + errAuthorizationFailedFmt = "authorization failed: %w" +) + +func defaultOSClient(endpoint string) (*gophercloud.ProviderClient, error) { + client, err := openstack.NewClient(endpoint) + client.HTTPClient = httpdefault.Client() + client.UserAgent.Prepend(httpdefault.UserAgent) + + return client, err +} + +type Credentials struct { + // IdentityEndpoint is an API endpoint to authorization. It is OS_AUTH_URL variable from rc.sh. + IdentityEndpoint string + // AccountID is Selectel account ID of the user. It is OS_PROJECT_DOMAIN_NAME variable from rc.sh. + AccountID string + // ProjectID is Selectel project ID of the user. It is OS_PROJECT_ID variable from rc.sh. + ProjectID string + // Username is service user's name. It is OS_USERNAME variable from rc.sh. + Username string + // Password is service user's password. It is OS_PASSWORD variable from rc.sh. + Password string +} + +type Provider struct { + logger *zap.Logger + // credentials contains data to access openstack identity API. + credentials Credentials +} + +// GetToken returns keystone token that may be used to authorize requests to Selectel API in the project scope. +// It generates new token for each call. +func (p Provider) GetToken() (string, error) { + p.logger.Info( + "getting keystone token", + zap.String("identity_endpoint", p.credentials.IdentityEndpoint), + zap.String("username", p.credentials.Username), + zap.String("account_id", p.credentials.AccountID), + zap.String("project_id", p.credentials.ProjectID), + ) + + opts := gophercloud.AuthOptions{ + IdentityEndpoint: p.credentials.IdentityEndpoint, + Username: p.credentials.Username, + Password: p.credentials.Password, + DomainName: p.credentials.AccountID, + Scope: &gophercloud.AuthScope{ + ProjectID: p.credentials.ProjectID, + }, + } + + p.logger.Debug("connecting to identity endpoint") + client, err := defaultOSClient(p.credentials.IdentityEndpoint) + if err != nil { + p.logger.Error("error during creating default openstack client", zap.Error(err)) + + return "", fmt.Errorf(errFailedCreateClientFmt, err) + } + + err = openstack.Authenticate(client, opts) + if err != nil { + return "", fmt.Errorf(errAuthorizationFailedFmt, err) + } + + return client.Token(), nil +} + +func NewProvider(logger *zap.Logger, credentials Credentials) *Provider { + return &Provider{ + logger: logger, + credentials: credentials, + } +} diff --git a/pkg/metrics/mock/http_middleware.go b/pkg/metrics/mock/http_middleware.go index 3ccd735..69f8943 100644 --- a/pkg/metrics/mock/http_middleware.go +++ b/pkg/metrics/mock/http_middleware.go @@ -1,5 +1,10 @@ // Code generated by MockGen. DO NOT EDIT. // Source: ./http_middleware.go +// +// Generated by this command: +// +// mockgen -destination=./mock/http_middleware.go -source=./http_middleware.go HttpApiMetrics +// // Package mock_metrics is a generated GoMock package. package mock_metrics @@ -64,7 +69,7 @@ func (m *MockHttpApiMetrics) CollectRequest(method, path string, statusCode int) } // CollectRequest indicates an expected call of CollectRequest. -func (mr *MockHttpApiMetricsMockRecorder) CollectRequest(method, path, statusCode interface{}) *gomock.Call { +func (mr *MockHttpApiMetricsMockRecorder) CollectRequest(method, path, statusCode any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequest", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequest), method, path, statusCode) } @@ -76,7 +81,7 @@ func (m *MockHttpApiMetrics) CollectRequestContentLength(method, path string, co } // CollectRequestContentLength indicates an expected call of CollectRequestContentLength. -func (mr *MockHttpApiMetricsMockRecorder) CollectRequestContentLength(method, path, contentLength interface{}) *gomock.Call { +func (mr *MockHttpApiMetricsMockRecorder) CollectRequestContentLength(method, path, contentLength any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequestContentLength", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequestContentLength), method, path, contentLength) } @@ -88,7 +93,7 @@ func (m *MockHttpApiMetrics) CollectRequestDuration(method, path string, duratio } // CollectRequestDuration indicates an expected call of CollectRequestDuration. -func (mr *MockHttpApiMetricsMockRecorder) CollectRequestDuration(method, path, duration interface{}) *gomock.Call { +func (mr *MockHttpApiMetricsMockRecorder) CollectRequestDuration(method, path, duration any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequestDuration", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequestDuration), method, path, duration) } @@ -100,7 +105,7 @@ func (m *MockHttpApiMetrics) CollectRequestResponseSize(method, path string, con } // CollectRequestResponseSize indicates an expected call of CollectRequestResponseSize. -func (mr *MockHttpApiMetricsMockRecorder) CollectRequestResponseSize(method, path, contentLength interface{}) *gomock.Call { +func (mr *MockHttpApiMetricsMockRecorder) CollectRequestResponseSize(method, path, contentLength any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CollectRequestResponseSize", reflect.TypeOf((*MockHttpApiMetrics)(nil).CollectRequestResponseSize), method, path, contentLength) } diff --git a/pkg/stackit/options.go b/pkg/stackit/options.go deleted file mode 100644 index 69fc2b0..0000000 --- a/pkg/stackit/options.go +++ /dev/null @@ -1,40 +0,0 @@ -package stackit - -import ( - "fmt" - "net/http" - "time" - - stackitconfig "github.com/stackitcloud/stackit-sdk-go/core/config" -) - -// SetConfigOptions sets the default config options for the STACKIT -// client and determines which type of authorization to use, depending on the -// passed bearerToken and keyPath parameters. If no baseURL or an invalid -// combination of auth options is given (neither or both), the function returns -// an error. -func SetConfigOptions(baseURL, bearerToken, keyPath string) ([]stackitconfig.ConfigurationOption, error) { - if len(baseURL) == 0 { - return nil, fmt.Errorf("base-url is required") - } - - options := []stackitconfig.ConfigurationOption{ - stackitconfig.WithHTTPClient(&http.Client{ - Timeout: 10 * time.Second, - }), - stackitconfig.WithEndpoint(baseURL), - } - - bearerTokenSet := len(bearerToken) > 0 - keyPathSet := len(keyPath) > 0 - - if (!bearerTokenSet && !keyPathSet) || (bearerTokenSet && keyPathSet) { - return nil, fmt.Errorf("exactly only one of auth-token or auth-key-path is required") - } - - if bearerTokenSet { - return append(options, stackitconfig.WithToken(bearerToken)), nil - } - - return append(options, stackitconfig.WithServiceAccountKeyPath(keyPath)), nil -} diff --git a/pkg/stackit/options_test.go b/pkg/stackit/options_test.go deleted file mode 100644 index 9a024c0..0000000 --- a/pkg/stackit/options_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package stackit - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestMissingBaseURL(t *testing.T) { - t.Parallel() - - options, err := SetConfigOptions("", "", "") - assert.ErrorContains(t, err, "base-url") - assert.Nil(t, options) -} - -func TestBothAuthOptionsMissing(t *testing.T) { - t.Parallel() - - options, err := SetConfigOptions("https://example.com", "", "") - assert.ErrorContains(t, err, "auth-token or auth-key-path") - assert.Nil(t, options) -} - -func TestBothAuthOptionsSet(t *testing.T) { - t.Parallel() - - options, err := SetConfigOptions("https://example.com", "token", "key/path") - assert.ErrorContains(t, err, "auth-token or auth-key-path") - assert.Nil(t, options) -} - -func TestBearerTokenSet(t *testing.T) { - t.Parallel() - - options, err := SetConfigOptions("https://example.com", "token", "") - assert.NoError(t, err) - assert.Len(t, options, 3) -} - -func TestKeyPathSet(t *testing.T) { - t.Parallel() - - options, err := SetConfigOptions("https://example.com", "", "key/path") - assert.NoError(t, err) - assert.Len(t, options, 3) -}