diff --git a/README.md b/README.md index 9986654..a2ad948 100644 --- a/README.md +++ b/README.md @@ -11,25 +11,19 @@ [![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) -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 -[ExternalDNS](https://github.com/kubernetes-sigs/external-dns). +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. -For utilizing ExternalDNS with STACKIT, it is mandatory to establish a STACKIT project, a service account -within the project, generate a service account key, authorize the service account with DNS Admin role, -and finally establish a STACKIT zone. +Consequently, the STACKIT webhook enables the management of your STACKIT 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, create credentials (either a Service Account Key or configure Workload Identity Federation), authorize the service account with the DNS Admin role, and establish a STACKIT 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 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. +The STACKIT webhook is provided as a standard Open Container Initiative (OCI) image available in the [GitHub container registry](https://github.com/stackitcloud/external-dns-stackit-webhook/pkgs/container/external-dns-stackit-webhook). It is deployed as a [sidecar container](https://kubernetes.io/docs/concepts/workloads/pods/#workload-resources-for-managing-pods) within the ExternalDNS pod. + +### Option A: Authenticating via Service Account Key + +This method uses a standard STACKIT Service Account Key mounted into the container as a file. ```shell # Create a Secret containing the STACKIT service-account key JSON (as a file). @@ -38,8 +32,7 @@ kubectl -n default create secret generic external-dns-stackit-webhook \ --from-file=sa.json=/path/to/stackit-service-account-key.json ``` -```shell -kubectl apply -f - <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`. +```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**: ExternalDNS will try to create a TXT record named `a-example.runs.onstackit.cloud`, which will fail because you cannot establish a record outside the boundary of the zone. The solution is to use a name that resolves *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: +For a project containing the zone `example.runs.onstackit.cloud`, suppose you've created these two ingresses: - ```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`. +```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 +``` -## Development +**Why isn't it working?** + +**Answer**: ExternalDNS will attempt to establish a record set for `test.example.stackit.rocks`. Because the zone `example.stackit.rocks` does not exist within the project, the operation will fail. -Run the app: +There are two potential fixes: +- Incorporate the zone `example.stackit.rocks` into the STACKIT project. +- Restrict ExternalDNS scoping by applying a domain filter flag `--domain-filter="example.runs.onstackit.cloud"`. This forces the webhook to ignore `test.example.stackit.rocks` and only synchronize records for `test.example.runs.onstackit.cloud`. + +## Development +Run the app locally: ```bash export BASE_URL="https://dns.api.stackit.cloud" export PROJECT_ID="c158c736-0300-4044-95c4-b7d404279b35" @@ -351,13 +366,11 @@ make run ``` Lint the code: - ```bash make lint ``` Test the code: - ```bash make test ``` @@ -373,4 +386,4 @@ make test-e2e-local \ PROJECT_ID="your-project-id" \ ZONE_NAME="your.test.zone.cloud" \ AUTH_KEY_PATH="/absolute/path/to/your/sa.json" -``` +``` \ No newline at end of file diff --git a/cmd/webhook/cmd/root.go b/cmd/webhook/cmd/root.go index 1f05107..3062500 100644 --- a/cmd/webhook/cmd/root.go +++ b/cmd/webhook/cmd/root.go @@ -19,16 +19,17 @@ import ( ) var ( - apiPort string - authBearerToken string - authKeyPath string - tokenUrl string - baseUrl string - projectID string - worker int - domainFilter []string - dryRun bool - logLevel string + apiPort string + authKeyPath string + authWif bool + authWifTokenPath string + tokenUrl string + baseUrl string + projectID string + worker int + domainFilter []string + dryRun bool + logLevel string ) var rootCmd = &cobra.Command{ @@ -46,30 +47,39 @@ var rootCmd = &cobra.Command{ endpointDomainFilter := endpoint.DomainFilter{Filters: domainFilter} - stackitConfigOptions, err := stackit.SetConfigOptions(baseUrl, authBearerToken, authKeyPath, tokenUrl) + authConfig := &stackit.WebhookAuthConfig{ + BaseURL: baseUrl, + TokenURL: tokenUrl, + KeyPath: authKeyPath, + WIFEnabled: authWif, + WIFTokenPath: authWifTokenPath, + } + + stackitConfigOptions, err := stackit.SetConfigOptions(authConfig) if err != nil { + logger.Error("failed to set STACKIT config options", zap.Error(err)) 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..., ) if err != nil { + logger.Error("failed to initialize STACKIT DNS provider", zap.Error(err)) panic(err) } app := api.New(logger.With(zap.String("component", "api")), metrics.NewHttpApiMetrics(), stackitProvider) err = app.Listen(apiPort) if err != nil { + logger.Error("server error", zap.Error(err)) panic(err) } }, @@ -114,27 +124,27 @@ 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(&authKeyPath, "auth-key-path", "", "Defines the file path of the service account key for the STACKIT API. Mutually exclusive with 'auth-wif'.") + rootCmd.PersistentFlags().BoolVar(&authWif, "auth-wif", false, "Enables Workload Identity Federation (WIF) authentication explicitly. Mutually exclusive with 'auth-key-path'.") + rootCmd.PersistentFlags().StringVar(&authWifTokenPath, "auth-wif-token-path", "", "Defines a custom file path for the federated JWT token for WIF authentication.") rootCmd.PersistentFlags().StringVar(&tokenUrl, "token-url", "", "Defines the authentication token endpoint for the STACKIT API.") - 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().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().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().IntVar(&worker, "worker", 10, "Specifies the number of workers to employ for querying the API.") + 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") + + err := rootCmd.MarkPersistentFlagRequired("project-id") + if err != nil { + panic(err) + } } func initConfig() { viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() - // There is some issue, where the integration of Cobra with Viper will result in wrong values, therefore we are - // setting the values from viper manually. The issue is, that with the standard integration, viper will see, that - // Cobra parameters are set - even if the command line parameter was not used and the default value was set. But - // when Viper notices that the value is set, it will not overwrite the default value with the environment variable. - // Another possibility would be to not have any default values set for cobra command line parameters, but this would - // break the automatic help output from the cli. The manual way here seems the best solution for now. rootCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { if !f.Changed && viper.IsSet(f.Name) { if err := rootCmd.PersistentFlags().Set(f.Name, fmt.Sprint(viper.Get(f.Name))); err != nil { diff --git a/pkg/stackit/options.go b/pkg/stackit/options.go index d8e508b..966e376 100644 --- a/pkg/stackit/options.go +++ b/pkg/stackit/options.go @@ -9,37 +9,78 @@ import ( 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, tokenURL string) ([]stackitconfig.ConfigurationOption, error) { - if len(baseURL) == 0 { +type AuthType int + +const ( + AuthTypeDefault AuthType = iota + AuthTypeExplicitKey + AuthTypeExplicitWIF +) + +type WebhookAuthConfig struct { + BaseURL string + TokenURL string + KeyPath string + WIFEnabled bool + WIFTokenPath string +} + +func determineAuthType(cfg *WebhookAuthConfig) (AuthType, error) { + var activeTypes []AuthType + + if len(cfg.KeyPath) > 0 { + activeTypes = append(activeTypes, AuthTypeExplicitKey) + } + if cfg.WIFEnabled || len(cfg.WIFTokenPath) > 0 { + activeTypes = append(activeTypes, AuthTypeExplicitWIF) + } + + if len(activeTypes) > 1 { + return AuthTypeDefault, fmt.Errorf("ambiguous authentication configuration: specify at most one of auth-key-path or auth-wif/auth-wif-token-path") + } + + if len(activeTypes) == 1 { + return activeTypes[0], nil + } + + return AuthTypeDefault, nil +} + +func SetConfigOptions(cfg *WebhookAuthConfig) ([]stackitconfig.ConfigurationOption, error) { + if cfg == nil { + return nil, fmt.Errorf("auth configuration is required") + } + if len(cfg.BaseURL) == 0 { return nil, fmt.Errorf("base-url is required") } + authType, err := determineAuthType(cfg) + if err != nil { + return nil, err + } + options := []stackitconfig.ConfigurationOption{ stackitconfig.WithHTTPClient(&http.Client{ Timeout: 10 * time.Second, }), - stackitconfig.WithEndpoint(baseURL), + stackitconfig.WithEndpoint(cfg.BaseURL), + stackitconfig.WithBackgroundTokenRefresh(context.Background()), } - 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 len(cfg.TokenURL) > 0 { + options = append(options, stackitconfig.WithTokenEndpoint(cfg.TokenURL)) } - if bearerTokenSet { - return append(options, stackitconfig.WithToken(bearerToken)), nil - } - if len(tokenURL) > 0 { - options = append(options, stackitconfig.WithTokenEndpoint(tokenURL)) + switch authType { + case AuthTypeExplicitKey: + options = append(options, stackitconfig.WithServiceAccountKeyPath(cfg.KeyPath)) + case AuthTypeExplicitWIF: + options = append(options, stackitconfig.WithWorkloadIdentityFederationAuth()) + if len(cfg.WIFTokenPath) > 0 { + options = append(options, stackitconfig.WithWorkloadIdentityFederationPath(cfg.WIFTokenPath)) + } + case AuthTypeDefault: } - options = append(options, stackitconfig.WithBackgroundTokenRefresh(context.Background())) - return append(options, stackitconfig.WithServiceAccountKeyPath(keyPath)), nil + return options, nil } diff --git a/pkg/stackit/options_test.go b/pkg/stackit/options_test.go index 1526f72..b011810 100644 --- a/pkg/stackit/options_test.go +++ b/pkg/stackit/options_test.go @@ -8,48 +8,73 @@ import ( func TestMissingBaseURL(t *testing.T) { t.Parallel() - - options, err := SetConfigOptions("", "", "", "") + cfg := WebhookAuthConfig{} + options, err := SetConfigOptions(&cfg) assert.ErrorContains(t, err, "base-url") assert.Nil(t, options) } -func TestBothAuthOptionsMissing(t *testing.T) { +func TestNoAuthOptionsSet_FallsBackToDefaultAuth(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) + cfg := WebhookAuthConfig{BaseURL: "https://example.com"} + options, err := SetConfigOptions(&cfg) + assert.NoError(t, err) + assert.Len(t, options, 3) } -func TestBothAuthOptionsSet(t *testing.T) { +func TestMultipleAuthOptionsSet_ReturnsError(t *testing.T) { t.Parallel() - - options, err := SetConfigOptions("https://example.com", "token", "key/path", "") - assert.ErrorContains(t, err, "auth-token or auth-key-path") + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + KeyPath: "key/path", + WIFEnabled: true, + } + options, err := SetConfigOptions(&cfg) + assert.ErrorContains(t, err, "ambiguous authentication configuration") assert.Nil(t, options) } -func TestBearerTokenSet(t *testing.T) { +func TestKeyPathSet(t *testing.T) { t.Parallel() - - options, err := SetConfigOptions("https://example.com", "token", "", "") + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + KeyPath: "key/path", + } + options, err := SetConfigOptions(&cfg) assert.NoError(t, err) - assert.Len(t, options, 3) + assert.Len(t, options, 4) } -func TestKeyPathSet(t *testing.T) { +func TestWIFSet_WithoutTokenPath(t *testing.T) { t.Parallel() - - options, err := SetConfigOptions("https://example.com", "", "key/path", "") + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + WIFEnabled: true, + } + options, err := SetConfigOptions(&cfg) assert.NoError(t, err) assert.Len(t, options, 4) } -func TestKeyPathAndURLSet(t *testing.T) { +func TestWIFSet_WithTokenPath(t *testing.T) { t.Parallel() + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + WIFTokenPath: "/var/run/secrets/tokens/stackit-token", + } + options, err := SetConfigOptions(&cfg) + assert.NoError(t, err) + assert.Len(t, options, 5) +} - options, err := SetConfigOptions("https://example.com", "", "key/path", "https://alternative.url.stackit.cloud/token") +func TestKeyPathAndURLSet(t *testing.T) { + t.Parallel() + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + KeyPath: "key/path", + TokenURL: "https://alternative.url.stackit.cloud/token", + } + options, err := SetConfigOptions(&cfg) assert.NoError(t, err) assert.Len(t, options, 5) }