Enable contextual login for helm OCI

If implemented, this pr will enable user to use the auto login feature
in order to automatically login to their provider of choice's container
registry (i.e. aws, gcr, acr).

Signed-off-by: Soule BA <soule@weave.works>
This commit is contained in:
Soule BA 2022-08-24 09:29:19 +02:00
parent 2010eef374
commit ad3eb5ca47
No known key found for this signature in database
GPG Key ID: 4D40965192802994
11 changed files with 363 additions and 9 deletions

View File

@ -68,7 +68,9 @@ type HelmRepositorySpec struct {
// +required // +required
Interval metav1.Duration `json:"interval"` Interval metav1.Duration `json:"interval"`
// Timeout of the index fetch operation, defaults to 60s. // Timeout is used for the index fetch operation for an HTTPS helm repository,
// and for remote OCI Repository operations like pulling for an OCI helm repository.
// Its default value is 60s.
// +kubebuilder:default:="60s" // +kubebuilder:default:="60s"
// +optional // +optional
Timeout *metav1.Duration `json:"timeout,omitempty"` Timeout *metav1.Duration `json:"timeout,omitempty"`
@ -89,6 +91,14 @@ type HelmRepositorySpec struct {
// +kubebuilder:validation:Enum=default;oci // +kubebuilder:validation:Enum=default;oci
// +optional // +optional
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
// Provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'.
// This field is optional, and only taken into account if the .spec.type field is set to 'oci'.
// When not specified, defaults to 'generic'.
// +kubebuilder:validation:Enum=generic;aws;azure;gcp
// +kubebuilder:default:=generic
// +optional
Provider string `json:"provider,omitempty"`
} }
// HelmRepositoryStatus records the observed state of the HelmRepository. // HelmRepositoryStatus records the observed state of the HelmRepository.

View File

@ -310,6 +310,18 @@ spec:
be done with caution, as it can potentially result in credentials be done with caution, as it can potentially result in credentials
getting stolen in a MITM-attack. getting stolen in a MITM-attack.
type: boolean type: boolean
provider:
default: generic
description: Provider used for authentication, can be 'aws', 'azure',
'gcp' or 'generic'. This field is optional, and only taken into
account if the .spec.type field is set to 'oci'. When not specified,
defaults to 'generic'.
enum:
- generic
- aws
- azure
- gcp
type: string
secretRef: secretRef:
description: SecretRef specifies the Secret containing authentication description: SecretRef specifies the Secret containing authentication
credentials for the HelmRepository. For HTTP/S basic auth the secret credentials for the HelmRepository. For HTTP/S basic auth the secret
@ -328,7 +340,9 @@ spec:
type: boolean type: boolean
timeout: timeout:
default: 60s default: 60s
description: Timeout of the index fetch operation, defaults to 60s. description: Timeout is used for the index fetch operation for an
HTTPS helm repository, and for remote OCI Repository operations
like pulling for an OCI helm repository. Its default value is 60s.
type: string type: string
type: type:
description: Type of the HelmRepository. When this field is set to "oci", description: Type of the HelmRepository. When this field is set to "oci",

View File

