Compare commits

...

34 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
34 changed files with 1000 additions and 157 deletions

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ jobs:
- github - github
steps: steps:
- name: Checkout - 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 # 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 # 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 working-directory: ./oci/tests/integration
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with: with:
@ -35,7 +35,7 @@ jobs:
- name: Setup Terraform - name: Setup Terraform
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
- name: configure aws credentials - 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: with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.OCI_E2E_AWS_ASSUME_ROLE_NAME }} role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.OCI_E2E_AWS_ASSUME_ROLE_NAME }}
role-session-name: OCI_GH_Actions role-session-name: OCI_GH_Actions

View File

@ -25,7 +25,7 @@ jobs:
working-directory: ./oci/tests/integration working-directory: ./oci/tests/integration
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with: with:

View File

@ -19,7 +19,7 @@ jobs:
working-directory: ./tools/reaper working-directory: ./tools/reaper
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
repository: fluxcd/test-infra repository: fluxcd/test-infra
- name: Setup Go - name: Setup Go
@ -35,7 +35,7 @@ jobs:
run: echo "GCRGC_VERSION=${GCRGC_VERSION}" >> $GITHUB_ENV run: echo "GCRGC_VERSION=${GCRGC_VERSION}" >> $GITHUB_ENV
- name: Cache gcrgc - name: Cache gcrgc
id: cache-gcrgc id: cache-gcrgc
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with: with:
path: ~/.local/bin/gcrgc path: ~/.local/bin/gcrgc
key: gcrgc-${{ env.GCRGC_VERSION }} 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 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/ mv gcrgc ~/.local/bin/
- name: Authenticate to Google Cloud - name: Authenticate to Google Cloud
uses: google-github-actions/auth@ba79af03959ebeac9769e648f473a284504d9193 # v2.1.10 uses: google-github-actions/auth@b7593ed2efd1c1617e1b0254da33b86225adb2a5 # v2.1.12
with: with:
credentials_json: '${{ secrets.CLEANUP_E2E_GOOGLE_CREDENTIALS }}' credentials_json: '${{ secrets.CLEANUP_E2E_GOOGLE_CREDENTIALS }}'
- name: Setup gcloud - 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 - name: Run gcrgc
# Cleanup all the GCR repositories in the project. They are not tracked # Cleanup all the GCR repositories in the project. They are not tracked
# by terraform used to provision test infra and are left behind. # by terraform used to provision test infra and are left behind.
@ -66,7 +66,7 @@ jobs:
if: true if: true
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
repository: fluxcd/test-infra repository: fluxcd/test-infra
- name: Setup Go - name: Setup Go
@ -89,7 +89,7 @@ jobs:
if: true if: true
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
repository: fluxcd/test-infra repository: fluxcd/test-infra
- name: Setup Go - name: Setup Go
@ -98,7 +98,7 @@ jobs:
go-version: 1.24.x go-version: 1.24.x
cache-dependency-path: ./tools/reaper/go.sum cache-dependency-path: ./tools/reaper/go.sum
- name: Authenticate to AWS - 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: with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.CLEANUP_E2E_AWS_ASSUME_ROLE_NAME }} role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.CLEANUP_E2E_AWS_ASSUME_ROLE_NAME }}
role-session-name: cleanup_GH_Actions role-session-name: cleanup_GH_Actions

View File

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

View File

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

View File

@ -10,7 +10,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Go - name: Setup Go

View File

@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Go - name: Setup Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with: with:
@ -28,13 +28,13 @@ jobs:
**/go.sum **/go.sum
**/go.mod **/go.mod
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 uses: github/codeql-action/init@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
with: with:
languages: go 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://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/ # xref: https://codeql.github.com/codeql-query-help/go/
queries: security-and-quality queries: security-and-quality
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 uses: github/codeql-action/autobuild@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 uses: github/codeql-action/analyze@76621b61decf072c1cee8dd1ce2d2a82d33c17ed # v3.29.8

View File

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

View File

