From ca0f7854d557320a4b307f7ffa08e368db169121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Tue, 18 Aug 2026 14:24:14 +0000 Subject: [PATCH 1/5] Add new authorization path with wif, rework auth logic to allow STACKIT DefaultAuth --- cmd/webhook/cmd/root.go | 66 ++++++++++++++++------------ pkg/stackit/options.go | 85 ++++++++++++++++++++++++++++--------- pkg/stackit/options_test.go | 77 ++++++++++++++++++++++++++------- 3 files changed, 164 insertions(+), 64 deletions(-) diff --git a/cmd/webhook/cmd/root.go b/cmd/webhook/cmd/root.go index 1f05107..d2ae642 100644 --- a/cmd/webhook/cmd/root.go +++ b/cmd/webhook/cmd/root.go @@ -19,16 +19,18 @@ import ( ) var ( - apiPort string - authBearerToken string - authKeyPath string - tokenUrl string - baseUrl string - projectID string - worker int - domainFilter []string - dryRun bool - logLevel string + apiPort string + authBearerToken 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,31 +48,38 @@ var rootCmd = &cobra.Command{ endpointDomainFilter := endpoint.DomainFilter{Filters: domainFilter} - stackitConfigOptions, err := stackit.SetConfigOptions(baseUrl, authBearerToken, authKeyPath, tokenUrl) + authConfig := &stackit.WebhookAuthConfig{ + BaseURL: baseUrl, + TokenURL: tokenUrl, + Token: authBearerToken, + KeyPath: authKeyPath, + WIFEnabled: authWif, + WIFTokenPath: authWifTokenPath, + } + + stackitConfigOptions, err := stackit.SetConfigOptions(authConfig) if err != nil { - panic(err) + logger.Fatal("failed to set STACKIT config options", zap.Error(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 { - panic(err) + logger.Fatal("failed to initialize STACKIT DNS provider", zap.Error(err)) } app := api.New(logger.With(zap.String("component", "api")), metrics.NewHttpApiMetrics(), stackitProvider) err = app.Listen(apiPort) if err != nil { - panic(err) + logger.Fatal("server error", zap.Error(err)) } }, } @@ -114,27 +123,28 @@ 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(&authBearerToken, "auth-token", "", "Defines the authentication token for the STACKIT API. Mutually exclusive with 'auth-key-path' and 'auth-wif'.") + 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' and 'auth-wif'.") + rootCmd.PersistentFlags().BoolVar(&authWif, "auth-wif", false, "Enables Workload Identity Federation (WIF) authentication explicitly.") + 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..243d76e 100644 --- a/pkg/stackit/options.go +++ b/pkg/stackit/options.go @@ -9,37 +9,82 @@ 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 + AuthTypeExplicitToken + AuthTypeExplicitKey + AuthTypeExplicitWIF +) + +type WebhookAuthConfig struct { + BaseURL string + TokenURL string + Token string + KeyPath string + WIFEnabled bool + WIFTokenPath string +} + +func determineAuthType(cfg *WebhookAuthConfig) (AuthType, error) { + var activeTypes []AuthType + + if len(cfg.Token) > 0 { + activeTypes = append(activeTypes, AuthTypeExplicitToken) + } + 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-token, 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 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 AuthTypeExplicitToken: + options = append(options, stackitconfig.WithToken(cfg.Token)) + 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..133ad47 100644 --- a/pkg/stackit/options_test.go +++ b/pkg/stackit/options_test.go @@ -8,48 +8,93 @@ 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() + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + Token: "token", + KeyPath: "key/path", + } + options, err := SetConfigOptions(&cfg) + assert.ErrorContains(t, err, "ambiguous authentication configuration") + assert.Nil(t, options) - 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) { t.Parallel() - - options, err := SetConfigOptions("https://example.com", "token", "", "") + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + Token: "token", + } + options, err := SetConfigOptions(&cfg) assert.NoError(t, err) - assert.Len(t, options, 3) + assert.Len(t, options, 4) } func TestKeyPathSet(t *testing.T) { t.Parallel() + cfg := WebhookAuthConfig{ + BaseURL: "https://example.com", + KeyPath: "key/path", + } + options, err := SetConfigOptions(&cfg) + assert.NoError(t, err) + assert.Len(t, options, 4) +} - options, err := SetConfigOptions("https://example.com", "", "key/path", "") +func TestWIFSet_WithoutTokenPath(t *testing.T) { + t.Parallel() + 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) } From 2f978bddbf51ad04764e5cf8d9bb2bb0bc3df3a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Wed, 19 Aug 2026 09:25:59 +0000 Subject: [PATCH 2/5] Remove deprecated static token auth flow. --- cmd/webhook/cmd/root.go | 7 ++----- pkg/stackit/options.go | 9 +-------- pkg/stackit/options_test.go | 22 +--------------------- 3 files changed, 4 insertions(+), 34 deletions(-) diff --git a/cmd/webhook/cmd/root.go b/cmd/webhook/cmd/root.go index d2ae642..652077a 100644 --- a/cmd/webhook/cmd/root.go +++ b/cmd/webhook/cmd/root.go @@ -20,7 +20,6 @@ import ( var ( apiPort string - authBearerToken string authKeyPath string authWif bool authWifTokenPath string @@ -51,7 +50,6 @@ var rootCmd = &cobra.Command{ authConfig := &stackit.WebhookAuthConfig{ BaseURL: baseUrl, TokenURL: tokenUrl, - Token: authBearerToken, KeyPath: authKeyPath, WIFEnabled: authWif, WIFTokenPath: authWifTokenPath, @@ -123,9 +121,8 @@ 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' and 'auth-wif'.") - 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' and 'auth-wif'.") - rootCmd.PersistentFlags().BoolVar(&authWif, "auth-wif", false, "Enables Workload Identity Federation (WIF) authentication explicitly.") + 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.") diff --git a/pkg/stackit/options.go b/pkg/stackit/options.go index 243d76e..cbbe435 100644 --- a/pkg/stackit/options.go +++ b/pkg/stackit/options.go @@ -13,7 +13,6 @@ type AuthType int const ( AuthTypeDefault AuthType = iota - AuthTypeExplicitToken AuthTypeExplicitKey AuthTypeExplicitWIF ) @@ -21,7 +20,6 @@ const ( type WebhookAuthConfig struct { BaseURL string TokenURL string - Token string KeyPath string WIFEnabled bool WIFTokenPath string @@ -30,9 +28,6 @@ type WebhookAuthConfig struct { func determineAuthType(cfg *WebhookAuthConfig) (AuthType, error) { var activeTypes []AuthType - if len(cfg.Token) > 0 { - activeTypes = append(activeTypes, AuthTypeExplicitToken) - } if len(cfg.KeyPath) > 0 { activeTypes = append(activeTypes, AuthTypeExplicitKey) } @@ -41,7 +36,7 @@ func determineAuthType(cfg *WebhookAuthConfig) (AuthType, error) { } if len(activeTypes) > 1 { - return AuthTypeDefault, fmt.Errorf("ambiguous authentication configuration: specify at most one of auth-token, auth-key-path, or auth-wif/auth-wif-token-path") + 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 { @@ -74,8 +69,6 @@ func SetConfigOptions(cfg *WebhookAuthConfig) ([]stackitconfig.ConfigurationOpti } switch authType { - case AuthTypeExplicitToken: - options = append(options, stackitconfig.WithToken(cfg.Token)) case AuthTypeExplicitKey: options = append(options, stackitconfig.WithServiceAccountKeyPath(cfg.KeyPath)) case AuthTypeExplicitWIF: diff --git a/pkg/stackit/options_test.go b/pkg/stackit/options_test.go index 133ad47..b011810 100644 --- a/pkg/stackit/options_test.go +++ b/pkg/stackit/options_test.go @@ -25,35 +25,15 @@ func TestNoAuthOptionsSet_FallsBackToDefaultAuth(t *testing.T) { func TestMultipleAuthOptionsSet_ReturnsError(t *testing.T) { t.Parallel() cfg := WebhookAuthConfig{ - BaseURL: "https://example.com", - Token: "token", - KeyPath: "key/path", - } - options, err := SetConfigOptions(&cfg) - assert.ErrorContains(t, err, "ambiguous authentication configuration") - assert.Nil(t, options) - - cfg = WebhookAuthConfig{ BaseURL: "https://example.com", KeyPath: "key/path", WIFEnabled: true, } - options, err = SetConfigOptions(&cfg) + options, err := SetConfigOptions(&cfg) assert.ErrorContains(t, err, "ambiguous authentication configuration") assert.Nil(t, options) } -func TestBearerTokenSet(t *testing.T) { - t.Parallel() - cfg := WebhookAuthConfig{ - BaseURL: "https://example.com", - Token: "token", - } - options, err := SetConfigOptions(&cfg) - assert.NoError(t, err) - assert.Len(t, options, 4) -} - func TestKeyPathSet(t *testing.T) { t.Parallel() cfg := WebhookAuthConfig{ From 0c3ef9c831475e23e575ffbeff904fcac9bcd479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Wed, 19 Aug 2026 09:54:42 +0000 Subject: [PATCH 3/5] Adjust README to reflect new auth reality. --- README.md | 275 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 143 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 9986654..29c2afc 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?** -Run the app: +**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. +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 BASE_URL="[https://dns.api.stackit.cloud](https://dns.api.stackit.cloud)" export PROJECT_ID="c158c736-0300-4044-95c4-b7d404279b35" export AUTH_KEY_PATH="/absolute/path/to/stackit-service-account-key.json" @@ -351,13 +364,11 @@ make run ``` Lint the code: - ```bash make lint ``` Test the code: - ```bash make test ``` @@ -373,4 +384,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 From e09d4b09f018670ca9a44ab0ad04de0a2d2b58cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Wed, 19 Aug 2026 09:56:05 +0000 Subject: [PATCH 4/5] Make WIF flow documentation more clear. --- README.md | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 29c2afc..1a7b6d4 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,25 @@ spec: If your cluster supports Workload Identity Federation, you can avoid managing long-lived Service Account keys entirely by projecting a short-lived token into the webhook container. -For prerequisites and cluster setup, refer to the [STACKIT Workload Identity Federation documentation](https://docs.stackit.cloud/products/runtime/kubernetes-engine/how-tos/workload-identity/). +For prerequisites and cluster setup, refer to the [Use Workload Identity STACKIT documentation](https://docs.stackit.cloud/de/products/runtime/kubernetes-engine/how-tos/workload-identity/). + +If you are using STACKIT Kubernetes Engine (SKE) or have the `stackit-pod-identity-webhook` installed, you do not need to manually mount the projected token volumes. You simply annotate the ServiceAccount, and the identity webhook will automatically inject the token and `STACKIT_FEDERATED_TOKEN_FILE` environment variable into the pod. + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns + namespace: default + annotations: + # Specify the STACKIT Service Account email to assume the identity of + workload-identity.stackit.cloud/service-account-email: "your-service-account@sa.stackit.cloud" + labels: + app.kubernetes.io/name: external-dns + app.kubernetes.io/instance: external-dns +``` + +In your deployment, simply pass the `--auth-wif` flag to explicitly enforce the federated flow: ```yaml - name: webhook @@ -224,22 +242,6 @@ For prerequisites and cluster setup, refer to the [STACKIT Workload Identity Fed args: - --project-id=c158c736-0300-4044-95c4-b7d404279b35 - --auth-wif - env: - # The SDK natively looks for this environment variable to locate the projected token - - name: STACKIT_FEDERATED_TOKEN_FILE - value: /var/run/secrets/tokens/stackit-token - volumeMounts: - - name: stackit-token - mountPath: /var/run/secrets/tokens - readOnly: true - volumes: - - name: stackit-token - projected: - sources: - - serviceAccountToken: - audience: [https://stackit.cloud](https://stackit.cloud) - expirationSeconds: 3600 - path: stackit-token ``` ## Configuration @@ -249,7 +251,7 @@ The configuration of the STACKIT webhook is accomplished through command-line ar ### Authentication Flags (Choose ONE) - `--auth-key-path`/`AUTH_KEY_PATH`: Defines the file path of the Service Account key JSON. - `--auth-wif`/`AUTH_WIF` (boolean): Explicitly enables Workload Identity Federation (WIF) authentication. -- `--auth-wif-token-path`/`AUTH_WIF_TOKEN_PATH` (optional): Defines a custom file path for the federated JWT token for WIF authentication. This is generally only needed if you are overriding standard Kubernetes volume projections. +- `--auth-wif-token-path`/`AUTH_WIF_TOKEN_PATH` (optional): Defines a custom file path for the federated JWT token for WIF authentication. This is generally only needed if you are overriding standard Kubernetes volume projections manually. *Note: If no explicit `--auth-*` flags are provided, the webhook delegates authentication to the STACKIT SDK, which will automatically search the environment for standard SDK variables (e.g., `STACKIT_FEDERATED_TOKEN_FILE`, `STACKIT_SERVICE_ACCOUNT_KEY_PATH`) or a local `~/.stackit/credentials.json` file.* From b20820b88bba43f4dd12cc4953445c996e5801aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20B=C4=9Bh=C3=A1vka?= Date: Wed, 19 Aug 2026 10:27:04 +0000 Subject: [PATCH 5/5] fixes from PR. --- README.md | 2 +- cmd/webhook/cmd/root.go | 9 ++++++--- pkg/stackit/options.go | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1a7b6d4..a2ad948 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ There are two potential fixes: Run the app locally: ```bash -export BASE_URL="[https://dns.api.stackit.cloud](https://dns.api.stackit.cloud)" +export BASE_URL="https://dns.api.stackit.cloud" export PROJECT_ID="c158c736-0300-4044-95c4-b7d404279b35" export AUTH_KEY_PATH="/absolute/path/to/stackit-service-account-key.json" diff --git a/cmd/webhook/cmd/root.go b/cmd/webhook/cmd/root.go index 652077a..3062500 100644 --- a/cmd/webhook/cmd/root.go +++ b/cmd/webhook/cmd/root.go @@ -57,7 +57,8 @@ var rootCmd = &cobra.Command{ stackitConfigOptions, err := stackit.SetConfigOptions(authConfig) if err != nil { - logger.Fatal("failed to set STACKIT config options", zap.Error(err)) + logger.Error("failed to set STACKIT config options", zap.Error(err)) + panic(err) } stackitProvider, err := stackitprovider.NewStackitDNSProvider( @@ -71,13 +72,15 @@ var rootCmd = &cobra.Command{ stackitConfigOptions..., ) if err != nil { - logger.Fatal("failed to initialize STACKIT DNS provider", zap.Error(err)) + 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.Fatal("server error", zap.Error(err)) + logger.Error("server error", zap.Error(err)) + panic(err) } }, } diff --git a/pkg/stackit/options.go b/pkg/stackit/options.go index cbbe435..966e376 100644 --- a/pkg/stackit/options.go +++ b/pkg/stackit/options.go @@ -47,6 +47,9 @@ func determineAuthType(cfg *WebhookAuthConfig) (AuthType, error) { } 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") }