@ -50,6 +50,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/source" "sigs.k8s.io/controller-runtime/pkg/source"
"github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/oci"
"github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/conditions"
helper "github.com/fluxcd/pkg/runtime/controller" helper "github.com/fluxcd/pkg/runtime/controller"
"github.com/fluxcd/pkg/runtime/events" "github.com/fluxcd/pkg/runtime/events"
@ -463,6 +464,9 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
tlsConfig *tls.Config tlsConfig *tls.Config
loginOpts []helmreg.LoginOption loginOpts []helmreg.LoginOption
) )
// Used to login with the repository declared provider
ctxTimeout, cancel := context.WithTimeout(ctx, repo.Spec.Timeout.Duration)
defer cancel()
normalizedURL := repository.NormalizeURL(repo.Spec.URL) normalizedURL := repository.NormalizeURL(repo.Spec.URL)
// Construct the Getter options from the HelmRepository data // Construct the Getter options from the HelmRepository data
@ -521,6 +525,21 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
loginOpts = append([]helmreg.LoginOption{}, loginOpt) loginOpts = append([]helmreg.LoginOption{}, loginOpt)
} }
if repo.Spec.Provider != sourcev1.GenericOCIProvider && repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI {
auth, authErr := oidcAuth(ctxTimeout, repo)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
e := &serror.Event{
Err: fmt.Errorf("failed to get credential from %s: %w", repo.Spec.Provider, authErr),
Reason: sourcev1.AuthenticationFailedReason,
}
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
return sreconcile.ResultEmpty, e
}
if auth != nil {
loginOpts = append([]helmreg.LoginOption{}, auth)
}
}
// Initialize the chart repository // Initialize the chart repository
var chartRepo repository.Downloader var chartRepo repository.Downloader
switch repo.Spec.Type { switch repo.Spec.Type {
@ -947,6 +966,11 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
}, },
} }
} }
// Used to login with the repository declared provider
ctxTimeout, cancel := context.WithTimeout(ctx, repo.Spec.Timeout.Duration)
defer cancel()
clientOpts := []helmgetter.Option{ clientOpts := []helmgetter.Option{
helmgetter.WithURL(normalizedURL), helmgetter.WithURL(normalizedURL),
helmgetter.WithTimeout(repo.Spec.Timeout.Duration), helmgetter.WithTimeout(repo.Spec.Timeout.Duration),
@ -976,6 +1000,16 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
loginOpts = append([]helmreg.LoginOption{}, loginOpt) loginOpts = append([]helmreg.LoginOption{}, loginOpt)
} }
if repo.Spec.Provider != sourcev1.GenericOCIProvider && repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI {
auth, authErr := oidcAuth(ctxTimeout, repo)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
return nil, fmt.Errorf("failed to get credential from %s: %w", repo.Spec.Provider, authErr)
}
if auth != nil {
loginOpts = append([]helmreg.LoginOption{}, auth)
}
}
var chartRepo repository.Downloader var chartRepo repository.Downloader
if helmreg.IsOCI(normalizedURL) { if helmreg.IsOCI(normalizedURL) {
registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpts != nil) registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpts != nil)

View File