@ -37,9 +37,10 @@ func NewCredentialsProvider(ctx context.Context, opts ...auth.Option) aws.Creden
// Retrieve implements aws.CredentialsProvider. // Retrieve implements aws.CredentialsProvider.
// The context is ignored, use the constructor to set the context. // The context is ignored, use the constructor to set the context.
// This is because some callers of the library pass context.Background() // This is because the GCP abstraction does not receive a context
// when calling this method (e.g. SOPS), so to ensure we have a real // in the method arguments, so we unfortunately need to standardize
// context we pass it in the constructor. // 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) { func (c *credentialsProvider) Retrieve(context.Context) (aws.Credentials, error) {
token, err := auth.GetAccessToken(c.ctx, Provider{}, c.opts...) token, err := auth.GetAccessToken(c.ctx, Provider{}, c.opts...)
if err != nil { if err != nil {

View File

@ -54,7 +54,9 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
var o auth.Options var o auth.Options
o.Apply(opts...) o.Apply(opts...)
var confOpts []func(*config.LoadOptions) error confOpts := []func(*config.LoadOptions) error{
config.WithHTTPClient(o.GetHTTPClient()),
}
stsRegion := o.STSRegion stsRegion := o.STSRegion
if stsRegion == "" { if stsRegion == "" {
@ -77,10 +79,6 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
confOpts = append(confOpts, config.WithBaseEndpoint(e)) confOpts = append(confOpts, config.WithBaseEndpoint(e))
} }
if hc := o.GetHTTPClient(); hc != nil {
confOpts = append(confOpts, config.WithHTTPClient(hc))
}
conf, err := p.impl().LoadDefaultConfig(ctx, confOpts...) conf, err := p.impl().LoadDefaultConfig(ctx, confOpts...)
if err != nil { if err != nil {
return nil, err return nil, err
@ -134,7 +132,8 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
roleSessionName := getRoleSessionName(serviceAccount, stsRegion) roleSessionName := getRoleSessionName(serviceAccount, stsRegion)
stsOpts := sts.Options{ stsOpts := sts.Options{
Region: stsRegion, Region: stsRegion,
HTTPClient: o.GetHTTPClient(),
} }
if e := o.STSEndpoint; e != "" { if e := o.STSEndpoint; e != "" {
@ -144,10 +143,6 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
stsOpts.BaseEndpoint = &e stsOpts.BaseEndpoint = &e
} }
if hc := o.GetHTTPClient(); hc != nil {
stsOpts.HTTPClient = hc
}
req := &sts.AssumeRoleWithWebIdentityInput{ req := &sts.AssumeRoleWithWebIdentityInput{
RoleArn: &roleARN, RoleArn: &roleARN,
RoleSessionName: &roleSessionName, RoleSessionName: &roleSessionName,
@ -243,10 +238,7 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registryIn
conf := aws.Config{ conf := aws.Config{
Region: getECRRegionFromRegistryInput(registryInput), Region: getECRRegionFromRegistryInput(registryInput),
Credentials: accessToken.(*Credentials).provider(), Credentials: accessToken.(*Credentials).provider(),
} HTTPClient: o.GetHTTPClient(),
if hc := o.GetHTTPClient(); hc != nil {
conf.HTTPClient = hc
} }
respAny, err := authTokenFunc(ctx, conf) respAny, err := authTokenFunc(ctx, conf)
@ -346,9 +338,7 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
eksOpts := eks.Options{ eksOpts := eks.Options{
Region: region, Region: region,
Credentials: creds, Credentials: creds,
} HTTPClient: hc,
if hc != nil {
eksOpts.HTTPClient = hc
} }
clusterResource, err := p.impl().DescribeCluster(ctx, describeInput, eksOpts) clusterResource, err := p.impl().DescribeCluster(ctx, describeInput, eksOpts)
if err != nil { if err != nil {
@ -391,6 +381,7 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
stsOpts := sts.Options{ stsOpts := sts.Options{
Region: region, Region: region,
Credentials: creds, Credentials: creds,
HTTPClient: hc,
} }
if e := o.STSEndpoint; e != "" { if e := o.STSEndpoint; e != "" {
if err := ValidateSTSEndpoint(e); err != nil { if err := ValidateSTSEndpoint(e); err != nil {
@ -398,9 +389,6 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
} }
stsOpts.BaseEndpoint = &e stsOpts.BaseEndpoint = &e
} }
if hc != nil {
stsOpts.HTTPClient = hc
}
presignedReq, err := p.impl().PresignGetCallerIdentity(ctx, presignOpts, stsOpts) presignedReq, err := p.impl().PresignGetCallerIdentity(ctx, presignOpts, stsOpts)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to presign GetCallerIdentity request: %w", err) return nil, fmt.Errorf("failed to presign GetCallerIdentity request: %w", err)

View File

@ -17,9 +17,12 @@ limitations under the License.
package azure package azure
import ( import (
"encoding/json"
"fmt" "fmt"
"os"
"regexp" "regexp"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
) )
@ -66,3 +69,80 @@ func parseCluster(cluster string) (string, string, string, error) {
clusterName := m[3] clusterName := m[3]
return subscriptionID, resourceGroup, clusterName, nil return subscriptionID, resourceGroup, clusterName, nil
} }
// envVarAzureEnvironmentFilepath is the environment variable name used to specify the path of the configuration file with custom Azure endpoints.
const envVarAzureEnvironmentFilepath = "AZURE_ENVIRONMENT_FILEPATH"
// 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 {
return nil, err
}
env := &Environment{}
if err = json.Unmarshal(content, env); err != nil {
return nil, err
}
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

@ -23,6 +23,7 @@ import (
"regexp" "regexp"
"strings" "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/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "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/azcore/policy"
@ -54,10 +55,10 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
var o auth.Options var o auth.Options
o.Apply(opts...) o.Apply(opts...)
var azOpts azidentity.DefaultAzureCredentialOptions azOpts := azidentity.DefaultAzureCredentialOptions{
ClientOptions: azcore.ClientOptions{
if hc := o.GetHTTPClient(); hc != nil { Transport: o.GetHTTPClient(),
azOpts.Transport = hc },
} }
credFunc := p.impl().NewDefaultAzureCredentialWithoutShellOut credFunc := p.impl().NewDefaultAzureCredentialWithoutShellOut
@ -102,10 +103,10 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
s := strings.Split(identity, "/") s := strings.Split(identity, "/")
tenantID, clientID := s[0], s[1] tenantID, clientID := s[0], s[1]
azOpts := &azidentity.ClientAssertionCredentialOptions{} azOpts := &azidentity.ClientAssertionCredentialOptions{
ClientOptions: azcore.ClientOptions{
if hc := o.GetHTTPClient(); hc != nil { Transport: o.GetHTTPClient(),
azOpts.Transport = hc },
} }
cred, err := p.impl().NewClientAssertionCredential(tenantID, clientID, func(context.Context) (string, error) { cred, err := p.impl().NewClientAssertionCredential(tenantID, clientID, func(context.Context) (string, error) {
@ -136,6 +137,12 @@ func (p Provider) GetAccessTokenOptionsForArtifactRepository(artifactRepository
var conf *cloud.Configuration var conf *cloud.Configuration
switch { switch {
case hasEnvironmentFile():
var err error
conf, err = getCloudConfigFromEnvironment()
if err != nil {
return nil, err
}
case strings.HasSuffix(registry, ".azurecr.cn"): case strings.HasSuffix(registry, ".azurecr.cn"):
conf = &cloud.AzureChina conf = &cloud.AzureChina
case strings.HasSuffix(registry, ".azurecr.us"): case strings.HasSuffix(registry, ".azurecr.us"):
@ -161,13 +168,27 @@ func (Provider) ParseArtifactRepository(artifactRepository string) (string, erro
return "", err return "", err
} }
if !registryRegex.MatchString(registry) { // For issuing Azure registry credentials the registry host is required.
return "", fmt.Errorf("invalid Azure registry: '%s'. must match %s", if registryRegex.MatchString(registry) {
registry, registryPattern) return registry, nil
} }
// For issuing Azure registry credentials the registry host is required. // Check if environment variable is configured for container registry suffix
return registry, nil 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. // NewArtifactRegistryCredentials implements auth.Provider.
@ -179,9 +200,10 @@ func (p Provider) NewArtifactRegistryCredentials(ctx context.Context, registry s
// Create the ACR authentication client. // Create the ACR authentication client.
endpoint := fmt.Sprintf("https://%s", registry) endpoint := fmt.Sprintf("https://%s", registry)
var clientOpts azcontainerregistry.AuthenticationClientOptions clientOpts := azcontainerregistry.AuthenticationClientOptions{
if hc := o.GetHTTPClient(); hc != nil { ClientOptions: azcore.ClientOptions{
clientOpts.Transport = hc Transport: o.GetHTTPClient(),
},
} }
client, err := azcontainerregistry.NewAuthenticationClient(endpoint, &clientOpts) client, err := azcontainerregistry.NewAuthenticationClient(endpoint, &clientOpts)
if err != nil { if err != nil {
@ -237,6 +259,12 @@ func (Provider) GetAccessTokenOptionsForCluster(opts ...auth.Option) ([][]auth.O
if o.ClusterAddress == "" || o.CAData == "" { if o.ClusterAddress == "" || o.CAData == "" {
conf := &cloud.AzurePublic conf := &cloud.AzurePublic
switch authorityHost := os.Getenv("AZURE_AUTHORITY_HOST"); { 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"): case strings.Contains(authorityHost, "chinacloudapi.cn"):
conf = &cloud.AzureChina conf = &cloud.AzureChina
case strings.Contains(authorityHost, "microsoftonline.us"): case strings.Contains(authorityHost, "microsoftonline.us"):
@ -275,9 +303,10 @@ func (p Provider) NewRESTConfig(ctx context.Context, accessTokens []auth.Token,
} }
// Create client for describing the cluster resource. // Create client for describing the cluster resource.
var clientOpts arm.ClientOptions clientOpts := arm.ClientOptions{
if hc := o.GetHTTPClient(); hc != nil { ClientOptions: azcore.ClientOptions{
clientOpts.Transport = hc Transport: o.GetHTTPClient(),
},
} }
client, err := p.impl().NewManagedClustersClient( client, err := p.impl().NewManagedClustersClient(
subscriptionID, armToken.credential(), &clientOpts) subscriptionID, armToken.credential(), &clientOpts)

View File

@ -22,6 +22,7 @@ import (
"crypto/rsa" "crypto/rsa"
"fmt" "fmt"
"net/url" "net/url"
"os"
"testing" "testing"
"time" "time"
@ -236,9 +237,10 @@ func TestProvider_NewArtifactRegistryCredentials(t *testing.T) {
func TestProvider_ParseArtifactRegistry(t *testing.T) { func TestProvider_ParseArtifactRegistry(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
artifactRepository string artifactRepository string
expectedRegistryURL string expectedRegistryURL string
expectValid bool containerRegistryDNSSuffix string
expectValid bool
}{ }{
{ {
artifactRepository: "foo.azurecr.io/repo", artifactRepository: "foo.azurecr.io/repo",
@ -272,10 +274,32 @@ func TestProvider_ParseArtifactRegistry(t *testing.T) {
artifactRepository: "012345678901.dkr.ecr.us-east-1.amazonaws.com", artifactRepository: "012345678901.dkr.ecr.us-east-1.amazonaws.com",
expectValid: false, 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) { t.Run(tt.artifactRepository, func(t *testing.T) {
g := NewWithT(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) registryURL, err := azure.Provider{}.ParseArtifactRepository(tt.artifactRepository)
if tt.expectValid { if tt.expectValid {
@ -289,6 +313,67 @@ 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) { func TestProvider_NewRESTConfig(t *testing.T) {
for _, tt := range []struct { for _, tt := range []struct {
name string name string
@ -543,6 +628,31 @@ func TestProvider_GetAccessTokenOptionsForCluster(t *testing.T) {
g.Expect(armOptions.Scopes).To(Equal([]string{"https://management.core.windows.net//.default"})) 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) { t.Run("no need to fetch cluster", func(t *testing.T) {
opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster( opts, err := azure.Provider{}.GetAccessTokenOptionsForCluster(
auth.WithClusterAddress("https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"), auth.WithClusterAddress("https://test-cluster-12345678.hcp.eastus.azmk8s.io:443"),
@ -580,3 +690,21 @@ users:
env: null env: null
`, serverURL, clusterName, clusterName, clusterName, clusterName, clusterName, clusterName)) `, 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

@ -38,9 +38,10 @@ func NewTokenCredential(ctx context.Context, opts ...auth.Option) azcore.TokenCr
// GetToken implements exported.TokenCredential. // GetToken implements exported.TokenCredential.
// The context is ignored, use the constructor to set the context. // The context is ignored, use the constructor to set the context.
// This is because some callers of the library pass context.Background() // This is because the GCP abstraction does not receive a context
// when calling this method (e.g. SOPS), so to ensure we have a real // in the method arguments, so we unfortunately need to standardize
// context we pass it in the constructor. // 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) { func (t *tokenCredential) GetToken(_ context.Context, tokenOpts policy.TokenRequestOptions) (azcore.AccessToken, error) {
opts := t.opts opts := t.opts
if tokenOpts.Scopes != nil { if tokenOpts.Scopes != nil {

View File

@ -55,9 +55,7 @@ func (p Provider) NewControllerToken(ctx context.Context, opts ...auth.Option) (
var o auth.Options var o auth.Options
o.Apply(opts...) o.Apply(opts...)
if hc := o.GetHTTPClient(); hc != nil { ctx = context.WithValue(ctx, oauth2.HTTPClient, o.GetHTTPClient())
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
}
src, err := p.impl().DefaultTokenSource(ctx, scopes...) src, err := p.impl().DefaultTokenSource(ctx, scopes...)
if err != nil { if err != nil {
@ -145,9 +143,7 @@ func (p Provider) NewTokenForServiceAccount(ctx context.Context, oidcToken strin
conf.TokenInfoURL = "https://sts.googleapis.com/v1/introspect" conf.TokenInfoURL = "https://sts.googleapis.com/v1/introspect"
} }
if hc := o.GetHTTPClient(); hc != nil { ctx = context.WithValue(ctx, oauth2.HTTPClient, o.GetHTTPClient())
ctx = context.WithValue(ctx, oauth2.HTTPClient, hc)
}
src, err := p.impl().NewTokenSource(ctx, conf) src, err := p.impl().NewTokenSource(ctx, conf)
if err != nil { if err != nil {

View File

@ -19,6 +19,7 @@ package auth
import ( import (
"net/http" "net/http"
"net/url" "net/url"
"time"
"sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client"
@ -152,14 +153,17 @@ func (o *Options) Apply(opts ...Option) {
} }
} }
// GetHTTPClient returns a *http.Client with the configured proxy URL // GetHTTPClient returns a *http.Client with appropriate timeouts and proxy settings.
// or nil if no proxy URL is set. // The client includes a 10-second timeout to prevent indefinite hangs during token acquisition.
func (o *Options) GetHTTPClient() *http.Client { func (o *Options) GetHTTPClient() *http.Client {
if o.ProxyURL == nil { transport := http.DefaultTransport.(*http.Transport).Clone()
return nil
if o.ProxyURL != nil {
transport.Proxy = http.ProxyURL(o.ProxyURL)
} }
transport := http.DefaultTransport.(*http.Transport).Clone() return &http.Client{
transport.Proxy = http.ProxyURL(o.ProxyURL) 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

@ -19,6 +19,7 @@ package github
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"crypto/tls"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@ -54,6 +55,7 @@ type Client struct {
name string name string
namespace string namespace string
operation string operation string
tlsConfig *tls.Config
} }
// OptFunc enables specifying options for the provider. // OptFunc enables specifying options for the provider.
@ -67,6 +69,9 @@ func New(opts ...OptFunc) (*Client, error) {
} }
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
if p.tlsConfig != nil {
transport.TLSClientConfig = p.tlsConfig
}
if p.proxyURL != nil { if p.proxyURL != nil {
proxyStr := p.proxyURL.String() proxyStr := p.proxyURL.String()
proxyConfig := &httpproxy.Config{ proxyConfig := &httpproxy.Config{
@ -111,6 +116,13 @@ func New(opts ...OptFunc) (*Client, error) {
return p, nil return p, nil
} }
// WithTLSConfig sets the tls config to use with the transport.
func WithTLSConfig(tlsConfig *tls.Config) OptFunc {
return func(p *Client) {
p.tlsConfig = tlsConfig
}
}
// WithAppData configures the client using data from a map // WithAppData configures the client using data from a map
func WithAppData(appData map[string][]byte) OptFunc { func WithAppData(appData map[string][]byte) OptFunc {
return func(p *Client) { return func(p *Client) {

View File

@ -18,6 +18,8 @@ package github
import ( import (
"context" "context"
"crypto/tls"
"crypto/x509"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@ -257,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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5
github.com/elazarl/goproxy v1.7.2 github.com/elazarl/goproxy v1.7.2
github.com/fluxcd/gitkit v0.6.0 github.com/fluxcd/gitkit v0.6.0
github.com/fluxcd/pkg/git v0.34.0 github.com/fluxcd/pkg/git v0.35.0
github.com/fluxcd/pkg/gittestserver v0.18.0 github.com/fluxcd/pkg/gittestserver v0.18.0
github.com/fluxcd/pkg/ssh v0.20.0 github.com/fluxcd/pkg/ssh v0.20.0
github.com/fluxcd/pkg/version v0.9.0 github.com/fluxcd/pkg/version v0.9.0

View File

@ -13,8 +13,8 @@ replace (
require ( require (
github.com/fluxcd/go-git-providers v0.22.0 github.com/fluxcd/go-git-providers v0.22.0
github.com/fluxcd/pkg/git v0.34.0 github.com/fluxcd/pkg/git v0.35.0
github.com/fluxcd/pkg/git/gogit v0.37.0 github.com/fluxcd/pkg/git/gogit v0.38.0
github.com/fluxcd/pkg/gittestserver v0.18.0 github.com/fluxcd/pkg/gittestserver v0.18.0
github.com/fluxcd/pkg/ssh v0.20.0 github.com/fluxcd/pkg/ssh v0.20.0
github.com/go-git/go-git/v5 v5.16.2 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

@ -18,11 +18,11 @@ require (
github.com/elazarl/goproxy v1.7.2 github.com/elazarl/goproxy v1.7.2
github.com/fluxcd/cli-utils v0.36.0-flux.14 github.com/fluxcd/cli-utils v0.36.0-flux.14
github.com/fluxcd/pkg/apis/meta v1.18.0 github.com/fluxcd/pkg/apis/meta v1.18.0
github.com/fluxcd/pkg/auth v0.23.0 github.com/fluxcd/pkg/auth v0.24.0
github.com/fluxcd/pkg/cache v0.10.0 github.com/fluxcd/pkg/cache v0.10.0
github.com/fluxcd/pkg/git v0.34.0 github.com/fluxcd/pkg/git v0.35.0
github.com/fluxcd/pkg/git/gogit v0.37.0 github.com/fluxcd/pkg/git/gogit v0.38.0
github.com/fluxcd/pkg/runtime v0.74.0 github.com/fluxcd/pkg/runtime v0.80.0
github.com/fluxcd/test-infra/tftestenv v0.0.0-20250626232827-e0ca9c3f8d7b github.com/fluxcd/test-infra/tftestenv v0.0.0-20250626232827-e0ca9c3f8d7b
github.com/go-git/go-git/v5 v5.16.2 github.com/go-git/go-git/v5 v5.16.2
github.com/google/go-containerregistry v0.20.6 github.com/google/go-containerregistry v0.20.6

View File

@ -30,6 +30,16 @@ import (
"sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log"
) )
// TLSConfigOption is a functional option for configuring TLS behavior.
type TLSConfigOption func(*tlsConfig)
// WithSystemCertPool enables the use of system certificate pool in addition to user-provided CA certificates.
func WithSystemCertPool() TLSConfigOption {
return func(c *tlsConfig) {
c.useSystemCertPool = true
}
}
// AuthMethodsFromSecret extracts all available authentication methods from a Kubernetes secret. // AuthMethodsFromSecret extracts all available authentication methods from a Kubernetes secret.
// //
// The function attempts to parse all supported authentication methods from the secret data. // The function attempts to parse all supported authentication methods from the secret data.
@ -41,11 +51,19 @@ import (
// - Bearer token authentication // - Bearer token authentication
// - Token authentication // - Token authentication
// - SSH authentication (private key, known hosts) // - SSH authentication (private key, known hosts)
// - GitHub App authentication (app ID, installation ID, private key)
// - TLS client certificates // - TLS client certificates
// //
// Multiple authentication methods can be present in a single secret and will be extracted // Multiple authentication methods can be present in a single secret and will be extracted
// simultaneously, enabling use cases like Basic Auth + TLS or Bearer Token + TLS. // simultaneously, enabling use cases like Basic Auth + TLS or Bearer Token + TLS.
func AuthMethodsFromSecret(ctx context.Context, secret *corev1.Secret) (*AuthMethods, error) { //
// Options can be provided to configure TLS extraction behavior.
func AuthMethodsFromSecret(ctx context.Context, secret *corev1.Secret, opts ...AuthMethodsOption) (*AuthMethods, error) {
config := &authMethodsConfig{}
for _, opt := range opts {
opt(config)
}
var methods AuthMethods var methods AuthMethods
if err := trySetAuth(ctx, secret, &methods.Basic, BasicAuthFromSecret); err != nil { if err := trySetAuth(ctx, secret, &methods.Basic, BasicAuthFromSecret); err != nil {
@ -64,7 +82,13 @@ func AuthMethodsFromSecret(ctx context.Context, secret *corev1.Secret) (*AuthMet
return nil, err return nil, err
} }
if err := trySetAuth(ctx, secret, &methods.TLS, TLSConfigFromSecret); err != nil { if err := trySetAuth(ctx, secret, &methods.GitHubAppData, GitHubAppDataFromSecret); err != nil {
return nil, err
}
if err := trySetAuth(ctx, secret, &methods.TLS, func(ctx context.Context, secret *corev1.Secret) (*tls.Config, error) {
return TLSConfigFromSecret(ctx, secret, config.tlsConfigOpts...)
}); err != nil {
return nil, err return nil, err
} }
@ -78,7 +102,15 @@ func AuthMethodsFromSecret(ctx context.Context, secret *corev1.Secret) (*AuthMet
// (certFile, keyFile, caFile) as fallbacks, logging warnings when they are used. // (certFile, keyFile, caFile) as fallbacks, logging warnings when they are used.
// //
// Standard field names always take precedence over legacy ones. // Standard field names always take precedence over legacy ones.
func TLSConfigFromSecret(ctx context.Context, secret *corev1.Secret) (*tls.Config, error) { //
// Optional TLSConfigOption parameters can be used to configure CA certificate handling:
// - WithSystemCertPool(): Include system certificates in addition to user-provided CA
func TLSConfigFromSecret(ctx context.Context, secret *corev1.Secret, opts ...TLSConfigOption) (*tls.Config, error) {
config := &tlsConfig{}
for _, opt := range opts {
opt(config)
}
logger := log.FromContext(ctx) logger := log.FromContext(ctx)
certData, err := getTLSCertificateData(secret, logger) certData, err := getTLSCertificateData(secret, logger)
if err != nil { if err != nil {
@ -92,7 +124,7 @@ func TLSConfigFromSecret(ctx context.Context, secret *corev1.Secret) (*tls.Confi
return nil, err return nil, err
} }
return buildTLSConfig(certData) return buildTLSConfig(certData, config)
} }
// ProxyURLFromSecret creates a proxy URL from a Kubernetes secret. // ProxyURLFromSecret creates a proxy URL from a Kubernetes secret.
@ -226,12 +258,56 @@ func SSHAuthFromSecret(ctx context.Context, secret *corev1.Secret) (*SSHAuth, er
return auth, nil return auth, nil
} }
// GitHubAppDataFromSecret retrieves GitHub App authentication data from a Kubernetes secret.
//
// The function expects the secret to contain "githubAppID", "githubAppInstallationID", and
// "githubAppPrivateKey" fields. All three fields are required and the function will return
// an error if any is missing. Optional "githubAppBaseURL" field can be present for GitHub
// Enterprise Server instances.
func GitHubAppDataFromSecret(ctx context.Context, secret *corev1.Secret) (GitHubAppData, error) {
_, hasAppID := secret.Data[KeyGitHubAppID]
_, hasInstallationID := secret.Data[KeyGitHubAppInstallationID]
_, hasPrivateKey := secret.Data[KeyGitHubAppPrivateKey]
// Complete absence - return KeyNotFoundError (will be ignored by trySetAuth)
if !hasAppID && !hasInstallationID && !hasPrivateKey {
return nil, &KeyNotFoundError{Key: KeyGitHubAppID, Secret: secret}
}
// Check for required fields - partial presence is an error
if !hasAppID {
return nil, &KeyNotFoundError{Key: KeyGitHubAppID, Secret: secret}
}
if !hasInstallationID {
return nil, &KeyNotFoundError{Key: KeyGitHubAppInstallationID, Secret: secret}
}
if !hasPrivateKey {
return nil, &KeyNotFoundError{Key: KeyGitHubAppPrivateKey, Secret: secret}
}
data := GitHubAppData{
KeyGitHubAppID: secret.Data[KeyGitHubAppID],
KeyGitHubAppInstallationID: secret.Data[KeyGitHubAppInstallationID],
KeyGitHubAppPrivateKey: secret.Data[KeyGitHubAppPrivateKey],
}
if baseURLData, exists := secret.Data[KeyGitHubAppBaseURL]; exists {
data[KeyGitHubAppBaseURL] = baseURLData
}
return data, nil
}
func getTLSCertificateData(secret *corev1.Secret, logger logr.Logger) (*tlsCertificateData, error) { func getTLSCertificateData(secret *corev1.Secret, logger logr.Logger) (*tlsCertificateData, error) {
return newTLSCertificateData(secret, logger) return newTLSCertificateData(secret, logger)
} }
func buildTLSConfig(certData *tlsCertificateData) (*tls.Config, error) { func buildTLSConfig(certData *tlsCertificateData, config *tlsConfig) (*tls.Config, error) {
tlsConfig := &tls.Config{} tlsConfig := &tls.Config{
// Note: InsecureSkipVerify is explicitly set to false in accordance with Flux security policy.
// TLS certificates must be validated using CA certificates or the system trust store.
InsecureSkipVerify: false,
}
if certData.hasCertPair() { if certData.hasCertPair() {
cert, err := tls.X509KeyPair(certData.cert, certData.key) cert, err := tls.X509KeyPair(certData.cert, certData.key)
@ -242,7 +318,17 @@ func buildTLSConfig(certData *tlsCertificateData) (*tls.Config, error) {
} }
if certData.hasCA() { if certData.hasCA() {
caCertPool := x509.NewCertPool() var caCertPool *x509.CertPool
if config.useSystemCertPool {
var err error
caCertPool, err = x509.SystemCertPool()
if err != nil {
return nil, fmt.Errorf("cannot retrieve system certificate pool: %w", err)
}
} else {
caCertPool = x509.NewCertPool()
}
if !caCertPool.AppendCertsFromPEM(certData.caCert) { if !caCertPool.AppendCertsFromPEM(certData.caCert) {
return nil, fmt.Errorf("failed to parse CA certificate") return nil, fmt.Errorf("failed to parse CA certificate")
} }

View File

@ -38,23 +38,20 @@ func TestAuthMethodsFromSecret(t *testing.T) {
validCACert, _, _ := generateTestCertificates(t) validCACert, _, _ := generateTestCertificates(t)
tests := []struct { tests := []struct {
name string name string
secretData map[string][]byte secretData map[string][]byte
wantBasic bool opt []secrets.AuthMethodsOption
wantBearer bool wantBasic bool
wantToken bool wantBearer bool
wantSSH bool wantToken bool
wantTLS bool wantSSH bool
wantErr error wantGitHubApp bool
wantTLS bool
wantErr error
}{ }{
{ {
name: "empty secret", name: "empty secret",
secretData: map[string][]byte{}, secretData: map[string][]byte{},
wantBasic: false,
wantBearer: false,
wantToken: false,
wantSSH: false,
wantTLS: false,
}, },
{ {
name: "basic auth only", name: "basic auth only",
@ -154,19 +151,43 @@ func TestAuthMethodsFromSecret(t *testing.T) {
{ {
name: "all authentication methods", name: "all authentication methods",
secretData: map[string][]byte{ secretData: map[string][]byte{
secrets.KeyUsername: []byte("testuser"), secrets.KeyUsername: []byte("testuser"),
secrets.KeyPassword: []byte("testpass"), secrets.KeyPassword: []byte("testpass"),
secrets.KeyBearerToken: []byte("token123"), secrets.KeyBearerToken: []byte("token123"),
secrets.KeyToken: []byte("api-token-123"), secrets.KeyToken: []byte("api-token-123"),
secrets.KeySSHPrivateKey: []byte(sshPrivateKey), secrets.KeySSHPrivateKey: []byte(sshPrivateKey),
secrets.KeySSHKnownHosts: []byte(sshKnownHosts), secrets.KeySSHKnownHosts: []byte(sshKnownHosts),
secrets.KeyCACert: validCACert, secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
secrets.KeyCACert: validCACert,
}, },
wantBasic: true, wantBasic: true,
wantBearer: true, wantBearer: true,
wantToken: true, wantToken: true,
wantSSH: true, wantSSH: true,
wantTLS: true, wantGitHubApp: true,
wantTLS: true,
},
{
name: "GitHub App only",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
},
wantGitHubApp: true,
},
{
name: "GitHub App + CA (source-controller PR scenario)",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
secrets.KeyCACert: validCACert,
},
wantGitHubApp: true,
wantTLS: true,
}, },
{ {
name: "malformed SSH auth with valid TLS", name: "malformed SSH auth with valid TLS",
@ -201,6 +222,24 @@ func TestAuthMethodsFromSecret(t *testing.T) {
}, },
wantErr: fmt.Errorf("secret 'test-namespace/test-secret': malformed basic auth - has 'username' but missing 'password'"), wantErr: fmt.Errorf("secret 'test-namespace/test-secret': malformed basic auth - has 'username' but missing 'password'"),
}, },
{
name: "TLS cert present, with all options",
secretData: map[string][]byte{
secrets.KeyCACert: validCACert,
},
opt: []secrets.AuthMethodsOption{secrets.WithTLSSystemCertPool()},
wantTLS: true,
},
{
name: "no TLS cert, with all options",
secretData: map[string][]byte{
secrets.KeyUsername: []byte("testuser"),
secrets.KeyPassword: []byte("testpass"),
},
opt: []secrets.AuthMethodsOption{secrets.WithTLSSystemCertPool()},
wantBasic: true,
wantTLS: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -216,7 +255,7 @@ func TestAuthMethodsFromSecret(t *testing.T) {
Data: tt.secretData, Data: tt.secretData,
} }
result, err := secrets.AuthMethodsFromSecret(ctx, secret) result, err := secrets.AuthMethodsFromSecret(ctx, secret, tt.opt...)
if tt.wantErr != nil { if tt.wantErr != nil {
g.Expect(err).To(HaveOccurred()) g.Expect(err).To(HaveOccurred())
@ -232,6 +271,7 @@ func TestAuthMethodsFromSecret(t *testing.T) {
g.Expect(result.HasBearerAuth()).To(Equal(tt.wantBearer)) g.Expect(result.HasBearerAuth()).To(Equal(tt.wantBearer))
g.Expect(result.HasTokenAuth()).To(Equal(tt.wantToken)) g.Expect(result.HasTokenAuth()).To(Equal(tt.wantToken))
g.Expect(result.HasSSH()).To(Equal(tt.wantSSH)) g.Expect(result.HasSSH()).To(Equal(tt.wantSSH))
g.Expect(result.HasGitHubAppData()).To(Equal(tt.wantGitHubApp))
g.Expect(result.HasTLS()).To(Equal(tt.wantTLS)) g.Expect(result.HasTLS()).To(Equal(tt.wantTLS))
if tt.wantBasic { if tt.wantBasic {
@ -256,11 +296,143 @@ func TestAuthMethodsFromSecret(t *testing.T) {
g.Expect(result.SSH.KnownHosts).ToNot(BeEmpty()) g.Expect(result.SSH.KnownHosts).ToNot(BeEmpty())
} }
if tt.wantTLS { if tt.wantGitHubApp {
g.Expect(result.TLS).ToNot(BeNil()) g.Expect(result.GitHubAppData).ToNot(BeNil())
g.Expect(result.TLS.RootCAs).ToNot(BeNil()) g.Expect(result.GitHubAppData).To(HaveKey(secrets.KeyGitHubAppID))
g.Expect(result.GitHubAppData).To(HaveKey(secrets.KeyGitHubAppInstallationID))
g.Expect(result.GitHubAppData).To(HaveKey(secrets.KeyGitHubAppPrivateKey))
} }
if tt.wantTLS {
g.Expect(result.TLS).ToNot(BeNil())
}
})
}
}
func TestGitHubAppDataFromSecret(t *testing.T) {
t.Parallel()
tests := []struct {
name string
secretData map[string][]byte
wantData map[string][]byte
errMsg string
}{
{
name: "valid GitHub App data with all fields",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
secrets.KeyGitHubAppBaseURL: []byte("https://github.example.com"),
},
wantData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
secrets.KeyGitHubAppBaseURL: []byte("https://github.example.com"),
},
},
{
name: "valid GitHub App data with required fields only",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
},
wantData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
},
},
{
name: "missing app ID",
secretData: map[string][]byte{
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
},
errMsg: `secret 'default/github-app-secret': key 'githubAppID' not found`,
},
{
name: "missing installation ID",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppPrivateKey: []byte("test-private-key"),
},
errMsg: `secret 'default/github-app-secret': key 'githubAppInstallationID' not found`,
},
{
name: "missing private key",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
},
errMsg: `secret 'default/github-app-secret': key 'githubAppPrivateKey' not found`,
},
{
name: "completely empty secret",
secretData: map[string][]byte{},
errMsg: `secret 'default/github-app-secret': key 'githubAppID' not found`,
},
{
name: "partial GitHub App data - only app ID",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
},
errMsg: `secret 'default/github-app-secret': key 'githubAppInstallationID' not found`,
},
{
name: "partial GitHub App data - app ID and installation ID only",
secretData: map[string][]byte{
secrets.KeyGitHubAppID: []byte("123456"),
secrets.KeyGitHubAppInstallationID: []byte("7890123"),
},
errMsg: `secret 'default/github-app-secret': key 'githubAppPrivateKey' not found`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
g := NewWithT(t)
ctx := context.Background()
secret := testSecret(
withName("github-app-secret"),
withData(tt.secretData),
)
result, err := secrets.GitHubAppDataFromSecret(ctx, secret)
if tt.errMsg != "" {
g.Expect(err).To(MatchError(ContainSubstring(tt.errMsg)))
g.Expect(result).To(BeNil())
} else {
g.Expect(err).ToNot(HaveOccurred())
g.Expect(result).ToNot(BeNil())
g.Expect(result).To(Equal(tt.wantData))
// Verify required fields are present
g.Expect(result).To(HaveKey(secrets.KeyGitHubAppID))
g.Expect(result).To(HaveKey(secrets.KeyGitHubAppInstallationID))
g.Expect(result).To(HaveKey(secrets.KeyGitHubAppPrivateKey))
// Verify content
g.Expect(string(result[secrets.KeyGitHubAppID])).To(Equal("123456"))
g.Expect(string(result[secrets.KeyGitHubAppInstallationID])).To(Equal("7890123"))
g.Expect(string(result[secrets.KeyGitHubAppPrivateKey])).To(Equal("test-private-key"))
// BaseURL should only be present if it was in the input
if tt.wantData[secrets.KeyGitHubAppBaseURL] != nil {
g.Expect(result).To(HaveKey(secrets.KeyGitHubAppBaseURL))
g.Expect(string(result[secrets.KeyGitHubAppBaseURL])).To(Equal("https://github.example.com"))
} else {
g.Expect(result).ToNot(HaveKey(secrets.KeyGitHubAppBaseURL))
}
}
}) })
} }
} }
@ -273,6 +445,7 @@ func TestTLSConfigFromSecret(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
secret *corev1.Secret secret *corev1.Secret
opts []secrets.TLSConfigOption
errMsg string errMsg string
expectedFields map[string]string // legacy key -> preferred key mapping expectedFields map[string]string // legacy key -> preferred key mapping
}{ }{
@ -401,6 +574,16 @@ func TestTLSConfigFromSecret(t *testing.T) {
), ),
errMsg: "secret 'default/tls-secret' must contain either 'ca.crt' or both 'tls.crt' and 'tls.key'", errMsg: "secret 'default/tls-secret' must contain either 'ca.crt' or both 'tls.crt' and 'tls.key'",
}, },
{
name: "WithSystemCertPool option",
secret: testSecret(
withName("tls-secret"),
withData(map[string][]byte{
secrets.KeyCACert: caCert,
}),
),
opts: []secrets.TLSConfigOption{secrets.WithSystemCertPool()},
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -423,7 +606,7 @@ func TestTLSConfigFromSecret(t *testing.T) {
ctx = log.IntoContext(ctx, logr.Discard()) ctx = log.IntoContext(ctx, logr.Discard())
} }
tlsConfig, err := secrets.TLSConfigFromSecret(ctx, tt.secret) tlsConfig, err := secrets.TLSConfigFromSecret(ctx, tt.secret, tt.opts...)
if tt.errMsg != "" { if tt.errMsg != "" {
g.Expect(err).To(MatchError(ContainSubstring(tt.errMsg))) g.Expect(err).To(MatchError(ContainSubstring(tt.errMsg)))
@ -431,6 +614,12 @@ func TestTLSConfigFromSecret(t *testing.T) {
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(tlsConfig).ToNot(BeNil()) g.Expect(tlsConfig).ToNot(BeNil())
// ServerName should be empty to allow automatic hostname verification
g.Expect(tlsConfig.ServerName).To(BeEmpty())
// InsecureSkipVerify must always be false per Flux security policy.
// The insecure parameter was removed to prevent bypassing certificate validation.
g.Expect(tlsConfig.InsecureSkipVerify).To(BeFalse())
hasCert := len(tt.secret.Data[secrets.KeyTLSCert]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSCert]) > 0 hasCert := len(tt.secret.Data[secrets.KeyTLSCert]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSCert]) > 0
hasKey := len(tt.secret.Data[secrets.KeyTLSPrivateKey]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSPrivateKey]) > 0 hasKey := len(tt.secret.Data[secrets.KeyTLSPrivateKey]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSPrivateKey]) > 0
hasCertPair := hasCert && hasKey hasCertPair := hasCert && hasKey

View File

@ -32,12 +32,15 @@ import (
// //
// The function fetches the secret from the API server and then processes it using // The function fetches the secret from the API server and then processes it using
// TLSConfigFromSecret. It supports the same field names and legacy field handling. // TLSConfigFromSecret. It supports the same field names and legacy field handling.
func TLSConfigFromSecretRef(ctx context.Context, c client.Client, secretRef types.NamespacedName) (*tls.Config, error) { //
// Optional TLSConfigOption parameters can be used to configure CA certificate handling:
// - WithSystemCertPool(): Include system certificates in addition to user-provided CA
func TLSConfigFromSecretRef(ctx context.Context, c client.Client, secretRef types.NamespacedName, opts ...TLSConfigOption) (*tls.Config, error) {
secret, err := getSecret(ctx, c, secretRef) secret, err := getSecret(ctx, c, secretRef)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return TLSConfigFromSecret(ctx, secret) return TLSConfigFromSecret(ctx, secret, opts...)
} }
// ProxyURLFromSecretRef creates a proxy URL from a Kubernetes secret reference. // ProxyURLFromSecretRef creates a proxy URL from a Kubernetes secret reference.
@ -52,6 +55,18 @@ func ProxyURLFromSecretRef(ctx context.Context, c client.Client, secretRef types
return ProxyURLFromSecret(ctx, secret) return ProxyURLFromSecret(ctx, secret)
} }
// GitHubAppDataFromSecretRef retrieves GitHub App authentication data from a Kubernetes secret reference.
//
// The function fetches the secret from the API server and then processes it using
// GitHubAppDataFromSecret. It expects the same field structure for GitHub App configuration.
func GitHubAppDataFromSecretRef(ctx context.Context, c client.Client, secretRef types.NamespacedName) (GitHubAppData, error) {
secret, err := getSecret(ctx, c, secretRef)
if err != nil {
return nil, err
}
return GitHubAppDataFromSecret(ctx, secret)
}
// PullSecretsFromServiceAccountRef retrieves all image pull secrets referenced by a service account. // PullSecretsFromServiceAccountRef retrieves all image pull secrets referenced by a service account.
// //
// The function resolves all secrets listed in the service account's imagePullSecrets field // The function resolves all secrets listed in the service account's imagePullSecrets field

View File

@ -41,6 +41,7 @@ func TestTLSConfigFromSecretRef(t *testing.T) {
name string name string
secretRef types.NamespacedName secretRef types.NamespacedName
secret *corev1.Secret // Secret to add to fake client (nil = not added) secret *corev1.Secret // Secret to add to fake client (nil = not added)
opts []secrets.TLSConfigOption
errMsg string errMsg string
}{ }{
{ {
@ -60,6 +61,17 @@ func TestTLSConfigFromSecretRef(t *testing.T) {
secretRef: types.NamespacedName{Name: "missing-secret", Namespace: testNS}, secretRef: types.NamespacedName{Name: "missing-secret", Namespace: testNS},
errMsg: "secret 'default/missing-secret' not found", errMsg: "secret 'default/missing-secret' not found",
}, },
{
name: "TLS secret with WithSystemCertPool option",
secretRef: types.NamespacedName{Name: "tls-secret", Namespace: testNS},
secret: testSecret(
withName("tls-secret"),
withData(map[string][]byte{
secrets.KeyCACert: caCert,
}),
),
opts: []secrets.TLSConfigOption{secrets.WithSystemCertPool()},
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -77,7 +89,7 @@ func TestTLSConfigFromSecretRef(t *testing.T) {
} }
c := fakeClient(objects...) c := fakeClient(objects...)
tlsConfig, err := secrets.TLSConfigFromSecretRef(ctx, c, tt.secretRef) tlsConfig, err := secrets.TLSConfigFromSecretRef(ctx, c, tt.secretRef, tt.opts...)
if tt.errMsg != "" { if tt.errMsg != "" {
g.Expect(err).To(MatchError(ContainSubstring(tt.errMsg))) g.Expect(err).To(MatchError(ContainSubstring(tt.errMsg)))
@ -85,6 +97,12 @@ func TestTLSConfigFromSecretRef(t *testing.T) {
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(tlsConfig).ToNot(BeNil()) g.Expect(tlsConfig).ToNot(BeNil())
// ServerName should be empty to allow automatic hostname verification
g.Expect(tlsConfig.ServerName).To(BeEmpty())
// InsecureSkipVerify must always be false per Flux security policy.
// The insecure parameter was removed to prevent bypassing certificate validation.
g.Expect(tlsConfig.InsecureSkipVerify).To(BeFalse())
hasCert := len(tt.secret.Data[secrets.KeyTLSCert]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSCert]) > 0 hasCert := len(tt.secret.Data[secrets.KeyTLSCert]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSCert]) > 0
hasKey := len(tt.secret.Data[secrets.KeyTLSPrivateKey]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSPrivateKey]) > 0 hasKey := len(tt.secret.Data[secrets.KeyTLSPrivateKey]) > 0 || len(tt.secret.Data[secrets.LegacyKeyTLSPrivateKey]) > 0
hasCertPair := hasCert && hasKey hasCertPair := hasCert && hasKey

View File

@ -73,11 +73,12 @@ const (
// AuthMethods holds all available authentication methods detected from a secret. // AuthMethods holds all available authentication methods detected from a secret.
type AuthMethods struct { type AuthMethods struct {
Basic *BasicAuth Basic *BasicAuth
Bearer BearerAuth Bearer BearerAuth
Token TokenAuth Token TokenAuth
SSH *SSHAuth SSH *SSHAuth
TLS *tls.Config GitHubAppData GitHubAppData
TLS *tls.Config
} }
// HasBasicAuth returns true if basic authentication is available. // HasBasicAuth returns true if basic authentication is available.
@ -100,11 +101,36 @@ func (am *AuthMethods) HasSSH() bool {
return am.SSH != nil return am.SSH != nil
} }
// HasGitHubAppData returns true if GitHub App authentication data is available.
func (am *AuthMethods) HasGitHubAppData() bool {
return len(am.GitHubAppData) > 0
}
// HasTLS returns true if TLS configuration is available. // HasTLS returns true if TLS configuration is available.
func (am *AuthMethods) HasTLS() bool { func (am *AuthMethods) HasTLS() bool {
return am.TLS != nil return am.TLS != nil
} }
// AuthMethodsOption configures the behavior of AuthMethodsFromSecret.
type AuthMethodsOption func(*authMethodsConfig)
// authMethodsConfig holds configuration for AuthMethods extraction.
type authMethodsConfig struct {
tlsConfigOpts []TLSConfigOption
}
// tlsConfig holds TLS-specific configuration options.
type tlsConfig struct {
useSystemCertPool bool
}
// WithTLSSystemCertPool enables the use of system certificate pool in addition to user-provided CA certificates.
func WithTLSSystemCertPool() AuthMethodsOption {
return func(cfg *authMethodsConfig) {
cfg.tlsConfigOpts = append(cfg.tlsConfigOpts, WithSystemCertPool())
}
}
// tlsCertificateData holds TLS certificate, key, and optional CA data // tlsCertificateData holds TLS certificate, key, and optional CA data
type tlsCertificateData struct { type tlsCertificateData struct {
cert []byte cert []byte
@ -205,6 +231,9 @@ type BearerAuth string
// TokenAuth holds generic token authentication credentials. // TokenAuth holds generic token authentication credentials.
type TokenAuth string type TokenAuth string
// GitHubAppData holds GitHub App authentication data as key-value pairs.
type GitHubAppData = map[string][]byte
// SSHAuth holds SSH authentication credentials. // SSHAuth holds SSH authentication credentials.
type SSHAuth struct { type SSHAuth struct {
PrivateKey []byte PrivateKey []byte
@ -213,14 +242,6 @@ type SSHAuth struct {
Password string Password string
} }
// GitHubAppAuth holds GitHub App authentication credentials.
type GitHubAppAuth struct {
AppID string
InstallationID string
PrivateKey []byte
BaseURL string
}
// getSecretData retrieves data from secret with fallback support for legacy keys. // getSecretData retrieves data from secret with fallback support for legacy keys.
func getSecretData(secret *corev1.Secret, key, fallbackKey string, logger logr.Logger) []byte { func getSecretData(secret *corev1.Secret, key, fallbackKey string, logger logr.Logger) []byte {
if data, exists := secret.Data[key]; exists { if data, exists := secret.Data[key]; exists {

View File

@ -767,6 +767,121 @@ func TestApply_IfNotPresent(t *testing.T) {
}) })
} }
func TestApply_Cleanup_ExactMatch(t *testing.T) {
timeout := 10 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
id := generateName("cleanup-exact")
objects, err := readManifest("testdata/test2.yaml", id)
if err != nil {
t.Fatal(err)
}
manager.SetOwnerLabels(objects, "app1", "default")
_, deployObject := getFirstObject(objects, "Deployment", id)
if err = normalize.UnstructuredList(objects); err != nil {
t.Fatal(err)
}
t.Run("creates objects as different managers", func(t *testing.T) {
// Apply all with prefix manager
for _, object := range objects {
obj := object.DeepCopy()
if err := manager.client.Patch(ctx, obj, client.Apply, client.FieldOwner("flux-apply-prefix")); err != nil {
t.Fatal(err)
}
}
// Apply deployment with exact match manager
deploy := deployObject.DeepCopy()
if err := manager.client.Patch(ctx, deploy, client.Apply, client.FieldOwner("flux")); err != nil {
t.Fatal(err)
}
// Check that the deployment has both managers
resultDeploy := deployObject.DeepCopy()
err = manager.Client().Get(ctx, client.ObjectKeyFromObject(deploy), resultDeploy)
if err != nil {
t.Fatal(err)
}
managedFields := resultDeploy.GetManagedFields()
foundExact := false
foundPrefix := false
for _, field := range managedFields {
if field.Manager == "flux" && field.Operation == metav1.ManagedFieldsOperationApply {
foundExact = true
}
if field.Manager == "flux-apply-prefix" && field.Operation == metav1.ManagedFieldsOperationApply {
foundPrefix = true
}
}
if !foundExact {
t.Errorf("Expected to find exact match manager 'flux' with Apply operation")
}
if !foundPrefix {
t.Errorf("Expected to find prefix manager 'flux-apply-prefix' with Apply operation")
}
})
t.Run("cleanup removes only exact match", func(t *testing.T) {
applyOpts := DefaultApplyOptions()
applyOpts.Cleanup = ApplyCleanupOptions{
FieldManagers: []FieldManager{
{
Name: "flux",
OperationType: metav1.ManagedFieldsOperationApply,
ExactMatch: true,
},
},
}
_, err := manager.ApplyAllStaged(ctx, objects, applyOpts)
if err != nil {
t.Fatal(err)
}
// Check that only exact match was removed
resultDeploy := deployObject.DeepCopy()
err = manager.Client().Get(ctx, client.ObjectKeyFromObject(resultDeploy), resultDeploy)
if err != nil {
t.Fatal(err)
}
managedFields := resultDeploy.GetManagedFields()
foundExact := false
foundPrefix := false
foundManager := false
for _, field := range managedFields {
t.Logf("Found managed field: Manager=%s, Operation=%s", field.Manager, field.Operation)
if field.Manager == "flux" {
foundExact = true
}
if field.Manager == "flux-apply-prefix" {
foundPrefix = true
}
if field.Manager == manager.owner.Field {
foundManager = true
}
}
if foundExact {
t.Errorf("Expected exact match 'flux' to be removed, but it was still present")
}
if !foundPrefix {
t.Errorf("Expected prefix match 'flux-apply-prefix' to remain, but it was not found")
}
if !foundManager {
t.Errorf("Expected manager '%s' to be present, but it was not found", manager.owner.Field)
}
})
}
func TestApply_Cleanup(t *testing.T) { func TestApply_Cleanup(t *testing.T) {
timeout := 10 * time.Second timeout := 10 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout) ctx, cancel := context.WithTimeout(context.Background(), timeout)

View File

@ -59,11 +59,27 @@ type FieldManager struct {
// Name is the name of the workflow managing fields. // Name is the name of the workflow managing fields.
Name string `json:"name"` Name string `json:"name"`
// ExactMatch controls the matching behavior for the manager name.
// When true, requires an exact match. When false, it uses prefix matching.
ExactMatch bool `json:"exactMatch"`
// OperationType is the type of operation performed by this manager, can be 'update' or 'apply'. // OperationType is the type of operation performed by this manager, can be 'update' or 'apply'.
OperationType metav1.ManagedFieldsOperationType `json:"operationType"` OperationType metav1.ManagedFieldsOperationType `json:"operationType"`
} }
// PatchRemoveFieldsManagers returns a jsonPatch array for removing managers with matching prefix and operation type. // matchFieldManager checks if the given ManagedFieldsEntry matches the specified FieldManager.
func matchFieldManager(entry metav1.ManagedFieldsEntry, manager FieldManager) bool {
if entry.Operation != manager.OperationType || entry.Subresource != "" {
return false
}
if manager.ExactMatch {
return entry.Manager == manager.Name
}
return strings.HasPrefix(entry.Manager, manager.Name)
}
// PatchRemoveFieldsManagers returns a jsonPatch array for removing managers with matching name or prefix.
func PatchRemoveFieldsManagers(object *unstructured.Unstructured, managers []FieldManager) []jsonPatch { func PatchRemoveFieldsManagers(object *unstructured.Unstructured, managers []FieldManager) []jsonPatch {
objEntries := object.GetManagedFields() objEntries := object.GetManagedFields()
@ -72,9 +88,7 @@ func PatchRemoveFieldsManagers(object *unstructured.Unstructured, managers []Fie
for _, entry := range objEntries { for _, entry := range objEntries {
exclude := false exclude := false
for _, manager := range managers { for _, manager := range managers {
if strings.HasPrefix(entry.Manager, manager.Name) && if matchFieldManager(entry, manager) {
entry.Operation == manager.OperationType &&
entry.Subresource == "" {
exclude = true exclude = true
break break
} }
@ -95,8 +109,8 @@ func PatchRemoveFieldsManagers(object *unstructured.Unstructured, managers []Fie
return append(patches, newPatchReplace(managedFieldsPath, entries)) return append(patches, newPatchReplace(managedFieldsPath, entries))
} }
// PatchReplaceFieldsManagers returns a jsonPatch array for replacing the managers with matching prefix and operation type // PatchReplaceFieldsManagers returns a jsonPatch array for replacing the managers with matching
// with the specified manager name and an apply operation. // name and operation type with the specified manager name and an apply operation.
func PatchReplaceFieldsManagers(object *unstructured.Unstructured, managers []FieldManager, name string) ([]jsonPatch, error) { func PatchReplaceFieldsManagers(object *unstructured.Unstructured, managers []FieldManager, name string) ([]jsonPatch, error) {
objEntries := object.GetManagedFields() objEntries := object.GetManagedFields()
@ -124,9 +138,7 @@ each_entry:
} }
for _, manager := range managers { for _, manager := range managers {
if strings.HasPrefix(entry.Manager, manager.Name) && if matchFieldManager(entry, manager) {
entry.Operation == manager.OperationType &&
entry.Subresource == "" {
// if no previous managedField was found, // if no previous managedField was found,
// rename the first match. // rename the first match.