Compare commits

...

86 Commits

Author SHA1 Message Date
Matheus Pimenta 4ca8fb011f
Merge pull request #1007 from cappyzawa/remove-servername-pinning
runtime/secrets: remove ServerName pinning from TLS config
2025-08-14 14:36:26 +01:00
cappyzawa 8ce7a0dc67
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-08-14 22:04:01 +09:00
cappyzawa a254eaccd7
runtime/secrets: remove ServerName pinning from TLS config
Remove ServerName pinning functionality that can cause TLS
verification failures in production environments with redirects,
proxies, and multi-host scenarios.

The Go standard library automatically handles SNI and hostname
verification based on the actual connection target, providing
better compatibility and security than fixed ServerName values.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-08-14 22:03:28 +09:00
Stefan Prodan 6bf77f094c
Merge pull request #1006 from fluxcd/oci-rename-util
oci: Expose `RenameWithFallback` utility
2025-08-12 13:37:27 +03:00
Stefan Prodan b0abad5db0
oci: Expose `RenameWithFallback` utility
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-08-12 13:21:31 +03:00
Stefan Prodan 621a899e4c
Merge pull request #1005 from fluxcd/dependabot/github_actions/ci-914c47a322
build(deps): bump the ci group across 1 directory with 7 updates
2025-08-12 08:47:31 +03:00
dependabot[bot] 2c7a66601d
build(deps): bump the ci group across 1 directory with 7 updates
Bumps the ci group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4.2.2` | `5.0.0` |
| [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) | `4.2.1` | `4.3.1` |
| [actions/cache](https://github.com/actions/cache) | `4.2.3` | `4.2.4` |
| [google-github-actions/auth](https://github.com/google-github-actions/auth) | `2.1.10` | `2.1.12` |
| [google-github-actions/setup-gcloud](https://github.com/google-github-actions/setup-gcloud) | `2.1.4` | `2.2.0` |
| [docker/login-action](https://github.com/docker/login-action) | `3.4.0` | `3.5.0` |
| [github/codeql-action](https://github.com/github/codeql-action) | `3.29.2` | `3.29.8` |



Updates `actions/checkout` from 4.2.2 to 5.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](11bd71901b...08c6903cd8)

Updates `aws-actions/configure-aws-credentials` from 4.2.1 to 4.3.1
- [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases)
- [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md)
- [Commits](b475783126...7474bc4690)

Updates `actions/cache` from 4.2.3 to 4.2.4
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](5a3ec84eff...0400d5f644)

Updates `google-github-actions/auth` from 2.1.10 to 2.1.12
- [Release notes](https://github.com/google-github-actions/auth/releases)
- [Changelog](https://github.com/google-github-actions/auth/blob/main/CHANGELOG.md)
- [Commits](ba79af0395...b7593ed2ef)

Updates `google-github-actions/setup-gcloud` from 2.1.4 to 2.2.0
- [Release notes](https://github.com/google-github-actions/setup-gcloud/releases)
- [Changelog](https://github.com/google-github-actions/setup-gcloud/blob/main/CHANGELOG.md)
- [Commits](77e7a554d4...cb1e50a993)

Updates `docker/login-action` from 3.4.0 to 3.5.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](74a5d14239...184bdaa072)

Updates `github/codeql-action` from 3.29.2 to 3.29.8
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](181d5eefc2...76621b61de)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: ci
- dependency-name: aws-actions/configure-aws-credentials
  dependency-version: 4.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: ci
- dependency-name: actions/cache
  dependency-version: 4.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: ci
- dependency-name: google-github-actions/auth
  dependency-version: 2.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: ci
- dependency-name: google-github-actions/setup-gcloud
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: ci
- dependency-name: docker/login-action
  dependency-version: 3.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: ci
- dependency-name: github/codeql-action
  dependency-version: 3.29.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: ci
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 04:34:29 +00:00
Matheus Pimenta 4bb23ecbdb
Merge pull request #1002 from dipti-pai/azure-privatecloud-oidc
auth/azure: Add support for custom Azure cloud configurations
2025-08-07 19:39:47 +01:00
Dipti Pai d6fbf47b79 auth/azure: Add support for custom Azure cloud configurations
- Read Azure endpoint configuration from JSON file.
- JSON file path is configured via AZURE_ENVIRONMENT_FILEPATH env variable.
- Unit tests.

Signed-off-by: Dipti Pai <diptipai89@outlook.com>
2025-08-07 11:23:52 -07:00
Matheus Pimenta a849bcf3b7
Merge pull request #1004 from fluxcd/auth-hc-refactor
auth: Refactor usage of http client from options
2025-08-07 16:55:43 +01:00
Matheus Pimenta a795fdc737
auth: Refactor usage of http client from options
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-08-07 15:59:34 +01:00
Matheus Pimenta 11982f6ab4
Merge pull request #1003 from cappyzawa/feat/http-client-timeout
auth: add 10-second timeout to HTTP client for token acquisition
2025-08-07 11:08:12 +01:00
cappyzawa a2c5712e16
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-08-07 10:42:03 +09:00
cappyzawa 48209adba5
auth: add 10-second timeout to HTTP client for token acquisition
Improve the GetHTTPClient() method to always return an HTTP client
with a 10-second timeout, preventing indefinite hangs during token
acquisition from cloud provider STS services.

The oauth2 library may not properly handle context cancellation
internally, so setting a timeout at the HTTP client level provides
reliable protection against indefinite hangs when making requests
to cloud provider endpoints for token exchange.

This ensures token acquisition completes within a reasonable time
frame while maintaining existing proxy configuration support.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-08-07 10:34:13 +09:00
Matheus Pimenta f227e67fdd
Merge pull request #1001 from cappyzawa/feat/github-app-support-authmethodsfromsecret
runtime/secrets: add GitHub App support to AuthMethodsFromSecret
2025-08-06 13:03:29 +01:00
cappyzawa 5f5e254bd2
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-08-06 14:48:09 +09:00
cappyzawa c3282981b7
runtime/secrets: add GitHub App support to AuthMethodsFromSecret
Enable unified GitHub App authentication handling across Flux controllers
by integrating GitHub App support into the existing AuthMethodsFromSecret
pattern used by other controllers.

This improves consistency and reduces code duplication when controllers
need to handle GitHub App authentication. The implementation uses a type
alias for direct compatibility with existing pkg/git/github APIs while
maintaining package independence.

Remove unused GitHubAppAuth struct to avoid confusion with the new
GitHubAppData type alias.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-08-05 20:50:59 +09:00
Matheus Pimenta f01e8d6848
Merge pull request #999 from abhijith-darshan/feat/gh_app_ca
git/github: Add support for mTLS to GitHub App transport
2025-08-02 21:39:16 +01:00
Matheus Pimenta e77a11bc22
Prepare for release
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-08-02 21:19:00 +01:00
abhijith-darshan 9fd9628968
git/github: Add support for mTLS to GitHub App transport
This commit ensures if available, a custom ca.crt is appended to system cert pool and set to the github app transport tls configuration

Signed-off-by: abhijith-darshan <abhijith.darshan@hotmail.com>

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-08-02 21:17:30 +01:00
Matheus Pimenta 04d916d0c5
Merge pull request #997 from cappyzawa/feat/tls-config-from-secret-ref-options
runtime/secrets: add TLSConfigOption support to TLSConfigFromSecretRef
2025-07-29 23:46:30 +01:00
cappyzawa 4642dabf28
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-30 07:19:18 +09:00
cappyzawa 27b414e80f
runtime/secrets: add TLSConfigOption support to TLSConfigFromSecretRef
Add TLSConfigOption support to TLSConfigFromSecretRef function to
maintain consistency with TLSConfigFromSecret. This enables the same
configuration options (like WithSystemCertPool) for both functions.

The change maintains backward compatibility by using variadic parameters.
Also added test coverage for the new functionality.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-30 07:17:36 +09:00
Matheus Pimenta dbf1d227b0
Merge pull request #995 from cappyzawa/feat/tls-config-options
runtime/secrets: add WithSystemCertPool for CA handling
2025-07-29 17:43:51 +01:00
cappyzawa cb022f764d
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-30 01:27:10 +09:00
cappyzawa 488ca0955a
runtime/secrets: add WithSystemCertPool for CA handling
Flux controllers require different CA certificate handling modes:
- Replace mode (default): trust only user-provided CA certificates
- Extend mode: trust both system and user-provided CA certificates

This change adds functional options to TLSConfigFromSecret and
AuthMethodsFromSecret to support both modes. The default behavior
remains unchanged (replace mode) for security, while controllers
can opt-in to extend mode using WithSystemCertPool().

This enables unified secret handling across all Flux components
while maintaining backward compatibility and controller-specific
CA trust requirements.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-30 01:26:30 +09:00
Matheus Pimenta 1c8c7bb531
Merge pull request #993 from cappyzawa/feat/remove-insecure-parameter
runtime/secrets: remove insecure parameter from TLS funcs
2025-07-28 16:00:07 +01:00
cappyzawa e98aecf00e
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-28 23:40:32 +09:00
cappyzawa b59d18da0a
runtime/secrets: remove insecure parameter from TLS funcs
Remove the insecure boolean parameter from TLSConfigFromSecret
and TLSConfigFromSecretRef functions to enforce Flux security
policy that prohibits bypassing TLS certificate validation.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-28 23:39:49 +09:00
Stefan Prodan 44b53bd4ae
Merge pull request #992 from fluxcd/ssa-manager-exact-match
ssa: Add exact match option to field manager removal
2025-07-28 12:42:34 +03:00
Stefan Prodan ff888a4ac7
ssa: Add exact match option to field manager removal
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-28 00:21:32 +03:00
Matheus Pimenta 95760a79c9
Merge pull request #991 from cappyzawa/feat/runtime-secrets-targeturl-insecure
runtime/secrets: add targetURL and insecure to TLS functions
2025-07-21 15:38:06 +01:00
cappyzawa 60b1dea324
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-21 00:30:30 +09:00
cappyzawa 7930b7b806
runtime/secrets: add targetURL and insecure to TLS functions
Add targetURL and insecure parameters to TLSConfigFromSecret and
TLSConfigFromSecretRef to resolve ServerName regression issues in
virtual hosting environments.

The migration to runtime/secrets caused TLS handshake failures
because Go cannot automatically set ServerName when custom
tls.Config is provided to http.Transport.TLSClientConfig.

This change enables proper SNI support by extracting hostname
from targetURL and setting tlsConfig.ServerName. The insecure
parameter provides consistent InsecureSkipVerify handling across
all Flux controllers.

Implement Functional Options Pattern for AuthMethodsFromSecret
with WithTLS() option to support mixed authentication scenarios
while maintaining backward compatibility through default values.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-21 00:27:20 +09:00
Matheus Pimenta ea0856db0e
Merge pull request #990 from ymettier/bug-nested-map-slice-slice
chartutil: Fix sort not recursing for slices
2025-07-19 13:32:17 +01:00
Yves Mettier 525798301f chartutil: Fix sort not recursing for slices
Signed-off-by: Yves Mettier <ymettier@free.fr>
2025-07-19 13:11:15 +01:00
Matheus Pimenta 4f64822f3a
Merge pull request #989 from cappyzawa/feat/runtime-secrets-token-auth-support
runtime/secrets: add TokenAuth detection support
2025-07-18 15:44:06 +01:00
cappyzawa a75690c399
Prepare for release
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-18 23:25:14 +09:00
cappyzawa 447804609f
runtime/secrets: add TokenAuth detection support
Add TokenAuth detection to AuthMethodsFromSecret. TokenAuth uses "token"
key while BearerAuth uses "bearerToken" key, enabling unified token-based
authentication through runtime/secrets.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-18 20:17:34 +09:00
cappyzawa 754624dafa
runtime/secrets: simplify BearerAuth to string type
BearerAuth represents a single string value and doesn't need struct
wrapping. This change improves memory efficiency and aligns with Go
idioms for simple value types.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-18 20:17:34 +09:00
Matheus Pimenta d93949611e
Merge pull request #988 from fluxcd/restconfig-azure-cloud
[RFC-0010] auth/azure: Support all Azure clouds for remote clusters at the controller level
2025-07-18 08:08:21 +01:00
Matheus Pimenta ef45e8d331
[RFC-0010] auth/azure: Support all Azure clouds for remote clusters at the controller level
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-17 17:40:34 +01:00
Matheus Pimenta c3b108fc36
Merge pull request #983 from cappyzawa/feature/detect-auth-from-secret
runtime/secrets: add AuthMethodsFromSecret for multiple auth support
2025-07-17 17:36:43 +01:00
Matheus Pimenta fddd5f143b
Prepare for release
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-17 17:18:47 +01:00
cappyzawa 22e822f7d9
runtime/secrets: support multiple authentication methods in secrets
Add AuthMethodsFromSecret function to handle secrets containing
multiple authentication credentials, enabling combinations like
Basic Auth + TLS or Bearer Token + SSH authentication.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-18 01:08:40 +09:00
Matheus Pimenta dc9bf746cb
Merge pull request #986 from jvcdk/bugfix/retryable-http-should-honor-http-code-429
runtime/events: Fix rate limits error handling in recorder
2025-07-16 14:32:23 +01:00
Matheus Pimenta 8804d2f561
Prepare for release
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-16 14:14:32 +01:00
Jørn Villesen Christensen 269920c337
runtime/events: Fix rate limits error handling in recorder
The notification controller sends a 429 Too Many Requests message when a message is duplicated. Thus if we
receive a 429, we should discard the message.

Signed-off-by: Jørn Villesen Christensen <11997038+jvcdk@users.noreply.github.com>
2025-07-16 14:14:06 +01:00
Matheus Pimenta 2e38bdab2d
Merge pull request #985 from fluxcd/test-watch-helm
runtime/controller: Add test case for watching all configs except helm storage
2025-07-16 08:43:29 +01:00
Matheus Pimenta 7528b2cc9c
runtime/controller: Add test case for watching all configs except helm storage
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-16 08:25:01 +01:00
Matheus Pimenta 86263bb10b
Merge pull request #984 from fluxcd/watch-configs-empty-everything
runtime/controller: Fix watch configs for empty label selector
2025-07-16 08:16:34 +01:00
Matheus Pimenta 7c029aeb7b
runtime/controller: Fix watch configs for empty label selector
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-16 07:53:15 +01:00
Matheus Pimenta 7a820924bf
Merge pull request #982 from fluxcd/watch-label
Introduce label selector for watching ConfigMaps and Secrets
2025-07-15 18:59:36 +01:00
Matheus Pimenta 252071547d
runtime/controller: Use controller_test package for tests
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-15 18:46:11 +01:00
Matheus Pimenta 200b6b6972
runtime/controller: Introduce flag for watching config resources
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-15 18:46:11 +01:00
Matheus Pimenta 237b27666d
apis/meta: Introduce label for watching config resources
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-15 18:46:06 +01:00
Stefan Prodan c706ea8774
Merge pull request #944 from fluxcd/dependabot/github_actions/ci-08ea3624a6
build(deps): bump the ci group across 1 directory with 3 updates
2025-07-15 09:04:13 +03:00
dependabot[bot] f63a79748e
build(deps): bump the ci group across 1 directory with 3 updates
Bumps the ci group with 3 updates in the / directory: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [ossf/scorecard-action](https://github.com/ossf/scorecard-action) and [github/codeql-action](https://github.com/github/codeql-action).


Updates `docker/setup-buildx-action` from 3.10.0 to 3.11.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](b5ca514318...18ce135bb5)

Updates `ossf/scorecard-action` from 2.4.1 to 2.4.2
- [Release notes](https://github.com/ossf/scorecard-action/releases)
- [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md)
- [Commits](f49aabe0b5...05b42c6244)

Updates `github/codeql-action` from 3.28.17 to 3.29.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](60168efe1c...ce28f5bb42)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: ci
- dependency-name: ossf/scorecard-action
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: ci
- dependency-name: github/codeql-action
  dependency-version: 3.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: ci
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-14 23:05:02 +00:00
Stefan Prodan 578210e99f
Merge pull request #981 from fluxcd/fix-permissions-gha
ci: Fix missing permissions for `preview-release` workflow
2025-07-14 20:07:58 +03:00
Stefan Prodan 0a9d0bb2a8
ci: Fix missing permissions for `preview-release` workflow
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-14 19:35:37 +03:00
Matheus Pimenta c866cff616
Merge pull request #980 from fluxcd/fix-typo
github: Remove redundant options
2025-07-14 16:17:00 +01:00
Matheus Pimenta 72ea7ac97f
github: Remove redundant options
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-14 15:56:18 +01:00
Matheus Pimenta 66a0184e4d
Merge pull request #979 from fluxcd/preview-release
Add Preview Release workflow
2025-07-14 13:39:07 +01:00
Matheus Pimenta e9a43c60b3
Add Preview Release workflow
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-14 13:27:15 +01:00
Matheus Pimenta 6d798f4353
Merge pull request #960 from fluxcd/restconfig
[RFC-0010] Introduce authentication for clusters
2025-07-14 12:29:40 +01:00
Matheus Pimenta 8bef64b166
[RFC-0010] Introduce authentication for clusters
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-14 11:27:08 +01:00
Stefan Prodan 97cc6ae9e4
Merge pull request #978 from fluxcd/make-sops-secret
runtime/secrets: Add `MakeSOPSSecret`
2025-07-14 13:23:05 +03:00
Stefan Prodan e4649aeb4c
runtime/secrets: Add `MakeSOPSSecret`
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-14 13:03:49 +03:00
Stefan Prodan 5d6f266ea7
Merge pull request #977 from fluxcd/set-secret-gvk
runtime/secrets: Set GVK on generated secrets
2025-07-14 12:29:31 +03:00
Stefan Prodan 5aaefdfd83
runtime/secrets: Set GVK on generated secrets
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-14 12:12:33 +03:00
Matheus Pimenta 7bbf65e375
Merge pull request #976 from fluxcd/refactor-deps-sort
runtime/dependency: Simplify dependency sorting algorithm
2025-07-14 09:30:37 +01:00
Matheus Pimenta d41b5b4983
runtime/dependency: Simplify dependency sorting algorithm
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-14 01:00:45 +01:00
Matheus Pimenta cd4fe98d56
Merge pull request #974 from fluxcd/fix-bump-detection
tools: Fix module bump detection for bumps that cause other bumps
2025-07-13 15:56:36 +01:00
Matheus Pimenta d63225ec17
tools: Fix module bump detection for bumps that cause other bumps
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-13 15:44:14 +01:00
Stefan Prodan b58975195e
Merge pull request #973 from fluxcd/readme-update
List the GitOps Toolkit Go SDK packages
2025-07-13 17:33:26 +03:00
Stefan Prodan 93ecf129b5
List the GitOps Toolkit Go SDK package
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-13 15:28:30 +03:00
Matheus Pimenta f30f3eb1dc
Merge pull request #972 from fluxcd/tools
tools: Introduce CLI tools for helping with Flux development tasks
2025-07-13 12:40:52 +01:00
Matheus Pimenta da8d033dfc
tools: Introduce CLI tools for helping with Flux development tasks
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-13 12:21:58 +01:00
Stefan Prodan a3804267aa
Merge pull request #971 from fluxcd/secrets-gha
runtime/secrets: Add `MakeGitHubAppSecret` & `MakeSSHSecret`
2025-07-13 14:15:45 +03:00
Stefan Prodan 6d156d81e1
runtime/secrets: Add server-side apply helper
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-13 12:09:12 +03:00
Stefan Prodan 022e5483ca
runtime/secrets: Standardise key names
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
2025-07-12 18:11:49 +01:00
Stefan Prodan ddfc64b0ad
runtime/secrets: Add `MakeSSHSecret`
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-12 18:18:04 +03:00
Stefan Prodan 18c2741e48
runtime/secrets: Add `MakeGitHubAppSecret`
Signed-off-by: Stefan Prodan <stefan.prodan@gmail.com>
2025-07-12 17:23:53 +03:00
Matheus Pimenta 856b04da5d
Merge pull request #970 from cappyzawa/add-client-based-secret-variants
runtime/secrets: add client-based API variants for TLS and Proxy functions
2025-07-12 04:49:19 +01:00
cappyzawa 14d994dcd5
runtime/secrets: separate API reading from Secret conversion
Separate file responsibilities in the runtime/secrets package:
- reader.go: API server reading functions (XXXFromSecretRef)
- converter.go: Secret conversion functions (XXXFromSecret)

The client-based API variants (TLSConfigFromSecretRef,
ProxyURLFromSecretRef) provide convenient auto-fetching for
use cases where Secrets contain only relevant data without
mixed authentication information.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-12 05:22:26 +09:00
cappyzawa c929dfc8af
runtime/secrets: add client-based API variants
Add new client-based API functions that automatically fetch secrets
from the API server:

- TLSConfigFromSecretRef: Creates TLS config from secret reference
- ProxyURLFromSecretRef: Creates proxy URL from secret reference

These functions provide convenience wrappers around the existing
Secret pointer-based APIs, reducing boilerplate for common use cases
like spec.certSecretRef and spec.proxySecretRef.

The functions internally use the existing TLSConfigFromSecret and
ProxyURLFromSecret implementations, ensuring consistent behavior
and maintaining all legacy field support.

Signed-off-by: cappyzawa <cappyzawa@gmail.com>
2025-07-12 05:09:32 +09:00
145 changed files with 10844 additions and 2505 deletions

View File

@ -21,7 +21,7 @@ jobs:
name: actions on ${{ matrix.version }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup yq
uses: ./actions/yq
- name: Setup kubeconform

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:

View File

@ -24,7 +24,7 @@ jobs:
- github
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
# Since this is a monorepo, changes in other packages will also trigger these e2e tests
# meant only for the git package. This detects us whether the changed files are part of the

View File

@ -26,7 +26,7 @@ jobs:
working-directory: ./oci/tests/integration
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
@ -35,7 +35,7 @@ jobs:
- name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- name: configure aws credentials
uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.OCI_E2E_AWS_ASSUME_ROLE_NAME }}
role-session-name: OCI_GH_Actions
@ -43,7 +43,7 @@ jobs:
- name: Setup QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Set dynamic variables in .env
run: |
cat > .env <<EOF

View File

@ -25,7 +25,7 @@ jobs:
working-directory: ./oci/tests/integration
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
@ -40,7 +40,7 @@ jobs:
- name: Setup QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Set dynamic variables in .env
run: |
cat > .env <<EOF

View File

@ -19,7 +19,7 @@ jobs:
working-directory: ./tools/reaper
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: fluxcd/test-infra
- name: Setup Go
@ -35,7 +35,7 @@ jobs:
run: echo "GCRGC_VERSION=${GCRGC_VERSION}" >> $GITHUB_ENV
- name: Cache gcrgc
id: cache-gcrgc
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: ~/.local/bin/gcrgc
key: gcrgc-${{ env.GCRGC_VERSION }}
@ -46,11 +46,11 @@ jobs:
wget https://github.com/graillus/gcrgc/releases/download/v${GCRGC_VERSION}/gcrgc_${GCRGC_VERSION}_linux_amd64.tar.gz -O - | tar xz
mv gcrgc ~/.local/bin/
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12
with:
credentials_json: '${{ secrets.CLEANUP_E2E_GOOGLE_CREDENTIALS }}'
- name: Setup gcloud
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397 # v2.2.0
- name: Run gcrgc
# Cleanup all the GCR repositories in the project. They are not tracked
# by terraform used to provision test infra and are left behind.
@ -66,7 +66,7 @@ jobs:
if: true
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: fluxcd/test-infra
- name: Setup Go
@ -89,7 +89,7 @@ jobs:
if: true
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: fluxcd/test-infra
- name: Setup Go
@ -98,7 +98,7 @@ jobs:
go-version: 1.24.x
cache-dependency-path: ./tools/reaper/go.sum
- name: Authenticate to AWS
uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.CLEANUP_E2E_AWS_ASSUME_ROLE_NAME }}
role-session-name: cleanup_GH_Actions

View File

@ -25,7 +25,7 @@ jobs:
working-directory: ./oci/tests/integration
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
@ -34,25 +34,25 @@ jobs:
- name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10
uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12
id: 'auth'
with:
credentials_json: '${{ secrets.OCI_E2E_GOOGLE_CREDENTIALS }}'
token_format: 'access_token'
- name: Setup gcloud
uses: google-github-actions/setup-gcloud@77e7a554d41e2ee56fc945c52dfd3f33d12def9a # v2.1.4
uses: google-github-actions/setup-gcloud@cb1e50a9932213ecece00a606661ae9ca44f3397 # v2.2.0
- name: Setup QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Log into gcr.io
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: gcr.io
username: oauth2accesstoken
password: ${{ steps.auth.outputs.access_token }}
- name: Log into us-central1-docker.pkg.dev
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: us-central1-docker.pkg.dev
username: oauth2accesstoken

View File

@ -25,9 +25,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: "Run analysis"
uses: ossf/scorecard-action@f49aabe0b5af0936a0987cfb85d86b75731b0186 # v2.4.1
uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2
with:
results_file: results.sarif
results_format: sarif
@ -50,6 +50,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
uses: github/codeql-action/upload-sarif@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
with:
sarif_file: results.sarif

24
.github/workflows/preview-release.yaml vendored Normal file
View File

@ -0,0 +1,24 @@
name: preview-release
on:
workflow_dispatch:
jobs:
preview-release:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- run: git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- run: git config --global user.name "github-actions[bot]"
- run: make tools
- run: ./bin/flux-tools pkg prep --yes
- run: git add .
- run: git commit -m "Release preview" || true
- run: ./bin/flux-tools pkg release --preview

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
@ -28,13 +28,13 @@ jobs:
**/go.sum
**/go.mod
- name: Initialize CodeQL
uses: github/codeql-action/init@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
uses: github/codeql-action/init@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
with:
languages: go
# xref: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# xref: https://codeql.github.com/codeql-query-help/go/
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
uses: github/codeql-action/autobuild@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3.28.17
uses: github/codeql-action/analyze@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8

View File

@ -17,7 +17,7 @@ jobs:
permissions:
issues: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: EndBug/label-sync@52074158190acb45f3077f9099fea818aa43f97a # v2.3.3
with:
# Configuration file

View File

@ -119,3 +119,22 @@ fuzz-smoketest: fuzz-build
-e ENVTEST_BIN_VERSION=$(ENVTEST_KUBERNETES_VERSION) \
local-fuzzing:latest \
bash -c "/runner.sh"
# Prepare release for Go modules.
.PHONY: prep
prep: tools
@./bin/flux-tools pkg prep
# Release Go modules.
.PHONY: release
release: tools
@./bin/flux-tools pkg release
# Run vet for tools.
.PHONY: tools
tools:
@cd cmd; \
go mod tidy; \
go fmt ./internal/... ./cli/...; \
go vet ./internal/... ./cli/...; \
go build -o ../bin/flux-tools ./cli

View File

@ -3,4 +3,63 @@
[![godev](https://img.shields.io/static/v1?label=godev&message=reference&color=00add8)](https://pkg.go.dev/github.com/fluxcd/pkg)
[![build](https://github.com/fluxcd/pkg/workflows/build/badge.svg)](https://github.com/fluxcd/pkg/actions)
GitOps Toolkit common packages.
## GitOps Toolkit Go SDK
### APIs
- **[github.com/fluxcd/pkg/apis/acl](./apis/acl)** - API types for defining access control lists
- **[github.com/fluxcd/pkg/apis/event](./apis/event)** - API Schema definitions for Flux eventing
- **[github.com/fluxcd/pkg/apis/kustomize](./apis/kustomize)** - API types for Kustomize resources
- **[github.com/fluxcd/pkg/apis/meta](./apis/meta)** - Generic metadata APIs for Kubernetes resources
### Authentication & Security
- **[github.com/fluxcd/pkg/auth](./auth)** - OIDC-based authentication with cloud providers (AWS, Azure, GCP)
- **[github.com/fluxcd/pkg/masktoken](./masktoken)** - Token redaction utilities for secure logging
- **[github.com/fluxcd/pkg/ssh](./ssh)** - SSH host key scanning and management
### Controller Runtime
- **[github.com/fluxcd/pkg/runtime](./runtime)** - Controller Runtime SDK
- **[runtime/acl](./runtime/acl)** - Cross-namespace access control utilities
- **[runtime/cel](./runtime/cel)** - Common Expression Language (CEL) evaluation utilities
- **[runtime/client](./runtime/client)** - Kubernetes client runtime configuration options
- **[runtime/conditions](./runtime/conditions)** - Status conditions manipulation utilities
- **[runtime/controller](./runtime/controller)** - Controller embeddable structs for GitOps Toolkit conventions
- **[runtime/dependency](./runtime/dependency)** - Dependency sorting for Kubernetes resources
- **[runtime/errors](./runtime/errors)** - Generic controller and reconciler runtime errors
- **[runtime/events](./runtime/events)** - Kubernetes Events recording on external HTTP endpoints
- **[runtime/features](./runtime/features)** - Feature gate management
- **[runtime/jitter](./runtime/jitter)** - Jitter utilities for reconciliation intervals
- **[runtime/leaderelection](./runtime/leaderelection)** - Leader election runtime configuration
- **[runtime/logger](./runtime/logger)** - Logging runtime configuration options
- **[runtime/metrics](./runtime/metrics)** - Standard metrics recording for GitOps Toolkit components
- **[runtime/object](./runtime/object)** - Helpers for interacting with GitOps Toolkit objects
- **[runtime/patch](./runtime/patch)** - Patch utilities for conflict-free object patching
- **[runtime/pprof](./runtime/pprof)** - pprof endpoints registration helper
- **[runtime/predicates](./runtime/predicates)** - Controller-runtime predicates for event filtering
- **[runtime/probes](./runtime/probes)** - Health and readiness probes configuration
- **[runtime/reconcile](./runtime/reconcile)** - Reconciliation helpers and result finalization
- **[runtime/secrets](./runtime/secrets)** - Kubernetes secrets handling utilities (TLS, auth, tokens)
- **[runtime/statusreaders](./runtime/statusreaders)** - Status readers for Kubernetes resources
- **[runtime/testenv](./runtime/testenv)** - Setup helpers for local Kubernetes test environment
- **[runtime/transform](./runtime/transform)** - Type transformation utilities
- **[github.com/fluxcd/pkg/ssa](./ssa)** - Kubernetes resources management using server-side apply
### Source Management
- **[github.com/fluxcd/pkg/git](./git)** - Git repository operations, commit verification, and reference handling
- **[github.com/fluxcd/pkg/sourceignore](./sourceignore)** - Gitignore-like functionality for source filtering
### Package Management
- **[github.com/fluxcd/pkg/chartutil](./chartutil)** - Helm chart values management from Kubernetes resources
- **[github.com/fluxcd/pkg/kustomize](./kustomize)** - Generic helpers for Kustomize operations
- **[github.com/fluxcd/pkg/oci](./oci)** - OCI registry operations (push, pull, tag artifacts)
### Utilities
- **[github.com/fluxcd/pkg/cache](./cache)** - Generic cache implementations (expiring and LRU)
- **[github.com/fluxcd/pkg/envsubst](./envsubst)** - Variable expansion in strings using `${var}` syntax
- **[github.com/fluxcd/pkg/lockedfile](./lockedfile)** - Atomic file operations with locking
- **[github.com/fluxcd/pkg/tar](./tar)** - Secure tarball extraction utilities
- **[github.com/fluxcd/pkg/version](./version)** - Semantic version parsing and sorting
### HTTP & Transport
- **[github.com/fluxcd/pkg/http/fetch](./http/fetch)** - Archive fetcher for HTTP resources
- **[github.com/fluxcd/pkg/http/transport](./http/transport)** - HTTP transport utilities

25
apis/meta/labels.go Normal file
View File

@ -0,0 +1,25 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package meta
const (
// LabelKeyWatch is used to indicate that a resource should be watched by Flux.
LabelKeyWatch = "reconcile.fluxcd.io/watch"
// LabelValueWatchEnabled is the value for LabelKeyWatch that indicates a resource should be watched.
LabelValueWatchEnabled = "Enabled"
)

View File

@ -35,6 +35,14 @@ type NamespacedObjectReference struct {
Namespace string `json:"namespace,omitempty"`
}
// String implements the fmt.Stringer interface for NamespacedObjectReference.
func (in NamespacedObjectReference) String() string {
if in.Namespace != "" {
return in.Namespace + "/" + in.Name
}
return in.Name
}
// NamespacedObjectKindReference contains enough information to locate the typed referenced Kubernetes resource object
// in any namespace.
type NamespacedObjectKindReference struct {
@ -69,19 +77,75 @@ type SecretKeyReference struct {
Key string `json:"key,omitempty"`
}
// KubeConfigReference contains enough information to locate the referenced
// Kubernetes secret that contains a kubeconfig file.
const (
// KubeConfigKeyProvider is the key in the ConfigMap that contains the provider name.
KubeConfigKeyProvider = "provider"
// KubeConfigKeyAddress is the key in the ConfigMap that contains the cluster resource
// name in the provider API
KubeConfigKeyCluster = "cluster"
// KubeConfigKeyAddress is the key in the ConfigMap that contains the address of the
// Kubernetes API server.
KubeConfigKeyAddress = "address"
// KubeConfigKeyCACert is the key in the ConfigMap that contains the PEM-encoded CA
// certificate for the Kubernetes API server.
KubeConfigKeyCACert = "ca.crt"
// KubeConfigKeyAudiences is the key in the ConfigMap that contains the audiences
// for the Kubernetes ServiceAccount token.
KubeConfigKeyAudiences = "audiences"
// KubeConfigKeyServiceAccountName is the key in the ConfigMap that contains the
// name of the Kubernetes ServiceAccount in the same namespace that should be used
// for authentication.
KubeConfigKeyServiceAccountName = "serviceAccountName"
)
// KubeConfigReference contains enough information build a kubeconfig
// in memory for connecting to remote Kubernetes clusters.
// +kubebuilder:validation:XValidation:rule="has(self.configMapRef) || has(self.secretRef)", message="exactly one of spec.kubeConfig.configMapRef or spec.kubeConfig.secretRef must be specified"
// +kubebuilder:validation:XValidation:rule="!has(self.configMapRef) || !has(self.secretRef)", message="exactly one of spec.kubeConfig.configMapRef or spec.kubeConfig.secretRef must be specified"
type KubeConfigReference struct {
// SecretRef holds the name of a secret that contains a key with
// ConfigMapRef holds an optional name of a ConfigMap that contains
// the following keys:
//
// - `provider`: the provider to use. One of `aws`, `azure`, `gcp`, or
// `generic`. Required.
// - `cluster`: the fully qualified resource name of the Kubernetes
// cluster in the cloud provider API. Not used by the `generic`
// provider. Required when one of `address` or `ca.crt` is not set.
// - `address`: the address of the Kubernetes API server. Required
// for `generic`. For the other providers, if not specified, the
// first address in the cluster resource will be used, and if
// specified, it must match one of the addresses in the cluster
// resource.
// If audiences is not set, will be used as the audience for the
// `generic` provider.
// - `ca.crt`: the optional PEM-encoded CA certificate for the
// Kubernetes API server. If not set, the controller will use the
// CA certificate from the cluster resource.
// - `audiences`: the optional audiences as a list of
// line-break-separated strings for the Kubernetes ServiceAccount
// token. Defaults to the `address` for the `generic` provider, or
// to specific values for the other providers depending on the
// provider.
// - `serviceAccountName`: the optional name of the Kubernetes
// ServiceAccount in the same namespace that should be used
// for authentication. If not specified, the controller
// ServiceAccount will be used.
//
// Mutually exclusive with SecretRef.
//
// +optional
ConfigMapRef *LocalObjectReference `json:"configMapRef,omitempty"`
// SecretRef holds an optional name of a secret that contains a key with
// the kubeconfig file as the value. If no key is set, the key will default
// to 'value'.
// to 'value'. Mutually exclusive with ConfigMapRef.
// It is recommended that the kubeconfig is self-contained, and the secret
// is regularly updated if credentials such as a cloud-access-token expire.
// Cloud specific `cmd-path` auth helpers will not function without adding
// binaries and credentials to the Pod that is responsible for reconciling
// Kubernetes resources.
// +required
SecretRef SecretKeyReference `json:"secretRef"`
// Kubernetes resources. Supported only for the generic provider.
// +optional
SecretRef *SecretKeyReference `json:"secretRef,omitempty"`
}
// ValuesReference contains a reference to a resource containing Helm values,

View File

@ -40,7 +40,16 @@ func (in *ForceRequestStatus) DeepCopy() *ForceRequestStatus {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *KubeConfigReference) DeepCopyInto(out *KubeConfigReference) {
*out = *in
out.SecretRef = in.SecretRef
if in.ConfigMapRef != nil {
in, out := &in.ConfigMapRef, &out.ConfigMapRef
*out = new(LocalObjectReference)
**out = **in
}
if in.SecretRef != nil {
in, out := &in.SecretRef, &out.SecretRef
*out = new(SecretKeyReference)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeConfigReference.

185
auth/access_token.go Normal file
View File

@ -0,0 +1,185 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth
import (
"context"
"fmt"
"strings"
authnv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/cache"
)
// GetAccessToken returns an access token for accessing resources in the given cloud provider.
func GetAccessToken(ctx context.Context, provider Provider, opts ...Option) (Token, error) {
var o Options
o.Apply(opts...)
// Initialize access token fetcher for controller.
newAccessToken := func() (Token, error) {
token, err := provider.NewControllerToken(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create provider access token for the controller: %w", err)
}
return token, nil
}
// Update access token fetcher for a service account if specified.
var serviceAccount *corev1.ServiceAccount
var providerIdentity string
var audiences []string
if o.ServiceAccount != nil {
// Fetch service account details.
var err error
serviceAccount, audiences, providerIdentity, err =
getServiceAccountAndProviderInfo(ctx, provider, o.Client, *o.ServiceAccount, opts...)
if err != nil {
return nil, err
}
// Update the function to create an access token using the service account.
newAccessToken = func() (Token, error) {
// Check the feature gate for object-level workload identity.
if !IsObjectLevelWorkloadIdentityEnabled() {
return nil, ErrObjectLevelWorkloadIdentityNotEnabled
}
// Issue Kubernetes OIDC token for the service account.
tokenReq := &authnv1.TokenRequest{
Spec: authnv1.TokenRequestSpec{
Audiences: audiences,
},
}
if err := o.Client.SubResource("token").Create(ctx, serviceAccount, tokenReq); err != nil {
return nil, fmt.Errorf("failed to create kubernetes token for service account '%s/%s': %w",
serviceAccount.Namespace, serviceAccount.Name, err)
}
oidcToken := tokenReq.Status.Token
// Exchange the Kubernetes OIDC token for a provider access token.
token, err := provider.NewTokenForServiceAccount(ctx, oidcToken, *serviceAccount, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create provider access token for service account '%s/%s': %w",
serviceAccount.Namespace, serviceAccount.Name, err)
}
return token, nil
}
}
// Bail out early if cache is disabled.
if o.Cache == nil {
return newAccessToken()
}
// Build cache key.
cacheKey := buildAccessTokenCacheKey(provider, audiences, providerIdentity, serviceAccount, opts...)
// Build involved object details.
kind := o.InvolvedObject.Kind
name := o.InvolvedObject.Name
namespace := o.InvolvedObject.Namespace
operation := o.InvolvedObject.Operation
// Get token from cache.
token, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) {
return newAccessToken()
}, cache.WithInvolvedObject(kind, name, namespace, operation))
if err != nil {
return nil, err
}
return token, nil
}
func getServiceAccountAndProviderInfo(ctx context.Context, provider Provider, client client.Client,
key client.ObjectKey, opts ...Option) (*corev1.ServiceAccount, []string, string, error) {
var o Options
o.Apply(opts...)
// Get service account.
var serviceAccount corev1.ServiceAccount
if err := client.Get(ctx, key, &serviceAccount); err != nil {
return nil, nil, "", fmt.Errorf("failed to get service account '%s/%s': %w",
key.Namespace, key.Name, err)
}
// Get provider audience.
audiences := o.Audiences
if len(audiences) == 0 {
var err error
audiences, err = provider.GetAudiences(ctx, serviceAccount)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to get provider audience: %w", err)
}
}
// Get provider identity.
providerIdentity, err := provider.GetIdentity(serviceAccount)
if err != nil {
return nil, nil, "", fmt.Errorf("failed to get provider identity from service account '%s/%s' annotations: %w",
key.Namespace, key.Name, err)
}
return &serviceAccount, audiences, providerIdentity, nil
}
func buildAccessTokenCacheKey(provider Provider, audiences []string, providerIdentity string,
serviceAccount *corev1.ServiceAccount, opts ...Option) string {
var o Options
o.Apply(opts...)
var parts []string
parts = append(parts, fmt.Sprintf("provider=%s", provider.GetName()))
if serviceAccount != nil {
parts = append(parts, fmt.Sprintf("providerIdentity=%s", providerIdentity))
parts = append(parts, fmt.Sprintf("serviceAccountName=%s", serviceAccount.Name))
parts = append(parts, fmt.Sprintf("serviceAccountNamespace=%s", serviceAccount.Namespace))
parts = append(parts, fmt.Sprintf("serviceAccountTokenAudiences=%s", strings.Join(audiences, ",")))
}
if len(o.Scopes) > 0 {
parts = append(parts, fmt.Sprintf("scopes=%s", strings.Join(o.Scopes, ",")))
}
if o.STSRegion != "" {
parts = append(parts, fmt.Sprintf("stsRegion=%s", o.STSRegion))
}
if o.STSEndpoint != "" {
parts = append(parts, fmt.Sprintf("stsEndpoint=%s", o.STSEndpoint))
}
if o.ProxyURL != nil {
parts = append(parts, fmt.Sprintf("proxyURL=%s", o.ProxyURL))
}
if o.CAData != "" {
parts = append(parts, fmt.Sprintf("caData=%s", o.CAData))
}
return buildCacheKey(parts...)
}

254
auth/access_token_test.go Normal file
View File

@ -0,0 +1,254 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth_test
import (
"context"
"net/url"
"testing"
"time"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/cache"
)
func TestGetAccessToken(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
kubeClient, oidcClient := newTestEnv(t, ctx)
// Create a default service account.
defaultServiceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Namespace: "default",
},
}
err := kubeClient.Create(ctx, defaultServiceAccount)
g.Expect(err).NotTo(HaveOccurred())
saRef := client.ObjectKey{
Name: defaultServiceAccount.Name,
Namespace: defaultServiceAccount.Namespace,
}
for _, tt := range []struct {
name string
provider *mockProvider
opts []auth.Option
disableObjectLevel bool
expectedToken auth.Token
expectedErr string
}{
{
name: "controller access token",
provider: &mockProvider{
returnControllerToken: &mockToken{token: "mock-default-token"},
},
opts: []auth.Option{
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
expectedToken: &mockToken{token: "mock-default-token"},
},
{
name: "controller access token allowing shell out",
provider: &mockProvider{
returnControllerToken: &mockToken{token: "mock-default-token"},
paramAllowShellOut: true,
},
opts: []auth.Option{
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
auth.WithAllowShellOut(),
},
expectedToken: &mockToken{token: "mock-default-token"},
},
{
name: "access token from service account",
provider: &mockProvider{
returnName: "mock-provider",
returnAccessToken: &mockToken{token: "mock-access-token"},
paramAudiences: []string{"audience1", "audience2"},
paramServiceAccount: *defaultServiceAccount,
paramOIDCTokenClient: oidcClient,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
// Exercise the code path where a cache is set but no token is
// available in the cache.
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(1)
g.Expect(err).NotTo(HaveOccurred())
o.Cache = tokenCache
},
},
expectedToken: &mockToken{token: "mock-access-token"},
},
{
name: "access token from service account - default audience",
provider: &mockProvider{
returnName: "mock-provider",
returnAccessToken: &mockToken{token: "mock-access-token"},
paramAudiences: []string{},
paramServiceAccount: *defaultServiceAccount,
paramOIDCTokenClient: oidcClient,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
expectedToken: &mockToken{token: "mock-access-token"},
},
{
name: "all the options are taken into account in the cache key",
provider: &mockProvider{
returnName: "mock-provider",
returnIdentity: "mock-identity",
paramAudiences: []string{"audience1", "audience2"},
paramServiceAccount: *defaultServiceAccount,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(1)
g.Expect(err).NotTo(HaveOccurred())
const key = "db625bd5a96dc48fcc100659c6db98857d1e0ceec930bbded0fdece14af4307c"
token := &mockToken{token: "cached-token"}
cachedToken, ok, err := tokenCache.GetOrSet(ctx, key, func(ctx context.Context) (cache.Token, error) {
return token, nil
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ok).To(BeFalse())
g.Expect(cachedToken).To(Equal(token))
o.Cache = tokenCache
},
},
expectedToken: &mockToken{token: "cached-token"},
},
{
name: "error getting identity",
provider: &mockProvider{
returnIdentityErr: "mock error",
paramServiceAccount: *defaultServiceAccount,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
expectedErr: "failed to get provider identity from service account 'default/default' annotations: mock error",
},
{
name: "error getting identity using cache",
provider: &mockProvider{
returnIdentityErr: "mock error",
paramServiceAccount: *defaultServiceAccount,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(1)
g.Expect(err).NotTo(HaveOccurred())
o.Cache = tokenCache
},
},
expectedErr: "failed to get provider identity from service account 'default/default' annotations: mock error",
},
{
name: "disable object level workload identity",
provider: &mockProvider{
paramServiceAccount: *defaultServiceAccount,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
disableObjectLevel: true,
expectedErr: "ObjectLevelWorkloadIdentity feature gate is not enabled",
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
tt.provider.t = t
if !tt.disableObjectLevel {
t.Setenv(auth.EnvVarEnableObjectLevelWorkloadIdentity, "true")
}
token, err := auth.GetAccessToken(ctx, tt.provider, tt.opts...)
if tt.expectedErr != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal(tt.expectedErr))
g.Expect(token).To(BeNil())
} else {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(tt.expectedToken))
}
})
}
}

View File

@ -37,23 +37,24 @@ func NewCredentialsProvider(ctx context.Context, opts ...auth.Option) aws.Creden
// Retrieve implements aws.CredentialsProvider.
// The context is ignored, use the constructor to set the context.
// This is because some callers of the library pass context.Background()
// when calling this method (e.g. SOPS), so to ensure we have a real
// context we pass it in the constructor.
// This is because the GCP abstraction does not receive a context
// in the method arguments, so we unfortunately need to standardize
// the behavior of all providers around this so the usage of this
// library can be consistent regardless of the provider.
func (c *credentialsProvider) Retrieve(context.Context) (aws.Credentials, error) {
token, err := auth.GetToken(c.ctx, Provider{}, c.opts...)
token, err := auth.GetAccessToken(c.ctx, Provider{}, c.opts...)
if err != nil {
return aws.Credentials{}, err
}
awsToken, ok := token.(*Token)
awsCreds, ok := token.(*Credentials)
if !ok {
return aws.Credentials{}, fmt.Errorf("failed to cast token to AWS token: %T", token)
}
return aws.Credentials{
AccessKeyID: *awsToken.AccessKeyId,
SecretAccessKey: *awsToken.SecretAccessKey,
SessionToken: *awsToken.SessionToken,
Expires: *awsToken.Expiration,
AccessKeyID: *awsCreds.AccessKeyId,
SecretAccessKey: *awsCreds.SecretAccessKey,
SessionToken: *awsCreds.SessionToken,
Expires: *awsCreds.Expiration,
CanExpire: true,
}, nil
}

View File

@ -20,9 +20,11 @@ import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
signerv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ecr"
"github.com/aws/aws-sdk-go-v2/service/ecrpublic"
"github.com/aws/aws-sdk-go-v2/service/eks"
"github.com/aws/aws-sdk-go-v2/service/sts"
)
@ -32,6 +34,8 @@ type Implementation interface {
AssumeRoleWithWebIdentity(ctx context.Context, params *sts.AssumeRoleWithWebIdentityInput, options sts.Options) (*sts.AssumeRoleWithWebIdentityOutput, error)
GetAuthorizationToken(ctx context.Context, cfg aws.Config) (any, error)
GetPublicAuthorizationToken(ctx context.Context, cfg aws.Config) (any, error)
DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, options eks.Options) (*eks.DescribeClusterOutput, error)
PresignGetCallerIdentity(ctx context.Context, optFn func(*sts.PresignOptions), options sts.Options) (*signerv4.PresignedHTTPRequest, error)
}
type implementation struct{}
@ -51,3 +55,11 @@ func (implementation) GetAuthorizationToken(ctx context.Context, cfg aws.Config)
func (implementation) GetPublicAuthorizationToken(ctx context.Context, cfg aws.Config) (any, error) {
return ecrpublic.NewFromConfig(cfg).GetAuthorizationToken(ctx, &ecrpublic.GetAuthorizationTokenInput{})
}
func (implementation) DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, options eks.Options) (*eks.DescribeClusterOutput, error) {
return eks.New(options).DescribeCluster(ctx, params)
}
func (implementation) PresignGetCallerIdentity(ctx context.Context, optFn func(*sts.PresignOptions), options sts.Options) (*signerv4.PresignedHTTPRequest, error) {
return sts.NewPresignClient(sts.New(options)).PresignGetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}, optFn)
}

View File

@ -19,16 +19,21 @@ package aws_test
import (
"context"
"encoding/base64"
"errors"
"net/http"
"net/url"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
signerv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ecr"
ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types"
"github.com/aws/aws-sdk-go-v2/service/ecrpublic"
ecrpublictypes "github.com/aws/aws-sdk-go-v2/service/ecrpublic/types"
"github.com/aws/aws-sdk-go-v2/service/eks"
ekstypes "github.com/aws/aws-sdk-go-v2/service/eks/types"
"github.com/aws/aws-sdk-go-v2/service/sts"
ststypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
. "github.com/onsi/gomega"
@ -39,6 +44,8 @@ type mockImplementation struct {
publicECR bool
expectEKSAPICall bool
argRoleARN string
argRoleSessionName string
argOIDCToken string
@ -46,10 +53,20 @@ type mockImplementation struct {
argSTSEndpoint string
argProxyURL *url.URL
argCredsProvider aws.CredentialsProvider
argClusterName string
returnCreds aws.Credentials
returnUsername string
returnPassword string
returnCreds aws.Credentials
returnUsername string
returnPassword string
returnEndpoint string
returnCAData string
returnPresignedURL string
}
type mockHTTPPresigner struct {
t *testing.T
argClusterName string
returnURL string
}
type mockCredentialsProvider struct{ aws.Credentials }
@ -131,6 +148,87 @@ func (m *mockImplementation) GetPublicAuthorizationToken(ctx context.Context, cf
}, nil
}
func (m *mockImplementation) DescribeCluster(ctx context.Context, params *eks.DescribeClusterInput, options eks.Options) (*eks.DescribeClusterOutput, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(m.expectEKSAPICall).To(BeTrue())
g.Expect(params).NotTo(BeNil())
g.Expect(params.Name).NotTo(BeNil())
g.Expect(*params.Name).To(Equal(m.argClusterName))
g.Expect(options.Region).To(Equal(m.argRegion))
g.Expect(options.Credentials).To(Equal(m.argCredsProvider))
g.Expect(options.HTTPClient).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client)).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client).Transport).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client).Transport.(*http.Transport)).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client).Transport.(*http.Transport).Proxy).NotTo(BeNil())
proxyURL, err := options.HTTPClient.(*http.Client).Transport.(*http.Transport).Proxy(nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(proxyURL).To(Equal(m.argProxyURL))
return &eks.DescribeClusterOutput{
Cluster: &ekstypes.Cluster{
Name: aws.String(m.argClusterName),
Endpoint: aws.String(m.returnEndpoint),
CertificateAuthority: &ekstypes.Certificate{
Data: aws.String(m.returnCAData),
},
},
}, nil
}
func (m *mockImplementation) PresignGetCallerIdentity(ctx context.Context, optFn func(*sts.PresignOptions), options sts.Options) (*signerv4.PresignedHTTPRequest, error) {
m.t.Helper()
g := NewWithT(m.t)
// Check that optFn adds the presigner with the custom EKS headers to the options.
g.Expect(optFn).NotTo(BeNil())
mockPresigner := &mockHTTPPresigner{
t: m.t,
argClusterName: m.argClusterName,
returnURL: m.returnPresignedURL,
}
var presignOpts sts.PresignOptions
presignOpts.Presigner = mockPresigner
optFn(&presignOpts)
g.Expect(presignOpts.Presigner).NotTo(Equal(mockPresigner))
req, _ := http.NewRequest("POST", "https://sts.amazonaws.com/", nil)
signingTime := time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
signerOptFn := func(opts *signerv4.SignerOptions) { opts.LogSigning = true }
creds := aws.Credentials{
AccessKeyID: "access-key-id",
SecretAccessKey: "secret-access-key",
SessionToken: "session-token",
}
presignedURL, presignedHeader, err := presignOpts.Presigner.PresignHTTP(
ctx, creds, req, "payload-hash", "sts", "us-east-1", signingTime, signerOptFn)
g.Expect(presignedURL).To(Equal(m.returnPresignedURL))
g.Expect(presignedHeader).To(Equal(http.Header{"foo": []string{"bar"}}))
g.Expect(err).To(MatchError("mock presign error"))
// Check the sts options.
g.Expect(options.Region).To(Equal(m.argRegion))
g.Expect(options.Credentials).To(Equal(m.argCredsProvider))
if m.argSTSEndpoint != "" {
g.Expect(options.BaseEndpoint).NotTo(BeNil())
g.Expect(*options.BaseEndpoint).To(Equal(m.argSTSEndpoint))
} else {
g.Expect(options.BaseEndpoint).To(BeNil())
}
g.Expect(options.HTTPClient).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client)).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client).Transport).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client).Transport.(*http.Transport)).NotTo(BeNil())
g.Expect(options.HTTPClient.(*http.Client).Transport.(*http.Transport).Proxy).NotTo(BeNil())
proxyURL, err := options.HTTPClient.(*http.Client).Transport.(*http.Transport).Proxy(nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(proxyURL).To(Equal(m.argProxyURL))
return &signerv4.PresignedHTTPRequest{
URL: m.returnPresignedURL,
}, nil
}
func (m *mockImplementation) checkGetAuthorizationToken(ctx context.Context, cfg aws.Config) {
m.t.Helper()
g := NewWithT(m.t)
@ -146,6 +244,38 @@ func (m *mockImplementation) checkGetAuthorizationToken(ctx context.Context, cfg
g.Expect(proxyURL).To(Equal(m.argProxyURL))
}
func (m *mockHTTPPresigner) PresignHTTP(ctx context.Context, credentials aws.Credentials,
r *http.Request, payloadHash string, service string, region string, signingTime time.Time,
optFns ...func(*signerv4.SignerOptions)) (url string, signedHeader http.Header, err error) {
m.t.Helper()
g := NewWithT(m.t)
// Check args.
g.Expect(ctx).NotTo(BeNil())
g.Expect(credentials.AccessKeyID).To(Equal("access-key-id"))
g.Expect(credentials.SecretAccessKey).To(Equal("secret-access-key"))
g.Expect(credentials.SessionToken).To(Equal("session-token"))
g.Expect(r).NotTo(BeNil())
g.Expect(r.Method).To(Equal("POST"))
g.Expect(r.URL.String()).To(Equal("https://sts.amazonaws.com/"))
g.Expect(r.Header.Get("x-k8s-aws-id")).To(Equal(m.argClusterName))
g.Expect(r.Header.Get("X-Amz-Expires")).To(Equal("900"))
g.Expect(payloadHash).To(Equal("payload-hash"))
g.Expect(service).To(Equal("sts"))
g.Expect(region).To(Equal("us-east-1"))
g.Expect(signingTime).To(Equal(time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)))
g.Expect(optFns).To(HaveLen(1))
optFn := optFns[0]
g.Expect(optFn).NotTo(BeNil())
var signerOpts signerv4.SignerOptions
optFn(&signerOpts)
g.Expect(signerOpts).To(Equal(signerv4.SignerOptions{LogSigning: true}))
return m.returnURL, http.Header{"foo": []string{"bar"}}, errors.New("mock presign error")
}
func (m *mockCredentialsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) {
return m.Credentials, nil
}

View File

@ -45,7 +45,7 @@ func ValidateSTSEndpoint(endpoint string) error {
return nil
}
const roleARNPattern = `^arn:aws:iam::[0-9]{1,30}:role/.{1,200}$`
const roleARNPattern = `^arn:aws[\w-]*:iam::[0-9]{1,30}:role/.{1,200}$`
var roleARNRegex = regexp.MustCompile(roleARNPattern)
@ -64,3 +64,18 @@ func getRoleSessionName(serviceAccount corev1.ServiceAccount, region string) str
namespace := serviceAccount.Namespace
return fmt.Sprintf("%s.%s.%s.fluxcd.io", name, namespace, region)
}
const clusterPattern = `^arn:aws[\w-]*:eks:([^:]{1,100}):[0-9]{1,30}:cluster/(.{1,200})$`
var clusterRegex = regexp.MustCompile(clusterPattern)
func parseCluster(cluster string) (string, string, error) {
m := clusterRegex.FindStringSubmatch(cluster)
if len(m) != 3 {
return "", "", fmt.Errorf("invalid EKS cluster ARN: '%s'. must match %s",
cluster, clusterPattern)
}
region := m[1]
name := m[2]
return region, name, nil
}

View File

@ -21,7 +21,6 @@ import (
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"regexp"
"strings"
@ -31,6 +30,7 @@ import (
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ecr"
"github.com/aws/aws-sdk-go-v2/service/ecrpublic"
"github.com/aws/aws-sdk-go-v2/service/eks"
"github.com/aws/aws-sdk-go-v2/service/sts"
"github.com/google/go-containerregistry/pkg/authn"
corev1 "k8s.io/api/core/v1"
@ -54,53 +54,32 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
var o auth.Options
o.Apply(opts...)
var awsOpts []func(*config.LoadOptions) error
confOpts := []func(*config.LoadOptions) error{
config.WithHTTPClient(o.GetHTTPClient()),
}
stsRegion := o.STSRegion
if stsRegion == "" {
// A region is required. Try to get it somewhere else.
switch {
// For artifact repositories we can take advantage of the fact that ECR
// repositories have a region we can use.
// **Important**: This code path is required for supporting EKS Node Identity
// for artifact repositories! This is because the environment variable
// AWS_REGION is set automatically for IRSA or EKS Pod Identity, but
// not for Node Identity.
// We strive to support Node Identity for container registry-based APIs because
// EKS users also use Node Identity for container images, so this allows a
// simpler/consistent user experience.
case o.ArtifactRepository != "":
// We can safely ignore the error here, auth.GetToken() has already called
// ParseArtifactRepository() and validated the repository at this point.
registryInput, _ := p.ParseArtifactRepository(o.ArtifactRepository)
stsRegion = getECRRegionFromRegistryInput(registryInput)
// EKS sets this environment variable automatically if the controller pod is
// properly configured with IRSA or EKS Pod Identity, so we can rely on this
// and communicate this to users since this is controller-level configuration.
default:
stsRegion = os.Getenv("AWS_REGION")
if stsRegion == "" {
return nil, errors.New("AWS_REGION environment variable is not set in the Flux controller. " +
"if you have properly configured IAM Roles for Service Accounts (IRSA) or EKS Pod Identity, " +
"please delete/replace the controller pod so the EKS admission controllers can inject this " +
"environment variable, or set it manually if the cluster is not EKS")
}
// properly configured with IRSA or EKS Pod Identity, so we can rely on it.
stsRegion = os.Getenv("AWS_REGION")
if stsRegion == "" {
return nil, errors.New("AWS_REGION environment variable is not set in the Flux controller. " +
"if you have properly configured IAM Roles for Service Accounts (IRSA) or EKS Pod Identity, " +
"please delete/replace the controller pod so the EKS admission controllers can inject this " +
"environment variable, or set it manually if the cluster is not EKS")
}
}
awsOpts = append(awsOpts, config.WithRegion(stsRegion))
confOpts = append(confOpts, config.WithRegion(stsRegion))
if e := o.STSEndpoint; e != "" {
if err := ValidateSTSEndpoint(e); err != nil {
return nil, err
}
awsOpts = append(awsOpts, config.WithBaseEndpoint(e))
confOpts = append(confOpts, config.WithBaseEndpoint(e))
}
if hc := o.GetHTTPClient(); hc != nil {
awsOpts = append(awsOpts, config.WithHTTPClient(hc))
}
conf, err := p.impl().LoadDefaultConfig(ctx, awsOpts...)
conf, err := p.impl().LoadDefaultConfig(ctx, confOpts...)
if err != nil {
return nil, err
}
@ -112,9 +91,9 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
return newTokenFromAWSCredentials(&creds), nil
}
// GetAudience implements auth.Provider.
func (Provider) GetAudience(context.Context, corev1.ServiceAccount) (string, error) {
return "sts.amazonaws.com", nil
// GetAudiences implements auth.Provider.
func (Provider) GetAudiences(context.Context, corev1.ServiceAccount) ([]string, error) {
return []string{"sts.amazonaws.com"}, nil
}
// GetIdentity implements auth.Provider.
@ -135,25 +114,14 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
stsRegion := o.STSRegion
if stsRegion == "" {
// A region is required. Try to get it somewhere else.
switch {
// For artifact repositories we can take advantage of the fact that ECR
// repositories have a region we can use.
case o.ArtifactRepository != "":
// We can safely ignore the error here, auth.GetToken() has already called
// ParseArtifactRepository() and validated the repository at this point.
registryInput, _ := p.ParseArtifactRepository(o.ArtifactRepository)
stsRegion = getECRRegionFromRegistryInput(registryInput)
// In this case we can't rely on IRSA or EKS Pod Identity for the controller
// pod because this is object-level configuration, so we show a different
// error message.
// In this error message we assume an API that has a region field, e.g. the
// Bucket API. APIs that can extract the region from the ARN (e.g. KMS) will
// never reach this code path.
default:
return nil, errors.New("an AWS region is required for authenticating with a service account. " +
"please configure one in the object spec")
}
return nil, errors.New("an AWS region is required for authenticating with a service account. " +
"please configure one in the object spec")
}
roleARN, err := getRoleARN(serviceAccount)
@ -163,22 +131,16 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
roleSessionName := getRoleSessionName(serviceAccount, stsRegion)
awsOpts := sts.Options{
Region: stsRegion,
stsOpts := sts.Options{
Region: stsRegion,
HTTPClient: o.GetHTTPClient(),
}
if e := o.STSEndpoint; e != "" {
if err := ValidateSTSEndpoint(e); err != nil {
return nil, err
}
awsOpts.BaseEndpoint = &e
}
if u := o.ProxyURL; u != nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyURL(u)
httpClient := &http.Client{Transport: transport}
awsOpts.HTTPClient = httpClient
stsOpts.BaseEndpoint = &e
}
req := &sts.AssumeRoleWithWebIdentityInput{
@ -186,7 +148,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
RoleSessionName: &roleSessionName,
WebIdentityToken: &oidcToken,
}
resp, err := p.impl().AssumeRoleWithWebIdentity(ctx, req, awsOpts)
resp, err := p.impl().AssumeRoleWithWebIdentity(ctx, req, stsOpts)
if err != nil {
return nil, err
}
@ -194,12 +156,31 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
return nil, fmt.Errorf("credentials are nil")
}
token := &Token{*resp.Credentials}
if token.Expiration == nil {
token.Expiration = &time.Time{}
creds := &Credentials{*resp.Credentials}
if creds.Expiration == nil {
creds.Expiration = &time.Time{}
}
return token, nil
return creds, nil
}
// GetAccessTokenOptionsForArtifactRepository implements auth.Provider.
func (p Provider) GetAccessTokenOptionsForArtifactRepository(artifactRepository string) ([]auth.Option, error) {
// AWS requires a region for getting access credentials. To avoid requiring
// two regions to be passed in the Flux APIs we leverage the region present
// in the ECR repository.
// **Important**: This code path is required for supporting the identity of
// the EKS node! The AWS_REGION environment variable is only automatically
// set for IRSA and EKS Pod Identity. We strive to support the identity of
// the node for artifact repository APIs because EKS users also use it for
// for pulling container images to spin up pods inside the cluster, so this
// allows a simpler user experience setting up ECR authentication only once.
registryInput, err := p.ParseArtifactRepository(artifactRepository)
if err != nil {
return nil, err
}
ecrRegion := getECRRegionFromRegistryInput(registryInput)
return []auth.Option{auth.WithSTSRegion(ecrRegion)}, nil
}
// This regex is sourced from the AWS ECR Credential Helper (https://github.com/awslabs/amazon-ecr-credential-helper).
@ -229,7 +210,6 @@ func (Provider) ParseArtifactRepository(artifactRepository string) (string, erro
registry, registryPattern)
}
// For issuing AWS registry credentials the ECR region is required.
ecrRegion := parts[0][2]
return ecrRegion, nil
}
@ -257,11 +237,8 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryIn
conf := aws.Config{
Region: getECRRegionFromRegistryInput(registryInput),
Credentials: accessToken.(*Token).CredentialsProvider(),
}
if hc := o.GetHTTPClient(); hc != nil {
conf.HTTPClient = hc
Credentials: accessToken.(*Credentials).provider(),
HTTPClient: o.GetHTTPClient(),
}
respAny, err := authTokenFunc(ctx, conf)
@ -315,6 +292,118 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryIn
}, nil
}
// GetAccessTokenOptionsForCluster implements auth.Provider.
func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) {
var o auth.Options
o.Apply(opts...)
// ClusterResource is always needed for AWS as we need to extract the region.
region, _, err := parseCluster(o.ClusterResource)
if err != nil {
return nil, err
}
return [][]auth.Option{{auth.WithSTSRegion(region)}}, nil
}
// NewRESTConfig implements auth.Provider.
//
// Reference:
// https://docs.aws.amazon.com/eks/latest/best-practices/identity-and-access-management.html#_controlling_access_to_eks_clusters
func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
opts ...auth.Option) (*auth.RESTConfig, error) {
// The expiration for an EKS restconfig is always 15 minutes, see the reference above.
// Let's record time.Now() on the beginning of the procedure to be on the safe side.
expiresAt := time.Now().Add(15 * time.Minute)
creds := accessTokens[0].(*Credentials).provider()
var o auth.Options
o.Apply(opts...)
hc := o.GetHTTPClient()
// ClusterResource is always needed for AWS as we need to extract the region.
cluster := o.ClusterResource
region, clusterName, err := parseCluster(cluster)
if err != nil {
return nil, err
}
// Describe the cluster resource to get missing CA or endpoint.
host := o.ClusterAddress
caData := []byte(o.CAData)
if host == "" || len(caData) == 0 {
describeInput := &eks.DescribeClusterInput{
Name: aws.String(clusterName),
}
eksOpts := eks.Options{
Region: region,
Credentials: creds,
HTTPClient: hc,
}
clusterResource, err := p.impl().DescribeCluster(ctx, describeInput, eksOpts)
if err != nil {
return nil, fmt.Errorf("failed to describe EKS cluster '%s': %w", cluster, err)
}
// Compare specified address and address from the cluster resource.
endpoint := *clusterResource.Cluster.Endpoint
if host != "" {
canonicalAddress, err := auth.ParseClusterAddress(host)
if err != nil {
return nil, fmt.Errorf("failed to parse specified cluster address '%s': %w", host, err)
}
canonicalEndpoint, err := auth.ParseClusterAddress(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse EKS endpoint '%s': %w", endpoint, err)
}
if canonicalAddress != canonicalEndpoint {
return nil, fmt.Errorf("EKS endpoint '%s' does not match specified address: '%s'", endpoint, host)
}
}
// Update host and CA with cluster details.
host = endpoint
if len(caData) == 0 {
caData, err = base64.StdEncoding.DecodeString(*clusterResource.Cluster.CertificateAuthority.Data)
if err != nil {
return nil, fmt.Errorf("failed to decode EKS CA certificate: %w", err)
}
}
}
// Build token. See reference above.
presignOpts := func(po *sts.PresignOptions) {
po.Presigner = &eksHTTPPresignerV4{
HTTPPresignerV4: po.Presigner,
clusterName: clusterName,
}
}
stsOpts := sts.Options{
Region: region,
Credentials: creds,
HTTPClient: hc,
}
if e := o.STSEndpoint; e != "" {
if err := ValidateSTSEndpoint(e); err != nil {
return nil, err
}
stsOpts.BaseEndpoint = &e
}
presignedReq, err := p.impl().PresignGetCallerIdentity(ctx, presignOpts, stsOpts)
if err != nil {
return nil, fmt.Errorf("failed to presign GetCallerIdentity request: %w", err)
}
token := fmt.Sprintf("k8s-aws-v1.%s", base64.RawURLEncoding.EncodeToString([]byte(presignedReq.URL)))
// Build and return the REST config.
return &auth.RESTConfig{
Host: host,
BearerToken: token,
CAData: caData,
ExpiresAt: expiresAt,
}, nil
}
func (p Provider) impl() Implementation {
if p.Implementation == nil {
return implementation{}

View File

@ -68,12 +68,6 @@ func TestProvider_NewControllerToken(t *testing.T) {
"please delete/replace the controller pod so the EKS admission controllers can inject this " +
"environment variable, or set it manually if the cluster is not EKS",
},
{
name: "missing region but can extract from artifact repository",
stsEndpoint: "https://sts.amazonaws.com",
artifactRepository: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1",
skipSTSRegion: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
@ -85,7 +79,6 @@ func TestProvider_NewControllerToken(t *testing.T) {
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
auth.WithSTSEndpoint(tt.stsEndpoint),
auth.WithArtifactRepository(tt.artifactRepository),
}
provider := aws.Provider{Implementation: impl}
@ -93,7 +86,7 @@ func TestProvider_NewControllerToken(t *testing.T) {
if tt.err == "" {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(&aws.Token{Credentials: types.Credentials{
g.Expect(token).To(Equal(&aws.Credentials{Credentials: types.Credentials{
AccessKeyId: awssdk.String("access-key-id"),
SecretAccessKey: awssdk.String(""),
SessionToken: awssdk.String(""),
@ -109,20 +102,9 @@ func TestProvider_NewControllerToken(t *testing.T) {
}
func TestProvider_NewTokenForServiceAccount(t *testing.T) {
impl := &mockImplementation{
t: t,
argRegion: "us-east-1",
argRoleARN: "arn:aws:iam::1234567890:role/some-role",
argRoleSessionName: "test-sa.test-ns.us-east-1.fluxcd.io",
argOIDCToken: "oidc-token",
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argSTSEndpoint: "https://sts.amazonaws.com",
returnCreds: awssdk.Credentials{AccessKeyID: "access-key-id"},
}
for _, tt := range []struct {
name string
annotations map[string]string
roleARN string
stsEndpoint string
artifactRepository string
skipSTSRegion bool
@ -130,53 +112,61 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
}{
{
name: "valid",
annotations: map[string]string{"eks.amazonaws.com/role-arn": "arn:aws:iam::1234567890:role/some-role"},
roleARN: "arn:aws:iam::1234567890:role/some-role",
stsEndpoint: "https://sts.amazonaws.com",
},
{
name: "us gov is valid",
roleARN: "arn:aws-us-gov:iam::1234567890:role/some-role",
stsEndpoint: "https://sts.amazonaws.com",
},
{
name: "invalid sts endpoint",
annotations: map[string]string{"eks.amazonaws.com/role-arn": "arn:aws:iam::1234567890:role/some-role"},
roleARN: "arn:aws:iam::1234567890:role/some-role",
stsEndpoint: "https://something.amazonaws.com",
err: `invalid STS endpoint: 'https://something.amazonaws.com'. must match ^https://(.+\.)?sts(-fips)?(\.[^.]+)?(\.vpce)?\.amazonaws\.com$`,
},
{
name: "missing region",
annotations: map[string]string{"eks.amazonaws.com/role-arn": "arn:aws:iam::1234567890:role/some-role"},
roleARN: "arn:aws:iam::1234567890:role/some-role",
stsEndpoint: "https://sts.amazonaws.com",
skipSTSRegion: true,
err: "an AWS region is required for authenticating with a service account. " +
"please configure one in the object spec",
},
{
name: "missing region but can extract from artifact repository",
annotations: map[string]string{"eks.amazonaws.com/role-arn": "arn:aws:iam::1234567890:role/some-role"},
stsEndpoint: "https://sts.amazonaws.com",
artifactRepository: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1",
skipSTSRegion: true,
},
{
name: "invalid role ARN",
annotations: map[string]string{"eks.amazonaws.com/role-arn": "foobar"},
roleARN: "foobar",
stsEndpoint: "https://sts.amazonaws.com",
err: "invalid eks.amazonaws.com/role-arn annotation: 'foobar'. must match ^arn:aws:iam::[0-9]{1,30}:role/.{1,200}$",
err: `invalid eks.amazonaws.com/role-arn annotation: 'foobar'. must match ^arn:aws[\w-]*:iam::[0-9]{1,30}:role/.{1,200}$`,
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
impl := &mockImplementation{
t: t,
argRegion: "us-east-1",
argRoleARN: tt.roleARN,
argRoleSessionName: "test-sa.test-ns.us-east-1.fluxcd.io",
argOIDCToken: "oidc-token",
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argSTSEndpoint: "https://sts.amazonaws.com",
returnCreds: awssdk.Credentials{AccessKeyID: "access-key-id"},
}
oidcToken := "oidc-token"
serviceAccount := corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sa",
Namespace: "test-ns",
Annotations: tt.annotations,
Annotations: map[string]string{"eks.amazonaws.com/role-arn": tt.roleARN},
},
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
auth.WithSTSEndpoint(tt.stsEndpoint),
auth.WithArtifactRepository(tt.artifactRepository),
}
if !tt.skipSTSRegion {
@ -188,7 +178,7 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
if tt.err == "" {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(&aws.Token{Credentials: types.Credentials{
g.Expect(token).To(Equal(&aws.Credentials{Credentials: types.Credentials{
AccessKeyId: awssdk.String("access-key-id"),
SecretAccessKey: awssdk.String(""),
SessionToken: awssdk.String(""),
@ -203,11 +193,11 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
}
}
func TestProvider_GetAudience(t *testing.T) {
func TestProvider_GetAudiences(t *testing.T) {
g := NewWithT(t)
aud, err := aws.Provider{}.GetAudience(context.Background(), corev1.ServiceAccount{})
aud, err := aws.Provider{}.GetAudiences(context.Background(), corev1.ServiceAccount{})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(aud).To(Equal("sts.amazonaws.com"))
g.Expect(aud).To(Equal([]string{"sts.amazonaws.com"}))
}
func TestProvider_GetIdentity(t *testing.T) {
@ -226,28 +216,28 @@ func TestProvider_GetIdentity(t *testing.T) {
func TestProvider_NewArtifactRegistryCredentials(t *testing.T) {
for _, tt := range []struct {
name string
registryInput string
expectedPublicECR bool
expectedRegion string
name string
artifactRepository string
expectedPublicECR bool
expectedRegion string
}{
{
name: "non public ECR",
registryInput: "us-east-1",
expectedRegion: "us-east-1",
expectedPublicECR: false,
name: "non public ECR, us-east-1",
artifactRepository: "012345678901.dkr.ecr.us-east-1.amazonaws.com/foo",
expectedRegion: "us-east-1",
expectedPublicECR: false,
},
{
name: "non public ECR",
registryInput: "us-west-2",
expectedRegion: "us-west-2",
expectedPublicECR: false,
name: "non public ECR, us-west-2",
artifactRepository: "012345678901.dkr.ecr.us-west-2.amazonaws.com/foo",
expectedRegion: "us-west-2",
expectedPublicECR: false,
},
{
name: "public ECR",
registryInput: "public.ecr.aws",
expectedRegion: "us-east-1", // Public ECR is always us-east-1
expectedPublicECR: true,
name: "public ECR",
artifactRepository: "public.ecr.aws",
expectedRegion: "us-east-1", // Public ECR is always us-east-1
expectedPublicECR: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
@ -259,24 +249,22 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) {
argRegion: tt.expectedRegion,
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argCredsProvider: credentials.NewStaticCredentialsProvider("access-key-id", "secret-access-key", "session-token"),
returnUsername: "username",
returnPassword: "password",
returnCreds: awssdk.Credentials{
AccessKeyID: "access-key-id",
SecretAccessKey: "secret-access-key",
SessionToken: "session-token",
},
returnUsername: "username",
returnPassword: "password",
}
accessToken := &aws.Token{
Credentials: types.Credentials{
AccessKeyId: awssdk.String("access-key-id"),
SecretAccessKey: awssdk.String("secret-access-key"),
SessionToken: awssdk.String("session-token"),
},
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
}
provider := aws.Provider{Implementation: impl}
creds, err := provider.NewArtifactRegistryCredentials(
context.Background(), tt.registryInput, accessToken, opts...)
creds, err := auth.GetArtifactRegistryCredentials(
context.Background(), provider, tt.artifactRepository, opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(creds).To(Equal(&auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{
@ -288,6 +276,19 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) {
}
}
func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) {
g := NewWithT(t)
opts, err := aws.Provider{}.GetAccessTokenOptionsForArtifactRepository(
"012345678901.dkr.ecr.us-east-1.amazonaws.com/foo:v1")
g.Expect(err).NotTo(HaveOccurred())
var o auth.Options
o.Apply(opts...)
g.Expect(o.STSRegion).To(Equal("us-east-1"))
}
func TestProvider_ParseArtifactRepository(t *testing.T) {
tests := []struct {
artifactRepository string
@ -365,3 +366,130 @@ func TestProvider_ParseArtifactRepository(t *testing.T) {
})
}
}
func TestProvider_NewRESTConfig(t *testing.T) {
for _, tt := range []struct {
name string
cluster string
clusterAddress string
caData string
stsEndpoint string
err string
}{
{
name: "valid EKS cluster",
cluster: "arn:aws:eks:us-east-1:123456789012:cluster/test-cluster",
},
{
name: "us gov EKS cluster is valid",
cluster: "arn:aws-us-gov:eks:us-east-1:123456789012:cluster/test-cluster",
},
{
name: "valid EKS cluster with address match",
cluster: "arn:aws:eks:us-east-1:123456789012:cluster/test-cluster",
clusterAddress: "https://EXAMPLE1234567890123456789012345678.gr7.us-east-1.eks.amazonaws.com:443",
},
{
name: "valid EKS cluster with CA",
cluster: "arn:aws:eks:us-east-1:123456789012:cluster/test-cluster",
caData: "-----BEGIN CERTIFICATE-----",
},
{
name: "CA and address only. EKS requires cluster to extract region",
clusterAddress: "https://EXAMPLE1234567890123456789012345678.gr7.us-east-1.eks.amazonaws.com:443",
caData: "-----BEGIN CERTIFICATE-----",
err: `invalid EKS cluster ARN: ''. must match ^arn:aws[\w-]*:eks:([^:]{1,100}):[0-9]{1,30}:cluster/(.{1,200})$`,
},
{
name: "cluster address mismatch",
cluster: "arn:aws:eks:us-east-1:123456789012:cluster/test-cluster",
clusterAddress: "https://different-endpoint.eks.amazonaws.com:443",
err: "EKS endpoint 'https://EXAMPLE1234567890123456789012345678.gr7.us-east-1.eks.amazonaws.com' does not match specified address: 'https://different-endpoint.eks.amazonaws.com:443'",
},
{
name: "valid EKS cluster with custom STS endpoint",
cluster: "arn:aws:eks:us-east-1:123456789012:cluster/test-cluster",
stsEndpoint: "https://sts.amazonaws.com",
},
{
name: "invalid STS endpoint",
cluster: "arn:aws:eks:us-east-1:123456789012:cluster/test-cluster",
stsEndpoint: "https://invalid.amazonaws.com",
err: `invalid STS endpoint: 'https://invalid.amazonaws.com'. must match ^https://(.+\.)?sts(-fips)?(\.[^.]+)?(\.vpce)?\.amazonaws\.com$`,
},
{
name: "invalid cluster ARN",
cluster: "invalid-cluster-arn",
err: `invalid EKS cluster ARN: 'invalid-cluster-arn'. must match ^arn:aws[\w-]*:eks:([^:]{1,100}):[0-9]{1,30}:cluster/(.{1,200})$`,
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
impl := &mockImplementation{
t: t,
expectEKSAPICall: tt.clusterAddress == "" || tt.caData == "",
argRegion: "us-east-1",
argClusterName: "test-cluster",
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argSTSEndpoint: tt.stsEndpoint,
argCredsProvider: credentials.NewStaticCredentialsProvider("access-key-id", "secret-access-key", "session-token"),
returnCreds: awssdk.Credentials{AccessKeyID: "access-key-id", SecretAccessKey: "secret-access-key", SessionToken: "session-token"},
returnEndpoint: "https://EXAMPLE1234567890123456789012345678.gr7.us-east-1.eks.amazonaws.com",
returnCAData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t", // base64 encoded "-----BEGIN CERTIFICATE-----"
returnPresignedURL: "https://sts.us-east-1.amazonaws.com/?Action=GetCallerIdentity&Version=2011-06-15&X-Amz-Algorithm=AWS4-HMAC-SHA256",
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
}
if tt.cluster != "" {
opts = append(opts, auth.WithClusterResource(tt.cluster))
}
if tt.clusterAddress != "" {
opts = append(opts, auth.WithClusterAddress(tt.clusterAddress))
}
if tt.caData != "" {
opts = append(opts, auth.WithCAData(tt.caData))
}
if tt.stsEndpoint != "" {
opts = append(opts, auth.WithSTSEndpoint(tt.stsEndpoint))
}
provider := aws.Provider{Implementation: impl}
restConfig, err := auth.GetRESTConfig(context.Background(), provider, opts...)
if tt.err == "" {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(restConfig).NotTo(BeNil())
g.Expect(restConfig.Host).To(Equal("https://EXAMPLE1234567890123456789012345678.gr7.us-east-1.eks.amazonaws.com"))
g.Expect(restConfig.BearerToken).To(Equal("k8s-aws-v1.aHR0cHM6Ly9zdHMudXMtZWFzdC0xLmFtYXpvbmF3cy5jb20vP0FjdGlvbj1HZXRDYWxsZXJJZGVudGl0eSZWZXJzaW9uPTIwMTEtMDYtMTUmWC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTY"))
g.Expect(restConfig.CAData).To(Equal([]byte("-----BEGIN CERTIFICATE-----")))
g.Expect(restConfig.ExpiresAt).To(BeTemporally(">", time.Now().Add(14*time.Minute)))
g.Expect(restConfig.ExpiresAt).To(BeTemporally("<", time.Now().Add(16*time.Minute)))
} else {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.err))
g.Expect(restConfig).To(BeNil())
}
})
}
}
func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) {
g := NewWithT(t)
opts, err := aws.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterResource("arn:aws:eks:us-west-2:123456789012:cluster/my-cluster"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(1))
var o auth.Options
o.Apply(opts[0]...)
g.Expect(o.STSRegion).To(Equal("us-west-2"))
}

45
auth/aws/restconfig.go Normal file
View File

@ -0,0 +1,45 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package aws
import (
"context"
"net/http"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
signerv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/service/sts"
)
// eksHTTPPresignerV4 implements sts.HTTPPresignerV4 adding the cluster name
// to the request header x-k8s-aws-id, as required by EKS authentication.
type eksHTTPPresignerV4 struct {
sts.HTTPPresignerV4
clusterName string
}
// PresignHTTP implements sts.HTTPPresignerV4.
func (e *eksHTTPPresignerV4) PresignHTTP(
ctx context.Context, credentials aws.Credentials, r *http.Request,
payloadHash string, service string, region string, signingTime time.Time,
optFns ...func(*signerv4.SignerOptions),
) (string, http.Header, error) {
r.Header.Add("x-k8s-aws-id", e.clusterName)
r.Header.Add("X-Amz-Expires", "900") // ref: https://github.com/aws/aws-sdk-go-v2/issues/1922#issuecomment-1429063756
return e.HTTPPresignerV4.PresignHTTP(ctx, credentials, r, payloadHash, service, region, signingTime, optFns...)
}

View File

@ -24,11 +24,11 @@ import (
"github.com/aws/aws-sdk-go-v2/service/sts/types"
)
// Token is the AWS token.
type Token struct{ types.Credentials }
// Credentials is the AWS token.
type Credentials struct{ types.Credentials }
func newTokenFromAWSCredentials(creds *aws.Credentials) *Token {
return &Token{types.Credentials{
func newTokenFromAWSCredentials(creds *aws.Credentials) *Credentials {
return &Credentials{types.Credentials{
AccessKeyId: &creds.AccessKeyID,
SecretAccessKey: &creds.SecretAccessKey,
SessionToken: &creds.SessionToken,
@ -37,11 +37,10 @@ func newTokenFromAWSCredentials(creds *aws.Credentials) *Token {
}
// GetDuration implements auth.Token.
func (t *Token) GetDuration() time.Duration {
return time.Until(*t.Expiration)
func (c *Credentials) GetDuration() time.Duration {
return time.Until(*c.Expiration)
}
// CredentialsProvider gets a credentials provider for the token to use with AWS libraries.
func (t *Token) CredentialsProvider() aws.CredentialsProvider {
return credentials.NewStaticCredentialsProvider(*t.AccessKeyId, *t.SecretAccessKey, *t.SessionToken)
func (c *Credentials) provider() aws.CredentialsProvider {
return credentials.NewStaticCredentialsProvider(*c.AccessKeyId, *c.SecretAccessKey, *c.SessionToken)
}

View File

@ -18,10 +18,12 @@ package azure
import (
"context"
"net/http"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice"
)
// Implementation provides the required methods of the Azure libraries.
@ -29,7 +31,14 @@ type Implementation interface {
NewDefaultAzureCredential(options *azidentity.DefaultAzureCredentialOptions) (azcore.TokenCredential, error)
NewDefaultAzureCredentialWithoutShellOut(options *azidentity.DefaultAzureCredentialOptions) (azcore.TokenCredential, error)
NewClientAssertionCredential(tenantID string, clientID string, getAssertion func(context.Context) (string, error), options *azidentity.ClientAssertionCredentialOptions) (azcore.TokenCredential, error)
SendRequest(req *http.Request, client *http.Client) (*http.Response, error)
ExchangeAADAccessTokenForACRRefreshToken(ctx context.Context, client *azcontainerregistry.AuthenticationClient, grantType azcontainerregistry.PostContentSchemaGrantType, service string, options *azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions) (azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenResponse, error)
NewManagedClustersClient(subscriptionID string, credential azcore.TokenCredential, options *arm.ClientOptions) (AKSClient, error)
}
// AKSClient provides the required methods of the AKS client.
type AKSClient interface {
Get(ctx context.Context, resourceGroupName string, resourceName string, options *armcontainerservice.ManagedClustersClientGetOptions) (armcontainerservice.ManagedClustersClientGetResponse, error)
ListClusterUserCredentials(ctx context.Context, resourceGroupName string, resourceName string, options *armcontainerservice.ManagedClustersClientListClusterUserCredentialsOptions) (armcontainerservice.ManagedClustersClientListClusterUserCredentialsResponse, error)
}
type implementation struct{}
@ -46,6 +55,10 @@ func (implementation) NewClientAssertionCredential(tenantID string, clientID str
return azidentity.NewClientAssertionCredential(tenantID, clientID, getAssertion, options)
}
func (implementation) SendRequest(req *http.Request, client *http.Client) (*http.Response, error) {
return client.Do(req)
func (implementation) ExchangeAADAccessTokenForACRRefreshToken(ctx context.Context, client *azcontainerregistry.AuthenticationClient, grantType azcontainerregistry.PostContentSchemaGrantType, service string, options *azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions) (azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenResponse, error) {
return client.ExchangeAADAccessTokenForACRRefreshToken(ctx, grantType, service, options)
}
func (implementation) NewManagedClustersClient(subscriptionID string, credential azcore.TokenCredential, options *arm.ClientOptions) (AKSClient, error) {
return armcontainerservice.NewManagedClustersClient(subscriptionID, credential, options)
}

View File

@ -18,15 +18,22 @@ package azure_test
import (
"context"
"io"
"net/http"
"net/url"
"reflect"
"testing"
"time"
"unsafe"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice"
. "github.com/onsi/gomega"
"github.com/fluxcd/pkg/auth/azure"
)
type mockImplementation struct {
@ -34,16 +41,28 @@ type mockImplementation struct {
shellOut bool
argTenantID string
argClientID string
argOIDCToken string
argURL string
argBody string
argProxyURL *url.URL
argScopes []string
expectAKSAPICall bool
returnResp *http.Response
returnToken string
argTenantID string
argClientID string
argOIDCToken string
argProxyURL *url.URL
argScopes []string
argToken string
argRegistry string
argSubscription string
argResourceGroup string
argClusterName string
// For dual-token flow (RESTConfig)
argFirstScopes []string
argSecondScopes []string
firstCallMade bool
returnToken string
returnACRToken string
returnCluster armcontainerservice.ManagedCluster
returnKubeconfigs []*armcontainerservice.CredentialResult
}
type mockTokenCredential struct {
@ -54,6 +73,16 @@ type mockTokenCredential struct {
returnToken string
}
type mockAKSClient struct {
t *testing.T
argResourceGroup string
argClusterName string
returnCluster armcontainerservice.ManagedCluster
returnKubeconfigs []*armcontainerservice.CredentialResult
}
func (m *mockImplementation) NewDefaultAzureCredential(options *azidentity.DefaultAzureCredentialOptions) (azcore.TokenCredential, error) {
m.t.Helper()
g := NewWithT(m.t)
@ -79,7 +108,19 @@ func (m *mockImplementation) newDefaultAzureCredential(options *azidentity.Defau
proxyURL, err := options.Transport.(*http.Client).Transport.(*http.Transport).Proxy(nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(proxyURL).To(Equal(m.argProxyURL))
return &mockTokenCredential{t: m.t, argScopes: m.argScopes, returnToken: m.returnToken}, nil
// Determine which scopes to expect based on dual-token flow
expectedScopes := m.argScopes
if m.argFirstScopes != nil && m.argSecondScopes != nil {
if !m.firstCallMade {
expectedScopes = m.argFirstScopes
m.firstCallMade = true
} else {
expectedScopes = m.argSecondScopes
}
}
return &mockTokenCredential{t: m.t, argScopes: expectedScopes, returnToken: m.returnToken}, nil
}
func (m *mockImplementation) NewClientAssertionCredential(tenantID string, clientID string, getAssertion func(context.Context) (string, error), options *azidentity.ClientAssertionCredentialOptions) (azcore.TokenCredential, error) {
@ -100,33 +141,128 @@ func (m *mockImplementation) NewClientAssertionCredential(tenantID string, clien
proxyURL, err := options.Transport.(*http.Client).Transport.(*http.Transport).Proxy(nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(proxyURL).To(Equal(m.argProxyURL))
return &mockTokenCredential{t: m.t, argScopes: m.argScopes, returnToken: m.returnToken}, nil
// Determine which scopes to expect based on dual-token flow
expectedScopes := m.argScopes
if m.argFirstScopes != nil && m.argSecondScopes != nil {
if !m.firstCallMade {
expectedScopes = m.argFirstScopes
m.firstCallMade = true
} else {
expectedScopes = m.argSecondScopes
}
}
return &mockTokenCredential{t: m.t, argScopes: expectedScopes, returnToken: m.returnToken}, nil
}
func (m *mockImplementation) SendRequest(req *http.Request, client *http.Client) (*http.Response, error) {
func (m *mockImplementation) ExchangeAADAccessTokenForACRRefreshToken(ctx context.Context, client *azcontainerregistry.AuthenticationClient, grantType azcontainerregistry.PostContentSchemaGrantType, service string, options *azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions) (azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenResponse, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(req).NotTo(BeNil())
g.Expect(req.Method).To(Equal(http.MethodPost))
g.Expect(req.URL).NotTo(BeNil())
g.Expect(req.URL.String()).To(Equal(m.argURL))
g.Expect(req.Body).NotTo(BeNil())
b, err := io.ReadAll(req.Body)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(string(b)).To(Equal(m.argBody))
g.Expect(client).NotTo(BeNil())
g.Expect(client.Transport).NotTo(BeNil())
g.Expect(client.Transport.(*http.Transport)).NotTo(BeNil())
g.Expect(client.Transport.(*http.Transport).Proxy).NotTo(BeNil())
proxyURL, err := client.Transport.(*http.Transport).Proxy(nil)
// Assert registry endpoint.
endpointField := reflect.ValueOf(client).Elem().FieldByName("endpoint")
endpointValue := reflect.NewAt(endpointField.Type(), unsafe.Pointer(endpointField.UnsafeAddr())).Elem().Interface().(string)
g.Expect(endpointValue).To(Equal("https://" + m.argRegistry))
// Assert proxy URL.
azcoreClientField := reflect.ValueOf(client).Elem().FieldByName("internal")
azcoreClientValue := reflect.NewAt(azcoreClientField.Type(), unsafe.Pointer(azcoreClientField.UnsafeAddr())).Elem().Interface().(*azcore.Client)
g.Expect(azcoreClientValue).NotTo(BeNil())
pipeline := azcoreClientValue.Pipeline()
g.Expect(pipeline).NotTo(BeNil())
pipelineValue := reflect.ValueOf(pipeline)
pipelinePtr := reflect.New(pipelineValue.Type())
pipelinePtr.Elem().Set(pipelineValue)
policiesField := pipelinePtr.Elem().FieldByName("policies")
policiesValue := reflect.NewAt(policiesField.Type(), unsafe.Pointer(policiesField.UnsafeAddr())).Elem().Interface().([]policy.Policy)
g.Expect(policiesValue).NotTo(BeNil())
transportPolicy := policiesValue[len(policiesValue)-1]
transportPolicyValue := reflect.ValueOf(transportPolicy)
transportPolicyPtr := reflect.New(transportPolicyValue.Type())
transportPolicyPtr.Elem().Set(transportPolicyValue)
transportField := transportPolicyPtr.Elem().FieldByName("trans")
transportValue := reflect.NewAt(transportField.Type(), unsafe.Pointer(transportField.UnsafeAddr())).Elem().Interface().(policy.Transporter)
g.Expect(transportValue).NotTo(BeNil())
g.Expect(transportValue.(*http.Client)).NotTo(BeNil())
g.Expect(transportValue.(*http.Client).Transport).NotTo(BeNil())
g.Expect(transportValue.(*http.Client).Transport.(*http.Transport)).NotTo(BeNil())
g.Expect(transportValue.(*http.Client).Transport.(*http.Transport).Proxy).NotTo(BeNil())
proxyURL, err := transportValue.(*http.Client).Transport.(*http.Transport).Proxy(nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(proxyURL).To(Equal(m.argProxyURL))
return m.returnResp, nil
// Assert trivial inputs.
g.Expect(grantType).To(Equal(azcontainerregistry.PostContentSchemaGrantTypeAccessToken))
g.Expect(service).To(Equal(m.argRegistry))
g.Expect(options).To(Equal(&azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions{
AccessToken: &m.argToken,
}))
return azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenResponse{
ACRRefreshToken: azcontainerregistry.ACRRefreshToken{RefreshToken: &m.returnACRToken},
}, nil
}
func (m *mockImplementation) NewManagedClustersClient(subscriptionID string, credential azcore.TokenCredential, options *arm.ClientOptions) (azure.AKSClient, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(m.expectAKSAPICall).To(BeTrue())
g.Expect(subscriptionID).To(Equal(m.argSubscription))
g.Expect(credential).NotTo(BeNil())
token, err := credential.GetToken(context.Background(), policy.TokenRequestOptions{})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token.Token).To(Equal(m.argToken))
g.Expect(options).NotTo(BeNil())
g.Expect(options.Transport).NotTo(BeNil())
g.Expect(options.Transport.(*http.Client)).NotTo(BeNil())
g.Expect(options.Transport.(*http.Client).Transport).NotTo(BeNil())
g.Expect(options.Transport.(*http.Client).Transport.(*http.Transport)).NotTo(BeNil())
g.Expect(options.Transport.(*http.Client).Transport.(*http.Transport).Proxy).NotTo(BeNil())
proxyURL, err := options.Transport.(*http.Client).Transport.(*http.Transport).Proxy(nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(proxyURL).To(Equal(m.argProxyURL))
return &mockAKSClient{
t: m.t,
argResourceGroup: m.argResourceGroup,
argClusterName: m.argClusterName,
returnCluster: m.returnCluster,
returnKubeconfigs: m.returnKubeconfigs,
}, nil
}
func (m *mockAKSClient) Get(ctx context.Context, resourceGroupName string, resourceName string, options *armcontainerservice.ManagedClustersClientGetOptions) (armcontainerservice.ManagedClustersClientGetResponse, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(ctx).NotTo(BeNil())
g.Expect(resourceGroupName).To(Equal(m.argResourceGroup))
g.Expect(resourceName).To(Equal(m.argClusterName))
g.Expect(options).To(BeNil())
return armcontainerservice.ManagedClustersClientGetResponse{
ManagedCluster: m.returnCluster,
}, nil
}
func (m *mockAKSClient) ListClusterUserCredentials(ctx context.Context, resourceGroupName string, resourceName string, options *armcontainerservice.ManagedClustersClientListClusterUserCredentialsOptions) (armcontainerservice.ManagedClustersClientListClusterUserCredentialsResponse, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(ctx).NotTo(BeNil())
g.Expect(resourceGroupName).To(Equal(m.argResourceGroup))
g.Expect(resourceName).To(Equal(m.argClusterName))
g.Expect(options).To(BeNil())
return armcontainerservice.ManagedClustersClientListClusterUserCredentialsResponse{
CredentialResults: armcontainerservice.CredentialResults{
Kubeconfigs: m.returnKubeconfigs,
},
}, nil
}
func (m *mockTokenCredential) GetToken(ctx context.Context, options policy.TokenRequestOptions) (azcore.AccessToken, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(options.Scopes).To(Equal(m.argScopes))
return azcore.AccessToken{Token: m.returnToken}, nil
return azcore.AccessToken{
Token: m.returnToken,
ExpiresOn: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), // Fixed expiry for testing
}, nil
}

View File

@ -17,13 +17,13 @@ limitations under the License.
package azure
import (
"encoding/json"
"fmt"
"strings"
"os"
"regexp"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
corev1 "k8s.io/api/core/v1"
"github.com/fluxcd/pkg/auth"
)
func getIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
@ -54,34 +54,95 @@ func getClientID(serviceAccount corev1.ServiceAccount) (string, error) {
return "", fmt.Errorf("azure client ID is not set in the service account annotation %s", key)
}
func getScopes(o *auth.Options) []string {
if acrScope := getACRScope(o.ArtifactRepository); acrScope != "" {
return []string{acrScope}
const clusterPattern = `(?i)^/subscriptions/([^/]{36})/resourceGroups/([^/]{1,200})/providers/Microsoft\.ContainerService/managedClusters/([^/]{1,200})$`
var clusterRegex = regexp.MustCompile(clusterPattern)
func parseCluster(cluster string) (string, string, string, error) {
m := clusterRegex.FindStringSubmatch(cluster)
if len(m) != 4 {
return "", "", "", fmt.Errorf("invalid AKS cluster ID: '%s'. must match %s",
cluster, clusterPattern)
}
return o.Scopes
subscriptionID := m[1]
resourceGroup := m[2]
clusterName := m[3]
return subscriptionID, resourceGroup, clusterName, nil
}
func getACRScope(artifactRepository string) string {
if artifactRepository == "" {
return ""
}
// envVarAzureEnvironmentFilepath is the environment variable name used to specify the path of the configuration file with custom Azure endpoints.
const envVarAzureEnvironmentFilepath = "AZURE_ENVIRONMENT_FILEPATH"
registry, err := auth.GetRegistryFromArtifactRepository(artifactRepository)
// Environment is used to read the Azure environment configuration from a JSON file, it is a subset of the struct defined in
// https://github.com/kubernetes-sigs/cloud-provider-azure/blob/e68bd888a7616d52f45f39238691f32821884120/pkg/azclient/cloud.go#L152-L185
// with exact same field names and json annotations.
// We define this struct here for two reasons:
// 1. We are not aware of any libraries we could import this struct from.
// 2. We don't use all the fields defined in the original struct.
type Environment struct {
ContainerRegistryDNSSuffix string `json:"containerRegistryDNSSuffix,omitempty"`
ResourceManagerEndpoint string `json:"resourceManagerEndpoint,omitempty"`
TokenAudience string `json:"tokenAudience,omitempty"`
}
// hasEnvironmentFile checks if the environment variable AZURE_ENVIRONMENT_FILEPATH is set
func hasEnvironmentFile() bool {
_, ok := os.LookupEnv(envVarAzureEnvironmentFilepath)
return ok
}
// getEnvironmentConfig reads the Azure environment configuration from a JSON file
// located at the path specified by the environment variable AZURE_ENVIRONMENT_FILEPATH.
// Call hasEnvironmentFile() before calling this function to ensure the file exists.
func getEnvironmentConfig() (*Environment, error) {
envFilePath := os.Getenv(envVarAzureEnvironmentFilepath)
if len(envFilePath) == 0 {
return nil, fmt.Errorf("environment variable %s is not set", envVarAzureEnvironmentFilepath)
}
content, err := os.ReadFile(envFilePath)
if err != nil {
// it's ok to swallow the error here, it should never happen
// because GetRegistryFromArtifactRepository() is already called
// earlier by auth.GetToken() and the error is handled there.
return ""
return nil, err
}
env := &Environment{}
if err = json.Unmarshal(content, env); err != nil {
return nil, err
}
var conf *cloud.Configuration
switch {
case strings.HasSuffix(registry, ".azurecr.cn"):
conf = &cloud.AzureChina
case strings.HasSuffix(registry, ".azurecr.us"):
conf = &cloud.AzureGovernment
default:
conf = &cloud.AzurePublic
}
return conf.Services[cloud.ResourceManager].Endpoint + "/" + ".default"
return env, nil
}
// getCloudConfigFromEnvironment reads the Azure environment configuration and returns a cloud.Configuration object.
func getCloudConfigFromEnvironment() (*cloud.Configuration, error) {
env, err := getEnvironmentConfig()
if err != nil {
return nil, err
}
cloudConf := cloud.Configuration{
Services: make(map[cloud.ServiceName]cloud.ServiceConfiguration),
}
if len(env.ResourceManagerEndpoint) > 0 && len(env.TokenAudience) > 0 {
cloudConf.Services[cloud.ResourceManager] = cloud.ServiceConfiguration{
Endpoint: env.ResourceManagerEndpoint,
Audience: env.TokenAudience,
}
} else {
return nil, fmt.Errorf("resourceManagerEndpoint and tokenAudience must be set in the environment file")
}
return &cloudConf, nil
}
// getContainerRegistryDNSSuffix reads the Azure environment configuration and returns the container registry DNS suffix.
func getContainerRegistryDNSSuffix() (string, error) {
env, err := getEnvironmentConfig()
if err != nil {
return "", err
}
if len(env.ContainerRegistryDNSSuffix) == 0 {
return "", fmt.Errorf("containerRegistryDNSSuffix must be set in the environment file")
}
return env.ContainerRegistryDNSSuffix, nil
}

View File

@ -18,19 +18,22 @@ package azure
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"path"
"os"
"regexp"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry"
"github.com/golang-jwt/jwt/v5"
"github.com/google/go-containerregistry/pkg/authn"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"github.com/fluxcd/pkg/auth"
)
@ -52,10 +55,10 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
var o auth.Options
o.Apply(opts...)
var azOpts azidentity.DefaultAzureCredentialOptions
if hc := o.GetHTTPClient(); hc != nil {
azOpts.Transport = hc
azOpts := azidentity.DefaultAzureCredentialOptions{
ClientOptions: azcore.ClientOptions{
Transport: o.GetHTTPClient(),
},
}
credFunc := p.impl().NewDefaultAzureCredentialWithoutShellOut
@ -67,7 +70,7 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
return nil, err
}
token, err := cred.GetToken(ctx, policy.TokenRequestOptions{
Scopes: getScopes(&o),
Scopes: o.Scopes,
})
if err != nil {
return nil, err
@ -76,9 +79,9 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
return &Token{token}, nil
}
// GetAudience implements auth.Provider.
func (Provider) GetAudience(context.Context, corev1.ServiceAccount) (string, error) {
return "api://AzureADTokenExchange", nil
// GetAudiences implements auth.Provider.
func (Provider) GetAudiences(context.Context, corev1.ServiceAccount) ([]string, error) {
return []string{"api://AzureADTokenExchange"}, nil
}
// GetIdentity implements auth.Provider.
@ -100,10 +103,10 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
s := strings.Split(identity, "/")
tenantID, clientID := s[0], s[1]
azOpts := &azidentity.ClientAssertionCredentialOptions{}
if hc := o.GetHTTPClient(); hc != nil {
azOpts.Transport = hc
azOpts := &azidentity.ClientAssertionCredentialOptions{
ClientOptions: azcore.ClientOptions{
Transport: o.GetHTTPClient(),
},
}
cred, err := p.impl().NewClientAssertionCredential(tenantID, clientID, func(context.Context) (string, error) {
@ -113,7 +116,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
return nil, err
}
token, err := cred.GetToken(ctx, policy.TokenRequestOptions{
Scopes: getScopes(&o),
Scopes: o.Scopes,
})
if err != nil {
return nil, err
@ -122,78 +125,106 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
return &Token{token}, nil
}
// GetAccessTokenOptionsForArtifactRepository implements auth.Provider.
func (p Provider) GetAccessTokenOptionsForArtifactRepository(artifactRepository string) ([]auth.Option, error) {
// Azure requires scopes for getting access tokens. Here we compute
// the scope for ACR, which is based on the registry host.
registry, err := auth.GetRegistryFromArtifactRepository(artifactRepository)
if err != nil {
return nil, err
}
var conf *cloud.Configuration
switch {
case hasEnvironmentFile():
var err error
conf, err = getCloudConfigFromEnvironment()
if err != nil {
return nil, err
}
case strings.HasSuffix(registry, ".azurecr.cn"):
conf = &cloud.AzureChina
case strings.HasSuffix(registry, ".azurecr.us"):
conf = &cloud.AzureGovernment
default:
conf = &cloud.AzurePublic
}
acrScope := conf.Services[cloud.ResourceManager].Endpoint + "/.default"
return []auth.Option{auth.WithScopes(acrScope)}, nil
}
// https://github.com/kubernetes/kubernetes/blob/v1.23.1/pkg/credentialprovider/azure/azure_credentials.go#L55
const registryPattern = `^.+\.(azurecr\.io|azurecr\.cn|azurecr\.de|azurecr\.us)$`
var registryRegex = regexp.MustCompile(registryPattern)
// ParseArtifactRepository implements auth.Provider.
// ParseArtifactRepository returns the ACR registry URL.
// ParseArtifactRepository returns the ACR registry host.
func (Provider) ParseArtifactRepository(artifactRepository string) (string, error) {
registry, err := auth.GetRegistryFromArtifactRepository(artifactRepository)
if err != nil {
return "", err
}
if !registryRegex.MatchString(registry) {
return "", fmt.Errorf("invalid Azure registry: '%s'. must match %s",
registry, registryPattern)
// For issuing Azure registry credentials the registry host is required.
if registryRegex.MatchString(registry) {
return registry, nil
}
// For issuing Azure registry credentials the registry URL is required.
registryURL := fmt.Sprintf("https://%s", registry)
return registryURL, nil
// Check if environment variable is configured for container registry suffix
if hasEnvironmentFile() {
// Load the environment configuration from the file
registrySuffix, err := getContainerRegistryDNSSuffix()
if err != nil {
return "", fmt.Errorf("failed to get container registry suffix from environment file: %w", err)
}
if strings.HasSuffix(registry, registrySuffix) {
return registry, nil
}
return "", fmt.Errorf("invalid Azure registry: '%s'. must end with %s",
registry, registrySuffix)
}
return "", fmt.Errorf("invalid Azure registry: '%s'. must match %s",
registry, registryPattern)
}
// NewArtifactRegistryCredentials implements auth.Provider.
func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryURL string,
func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registry string,
accessToken auth.Token, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) {
t := accessToken.(*Token)
var o auth.Options
o.Apply(opts...)
// Build request.
exchangeURL, err := url.Parse(registryURL)
// Create the ACR authentication client.
endpoint := fmt.Sprintf("https://%s", registry)
clientOpts := azcontainerregistry.AuthenticationClientOptions{
ClientOptions: azcore.ClientOptions{
Transport: o.GetHTTPClient(),
},
}
client, err := azcontainerregistry.NewAuthenticationClient(endpoint, &clientOpts)
if err != nil {
return nil, err
}
exchangeURL.Path = path.Join(exchangeURL.Path, "oauth2/exchange")
parameters := url.Values{}
parameters.Add("grant_type", "access_token")
parameters.Add("service", exchangeURL.Hostname())
parameters.Add("access_token", t.Token)
body := strings.NewReader(parameters.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Send request.
httpClient := http.DefaultClient
if hc := o.GetHTTPClient(); hc != nil {
httpClient = hc
// Exchange the access token for an ACR token.
grantType := azcontainerregistry.PostContentSchemaGrantTypeAccessToken
service := registry
tokenOpts := &azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions{
AccessToken: &accessToken.(*Token).Token,
}
resp, err := p.impl().SendRequest(req, httpClient)
resp, err := p.impl().ExchangeAADAccessTokenForACRRefreshToken(ctx, client, grantType, service, tokenOpts)
if err != nil {
return nil, err
}
defer resp.Body.Close()
token := *resp.RefreshToken
// Parse response.
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status from ACR exchange request: %d", resp.StatusCode)
}
var tokenResp struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return nil, err
}
// Parse the refresh token to get the expiry time.
var claims jwt.MapClaims
if _, _, err := jwt.NewParser().ParseUnverified(tokenResp.RefreshToken, &claims); err != nil {
if _, _, err := jwt.NewParser().ParseUnverified(token, &claims); err != nil {
return nil, err
}
expiry, err := claims.GetExpirationTime()
@ -201,16 +232,159 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryUR
return nil, err
}
// Return the credentials.
return &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{
// https://docs.microsoft.com/en-us/azure/container-registry/container-registry-authentication?tabs=azure-cli#az-acr-login-with---expose-token
Username: "00000000-0000-0000-0000-000000000000",
Password: tokenResp.RefreshToken,
Password: token,
}),
ExpiresAt: expiry.Time,
}, nil
}
// GetAccessTokenOptionsForCluster implements auth.Provider.
func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) {
var o auth.Options
o.Apply(opts...)
var atOpts [][]auth.Option
// Token used for impersonating the Managed Identity inside the AKS cluster.
const aksScope = "6dae42f8-4368-4678-94ff-3960e28e3630/.default"
aksTokenOpts := []auth.Option{auth.WithScopes(aksScope)}
atOpts = append(atOpts, aksTokenOpts)
// Token needed for looking up details of the cluster resource.
if o.ClusterAddress == "" || o.CAData == "" {
conf := &cloud.AzurePublic
switch authorityHost := os.Getenv("AZURE_AUTHORITY_HOST"); {
case hasEnvironmentFile():
var err error
conf, err = getCloudConfigFromEnvironment()
if err != nil {
return nil, err
}
case strings.Contains(authorityHost, "chinacloudapi.cn"):
conf = &cloud.AzureChina
case strings.Contains(authorityHost, "microsoftonline.us"):
conf = &cloud.AzureGovernment
}
armScope := conf.Services[cloud.ResourceManager].Audience + "/.default"
armTokenOpts := []auth.Option{auth.WithScopes(armScope)}
atOpts = append(atOpts, armTokenOpts)
}
return atOpts, nil
}
// NewRESTConfig implements auth.Provider.
func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
opts ...auth.Option) (*auth.RESTConfig, error) {
aksToken := accessTokens[0].(*Token)
var armToken *Token
if len(accessTokens) == 2 {
armToken = accessTokens[1].(*Token)
}
var o auth.Options
o.Apply(opts...)
// Describe the cluster resource to get missing CA or endpoint.
host := o.ClusterAddress
caData := []byte(o.CAData)
if host == "" || len(caData) == 0 {
cluster := o.ClusterResource
subscriptionID, resourceGroup, clusterName, err := parseCluster(cluster)
if err != nil {
return nil, err
}
// Create client for describing the cluster resource.
clientOpts := arm.ClientOptions{
ClientOptions: azcore.ClientOptions{
Transport: o.GetHTTPClient(),
},
}
client, err := p.impl().NewManagedClustersClient(
subscriptionID, armToken.credential(), &clientOpts)
if err != nil {
return nil, fmt.Errorf("failed to create client for describing AKS cluster: %w", err)
}
// Describe the cluster resource.
clusterResource, err := client.Get(ctx, resourceGroup, clusterName, nil)
if err != nil {
return nil, fmt.Errorf("failed to describe AKS cluster: %w", err)
}
// We only support clusters with Microsoft Entra ID integration enabled.
if clusterResource.Properties.AADProfile == nil {
return nil, fmt.Errorf("AKS cluster %s does not have Microsoft Entra ID integration enabled. "+
"See docs for enabling: https://learn.microsoft.com/en-us/azure/aks/enable-authentication-microsoft-entra-id",
cluster)
}
// Parse specified cluster address.
var canonicalHost string
if host != "" {
var err error
canonicalHost, err = auth.ParseClusterAddress(host)
if err != nil {
return nil, fmt.Errorf("failed to parse specified cluster address '%s': %w", host, err)
}
}
// List kubeconfigs for this AKS cluster. We need to find the one
// matching the canonical address, or the first one if no address
// is specified.
resp, err := client.ListClusterUserCredentials(ctx, resourceGroup, clusterName, nil)
if err != nil {
return nil, err
}
var restConfig *rest.Config
var addresses []string
for i, kc := range resp.Kubeconfigs {
conf, err := clientcmd.RESTConfigFromKubeConfig(kc.Value)
if err != nil {
return nil, fmt.Errorf("failed to parse kubeconfig[%d]: %w", i, err)
}
addresses = append(addresses, fmt.Sprintf("'%s'", conf.Host))
canonicalHostFromAPI, err := auth.ParseClusterAddress(conf.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse address '%s' from kubeconfig[%d]: %w", conf.Host, i, err)
}
if canonicalHost == "" || canonicalHostFromAPI == canonicalHost {
restConfig = conf
break
}
}
if restConfig == nil {
if canonicalHost == "" {
return nil, fmt.Errorf("no kubeconfig found for AKS cluster %s", cluster)
}
return nil, fmt.Errorf("AKS cluster %s does not match specified address '%s'. cluster addresses: [%s]",
cluster, o.ClusterAddress, strings.Join(addresses, ", "))
}
// Update host and CA with cluster details.
host = restConfig.Host
if len(caData) == 0 {
caData = restConfig.CAData
}
}
// Build and return the REST config.
return &auth.RESTConfig{
Host: host,
BearerToken: aksToken.Token,
CAData: caData,
ExpiresAt: aksToken.ExpiresOn,
}, nil
}
func (p Provider) impl() Implementation {
if p.Implementation == nil {
return implementation{}

View File

@ -21,14 +21,13 @@ import (
"crypto/rand"
"crypto/rsa"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"os"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice"
"github.com/golang-jwt/jwt/v5"
"github.com/google/go-containerregistry/pkg/authn"
. "github.com/onsi/gomega"
@ -40,60 +39,51 @@ import (
)
func TestProvider_NewControllerToken(t *testing.T) {
g := NewWithT(t)
for _, tt := range []struct {
name string
shellOut bool
}{
{
name: "without shell out",
shellOut: false,
},
{
name: "with shell out",
shellOut: true,
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
impl := &mockImplementation{
t: t,
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argScopes: []string{"scope1", "scope2"},
returnToken: "access-token",
impl := &mockImplementation{
t: t,
shellOut: tt.shellOut,
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argScopes: []string{"scope1", "scope2"},
returnToken: "access-token",
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
auth.WithScopes("scope1", "scope2"),
}
if tt.shellOut {
opts = append(opts, auth.WithAllowShellOut())
}
provider := azure.Provider{Implementation: impl}
token, err := provider.NewControllerToken(context.Background(), opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{
Token: "access-token",
ExpiresOn: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
}}))
})
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
auth.WithScopes("scope1", "scope2"),
}
provider := azure.Provider{Implementation: impl}
token, err := provider.NewControllerToken(context.Background(), opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{Token: "access-token"}}))
}
func TestProvider_NewControllerTokenWithShellOut(t *testing.T) {
g := NewWithT(t)
impl := &mockImplementation{
t: t,
shellOut: true,
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argScopes: []string{"scope1", "scope2"},
returnToken: "access-token",
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
auth.WithScopes("scope1", "scope2"),
auth.WithAllowShellOut(),
}
provider := azure.Provider{Implementation: impl}
token, err := provider.NewControllerToken(context.Background(), opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{Token: "access-token"}}))
}
func TestProvider_NewTokenForServiceAccount(t *testing.T) {
impl := &mockImplementation{
t: t,
argTenantID: "tenant-id",
argClientID: "client-id",
argOIDCToken: "oidc-token",
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argScopes: []string{"scope1", "scope2"},
returnToken: "access-token",
}
for _, tt := range []struct {
name string
annotations map[string]string
@ -124,6 +114,16 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
impl := &mockImplementation{
t: t,
argTenantID: "tenant-id",
argClientID: "client-id",
argOIDCToken: "oidc-token",
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argScopes: []string{"scope1", "scope2"},
returnToken: "access-token",
}
oidcToken := "oidc-token"
serviceAccount := corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
@ -140,7 +140,10 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
if tt.err == "" {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{Token: "access-token"}}))
g.Expect(token).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{
Token: "access-token",
ExpiresOn: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
}}))
} else {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal(tt.err))
@ -150,11 +153,11 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
}
}
func TestProvider_GetAudience(t *testing.T) {
func TestProvider_GetAudiences(t *testing.T) {
g := NewWithT(t)
aud, err := azure.Provider{}.GetAudience(context.Background(), corev1.ServiceAccount{})
aud, err := azure.Provider{}.GetAudiences(context.Background(), corev1.ServiceAccount{})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(aud).To(Equal("api://AzureADTokenExchange"))
g.Expect(aud).To(Equal([]string{"api://AzureADTokenExchange"}))
}
func TestProvider_GetIdentity(t *testing.T) {
@ -204,36 +207,24 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) {
g := NewWithT(t)
impl := &mockImplementation{
t: t,
argURL: fmt.Sprintf("https://%s/oauth2/exchange", tt.registry),
argBody: fmt.Sprintf("access_token=access-token&grant_type=access_token&service=%s", tt.registry),
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argScopes: []string{tt.expectedScope},
returnToken: "access-token",
returnResp: &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"refresh_token":"%s"}`, refreshToken))),
},
t: t,
argRegistry: tt.registry,
argToken: "access-token",
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
argScopes: []string{tt.expectedScope},
returnToken: "access-token",
returnACRToken: refreshToken,
}
provider := azure.Provider{Implementation: impl}
artifactRepository := fmt.Sprintf("%s/repo", tt.registry)
opts := []auth.Option{
auth.WithArtifactRepository(artifactRepository),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
}
registryURL, err := provider.ParseArtifactRepository(artifactRepository)
creds, err := auth.GetArtifactRegistryCredentials(context.Background(), provider, artifactRepository, opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(registryURL).To(Equal(fmt.Sprintf("https://%s", tt.registry)))
accessToken, err := provider.NewControllerToken(context.Background(), opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(accessToken).To(Equal(&azure.Token{AccessToken: azcore.AccessToken{Token: "access-token"}}))
token, err := provider.NewArtifactRegistryCredentials(context.Background(), registryURL, accessToken, opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(&auth.ArtifactRegistryCredentials{
g.Expect(creds).To(Equal(&auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{
Username: "00000000-0000-0000-0000-000000000000",
Password: refreshToken,
@ -246,46 +237,69 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) {
func TestProvider_ParseArtifactRegistry(t *testing.T) {
for _, tt := range []struct {
artifactRepository string
expectedRegistryURL string
expectValid bool
artifactRepository string
expectedRegistryURL string
containerRegistryDNSSuffix string
expectValid bool
}{
{
artifactRepository: "foo.azurecr.io",
expectedRegistryURL: "https://foo.azurecr.io",
artifactRepository: "foo.azurecr.io/repo",
expectedRegistryURL: "foo.azurecr.io",
expectValid: true,
},
{
artifactRepository: "foo.azurecr.cn",
expectedRegistryURL: "https://foo.azurecr.cn",
artifactRepository: "foo.azurecr.cn/repo",
expectedRegistryURL: "foo.azurecr.cn",
expectValid: true,
},
{
artifactRepository: "foo.azurecr.de",
expectedRegistryURL: "https://foo.azurecr.de",
artifactRepository: "foo.azurecr.de/repo",
expectedRegistryURL: "foo.azurecr.de",
expectValid: true,
},
{
artifactRepository: "foo.azurecr.us",
expectedRegistryURL: "https://foo.azurecr.us",
artifactRepository: "foo.azurecr.us/repo",
expectedRegistryURL: "foo.azurecr.us",
expectValid: true,
},
{
artifactRepository: "foo.azurecr.com",
artifactRepository: "foo.azurecr.com/repo",
expectValid: false,
},
{
artifactRepository: ".azurecr.io",
artifactRepository: ".azurecr.io/repo",
expectValid: false,
},
{
artifactRepository: "012345678901.dkr.ecr.us-east-1.amazonaws.com",
expectValid: false,
},
{
artifactRepository: "foo.azurecr.private/repo",
expectedRegistryURL: "foo.azurecr.private",
containerRegistryDNSSuffix: "azurecr.private",
expectValid: true,
},
{
artifactRepository: "foo.azurecr.private/repo",
expectedRegistryURL: "foo.azurecr.private",
containerRegistryDNSSuffix: "azurecr.pr",
expectValid: false,
},
} {
t.Run(tt.artifactRepository, func(t *testing.T) {
g := NewWithT(t)
// Create a temporary JSON file if containerRegistryDNS is defined
if tt.containerRegistryDNSSuffix != "" {
envContent := fmt.Sprintf(`{"containerRegistryDNSSuffix": "%s"}`, tt.containerRegistryDNSSuffix)
tempFileName, err := createTempAzureEnvFile(envContent)
g.Expect(err).NotTo(HaveOccurred())
defer os.Remove(tempFileName)
// Set the environment variable to point to the temp file
t.Setenv("AZURE_ENVIRONMENT_FILEPATH", tempFileName)
}
registryURL, err := azure.Provider{}.ParseArtifactRepository(tt.artifactRepository)
if tt.expectValid {
@ -298,3 +312,399 @@ func TestProvider_ParseArtifactRegistry(t *testing.T) {
})
}
}
func TestProvider_GetAccessTokenOptionsForArtifactRepository(t *testing.T) {
for _, tt := range []struct {
name string
artifactRepository string
readFromEnv bool
expectedScope string
}{
{
name: "Azure Public Cloud",
artifactRepository: "myregistry.azurecr.io",
expectedScope: "https://management.azure.com/.default",
},
{
name: "Azure China Cloud",
artifactRepository: "myregistry.azurecr.cn",
expectedScope: "https://management.chinacloudapi.cn/.default",
},
{
name: "Azure Government Cloud",
artifactRepository: "myregistry.azurecr.us",
expectedScope: "https://management.usgovcloudapi.net/.default",
},
{
name: "Invalid registry",
artifactRepository: "myregistry.invalid.io",
expectedScope: "https://management.azure.com/.default",
},
{
name: "Custom environment file",
artifactRepository: "myregistry.private.io",
readFromEnv: true,
expectedScope: "https://management.core.azure.private/.default",
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
if tt.readFromEnv {
envContent := fmt.Sprintf(`{"resourceManagerEndpoint": "%s", "tokenAudience": "%s", "extraField": "%s"}`, "https://management.core.azure.private", "https://management.core.azure.private", "random-extra-field-for-testing")
tempFileName, err := createTempAzureEnvFile(envContent)
g.Expect(err).NotTo(HaveOccurred())
defer os.Remove(tempFileName)
// Set the environment variable to point to the temp file
t.Setenv("AZURE_ENVIRONMENT_FILEPATH", tempFileName)
}
provider := azure.Provider{}
opts, err := provider.GetAccessTokenOptionsForArtifactRepository(tt.artifactRepository)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(1))
var armOptions auth.Options
armOptions.Apply(opts...)
g.Expect(armOptions.Scopes).To(Equal([]string{tt.expectedScope}))
})
}
}
func TestProvider_NewRESTConfig(t *testing.T) {
for _, tt := range []struct {
name string
cluster string
clusterAddress string
caData string
aadProfile *armcontainerservice.ManagedClusterAADProfile
kubeconfigs []*armcontainerservice.CredentialResult
authorityHost string
secondScope string
err string
}{
{
name: "valid AKS cluster",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
{
Name: &[]string{"clusterUser-secondary"}[0],
Value: createKubeconfig("test-cluster-secondary", "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443"),
},
},
},
{
name: "valid AKS cluster - china",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
{
Name: &[]string{"clusterUser-secondary"}[0],
Value: createKubeconfig("test-cluster-secondary", "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443"),
},
},
authorityHost: "https://login.chinacloudapi.cn/",
secondScope: "https://management.core.chinacloudapi.cn/.default",
},
{
name: "valid AKS cluster - us gov",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
{
Name: &[]string{"clusterUser-secondary"}[0],
Value: createKubeconfig("test-cluster-secondary", "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443"),
},
},
authorityHost: "https://login.microsoftonline.us/",
secondScope: "https://management.core.usgovcloudapi.net/.default",
},
{
name: "valid AKS cluster - lowercase",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourcegroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
{
Name: &[]string{"clusterUser-secondary"}[0],
Value: createKubeconfig("test-cluster-secondary", "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443"),
},
},
},
{
name: "valid AKS cluster with address match",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
clusterAddress: "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
{
Name: &[]string{"clusterUser-secondary"}[0],
Value: createKubeconfig("test-cluster-secondary", "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443"),
},
},
},
{
name: "valid AKS cluster with CA",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
caData: "-----BEGIN CERTIFICATE-----",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
{
Name: &[]string{"clusterUser-secondary"}[0],
Value: createKubeconfig("test-cluster-secondary", "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443"),
},
},
},
{
name: "CA and address only",
clusterAddress: "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443",
caData: "-----BEGIN CERTIFICATE-----",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
{
Name: &[]string{"clusterUser-secondary"}[0],
Value: createKubeconfig("test-cluster-secondary", "https://test-cluster-secondary-87654321.hcp.westus.azmk8s.io:443"),
},
},
},
{
name: "cluster address mismatch",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
clusterAddress: "https://different-cluster.hcp.eastus.azmk8s.io:443",
aadProfile: &armcontainerservice.ManagedClusterAADProfile{
Managed: &[]bool{true}[0],
},
kubeconfigs: []*armcontainerservice.CredentialResult{
{
Name: &[]string{"clusterUser"}[0],
Value: createKubeconfig("test-cluster", "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
},
},
err: "AKS cluster /subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster does not match specified address 'https://different-cluster.hcp.eastus.azmk8s.io:443'. cluster addresses: ['https://test-cluster-12345678.hcp.eastus.azmk8s.io:443']",
},
{
name: "cluster without AAD integration",
cluster: "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster",
err: "AKS cluster /subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster does not have Microsoft Entra ID integration enabled. See docs for enabling: https://learn.microsoft.com/en-us/azure/aks/enable-authentication-microsoft-entra-id",
},
{
name: "invalid cluster ID",
cluster: "invalid-cluster-id",
err: `invalid AKS cluster ID: 'invalid-cluster-id'. must match (?i)^/subscriptions/([^/]{36})/resourceGroups/([^/]{1,200})/providers/Microsoft\.ContainerService/managedClusters/([^/]{1,200})$`,
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
if tt.authorityHost != "" {
t.Setenv("AZURE_AUTHORITY_HOST", tt.authorityHost)
}
secondScope := "https://management.core.windows.net//.default"
if tt.secondScope != "" {
secondScope = tt.secondScope
}
impl := &mockImplementation{
t: t,
expectAKSAPICall: tt.clusterAddress == "" || tt.caData == "",
argToken: "access-token",
argFirstScopes: []string{"6dae42f8-4368-4678-94ff-3960e28e3630/.default"},
argSecondScopes: []string{secondScope},
argSubscription: "12345678-1234-1234-1234-123456789012",
argResourceGroup: "test-rg",
argClusterName: "test-cluster",
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
returnToken: "access-token",
returnCluster: armcontainerservice.ManagedCluster{
Properties: &armcontainerservice.ManagedClusterProperties{
AADProfile: tt.aadProfile,
},
},
returnKubeconfigs: tt.kubeconfigs,
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
}
if tt.cluster != "" {
opts = append(opts, auth.WithClusterResource(tt.cluster))
}
if tt.clusterAddress != "" {
opts = append(opts, auth.WithClusterAddress(tt.clusterAddress))
}
if tt.caData != "" {
opts = append(opts, auth.WithCAData(tt.caData))
}
provider := azure.Provider{Implementation: impl}
restConfig, err := auth.GetRESTConfig(context.Background(), provider, opts...)
if tt.err == "" {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(restConfig).NotTo(BeNil())
expectedHost := "https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"
if tt.clusterAddress != "" {
expectedHost = tt.clusterAddress
}
g.Expect(restConfig.Host).To(Equal(expectedHost))
g.Expect(restConfig.BearerToken).To(Equal("access-token"))
g.Expect(restConfig.CAData).To(Equal([]byte("-----BEGIN CERTIFICATE-----")))
g.Expect(restConfig.ExpiresAt).To(Equal(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)))
} else {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.err))
g.Expect(restConfig).To(BeNil())
}
})
}
}
func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) {
g := NewWithT(t)
t.Run("needs to fetch cluster", func(t *testing.T) {
opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterResource("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(2))
// AKS token options
var aksOptions auth.Options
aksOptions.Apply(opts[0]...)
g.Expect(aksOptions.Scopes).To(Equal([]string{"6dae42f8-4368-4678-94ff-3960e28e3630/.default"}))
// ARM token options
var armOptions auth.Options
armOptions.Apply(opts[1]...)
g.Expect(armOptions.Scopes).To(Equal([]string{"https://management.core.windows.net//.default"}))
})
t.Run("needs to fetch cluster arm options from env", func(t *testing.T) {
envContent := fmt.Sprintf(`{"resourceManagerEndpoint": "%s", "tokenAudience": "%s", "extraField": "%s"}`, "https://management.core.azure.private/", "https://management.core.azure.private/", "random-extra-field-for-testing")
tempFileName, err := createTempAzureEnvFile(envContent)
g.Expect(err).NotTo(HaveOccurred())
defer os.Remove(tempFileName)
// Set the environment variable to point to the temp file
t.Setenv("AZURE_ENVIRONMENT_FILEPATH", tempFileName)
opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterResource("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(2))
// AKS token options
var aksOptions auth.Options
aksOptions.Apply(opts[0]...)
g.Expect(aksOptions.Scopes).To(Equal([]string{"6dae42f8-4368-4678-94ff-3960e28e3630/.default"}))
// ARM token options
var armOptions auth.Options
armOptions.Apply(opts[1]...)
g.Expect(armOptions.Scopes).To(Equal([]string{"https://management.core.azure.private//.default"}))
})
t.Run("no need to fetch cluster", func(t *testing.T) {
opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterAddress("https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
auth.WithCAData("-----BEGIN CERTIFICATE-----"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(1))
// AKS token options
var aksOptions auth.Options
aksOptions.Apply(opts[0]...)
g.Expect(aksOptions.Scopes).To(Equal([]string{"6dae42f8-4368-4678-94ff-3960e28e3630/.default"}))
})
}
func createKubeconfig(clusterName, serverURL string) []byte {
return []byte(fmt.Sprintf(`apiVersion: v1
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t
server: %s
name: %s
contexts:
- context:
cluster: %s
user: clusterUser_test-rg_%s
name: %s
current-context: %s
kind: Config
users:
- name: clusterUser_test-rg_%s
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: kubelogin
env: null
`, serverURL, clusterName, clusterName, clusterName, clusterName, clusterName, clusterName))
}
func createTempAzureEnvFile(content string) (string, error) {
tempFile, err := os.CreateTemp("", "azure_env_*.json")
if err != nil {
return "", err
}
if err := tempFile.Close(); err != nil {
os.Remove(tempFile.Name())
return "", err
}
if err := os.WriteFile(tempFile.Name(), []byte(content), 0644); err != nil {
return "", err
}
return tempFile.Name(), nil
}

View File

@ -17,7 +17,7 @@ limitations under the License.
package azure
const (
// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity?view=azure-devops#q-can-i-use-a-service-principal-or-managed-identity-with-azure-cli
// https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/service-principal-managed-identity?view=azure-devops#q-can-i-add-a-managed-identity-from-a-different-tenant-to-my-organization
ScopeDevOps = "499b84ac-1321-427f-aa17-267ca6975798/.default"
// https://github.com/Azure/azure-sdk-for-go/blob/f5dfe3b53fe63aacd3aeba948bbe21d961edf376/sdk/storage/azqueue/internal/shared/shared.go#L18

View File

@ -34,8 +34,7 @@ func (t *Token) GetDuration() time.Duration {
return time.Until(t.ExpiresOn)
}
// Credential gets a token credential for the token to use with Azure libraries.
func (t *Token) Credential() azcore.TokenCredential {
func (t *Token) credential() azcore.TokenCredential {
return &staticTokenCredential{t.AccessToken}
}

View File

@ -38,15 +38,16 @@ func NewTokenCredential(ctx context.Context, opts ...auth.Option) azcore.TokenCr
// GetToken implements exported.TokenCredential.
// The context is ignored, use the constructor to set the context.
// This is because some callers of the library pass context.Background()
// when calling this method (e.g. SOPS), so to ensure we have a real
// context we pass it in the constructor.
// This is because the GCP abstraction does not receive a context
// in the method arguments, so we unfortunately need to standardize
// the behavior of all providers around this so the usage of this
// library can be consistent regardless of the provider.
func (t *tokenCredential) GetToken(_ context.Context, tokenOpts policy.TokenRequestOptions) (azcore.AccessToken, error) {
opts := t.opts
if tokenOpts.Scopes != nil {
opts = append(opts, auth.WithScopes(tokenOpts.Scopes...))
}
token, err := auth.GetToken(t.ctx, Provider{}, opts...)
token, err := auth.GetAccessToken(t.ctx, Provider{}, opts...)
if err != nil {
return azcore.AccessToken{}, err
}

29
auth/cache_key.go Normal file
View File

@ -0,0 +1,29 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth
import (
"crypto/sha256"
"fmt"
"strings"
)
func buildCacheKey(parts ...string) string {
s := strings.Join(parts, "\n")
hash := sha256.Sum256([]byte(s))
return fmt.Sprintf("%x", hash)
}

View File

@ -22,12 +22,14 @@ import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/google/externalaccount"
"google.golang.org/api/container/v1"
)
// Implementation provides the required methods of the GCP libraries.
type Implementation interface {
DefaultTokenSource(ctx context.Context, scope ...string) (oauth2.TokenSource, error)
NewTokenSource(ctx context.Context, conf externalaccount.Config) (oauth2.TokenSource, error)
GetCluster(ctx context.Context, cluster string, client *container.Service) (*container.Cluster, error)
}
type implementation struct{}
@ -39,3 +41,7 @@ func (implementation) DefaultTokenSource(ctx context.Context, scope ...string) (
func (implementation) NewTokenSource(ctx context.Context, conf externalaccount.Config) (oauth2.TokenSource, error) {
return externalaccount.NewTokenSource(ctx, conf)
}
func (implementation) GetCluster(ctx context.Context, cluster string, client *container.Service) (*container.Cluster, error) {
return client.Projects.Locations.Clusters.Get(cluster).Context(ctx).Do()
}

View File

@ -20,20 +20,28 @@ import (
"context"
"net/http"
"net/url"
"reflect"
"testing"
"unsafe"
. "github.com/onsi/gomega"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google/externalaccount"
"google.golang.org/api/container/v1"
)
type mockImplementation struct {
t *testing.T
expectGKEAPICall bool
argConfig externalaccount.Config
argProxyURL *url.URL
argCluster string
returnToken *oauth2.Token
returnToken *oauth2.Token
returnCluster *container.Cluster
}
func (m *mockImplementation) DefaultTokenSource(ctx context.Context, scope ...string) (oauth2.TokenSource, error) {
@ -70,3 +78,35 @@ func (m *mockImplementation) NewTokenSource(ctx context.Context, conf externalac
g.Expect(conf).To(Equal(m.argConfig))
return oauth2.StaticTokenSource(m.returnToken), nil
}
func (m *mockImplementation) GetCluster(ctx context.Context, cluster string, client *container.Service) (*container.Cluster, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(m.expectGKEAPICall).To(BeTrue())
g.Expect(ctx).NotTo(BeNil())
g.Expect(cluster).To(Equal(m.argCluster))
g.Expect(client).NotTo(BeNil())
g.Expect(client.BasePath).To(Equal("https://container.googleapis.com/"))
httpClientField := reflect.ValueOf(client).Elem().FieldByName("client")
httpClientValue := reflect.NewAt(httpClientField.Type(), unsafe.Pointer(httpClientField.UnsafeAddr())).Elem().Interface().(*http.Client)
g.Expect(httpClientValue).NotTo(BeNil())
g.Expect(httpClientValue.Transport).NotTo(BeNil())
g.Expect(httpClientValue.Transport.(*oauth2.Transport)).NotTo(BeNil())
g.Expect(httpClientValue.Transport.(*oauth2.Transport).Source).NotTo(BeNil())
token, err := httpClientValue.Transport.(*oauth2.Transport).Source.Token()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(m.returnToken))
g.Expect(httpClientValue.Transport.(*oauth2.Transport).Base).NotTo(BeNil())
g.Expect(httpClientValue.Transport.(*oauth2.Transport).Base.(*otelhttp.Transport)).NotTo(BeNil())
otelRoundTripperField := reflect.ValueOf(httpClientValue.Transport.(*oauth2.Transport).Base.(*otelhttp.Transport)).Elem().FieldByName("rt")
otelRoundTripperValue := reflect.NewAt(otelRoundTripperField.Type(), unsafe.Pointer(otelRoundTripperField.UnsafeAddr())).Elem().Interface()
g.Expect(otelRoundTripperValue).NotTo(BeNil())
parameterRoundTripperField := reflect.ValueOf(otelRoundTripperValue).Elem().FieldByName("base")
parameterRoundTripperValue := reflect.NewAt(parameterRoundTripperField.Type(), unsafe.Pointer(parameterRoundTripperField.UnsafeAddr())).Elem().Interface()
g.Expect(parameterRoundTripperValue.(*http.Transport)).NotTo(BeNil())
g.Expect(parameterRoundTripperValue.(*http.Transport).Proxy).NotTo(BeNil())
proxyURL, err := parameterRoundTripperValue.(*http.Transport).Proxy(nil)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(proxyURL).To(Equal(m.argProxyURL))
return m.returnCluster, nil
}

View File

@ -56,3 +56,15 @@ func getWorkloadIdentityProviderAudience(serviceAccount corev1.ServiceAccount) (
}
return fmt.Sprintf("//iam.googleapis.com/%s", wip), nil
}
const clusterPattern = `^projects/[^/]{1,200}/locations/[^/]{1,200}/clusters/[^/]{1,200}$`
var clusterRegex = regexp.MustCompile(clusterPattern)
func parseCluster(cluster string) error {
if !clusterRegex.MatchString(cluster) {
return fmt.Errorf("invalid GKE cluster ID: '%s'. must match %s",
cluster, clusterPattern)
}
return nil
}

View File

@ -18,12 +18,17 @@ package gcp
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"regexp"
"github.com/google/go-containerregistry/pkg/authn"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google/externalaccount"
"google.golang.org/api/container/v1"
"google.golang.org/api/option"
htransport "google.golang.org/api/transport/http"
corev1 "k8s.io/api/core/v1"
auth "github.com/fluxcd/pkg/auth"
@ -50,9 +55,7 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
var o auth.Options
o.Apply(opts...)
if hc := o.GetHTTPClient(); hc != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, o.GetHTTPClient())
src, err := p.impl().DefaultTokenSource(ctx, scopes...)
if err != nil {
@ -66,20 +69,25 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
return &Token{*token}, nil
}
// GetAudience implements auth.Provider.
func (Provider) GetAudience(ctx context.Context, serviceAccount corev1.ServiceAccount) (string, error) {
// GetAudiences implements auth.Provider.
func (Provider) GetAudiences(ctx context.Context, serviceAccount corev1.ServiceAccount) ([]string, error) {
// Check if a workload identity provider is specified in the service account.
// If so, the current cluster is not GKE and the audience is the provider itself.
audience, err := getWorkloadIdentityProviderAudience(serviceAccount)
if err != nil {
return "", err
return nil, err
}
if audience != "" {
return audience, nil
return []string{audience}, nil
}
// Assume we are in GKE. In this case, the audience is the workload identity pool.
return gkeMetadata.workloadIdentityPool(ctx)
audience, err = gkeMetadata.workloadIdentityPool(ctx)
if err != nil {
return nil, err
}
return []string{audience}, nil
}
// GetIdentity implements auth.Provider.
@ -118,7 +126,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
Audience: audience,
SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt",
TokenURL: "https://sts.googleapis.com/v1/token",
SubjectTokenSupplier: TokenSupplier(oidcToken),
SubjectTokenSupplier: StaticTokenSupplier(oidcToken),
Scopes: scopes,
}
@ -135,9 +143,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
conf.TokenInfoURL = "https://sts.googleapis.com/v1/introspect"
}
if hc := o.GetHTTPClient(); hc != nil {
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, o.GetHTTPClient())
src, err := p.impl().NewTokenSource(ctx, conf)
if err != nil {
@ -151,6 +157,12 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
return &Token{*token}, nil
}
// GetAccessTokenOptionsForArtifactRepository implements auth.Provider.
func (Provider) GetAccessTokenOptionsForArtifactRepository(string) ([]auth.Option, error) {
// GCP does not require any special options to retrieve access tokens.
return nil, nil
}
const registryPattern = `^(((.+\.)?gcr\.io)|(.+-docker\.pkg\.dev))$`
var registryRegex = regexp.MustCompile(registryPattern)
@ -187,6 +199,85 @@ func (Provider) NewArtifactRegistryCredentials(_ context.Context, _ string,
}, nil
}
// GetAccessTokenOptionsForCluster implements auth.Provider.
func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) {
// A single token is needed. No options.
return [][]auth.Option{{}}, nil
}
// NewRESTConfig implements auth.Provider.
func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
opts ...auth.Option) (*auth.RESTConfig, error) {
token := accessTokens[0].(*Token)
var o auth.Options
o.Apply(opts...)
// Describe the cluster resource to get missing CA or endpoint.
host := o.ClusterAddress
caData := []byte(o.CAData)
if host == "" || len(caData) == 0 {
cluster := o.ClusterResource
if err := parseCluster(cluster); err != nil {
return nil, err
}
// Create client for describing the cluster resource.
baseTransport := http.DefaultTransport.(*http.Transport).Clone()
if p := o.ProxyURL; p != nil {
baseTransport.Proxy = http.ProxyURL(p)
}
transport, err := htransport.NewTransport(ctx, baseTransport, option.WithTokenSource(token.source()))
if err != nil {
return nil, fmt.Errorf("failed to create google http transport for describing GKE cluster: %w", err)
}
client, err := container.NewService(ctx, option.WithHTTPClient(&http.Client{Transport: transport}))
if err != nil {
return nil, fmt.Errorf("failed to create client for describing GKE cluster: %w", err)
}
// Describe the cluster resource.
clusterResource, err := p.impl().GetCluster(ctx, cluster, client)
if err != nil {
return nil, fmt.Errorf("failed to describe GKE cluster '%s': %w", cluster, err)
}
// Compare specified address and address from the cluster resource.
endpoint := clusterResource.Endpoint
if host != "" {
canonicalAddress, err := auth.ParseClusterAddress(host)
if err != nil {
return nil, fmt.Errorf("failed to parse specified cluster address '%s': %w", host, err)
}
canonicalEndpoint, err := auth.ParseClusterAddress(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to parse GKE endpoint '%s': %w", endpoint, err)
}
if canonicalAddress != canonicalEndpoint {
return nil, fmt.Errorf("GKE endpoint '%s' does not match specified address: '%s'", endpoint, host)
}
}
// Update host and CA with cluster details.
host = endpoint
if len(caData) == 0 {
caData, err = base64.StdEncoding.DecodeString(clusterResource.MasterAuth.ClusterCaCertificate)
if err != nil {
return nil, fmt.Errorf("failed to decode GKE CA certificate: %w", err)
}
}
}
// Build and return the REST config.
return &auth.RESTConfig{
Host: host,
BearerToken: token.AccessToken,
CAData: caData,
ExpiresAt: token.Expiry,
}, nil
}
func (p Provider) impl() Implementation {
if p.Implementation == nil {
return implementation{}

View File

@ -26,6 +26,7 @@ import (
. "github.com/onsi/gomega"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google/externalaccount"
"google.golang.org/api/container/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -33,7 +34,7 @@ import (
"github.com/fluxcd/pkg/auth/gcp"
)
func TestNewControllerToken(t *testing.T) {
func TestProvider_NewControllerToken(t *testing.T) {
g := NewWithT(t)
impl := &mockImplementation{
@ -72,7 +73,7 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
},
SubjectTokenSupplier: gcp.TokenSupplier("oidc-token"),
SubjectTokenSupplier: gcp.StaticTokenSupplier("oidc-token"),
UniverseDomain: "googleapis.com",
},
},
@ -87,7 +88,7 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
},
SubjectTokenSupplier: gcp.TokenSupplier("oidc-token"),
SubjectTokenSupplier: gcp.StaticTokenSupplier("oidc-token"),
UniverseDomain: "googleapis.com",
},
annotations: map[string]string{
@ -105,7 +106,7 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
},
SubjectTokenSupplier: gcp.TokenSupplier("oidc-token"),
SubjectTokenSupplier: gcp.StaticTokenSupplier("oidc-token"),
UniverseDomain: "googleapis.com",
},
annotations: map[string]string{
@ -123,7 +124,7 @@ func TestProvider_NewTokenForServiceAccount(t *testing.T) {
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
},
SubjectTokenSupplier: gcp.TokenSupplier("oidc-token"),
SubjectTokenSupplier: gcp.StaticTokenSupplier("oidc-token"),
UniverseDomain: "googleapis.com",
},
annotations: map[string]string{
@ -213,9 +214,9 @@ func TestProvider_GetAudience(t *testing.T) {
},
}
aud, err := gcp.Provider{}.GetAudience(context.Background(), serviceAccount)
aud, err := gcp.Provider{}.GetAudiences(context.Background(), serviceAccount)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(aud).To(Equal(tt.expected))
g.Expect(aud).To(Equal([]string{tt.expected}))
})
}
}
@ -259,12 +260,19 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) {
exp := time.Now()
accessToken := &gcp.Token{oauth2.Token{
AccessToken: "access-token",
Expiry: exp,
}}
provider := gcp.Provider{
Implementation: &mockImplementation{
t: t,
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
returnToken: &oauth2.Token{
AccessToken: "access-token",
Expiry: exp,
},
},
}
creds, err := gcp.Provider{}.NewArtifactRegistryCredentials(context.Background(), "", accessToken)
creds, err := auth.GetArtifactRegistryCredentials(context.Background(), provider, "gcr.io",
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(creds).NotTo(BeNil())
g.Expect(creds.ExpiresAt).To(Equal(exp))
@ -322,3 +330,131 @@ func TestProvider_ParseArtifactRegistry(t *testing.T) {
})
}
}
func TestProvider_NewRESTConfig(t *testing.T) {
for _, tt := range []struct {
name string
cluster string
clusterAddress string
caData string
masterAuth *container.MasterAuth
endpoint string
err string
}{
{
name: "valid GKE cluster",
cluster: "projects/test-project/locations/us-central1/clusters/test-cluster",
masterAuth: &container.MasterAuth{
ClusterCaCertificate: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t", // base64 encoded "-----BEGIN CERTIFICATE-----"
},
endpoint: "https://203.0.113.10",
},
{
name: "valid GKE cluster with address match",
cluster: "projects/test-project/locations/us-central1/clusters/test-cluster",
clusterAddress: "https://203.0.113.10:443",
masterAuth: &container.MasterAuth{
ClusterCaCertificate: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t",
},
endpoint: "https://203.0.113.10",
},
{
name: "valid GKE cluster with CA",
cluster: "projects/test-project/locations/us-central1/clusters/test-cluster",
caData: "-----BEGIN CERTIFICATE-----",
endpoint: "https://203.0.113.10",
},
{
name: "CA and address only",
clusterAddress: "https://203.0.113.10",
caData: "-----BEGIN CERTIFICATE-----",
endpoint: "https://203.0.113.10",
},
{
name: "cluster address mismatch",
cluster: "projects/test-project/locations/us-central1/clusters/test-cluster",
clusterAddress: "https://198.51.100.10:443",
masterAuth: &container.MasterAuth{
ClusterCaCertificate: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t",
},
endpoint: "https://203.0.113.10",
err: "GKE endpoint 'https://203.0.113.10' does not match specified address: 'https://198.51.100.10:443'",
},
{
name: "invalid cluster ID",
cluster: "invalid-cluster-id",
err: "invalid GKE cluster ID: 'invalid-cluster-id'. must match ^projects/[^/]{1,200}/locations/[^/]{1,200}/clusters/[^/]{1,200}$",
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
tokenExpiry := time.Now().Add(1 * time.Hour)
impl := &mockImplementation{
t: t,
expectGKEAPICall: tt.clusterAddress == "" || tt.caData == "",
argCluster: tt.cluster,
argProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com"},
returnToken: &oauth2.Token{
AccessToken: "access-token",
Expiry: tokenExpiry,
},
returnCluster: &container.Cluster{
Endpoint: tt.endpoint,
MasterAuth: tt.masterAuth,
},
}
opts := []auth.Option{
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.example.com"}),
}
if tt.cluster != "" {
opts = append(opts, auth.WithClusterResource(tt.cluster))
}
if tt.clusterAddress != "" {
opts = append(opts, auth.WithClusterAddress(tt.clusterAddress))
}
if tt.caData != "" {
opts = append(opts, auth.WithCAData(tt.caData))
}
provider := gcp.Provider{Implementation: impl}
restConfig, err := auth.GetRESTConfig(context.Background(), provider, opts...)
if tt.err == "" {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(restConfig).NotTo(BeNil())
g.Expect(restConfig.Host).To(Equal(tt.endpoint))
g.Expect(restConfig.BearerToken).To(Equal("access-token"))
g.Expect(restConfig.CAData).To(Equal([]byte("-----BEGIN CERTIFICATE-----")))
g.Expect(restConfig.ExpiresAt).To(Equal(tokenExpiry))
} else {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.err))
g.Expect(restConfig).To(BeNil())
}
})
}
}
func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) {
g := NewWithT(t)
t.Run("with cluster resource", func(t *testing.T) {
opts, err := gcp.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterResource("projects/test-project/locations/us-central1/clusters/test-cluster"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(1))
g.Expect(opts[0]).To(HaveLen(0)) // Empty slice - no options needed for GCP
})
t.Run("without cluster resource", func(t *testing.T) {
opts, err := gcp.Provider{}.GetAccessTokenOptionsForCluster()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(1))
g.Expect(opts[0]).To(HaveLen(0)) // Empty slice - no options needed for GCP
})
}

View File

@ -30,7 +30,6 @@ func (t *Token) GetDuration() time.Duration {
return time.Until(t.Expiry)
}
// Source gets a token source for the token to use with GCP libraries.
func (t *Token) Source() oauth2.TokenSource {
func (t *Token) source() oauth2.TokenSource {
return oauth2.StaticTokenSource(&t.Token)
}

View File

@ -37,7 +37,7 @@ func NewTokenSource(ctx context.Context, opts ...auth.Option) oauth2.TokenSource
// Token implements oauth2.TokenSource.
func (t *tokenSource) Token() (*oauth2.Token, error) {
token, err := auth.GetToken(t.ctx, Provider{}, t.opts...)
token, err := auth.GetAccessToken(t.ctx, Provider{}, t.opts...)
if err != nil {
return nil, err
}

View File

@ -22,10 +22,10 @@ import (
"golang.org/x/oauth2/google/externalaccount"
)
// TokenSupplier provides a static OIDC token.
type TokenSupplier string
// StaticTokenSupplier provides a static OIDC token.
type StaticTokenSupplier string
// SubjectToken implements externalaccount.SubjectTokenSupplier.
func (s TokenSupplier) SubjectToken(context.Context, externalaccount.SupplierOptions) (string, error) {
func (s StaticTokenSupplier) SubjectToken(context.Context, externalaccount.SupplierOptions) (string, error) {
return string(s), nil
}

View File

@ -14,13 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package authutils
package generic
import "errors"
import "os"
// ErrProviderDoesNotSupportRegistry is returned when the provider does not
// support registry authentication.
var ErrProviderDoesNotSupportRegistry = errors.New("provider does not support registry authentication")
// Implementation provides the required methods of the generic libraries.
type Implementation interface {
ReadFile(name string) ([]byte, error)
}
// ErrUnsupportedProvider is returned when the provider is not supported.
var ErrUnsupportedProvider = errors.New("unsupported provider")
type implementation struct{}
func (implementation) ReadFile(name string) ([]byte, error) {
return os.ReadFile(name)
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package generic_test
import (
"testing"
. "github.com/onsi/gomega"
)
type mockImplementation struct {
t *testing.T
b []byte
}
func (m *mockImplementation) ReadFile(name string) ([]byte, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(name).To(Equal("/var/run/secrets/kubernetes.io/serviceaccount/token"))
return m.b, nil
}

201
auth/generic/provider.go Normal file
View File

@ -0,0 +1,201 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package generic
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
authnv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/auth"
)
// ProviderName is the name of the generic authentication provider.
const ProviderName = "generic"
// Provider implements the auth.Provider interface for generic authentication.
type Provider struct{ Implementation }
// GetName implements auth.RESTConfigProvider.
func (p Provider) GetName() string {
return ProviderName
}
// NewControllerToken implements auth.RESTConfigProvider.
func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) {
var o auth.Options
o.Apply(opts...)
if o.Client == nil {
return nil, errors.New("client is required to create a controller token")
}
// Like all providers, this one should fetch controller-level credentials
// from the environment. In this case, this means opening the well-known
// Kubernetes service account token file and parsing it to figure out
// the controller's identity.
const tokenFile = "/var/run/secrets/kubernetes.io/serviceaccount/token"
b, err := p.impl().ReadFile(tokenFile)
if err != nil {
return nil, fmt.Errorf("failed to read service account token file %s: %w", tokenFile, err)
}
// Get controller service account from token subject.
tok, _, err := jwt.NewParser().ParseUnverified(string(b), jwt.MapClaims{})
if err != nil {
return nil, fmt.Errorf("failed to parse service account token: %w", err)
}
sub, err := tok.Claims.GetSubject()
if err != nil {
return nil, fmt.Errorf("failed to get subject from service account token: %w", err)
}
parts := strings.Split(sub, ":")
if len(parts) != 4 {
return nil, fmt.Errorf("invalid subject format in service account token: %s", sub)
}
serviceAccount := corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: parts[3],
Namespace: parts[2],
},
}
// Create token.
tokenReq := &authnv1.TokenRequest{
Spec: authnv1.TokenRequestSpec{
Audiences: o.Audiences,
},
}
if err := o.Client.SubResource("token").Create(ctx, &serviceAccount, tokenReq); err != nil {
return nil, fmt.Errorf("failed to create kubernetes token for controller service account '%s': %w",
client.ObjectKeyFromObject(&serviceAccount), err)
}
token := tokenReq.Status.Token
exp, err := getExpirationFromToken(token)
if err != nil {
return nil, err
}
return &Token{
Token: token,
ExpiresAt: *exp,
}, nil
}
// GetAudiences implements auth.RESTConfigProvider.
func (Provider) GetAudiences(context.Context, corev1.ServiceAccount) ([]string, error) {
// Use TokenRequest default audiences.
return nil, nil
}
// GetIdentity implements auth.RESTConfigProvider.
func (Provider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
return fmt.Sprintf("system:serviceaccount:%s:%s", serviceAccount.Namespace, serviceAccount.Name), nil
}
// NewTokenForServiceAccount implements auth.RESTConfigProvider.
func (Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken string,
serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) {
exp, err := getExpirationFromToken(oidcToken)
if err != nil {
return nil, err
}
return &Token{
Token: oidcToken,
ExpiresAt: *exp,
}, nil
}
// GetAccessTokenOptionsForCluster implements auth.RESTConfigProvider.
func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) {
var o auth.Options
o.Apply(opts...)
audiences := o.Audiences
if len(audiences) == 0 {
// Use cluster address as the default audience.
audiences = []string{o.ClusterAddress}
}
return [][]auth.Option{{auth.WithAudiences(audiences...)}}, nil
}
// NewRESTConfig implements auth.RESTConfigProvider.
func (Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
opts ...auth.Option) (*auth.RESTConfig, error) {
token := accessTokens[0].(*Token)
var o auth.Options
o.Apply(opts...)
// Parse the cluster address.
host := o.ClusterAddress
if host == "" {
return nil, errors.New("cluster address is required to create a REST config")
}
var err error
host, err = auth.ParseClusterAddress(host)
if err != nil {
return nil, fmt.Errorf("failed to parse cluster address %s: %w", o.ClusterAddress, err)
}
// Get CA if provided.
var caData []byte
if o.CAData != "" {
caData = []byte(o.CAData)
}
return &auth.RESTConfig{
Host: host,
CAData: caData,
BearerToken: token.Token,
ExpiresAt: token.ExpiresAt,
}, nil
}
func (p Provider) impl() Implementation {
if p.Implementation == nil {
return implementation{}
}
return p.Implementation
}
func getExpirationFromToken(token string) (*time.Time, error) {
tok, _, err := jwt.NewParser().ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, fmt.Errorf("failed to parse service account token: %w", err)
}
exp, err := tok.Claims.GetExpirationTime()
if err != nil {
return nil, fmt.Errorf("failed to get expiration time from service account token: %w", err)
}
return &exp.Time, nil
}

View File

@ -0,0 +1,349 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package generic_test
import (
"context"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/auth/generic"
"github.com/fluxcd/pkg/auth/utils"
)
func TestProvider_NewControllerToken(t *testing.T) {
t.Run("no client", func(t *testing.T) {
g := NewWithT(t)
token, err := generic.Provider{}.NewControllerToken(context.Background())
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal("client is required to create a controller token"))
g.Expect(token).To(BeNil())
})
t.Run("with audiences", func(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
_, envClient, oidcClient := newTestEnv(t, ctx)
// Create service account.
serviceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "controller",
Namespace: "default",
},
}
err := envClient.Create(ctx, serviceAccount)
g.Expect(err).NotTo(HaveOccurred())
// Create token.
m := &mockImplementation{
t: t,
b: []byte("eyJhbGciOiJSUzI1NiIsImtpZCI6IkU2cUVmaVJ0QUY2OWhoNThZWU1QUmhPc1F1b1N5XzJuT1ZfRWF3TVRETlkifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNzUyMjkwMDE1LCJpYXQiOjE3NTIyODY0MTUsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiMzEwMTgxZGItZDc3MC00MGE5LTg5MDEtN2M1NTQzOTBjZDhjIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImNvbnRyb2xsZXIiLCJ1aWQiOiJjMTUzNWEyNi01NDY5LTRmYzAtOGRiMi1kZWFhMGRlNDRmZjUifX0sIm5iZiI6MTc1MjI4NjQxNSwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6Y29udHJvbGxlciJ9.k-jt09bIwrGUNbSATEwaHHaaoym7NjcdStXcM0RYXZbL_PXCwP-TZPgBb2FzCq6V79E_q-NtZrY3RyvyAynUezXr6IPVkGne201uvOAjaibLvDxLzvbA5jWlZ0bHuLCfOxlC7GYSWjsglyH_ufulb6vxoMhY0rmiQzBbDHfB3EWM79-udcqLrxBsGgxjDnW4BXMIgSpuvipNA1GaMkpQb5AaY7Ns4zd0FftOimQmmvnwz8oDrGrCf2kmw91r0sAovva5B2BoJKlZwYGwO93zwTwK1qOMPLN2QHCUNBEY4K-QQlgz0oMUYR-YRpPJr7akjTQ6hm9zrTD90Tm0Jbqw7g\n"),
}
token, err := auth.GetAccessToken(ctx, generic.Provider{m},
auth.WithClient(envClient),
auth.WithAudiences("audience1", "audience2"))
g.Expect(err).NotTo(HaveOccurred())
genericToken := token.(*generic.Token)
g.Expect(genericToken).NotTo(BeNil())
// Validate token.
jwtToken, _, err := jwt.NewParser().ParseUnverified(genericToken.Token, jwt.MapClaims{})
g.Expect(err).NotTo(HaveOccurred())
sub, err := jwtToken.Claims.GetSubject()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(sub).To(Equal("system:serviceaccount:default:controller"))
iss, err := jwtToken.Claims.GetIssuer()
g.Expect(err).NotTo(HaveOccurred())
ctx = oidc.ClientContext(ctx, oidcClient)
jwks := oidc.NewRemoteKeySet(ctx, iss+"openid/v1/jwks")
for _, aud := range []string{"audience1", "audience2"} {
_, err = oidc.NewVerifier(iss, jwks, &oidc.Config{
ClientID: aud,
SupportedSigningAlgs: []string{jwtToken.Method.Alg()},
}).Verify(ctx, genericToken.Token)
g.Expect(err).NotTo(HaveOccurred())
}
g.Expect(time.Until(genericToken.ExpiresAt)).To(BeNumerically("~", time.Hour, 10*time.Second))
})
}
func TestProvider_NewTokenForServiceAccount(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
_, envClient, oidcClient := newTestEnv(t, ctx)
t.Setenv(auth.EnvVarEnableObjectLevelWorkloadIdentity, "true")
// Create service account.
serviceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant",
Namespace: "default",
},
}
err := envClient.Create(ctx, serviceAccount)
g.Expect(err).NotTo(HaveOccurred())
// Create token.
token, err := auth.GetAccessToken(ctx, generic.Provider{},
auth.WithServiceAccount(client.ObjectKeyFromObject(serviceAccount), envClient),
auth.WithAudiences("audience1", "audience2"))
g.Expect(err).NotTo(HaveOccurred())
genericToken := token.(*generic.Token)
g.Expect(genericToken).NotTo(BeNil())
// Validate token.
jwtToken, _, err := jwt.NewParser().ParseUnverified(genericToken.Token, jwt.MapClaims{})
g.Expect(err).NotTo(HaveOccurred())
sub, err := jwtToken.Claims.GetSubject()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(sub).To(Equal("system:serviceaccount:default:tenant"))
iss, err := jwtToken.Claims.GetIssuer()
g.Expect(err).NotTo(HaveOccurred())
ctx = oidc.ClientContext(ctx, oidcClient)
jwks := oidc.NewRemoteKeySet(ctx, iss+"openid/v1/jwks")
for _, aud := range []string{"audience1", "audience2"} {
_, err = oidc.NewVerifier(iss, jwks, &oidc.Config{
ClientID: aud,
SupportedSigningAlgs: []string{jwtToken.Method.Alg()},
}).Verify(ctx, genericToken.Token)
g.Expect(err).NotTo(HaveOccurred())
}
g.Expect(time.Until(genericToken.ExpiresAt)).To(BeNumerically("~", time.Hour, 10*time.Second))
}
func TestProvider_GetIdentity(t *testing.T) {
g := NewWithT(t)
id, err := generic.Provider{}.GetIdentity(corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "tenant",
Namespace: "default",
},
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(id).To(Equal("system:serviceaccount:default:tenant"))
}
func TestProvider_NewRESTConfig(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
_, envClient, oidcClient := newTestEnv(t, ctx)
// Create service account.
serviceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "controller",
Namespace: "default",
},
}
err := envClient.Create(ctx, serviceAccount)
g.Expect(err).NotTo(HaveOccurred())
// Mock implementation.
m := &mockImplementation{
t: t,
b: []byte("eyJhbGciOiJSUzI1NiIsImtpZCI6IkU2cUVmaVJ0QUY2OWhoNThZWU1QUmhPc1F1b1N5XzJuT1ZfRWF3TVRETlkifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNzUyMjkwMDE1LCJpYXQiOjE3NTIyODY0MTUsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiMzEwMTgxZGItZDc3MC00MGE5LTg5MDEtN2M1NTQzOTBjZDhjIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImNvbnRyb2xsZXIiLCJ1aWQiOiJjMTUzNWEyNi01NDY5LTRmYzAtOGRiMi1kZWFhMGRlNDRmZjUifX0sIm5iZiI6MTc1MjI4NjQxNSwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6Y29udHJvbGxlciJ9.k-jt09bIwrGUNbSATEwaHHaaoym7NjcdStXcM0RYXZbL_PXCwP-TZPgBb2FzCq6V79E_q-NtZrY3RyvyAynUezXr6IPVkGne201uvOAjaibLvDxLzvbA5jWlZ0bHuLCfOxlC7GYSWjsglyH_ufulb6vxoMhY0rmiQzBbDHfB3EWM79-udcqLrxBsGgxjDnW4BXMIgSpuvipNA1GaMkpQb5AaY7Ns4zd0FftOimQmmvnwz8oDrGrCf2kmw91r0sAovva5B2BoJKlZwYGwO93zwTwK1qOMPLN2QHCUNBEY4K-QQlgz0oMUYR-YRpPJr7akjTQ6hm9zrTD90Tm0Jbqw7g\n"),
}
for _, tt := range []struct {
name string
audiences []string
clusterAddress string
err string
}{
{
name: "address is required",
err: "cluster address is required to create a REST config",
},
{
name: "with audiences",
clusterAddress: "https://example.com",
audiences: []string{"audience1", "audience2"},
},
{
name: "without audiences",
clusterAddress: "https://example.com",
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
opts := []auth.Option{
auth.WithClient(envClient),
auth.WithCAData("----- BEGIN CERTIFICATE-----"),
}
if len(tt.audiences) > 0 {
opts = append(opts, auth.WithAudiences(tt.audiences...))
}
if tt.clusterAddress != "" {
opts = append(opts, auth.WithClusterAddress(tt.clusterAddress))
}
conf, err := auth.GetRESTConfig(ctx, generic.Provider{m}, opts...)
if tt.err != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal(tt.err))
g.Expect(conf).To(BeNil())
return
}
g.Expect(err).NotTo(HaveOccurred())
g.Expect(conf).NotTo(BeNil())
// Validate REST config.
g.Expect(conf.Host).To(Equal("https://example.com:443"))
g.Expect(conf.CAData).To(Equal([]byte("----- BEGIN CERTIFICATE-----")))
g.Expect(time.Until(conf.ExpiresAt)).To(BeNumerically("~", time.Hour, 10*time.Second))
// Validate token.
jwtToken, _, err := jwt.NewParser().ParseUnverified(conf.BearerToken, jwt.MapClaims{})
g.Expect(err).NotTo(HaveOccurred())
sub, err := jwtToken.Claims.GetSubject()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(sub).To(Equal("system:serviceaccount:default:controller"))
iss, err := jwtToken.Claims.GetIssuer()
g.Expect(err).NotTo(HaveOccurred())
ctx = oidc.ClientContext(ctx, oidcClient)
jwks := oidc.NewRemoteKeySet(ctx, iss+"openid/v1/jwks")
expectedAudiences := []string{"audience1", "audience2"}
if len(tt.audiences) == 0 {
expectedAudiences = []string{"https://example.com"}
}
for _, aud := range expectedAudiences {
_, err = oidc.NewVerifier(iss, jwks, &oidc.Config{
ClientID: aud,
SupportedSigningAlgs: []string{jwtToken.Method.Alg()},
}).Verify(ctx, conf.BearerToken)
g.Expect(err).NotTo(HaveOccurred())
}
})
}
}
func TestProvider_NewRESTConfig_EndToEnd(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
envConfig, envClient, _ := newTestEnv(t, ctx)
t.Setenv(auth.EnvVarEnableObjectLevelWorkloadIdentity, "true")
// Create service account.
const (
namespace = "default"
saName = "tenant"
cmName = "kubeconfig"
)
serviceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: saName,
Namespace: namespace,
},
}
g.Expect(envClient.Create(ctx, serviceAccount)).NotTo(HaveOccurred())
// Create kubeconfig configmap.
kubeconfig := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: cmName,
Namespace: namespace,
},
Data: map[string]string{
meta.KubeConfigKeyProvider: generic.ProviderName,
meta.KubeConfigKeyAddress: envConfig.Host,
meta.KubeConfigKeyCACert: string(envConfig.CAData),
meta.KubeConfigKeyServiceAccountName: saName,
},
}
g.Expect(envClient.Create(ctx, kubeconfig)).NotTo(HaveOccurred())
// Create the authenticated client.
fetcher := utils.GetRESTConfigFetcher(
auth.WithClient(envClient),
auth.WithClusterAddress(envConfig.Host),
auth.WithCAData(string(envConfig.CAData)))
conf, err := fetcher(ctx, meta.KubeConfigReference{
ConfigMapRef: &meta.LocalObjectReference{
Name: cmName,
},
}, namespace, envClient)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(conf).NotTo(BeNil())
client, err := kubernetes.NewForConfig(conf)
g.Expect(err).NotTo(HaveOccurred())
// Test a permission that an authenticated ServiceAccount should have.
version, err := client.Discovery().ServerVersion()
g.Expect(err).NotTo(HaveOccurred())
g.Expect(version).NotTo(BeNil())
// Test a permission that an authenticated ServiceAccount without any RBAC should NOT have.
_, err = client.CoreV1().Namespaces().Get(ctx, "default", metav1.GetOptions{})
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(`forbidden: User "system:serviceaccount:default:tenant" cannot get resource "namespaces"`))
}
func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) {
t.Run("without audiences", func(t *testing.T) {
g := NewWithT(t)
opts, err := generic.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterAddress("https://example.com"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(1))
g.Expect(opts[0]).To(HaveLen(1))
var o auth.Options
o.Apply(opts[0]...)
g.Expect(o.Audiences).To(ConsistOf("https://example.com"))
})
t.Run("with audiences", func(t *testing.T) {
g := NewWithT(t)
opts, err := generic.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterAddress("https://example.com"),
auth.WithAudiences("audience1", "audience2"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(opts).To(HaveLen(1))
g.Expect(opts[0]).To(HaveLen(1))
var o auth.Options
o.Apply(opts[0]...)
g.Expect(o.Audiences).To(ConsistOf("audience1", "audience2"))
})
}

View File

@ -0,0 +1,77 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package generic_test
import (
"context"
"crypto/tls"
"crypto/x509"
"net/http"
"testing"
. "github.com/onsi/gomega"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
func newTestEnv(t *testing.T, ctx context.Context) (*rest.Config, client.Client, *http.Client) {
t.Helper()
g := NewWithT(t)
// Create test env.
testEnv := &envtest.Environment{}
conf, err := testEnv.Start()
g.Expect(err).NotTo(HaveOccurred())
t.Cleanup(func() { testEnv.Stop() })
envClient, err := client.New(conf, client.Options{})
g.Expect(err).NotTo(HaveOccurred())
// Create HTTP client for OIDC verification.
clusterCAPool := x509.NewCertPool()
ok := clusterCAPool.AppendCertsFromPEM(conf.TLSClientConfig.CAData)
g.Expect(ok).To(BeTrue())
oidcClient := &http.Client{}
oidcClient.Transport = http.DefaultTransport.(*http.Transport).Clone()
oidcClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{
RootCAs: clusterCAPool,
}
// Grant anonymous access to service account issuer discovery.
err = envClient.Create(ctx, &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "anonymous-service-account-issuer-discovery",
},
Subjects: []rbacv1.Subject{
{
APIGroup: "rbac.authorization.k8s.io",
Kind: "User",
Name: "system:anonymous",
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "ClusterRole",
Name: "system:service-account-issuer-discovery",
},
})
g.Expect(err).NotTo(HaveOccurred())
return testEnv.Config, envClient, oidcClient
}

32
auth/generic/token.go Normal file
View File

@ -0,0 +1,32 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package generic
import (
"time"
)
// Token is the generic token.
type Token struct {
Token string
ExpiresAt time.Time
}
// GetDuration implements auth.Token.
func (t *Token) GetDuration() time.Duration {
return time.Until(t.ExpiresAt)
}

View File

@ -1,208 +0,0 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth
import (
"context"
"crypto/sha256"
"fmt"
"strings"
authnv1 "k8s.io/api/authentication/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/cache"
)
// GetToken returns an access token for accessing resources in the given cloud provider.
func GetToken(ctx context.Context, provider Provider, opts ...Option) (Token, error) {
var o Options
o.Apply(opts...)
// Initialize access token fetcher for controller.
newAccessToken := func() (Token, error) {
token, err := provider.NewControllerToken(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create provider access token for the controller: %w", err)
}
return token, nil
}
// Update access token fetcher for a service account if specified.
var providerAudience string
var providerIdentity string
var serviceAccountP *corev1.ServiceAccount
if o.ServiceAccount != nil {
// Check the feature gate for object-level workload identity.
if !IsObjectLevelWorkloadIdentityEnabled() {
return nil, ErrObjectLevelWorkloadIdentityNotEnabled
}
// Get service account and prepare a function to create a token for it.
var serviceAccount corev1.ServiceAccount
if err := o.Client.Get(ctx, *o.ServiceAccount, &serviceAccount); err != nil {
return nil, fmt.Errorf("failed to get service account '%s/%s': %w",
o.ServiceAccount.Namespace, o.ServiceAccount.Name, err)
}
serviceAccountP = &serviceAccount
// Get provider audience.
var err error
providerAudience, err = provider.GetAudience(ctx, serviceAccount)
if err != nil {
return nil, fmt.Errorf("failed to get provider audience: %w", err)
}
// Get provider identity.
providerIdentity, err = provider.GetIdentity(serviceAccount)
if err != nil {
return nil, fmt.Errorf("failed to get provider identity from service account '%s/%s' annotations: %w",
serviceAccount.Namespace, serviceAccount.Name, err)
}
// Update access token fetcher.
newAccessToken = func() (Token, error) {
identityToken, err := newServiceAccountToken(ctx, o.Client, serviceAccount, providerAudience)
if err != nil {
return nil, fmt.Errorf("failed to create kubernetes token for service account '%s/%s': %w",
serviceAccount.Namespace, serviceAccount.Name, err)
}
token, err := provider.NewTokenForServiceAccount(ctx, identityToken, serviceAccount, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create provider access token for service account '%s/%s': %w",
serviceAccount.Namespace, serviceAccount.Name, err)
}
return token, nil
}
}
// Initialize token fetcher with access token fetcher.
newToken := newAccessToken
// Update token fetcher to registry token fetcher if artifact repository is specified.
var artifactRepositoryCacheKey string
if o.ArtifactRepository != "" {
// Parse artifact repository.
registryInput, err := provider.ParseArtifactRepository(o.ArtifactRepository)
if err != nil {
return nil, fmt.Errorf("failed to parse artifact repository '%s': %w",
o.ArtifactRepository, err)
}
// Set artifact repository cache key.
artifactRepositoryCacheKey = registryInput
// Update token fetcher.
newToken = func() (Token, error) {
accessToken, err := newAccessToken()
if err != nil {
return nil, err
}
token, err := provider.NewArtifactRegistryCredentials(ctx, registryInput, accessToken, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create artifact registry credentials: %w", err)
}
return token, nil
}
}
// Bail out early if cache is disabled.
if o.Cache == nil {
return newToken()
}
// Build cache key.
cacheKey := buildCacheKey(provider, providerAudience, providerIdentity,
artifactRepositoryCacheKey, serviceAccountP, opts...)
// Get involved object details.
kind := o.InvolvedObject.Kind
name := o.InvolvedObject.Name
namespace := o.InvolvedObject.Namespace
operation := o.InvolvedObject.Operation
// Get token from cache.
token, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) {
return newToken()
}, cache.WithInvolvedObject(kind, name, namespace, operation))
if err != nil {
return nil, err
}
return token, nil
}
func newServiceAccountToken(ctx context.Context, client client.Client,
serviceAccount corev1.ServiceAccount, providerAudience string) (string, error) {
tokenReq := &authnv1.TokenRequest{
Spec: authnv1.TokenRequestSpec{
Audiences: []string{providerAudience},
},
}
if err := client.SubResource("token").Create(ctx, &serviceAccount, tokenReq); err != nil {
return "", err
}
return tokenReq.Status.Token, nil
}
func buildCacheKey(provider Provider, providerAudience, providerIdentity, artifactRepositoryKey string,
serviceAccount *corev1.ServiceAccount, opts ...Option) string {
var o Options
o.Apply(opts...)
var keyParts []string
keyParts = append(keyParts, fmt.Sprintf("provider=%s", provider.GetName()))
if serviceAccount != nil {
keyParts = append(keyParts, fmt.Sprintf("providerAudience=%s", providerAudience))
keyParts = append(keyParts, fmt.Sprintf("providerIdentity=%s", providerIdentity))
keyParts = append(keyParts, fmt.Sprintf("serviceAccountName=%s", serviceAccount.Name))
keyParts = append(keyParts, fmt.Sprintf("serviceAccountNamespace=%s", serviceAccount.Namespace))
}
if len(o.Scopes) > 0 {
keyParts = append(keyParts, fmt.Sprintf("scopes=%s", strings.Join(o.Scopes, ",")))
}
if o.ArtifactRepository != "" {
keyParts = append(keyParts, fmt.Sprintf("artifactRepositoryKey=%s", artifactRepositoryKey))
}
if o.STSRegion != "" {
keyParts = append(keyParts, fmt.Sprintf("stsRegion=%s", o.STSRegion))
}
if o.STSEndpoint != "" {
keyParts = append(keyParts, fmt.Sprintf("stsEndpoint=%s", o.STSEndpoint))
}
if o.ProxyURL != nil {
keyParts = append(keyParts, fmt.Sprintf("proxyURL=%s", o.ProxyURL.String()))
}
s := strings.Join(keyParts, ",")
hash := sha256.Sum256([]byte(s))
return fmt.Sprintf("%x", hash)
}

View File

@ -1,428 +0,0 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth_test
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net/http"
"net/url"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
"github.com/google/go-containerregistry/pkg/authn"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/cache"
)
type mockToken struct {
token string
}
func (m *mockToken) GetDuration() time.Duration {
return time.Hour
}
type mockProvider struct {
t *testing.T
returnName string
returnAudience string
returnIdentity string
returnIdentityErr string
returnRegistryErr string
returnRegistryInput string
returnControllerToken auth.Token
returnAccessToken auth.Token
returnRegistryToken *auth.ArtifactRegistryCredentials
paramServiceAccount corev1.ServiceAccount
paramOIDCTokenClient *http.Client
paramArtifactRepository string
paramAccessToken auth.Token
paramAllowShellOut bool
}
func (m *mockProvider) GetName() string {
return m.returnName
}
func (m *mockProvider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) {
checkOptions(m.t, m.paramAllowShellOut, opts...)
return m.returnControllerToken, nil
}
func (m *mockProvider) GetAudience(ctx context.Context, serviceAccount corev1.ServiceAccount) (string, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(serviceAccount).To(Equal(m.paramServiceAccount))
return m.returnAudience, nil
}
func (m *mockProvider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(serviceAccount).To(Equal(m.paramServiceAccount))
if m.returnIdentityErr != "" {
return "", errors.New(m.returnIdentityErr)
}
return m.returnIdentity, nil
}
func (m *mockProvider) NewTokenForServiceAccount(ctx context.Context, oidcToken string,
serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) {
m.t.Helper()
g := NewWithT(m.t)
// Verify the OIDC token.
g.Expect(m.returnAudience).NotTo(BeEmpty())
token, _, err := jwt.NewParser().ParseUnverified(oidcToken, jwt.MapClaims{})
g.Expect(err).NotTo(HaveOccurred())
iss, err := token.Claims.GetIssuer()
g.Expect(err).NotTo(HaveOccurred())
ctx = oidc.ClientContext(ctx, m.paramOIDCTokenClient)
jwks := oidc.NewRemoteKeySet(ctx, iss+"openid/v1/jwks")
_, err = oidc.NewVerifier(iss, jwks, &oidc.Config{
ClientID: m.returnAudience,
SupportedSigningAlgs: []string{token.Method.Alg()},
}).Verify(ctx, oidcToken)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(serviceAccount).To(Equal(m.paramServiceAccount))
checkOptions(m.t, m.paramAllowShellOut, opts...)
return m.returnAccessToken, nil
}
func (m *mockProvider) ParseArtifactRepository(artifactRepository string) (string, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(artifactRepository).To(Equal(m.paramArtifactRepository))
if m.returnRegistryErr != "" {
return "", errors.New(m.returnRegistryErr)
}
return m.returnRegistryInput, nil
}
func (m *mockProvider) NewArtifactRegistryCredentials(ctx context.Context, registryInput string,
accessToken auth.Token, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(registryInput).To(Equal(m.paramArtifactRepository))
g.Expect(accessToken).To(Equal(m.paramAccessToken))
checkOptions(m.t, m.paramAllowShellOut, opts...)
return m.returnRegistryToken, nil
}
func checkOptions(t *testing.T, allowShellOut bool, opts ...auth.Option) {
t.Helper()
g := NewWithT(t)
var o auth.Options
o.Apply(opts...)
g.Expect(o.Scopes).To(Equal([]string{"scope1", "scope2"}))
g.Expect(o.STSRegion).To(Equal("us-east-1"))
g.Expect(o.STSEndpoint).To(Equal("https://sts.some-cloud.io"))
g.Expect(o.ProxyURL).To(Equal(&url.URL{Scheme: "http", Host: "proxy.io:8080"}))
g.Expect(o.AllowShellOut).To(Equal(allowShellOut))
}
func TestGetToken(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
// Create test env.
testEnv := &envtest.Environment{}
conf, err := testEnv.Start()
g.Expect(err).NotTo(HaveOccurred())
t.Cleanup(func() { testEnv.Stop() })
kubeClient, err := client.New(conf, client.Options{})
g.Expect(err).NotTo(HaveOccurred())
// Create HTTP client for OIDC verification.
clusterCAPool := x509.NewCertPool()
ok := clusterCAPool.AppendCertsFromPEM(conf.TLSClientConfig.CAData)
g.Expect(ok).To(BeTrue())
oidcClient := &http.Client{}
oidcClient.Transport = http.DefaultTransport.(*http.Transport).Clone()
oidcClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{
RootCAs: clusterCAPool,
}
// Grant anonymous access to service account issuer discovery.
err = kubeClient.Create(ctx, &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "anonymous-service-account-issuer-discovery",
},
Subjects: []rbacv1.Subject{
{
APIGroup: "rbac.authorization.k8s.io",
Kind: "User",
Name: "system:anonymous",
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "ClusterRole",
Name: "system:service-account-issuer-discovery",
},
})
g.Expect(err).NotTo(HaveOccurred())
// Create a default service account.
defaultServiceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Namespace: "default",
},
}
err = kubeClient.Create(ctx, defaultServiceAccount)
g.Expect(err).NotTo(HaveOccurred())
saRef := client.ObjectKey{
Name: defaultServiceAccount.Name,
Namespace: defaultServiceAccount.Namespace,
}
for _, tt := range []struct {
name string
provider *mockProvider
opts []auth.Option
disableObjectLevel bool
expectedToken auth.Token
expectedErr string
}{
{
name: "controller access token",
provider: &mockProvider{
returnControllerToken: &mockToken{token: "mock-default-token"},
},
opts: []auth.Option{
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
},
expectedToken: &mockToken{token: "mock-default-token"},
},
{
name: "controller access token allowing shell out",
provider: &mockProvider{
returnControllerToken: &mockToken{token: "mock-default-token"},
paramAllowShellOut: true,
},
opts: []auth.Option{
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithAllowShellOut(),
},
expectedToken: &mockToken{token: "mock-default-token"},
},
{
name: "registry token from controller access token",
provider: &mockProvider{
returnRegistryInput: "some-registry.io/some/artifact",
returnControllerToken: &mockToken{token: "mock-default-token"},
returnRegistryToken: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
paramAccessToken: &mockToken{token: "mock-default-token"},
paramArtifactRepository: "some-registry.io/some/artifact",
},
opts: []auth.Option{
auth.WithArtifactRepository("some-registry.io/some/artifact"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
},
expectedToken: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
},
{
name: "access token from service account",
provider: &mockProvider{
returnName: "mock-provider",
returnAudience: "mock-audience",
returnAccessToken: &mockToken{token: "mock-access-token"},
paramServiceAccount: *defaultServiceAccount,
paramOIDCTokenClient: oidcClient,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
// Exercise the code path where a cache is set but no token is
// available in the cache.
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(1)
g.Expect(err).NotTo(HaveOccurred())
o.Cache = tokenCache
},
},
expectedToken: &mockToken{token: "mock-access-token"},
},
{
name: "registry token from access token from service account",
provider: &mockProvider{
returnName: "mock-provider",
returnAudience: "mock-audience",
returnRegistryInput: "some-registry.io/some/artifact",
returnAccessToken: &mockToken{token: "mock-access-token"},
returnRegistryToken: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
paramServiceAccount: *defaultServiceAccount,
paramOIDCTokenClient: oidcClient,
paramArtifactRepository: "some-registry.io/some/artifact",
paramAccessToken: &mockToken{token: "mock-access-token"},
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithArtifactRepository("some-registry.io/some/artifact"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
},
expectedToken: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
},
{
name: "all the options are taken into account in the cache key",
provider: &mockProvider{
returnName: "mock-provider",
returnAudience: "mock-audience",
returnIdentity: "mock-identity",
returnRegistryInput: "artifact-cache-key",
paramServiceAccount: *defaultServiceAccount,
paramArtifactRepository: "some-registry.io/some/artifact",
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithScopes("scope1", "scope2"),
auth.WithArtifactRepository("some-registry.io/some/artifact"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(1)
g.Expect(err).NotTo(HaveOccurred())
const key = "3e8e270134e99fda1a01d7dca77f29448eb4c7f6cc026137b85a1bcd96b276fa"
token := &mockToken{token: "cached-token"}
cachedToken, ok, err := tokenCache.GetOrSet(ctx, key, func(ctx context.Context) (cache.Token, error) {
return token, nil
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ok).To(BeFalse())
g.Expect(cachedToken).To(Equal(token))
o.Cache = tokenCache
},
},
expectedToken: &mockToken{token: "cached-token"},
},
{
name: "error getting identity",
provider: &mockProvider{
returnIdentityErr: "mock error",
paramServiceAccount: *defaultServiceAccount,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
},
expectedErr: "failed to get provider identity from service account 'default/default' annotations: mock error",
},
{
name: "error getting identity using cache",
provider: &mockProvider{
returnIdentityErr: "mock error",
paramServiceAccount: *defaultServiceAccount,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(1)
g.Expect(err).NotTo(HaveOccurred())
o.Cache = tokenCache
},
},
expectedErr: "failed to get provider identity from service account 'default/default' annotations: mock error",
},
{
name: "error parsing artifact repository",
provider: &mockProvider{
paramArtifactRepository: "some-registry.io/some/artifact",
returnRegistryErr: "mock error",
},
opts: []auth.Option{
auth.WithArtifactRepository("some-registry.io/some/artifact"),
},
expectedErr: "failed to parse artifact repository 'some-registry.io/some/artifact': mock error",
},
{
name: "disable object level workload identity",
provider: &mockProvider{},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
},
disableObjectLevel: true,
expectedErr: "ObjectLevelWorkloadIdentity feature gate is not enabled",
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
tt.provider.t = t
if !tt.disableObjectLevel {
t.Setenv(auth.EnvVarEnableObjectLevelWorkloadIdentity, "true")
}
token, err := auth.GetToken(ctx, tt.provider, tt.opts...)
if tt.expectedErr != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal(tt.expectedErr))
} else {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(token).To(Equal(tt.expectedToken))
}
})
}
}

View File

@ -2,30 +2,42 @@ module github.com/fluxcd/pkg/auth
go 1.24.0
replace github.com/fluxcd/pkg/cache => ../cache
replace (
github.com/fluxcd/pkg/apis/meta => ../apis/meta
github.com/fluxcd/pkg/cache => ../cache
)
require (
cloud.google.com/go/compute/metadata v0.7.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0
github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0
github.com/aws/aws-sdk-go-v2 v1.36.5
github.com/aws/aws-sdk-go-v2/config v1.29.17
github.com/aws/aws-sdk-go-v2/credentials v1.17.70
github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2
github.com/aws/aws-sdk-go-v2/service/eks v1.66.1
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0
github.com/coreos/go-oidc/v3 v3.14.1
github.com/fluxcd/pkg/apis/meta v1.18.0
github.com/fluxcd/pkg/cache v0.10.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/go-containerregistry v0.20.6
github.com/onsi/gomega v1.37.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
golang.org/x/oauth2 v0.30.0
google.golang.org/api v0.241.0
k8s.io/api v0.33.2
k8s.io/apimachinery v0.33.2
k8s.io/client-go v0.33.2
sigs.k8s.io/controller-runtime v0.21.0
)
require (
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
@ -45,16 +57,21 @@ require (
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
@ -73,6 +90,10 @@ require (
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.36.0 // indirect
go.opentelemetry.io/otel/metric v1.36.0 // indirect
go.opentelemetry.io/otel/trace v1.36.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/net v0.41.0 // indirect
@ -80,12 +101,13 @@ require (
golang.org/x/term v0.33.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.73.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.33.2 // indirect
k8s.io/client-go v0.33.2 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect

View File

@ -1,3 +1,7 @@
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
@ -6,8 +10,12 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0 h1:OVoM452qUFBrX+URdH3Vp
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.9.0/go.mod h1:kUjrAo8bgEwLeZ/CmHqNl3Z/kPm7y6FKfxxK0izYUg4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 h1:ldKsKtEIblsgsr6mPwrd9yRntoX6uLz/K89wsldwx/k=
github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3/go.mod h1:MAm7bk0oDLmD8yIkvfbxPW04fxzphPyL+7GzwHxOp6Y=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0 h1:figxyQZXzZQIcP3njhC68bYUiTw45J8/SsHaLW8Ax0M=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice v1.0.0/go.mod h1:TmlMW4W5OvXOmOyKNnor8nlMMiO1ctIyzmHme/VHsrA=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
@ -30,6 +38,8 @@ github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1 h1:Bwzh202Aq7/MYnAjXA9VawCf6u+h
github.com/aws/aws-sdk-go-v2/service/ecr v1.45.1/go.mod h1:xZzWl9AXYa6zsLLH41HBFW8KRKJRIzlGmvSM0mVMIX4=
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2 h1:XJ/AEFYj9VFPJdF+VFi4SUPEDfz1akHwxxm07JfZJcs=
github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.33.2/go.mod h1:JUBHdhvKbbKmhaHjLsKJAWnQL80T6nURmhB/LEprV+4=
github.com/aws/aws-sdk-go-v2/service/eks v1.66.1 h1:sD1y3G4WXw1GjK95L5dBXPFXNWl/O8GMradUojUYqCg=
github.com/aws/aws-sdk-go-v2/service/eks v1.66.1/go.mod h1:Qj90srO2HigGG5x8Ro6RxixxqiSjZjF91WTEVpnsjAs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4 h1:CXV68E2dNqhuynZJPB80bhPQwAKqBWVer887figW6Jc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.4/go.mod h1:/xFi9KtvBXP97ppCz1TAEvU1Uf66qvid89rbem3wCzQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.17 h1:t0E6FzREdtCsiLIoLCWsYliNsRBgyGD/MCK571qk4MI=
@ -65,14 +75,19 @@ github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxER
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
@ -89,6 +104,8 @@ 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.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@ -99,8 +116,14 @@ github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@ -173,6 +196,20 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
@ -199,6 +236,8 @@ golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKl
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -226,8 +265,18 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/api v0.241.0 h1:QKwqWQlkc6O895LchPEDUSYr22Xp3NCxpQRiWTB6avE=
google.golang.org/api v0.241.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2 h1:vPV0tzlsK6EzEDHNNH5sa7Hs9bd7iXR7B1tSiPepkV0=
google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=
google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@ -19,6 +19,7 @@ package auth
import (
"net/http"
"net/url"
"time"
"sigs.k8s.io/controller-runtime/pkg/client"
@ -31,25 +32,35 @@ type Option func(*Options)
// Options contains options for configuring the behavior of the provider methods.
// Not all providers/methods support all options.
type Options struct {
Client client.Client
Cache *cache.TokenCache
ServiceAccount *client.ObjectKey
InvolvedObject cache.InvolvedObject
Scopes []string
ArtifactRepository string
STSRegion string
STSEndpoint string
ProxyURL *url.URL
AllowShellOut bool
Client client.Client
Cache *cache.TokenCache
ServiceAccount *client.ObjectKey
InvolvedObject cache.InvolvedObject
Audiences []string
Scopes []string
STSRegion string
STSEndpoint string
ProxyURL *url.URL
CAData string
ClusterResource string
ClusterAddress string
AllowShellOut bool
}
// WithClient sets the controller-runtime client for the provider.
func WithClient(client client.Client) Option {
return func(o *Options) {
o.Client = client
}
}
// WithServiceAccount sets the ServiceAccount reference for the token
// and a controller-runtime client to fetch the ServiceAccount and
// create an OIDC token for it in the Kubernetes API.
func WithServiceAccount(saRef client.ObjectKey, client client.Client) Option {
func WithServiceAccount(saRef client.ObjectKey, c client.Client) Option {
return func(o *Options) {
o.ServiceAccount = &saRef
o.Client = client
o.Client = c
}
}
@ -61,6 +72,13 @@ func WithCache(cache cache.TokenCache, involvedObject cache.InvolvedObject) Opti
}
}
// WithAudiences sets the audiences for the Kubernetes ServiceAccount token.
func WithAudiences(audiences ...string) Option {
return func(o *Options) {
o.Audiences = audiences
}
}
// WithScopes sets the scopes for the token.
func WithScopes(scopes ...string) Option {
return func(o *Options) {
@ -68,16 +86,6 @@ func WithScopes(scopes ...string) Option {
}
}
// WithArtifactRepository sets the artifact repository the token will be used for.
// In most cases artifact registry credentials require an additional
// token exchange at the end. This option allows the library to implement
// this exchange and cache the final token.
func WithArtifactRepository(artifactRepository string) Option {
return func(o *Options) {
o.ArtifactRepository = artifactRepository
}
}
// WithSTSRegion sets the region for the STS service (some cloud providers
// require a region, e.g. AWS).
func WithSTSRegion(stsRegion string) Option {
@ -100,7 +108,38 @@ func WithProxyURL(proxyURL url.URL) Option {
}
}
// WithAllowShellOut allows the provider to shell out to binaries.
// WithCAData sets the CA data for credentials that require a CA,
// e.g. for Kubernetes REST config.
func WithCAData(caData string) Option {
return func(o *Options) {
o.CAData = caData
}
}
// WithClusterResource sets the cluster resource for creating a REST config.
// Must be the fully qualified name of the cluster resource in the cloud
// provider API.
func WithClusterResource(clusterResource string) Option {
return func(o *Options) {
o.ClusterResource = clusterResource
}
}
// WithClusterAddress sets the cluster address for creating a REST config.
// This address is used to select the correct cluster endpoint and CA data
// when the provider has a list of endpoints to choose from, or to simply
// validate the address against the cluster resource when the provider
// returns a single endpoint. This is optional, providers returning a list
// of endpoints will select the first one if no address is provided.
func WithClusterAddress(clusterAddress string) Option {
return func(o *Options) {
o.ClusterAddress = clusterAddress
}
}
// WithAllowShellOut allows the provider to shell out to binary tools
// for acquiring controller tokens. MUST be used only by the Flux CLI,
// i.e. in the github.com/fluxcd/flux2 Git repository.
func WithAllowShellOut() Option {
return func(o *Options) {
o.AllowShellOut = true
@ -114,14 +153,17 @@ func (o *Options) Apply(opts ...Option) {
}
}
// GetHTTPClient returns a *http.Client with the configured proxy URL
// or nil if no proxy URL is set.
// GetHTTPClient returns a *http.Client with appropriate timeouts and proxy settings.
// The client includes a 10-second timeout to prevent indefinite hangs during token acquisition.
func (o *Options) GetHTTPClient() *http.Client {
if o.ProxyURL == nil {
return nil
transport := http.DefaultTransport.(*http.Transport).Clone()
if o.ProxyURL != nil {
transport.Proxy = http.ProxyURL(o.ProxyURL)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyURL(o.ProxyURL)
return &http.Client{Transport: transport}
return &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
}

68
auth/options_test.go Normal file
View File

@ -0,0 +1,68 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth
import (
"net/url"
"testing"
"time"
)
func TestOptions_GetHTTPClient(t *testing.T) {
tests := []struct {
name string
options Options
expectProxy bool
}{
{
name: "no proxy configured",
options: Options{},
expectProxy: false,
},
{
name: "proxy configured",
options: Options{
ProxyURL: &url.URL{Scheme: "http", Host: "proxy.example.com:8080"},
},
expectProxy: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := tt.options.GetHTTPClient()
if client == nil {
t.Error("GetHTTPClient() returned nil, expected non-nil client")
}
expectedTimeout := 10 * time.Second
if client.Timeout != expectedTimeout {
t.Errorf("GetHTTPClient() timeout = %v, want %v", client.Timeout, expectedTimeout)
}
if client.Transport == nil {
t.Error("GetHTTPClient() transport is nil")
return
}
if tt.expectProxy && tt.options.ProxyURL == nil {
t.Error("Expected proxy but ProxyURL is nil")
}
})
}
}

View File

@ -22,10 +22,10 @@ import (
corev1 "k8s.io/api/core/v1"
)
// Provider contains the logic to retrieve an access token for a cloud
// provider from a ServiceAccount (OIDC/JWT) token.
// Provider contains the logic to retrieve security credentials
// for accessing resources in a cloud provider.
type Provider interface {
// GetName returns the name of the provider.
// GetName returns the name of the cloud provider.
GetName() string
// NewControllerToken returns a token that can be used to authenticate
@ -34,11 +34,11 @@ type Provider interface {
// environment variables, local metadata services, etc.
NewControllerToken(ctx context.Context, opts ...Option) (Token, error)
// GetAudience returns the audience the OIDC tokens issued representing
// ServiceAccounts should have. This is usually a string that represents
// GetAudiences returns the audiences the OIDC tokens issued representing
// ServiceAccounts should have. These are usually strings that represent
// the cloud provider's STS service, or some entity in the provider for
// which the OIDC tokens are targeted to.
GetAudience(ctx context.Context, serviceAccount corev1.ServiceAccount) (string, error)
GetAudiences(ctx context.Context, serviceAccount corev1.ServiceAccount) ([]string, error)
// GetIdentity takes a ServiceAccount and returns the identity which the
// ServiceAccount wants to impersonate, by looking at annotations.
@ -51,16 +51,4 @@ type Provider interface {
// token through the provider's STS service.
NewTokenForServiceAccount(ctx context.Context, oidcToken string,
serviceAccount corev1.ServiceAccount, opts ...Option) (Token, error)
// ParseArtifactRepository parses the artifact repository to verify if it
// is a valid repository for the provider. As a result, it returns the
// input required for the provider to issue the registry credentials. This
// input is also included as part of the cache key for the issued credentials.
ParseArtifactRepository(artifactRepository string) (string, error)
// NewArtifactRegistryCredentials takes the registry input extracted by
// ParseArtifactRepository() and an access token and returns credentials
// that can be used to authenticate with the registry.
NewArtifactRegistryCredentials(ctx context.Context, registryInput string,
accessToken Token, opts ...Option) (*ArtifactRegistryCredentials, error)
}

209
auth/provider_test.go Normal file
View File

@ -0,0 +1,209 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth_test
import (
"context"
"errors"
"net/http"
"net/url"
"testing"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/golang-jwt/jwt/v5"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
"github.com/fluxcd/pkg/auth"
)
type mockProvider struct {
t *testing.T
returnName string
returnIdentity string
returnIdentityErr string
returnRegistryErr string
returnRegistryInput string
returnRESTConfig *auth.RESTConfig
returnRESTConfigOptsErr string
returnControllerToken auth.Token
returnAccessToken auth.Token
returnRegistryOptions []auth.Option
returnRegistryToken *auth.ArtifactRegistryCredentials
paramAudiences []string
paramServiceAccount corev1.ServiceAccount
paramOIDCTokenClient *http.Client
paramArtifactRepository string
paramCluster string
paramClusterAddress string
paramAccessToken auth.Token
paramAccessTokens []auth.Token
paramAllowShellOut bool
// For multi-token flow (RESTConfig)
paramFirstScopes []string
paramSecondScopes []string
expectFirstScopes bool
expectSecondScopes bool
}
func (m *mockProvider) GetName() string {
return m.returnName
}
func (m *mockProvider) NewControllerToken(ctx context.Context, opts ...auth.Option) (auth.Token, error) {
m.checkOptions(opts...)
return m.returnControllerToken, nil
}
func (m *mockProvider) GetAudiences(ctx context.Context, serviceAccount corev1.ServiceAccount) ([]string, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(serviceAccount).To(Equal(m.paramServiceAccount))
return []string{"mock-audience"}, nil
}
func (m *mockProvider) GetIdentity(serviceAccount corev1.ServiceAccount) (string, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(serviceAccount).To(Equal(m.paramServiceAccount))
if m.returnIdentityErr != "" {
return "", errors.New(m.returnIdentityErr)
}
return m.returnIdentity, nil
}
func (m *mockProvider) NewTokenForServiceAccount(ctx context.Context, oidcToken string,
serviceAccount corev1.ServiceAccount, opts ...auth.Option) (auth.Token, error) {
m.t.Helper()
g := NewWithT(m.t)
// Verify the OIDC token.
token, _, err := jwt.NewParser().ParseUnverified(oidcToken, jwt.MapClaims{})
g.Expect(err).NotTo(HaveOccurred())
iss, err := token.Claims.GetIssuer()
g.Expect(err).NotTo(HaveOccurred())
ctx = oidc.ClientContext(ctx, m.paramOIDCTokenClient)
jwks := oidc.NewRemoteKeySet(ctx, iss+"openid/v1/jwks")
clientIDs := m.paramAudiences
if len(clientIDs) == 0 {
clientIDs = []string{"mock-audience"}
}
for _, aud := range clientIDs {
_, err = oidc.NewVerifier(iss, jwks, &oidc.Config{
ClientID: aud,
SupportedSigningAlgs: []string{token.Method.Alg()},
}).Verify(ctx, oidcToken)
g.Expect(err).NotTo(HaveOccurred())
}
g.Expect(serviceAccount).To(Equal(m.paramServiceAccount))
m.checkOptions(opts...)
return m.returnAccessToken, nil
}
func (m *mockProvider) ParseArtifactRepository(artifactRepository string) (string, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(artifactRepository).To(Equal(m.paramArtifactRepository))
if m.returnRegistryErr != "" {
return "", errors.New(m.returnRegistryErr)
}
return m.returnRegistryInput, nil
}
func (m *mockProvider) GetAccessTokenOptionsForArtifactRepository(artifactRepository string) ([]auth.Option, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(artifactRepository).To(Equal(m.paramArtifactRepository))
return m.returnRegistryOptions, nil
}
func (m *mockProvider) NewArtifactRegistryCredentials(ctx context.Context, registryInput string,
accessToken auth.Token, opts ...auth.Option) (*auth.ArtifactRegistryCredentials, error) {
m.t.Helper()
g := NewWithT(m.t)
g.Expect(registryInput).To(Equal(m.paramArtifactRepository))
g.Expect(accessToken).To(Equal(m.paramAccessToken))
m.checkOptions(opts...)
return m.returnRegistryToken, nil
}
func (m *mockProvider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.Option, error) {
m.t.Helper()
g := NewWithT(m.t)
var o auth.Options
o.Apply(opts...)
g.Expect(o.ClusterResource).To(Equal(m.paramCluster))
if m.returnRESTConfigOptsErr != "" {
return nil, errors.New(m.returnRESTConfigOptsErr)
}
return [][]auth.Option{{auth.WithScopes("first-token")}, {auth.WithScopes("second-token")}}, nil
}
func (m *mockProvider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
opts ...auth.Option) (*auth.RESTConfig, error) {
m.t.Helper()
g := NewWithT(m.t)
var o auth.Options
o.Apply(opts...)
g.Expect(o.ClusterResource).To(Equal(m.paramCluster))
g.Expect(o.ClusterAddress).To(Equal(m.paramClusterAddress))
g.Expect(accessTokens).To(Equal(m.paramAccessTokens))
m.checkOptions(opts...)
return m.returnRESTConfig, nil
}
func (m *mockProvider) checkOptions(opts ...auth.Option) {
m.t.Helper()
g := NewWithT(m.t)
var o auth.Options
o.Apply(opts...)
// Determine which scopes to expect based on multi-token flow
expectedScopes := []string{"scope1", "scope2"}
if m.paramFirstScopes != nil && m.paramSecondScopes != nil {
switch {
case m.expectFirstScopes:
expectedScopes = m.paramFirstScopes
m.expectFirstScopes = false
case m.expectSecondScopes:
expectedScopes = m.paramSecondScopes
m.expectSecondScopes = false
default:
expectedScopes = []string{"scope1", "scope2"}
}
}
expectedAudiences := []string{"audience1", "audience2"}
if m.paramAudiences != nil {
expectedAudiences = m.paramAudiences
}
g.Expect(o.Audiences).To(ConsistOf(expectedAudiences))
g.Expect(o.Scopes).To(Equal(expectedScopes))
g.Expect(o.STSRegion).To(Equal("us-east-1"))
g.Expect(o.STSEndpoint).To(Equal("https://sts.some-cloud.io"))
g.Expect(o.ProxyURL).To(Equal(&url.URL{Scheme: "http", Host: "proxy.io:8080"}))
g.Expect(o.CAData).To(Equal("ca-data"))
g.Expect(o.AllowShellOut).To(Equal(m.paramAllowShellOut))
}

View File

@ -17,22 +17,49 @@ limitations under the License.
package auth
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
corev1 "k8s.io/api/core/v1"
"github.com/fluxcd/pkg/cache"
)
// ArtifactRegistryCredentialsProvider is an interface that defines methods
// for retrieving credentials for artifact registries from cloud providers.
type ArtifactRegistryCredentialsProvider interface {
Provider
// GetAccessTokenOptionsForArtifactRepository returns the options that must be
// passed to the provider to retrieve access tokens for an artifact repository.
GetAccessTokenOptionsForArtifactRepository(artifactRepository string) ([]Option, error)
// ParseArtifactRepository parses the artifact repository to verify
// it's a valid repository for the provider. As a result, it returns
// the input required for the provider to issue registry credentials.
// This input is included in the cache key for the issued credentials.
ParseArtifactRepository(artifactRepository string) (string, error)
// NewArtifactRegistryCredentials takes the registry input extracted by
// ParseArtifactRepository() and an access token and returns credentials
// that can be used to authenticate with the registry.
NewArtifactRegistryCredentials(ctx context.Context, registryInput string,
accessToken Token, opts ...Option) (*ArtifactRegistryCredentials, error)
}
// ArtifactRegistryCredentials is a particular type implementing the Token interface
// for credentials that can be used to authenticate against an artifact registry
// from a cloud provider. This type is compatible with all the cloud providers
// and should be returned when the artifact repository is configured in the options.
// from a cloud provider.
type ArtifactRegistryCredentials struct {
authn.Authenticator
ExpiresAt time.Time
}
// GetDuration implements Token.
func (a *ArtifactRegistryCredentials) GetDuration() time.Duration {
return time.Until(a.ExpiresAt)
}
@ -43,9 +70,84 @@ func GetRegistryFromArtifactRepository(artifactRepository string) (string, error
if strings.ContainsRune(registry, '/') {
ref, err := name.ParseReference(registry)
if err != nil {
return "", err
return "", fmt.Errorf("failed to parse artifact repository '%s': %w",
artifactRepository, err)
}
return ref.Context().RegistryStr(), nil
}
return registry, nil
}
// GetArtifactRegistryCredentials retrieves the registry credentials for the
// specified artifact repository and provider.
func GetArtifactRegistryCredentials(ctx context.Context, provider ArtifactRegistryCredentialsProvider,
artifactRepository string, opts ...Option) (*ArtifactRegistryCredentials, error) {
registryInput, err := provider.ParseArtifactRepository(artifactRepository)
if err != nil {
return nil, err
}
// First, we need an access token. This cannot be retrieved inside the
// cache lock, otherwise we reach a deadlock.
accessTokenOpts, err := provider.GetAccessTokenOptionsForArtifactRepository(artifactRepository)
if err != nil {
return nil, err
}
accessTokenOpts = append(opts, accessTokenOpts...)
accessToken, err := GetAccessToken(ctx, provider, accessTokenOpts...)
if err != nil {
return nil, fmt.Errorf("failed to get access token for artifact registry: %w", err)
}
// Prepare a function to create new credentials.
newArtifactRegistryCredentials := func() (*ArtifactRegistryCredentials, error) {
creds, err := provider.NewArtifactRegistryCredentials(ctx, registryInput, accessToken, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create artifact registry credentials: %w", err)
}
return creds, nil
}
var o Options
o.Apply(opts...)
// Bail out early if cache is disabled.
if o.Cache == nil {
return newArtifactRegistryCredentials()
}
// Build cache key.
var serviceAccount *corev1.ServiceAccount
var providerIdentity string
var audiences []string
if o.ServiceAccount != nil {
var err error
serviceAccount, audiences, providerIdentity, err =
getServiceAccountAndProviderInfo(ctx, provider, o.Client, *o.ServiceAccount, opts...)
if err != nil {
return nil, err
}
}
accessTokenCacheKey := buildAccessTokenCacheKey(provider, audiences,
providerIdentity, serviceAccount, accessTokenOpts...)
cacheKey := buildCacheKey(
fmt.Sprintf("accessTokenCacheKey=%s", accessTokenCacheKey),
fmt.Sprintf("artifactRepositoryCacheKey=%s", registryInput))
// Build involved object details.
kind := o.InvolvedObject.Kind
name := o.InvolvedObject.Name
namespace := o.InvolvedObject.Namespace
operation := o.InvolvedObject.Operation
// Get credentials from cache.
creds, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) {
return newArtifactRegistryCredentials()
}, cache.WithInvolvedObject(kind, name, namespace, operation))
if err != nil {
return nil, err
}
return creds.(*ArtifactRegistryCredentials), nil
}

View File

@ -17,11 +17,19 @@ limitations under the License.
package auth_test
import (
"context"
"net/url"
"testing"
"time"
"github.com/google/go-containerregistry/pkg/authn"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/cache"
)
func TestGetRegistryFromArtifactRepository(t *testing.T) {
@ -86,3 +94,190 @@ func TestGetRegistryFromArtifactRepository(t *testing.T) {
})
}
}
func TestGetArtifactRegistryCredentials(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
kubeClient, oidcClient := newTestEnv(t, ctx)
// Create a default service account.
defaultServiceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Namespace: "default",
},
}
err := kubeClient.Create(ctx, defaultServiceAccount)
g.Expect(err).NotTo(HaveOccurred())
saRef := client.ObjectKey{
Name: defaultServiceAccount.Name,
Namespace: defaultServiceAccount.Namespace,
}
now := time.Now()
for _, tt := range []struct {
name string
provider *mockProvider
artifactRepository string
opts []auth.Option
disableObjectLevel bool
expectedCreds *auth.ArtifactRegistryCredentials
expectedErr string
}{
{
name: "registry token from controller access token",
provider: &mockProvider{
returnRegistryInput: "some-registry.io/some/artifact",
returnControllerToken: &mockToken{token: "mock-default-token"},
returnRegistryToken: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
paramAccessToken: &mockToken{token: "mock-default-token"},
paramArtifactRepository: "some-registry.io/some/artifact",
},
artifactRepository: "some-registry.io/some/artifact",
opts: []auth.Option{
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
expectedCreds: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
},
{
name: "registry token from access token from service account",
provider: &mockProvider{
returnName: "mock-provider",
returnRegistryInput: "some-registry.io/some/artifact",
returnAccessToken: &mockToken{token: "mock-access-token"},
returnRegistryToken: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
paramAudiences: []string{"audience1", "audience2"},
paramServiceAccount: *defaultServiceAccount,
paramOIDCTokenClient: oidcClient,
paramArtifactRepository: "some-registry.io/some/artifact",
paramAccessToken: &mockToken{token: "mock-access-token"},
},
artifactRepository: "some-registry.io/some/artifact",
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
expectedCreds: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "mock-registry-token"}),
},
},
{
name: "all the options are taken into account in the cache key",
provider: &mockProvider{
returnName: "mock-provider",
returnIdentity: "mock-identity",
returnRegistryInput: "artifact-cache-key",
paramServiceAccount: *defaultServiceAccount,
paramArtifactRepository: "some-registry.io/some/artifact",
},
artifactRepository: "some-registry.io/some/artifact",
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(2)
g.Expect(err).NotTo(HaveOccurred())
const accessTokenKey = "db625bd5a96dc48fcc100659c6db98857d1e0ceec930bbded0fdece14af4307c"
var token auth.Token = &mockToken{token: "cached-token"}
cachedToken, ok, err := tokenCache.GetOrSet(ctx, accessTokenKey, func(ctx context.Context) (cache.Token, error) {
return token, nil
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ok).To(BeFalse())
g.Expect(cachedToken).To(Equal(token))
const artifactRegistryCredentialsKey = "61fe71ebbf306060d67acbdc2389d5fd816bee40e7685afe2fdc18b7d3bde1d6"
token = &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "cached-registry-token"}),
ExpiresAt: now.Add(time.Hour),
}
cachedToken, ok, err = tokenCache.GetOrSet(ctx, artifactRegistryCredentialsKey, func(ctx context.Context) (cache.Token, error) {
return token, nil
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ok).To(BeFalse())
g.Expect(cachedToken).To(Equal(token))
o.Cache = tokenCache
},
},
expectedCreds: &auth.ArtifactRegistryCredentials{
Authenticator: authn.FromConfig(authn.AuthConfig{Username: "cached-registry-token"}),
ExpiresAt: now.Add(time.Hour),
},
},
{
name: "error parsing artifact repository",
provider: &mockProvider{
paramArtifactRepository: "some-registry.io/some/artifact",
returnRegistryErr: "mock error",
},
artifactRepository: "some-registry.io/some/artifact",
expectedErr: "mock error",
},
{
name: "disable object level workload identity",
provider: &mockProvider{
paramServiceAccount: *defaultServiceAccount,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
disableObjectLevel: true,
expectedErr: "ObjectLevelWorkloadIdentity feature gate is not enabled",
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
tt.provider.t = t
if !tt.disableObjectLevel {
t.Setenv(auth.EnvVarEnableObjectLevelWorkloadIdentity, "true")
}
creds, err := auth.GetArtifactRegistryCredentials(ctx, tt.provider, tt.artifactRepository, tt.opts...)
if tt.expectedErr != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr))
g.Expect(creds).To(BeNil())
} else {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(creds).To(Equal(tt.expectedCreds))
}
})
}
}

170
auth/restconfig.go Normal file
View File

@ -0,0 +1,170 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"github.com/fluxcd/pkg/cache"
)
// RESTConfigProvider is an interface that defines methods for retrieving
// REST configurations for Kubernetes clusters from cloud providers.
type RESTConfigProvider interface {
Provider
// GetAccessTokenOptionsForCluster returns the options that must be
// passed to the provider to retrieve access tokens for a cluster.
// More than one access token may be required depending on the
// provider, with different options (e.g. scope). Hence the return
// type is a slice of []Option.
GetAccessTokenOptionsForCluster(opts ...Option) ([][]Option, error)
// NewRESTConfig returns a new RESTConfig that can be used to authenticate
// with the Kubernetes API server. The access tokens are used for looking
// up connection details like the API server address and CA certificate
// data, and for accessing the cluster API server itself via the IAM
// system of the cloud provider. If it's just a single token or multiple,
// it depends on the provider.
NewRESTConfig(ctx context.Context, accessTokens []Token, opts ...Option) (*RESTConfig, error)
}
// RESTConfig is a particular type implementing the Token interface
// for Kubernetes REST configurations.
type RESTConfig struct {
Host string
BearerToken string
CAData []byte
ExpiresAt time.Time
}
// GetDuration implements Token.
func (r *RESTConfig) GetDuration() time.Duration {
return time.Until(r.ExpiresAt)
}
// ParseClusterAddress parses the given cluster address and returns
// the canonical form https://<lowercase(host)>:<port>.
func ParseClusterAddress(address string) (string, error) {
if address == "" {
return "", errors.New("empty address")
}
if !strings.HasPrefix(address, "http") {
address = fmt.Sprintf("https://%s", address)
}
u, err := url.Parse(address)
if err != nil {
return "", fmt.Errorf("failed to parse Kubernetes API server address '%s': %w", address, err)
}
if u.Scheme != "https" {
return "", fmt.Errorf("the Kubernetes API server address '%s' must use https scheme", address)
}
host := u.Host
if u.Port() == "" {
host += ":443"
}
return fmt.Sprintf("https://%s", strings.ToLower(host)), nil
}
// GetRESTConfig retrieves the authentication and connection
// details to a remote Kubernetes cluster for the given provider,
// cluster resource name and options.
func GetRESTConfig(ctx context.Context, provider RESTConfigProvider, opts ...Option) (*RESTConfig, error) {
var o Options
o.Apply(opts...)
// First, we need the access tokens. They cannot be retrieved inside the
// cache lock, otherwise we reach a deadlock.
accessTokenOpts, err := provider.GetAccessTokenOptionsForCluster(opts...)
if err != nil {
return nil, err
}
accessTokens := make([]Token, 0, len(accessTokenOpts))
for i := range accessTokenOpts {
accessTokenOpts[i] = append(slices.Clone(opts), accessTokenOpts[i]...)
token, err := GetAccessToken(ctx, provider, accessTokenOpts[i]...)
if err != nil {
return nil, fmt.Errorf("failed to get access token for cluster: %w", err)
}
accessTokens = append(accessTokens, token)
}
// Prepare a function to create the restconfig if needed.
newRESTConfig := func() (*RESTConfig, error) {
conf, err := provider.NewRESTConfig(ctx, accessTokens, opts...)
if err != nil {
return nil, err
}
return conf, nil
}
// Bail out early if cache is disabled.
if o.Cache == nil {
return newRESTConfig()
}
// Build cache key.
var serviceAccount *corev1.ServiceAccount
var providerIdentity string
var audiences []string
if o.ServiceAccount != nil {
var err error
serviceAccount, audiences, providerIdentity, err =
getServiceAccountAndProviderInfo(ctx, provider, o.Client, *o.ServiceAccount, opts...)
if err != nil {
return nil, err
}
}
var cacheKeyParts []string
for i, atOpts := range accessTokenOpts {
key := buildAccessTokenCacheKey(provider, audiences, providerIdentity, serviceAccount, atOpts...)
cacheKeyParts = append(cacheKeyParts, fmt.Sprintf("accessToken%dCacheKey=%s", i, key))
}
if c := o.ClusterResource; c != "" {
cacheKeyParts = append(cacheKeyParts, fmt.Sprintf("cluster=%s", c))
}
if a := o.ClusterAddress; a != "" {
cacheKeyParts = append(cacheKeyParts, fmt.Sprintf("address=%s", a))
}
cacheKey := buildCacheKey(cacheKeyParts...)
// Build involved object details.
cacheOpts := []cache.Options{cache.WithInvolvedObject(
o.InvolvedObject.Kind,
o.InvolvedObject.Name,
o.InvolvedObject.Namespace,
o.InvolvedObject.Operation)}
// Get restconfig from cache.
token, _, err := o.Cache.GetOrSet(ctx, cacheKey, func(ctx context.Context) (cache.Token, error) {
return newRESTConfig()
}, cacheOpts...)
if err != nil {
return nil, err
}
return token.(*RESTConfig), nil
}

322
auth/restconfig_test.go Normal file
View File

@ -0,0 +1,322 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth_test
import (
"context"
"net/url"
"strings"
"testing"
"time"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/cache"
)
func TestParseClusterAddress(t *testing.T) {
tests := []struct {
address string
expected string
err string
}{
{
address: "https://example.com:443",
expected: "https://example.com:443",
},
{
address: "example.com",
expected: "https://example.com:443",
},
{
address: "EXAMPLE.COM:8080",
expected: "https://example.com:8080",
},
{
address: "34.44.60.80",
expected: "https://34.44.60.80:443",
},
{
address: "",
err: "empty address",
},
{
address: "------------\t",
err: "failed to parse Kubernetes API server address 'https://------------ ':",
},
{
address: "http://example.com:443",
err: "Kubernetes API server address 'http://example.com:443' must use https scheme",
},
}
for _, tt := range tests {
t.Run(strings.ReplaceAll(tt.address, "/", ""), func(t *testing.T) {
g := NewWithT(t)
address, err := auth.ParseClusterAddress(tt.address)
if tt.err != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.err))
g.Expect(address).To(BeEmpty())
} else {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(address).To(Equal(tt.expected))
}
})
}
}
func TestGetRESTConfig(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
kubeClient, oidcClient := newTestEnv(t, ctx)
// Create a default service account.
defaultServiceAccount := &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "default",
Namespace: "default",
},
}
err := kubeClient.Create(ctx, defaultServiceAccount)
g.Expect(err).NotTo(HaveOccurred())
saRef := client.ObjectKey{
Name: defaultServiceAccount.Name,
Namespace: defaultServiceAccount.Namespace,
}
now := time.Now()
for _, tt := range []struct {
name string
provider *mockProvider
cluster string
opts []auth.Option
disableObjectLevel bool
expectedCreds *auth.RESTConfig
expectedErr string
}{
{
name: "restconfig from controller access token",
provider: &mockProvider{
returnControllerToken: &mockToken{token: "mock-default-token"},
returnRESTConfig: &auth.RESTConfig{
Host: "https://cluster/resource/name",
BearerToken: "mock-bearer-token",
CAData: []byte("ca-data"),
},
paramCluster: "cluster/resource/name",
paramFirstScopes: []string{"first-token"},
paramSecondScopes: []string{"second-token"},
expectFirstScopes: true,
expectSecondScopes: true,
paramAccessTokens: []auth.Token{
&mockToken{token: "mock-default-token"},
&mockToken{token: "mock-default-token"},
},
},
cluster: "cluster/resource/name",
opts: []auth.Option{
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
expectedCreds: &auth.RESTConfig{
Host: "https://cluster/resource/name",
BearerToken: "mock-bearer-token",
CAData: []byte("ca-data"),
},
},
{
name: "restconfig from access token from service account",
provider: &mockProvider{
returnName: "mock-provider",
returnAccessToken: &mockToken{token: "mock-access-token"},
returnRESTConfig: &auth.RESTConfig{
Host: "https://cluster/resource/name",
BearerToken: "mock-bearer-token",
CAData: []byte("ca-data"),
},
paramAudiences: []string{"audience1", "audience2"},
paramServiceAccount: *defaultServiceAccount,
paramOIDCTokenClient: oidcClient,
paramCluster: "cluster/resource/name",
paramFirstScopes: []string{"first-token"},
paramSecondScopes: []string{"second-token"},
expectFirstScopes: true,
expectSecondScopes: true,
paramAccessTokens: []auth.Token{
&mockToken{token: "mock-access-token"},
&mockToken{token: "mock-access-token"},
},
},
cluster: "cluster/resource/name",
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
expectedCreds: &auth.RESTConfig{
Host: "https://cluster/resource/name",
BearerToken: "mock-bearer-token",
CAData: []byte("ca-data"),
},
},
{
name: "all the options are taken into account in the cache key",
provider: &mockProvider{
returnName: "mock-provider",
returnIdentity: "mock-identity",
paramAudiences: []string{"audience1", "audience2"},
paramServiceAccount: *defaultServiceAccount,
paramCluster: "cluster/resource/name",
paramClusterAddress: "https://cluster/resource/name",
paramFirstScopes: []string{"first-token"},
paramSecondScopes: []string{"second-token"},
expectFirstScopes: true,
expectSecondScopes: true,
},
cluster: "cluster/resource/name",
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
auth.WithClusterResource("cluster/resource/name"),
auth.WithClusterAddress("https://cluster/resource/name"),
func(o *auth.Options) {
tokenCache, err := cache.NewTokenCache(3)
g.Expect(err).NotTo(HaveOccurred())
accessTokenKey := "500a3116f5d1c492d7a5ea97cdf9a7f869815346c79f01c7368703c241ebb5eb"
var token auth.Token = &mockToken{token: "cached-token"}
cachedToken, ok, err := tokenCache.GetOrSet(ctx, accessTokenKey, func(ctx context.Context) (cache.Token, error) {
return token, nil
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ok).To(BeFalse())
g.Expect(cachedToken).To(Equal(token))
accessTokenKey = "0b1167fc851943c6153d40e149cd2970aac121aaf03b1fcad158672974f58827"
token = &mockToken{token: "cached-token"}
cachedToken, ok, err = tokenCache.GetOrSet(ctx, accessTokenKey, func(ctx context.Context) (cache.Token, error) {
return token, nil
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ok).To(BeFalse())
g.Expect(cachedToken).To(Equal(token))
const restConfigKey = "a1937b7b1df13ac8ad784db686088c4cd5b4c4877318d07d3fa19ab8caf9d7c2"
token = &auth.RESTConfig{
Host: "https://cluster/resource/name",
BearerToken: "mock-bearer-token",
CAData: []byte("ca-data"),
ExpiresAt: now.Add(time.Hour),
}
cachedToken, ok, err = tokenCache.GetOrSet(ctx, restConfigKey, func(ctx context.Context) (cache.Token, error) {
return token, nil
})
g.Expect(err).NotTo(HaveOccurred())
g.Expect(ok).To(BeFalse())
g.Expect(cachedToken).To(Equal(token))
o.Cache = tokenCache
},
},
expectedCreds: &auth.RESTConfig{
Host: "https://cluster/resource/name",
BearerToken: "mock-bearer-token",
CAData: []byte("ca-data"),
ExpiresAt: now.Add(time.Hour),
},
},
{
name: "error getting access token options for cluster",
provider: &mockProvider{
paramCluster: "cluster/resource/name",
returnRESTConfigOptsErr: "mock error",
},
cluster: "cluster/resource/name",
expectedErr: "mock error",
},
{
name: "disable object level workload identity",
provider: &mockProvider{
paramServiceAccount: *defaultServiceAccount,
paramFirstScopes: []string{"first-token"},
paramSecondScopes: []string{"second-token"},
expectFirstScopes: true,
expectSecondScopes: true,
},
opts: []auth.Option{
auth.WithServiceAccount(saRef, kubeClient),
auth.WithAudiences("audience1", "audience2"),
auth.WithScopes("scope1", "scope2"),
auth.WithSTSRegion("us-east-1"),
auth.WithSTSEndpoint("https://sts.some-cloud.io"),
auth.WithProxyURL(url.URL{Scheme: "http", Host: "proxy.io:8080"}),
auth.WithCAData("ca-data"),
},
disableObjectLevel: true,
expectedErr: "ObjectLevelWorkloadIdentity feature gate is not enabled",
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
tt.provider.t = t
if !tt.disableObjectLevel {
t.Setenv(auth.EnvVarEnableObjectLevelWorkloadIdentity, "true")
}
if tt.cluster != "" {
tt.opts = append(tt.opts, auth.WithClusterResource(tt.cluster))
}
creds, err := auth.GetRESTConfig(ctx, tt.provider, tt.opts...)
if tt.expectedErr != "" {
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring(tt.expectedErr))
g.Expect(creds).To(BeNil())
} else {
g.Expect(err).NotTo(HaveOccurred())
g.Expect(creds).To(Equal(tt.expectedCreds))
}
})
}
}

76
auth/suite_test.go Normal file
View File

@ -0,0 +1,76 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth_test
import (
"context"
"crypto/tls"
"crypto/x509"
"net/http"
"testing"
. "github.com/onsi/gomega"
rbacv1 "k8s.io/api/rbac/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
)
func newTestEnv(t *testing.T, ctx context.Context) (client.Client, *http.Client) {
t.Helper()
g := NewWithT(t)
// Create test env.
testEnv := &envtest.Environment{}
conf, err := testEnv.Start()
g.Expect(err).NotTo(HaveOccurred())
t.Cleanup(func() { testEnv.Stop() })
kubeClient, err := client.New(conf, client.Options{})
g.Expect(err).NotTo(HaveOccurred())
// Create HTTP client for OIDC verification.
clusterCAPool := x509.NewCertPool()
ok := clusterCAPool.AppendCertsFromPEM(conf.TLSClientConfig.CAData)
g.Expect(ok).To(BeTrue())
oidcClient := &http.Client{}
oidcClient.Transport = http.DefaultTransport.(*http.Transport).Clone()
oidcClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{
RootCAs: clusterCAPool,
}
// Grant anonymous access to service account issuer discovery.
err = kubeClient.Create(ctx, &rbacv1.ClusterRoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "anonymous-service-account-issuer-discovery",
},
Subjects: []rbacv1.Subject{
{
APIGroup: "rbac.authorization.k8s.io",
Kind: "User",
Name: "system:anonymous",
},
},
RoleRef: rbacv1.RoleRef{
APIGroup: "rbac.authorization.k8s.io",
Kind: "ClusterRole",
Name: "system:service-account-issuer-discovery",
},
})
g.Expect(err).NotTo(HaveOccurred())
return kubeClient, oidcClient
}

29
auth/token_test.go Normal file
View File

@ -0,0 +1,29 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package auth_test
import (
"time"
)
type mockToken struct {
token string
}
func (m *mockToken) GetDuration() time.Duration {
return time.Hour
}

View File

@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
// authutils contains small utility functions without much logic
// Package utils contains small utility functions without much logic
// wrapping the major APIs of the core auth package for ease of use
// in the controllers. These functions also import the provider
// packages to wrap switch-case choice of provider implementations.
// Because of that, these functions cannot be placed in the core
// package as they would cause a cyclic dependency given that the
// provider packages also import the core package.
package authutils
package utils

View File

@ -14,10 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package authutils
package utils
import (
"context"
"fmt"
"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/auth/azure"
@ -37,7 +38,7 @@ func GetGitCredentials(ctx context.Context, providerName string, opts ...auth.Op
switch providerName {
case azure.ProviderName:
opts = append(opts, auth.WithScopes(azure.ScopeDevOps))
token, err := auth.GetToken(ctx, azure.Provider{}, opts...)
token, err := auth.GetAccessToken(ctx, azure.Provider{}, opts...)
if err != nil {
return nil, err
}
@ -45,6 +46,6 @@ func GetGitCredentials(ctx context.Context, providerName string, opts ...auth.Op
BearerToken: token.(*azure.Token).Token,
}, nil
default:
return nil, ErrUnsupportedProvider
return nil, fmt.Errorf("provider '%s' does not support Git credentials", providerName)
}
}

47
auth/utils/git_test.go Normal file
View File

@ -0,0 +1,47 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package utils_test
import (
"context"
"testing"
"time"
. "github.com/onsi/gomega"
authutils "github.com/fluxcd/pkg/auth/utils"
)
func TestGetGitCredentials(t *testing.T) {
t.Run("azure", func(t *testing.T) {
g := NewWithT(t)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
p, err := authutils.GetGitCredentials(ctx, "azure")
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).NotTo(ContainSubstring("does not support Git credentials"))
g.Expect(p).To(BeNil())
})
t.Run("unknown provider", func(t *testing.T) {
g := NewWithT(t)
p, err := authutils.GetGitCredentials(context.Background(), "unknown")
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal("provider 'unknown' does not support Git credentials"))
g.Expect(p).To(BeNil())
})
}

View File

@ -14,25 +14,39 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package authutils
package utils
import (
"github.com/fluxcd/pkg/auth"
"fmt"
"github.com/fluxcd/pkg/auth/aws"
"github.com/fluxcd/pkg/auth/azure"
"github.com/fluxcd/pkg/auth/gcp"
"github.com/fluxcd/pkg/auth/generic"
)
// ProviderByName looks up the implemented providers by name.
func ProviderByName(name string) auth.Provider {
// ProviderByName looks up the implemented providers by name and type.
func ProviderByName[T any](name string) (T, error) {
var p any
var zero T
switch name {
case aws.ProviderName:
return aws.Provider{}
p = aws.Provider{}
case azure.ProviderName:
return azure.Provider{}
p = azure.Provider{}
case gcp.ProviderName:
return gcp.Provider{}
p = gcp.Provider{}
case generic.ProviderName:
p = generic.Provider{}
default:
return nil
return zero, fmt.Errorf("provider '%s' not implemented", name)
}
provider, ok := p.(T)
if !ok {
return zero, fmt.Errorf("provider '%s' does not implement the expected interface", name)
}
return provider, nil
}

171
auth/utils/provider_test.go Normal file
View File

@ -0,0 +1,171 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package utils_test
import (
"testing"
. "github.com/onsi/gomega"
"github.com/fluxcd/pkg/auth"
"github.com/fluxcd/pkg/auth/aws"
"github.com/fluxcd/pkg/auth/azure"
"github.com/fluxcd/pkg/auth/gcp"
"github.com/fluxcd/pkg/auth/generic"
authutils "github.com/fluxcd/pkg/auth/utils"
)
func TestProviderByName(t *testing.T) {
t.Run("sts providers", func(t *testing.T) {
for _, tt := range []struct {
name string
provider any
}{
{
name: azure.ProviderName,
provider: azure.Provider{},
},
{
name: aws.ProviderName,
provider: aws.Provider{},
},
{
name: gcp.ProviderName,
provider: gcp.Provider{},
},
{
name: generic.ProviderName,
provider: generic.Provider{},
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
p, err := authutils.ProviderByName[auth.Provider](tt.name)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(p).To(Equal(tt.provider))
})
}
})
t.Run("registry providers", func(t *testing.T) {
for _, tt := range []struct {
name string
provider any
}{
{
name: azure.ProviderName,
provider: azure.Provider{},
},
{
name: aws.ProviderName,
provider: aws.Provider{},
},
{
name: gcp.ProviderName,
provider: gcp.Provider{},
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
p, err := authutils.ProviderByName[auth.ArtifactRegistryCredentialsProvider](tt.name)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(p).To(Equal(tt.provider))
})
}
t.Run("generic provider", func(t *testing.T) {
g := NewWithT(t)
p, err := authutils.ProviderByName[auth.ArtifactRegistryCredentialsProvider](generic.ProviderName)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("does not implement the expected interface"))
g.Expect(p).To(BeNil())
})
})
t.Run("restconfig providers", func(t *testing.T) {
for _, tt := range []struct {
name string
provider any
}{
{
name: azure.ProviderName,
provider: azure.Provider{},
},
{
name: aws.ProviderName,
provider: aws.Provider{},
},
{
name: gcp.ProviderName,
provider: gcp.Provider{},
},
{
name: generic.ProviderName,
provider: generic.Provider{},
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
p, err := authutils.ProviderByName[auth.RESTConfigProvider](tt.name)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(p).To(Equal(tt.provider))
})
}
})
t.Run("errors", func(t *testing.T) {
type iface interface{ foo() }
for _, tt := range []struct {
name string
provider any
}{
{
name: azure.ProviderName,
provider: azure.Provider{},
},
{
name: aws.ProviderName,
provider: aws.Provider{},
},
{
name: gcp.ProviderName,
provider: gcp.Provider{},
},
{
name: generic.ProviderName,
provider: generic.Provider{},
},
} {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
p, err := authutils.ProviderByName[iface](tt.name)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("does not implement the expected interface"))
g.Expect(p).To(BeNil())
})
}
t.Run("unknown provider", func(t *testing.T) {
g := NewWithT(t)
p, err := authutils.ProviderByName[iface]("unknown")
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(Equal("provider 'unknown' not implemented"))
g.Expect(p).To(BeNil())
})
})
}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
package authutils
package utils
import (
"context"
@ -24,29 +24,15 @@ import (
"github.com/fluxcd/pkg/auth"
)
// GetArtifactRegistryCredentials retrieves the credentials for the specified
// artifact repository using the specified provider. It returns an
// authn.Authenticator that can be used to authenticate with the registry.
func GetArtifactRegistryCredentials(ctx context.Context,
providerName, artifactRepository string,
opts ...auth.Option) (authn.Authenticator, error) {
// GetArtifactRegistryCredentials retrieves the registry credentials for the
// specified artifact repository and provider.
func GetArtifactRegistryCredentials(ctx context.Context, providerName string,
artifactRepository string, opts ...auth.Option) (authn.Authenticator, error) {
provider := ProviderByName(providerName)
if provider == nil {
return nil, ErrUnsupportedProvider
}
opts = append(opts, auth.WithArtifactRepository(artifactRepository))
token, err := auth.GetToken(ctx, provider, opts...)
provider, err := ProviderByName[auth.ArtifactRegistryCredentialsProvider](providerName)
if err != nil {
return nil, err
}
authenticator, ok := token.(authn.Authenticator)
if !ok {
return nil, ErrProviderDoesNotSupportRegistry
}
return authenticator, nil
return auth.GetArtifactRegistryCredentials(ctx, provider, artifactRepository, opts...)
}

View File

@ -1,79 +0,0 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package authutils_test
import (
"context"
"testing"
. "github.com/onsi/gomega"
authutils "github.com/fluxcd/pkg/auth/utils"
)
// TestGetArtifactRegistryCredentials_ProviderLookup tests the provider lookup
// behavior of GetArtifactRegistryCredentials. Full function testing is difficult
// because auth.GetToken is not interface-based and would require complex mocking.
func TestGetArtifactRegistryCredentials_ProviderLookup(t *testing.T) {
t.Parallel()
tests := []struct {
name string
providerName string
expectUnsupportedError bool
}{
{
name: "unsupported provider",
providerName: "unsupported-provider",
expectUnsupportedError: true,
},
{
name: "AWS provider",
providerName: "aws",
expectUnsupportedError: false,
},
{
name: "Azure provider",
providerName: "azure",
expectUnsupportedError: false,
},
{
name: "GCP provider",
providerName: "gcp",
expectUnsupportedError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := NewWithT(t)
ctx := context.Background()
authenticator, err := authutils.GetArtifactRegistryCredentials(ctx, tt.providerName, "registry.example.com")
g.Expect(err).To(HaveOccurred())
g.Expect(authenticator).To(BeNil())
if tt.expectUnsupportedError {
g.Expect(err).To(MatchError(authutils.ErrUnsupportedProvider))
} else {
g.Expect(err).NotTo(MatchError(authutils.ErrUnsupportedProvider))
}
})
}
}

162
auth/utils/restconfig.go Normal file
View File

@ -0,0 +1,162 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package utils
import (
"context"
"fmt"
"net/http"
"strings"
corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/auth"
)
// RESTConfigFetcher is a function that retrieves a *rest.Config for a given
// meta.KubeConfigReference, a namespace, and a controller-runtime client.
type RESTConfigFetcher func(ctx context.Context, ref meta.KubeConfigReference,
namespace string, ctrlClient client.Client) (*rest.Config, error)
// GetRESTConfigFetcher is a convenience function for controllers that use the
// runtime/client.(*Impersonator) to create controller-runtime clients. To keep
// runtime decoupled from auth, this function closes over the controller-provided
// options and returns a function that can be called by runtime without runtime
// needing to know about the type auth.Option. Usage example:
//
// provider := authutils.GetRESTConfigFetcher(opts...)
// impersonatorOpts = append(impersonatorOpts,
// runtimeclient.WithKubeConfig(ref, kubeConfOpts, namespace, provider))
//
// Controllers that don't use the runtime/client.(*Impersonator) can simply call
// GetRESTConfig directly, passing the options as variadic arguments.
func GetRESTConfigFetcher(opts ...auth.Option) RESTConfigFetcher {
return func(ctx context.Context, ref meta.KubeConfigReference,
namespace string, ctrlClient client.Client) (*rest.Config, error) {
return GetRESTConfig(ctx, ref, namespace, ctrlClient, opts...)
}
}
// GetRESTConfig retrieves a *rest.Config for the given meta.KubeConfigReference,
// namespace, controller-runtime client and options. It's a convenience wrapper
// for auth.GetRESTConfig so controllers can pass a meta.KubeConfigReference
// object directly without converting it to auth.Option(s).
//
// Additionally, the resulting *rest.Config will call auth.GetRESTConfig for every
// HTTP request to the remote cluster. This is needed for long-running operations
// that wait on resources until a potentially long timeout is reached, like kstatus
// health checks, and whatever Helm does. The timeout may be longer than a token's
// lifetime, so tokens can expire during such operations. auth.GetRESTConfig will
// create a fresh token if that happens.
//
// With the resulting *rest.Config, if a cache is not set in the options, a fresh
// token will be created for every HTTP request sent to the remote cluster.
func GetRESTConfig(ctx context.Context,
kubeConfigRef meta.KubeConfigReference,
namespace string, ctrlClient client.Client,
opts ...auth.Option) (*rest.Config, error) {
// Get ConfigMap.
cmKey := client.ObjectKey{
Name: kubeConfigRef.ConfigMapRef.Name,
Namespace: namespace,
}
var cm corev1.ConfigMap
if err := ctrlClient.Get(ctx, cmKey, &cm); err != nil {
return nil, fmt.Errorf("failed to get configmap %s: %w", cmKey.String(), err)
}
// Get provider by name.
provider, err := ProviderByName[auth.RESTConfigProvider](cm.Data[meta.KubeConfigKeyProvider])
if err != nil {
return nil, err
}
// Configure options.
if c, ok := cm.Data[meta.KubeConfigKeyCluster]; ok {
opts = append(opts, auth.WithClusterResource(c))
}
if a, ok := cm.Data[meta.KubeConfigKeyAddress]; ok {
opts = append(opts, auth.WithClusterAddress(a))
}
if ca, ok := cm.Data[meta.KubeConfigKeyCACert]; ok {
opts = append(opts, auth.WithCAData(ca))
}
if name, ok := cm.Data[meta.KubeConfigKeyServiceAccountName]; ok {
saKey := client.ObjectKey{
Name: name,
Namespace: namespace,
}
opts = append(opts, auth.WithServiceAccount(saKey, ctrlClient))
} else {
opts = append(opts, auth.WithClient(ctrlClient))
}
if a, ok := cm.Data[meta.KubeConfigKeyAudiences]; ok {
var audiences []string
for aud := range strings.SplitSeq(a, "\n") {
aud = strings.TrimSpace(aud)
if aud == "" {
continue
}
audiences = append(audiences, aud)
}
opts = append(opts, auth.WithAudiences(audiences...))
}
conf, err := auth.GetRESTConfig(ctx, provider, opts...)
if err != nil {
return nil, err
}
// Build wrapped *rest.Config that will call
// auth.GetRESTConfig for every HTTP request.
restConfig := &rest.Config{
Host: conf.Host,
TLSClientConfig: rest.TLSClientConfig{CAData: conf.CAData},
}
restConfig.Wrap(func(base http.RoundTripper) http.RoundTripper {
return &restConfigRoundTripper{
base: base,
provider: provider,
opts: opts,
}
})
return restConfig, nil
}
// restConfigRoundTripper is an http.RoundTripper that wraps the base
// RoundTripper and retrieves a bearer token for the remote cluster
// using auth.GetRESTConfig before each HTTP request.
type restConfigRoundTripper struct {
base http.RoundTripper
provider auth.RESTConfigProvider
opts []auth.Option
}
// RoundTrip implements http.RoundTripper.
func (r *restConfigRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
details, err := auth.GetRESTConfig(req.Context(), r.provider, r.opts...)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+details.BearerToken)
return r.base.RoundTrip(req)
}

View File

@ -19,8 +19,8 @@ package chartutil
import (
"io"
goyaml "go.yaml.in/yaml/v2"
"sigs.k8s.io/yaml"
goyaml "sigs.k8s.io/yaml/goyaml.v2"
)
// PreEncoder allows for pre-processing of the YAML data before encoding.

View File

@ -9,10 +9,11 @@ replace github.com/fluxcd/pkg/apis/meta => ../apis/meta
replace github.com/opencontainers/go-digest => github.com/opencontainers/go-digest v1.0.1-0.20231025023718-d50d2fec9c98
require (
github.com/fluxcd/pkg/apis/meta v1.15.0
github.com/fluxcd/pkg/apis/meta v1.18.0
github.com/go-logr/logr v1.4.2
github.com/onsi/gomega v1.37.0
github.com/opencontainers/go-digest v1.0.0
go.yaml.in/yaml/v2 v2.4.2
helm.sh/helm/v3 v3.18.4
k8s.io/api v0.33.2
k8s.io/apimachinery v0.33.2
@ -50,7 +51,6 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.34.0 // indirect

View File

@ -19,9 +19,19 @@ package chartutil
import (
"sort"
goyaml "sigs.k8s.io/yaml/goyaml.v2"
goyaml "go.yaml.in/yaml/v2"
)
func sortSlice(s []interface{}) {
for _, item := range s {
if nestedMS, ok := item.(goyaml.MapSlice); ok {
SortMapSlice(nestedMS)
} else if nestedSlice, ok := item.([]interface{}); ok {
sortSlice(nestedSlice)
}
}
}
// SortMapSlice recursively sorts the given goyaml.MapSlice by key.
// It can be used in combination with Encode to sort YAML by key
// before encoding it.
@ -34,11 +44,7 @@ func SortMapSlice(ms goyaml.MapSlice) {
if nestedMS, ok := item.Value.(goyaml.MapSlice); ok {
SortMapSlice(nestedMS)
} else if nestedSlice, ok := item.Value.([]interface{}); ok {
for _, vItem := range nestedSlice {
if nestedMS, ok := vItem.(goyaml.MapSlice); ok {
SortMapSlice(nestedMS)
}
}
sortSlice(nestedSlice)
}
}
}

View File

@ -20,8 +20,8 @@ import (
"bytes"
"testing"
goyaml "go.yaml.in/yaml/v2"
"sigs.k8s.io/yaml"
goyaml "sigs.k8s.io/yaml/goyaml.v2"
)
func TestSortMapSlice(t *testing.T) {
@ -124,6 +124,54 @@ func TestSortMapSlice(t *testing.T) {
"r": "value-r",
},
"e": []interface{}{"strawberry", "banana"},
"f": []interface{}{
[]interface{}{
map[string]interface{}{
"f1": map[string]interface{}{
"f1q": "value-f1q",
"f1p": "value-f1p",
"f1r": "value-f1r",
},
},
map[string]interface{}{
"f2": map[string]interface{}{
"f2q": "value-f2q",
"f2p": "value-f2p",
"f2r": "value-f2r",
},
},
map[string]interface{}{
"f3": map[string]interface{}{
"f3q": "value-f3q",
"f3p": "value-f3p",
"f3r": "value-f3r",
},
},
},
[]interface{}{
map[string]interface{}{
"F1": map[string]interface{}{
"F1q": "value-F1q",
"F1p": "value-F1p",
"F1r": "value-F1r",
},
},
map[string]interface{}{
"F2": map[string]interface{}{
"F2q": "value-F2q",
"F2p": "value-F2p",
"F2r": "value-F2r",
},
},
map[string]interface{}{
"F3": map[string]interface{}{
"F3q": "value-F3q",
"F3p": "value-F3p",
"F3r": "value-F3r",
},
},
},
},
},
want: map[string]interface{}{
"a": map[string]interface{}{
@ -146,6 +194,54 @@ func TestSortMapSlice(t *testing.T) {
"r": "value-r",
},
"e": []interface{}{"strawberry", "banana"},
"f": []interface{}{
[]interface{}{
map[string]interface{}{
"f1": map[string]interface{}{
"f1p": "value-f1p",
"f1q": "value-f1q",
"f1r": "value-f1r",
},
},
map[string]interface{}{
"f2": map[string]interface{}{
"f2p": "value-f2p",
"f2q": "value-f2q",
"f2r": "value-f2r",
},
},
map[string]interface{}{
"f3": map[string]interface{}{
"f3p": "value-f3p",
"f3q": "value-f3q",
"f3r": "value-f3r",
},
},
},
[]interface{}{
map[string]interface{}{
"F1": map[string]interface{}{
"F1p": "value-F1p",
"F1q": "value-F1q",
"F1r": "value-F1r",
},
},
map[string]interface{}{
"F2": map[string]interface{}{
"F2p": "value-F2p",
"F2q": "value-F2q",
"F2r": "value-F2r",
},
},
map[string]interface{}{
"F3": map[string]interface{}{
"F3p": "value-F3p",
"F3q": "value-F3q",
"F3r": "value-F3r",
},
},
},
},
},
},
{

28
cmd/cli/main.go Normal file
View File

@ -0,0 +1,28 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"os"
)
func main() {
rootCmd.SilenceUsage = true
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

30
cmd/cli/pkg.go Normal file
View File

@ -0,0 +1,30 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"github.com/spf13/cobra"
)
var pkgCmd = &cobra.Command{
Use: "pkg",
Short: "Tools for helping with Flux development tasks in the github.com/fluxcd/pkg repository",
}
func init() {
rootCmd.AddCommand(pkgCmd)
}

View File

@ -0,0 +1,81 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/fluxcd/pkg/cmd/internal"
)
var pkgPrepareReleaseCmd = &cobra.Command{
Use: "prep",
Short: "Prepare release for the github.com/fluxcd/pkg repository Go modules",
RunE: runPrepareRelease,
}
var pkgPrepareReleaseCmdFlags struct {
yes bool
}
func init() {
pkgCmd.AddCommand(pkgPrepareReleaseCmd)
pkgPrepareReleaseCmd.Flags().BoolVar(&pkgPrepareReleaseCmdFlags.yes, "yes", false,
"Skip confirmation prompt and apply changes directly. Use with caution.")
pkgPrepareReleaseCmd.Flags().MarkHidden("yes")
}
func runPrepareRelease(cmd *cobra.Command, args []string) error {
ctx := ctrl.SetupSignalHandler()
res, err := internal.ComputeModuleBumps(ctx)
if err != nil {
return fmt.Errorf("failed to compute module bumps: %w", err)
}
if !res.MustBumpInternalModules() {
return nil
}
res.PrintBumps()
// Prompt for confirmation to apply changes.
if !pkgPrepareReleaseCmdFlags.yes {
fmt.Println("\nConfirm applying changes above to file system? (Y/n, only uppercase Y will confirm)")
var response string
fmt.Scanln(&response)
if response != "Y" {
fmt.Println("Aborting changes.")
return nil
}
}
// Apply changes to the file system.
if err := res.ApplyInternalBumps(ctx); err != nil {
return fmt.Errorf("failed to apply module bumps: %w", err)
}
// Show git status to the user.
gitStatus := exec.CommandContext(ctx, "git", "status")
gitStatus.Stdout = os.Stdout
gitStatus.Stderr = os.Stderr
return gitStatus.Run()
}

81
cmd/cli/pkg_release.go Normal file
View File

@ -0,0 +1,81 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"errors"
"fmt"
"github.com/spf13/cobra"
ctrl "sigs.k8s.io/controller-runtime"
"github.com/fluxcd/pkg/cmd/internal"
)
var pkgReleaseCmd = &cobra.Command{
Use: "release",
Short: "Release the github.com/fluxcd/pkg repository Go modules",
RunE: runRelease,
}
var pkgReleaseCmdFlags struct {
preview bool
}
func init() {
pkgCmd.AddCommand(pkgReleaseCmd)
pkgReleaseCmd.Flags().BoolVar(&pkgReleaseCmdFlags.preview, "preview", false,
"Preview the release changes without applying them.")
}
func runRelease(cmd *cobra.Command, args []string) error {
ctx := ctrl.SetupSignalHandler()
res, err := internal.ComputeModuleBumps(ctx)
if err != nil {
return fmt.Errorf("failed to compute module bumps: %w", err)
}
if res.MustBumpInternalModules() {
res.PrintBumps()
return errors.New("modules need to be bumped, please run 'make prep' first and open a pull request")
}
if !res.MustPushTags() {
return nil
}
res.PrintTags()
if pkgReleaseCmdFlags.preview {
return nil
}
// Prompt for confirmation to push the tags.
fmt.Println("\nConfirm pushing tags above to Git repository? (Y/n, only uppercase Y will confirm)")
var response string
fmt.Scanln(&response)
if response != "Y" {
fmt.Println("Aborting changes.")
return nil
}
// Push the tags to the remote repository.
if err := res.PushTags(ctx); err != nil {
return fmt.Errorf("failed to push tags: %w", err)
}
return nil
}

26
cmd/cli/root.go Normal file
View File

@ -0,0 +1,26 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "flux-tools",
Short: "Tools for helping with Flux development tasks",
}

83
cmd/go.mod Normal file
View File

@ -0,0 +1,83 @@
module github.com/fluxcd/pkg/cmd
go 1.24.0
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/go-git/go-git/v5 v5.16.2
github.com/spf13/cobra v1.9.1
sigs.k8s.io/controller-runtime v0.21.0
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pjbgf/sha1cd v0.4.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/term v0.33.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/time v0.9.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/api v0.33.0 // indirect
k8s.io/apiextensions-apiserver v0.33.0 // indirect
k8s.io/apimachinery v0.33.0 // indirect
k8s.io/client-go v0.33.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

269
cmd/go.sum Normal file
View File

@ -0,0 +1,269 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM=
github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pjbgf/sha1cd v0.4.0 h1:NXzbL1RvjTUi6kgYZCX3fPwwl27Q1LJndxtUDVfJGRY=
github.com/pjbgf/sha1cd v0.4.0/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
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/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU=
k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM=
k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs=
k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc=
k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ=
k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM=
k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98=
k8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4=
k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=
sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc=
sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View File

@ -0,0 +1,276 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package internal
import (
"context"
"fmt"
"slices"
"sort"
"strings"
"github.com/Masterminds/semver/v3"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/format/diff"
)
// ComputeModuleBumps looks at the current Git repository and computes
// the necessary module bumps based on the latest tags and changes in
// the codebase. It returns the modules that need to be bumped and their
// tags in the correct push order.
func ComputeModuleBumps(ctx context.Context) (*ModuleBumps, error) {
// Enumerate taggable and bumpable modules in the repository.
taggables, err := EnumerateTaggableModules()
if err != nil {
return nil, fmt.Errorf("failed to enumerate taggable modules: %w", err)
}
bumpables := EnumerateBumpableModules(taggables)
isTaggable := make(map[string]bool)
for _, bumpable := range bumpables {
if slices.Contains(taggables, bumpable) {
isTaggable[bumpable] = true
}
}
// Open the current Git repository.
repo, err := git.PlainOpen(".")
if err != nil {
return nil, fmt.Errorf("failed to open repository: %w", err)
}
// Get iterator for tags in the repository.
tagsIter, err := repo.Tags()
if err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
defer tagsIter.Close()
// Collect tags for each module.
moduleTags := make(map[string][]*semver.Version)
err = tagsIter.ForEach(func(ref *plumbing.Reference) error {
tag := ref.Name().Short()
for _, module := range taggables {
prefix := module + "/v"
if !strings.HasPrefix(tag, prefix) {
continue
}
v, err := semver.NewVersion(strings.TrimPrefix(tag, prefix))
if err != nil {
continue
}
moduleTags[module] = append(moduleTags[module], v)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to iterate over tags: %w", err)
}
// Find latest for each module.
moduleLatest := make(map[string]*semver.Version)
for module, versions := range moduleTags {
sort.Sort(sort.Reverse(semver.Collection(versions)))
moduleLatest[module] = versions[0]
}
// Find modules that have a diff between the latest tag and HEAD,
// i.e. those that need to be bumped.
var moduleBumps []*ModuleBump
headRef, err := repo.Head()
if err != nil {
return nil, fmt.Errorf("failed to get HEAD reference: %w", err)
}
headCommit, err := repo.CommitObject(headRef.Hash())
if err != nil {
return nil, fmt.Errorf("failed to get HEAD commit: %w", err)
}
for _, taggable := range taggables {
// Is it a new module?
latest, ok := moduleLatest[taggable]
if !ok {
bump, err := NewModuleBumpForNewModule(taggable)
if err != nil {
return nil, fmt.Errorf("failed to create module bump for new module %s: %w", taggable, err)
}
moduleBumps = append(moduleBumps, bump)
continue
}
// Compute the patch (the diff) between the latest tag and HEAD.
tag := fmt.Sprintf("%s/v%s", taggable, latest.Original())
tagRef, err := repo.Tag(tag)
if err != nil {
return nil, fmt.Errorf("failed to get tag %s: %w", tag, err)
}
tagObject, err := repo.TagObject(tagRef.Hash())
if err != nil {
return nil, fmt.Errorf("failed to get commit for tag %s: %w", tag, err)
}
tagCommit, err := repo.CommitObject(tagObject.Target)
if err != nil {
return nil, fmt.Errorf("failed to get commit for tag %s: %w", tag, err)
}
patch, err := tagCommit.PatchContext(ctx, headCommit)
if err != nil {
return nil, fmt.Errorf("failed to create patch between %s and HEAD: %w", tag, err)
}
// For each file change in the patch, check if it belongs to the module.
prefix := taggable + "/"
fileChanged := func(f diff.File) bool {
if f == nil || !strings.HasPrefix(f.Path(), prefix) {
return false
}
// This loop is for removing bumps to <taggable> if the file
// is of the form <taggable>/<other_module>/<file> where
// <bumpable> is <taggable>/<other_module>.
path := f.Path()
for _, bumpable := range bumpables {
if strings.HasPrefix(path, bumpable+"/") && strings.HasPrefix(bumpable, prefix) {
return false
}
}
return true
}
changed := false
for _, file := range patch.FilePatches() {
from, to := file.Files()
if fileChanged(from) || fileChanged(to) {
changed = true
break
}
}
if changed {
bump, err := NewModuleBump(taggable, latest.Original(), latest.IncMinor().String())
if err != nil {
return nil, fmt.Errorf("failed to create module bump for %s: %w", taggable, err)
}
moduleBumps = append(moduleBumps, bump)
}
}
// For each taggable module that needs to receive a new release,
// bump it inside other bumpable modules.
targetModules := make([][]string, len(moduleBumps))
mustBumpInternalModules := false
for i := 0; i < len(moduleBumps); i++ { // moduleBumps grows dynamically inside this loop.
bump := moduleBumps[i]
for _, targetModule := range bumpables {
if targetModule == bump.module {
continue
}
ok, err := bump.DryRunApply(ctx, targetModule)
if err != nil {
return nil, fmt.Errorf("failed to apply bump %s to module %s: %w", bump, targetModule, err)
}
if ok {
targetModules[i] = append(targetModules[i], targetModule)
// After updating the targetModule, if targetModule is taggable,
// then it must be bumped as well.
if !isTaggable[targetModule] {
continue
}
willBeBumpedAlready := false
for _, existingBump := range moduleBumps {
if targetModule == existingBump.module {
willBeBumpedAlready = true
break
}
}
if !willBeBumpedAlready {
var newBump *ModuleBump
latest, ok := moduleLatest[targetModule]
if !ok {
// This is a new module that was never tagged before.
newBump, err = NewModuleBumpForNewModule(targetModule)
if err != nil {
return nil, fmt.Errorf("failed to create module bump for new module %s: %w", targetModule, err)
}
} else {
// This is an existing module that already has a tag.
newBump, err = NewModuleBump(targetModule, latest.Original(), latest.IncMinor().String())
if err != nil {
return nil, fmt.Errorf("failed to create module bump for %s: %w", targetModule, err)
}
}
moduleBumps = append(moduleBumps, newBump)
targetModules = append(targetModules, nil)
}
}
}
if len(targetModules[i]) > 0 {
mustBumpInternalModules = true
}
}
// If bumps must be applied, return early without computing the order of tags to push.
if mustBumpInternalModules {
return &ModuleBumps{
bumps: moduleBumps,
targetModules: targetModules,
mustBumpInternalModules: true,
}, nil
}
// Compute topological order of tags to push using a depth-first search.
// https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search
const (
unmarked = iota
permanentMark
temporaryMark
)
mark := make([]int, len(moduleBumps))
tagsToPush := make([]string, 0, len(moduleBumps))
var depthFirstSearch func(i int) // Recursive closures need to be defined like this.
depthFirstSearch = func(i int) {
if mark[i] == permanentMark {
return
}
if mark[i] == temporaryMark {
// Should never happen, as cycles are not allowed in Go modules dependencies.
panic("cycle detected in module bumps")
}
mark[i] = temporaryMark
for _, targetModule := range targetModules[i] {
// Find which j represents targetModule in moduleBumps.
for j, targetBump := range moduleBumps {
if targetBump.module == targetModule {
depthFirstSearch(j)
break
}
}
}
mark[i] = permanentMark
tag := fmt.Sprintf("%s/v%s", moduleBumps[i].module, moduleBumps[i].newVersion)
tagsToPush = append(tagsToPush, tag)
}
for i := range moduleBumps {
depthFirstSearch(i)
}
return &ModuleBumps{
bumps: moduleBumps,
targetModules: targetModules,
mustBumpInternalModules: false,
tagsToPush: tagsToPush,
}, nil
}

View File

@ -0,0 +1,25 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package internal
// EnumerateBumpableModules returns a list of Go modules that can be bumped
// in the github.com/fluxcd/pkg repository. This includes all modules that
// are taggable, as well as specific modules that are not tagged but should
// still be considered for version bumps.
func EnumerateBumpableModules(taggable []string) []string {
return append(taggable, testModules...)
}

View File

@ -0,0 +1,60 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package internal
import (
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
)
// EnumerateTaggableModules traverses the current directory and returns the
// paths of directories containing Go modules that are taggable for release.
func EnumerateTaggableModules() ([]string, error) {
var nonTaggables = append([]string{"cmd"}, testModules...)
var taggables []string
err := fs.WalkDir(os.DirFS("."), ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
for _, nonTaggable := range nonTaggables {
if path == nonTaggable || strings.HasPrefix(path, nonTaggable+"/") {
return nil
}
}
f, err := os.Open(filepath.Join(path, "go.mod"))
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
f.Close()
taggables = append(taggables, path)
return nil
})
if err != nil {
return nil, err
}
slices.Sort(taggables)
return taggables, nil
}

105
cmd/internal/module_bump.go Normal file
View File

@ -0,0 +1,105 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package internal
import (
"context"
"fmt"
"os"
"os/exec"
"regexp"
)
// ModuleBump represents a module version bump operation and
// helps applying the bump to target modules in the repository.
type ModuleBump struct {
module string
oldVersion string
newVersion string
regex *regexp.Regexp
}
// NewModuleBump creates a ModuleBump for the given module with the old and new versions.
func NewModuleBump(module, oldVersion, newVersion string) (*ModuleBump, error) {
pattern := fmt.Sprintf(`github\.com/fluxcd/pkg/%s v([^\s]+)`, module)
regex, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("failed to compile regex for module %s: %w", module, err)
}
return &ModuleBump{
module: module,
oldVersion: oldVersion,
newVersion: newVersion,
regex: regex,
}, nil
}
// NewModuleBumpForNewModule creates a ModuleBump for a new module with an initial version.
func NewModuleBumpForNewModule(module string) (*ModuleBump, error) {
return NewModuleBump(module, "", "0.1.0")
}
// String implements the fmt.Stringer interface for *ModuleBump.
func (m *ModuleBump) String() string {
from := "new module"
if m.oldVersion != "" {
from = m.oldVersion
}
return fmt.Sprintf("%s: %s => %s", m.module, from, m.newVersion)
}
// Apply replaces the module version in the given target module.
func (m *ModuleBump) Apply(ctx context.Context, targetModule string) (bool, error) {
const dryRun = false
return m.apply(ctx, targetModule, dryRun)
}
// DryRunApply replaces the module version in the given target module without writing changes.
func (m *ModuleBump) DryRunApply(ctx context.Context, targetModule string) (bool, error) {
const dryRun = true
return m.apply(ctx, targetModule, dryRun)
}
// apply replaces the module version in the given target module.
func (m *ModuleBump) apply(ctx context.Context, targetModule string, dryRun bool) (bool, error) {
gomod := fmt.Sprintf("%s/go.mod", targetModule)
b, err := os.ReadFile(gomod)
if err != nil {
return false, fmt.Errorf("failed to read %s: %w", gomod, err)
}
oldContent := string(b)
if !m.regex.MatchString(oldContent) {
return false, nil
}
bumpString := fmt.Sprintf("github.com/fluxcd/pkg/%s v%s", m.module, m.newVersion)
newContent := m.regex.ReplaceAllString(oldContent, bumpString)
if oldContent == newContent {
return false, nil
}
if !dryRun {
if err := os.WriteFile(gomod, []byte(newContent), 0644); err != nil {
return false, fmt.Errorf("failed to write %s: %w", gomod, err)
}
gomodtidy := exec.CommandContext(ctx, "go", "mod", "tidy")
gomodtidy.Dir = targetModule
b, err = gomodtidy.CombinedOutput()
if err != nil {
return false, fmt.Errorf("failed to run go mod tidy in %s: %w\n%s", targetModule, err, string(b))
}
}
return true, nil
}

View File

@ -0,0 +1,103 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package internal
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
)
// ModuleBumps represents a collection of module bumps
// that need to be applied to the repository.
type ModuleBumps struct {
// bumps are the Go modules that need to be released because
// there is a diff between the latest tag and HEAD.
bumps []*ModuleBump
// targetModules[i][j] is the j-th module that needs to receive
// the i-th bump, i.e. bumps[i].
targetModules [][]string
// mustBumpInternalModules is true if at least one module needs to receive at least one bump.
mustBumpInternalModules bool
// tagsToPush are the tags that need to be pushed to the remote repository
// in the topological order.
tagsToPush []string
}
// MustBumpInternalModules returns true if there are any module bumps that need to be applied
// to the current repository.
func (m *ModuleBumps) MustBumpInternalModules() bool {
return m.mustBumpInternalModules
}
// PrintBumps prints the module bumps result in a human-readable format.
func (m *ModuleBumps) PrintBumps() {
for i, bump := range m.bumps {
if len(m.targetModules[i]) == 0 {
continue
}
fmt.Printf("Bumped %s in modules: %s\n", bump, strings.Join(m.targetModules[i], ", "))
}
}
// ApplyInternalBumps applies the module bumps to the file system.
func (m *ModuleBumps) ApplyInternalBumps(ctx context.Context) error {
for i, bump := range m.bumps {
for _, targetModule := range m.targetModules[i] {
if _, err := bump.Apply(ctx, targetModule); err != nil {
return fmt.Errorf("failed to apply bump %s to module %s: %w", bump, targetModule, err)
}
}
}
return nil
}
// MustPushTags tells whether the tags need to be pushed to the remote repository.
func (m *ModuleBumps) MustPushTags() bool {
return len(m.tagsToPush) > 0
}
// PrintTags prints the tags that will be pushed to the remote repository.
func (m *ModuleBumps) PrintTags() {
for _, tag := range m.tagsToPush {
fmt.Println(tag)
}
}
// PushTags pushes the tags to the remote repository.
func (m *ModuleBumps) PushTags(ctx context.Context) error {
for _, tag := range m.tagsToPush {
tagCmd := exec.CommandContext(ctx, "git", "tag", "-s", "-m", tag, tag)
tagCmd.Stdout = os.Stdout
tagCmd.Stderr = os.Stderr
if err := tagCmd.Run(); err != nil {
return fmt.Errorf("failed to create tag %s: %w", tag, err)
}
pushCmd := exec.CommandContext(ctx, "git", "push", "origin", tag)
pushCmd.Stdout = os.Stdout
pushCmd.Stderr = os.Stderr
if err := pushCmd.Run(); err != nil {
return fmt.Errorf("failed to push tag %s: %w", tag, err)
}
}
return nil
}

View File

@ -0,0 +1,24 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package internal
// testModules contains the paths of modules that are used for testing
// only and should not be tagged.
var testModules = []string{
"git/internal/e2e",
"oci/tests/integration",
}

View File

@ -1,6 +1,31 @@
# Release
# Release Documentation
To release a new version of a package the following steps should be followed:
This process is intended to be run locally, in the clone of a Flux maintainer,
properly configured with commit signing and GitHub credentials.
1. Run `make release-<package name> VER=<next semver>`.
1. Confirm CI builds and releases the newly tagged version.
First, a preparation PR must be created bumping all the Go modules
from this repository that have changed since their latest version.
Run the following commands:
1. `git checkout main`
2. `git pull`
3. `make prep`
If there are any changes, commit, open a PR `Prepare for release` and merge.
If no changes are needed, then:
1. `git checkout main`
2. `git pull`
3. `make release`
Both `make` commands will show a plan of the changes they
will make and ask for confirmation.
## New test Go modules
Whenever adding new test Go modules like `git/internal/e2e` or `oci/tests/integration`,
you must also add the module path to the `testModules` slice in `cmd/internal/test_modules.go`.
This is necessary to ensure that these modules are not considered for tagging when running
the `make release` command.

View File

@ -19,6 +19,7 @@ package github
import (
"context"
"crypto/sha256"
"crypto/tls"
"fmt"
"net/http"
"net/url"
@ -33,10 +34,10 @@ import (
)
const (
AppIDKey = "githubAppID"
AppInstallationIDKey = "githubAppInstallationID"
AppPrivateKey = "githubAppPrivateKey"
AppBaseUrlKey = "githubAppBaseURL"
KeyAppID = "githubAppID"
KeyAppInstallationID = "githubAppInstallationID"
KeyAppPrivateKey = "githubAppPrivateKey"
KeyAppBaseURL = "githubAppBaseURL"
AccessTokenUsername = "x-access-token"
)
@ -54,6 +55,7 @@ type Client struct {
name string
namespace string
operation string
tlsConfig *tls.Config
}
// OptFunc enables specifying options for the provider.
@ -67,6 +69,9 @@ func New(opts ...OptFunc) (*Client, error) {
}
transport := http.DefaultTransport.(*http.Transport).Clone()
if p.tlsConfig != nil {
transport.TLSClientConfig = p.tlsConfig
}
if p.proxyURL != nil {
proxyStr := p.proxyURL.String()
proxyConfig := &httpproxy.Config{
@ -111,51 +116,29 @@ func New(opts ...OptFunc) (*Client, error) {
return p, nil
}
// WithInstallationID configures the installation ID of the GitHub App.
func WithInstllationID(installationID string) OptFunc {
// WithTLSConfig sets the tls config to use with the transport.
func WithTLSConfig(tlsConfig *tls.Config) OptFunc {
return func(p *Client) {
p.installationID = installationID
}
}
// WithAppID configures the app ID of the GitHub App.
func WithAppID(appID string) OptFunc {
return func(p *Client) {
p.appID = appID
}
}
// WithPrivateKey configures the private key of the GitHub App.
func WithPrivateKey(pk []byte) OptFunc {
return func(p *Client) {
p.privateKey = pk
}
}
// WithAppBaseURL configures the GitHub API endpoint to use to fetch GitHub App
// installation token.
func WithAppBaseURL(appBaseURL string) OptFunc {
return func(p *Client) {
p.apiURL = appBaseURL
p.tlsConfig = tlsConfig
}
}
// WithAppData configures the client using data from a map
func WithAppData(appData map[string][]byte) OptFunc {
return func(p *Client) {
val, ok := appData[AppIDKey]
val, ok := appData[KeyAppID]
if ok {
p.appID = string(val)
}
val, ok = appData[AppInstallationIDKey]
val, ok = appData[KeyAppInstallationID]
if ok {
p.installationID = string(val)
}
val, ok = appData[AppPrivateKey]
val, ok = appData[KeyAppPrivateKey]
if ok {
p.privateKey = val
}
val, ok = appData[AppBaseUrlKey]
val, ok = appData[KeyAppBaseURL]
if ok {
p.apiURL = string(val)
}
@ -249,10 +232,10 @@ func GetCredentials(ctx context.Context, opts ...OptFunc) (string, string, error
func (p *Client) buildCacheKey() string {
keyParts := []string{
fmt.Sprintf("%s=%s", AppIDKey, p.appID),
fmt.Sprintf("%s=%s", AppInstallationIDKey, p.installationID),
fmt.Sprintf("%s=%s", AppBaseUrlKey, p.apiURL),
fmt.Sprintf("%s=%s", AppPrivateKey, string(p.privateKey)),
fmt.Sprintf("%s=%s", KeyAppID, p.appID),
fmt.Sprintf("%s=%s", KeyAppInstallationID, p.installationID),
fmt.Sprintf("%s=%s", KeyAppBaseURL, p.apiURL),
fmt.Sprintf("%s=%s", KeyAppPrivateKey, string(p.privateKey)),
}
rawKey := strings.Join(keyParts, ",")
hash := sha256.Sum256([]byte(rawKey))

View File

@ -18,6 +18,8 @@ package github
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"net/http"
@ -46,25 +48,32 @@ func TestClient_Options(t *testing.T) {
wantErr error
}{
{
name: "Create new client",
opts: []OptFunc{WithInstllationID(installationID), WithAppID(appID), WithPrivateKey(kp.PrivateKey)},
name: "Create new client with proxy",
opts: []OptFunc{
WithAppData(map[string][]byte{
KeyAppID: []byte(appID),
KeyAppInstallationID: []byte(installationID),
KeyAppPrivateKey: kp.PrivateKey,
}),
WithProxyURL(proxy),
},
},
{
name: "Create new client with proxy",
opts: []OptFunc{WithInstllationID(installationID), WithAppID(appID), WithPrivateKey(kp.PrivateKey), WithProxyURL((proxy))},
name: "Create new client",
opts: []OptFunc{WithAppData(map[string][]byte{
KeyAppID: []byte(appID),
KeyAppInstallationID: []byte(installationID),
KeyAppPrivateKey: kp.PrivateKey,
})},
},
{
name: "Create new client with custom api url",
opts: []OptFunc{WithAppBaseURL(gitHubEnterpriseURL), WithInstllationID(installationID), WithAppID(appID), WithPrivateKey(kp.PrivateKey)},
},
{
name: "Create new client with app data",
opts: []OptFunc{WithAppData(map[string][]byte{
AppIDKey: []byte(appID),
AppInstallationIDKey: []byte(installationID),
AppPrivateKey: kp.PrivateKey,
},
)},
KeyAppID: []byte(appID),
KeyAppInstallationID: []byte(installationID),
KeyAppBaseURL: []byte(gitHubEnterpriseURL),
KeyAppPrivateKey: kp.PrivateKey,
})},
},
{
name: "Create new client with empty data",
@ -74,8 +83,8 @@ func TestClient_Options(t *testing.T) {
{
name: "Create new client with app data with missing AppID Key",
opts: []OptFunc{WithAppData(map[string][]byte{
AppInstallationIDKey: []byte(installationID),
AppPrivateKey: kp.PrivateKey,
KeyAppInstallationID: []byte(installationID),
KeyAppPrivateKey: kp.PrivateKey,
},
)},
wantErr: errors.New("app ID must be provided to use github app authentication"),
@ -83,8 +92,8 @@ func TestClient_Options(t *testing.T) {
{
name: "Create new client with app data with missing AppInstallationID Key",
opts: []OptFunc{WithAppData(map[string][]byte{
AppIDKey: []byte("123"),
AppPrivateKey: kp.PrivateKey,
KeyAppID: []byte("123"),
KeyAppPrivateKey: kp.PrivateKey,
},
)},
wantErr: errors.New("app installation ID must be provided to use github app authentication"),
@ -92,8 +101,8 @@ func TestClient_Options(t *testing.T) {
{
name: "Create new client with app data with missing private Key",
opts: []OptFunc{WithAppData(map[string][]byte{
AppIDKey: []byte(appID),
AppInstallationIDKey: []byte(installationID),
KeyAppID: []byte(appID),
KeyAppInstallationID: []byte(installationID),
},
)},
wantErr: errors.New("private key must be provided to use github app authentication"),
@ -101,9 +110,9 @@ func TestClient_Options(t *testing.T) {
{
name: "Create new client with invalid appID in app data",
opts: []OptFunc{WithAppData(map[string][]byte{
AppIDKey: []byte("abc"),
AppInstallationIDKey: []byte(installationID),
AppPrivateKey: kp.PrivateKey,
KeyAppID: []byte("abc"),
KeyAppInstallationID: []byte(installationID),
KeyAppPrivateKey: kp.PrivateKey,
},
)},
wantErr: errors.New("invalid app id, err: strconv.Atoi: parsing \"abc\": invalid syntax"),
@ -111,9 +120,9 @@ func TestClient_Options(t *testing.T) {
{
name: "Create new client with invalid installationID in app data",
opts: []OptFunc{WithAppData(map[string][]byte{
AppIDKey: []byte(appID),
AppInstallationIDKey: []byte("abc"),
AppPrivateKey: kp.PrivateKey,
KeyAppID: []byte(appID),
KeyAppInstallationID: []byte("abc"),
KeyAppPrivateKey: kp.PrivateKey,
},
)},
wantErr: errors.New("invalid app installation id, err: strconv.Atoi: parsing \"abc\": invalid syntax"),
@ -121,9 +130,9 @@ func TestClient_Options(t *testing.T) {
{
name: "Create new client with invalid private key in app data",
opts: []OptFunc{WithAppData(map[string][]byte{
AppIDKey: []byte(appID),
AppInstallationIDKey: []byte(installationID),
AppPrivateKey: []byte(" "),
KeyAppID: []byte(appID),
KeyAppInstallationID: []byte(installationID),
KeyAppPrivateKey: []byte(" "),
},
)},
wantErr: errors.New("could not parse private key: invalid key: Key must be a PEM encoded PKCS1 or PKCS8 key"),
@ -230,7 +239,12 @@ func TestClient_GetCredentials(t *testing.T) {
kp, err := ssh.GenerateKeyPair(ssh.RSA_4096)
g.Expect(err).ToNot(HaveOccurred())
opts := []OptFunc{
WithAppBaseURL(srv.URL), WithInstllationID("123"), WithAppID("456"), WithPrivateKey(kp.PrivateKey),
WithAppData(map[string][]byte{
KeyAppID: []byte("123"),
KeyAppInstallationID: []byte("456"),
KeyAppBaseURL: []byte(srv.URL),
KeyAppPrivateKey: kp.PrivateKey,
}),
}
opts = append(opts, tt.opts...)
@ -245,3 +259,55 @@ func TestClient_GetCredentials(t *testing.T) {
})
}
}
func TestClient_TLS_RootCA(t *testing.T) {
g := NewWithT(t)
// spin up a TLS server with a self-signed cert
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
tok := &AppToken{
Token: "enterprise-token",
ExpiresAt: time.Now().Add(time.Hour),
}
_ = json.NewEncoder(w).Encode(tok)
})
srv := httptest.NewTLSServer(handler)
defer srv.Close()
// generate a dummy GitHub App keypair
kp, err := ssh.GenerateKeyPair(ssh.RSA_4096)
g.Expect(err).NotTo(HaveOccurred())
opts := []OptFunc{
WithAppData(map[string][]byte{
KeyAppID: []byte("123"),
KeyAppInstallationID: []byte("456"),
KeyAppPrivateKey: kp.PrivateKey,
KeyAppBaseURL: []byte(srv.URL),
}),
}
t.Run("it should error out if a Root CA is not provided", func(t *testing.T) {
g := NewWithT(t)
// with no TLSConfig, system roots wont trust our servers cert
_, _, err := GetCredentials(context.Background(), opts...)
g.Expect(err).To(HaveOccurred())
g.Expect(err.Error()).To(ContainSubstring("certificate signed by unknown authority"))
})
t.Run("it should succeed when Root CA is provided", func(t *testing.T) {
g := NewWithT(t)
// create a cert pool with server cert
certPool := x509.NewCertPool()
certPool.AddCert(srv.Certificate())
opts := append(opts,
WithTLSConfig(&tls.Config{RootCAs: certPool}),
)
user, pass, err := GetCredentials(context.Background(), opts...)
g.Expect(err).NotTo(HaveOccurred())
g.Expect(user).To(Equal(AccessTokenUsername))
g.Expect(pass).To(Equal("enterprise-token"))
})
}

View File

@ -14,7 +14,7 @@ require (
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/elazarl/goproxy v1.7.2
github.com/fluxcd/gitkit v0.6.0
github.com/fluxcd/pkg/git v0.33.0
github.com/fluxcd/pkg/git v0.35.0
github.com/fluxcd/pkg/gittestserver v0.18.0
github.com/fluxcd/pkg/ssh v0.20.0
github.com/fluxcd/pkg/version v0.9.0

View File

@ -149,9 +149,11 @@ func TestGitHubE2E(t *testing.T) {
var data map[string][]byte
authOptions, err = git.NewAuthOptions(*repoURL, data)
username, password, err := pkggithub.GetCredentials(context.Background(),
pkggithub.WithAppID(githubAppID),
pkggithub.WithInstllationID(githubAppInstallID),
pkggithub.WithPrivateKey(githubAppPrivateKey))
pkggithub.WithAppData(map[string][]byte{
pkggithub.KeyAppID: []byte(githubAppID),
pkggithub.KeyAppInstallationID: []byte(githubAppInstallID),
pkggithub.KeyAppPrivateKey: githubAppPrivateKey,
}))
if err != nil {
return nil, nil, err
}

View File

@ -13,8 +13,8 @@ replace (
require (
github.com/fluxcd/go-git-providers v0.22.0
github.com/fluxcd/pkg/git v0.33.0
github.com/fluxcd/pkg/git/gogit v0.36.0
github.com/fluxcd/pkg/git v0.35.0
github.com/fluxcd/pkg/git/gogit v0.38.0
github.com/fluxcd/pkg/gittestserver v0.18.0
github.com/fluxcd/pkg/ssh v0.20.0
github.com/go-git/go-git/v5 v5.16.2

26
oci/fs.go Normal file
View File

@ -0,0 +1,26 @@
/*
Copyright 2025 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package oci
import "github.com/fluxcd/pkg/oci/internal/fs"
// RenameWithFallback attempts to rename a file or directory, but falls back to
// copying in the event of a cross-device link error. If the fallback copy
// succeeds, src is still removed, emulating normal rename behavior.
func RenameWithFallback(src, dst string) error {
return fs.RenameWithFallback(src, dst)
}

View File

@ -25,8 +25,6 @@
# export TF_VAR_gcp_project_id=
# export TF_VAR_gcp_region=us-central1
# export TF_VAR_gcp_zone=us-central1-c
## Leave GCR region empty to use gcr.io. Else set it to `us`, `eu` or `asia`.
# export TF_VAR_gcr_region=
## Set the following only when using service account.
## Provide absolute path to the service account JSON key file.
# export GOOGLE_APPLICATION_CREDENTIALS=

View File

@ -1,7 +1,7 @@
GO_TEST_ARGS ?=
GO_TEST_PREFIX ?=
PROVIDER_ARG ?=
TEST_TIMEOUT ?= 30m
TEST_TIMEOUT ?= 50m
GOARCH ?= amd64
GOOS ?= linux

View File

@ -25,6 +25,8 @@ import (
tfjson "github.com/hashicorp/terraform-json"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/auth/aws"
"github.com/fluxcd/test-infra/tftestenv"
)
@ -110,6 +112,32 @@ func getWISAAnnotationsAWS(output map[string]*tfjson.StateOutput) (map[string]st
}, nil
}
// getClusterConfigMapAWS returns the cluster configmap data for kubeconfig auth tests.
func getClusterConfigMapAWS(output map[string]*tfjson.StateOutput) (map[string]string, error) {
clusterResource := output["eks_cluster_arn"].Value.(string)
if clusterResource == "" {
return nil, fmt.Errorf("no EKS cluster id in terraform output")
}
clusterAddress := output["eks_cluster_endpoint"].Value.(string)
if clusterAddress == "" {
return nil, fmt.Errorf("no EKS cluster address in terraform output")
}
return map[string]string{
meta.KubeConfigKeyProvider: aws.ProviderName,
meta.KubeConfigKeyCluster: clusterResource,
meta.KubeConfigKeyAddress: clusterAddress,
}, nil
}
// getClusterUsersAWS returns the cluster users for kubeconfig auth tests.
func getClusterUsersAWS(output map[string]*tfjson.StateOutput) ([]string, error) {
clusterUser := output["aws_wi_iam_arn"].Value.(string)
if clusterUser == "" {
return nil, fmt.Errorf("no EKS cluster user id in terraform output")
}
return []string{clusterUser}, nil
}
// When implemented, getGitTestConfigAws would return the git-specific test config for AWS
func getGitTestConfigAWS(outputs map[string]*tfjson.StateOutput) (*gitTestConfig, error) {
return nil, fmt.Errorf("NotImplemented for AWS")

View File

@ -28,6 +28,8 @@ import (
"strings"
"time"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/auth/azure"
"github.com/fluxcd/pkg/git"
"github.com/fluxcd/test-infra/tftestenv"
"github.com/google/uuid"
@ -101,6 +103,32 @@ func getWISAAnnotationsAzure(output map[string]*tfjson.StateOutput) (map[string]
}, nil
}
// getClusterConfigMapAzure returns the cluster configmap data for kubeconfig auth tests.
func getClusterConfigMapAzure(output map[string]*tfjson.StateOutput) (map[string]string, error) {
clusterResource := output["cluster_id"].Value.(string)
if clusterResource == "" {
return nil, fmt.Errorf("no AKS cluster id in terraform output")
}
clusterAddress := output["cluster_address"].Value.(string)
if clusterAddress == "" {
return nil, fmt.Errorf("no AKS cluster address in terraform output")
}
return map[string]string{
meta.KubeConfigKeyProvider: azure.ProviderName,
meta.KubeConfigKeyCluster: clusterResource,
meta.KubeConfigKeyAddress: clusterAddress,
}, nil
}
// getClusterUsersAzure returns the cluster users for kubeconfig auth tests.
func getClusterUsersAzure(output map[string]*tfjson.StateOutput) ([]string, error) {
clusterUser := output["workload_identity_object_id"].Value.(string)
if clusterUser == "" {
return nil, fmt.Errorf("no AKS cluster user id in terraform output")
}
return []string{clusterUser}, nil
}
// Give managed identity permissions on the azure devops project. Refer
// https://learn.microsoft.com/en-us/rest/api/azure/devops/memberentitlementmanagement/service-principal-entitlements/add?view=azure-devops-rest-7.1&tabs=HTTP.
// This can be moved to terraform if/when this PR completes -

Some files were not shown because too many files have changed in this diff Show More