@ -1085,9 +1085,10 @@ func TestHelmChartReconciler_buildFromOCIHelmRepository(t *testing.T) {
GenerateName: "helmrepository-", GenerateName: "helmrepository-",
}, },
Spec: sourcev1.HelmRepositorySpec{ Spec: sourcev1.HelmRepositorySpec{
URL: fmt.Sprintf("oci://%s/testrepo", testRegistryServer.registryHost), URL: fmt.Sprintf("oci://%s/testrepo", testRegistryServer.registryHost),
Timeout: &metav1.Duration{Duration: timeout}, Timeout: &metav1.Duration{Duration: timeout},
Type: sourcev1.HelmRepositoryTypeOCI, Provider: sourcev1.GenericOCIProvider,
Type: sourcev1.HelmRepositoryTypeOCI,
}, },
} }
obj := &sourcev1.HelmChart{ obj := &sourcev1.HelmChart{

View File

@ -22,6 +22,7 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"strings"
"time" "time"
helmgetter "helm.sh/helm/v3/pkg/getter" helmgetter "helm.sh/helm/v3/pkg/getter"
@ -41,10 +42,13 @@ import (
"sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/predicate"
"github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/oci"
"github.com/fluxcd/pkg/oci/auth/login"
"github.com/fluxcd/pkg/runtime/conditions" "github.com/fluxcd/pkg/runtime/conditions"
helper "github.com/fluxcd/pkg/runtime/controller" helper "github.com/fluxcd/pkg/runtime/controller"
"github.com/fluxcd/pkg/runtime/patch" "github.com/fluxcd/pkg/runtime/patch"
"github.com/fluxcd/pkg/runtime/predicates" "github.com/fluxcd/pkg/runtime/predicates"
"github.com/google/go-containerregistry/pkg/name"
"github.com/fluxcd/source-controller/api/v1beta2" "github.com/fluxcd/source-controller/api/v1beta2"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
@ -204,6 +208,9 @@ func (r *HelmRepositoryOCIReconciler) Reconcile(ctx context.Context, req ctrl.Re
// block at the very end to summarize the conditions to be in a consistent // block at the very end to summarize the conditions to be in a consistent
// state. // state.
func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, obj *v1beta2.HelmRepository) (result ctrl.Result, retErr error) { func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, obj *v1beta2.HelmRepository) (result ctrl.Result, retErr error) {
ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration)
defer cancel()
oldObj := obj.DeepCopy() oldObj := obj.DeepCopy()
defer func() { defer func() {
@ -296,6 +303,19 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, obj *v1beta
} }
} }
if obj.Spec.Provider != sourcev1.GenericOCIProvider && obj.Spec.Type == sourcev1.HelmRepositoryTypeOCI {
auth, authErr := oidcAuth(ctxTimeout, obj)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
e := fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr)
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error())
result, retErr = ctrl.Result{}, e
return
}
if auth != nil {
loginOpts = append(loginOpts, auth)
}
}
// Create registry client and login if needed. // Create registry client and login if needed.
registryClient, file, err := r.RegistryClientGenerator(loginOpts != nil) registryClient, file, err := r.RegistryClientGenerator(loginOpts != nil)
if err != nil { if err != nil {
@ -366,3 +386,42 @@ func (r *HelmRepositoryOCIReconciler) eventLogf(ctx context.Context, obj runtime
} }
r.Eventf(obj, eventType, reason, msg) r.Eventf(obj, eventType, reason, msg)
} }
// oidcAuth generates the OIDC credential authenticator based on the specified cloud provider.
func oidcAuth(ctx context.Context, obj *sourcev1.HelmRepository) (helmreg.LoginOption, error) {
url := strings.TrimPrefix(obj.Spec.URL, sourcev1.OCIRepositoryPrefix)
ref, err := name.ParseReference(url)
if err != nil {
return nil, fmt.Errorf("failed to parse URL '%s': %w", obj.Spec.URL, err)
}
loginOpt, err := loginWithManager(ctx, obj.Spec.Provider, url, ref)
if err != nil {
return nil, fmt.Errorf("failed to login to registry '%s': %w", obj.Spec.URL, err)
}
return loginOpt, nil
}
func loginWithManager(ctx context.Context, provider, url string, ref name.Reference) (helmreg.LoginOption, error) {
opts := login.ProviderOptions{}
switch provider {
case sourcev1.AmazonOCIProvider:
opts.AwsAutoLogin = true
case sourcev1.AzureOCIProvider:
opts.AzureAutoLogin = true
case sourcev1.GoogleOCIProvider:
opts.GcpAutoLogin = true
}
auth, err := login.NewManager().Login(ctx, url, ref, opts)
if err != nil {
return nil, err
}
if auth == nil {
return nil, nil
}
return registry.OIDCAdaptHelper(auth)
}

View File

@ -94,7 +94,8 @@ func TestHelmRepositoryOCIReconciler_Reconcile(t *testing.T) {
SecretRef: &meta.LocalObjectReference{ SecretRef: &meta.LocalObjectReference{
Name: secret.Name, Name: secret.Name,
}, },
Type: sourcev1.HelmRepositoryTypeOCI, Provider: sourcev1.GenericOCIProvider,
Type: sourcev1.HelmRepositoryTypeOCI,
}, },
} }
g.Expect(testEnv.Create(ctx, obj)).To(Succeed()) g.Expect(testEnv.Create(ctx, obj)).To(Succeed())

View File

