diff --git a/internal/controller/helmchart_controller.go b/internal/controller/helmchart_controller.go index 6095c60ed..548b4bc53 100644 --- a/internal/controller/helmchart_controller.go +++ b/internal/controller/helmchart_controller.go @@ -18,7 +18,6 @@ package controller import ( "context" - "crypto/tls" "errors" "fmt" "net/url" @@ -28,7 +27,6 @@ import ( "strings" "time" - "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/opencontainers/go-digest" helmgetter "helm.sh/helm/v3/pkg/getter" @@ -54,7 +52,6 @@ import ( eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/git" - "github.com/fluxcd/pkg/oci" "github.com/fluxcd/pkg/runtime/conditions" helper "github.com/fluxcd/pkg/runtime/controller" "github.com/fluxcd/pkg/runtime/patch" @@ -68,7 +65,6 @@ import ( serror "github.com/fluxcd/source-controller/internal/error" "github.com/fluxcd/source-controller/internal/helm/chart" "github.com/fluxcd/source-controller/internal/helm/getter" - "github.com/fluxcd/source-controller/internal/helm/registry" "github.com/fluxcd/source-controller/internal/helm/repository" soci "github.com/fluxcd/source-controller/internal/oci" sreconcile "github.com/fluxcd/source-controller/internal/reconcile" @@ -506,11 +502,6 @@ func (r *HelmChartReconciler) reconcileSource(ctx context.Context, sp *patch.Ser // object, and returns early. func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *helmv1.HelmChart, repo *helmv1.HelmRepository, b *chart.Build) (sreconcile.Result, error) { - var ( - tlsConfig *tls.Config - authenticator authn.Authenticator - keychain authn.Keychain - ) // Used to login with the repository declared provider ctxTimeout, cancel := context.WithTimeout(ctx, repo.Spec.Timeout.Duration) defer cancel() @@ -519,65 +510,8 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * if err != nil { return chartRepoConfigErrorReturn(err, obj) } - // Construct the Getter options from the HelmRepository data - clientOpts := []helmgetter.Option{ - helmgetter.WithURL(normalizedURL), - helmgetter.WithTimeout(repo.Spec.Timeout.Duration), - helmgetter.WithPassCredentialsAll(repo.Spec.PassCredentials), - } - if secret, err := r.getHelmRepositorySecret(ctx, repo); secret != nil || err != nil { - if err != nil { - e := &serror.Event{ - Err: fmt.Errorf("failed to get secret '%s': %w", repo.Spec.SecretRef.Name, err), - Reason: sourcev1.AuthenticationFailedReason, - } - conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) - // Return error as the world as observed may change - return sreconcile.ResultEmpty, e - } - - // Build client options from secret - opts, tlsCfg, err := r.clientOptionsFromSecret(secret, normalizedURL) - if err != nil { - e := &serror.Event{ - Err: err, - Reason: sourcev1.AuthenticationFailedReason, - } - conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) - // Requeue as content of secret might change - return sreconcile.ResultEmpty, e - } - clientOpts = append(clientOpts, opts...) - tlsConfig = tlsCfg - - // Build registryClient options from secret - keychain, err = registry.LoginOptionFromSecret(normalizedURL, *secret) - if err != nil { - e := &serror.Event{ - Err: fmt.Errorf("failed to configure Helm client with secret data: %w", err), - Reason: sourcev1.AuthenticationFailedReason, - } - conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) - // Requeue as content of secret might change - return sreconcile.ResultEmpty, e - } - } else if repo.Spec.Provider != helmv1.GenericOCIProvider && repo.Spec.Type == helmv1.HelmRepositoryTypeOCI { - auth, authErr := oidcAuth(ctxTimeout, repo.Spec.URL, repo.Spec.Provider) - 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 { - authenticator = auth - } - } - - loginOpt, err := makeLoginOption(authenticator, keychain, normalizedURL) - if err != nil { + clientOpts, err := getter.GetClientOpts(ctxTimeout, r.Client, repo, normalizedURL) + if err != nil && !errors.Is(err, getter.ErrDeprecatedTLSConfig) { e := &serror.Event{ Err: err, Reason: sourcev1.AuthenticationFailedReason, @@ -585,6 +519,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) return sreconcile.ResultEmpty, e } + getterOpts := clientOpts.GetterOpts // Initialize the chart repository var chartRepo repository.Downloader @@ -599,7 +534,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * // this is needed because otherwise the credentials are stored in ~/.docker/config.json. // TODO@souleb: remove this once the registry move to Oras v2 // or rework to enable reusing credentials to avoid the unneccessary handshake operations - registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpt != nil) + registryClient, credentialsFile, err := r.RegistryClientGenerator(clientOpts.RegLoginOpt != nil) if err != nil { e := &serror.Event{ Err: fmt.Errorf("failed to construct Helm client: %w", err), @@ -621,7 +556,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * var verifiers []soci.Verifier if obj.Spec.Verify != nil { provider := obj.Spec.Verify.Provider - verifiers, err = r.makeVerifiers(ctx, obj, authenticator, keychain) + verifiers, err = r.makeVerifiers(ctx, obj, *clientOpts) if err != nil { if obj.Spec.Verify.SecretRef == nil { provider = fmt.Sprintf("%s keyless", provider) @@ -636,21 +571,20 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * } // Tell the chart repository to use the OCI client with the configured getter - clientOpts = append(clientOpts, helmgetter.WithRegistryClient(registryClient)) + getterOpts = append(getterOpts, helmgetter.WithRegistryClient(registryClient)) ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL, repository.WithOCIGetter(r.Getters), - repository.WithOCIGetterOptions(clientOpts), + repository.WithOCIGetterOptions(getterOpts), repository.WithOCIRegistryClient(registryClient), repository.WithVerifiers(verifiers)) if err != nil { return chartRepoConfigErrorReturn(err, obj) } - chartRepo = ociChartRepo // If login options are configured, use them to login to the registry // The OCIGetter will later retrieve the stored credentials to pull the chart - if loginOpt != nil { - err = ociChartRepo.Login(loginOpt) + if clientOpts.RegLoginOpt != nil { + err = ociChartRepo.Login(clientOpts.RegLoginOpt) if err != nil { e := &serror.Event{ Err: fmt.Errorf("failed to login to OCI registry: %w", err), @@ -660,8 +594,9 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj * return sreconcile.ResultEmpty, e } } + chartRepo = ociChartRepo default: - httpChartRepo, err := repository.NewChartRepository(normalizedURL, r.Storage.LocalPath(*repo.GetArtifact()), r.Getters, tlsConfig, clientOpts...) + httpChartRepo, err := repository.NewChartRepository(normalizedURL, r.Storage.LocalPath(*repo.GetArtifact()), r.Getters, clientOpts.TlsConfig, getterOpts...) if err != nil { return chartRepoConfigErrorReturn(err, obj) } @@ -1024,12 +959,6 @@ func (r *HelmChartReconciler) garbageCollect(ctx context.Context, obj *helmv1.He // The callback returns an object with a state, so the caller has to do the necessary cleanup. func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Context, name, namespace string) chart.GetChartDownloaderCallback { return func(url string) (repository.Downloader, error) { - var ( - tlsConfig *tls.Config - authenticator authn.Authenticator - keychain authn.Keychain - ) - normalizedURL, err := repository.NormalizeURL(url) if err != nil { return nil, err @@ -1052,61 +981,28 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration) defer cancel() - clientOpts := []helmgetter.Option{ - helmgetter.WithURL(normalizedURL), - helmgetter.WithTimeout(obj.Spec.Timeout.Duration), - helmgetter.WithPassCredentialsAll(obj.Spec.PassCredentials), - } - if secret, err := r.getHelmRepositorySecret(ctx, obj); secret != nil || err != nil { - if err != nil { - return nil, err - } - - // Build client options from secret - opts, tlsCfg, err := r.clientOptionsFromSecret(secret, normalizedURL) - if err != nil { - return nil, err - } - clientOpts = append(clientOpts, opts...) - tlsConfig = tlsCfg - - // Build registryClient options from secret - keychain, err = registry.LoginOptionFromSecret(normalizedURL, *secret) - if err != nil { - return nil, fmt.Errorf("failed to create login options for HelmRepository '%s': %w", obj.Name, err) - } - - } else if obj.Spec.Provider != helmv1.GenericOCIProvider && obj.Spec.Type == helmv1.HelmRepositoryTypeOCI { - auth, authErr := oidcAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider) - if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) { - return nil, fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr) - } - if auth != nil { - authenticator = auth - } - } - - loginOpt, err := makeLoginOption(authenticator, keychain, normalizedURL) - if err != nil { + clientOpts, err := getter.GetClientOpts(ctxTimeout, r.Client, obj, normalizedURL) + if err != nil && !errors.Is(err, getter.ErrDeprecatedTLSConfig) { return nil, err } + getterOpts := clientOpts.GetterOpts var chartRepo repository.Downloader if helmreg.IsOCI(normalizedURL) { - registryClient, credentialsFile, err := r.RegistryClientGenerator(loginOpt != nil) + registryClient, credentialsFile, err := r.RegistryClientGenerator(clientOpts.RegLoginOpt != nil) if err != nil { - return nil, fmt.Errorf("failed to create registry client for HelmRepository '%s': %w", obj.Name, err) + return nil, fmt.Errorf("failed to create registry client: %w", err) } var errs []error // Tell the chart repository to use the OCI client with the configured getter - clientOpts = append(clientOpts, helmgetter.WithRegistryClient(registryClient)) + getterOpts = append(getterOpts, helmgetter.WithRegistryClient(registryClient)) ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL, repository.WithOCIGetter(r.Getters), - repository.WithOCIGetterOptions(clientOpts), + repository.WithOCIGetterOptions(getterOpts), repository.WithOCIRegistryClient(registryClient), repository.WithCredentialsFile(credentialsFile)) if err != nil { - errs = append(errs, fmt.Errorf("failed to create OCI chart repository for HelmRepository '%s': %w", obj.Name, err)) + errs = append(errs, fmt.Errorf("failed to create OCI chart repository: %w", err)) // clean up the credentialsFile if credentialsFile != "" { if err := os.Remove(credentialsFile); err != nil { @@ -1118,10 +1014,10 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont // If login options are configured, use them to login to the registry // The OCIGetter will later retrieve the stored credentials to pull the chart - if loginOpt != nil { - err = ociChartRepo.Login(loginOpt) + if clientOpts.RegLoginOpt != nil { + err = ociChartRepo.Login(clientOpts.RegLoginOpt) if err != nil { - errs = append(errs, fmt.Errorf("failed to login to OCI chart repository for HelmRepository '%s': %w", obj.Name, err)) + errs = append(errs, fmt.Errorf("failed to login to OCI chart repository: %w", err)) // clean up the credentialsFile errs = append(errs, ociChartRepo.Clear()) return nil, kerrors.NewAggregate(errs) @@ -1130,7 +1026,7 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont chartRepo = ociChartRepo } else { - httpChartRepo, err := repository.NewChartRepository(normalizedURL, "", r.Getters, tlsConfig, clientOpts...) + httpChartRepo, err := repository.NewChartRepository(normalizedURL, "", r.Getters, clientOpts.TlsConfig, getterOpts...) if err != nil { return nil, err } @@ -1178,36 +1074,6 @@ func (r *HelmChartReconciler) resolveDependencyRepository(ctx context.Context, u return nil, fmt.Errorf("no HelmRepository found for '%s' in '%s' namespace", url, namespace) } -func (r *HelmChartReconciler) clientOptionsFromSecret(secret *corev1.Secret, normalizedURL string) ([]helmgetter.Option, *tls.Config, error) { - opts, err := getter.ClientOptionsFromSecret(*secret) - if err != nil { - return nil, nil, fmt.Errorf("failed to configure Helm client with secret data: %w", err) - } - - tlsConfig, err := getter.TLSClientConfigFromSecret(*secret, normalizedURL) - if err != nil { - return nil, nil, fmt.Errorf("failed to create TLS client config with secret data: %w", err) - } - - return opts, tlsConfig, nil -} - -func (r *HelmChartReconciler) getHelmRepositorySecret(ctx context.Context, repository *helmv1.HelmRepository) (*corev1.Secret, error) { - if repository.Spec.SecretRef == nil { - return nil, nil - } - name := types.NamespacedName{ - Namespace: repository.GetNamespace(), - Name: repository.Spec.SecretRef.Name, - } - var secret corev1.Secret - err := r.Client.Get(ctx, name, &secret) - if err != nil { - return nil, err - } - return &secret, nil -} - func (r *HelmChartReconciler) indexHelmRepositoryByURL(o client.Object) []string { repo, ok := o.(*helmv1.HelmRepository) if !ok { @@ -1412,13 +1278,14 @@ func chartRepoConfigErrorReturn(err error, obj *helmv1.HelmChart) (sreconcile.Re } // makeVerifiers returns a list of verifiers for the given chart. -func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.HelmChart, auth authn.Authenticator, keychain authn.Keychain) ([]soci.Verifier, error) { +func (r *HelmChartReconciler) makeVerifiers(ctx context.Context, obj *helmv1.HelmChart, clientOpts getter.ClientOpts) ([]soci.Verifier, error) { var verifiers []soci.Verifier verifyOpts := []remote.Option{} - if auth != nil { - verifyOpts = append(verifyOpts, remote.WithAuth(auth)) + + if clientOpts.Authenticator != nil { + verifyOpts = append(verifyOpts, remote.WithAuth(clientOpts.Authenticator)) } else { - verifyOpts = append(verifyOpts, remote.WithAuthFromKeychain(keychain)) + verifyOpts = append(verifyOpts, remote.WithAuthFromKeychain(clientOpts.Keychain)) } switch obj.Spec.Verify.Provider { diff --git a/internal/controller/helmchart_controller_test.go b/internal/controller/helmchart_controller_test.go index 7e94ac775..b7002245a 100644 --- a/internal/controller/helmchart_controller_test.go +++ b/internal/controller/helmchart_controller_test.go @@ -922,12 +922,12 @@ func TestHelmChartReconciler_buildFromHelmRepository(t *testing.T) { } }, want: sreconcile.ResultEmpty, - wantErr: &serror.Event{Err: errors.New("failed to get secret 'invalid'")}, + wantErr: &serror.Event{Err: errors.New("failed to get authentication secret '/invalid'")}, assertFunc: func(g *WithT, obj *helmv1.HelmChart, build chart.Build) { g.Expect(build.Complete()).To(BeFalse()) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ - *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret 'invalid'"), + *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get authentication secret '/invalid'"), })) }, }, @@ -1190,12 +1190,12 @@ func TestHelmChartReconciler_buildFromOCIHelmRepository(t *testing.T) { } }, want: sreconcile.ResultEmpty, - wantErr: &serror.Event{Err: errors.New("failed to get secret 'invalid'")}, + wantErr: &serror.Event{Err: errors.New("failed to get authentication secret '/invalid'")}, assertFunc: func(g *WithT, obj *helmv1.HelmChart, build chart.Build) { g.Expect(build.Complete()).To(BeFalse()) g.Expect(obj.Status.Conditions).To(conditions.MatchConditions([]metav1.Condition{ - *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret 'invalid'"), + *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get authentication secret '/invalid'"), })) }, }, @@ -1649,83 +1649,6 @@ func TestHelmChartReconciler_reconcileArtifact(t *testing.T) { } } -func TestHelmChartReconciler_getHelmRepositorySecret(t *testing.T) { - mock := &corev1.Secret{ - TypeMeta: metav1.TypeMeta{ - Kind: "Secret", - APIVersion: "v1", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "secret", - Namespace: "foo", - }, - Data: map[string][]byte{ - "key": []byte("bar"), - }, - } - - r := &HelmChartReconciler{ - Client: fakeclient.NewClientBuilder(). - WithObjects(mock). - Build(), - patchOptions: getPatchOptions(helmChartReadyCondition.Owned, "sc"), - } - - tests := []struct { - name string - repository *helmv1.HelmRepository - want *corev1.Secret - wantErr bool - }{ - { - name: "Existing secret reference", - repository: &helmv1.HelmRepository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: mock.Namespace, - }, - Spec: helmv1.HelmRepositorySpec{ - SecretRef: &meta.LocalObjectReference{ - Name: mock.Name, - }, - }, - }, - want: mock, - }, - { - name: "Empty secret reference", - repository: &helmv1.HelmRepository{ - Spec: helmv1.HelmRepositorySpec{ - SecretRef: nil, - }, - }, - want: nil, - }, - { - name: "Error on client error", - repository: &helmv1.HelmRepository{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: "different", - }, - Spec: helmv1.HelmRepositorySpec{ - SecretRef: &meta.LocalObjectReference{ - Name: mock.Name, - }, - }, - }, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - g := NewWithT(t) - - got, err := r.getHelmRepositorySecret(context.TODO(), tt.repository) - g.Expect(err != nil).To(Equal(tt.wantErr)) - g.Expect(got).To(Equal(tt.want)) - }) - } -} - func TestHelmChartReconciler_getSource(t *testing.T) { mocks := []client.Object{ &helmv1.HelmRepository{ diff --git a/internal/controller/helmrepository_controller.go b/internal/controller/helmrepository_controller.go index d5175fdf1..1b6161ee0 100644 --- a/internal/controller/helmrepository_controller.go +++ b/internal/controller/helmrepository_controller.go @@ -18,7 +18,6 @@ package controller import ( "context" - "crypto/tls" "errors" "fmt" "net/url" @@ -29,7 +28,6 @@ import ( helmgetter "helm.sh/helm/v3/pkg/getter" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" kuberecorder "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -390,59 +388,33 @@ func (r *HelmRepositoryReconciler) reconcileStorage(ctx context.Context, sp *pat // pointer is set to the newly fetched index. func (r *HelmRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch.SerialPatcher, obj *helmv1.HelmRepository, artifact *sourcev1.Artifact, chartRepo *repository.ChartRepository) (sreconcile.Result, error) { - var tlsConfig *tls.Config - - // Configure Helm client to access repository - clientOpts := []helmgetter.Option{ - helmgetter.WithTimeout(obj.Spec.Timeout.Duration), - helmgetter.WithURL(obj.Spec.URL), - helmgetter.WithPassCredentialsAll(obj.Spec.PassCredentials), - } - - // Configure any authentication related options - if obj.Spec.SecretRef != nil { - // Attempt to retrieve secret - name := types.NamespacedName{ - Namespace: obj.GetNamespace(), - Name: obj.Spec.SecretRef.Name, - } - var secret corev1.Secret - if err := r.Client.Get(ctx, name, &secret); err != nil { - e := &serror.Event{ - Err: fmt.Errorf("failed to get secret '%s': %w", name.String(), err), - Reason: sourcev1.AuthenticationFailedReason, - } - conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) - return sreconcile.ResultEmpty, e - } - - // Construct actual options - opts, err := getter.ClientOptionsFromSecret(secret) - if err != nil { - e := &serror.Event{ - Err: fmt.Errorf("failed to configure Helm client with secret data: %w", err), - Reason: sourcev1.AuthenticationFailedReason, - } - conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) - // Return err as the content of the secret may change. - return sreconcile.ResultEmpty, e + normalizedURL, err := repository.NormalizeURL(obj.Spec.URL) + if err != nil { + e := &serror.Stalling{ + Err: fmt.Errorf("invalid Helm repository URL: %w", err), + Reason: sourcev1.URLInvalidReason, } - clientOpts = append(clientOpts, opts...) + conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) + return sreconcile.ResultEmpty, e + } - tlsConfig, err = getter.TLSClientConfigFromSecret(secret, obj.Spec.URL) - if err != nil { + clientOpts, err := getter.GetClientOpts(ctx, r.Client, obj, normalizedURL) + if err != nil { + if errors.Is(err, getter.ErrDeprecatedTLSConfig) { + ctrl.LoggerFrom(ctx). + Info("warning: specifying TLS authentication data via `.spec.secretRef` is deprecated, please use `.spec.certSecretRef` instead") + } else { e := &serror.Event{ - Err: fmt.Errorf("failed to create TLS client config with secret data: %w", err), + Err: err, Reason: sourcev1.AuthenticationFailedReason, } conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) - // Requeue as content of secret might change return sreconcile.ResultEmpty, e } } // Construct Helm chart repository with options and download index - newChartRepo, err := repository.NewChartRepository(obj.Spec.URL, "", r.Getters, tlsConfig, clientOpts...) + newChartRepo, err := repository.NewChartRepository(obj.Spec.URL, "", r.Getters, clientOpts.TlsConfig, clientOpts.GetterOpts...) if err != nil { switch err.(type) { case *url.Error: diff --git a/internal/controller/helmrepository_controller_oci.go b/internal/controller/helmrepository_controller_oci.go index 2af060f30..048227978 100644 --- a/internal/controller/helmrepository_controller_oci.go +++ b/internal/controller/helmrepository_controller_oci.go @@ -54,6 +54,7 @@ import ( "github.com/fluxcd/source-controller/internal/helm/registry" "github.com/fluxcd/source-controller/internal/helm/repository" "github.com/fluxcd/source-controller/internal/object" + soci "github.com/fluxcd/source-controller/internal/oci" intpredicates "github.com/fluxcd/source-controller/internal/predicates" ) @@ -318,7 +319,7 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, sp *patch.S return } } else if obj.Spec.Provider != helmv1.GenericOCIProvider && obj.Spec.Type == helmv1.HelmRepositoryTypeOCI { - auth, authErr := oidcAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider) + auth, authErr := soci.OIDCAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider) 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()) diff --git a/internal/controller/helmrepository_controller_test.go b/internal/controller/helmrepository_controller_test.go index d6f56920c..bd3e45f6a 100644 --- a/internal/controller/helmrepository_controller_test.go +++ b/internal/controller/helmrepository_controller_test.go @@ -388,7 +388,7 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { assertConditions []metav1.Condition }{ { - name: "HTTPS with secretRef pointing to CA cert but public repo URL succeeds", + name: "HTTPS with certSecretRef pointing to CA cert but public repo URL succeeds", protocol: "http", url: "https://stefanprodan.github.io/podinfo", want: sreconcile.ResultSuccess, @@ -400,6 +400,9 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { "caFile": tlsCA, }, }, + beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev, dig digest.Digest) { + obj.Spec.CertSecretRef = &meta.LocalObjectReference{Name: "ca-file"} + }, assertConditions: []metav1.Condition{ *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), @@ -450,37 +453,7 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { }, }, { - name: "HTTPS with CAFile secret makes ArtifactOutdated=True", - protocol: "https", - server: options{ - publicKey: tlsPublicKey, - privateKey: tlsPrivateKey, - ca: tlsCA, - }, - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ca-file", - }, - Data: map[string][]byte{ - "caFile": tlsCA, - }, - }, - beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev, dig digest.Digest) { - obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "ca-file"} - }, - want: sreconcile.ResultSuccess, - assertConditions: []metav1.Condition{ - *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new index revision"), - *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new index revision"), - }, - afterFunc: func(t *WithT, obj *helmv1.HelmRepository, artifact sourcev1.Artifact, chartRepo *repository.ChartRepository) { - t.Expect(chartRepo.Path).ToNot(BeEmpty()) - t.Expect(chartRepo.Index).ToNot(BeNil()) - t.Expect(artifact.Revision).ToNot(BeEmpty()) - }, - }, - { - name: "HTTPS with invalid CAFile secret makes FetchFailed=True and returns error", + name: "HTTPS with invalid CAFile in certSecretRef makes FetchFailed=True and returns error", protocol: "https", server: options{ publicKey: tlsPublicKey, @@ -496,13 +469,13 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { }, }, beforeFunc: func(t *WithT, obj *helmv1.HelmRepository, rev, dig digest.Digest) { - obj.Spec.SecretRef = &meta.LocalObjectReference{Name: "invalid-ca"} + obj.Spec.CertSecretRef = &meta.LocalObjectReference{Name: "invalid-ca"} conditions.MarkReconciling(obj, meta.ProgressingReason, "foo") conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar") }, wantErr: true, assertConditions: []metav1.Condition{ - *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to create TLS client config with secret data: cannot append certificate into certificate pool: invalid caFile"), + *conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "cannot append certificate into certificate pool: invalid caFile"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"), *conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"), }, @@ -766,32 +739,32 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { } // Calculate the artifact digest for valid repos configurations. - clientOpts := []helmgetter.Option{ + getterOpts := []helmgetter.Option{ helmgetter.WithURL(server.URL()), } var newChartRepo *repository.ChartRepository - var tOpts *tls.Config + var tlsConf *tls.Config validSecret := true if secret != nil { // Extract the client options from secret, ignoring any invalid // value. validSecret is used to determine if the index digest // should be calculated below. - var cOpts []helmgetter.Option + var gOpts []helmgetter.Option var serr error - cOpts, serr = getter.ClientOptionsFromSecret(*secret) + gOpts, serr = getter.GetterOptionsFromSecret(*secret) if serr != nil { validSecret = false } - clientOpts = append(clientOpts, cOpts...) + getterOpts = append(getterOpts, gOpts...) repoURL := server.URL() if tt.url != "" { repoURL = tt.url } - tOpts, serr = getter.TLSClientConfigFromSecret(*secret, repoURL) + tlsConf, serr = getter.TLSClientConfigFromSecret(*secret, repoURL) if serr != nil { validSecret = false } - newChartRepo, err = repository.NewChartRepository(obj.Spec.URL, "", testGetters, tOpts, clientOpts...) + newChartRepo, err = repository.NewChartRepository(obj.Spec.URL, "", testGetters, tlsConf, getterOpts...) } else { newChartRepo, err = repository.NewChartRepository(obj.Spec.URL, "", testGetters, nil) } @@ -807,9 +780,6 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { g.Expect(newChartRepo.LoadFromPath()).To(Succeed()) rev = newChartRepo.Digest(intdigest.Canonical) } - if tt.beforeFunc != nil { - tt.beforeFunc(g, obj, rev, dig) - } r := &HelmRepositoryReconciler{ EventRecorder: record.NewFakeRecorder(32), @@ -818,6 +788,9 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) { Getters: testGetters, patchOptions: getPatchOptions(helmRepositoryReadyCondition.Owned, "sc"), } + if tt.beforeFunc != nil { + tt.beforeFunc(g, obj, rev, dig) + } g.Expect(r.Client.Create(context.TODO(), obj)).ToNot(HaveOccurred()) defer func() { diff --git a/internal/controller/ocirepository_controller.go b/internal/controller/ocirepository_controller.go index 9ab36c748..7b282979e 100644 --- a/internal/controller/ocirepository_controller.go +++ b/internal/controller/ocirepository_controller.go @@ -55,7 +55,6 @@ import ( eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" "github.com/fluxcd/pkg/apis/meta" "github.com/fluxcd/pkg/oci" - "github.com/fluxcd/pkg/oci/auth/login" "github.com/fluxcd/pkg/runtime/conditions" helper "github.com/fluxcd/pkg/runtime/controller" "github.com/fluxcd/pkg/runtime/patch" @@ -345,7 +344,7 @@ func (r *OCIRepositoryReconciler) reconcileSource(ctx context.Context, sp *patch if _, ok := keychain.(soci.Anonymous); obj.Spec.Provider != ociv1.GenericOCIProvider && ok { var authErr error - auth, authErr = oidcAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider) + auth, authErr = soci.OIDCAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider) if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) { e := serror.NewGeneric( fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr), @@ -870,27 +869,6 @@ func (r *OCIRepositoryReconciler) transport(ctx context.Context, obj *ociv1.OCIR return transport, nil } -// oidcAuth generates the OIDC credential authenticator based on the specified cloud provider. -func oidcAuth(ctx context.Context, url, provider string) (authn.Authenticator, error) { - u := strings.TrimPrefix(url, ociv1.OCIRepositoryPrefix) - ref, err := name.ParseReference(u) - if err != nil { - return nil, fmt.Errorf("failed to parse URL '%s': %w", u, err) - } - - opts := login.ProviderOptions{} - switch provider { - case ociv1.AmazonOCIProvider: - opts.AwsAutoLogin = true - case ociv1.AzureOCIProvider: - opts.AzureAutoLogin = true - case ociv1.GoogleOCIProvider: - opts.GcpAutoLogin = true - } - - return login.NewManager().Login(ctx, u, ref, opts) -} - // reconcileStorage ensures the current state of the storage matches the // desired and previously observed state. // diff --git a/internal/helm/getter/client_opts.go b/internal/helm/getter/client_opts.go new file mode 100644 index 000000000..2af928c8e --- /dev/null +++ b/internal/helm/getter/client_opts.go @@ -0,0 +1,196 @@ +/* +Copyright 2023 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 getter + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/url" + + "github.com/fluxcd/pkg/oci" + "github.com/google/go-containerregistry/pkg/authn" + helmgetter "helm.sh/helm/v3/pkg/getter" + helmreg "helm.sh/helm/v3/pkg/registry" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + helmv1 "github.com/fluxcd/source-controller/api/v1beta2" + "github.com/fluxcd/source-controller/internal/helm/registry" + soci "github.com/fluxcd/source-controller/internal/oci" +) + +var ErrDeprecatedTLSConfig = errors.New("TLS configured in a deprecated manner") + +// ClientOpts contains the various options to use while constructing +// a Helm repository client. +type ClientOpts struct { + Authenticator authn.Authenticator + Keychain authn.Keychain + RegLoginOpt helmreg.LoginOption + TlsConfig *tls.Config + GetterOpts []helmgetter.Option +} + +// GetClientOpts uses the provided HelmRepository object and a normalized +// URL to construct a HelmClientOpts object. If obj is an OCI HelmRepository, +// then the returned options object will also contain the required registry +// auth mechanisms. +func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmRepository, url string) (*ClientOpts, error) { + hrOpts := &ClientOpts{ + GetterOpts: []helmgetter.Option{ + helmgetter.WithURL(url), + helmgetter.WithTimeout(obj.Spec.Timeout.Duration), + helmgetter.WithPassCredentialsAll(obj.Spec.PassCredentials), + }, + } + ociRepo := obj.Spec.Type == helmv1.HelmRepositoryTypeOCI + + var certSecret *corev1.Secret + var err error + // Check `.spec.certSecretRef` first for any TLS auth data. + if obj.Spec.CertSecretRef != nil { + certSecret, err = fetchSecret(ctx, c, obj.Spec.CertSecretRef.Name, obj.GetNamespace()) + if err != nil { + return nil, fmt.Errorf("failed to get TLS authentication secret '%s/%s': %w", obj.GetNamespace(), obj.Spec.CertSecretRef.Name, err) + } + + hrOpts.TlsConfig, err = TLSClientConfigFromSecret(*certSecret, url) + if err != nil { + return nil, fmt.Errorf("failed to construct Helm client's TLS config: %w", err) + } + } + + var authSecret *corev1.Secret + var deprecatedTLSConfig bool + if obj.Spec.SecretRef != nil { + authSecret, err = fetchSecret(ctx, c, obj.Spec.SecretRef.Name, obj.GetNamespace()) + if err != nil { + return nil, fmt.Errorf("failed to get authentication secret '%s/%s': %w", obj.GetNamespace(), obj.Spec.SecretRef.Name, err) + } + + // Construct actual Helm client options. + opts, err := GetterOptionsFromSecret(*authSecret) + if err != nil { + return nil, fmt.Errorf("failed to configure Helm client: %w", err) + } + hrOpts.GetterOpts = append(hrOpts.GetterOpts, opts...) + + // If the TLS config is nil, i.e. one couldn't be constructed using `.spec.certSecretRef` + // then try to use `.spec.certSecretRef`. + if hrOpts.TlsConfig == nil { + hrOpts.TlsConfig, err = TLSClientConfigFromSecret(*authSecret, url) + if err != nil { + return nil, fmt.Errorf("failed to construct Helm client's TLS config: %w", err) + } + // Constructing a TLS config using the auth secret is deprecated behavior. + if hrOpts.TlsConfig != nil { + deprecatedTLSConfig = true + } + } + + if ociRepo { + hrOpts.Keychain, err = registry.LoginOptionFromSecret(url, *authSecret) + if err != nil { + return nil, fmt.Errorf("failed to configure login options: %w", err) + } + } + } else if obj.Spec.Provider != helmv1.GenericOCIProvider && obj.Spec.Type == helmv1.HelmRepositoryTypeOCI && ociRepo { + authenticator, authErr := soci.OIDCAuth(ctx, obj.Spec.URL, obj.Spec.Provider) + if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) { + return nil, fmt.Errorf("failed to get credential from '%s': %w", obj.Spec.Provider, authErr) + } + if authenticator != nil { + hrOpts.Authenticator = authenticator + } + } + + if ociRepo { + hrOpts.RegLoginOpt, err = registry.NewLoginOption(hrOpts.Authenticator, hrOpts.Keychain, url) + if err != nil { + return nil, err + } + } + if deprecatedTLSConfig { + err = ErrDeprecatedTLSConfig + } + + return hrOpts, err +} + +func fetchSecret(ctx context.Context, c client.Client, name, namespace string) (*corev1.Secret, error) { + key := types.NamespacedName{ + Namespace: namespace, + Name: name, + } + var secret corev1.Secret + if err := c.Get(ctx, key, &secret); err != nil { + return nil, err + } + return &secret, nil +} + +// TLSClientConfigFromSecret attempts to construct a TLS client config +// for the given v1.Secret. It returns the TLS client config or an error. +// +// Secrets with no certFile, keyFile, AND caFile are ignored, if only a +// certBytes OR keyBytes is defined it returns an error. +func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls.Config, error) { + certBytes, keyBytes, caBytes := secret.Data["certFile"], secret.Data["keyFile"], secret.Data["caFile"] + switch { + case len(certBytes)+len(keyBytes)+len(caBytes) == 0: + return nil, nil + case (len(certBytes) > 0 && len(keyBytes) == 0) || (len(keyBytes) > 0 && len(certBytes) == 0): + return nil, fmt.Errorf("invalid '%s' secret data: fields 'certFile' and 'keyFile' require each other's presence", + secret.Name) + } + + tlsConf := &tls.Config{} + if len(certBytes) > 0 && len(keyBytes) > 0 { + cert, err := tls.X509KeyPair(certBytes, keyBytes) + if err != nil { + return nil, err + } + tlsConf.Certificates = append(tlsConf.Certificates, cert) + } + + if len(caBytes) > 0 { + cp, err := x509.SystemCertPool() + if err != nil { + return nil, fmt.Errorf("cannot retrieve system certificate pool: %w", err) + } + if !cp.AppendCertsFromPEM(caBytes) { + return nil, fmt.Errorf("cannot append certificate into certificate pool: invalid caFile") + } + + tlsConf.RootCAs = cp + } + + tlsConf.BuildNameToCertificate() + + u, err := url.Parse(repositoryUrl) + if err != nil { + return nil, fmt.Errorf("cannot parse repository URL: %w", err) + } + + tlsConf.ServerName = u.Hostname() + + return tlsConf, nil +} diff --git a/internal/helm/getter/client_opts_test.go b/internal/helm/getter/client_opts_test.go new file mode 100644 index 000000000..2231e2a52 --- /dev/null +++ b/internal/helm/getter/client_opts_test.go @@ -0,0 +1,254 @@ +/* +Copyright 2023 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 getter + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "math/big" + "os" + "testing" + "time" + + "github.com/fluxcd/pkg/apis/meta" + "github.com/google/go-containerregistry/pkg/name" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + + helmv1 "github.com/fluxcd/source-controller/api/v1beta2" +) + +func TestGetClientOpts(t *testing.T) { + tlsCA, err := os.ReadFile("../../controller/testdata/certs/ca.pem") + if err != nil { + t.Errorf("could not read CA file: %s", err) + } + + tests := []struct { + name string + certSecret *corev1.Secret + authSecret *corev1.Secret + afterFunc func(t *WithT, hcOpts *ClientOpts) + oci bool + err error + }{ + { + name: "HelmRepository with certSecretRef discards TLS config in secretRef", + certSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ca-file", + }, + Data: map[string][]byte{ + "caFile": tlsCA, + }, + }, + authSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "auth", + }, + Data: map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + "caFile": []byte("invalid"), + }, + }, + afterFunc: func(t *WithT, hcOpts *ClientOpts) { + t.Expect(hcOpts.TlsConfig).ToNot(BeNil()) + t.Expect(len(hcOpts.GetterOpts)).To(Equal(4)) + }, + }, + { + name: "HelmRepository with TLS config only in secretRef is marked as deprecated", + authSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-tls", + }, + Data: map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + "caFile": tlsCA, + }, + }, + afterFunc: func(t *WithT, hcOpts *ClientOpts) { + t.Expect(hcOpts.TlsConfig).ToNot(BeNil()) + t.Expect(len(hcOpts.GetterOpts)).To(Equal(4)) + }, + err: ErrDeprecatedTLSConfig, + }, + { + name: "OCI HelmRepository with secretRef has auth configured", + authSecret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "auth-oci", + }, + Data: map[string][]byte{ + "username": []byte("user"), + "password": []byte("pass"), + }, + }, + afterFunc: func(t *WithT, hcOpts *ClientOpts) { + repo, err := name.NewRepository("ghcr.io/dummy") + t.Expect(err).ToNot(HaveOccurred()) + authenticator, err := hcOpts.Keychain.Resolve(repo) + t.Expect(err).ToNot(HaveOccurred()) + config, err := authenticator.Authorization() + t.Expect(err).ToNot(HaveOccurred()) + t.Expect(config.Username).To(Equal("user")) + t.Expect(config.Password).To(Equal("pass")) + }, + oci: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + helmRepo := &helmv1.HelmRepository{ + Spec: helmv1.HelmRepositorySpec{ + Timeout: &metav1.Duration{ + Duration: time.Second, + }, + }, + } + if tt.oci { + helmRepo.Spec.Type = helmv1.HelmRepositoryTypeOCI + } + + clientBuilder := fakeclient.NewClientBuilder() + if tt.authSecret != nil { + clientBuilder.WithObjects(tt.authSecret.DeepCopy()) + helmRepo.Spec.SecretRef = &meta.LocalObjectReference{ + Name: tt.authSecret.Name, + } + } + if tt.certSecret != nil { + clientBuilder.WithObjects(tt.certSecret.DeepCopy()) + helmRepo.Spec.CertSecretRef = &meta.LocalObjectReference{ + Name: tt.certSecret.Name, + } + } + c := clientBuilder.Build() + + clientOpts, err := GetClientOpts(context.TODO(), c, helmRepo, "https://ghcr.io/dummy") + if tt.err != nil { + g.Expect(err).To(Equal(tt.err)) + } else { + g.Expect(err).ToNot(HaveOccurred()) + } + tt.afterFunc(g, clientOpts) + }) + } +} + +func Test_tlsClientConfigFromSecret(t *testing.T) { + tlsSecretFixture := validTlsSecret(t) + + tests := []struct { + name string + secret corev1.Secret + modify func(secret *corev1.Secret) + wantErr bool + wantNil bool + }{ + {"certFile, keyFile and caFile", tlsSecretFixture, nil, false, false}, + {"without certFile", tlsSecretFixture, func(s *corev1.Secret) { delete(s.Data, "certFile") }, true, true}, + {"without keyFile", tlsSecretFixture, func(s *corev1.Secret) { delete(s.Data, "keyFile") }, true, true}, + {"without caFile", tlsSecretFixture, func(s *corev1.Secret) { delete(s.Data, "caFile") }, false, false}, + {"empty", corev1.Secret{}, nil, false, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secret := tt.secret.DeepCopy() + if tt.modify != nil { + tt.modify(secret) + } + + got, err := TLSClientConfigFromSecret(*secret, "") + if (err != nil) != tt.wantErr { + t.Errorf("TLSClientConfigFromSecret() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantNil && got != nil { + t.Error("TLSClientConfigFromSecret() != nil") + return + } + }) + } +} + +// validTlsSecret creates a secret containing key pair and CA certificate that are +// valid from a syntax (minimum requirements) perspective. +func validTlsSecret(t *testing.T) corev1.Secret { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal("Private key cannot be created.", err.Error()) + } + + certTemplate := x509.Certificate{ + SerialNumber: big.NewInt(1337), + } + cert, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &key.PublicKey, key) + if err != nil { + t.Fatal("Certificate cannot be created.", err.Error()) + } + + ca := &x509.Certificate{ + SerialNumber: big.NewInt(7331), + IsCA: true, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + } + + caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + t.Fatal("CA private key cannot be created.", err.Error()) + } + + caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) + if err != nil { + t.Fatal("CA certificate cannot be created.", err.Error()) + } + + keyPem := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + }) + + certPem := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: cert, + }) + + caPem := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: caBytes, + }) + + return corev1.Secret{ + Data: map[string][]byte{ + "certFile": []byte(certPem), + "keyFile": []byte(keyPem), + "caFile": []byte(caPem), + }, + } +} diff --git a/internal/helm/getter/getter.go b/internal/helm/getter/getter.go index 25214372f..18661da16 100644 --- a/internal/helm/getter/getter.go +++ b/internal/helm/getter/getter.go @@ -17,20 +17,17 @@ limitations under the License. package getter import ( - "crypto/tls" - "crypto/x509" "fmt" - "net/url" "helm.sh/helm/v3/pkg/getter" corev1 "k8s.io/api/core/v1" ) -// ClientOptionsFromSecret constructs a getter.Option slice for the given secret. +// GetterOptionsFromSecret constructs a getter.Option slice for the given secret. // It returns the slice, or an error. -func ClientOptionsFromSecret(secret corev1.Secret) ([]getter.Option, error) { +func GetterOptionsFromSecret(secret corev1.Secret) ([]getter.Option, error) { var opts []getter.Option - basicAuth, err := BasicAuthFromSecret(secret) + basicAuth, err := basicAuthFromSecret(secret) if err != nil { return opts, err } @@ -40,12 +37,12 @@ func ClientOptionsFromSecret(secret corev1.Secret) ([]getter.Option, error) { return opts, nil } -// BasicAuthFromSecret attempts to construct a basic auth getter.Option for the +// basicAuthFromSecret attempts to construct a basic auth getter.Option for the // given v1.Secret and returns the result. // // Secrets with no username AND password are ignored, if only one is defined it // returns an error. -func BasicAuthFromSecret(secret corev1.Secret) (getter.Option, error) { +func basicAuthFromSecret(secret corev1.Secret) (getter.Option, error) { username, password := string(secret.Data["username"]), string(secret.Data["password"]) switch { case username == "" && password == "": @@ -55,51 +52,3 @@ func BasicAuthFromSecret(secret corev1.Secret) (getter.Option, error) { } return getter.WithBasicAuth(username, password), nil } - -// TLSClientConfigFromSecret attempts to construct a TLS client config -// for the given v1.Secret. It returns the TLS client config or an error. -// -// Secrets with no certFile, keyFile, AND caFile are ignored, if only a -// certBytes OR keyBytes is defined it returns an error. -func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls.Config, error) { - certBytes, keyBytes, caBytes := secret.Data["certFile"], secret.Data["keyFile"], secret.Data["caFile"] - switch { - case len(certBytes)+len(keyBytes)+len(caBytes) == 0: - return nil, nil - case (len(certBytes) > 0 && len(keyBytes) == 0) || (len(keyBytes) > 0 && len(certBytes) == 0): - return nil, fmt.Errorf("invalid '%s' secret data: fields 'certFile' and 'keyFile' require each other's presence", - secret.Name) - } - - tlsConf := &tls.Config{} - if len(certBytes) > 0 && len(keyBytes) > 0 { - cert, err := tls.X509KeyPair(certBytes, keyBytes) - if err != nil { - return nil, err - } - tlsConf.Certificates = append(tlsConf.Certificates, cert) - } - - if len(caBytes) > 0 { - cp, err := x509.SystemCertPool() - if err != nil { - return nil, fmt.Errorf("cannot retrieve system certificate pool: %w", err) - } - if !cp.AppendCertsFromPEM(caBytes) { - return nil, fmt.Errorf("cannot append certificate into certificate pool: invalid caFile") - } - - tlsConf.RootCAs = cp - } - - tlsConf.BuildNameToCertificate() - - u, err := url.Parse(repositoryUrl) - if err != nil { - return nil, fmt.Errorf("cannot parse repository URL: %w", err) - } - - tlsConf.ServerName = u.Hostname() - - return tlsConf, nil -} diff --git a/internal/helm/getter/getter_test.go b/internal/helm/getter/getter_test.go index a13c029e3..cffe0064f 100644 --- a/internal/helm/getter/getter_test.go +++ b/internal/helm/getter/getter_test.go @@ -17,11 +17,6 @@ limitations under the License. package getter import ( - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "math/big" "testing" corev1 "k8s.io/api/core/v1" @@ -36,7 +31,7 @@ var ( } ) -func TestClientOptionsFromSecret(t *testing.T) { +func TestGetterOptionsFromSecret(t *testing.T) { tests := []struct { name string secrets []corev1.Secret @@ -53,7 +48,7 @@ func TestClientOptionsFromSecret(t *testing.T) { } } - got, err := ClientOptionsFromSecret(secret) + got, err := GetterOptionsFromSecret(secret) if err != nil { t.Errorf("ClientOptionsFromSecret() error = %v", err) return @@ -65,7 +60,7 @@ func TestClientOptionsFromSecret(t *testing.T) { } } -func TestBasicAuthFromSecret(t *testing.T) { +func Test_basicAuthFromSecret(t *testing.T) { tests := []struct { name string secret corev1.Secret @@ -84,7 +79,7 @@ func TestBasicAuthFromSecret(t *testing.T) { if tt.modify != nil { tt.modify(secret) } - got, err := BasicAuthFromSecret(*secret) + got, err := basicAuthFromSecret(*secret) if (err != nil) != tt.wantErr { t.Errorf("BasicAuthFromSecret() error = %v, wantErr %v", err, tt.wantErr) return @@ -96,96 +91,3 @@ func TestBasicAuthFromSecret(t *testing.T) { }) } } - -func TestTLSClientConfigFromSecret(t *testing.T) { - tlsSecretFixture := validTlsSecret(t) - - tests := []struct { - name string - secret corev1.Secret - modify func(secret *corev1.Secret) - wantErr bool - wantNil bool - }{ - {"certFile, keyFile and caFile", tlsSecretFixture, nil, false, false}, - {"without certFile", tlsSecretFixture, func(s *corev1.Secret) { delete(s.Data, "certFile") }, true, true}, - {"without keyFile", tlsSecretFixture, func(s *corev1.Secret) { delete(s.Data, "keyFile") }, true, true}, - {"without caFile", tlsSecretFixture, func(s *corev1.Secret) { delete(s.Data, "caFile") }, false, false}, - {"empty", corev1.Secret{}, nil, false, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - secret := tt.secret.DeepCopy() - if tt.modify != nil { - tt.modify(secret) - } - - got, err := TLSClientConfigFromSecret(*secret, "") - if (err != nil) != tt.wantErr { - t.Errorf("TLSClientConfigFromSecret() error = %v, wantErr %v", err, tt.wantErr) - return - } - if tt.wantNil && got != nil { - t.Error("TLSClientConfigFromSecret() != nil") - return - } - }) - } -} - -// validTlsSecret creates a secret containing key pair and CA certificate that are -// valid from a syntax (minimum requirements) perspective. -func validTlsSecret(t *testing.T) corev1.Secret { - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatal("Private key cannot be created.", err.Error()) - } - - certTemplate := x509.Certificate{ - SerialNumber: big.NewInt(1337), - } - cert, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &key.PublicKey, key) - if err != nil { - t.Fatal("Certificate cannot be created.", err.Error()) - } - - ca := &x509.Certificate{ - SerialNumber: big.NewInt(7331), - IsCA: true, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, - } - - caPrivKey, err := rsa.GenerateKey(rand.Reader, 4096) - if err != nil { - t.Fatal("CA private key cannot be created.", err.Error()) - } - - caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey) - if err != nil { - t.Fatal("CA certificate cannot be created.", err.Error()) - } - - keyPem := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(key), - }) - - certPem := pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: cert, - }) - - caPem := pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: caBytes, - }) - - return corev1.Secret{ - Data: map[string][]byte{ - "certFile": []byte(certPem), - "keyFile": []byte(keyPem), - "caFile": []byte(caPem), - }, - } -} diff --git a/internal/helm/registry/auth.go b/internal/helm/registry/auth.go index d843d7d3b..c48ec0b2b 100644 --- a/internal/helm/registry/auth.go +++ b/internal/helm/registry/auth.go @@ -26,6 +26,7 @@ import ( "github.com/fluxcd/source-controller/internal/oci" "github.com/google/go-containerregistry/pkg/authn" "helm.sh/helm/v3/pkg/registry" + helmreg "helm.sh/helm/v3/pkg/registry" corev1 "k8s.io/api/core/v1" ) @@ -139,3 +140,17 @@ func (r stringResource) String() string { func (r stringResource) RegistryStr() string { return r.registry } + +// NewLoginOption returns a registry login option for the given HelmRepository. +// If the HelmRepository does not specify a secretRef, a nil login option is returned. +func NewLoginOption(auth authn.Authenticator, keychain authn.Keychain, registryURL string) (helmreg.LoginOption, error) { + if auth != nil { + return AuthAdaptHelper(auth) + } + + if keychain != nil { + return KeychainAdaptHelper(keychain)(registryURL) + } + + return nil, nil +} diff --git a/internal/oci/auth.go b/internal/oci/auth.go index 88b0e9442..7b3eab896 100644 --- a/internal/oci/auth.go +++ b/internal/oci/auth.go @@ -16,7 +16,17 @@ limitations under the License. package oci -import "github.com/google/go-containerregistry/pkg/authn" +import ( + "context" + "fmt" + "strings" + + "github.com/fluxcd/pkg/oci/auth/login" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" +) // Anonymous is an authn.AuthConfig that always returns an anonymous // authenticator. It is useful for registries that do not require authentication @@ -28,3 +38,24 @@ type Anonymous authn.AuthConfig func (a Anonymous) Resolve(_ authn.Resource) (authn.Authenticator, error) { return authn.Anonymous, nil } + +// OIDCAuth generates the OIDC credential authenticator based on the specified cloud provider. +func OIDCAuth(ctx context.Context, url, provider string) (authn.Authenticator, error) { + u := strings.TrimPrefix(url, sourcev1.OCIRepositoryPrefix) + ref, err := name.ParseReference(u) + if err != nil { + return nil, fmt.Errorf("failed to parse URL '%s': %w", u, err) + } + + opts := login.ProviderOptions{} + switch provider { + case sourcev1.AmazonOCIProvider: + opts.AwsAutoLogin = true + case sourcev1.AzureOCIProvider: + opts.AzureAutoLogin = true + case sourcev1.GoogleOCIProvider: + opts.GcpAutoLogin = true + } + + return login.NewManager().Login(ctx, u, ref, opts) +}