@ -818,7 +818,9 @@ Kubernetes meta/v1.Duration
</td> </td>
<td> <td>
<em>(Optional)</em> <em>(Optional)</em>
<p>Timeout of the index fetch operation, defaults to 60s.</p> <p>Timeout is used for the index fetch operation for an HTTPS helm repository,
and for remote OCI Repository operations like pulling for an OCI helm repository.
Its default value is 60s.</p>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -863,6 +865,20 @@ string
When this field is set to &ldquo;oci&rdquo;, the URL field value must be prefixed with &ldquo;oci://&rdquo;.</p> When this field is set to &ldquo;oci&rdquo;, the URL field value must be prefixed with &ldquo;oci://&rdquo;.</p>
</td> </td>
</tr> </tr>
<tr>
<td>
<code>provider</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>Provider used for authentication, can be &lsquo;aws&rsquo;, &lsquo;azure&rsquo;, &lsquo;gcp&rsquo; or &lsquo;generic&rsquo;.
This field is optional, and only taken into account if the .spec.type field is set to &lsquo;oci&rsquo;.
When not specified, defaults to &lsquo;generic&rsquo;.</p>
</td>
</tr>
</table> </table>
</td> </td>
</tr> </tr>
@ -2347,7 +2363,9 @@ Kubernetes meta/v1.Duration
</td> </td>
<td> <td>
<em>(Optional)</em> <em>(Optional)</em>
<p>Timeout of the index fetch operation, defaults to 60s.</p> <p>Timeout is used for the index fetch operation for an HTTPS helm repository,
and for remote OCI Repository operations like pulling for an OCI helm repository.
Its default value is 60s.</p>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -2392,6 +2410,20 @@ string
When this field is set to &ldquo;oci&rdquo;, the URL field value must be prefixed with &ldquo;oci://&rdquo;.</p> When this field is set to &ldquo;oci&rdquo;, the URL field value must be prefixed with &ldquo;oci://&rdquo;.</p>
</td> </td>
</tr> </tr>
<tr>
<td>
<code>provider</code><br>
<em>
string
</em>
</td>
<td>
<em>(Optional)</em>
<p>Provider used for authentication, can be &lsquo;aws&rsquo;, &lsquo;azure&rsquo;, &lsquo;gcp&rsquo; or &lsquo;generic&rsquo;.
This field is optional, and only taken into account if the .spec.type field is set to &lsquo;oci&rsquo;.
When not specified, defaults to &lsquo;generic&rsquo;.</p>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -162,6 +162,134 @@ A HelmRepository also needs a
Possible values are `default` for a Helm HTTP/S repository, or `oci` for an OCI Helm repository. Possible values are `default` for a Helm HTTP/S repository, or `oci` for an OCI Helm repository.
### Provider
`.spec.provider` is an optional field that allows specifying an OIDC provider used
for authentication purposes.
Supported options are:
- `generic`
- `aws`
- `azure`
- `gcp`
The `generic` provider can be used for public repositories or when static credentials
are used for authentication. If you do not specify `.spec.provider`, it defaults
to `generic`.
**Note**: The provider field is supported only for Helm OCI repositories. The `spec.type`
field must be set to `oci`.
#### AWS
The `aws` provider can be used to authenticate automatically using the EKS worker
node IAM role or IAM Role for Service Accounts (IRSA), and by extension gain access
to ECR.
When the worker node IAM role has access to ECR, source-controller running on it
will also have access to ECR.
When using IRSA to enable access to ECR, add the following patch to your bootstrap
repository, in the `flux-system/kustomization.yaml` file:
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- gotk-components.yaml
- gotk-sync.yaml
patches:
- patch: |
apiVersion: v1
kind: ServiceAccount
metadata:
name: source-controller
annotations:
eks.amazonaws.com/role-arn: <role arn>
target:
kind: ServiceAccount
name: source-controller
```
Note that you can attach the AWS managed policy `arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly`
to the IAM role when using IRSA.
#### Azure
The `azure` provider can be used to authenticate automatically using kubelet managed
identity or Azure Active Directory pod-managed identity (aad-pod-identity), and
by extension gain access to ACR.
When the kubelet managed identity has access to ACR, source-controller running on
it will also have access to ACR.
When using aad-pod-identity to enable access to ACR, add the following patch to
your bootstrap repository, in the `flux-system/kustomization.yaml` file:
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- gotk-components.yaml
- gotk-sync.yaml
patches:
- patch: |
- op: add
path: /spec/template/metadata/labels/aadpodidbinding
value: <identity-name>
target:
kind: Deployment
name: source-controller
```
When using pod-managed identity on an AKS cluster, AAD Pod Identity has to be used
to give the `source-controller` pod access to the ACR. To do this, you have to install
`aad-pod-identity` on your cluster, create a managed identity that has access to the
container registry (this can also be the Kubelet identity if it has `AcrPull` role
assignment on the ACR), create an `AzureIdentity` and `AzureIdentityBinding` that describe
the managed identity and then label the `source-controller` pods with the name of the
AzureIdentity as shown in the patch above. Please take a look at [this guide](https://azure.github.io/aad-pod-identity/docs/)
or [this one](https://docs.microsoft.com/en-us/azure/aks/use-azure-ad-pod-identity)
if you want to use AKS pod-managed identities add-on that is in preview.
#### GCP
The `gcp` provider can be used to authenticate automatically using OAuth scopes or
Workload Identity, and by extension gain access to GCR or Artifact Registry.
When the GKE nodes have the appropriate OAuth scope for accessing GCR and Artifact Registry,
source-controller running on it will also have access to them.
When using Workload Identity to enable access to GCR or Artifact Registry, add the
following patch to your bootstrap repository, in the `flux-system/kustomization.yaml`
file:
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- gotk-components.yaml
- gotk-sync.yaml
patches:
- patch: |
apiVersion: v1
kind: ServiceAccount
metadata:
name: source-controller
annotations:
iam.gke.io/gcp-service-account: <identity-name>
target:
kind: ServiceAccount
name: source-controller
```
The Artifact Registry service uses the permission `artifactregistry.repositories.downloadArtifacts`
that is located under the Artifact Registry Reader role. If you are using Google Container Registry service,
the needed permission is instead `storage.objects.list` which can be bound as part
of the Container Registry Service Agent role. Take a look at [this guide](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity)
for more information about setting up GKE Workload Identity.
### Interval ### Interval
`.spec.interval` is a required field that specifies the interval which the `.spec.interval` is a required field that specifies the interval which the

View File

@ -161,7 +161,7 @@ and by extension gain access to ACR.
When the kubelet managed identity has access to ACR, source-controller running When the kubelet managed identity has access to ACR, source-controller running
on it will also have access to ACR. on it will also have access to ACR.
When using aad-pod-identity to enable access to ECR, add the following patch to When using aad-pod-identity to enable access to ACR, add the following patch to
your bootstrap repository, in the `flux-system/kustomization.yaml` file: your bootstrap repository, in the `flux-system/kustomization.yaml` file:
```yaml ```yaml

View File

@ -23,6 +23,7 @@ import (
"github.com/docker/cli/cli/config" "github.com/docker/cli/cli/config"
"github.com/docker/cli/cli/config/credentials" "github.com/docker/cli/cli/config/credentials"
"github.com/google/go-containerregistry/pkg/authn"
"helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/registry"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
) )
@ -68,3 +69,25 @@ func LoginOptionFromSecret(registryURL string, secret corev1.Secret) (registry.L
} }
return registry.LoginOptBasicAuth(username, password), nil return registry.LoginOptBasicAuth(username, password), nil
} }
// OIDCAdaptHelper returns an ORAS credentials callback configured with the authorization data
// from the given authn authenticator. This allows for example to make use of credential helpers from
// cloud providers.
// Ref: https://github.com/google/go-containerregistry/tree/main/pkg/authn
func OIDCAdaptHelper(authenticator authn.Authenticator) (registry.LoginOption, error) {
authConfig, err := authenticator.Authorization()
if err != nil {
return nil, fmt.Errorf("unable to get authentication data from OIDC: %w", err)
}
username := authConfig.Username
password := authConfig.Password
switch {
case username == "" && password == "":
return nil, nil
case username == "" || password == "":
return nil, fmt.Errorf("invalid auth data: required fields 'username' and 'password'")
}
return registry.LoginOptBasicAuth(username, password), nil
}

View File

@ -19,6 +19,7 @@ package registry
import ( import (
"testing" "testing"
"github.com/google/go-containerregistry/pkg/authn"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
) )
@ -129,3 +130,54 @@ func TestLoginOptionFromSecret(t *testing.T) {
}) })
} }
} }
func TestOIDCAdaptHelper(t *testing.T) {
auth := &authn.Basic{
Username: "flux",
Password: "flux_password",
}
tests := []struct {
name string
auth authn.Authenticator
expectedLogin bool
wantErr bool
}{
{
name: "Login from basic auth with empty auth",
auth: &authn.Basic{},
expectedLogin: false,
wantErr: false,
},
{
name: "Login from basic auth",
auth: auth,
expectedLogin: true,
wantErr: false,
},
{
name: "Login with missing password",
auth: &authn.Basic{Username: "flux"},
expectedLogin: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
loginOpt, err := OIDCAdaptHelper(tt.auth)
if tt.wantErr {
g.Expect(err).To(HaveOccurred())
return
}
g.Expect(err).To(BeNil())
if tt.expectedLogin {
g.Expect(loginOpt).ToNot(BeNil())
} else {
g.Expect(loginOpt).To(BeNil())
}
})
}
}