Add support for custom certificate and skip-tls-verify in helm OCI

If implemented user will be able to provide their own custom start and
bypass tls verification when interacting with OCI registries over https
to pull helmCharts.

Signed-off-by: Soule BA <soule@weave.works>
This commit is contained in:
Soule BA 2023-05-16 18:12:32 +02:00 committed by Stefan Prodan
parent 6377c6fa4a
commit d45c08cba6
No known key found for this signature in database
GPG Key ID: 3299AEB0E4085BAF
14 changed files with 615 additions and 185 deletions

View File

@ -459,8 +459,6 @@ a deprecation warning will be logged.
### Cert secret reference ### Cert secret reference
**Note:** TLS authentication is not yet supported by OCI Helm repositories.
`.spec.certSecretRef.name` is an optional field to specify a secret containing TLS `.spec.certSecretRef.name` is an optional field to specify a secret containing TLS
certificate data. The secret can contain the following keys: certificate data. The secret can contain the following keys:

View File

@ -512,7 +512,8 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
if err != nil { if err != nil {
return chartRepoConfigErrorReturn(err, obj) return chartRepoConfigErrorReturn(err, obj)
} }
clientOpts, err := getter.GetClientOpts(ctxTimeout, r.Client, repo, normalizedURL)
clientOpts, certsTmpDir, err := getter.GetClientOpts(ctxTimeout, r.Client, repo, normalizedURL)
if err != nil && !errors.Is(err, getter.ErrDeprecatedTLSConfig) { if err != nil && !errors.Is(err, getter.ErrDeprecatedTLSConfig) {
e := &serror.Event{ e := &serror.Event{
Err: err, Err: err,
@ -521,6 +522,15 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error()) conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Err.Error())
return sreconcile.ResultEmpty, e return sreconcile.ResultEmpty, e
} }
if certsTmpDir != "" {
defer func() {
if err := os.RemoveAll(certsTmpDir); err != nil {
r.eventLogf(ctx, obj, corev1.EventTypeWarning, meta.FailedReason,
"failed to delete temporary certificates directory: %s", err)
}
}()
}
getterOpts := clientOpts.GetterOpts getterOpts := clientOpts.GetterOpts
// Initialize the chart repository // Initialize the chart repository
@ -536,7 +546,7 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
// this is needed because otherwise the credentials are stored in ~/.docker/config.json. // this is needed because otherwise the credentials are stored in ~/.docker/config.json.
// TODO@souleb: remove this once the registry move to Oras v2 // TODO@souleb: remove this once the registry move to Oras v2
// or rework to enable reusing credentials to avoid the unneccessary handshake operations // or rework to enable reusing credentials to avoid the unneccessary handshake operations
registryClient, credentialsFile, err := r.RegistryClientGenerator(clientOpts.RegLoginOpt != nil) registryClient, credentialsFile, err := r.RegistryClientGenerator(clientOpts.TlsConfig, clientOpts.MustLoginToRegistry())
if err != nil { if err != nil {
e := &serror.Event{ e := &serror.Event{
Err: fmt.Errorf("failed to construct Helm client: %w", err), Err: fmt.Errorf("failed to construct Helm client: %w", err),
@ -585,8 +595,8 @@ func (r *HelmChartReconciler) buildFromHelmRepository(ctx context.Context, obj *
// If login options are configured, use them to login to the registry // If login options are configured, use them to login to the registry
// The OCIGetter will later retrieve the stored credentials to pull the chart // The OCIGetter will later retrieve the stored credentials to pull the chart
if clientOpts.RegLoginOpt != nil { if clientOpts.MustLoginToRegistry() {
err = ociChartRepo.Login(clientOpts.RegLoginOpt) err = ociChartRepo.Login(clientOpts.RegLoginOpts...)
if err != nil { if err != nil {
e := &serror.Event{ e := &serror.Event{
Err: fmt.Errorf("failed to login to OCI registry: %w", err), Err: fmt.Errorf("failed to login to OCI registry: %w", err),
@ -983,7 +993,7 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration) ctxTimeout, cancel := context.WithTimeout(ctx, obj.Spec.Timeout.Duration)
defer cancel() defer cancel()
clientOpts, err := getter.GetClientOpts(ctxTimeout, r.Client, obj, normalizedURL) clientOpts, certsTmpDir, err := getter.GetClientOpts(ctxTimeout, r.Client, obj, normalizedURL)
if err != nil && !errors.Is(err, getter.ErrDeprecatedTLSConfig) { if err != nil && !errors.Is(err, getter.ErrDeprecatedTLSConfig) {
return nil, err return nil, err
} }
@ -991,7 +1001,7 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
var chartRepo repository.Downloader var chartRepo repository.Downloader
if helmreg.IsOCI(normalizedURL) { if helmreg.IsOCI(normalizedURL) {
registryClient, credentialsFile, err := r.RegistryClientGenerator(clientOpts.RegLoginOpt != nil) registryClient, credentialsFile, err := r.RegistryClientGenerator(clientOpts.TlsConfig, clientOpts.MustLoginToRegistry())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create registry client: %w", err) return nil, fmt.Errorf("failed to create registry client: %w", err)
} }
@ -1002,6 +1012,7 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL, repository.WithOCIGetter(r.Getters), ociChartRepo, err := repository.NewOCIChartRepository(normalizedURL, repository.WithOCIGetter(r.Getters),
repository.WithOCIGetterOptions(getterOpts), repository.WithOCIGetterOptions(getterOpts),
repository.WithOCIRegistryClient(registryClient), repository.WithOCIRegistryClient(registryClient),
repository.WithCertificatesStore(certsTmpDir),
repository.WithCredentialsFile(credentialsFile)) repository.WithCredentialsFile(credentialsFile))
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("failed to create OCI chart repository: %w", err)) errs = append(errs, fmt.Errorf("failed to create OCI chart repository: %w", err))
@ -1016,8 +1027,8 @@ func (r *HelmChartReconciler) namespacedChartRepositoryCallback(ctx context.Cont
// If login options are configured, use them to login to the registry // If login options are configured, use them to login to the registry
// The OCIGetter will later retrieve the stored credentials to pull the chart // The OCIGetter will later retrieve the stored credentials to pull the chart
if clientOpts.RegLoginOpt != nil { if clientOpts.MustLoginToRegistry() {
err = ociChartRepo.Login(clientOpts.RegLoginOpt) err = ociChartRepo.Login(clientOpts.RegLoginOpts...)
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("failed to login to OCI chart repository: %w", err)) errs = append(errs, fmt.Errorf("failed to login to OCI chart repository: %w", err))
// clean up the credentialsFile // clean up the credentialsFile

View File

@ -1109,7 +1109,7 @@ func TestHelmChartReconciler_buildFromOCIHelmRepository(t *testing.T) {
g.Expect(err).NotTo(HaveOccurred()) g.Expect(err).NotTo(HaveOccurred())
// Upload the test chart // Upload the test chart
metadata, err := loadTestChartToOCI(chartData, chartPath, testRegistryServer) metadata, err := loadTestChartToOCI(chartData, testRegistryServer, "", "", "")
g.Expect(err).NotTo(HaveOccurred()) g.Expect(err).NotTo(HaveOccurred())
storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords) storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords)
@ -2244,6 +2244,9 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
url string url string
registryOpts registryOptions registryOpts registryOptions
secretOpts secretOptions secretOpts secretOptions
secret *corev1.Secret
certsecret *corev1.Secret
insecure bool
provider string provider string
providerImg string providerImg string
want sreconcile.Result want sreconcile.Result
@ -2253,6 +2256,7 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
{ {
name: "HTTP without basic auth", name: "HTTP without basic auth",
want: sreconcile.ResultSuccess, want: sreconcile.ResultSuccess,
insecure: true,
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
@ -2261,6 +2265,7 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
{ {
name: "HTTP with basic auth secret", name: "HTTP with basic auth secret",
want: sreconcile.ResultSuccess, want: sreconcile.ResultSuccess,
insecure: true,
registryOpts: registryOptions{ registryOpts: registryOptions{
withBasicAuth: true, withBasicAuth: true,
}, },
@ -2268,6 +2273,13 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
username: testRegistryUsername, username: testRegistryUsername,
password: testRegistryPassword, password: testRegistryPassword,
}, },
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
@ -2277,6 +2289,7 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
name: "HTTP registry - basic auth with invalid secret", name: "HTTP registry - basic auth with invalid secret",
want: sreconcile.ResultEmpty, want: sreconcile.ResultEmpty,
wantErr: true, wantErr: true,
insecure: true,
registryOpts: registryOptions{ registryOpts: registryOptions{
withBasicAuth: true, withBasicAuth: true,
}, },
@ -2284,6 +2297,13 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
username: "wrong-pass", username: "wrong-pass",
password: "wrong-pass", password: "wrong-pass",
}, },
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.FetchFailedCondition, "Unknown", "unknown build error: failed to login to OCI registry"), *conditions.TrueCondition(sourcev1.FetchFailedCondition, "Unknown", "unknown build error: failed to login to OCI registry"),
}, },
@ -2291,6 +2311,7 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
{ {
name: "with contextual login provider", name: "with contextual login provider",
wantErr: true, wantErr: true,
insecure: true,
provider: "aws", provider: "aws",
providerImg: "oci://123456789000.dkr.ecr.us-east-2.amazonaws.com/test", providerImg: "oci://123456789000.dkr.ecr.us-east-2.amazonaws.com/test",
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
@ -2303,16 +2324,87 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
registryOpts: registryOptions{ registryOpts: registryOptions{
withBasicAuth: true, withBasicAuth: true,
}, },
insecure: true,
secretOpts: secretOptions{ secretOpts: secretOptions{
username: testRegistryUsername, username: testRegistryUsername,
password: testRegistryPassword, password: testRegistryPassword,
}, },
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
provider: "azure", provider: "azure",
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"), *conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
}, },
}, },
{
name: "HTTPS With invalid CA cert",
wantErr: true,
registryOpts: registryOptions{
withTLS: true,
withClientCertAuth: true,
},
secretOpts: secretOptions{
username: testRegistryUsername,
password: testRegistryPassword,
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
certsecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "certs-secretref",
},
Data: map[string][]byte{
"caFile": []byte("invalid caFile"),
},
},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(sourcev1.FetchFailedCondition, "Unknown", "unknown build error: failed to construct Helm client's TLS config: cannot append certificate into certificate pool: invalid caFile"),
},
},
{
name: "HTTPS With CA cert",
want: sreconcile.ResultSuccess,
registryOpts: registryOptions{
withTLS: true,
withClientCertAuth: true,
},
secretOpts: secretOptions{
username: testRegistryUsername,
password: testRegistryPassword,
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
certsecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "certs-secretref",
},
Data: map[string][]byte{
"caFile": tlsCA,
"certFile": clientPublicKey,
"keyFile": clientPrivateKey,
},
},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: pulled 'helmchart' chart with version '0.1.0'"),
},
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -2325,7 +2417,9 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
workspaceDir := t.TempDir() workspaceDir := t.TempDir()
if tt.insecure {
tt.registryOpts.disableDNSMocking = true tt.registryOpts.disableDNSMocking = true
}
server, err := setupRegistryServer(ctx, workspaceDir, tt.registryOpts) server, err := setupRegistryServer(ctx, workspaceDir, tt.registryOpts)
g.Expect(err).NotTo(HaveOccurred()) g.Expect(err).NotTo(HaveOccurred())
t.Cleanup(func() { t.Cleanup(func() {
@ -2337,7 +2431,7 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
// Upload the test chart // Upload the test chart
metadata, err := loadTestChartToOCI(chartData, chartPath, server) metadata, err := loadTestChartToOCI(chartData, server, "testdata/certs/client.pem", "testdata/certs/client-key.pem", "testdata/certs/ca.pem")
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
repo := &helmv1.HelmRepository{ repo := &helmv1.HelmRepository{
@ -2364,24 +2458,25 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_authStrategy(t *testing.T) {
} }
if tt.secretOpts.username != "" && tt.secretOpts.password != "" { if tt.secretOpts.username != "" && tt.secretOpts.password != "" {
secret := &corev1.Secret{ tt.secret.Data[".dockerconfigjson"] = []byte(fmt.Sprintf(`{"auths": {%q: {"username": %q, "password": %q}}}`,
ObjectMeta: metav1.ObjectMeta{ server.registryHost, tt.secretOpts.username, tt.secretOpts.password))
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{
".dockerconfigjson": []byte(fmt.Sprintf(`{"auths": {%q: {"username": %q, "password": %q}}}`,
server.registryHost, tt.secretOpts.username, tt.secretOpts.password)),
},
} }
if tt.secret != nil {
repo.Spec.SecretRef = &meta.LocalObjectReference{ repo.Spec.SecretRef = &meta.LocalObjectReference{
Name: secret.Name, Name: tt.secret.Name,
} }
clientBuilder.WithObjects(secret, repo) clientBuilder.WithObjects(tt.secret)
} else { }
if tt.certsecret != nil {
repo.Spec.CertSecretRef = &meta.LocalObjectReference{
Name: tt.certsecret.Name,
}
clientBuilder.WithObjects(tt.certsecret)
}
clientBuilder.WithObjects(repo) clientBuilder.WithObjects(repo)
}
obj := &helmv1.HelmChart{ obj := &helmv1.HelmChart{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
@ -2456,7 +2551,7 @@ func TestHelmChartReconciler_reconcileSourceFromOCI_verifySignature(t *testing.T
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
// Upload the test chart // Upload the test chart
metadata, err := loadTestChartToOCI(chartData, chartPath, server) metadata, err := loadTestChartToOCI(chartData, server, "", "", "")
g.Expect(err).NotTo(HaveOccurred()) g.Expect(err).NotTo(HaveOccurred())
storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords) storage, err := NewStorage(tmpDir, "example.com", retentionTTL, retentionRecords)
@ -2687,30 +2782,24 @@ func extractChartMeta(chartData []byte) (*hchart.Metadata, error) {
return ch.Metadata, nil return ch.Metadata, nil
} }
func loadTestChartToOCI(chartData []byte, chartPath string, server *registryClientTestServer) (*hchart.Metadata, error) { func loadTestChartToOCI(chartData []byte, server *registryClientTestServer, certFile, keyFile, cafile string) (*hchart.Metadata, error) {
// Login to the registry // Login to the registry
err := server.registryClient.Login(server.registryHost, err := server.registryClient.Login(server.registryHost,
helmreg.LoginOptBasicAuth(testRegistryUsername, testRegistryPassword), helmreg.LoginOptBasicAuth(testRegistryUsername, testRegistryPassword),
helmreg.LoginOptInsecure(true)) helmreg.LoginOptTLSClientConfig(certFile, keyFile, cafile))
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to login to OCI registry: %w", err)
}
// Load a test chart
chartData, err = os.ReadFile(chartPath)
if err != nil {
return nil, err
} }
metadata, err := extractChartMeta(chartData) metadata, err := extractChartMeta(chartData)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to extract chart metadata: %w", err)
} }
// Upload the test chart // Upload the test chart
ref := fmt.Sprintf("%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version) ref := fmt.Sprintf("%s/testrepo/%s:%s", server.registryHost, metadata.Name, metadata.Version)
_, err = server.registryClient.Push(chartData, ref) _, err = server.registryClient.Push(chartData, ref)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to push chart: %w", err)
} }
return metadata, nil return metadata, nil

View File

@ -399,7 +399,7 @@ func (r *HelmRepositoryReconciler) reconcileSource(ctx context.Context, sp *patc
return sreconcile.ResultEmpty, e return sreconcile.ResultEmpty, e
} }
clientOpts, err := getter.GetClientOpts(ctx, r.Client, obj, normalizedURL) clientOpts, _, err := getter.GetClientOpts(ctx, r.Client, obj, normalizedURL)
if err != nil { if err != nil {
if errors.Is(err, getter.ErrDeprecatedTLSConfig) { if errors.Is(err, getter.ErrDeprecatedTLSConfig) {
ctrl.LoggerFrom(ctx). ctrl.LoggerFrom(ctx).

View File

@ -18,20 +18,18 @@ package controller
import ( import (
"context" "context"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"time" "time"
"github.com/google/go-containerregistry/pkg/authn"
helmgetter "helm.sh/helm/v3/pkg/getter"
helmreg "helm.sh/helm/v3/pkg/registry" helmreg "helm.sh/helm/v3/pkg/registry"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors" apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
kerrors "k8s.io/apimachinery/pkg/util/errors" kerrors "k8s.io/apimachinery/pkg/util/errors"
kuberecorder "k8s.io/client-go/tools/record" kuberecorder "k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime" ctrl "sigs.k8s.io/controller-runtime"
@ -42,7 +40,6 @@ import (
eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1"
"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/patch" "github.com/fluxcd/pkg/runtime/patch"
@ -51,10 +48,9 @@ import (
sourcev1 "github.com/fluxcd/source-controller/api/v1" sourcev1 "github.com/fluxcd/source-controller/api/v1"
helmv1 "github.com/fluxcd/source-controller/api/v1beta2" helmv1 "github.com/fluxcd/source-controller/api/v1beta2"
"github.com/fluxcd/source-controller/internal/helm/registry" "github.com/fluxcd/source-controller/internal/helm/getter"
"github.com/fluxcd/source-controller/internal/helm/repository" "github.com/fluxcd/source-controller/internal/helm/repository"
"github.com/fluxcd/source-controller/internal/object" "github.com/fluxcd/source-controller/internal/object"
soci "github.com/fluxcd/source-controller/internal/oci"
intpredicates "github.com/fluxcd/source-controller/internal/predicates" intpredicates "github.com/fluxcd/source-controller/internal/predicates"
) )
@ -79,7 +75,7 @@ type HelmRepositoryOCIReconciler struct {
client.Client client.Client
kuberecorder.EventRecorder kuberecorder.EventRecorder
helper.Metrics helper.Metrics
Getters helmgetter.Providers
ControllerName string ControllerName string
RegistryClientGenerator RegistryClientGeneratorFunc RegistryClientGenerator RegistryClientGeneratorFunc
@ -95,7 +91,7 @@ type HelmRepositoryOCIReconciler struct {
// and an optional file name. // and an optional file name.
// The file is used to store the registry client credentials. // The file is used to store the registry client credentials.
// The caller is responsible for deleting the file. // The caller is responsible for deleting the file.
type RegistryClientGeneratorFunc func(isLogin bool) (*helmreg.Client, string, error) type RegistryClientGeneratorFunc func(tlsConfig *tls.Config, isLogin bool) (*helmreg.Client, string, error)
func (r *HelmRepositoryOCIReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *HelmRepositoryOCIReconciler) SetupWithManager(mgr ctrl.Manager) error {
return r.SetupWithManagerAndOptions(mgr, HelmRepositoryReconcilerOptions{}) return r.SetupWithManagerAndOptions(mgr, HelmRepositoryReconcilerOptions{})
@ -226,7 +222,7 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, sp *patch.S
} }
// Check if it's a successful reconciliation. // Check if it's a successful reconciliation.
if result.RequeueAfter == obj.GetRequeueAfter() && result.Requeue == false && if result.RequeueAfter == obj.GetRequeueAfter() && !result.Requeue &&
retErr == nil { retErr == nil {
// Remove reconciling condition if the reconciliation was successful. // Remove reconciling condition if the reconciliation was successful.
conditions.Delete(obj, meta.ReconcilingCondition) conditions.Delete(obj, meta.ReconcilingCondition)
@ -305,43 +301,34 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, sp *patch.S
result, retErr = ctrl.Result{}, nil result, retErr = ctrl.Result{}, nil
return return
} }
normalizedURL, err := repository.NormalizeURL(obj.Spec.URL)
if err != nil {
conditions.MarkStalled(obj, sourcev1.URLInvalidReason, err.Error())
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.URLInvalidReason, err.Error())
result, retErr = ctrl.Result{}, nil
return
}
conditions.Delete(obj, meta.StalledCondition) conditions.Delete(obj, meta.StalledCondition)
var ( clientOpts, certsTmpDir, err := getter.GetClientOpts(ctxTimeout, r.Client, obj, normalizedURL)
authenticator authn.Authenticator
keychain authn.Keychain
err error
)
// Configure any authentication related options.
if obj.Spec.SecretRef != nil {
keychain, err = authFromSecret(ctx, r.Client, obj)
if err != nil { if err != nil {
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, err.Error()) conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, err.Error())
result, retErr = ctrl.Result{}, err result, retErr = ctrl.Result{}, err
return return
} }
} else if obj.Spec.Provider != helmv1.GenericOCIProvider && obj.Spec.Type == helmv1.HelmRepositoryTypeOCI { if certsTmpDir != "" {
auth, authErr := soci.OIDCAuth(ctxTimeout, obj.Spec.URL, obj.Spec.Provider) defer func() {
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) { if err := os.RemoveAll(certsTmpDir); err != nil {
e := fmt.Errorf("failed to get credential from %s: %w", obj.Spec.Provider, authErr) r.eventLogf(ctx, obj, corev1.EventTypeWarning, meta.FailedReason,
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error()) "failed to delete temporary certs directory: %s", err)
result, retErr = ctrl.Result{}, e
return
} }
if auth != nil { }()
authenticator = auth
}
}
loginOpt, err := makeLoginOption(authenticator, keychain, obj.Spec.URL)
if err != nil {
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, err.Error())
result, retErr = ctrl.Result{}, err
return
} }
// Create registry client and login if needed. // Create registry client and login if needed.
registryClient, file, err := r.RegistryClientGenerator(loginOpt != nil) registryClient, file, err := r.RegistryClientGenerator(clientOpts.TlsConfig, clientOpts.MustLoginToRegistry())
if err != nil { if err != nil {
e := fmt.Errorf("failed to create registry client: %w", err) e := fmt.Errorf("failed to create registry client: %w", err)
conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, e.Error()) conditions.MarkFalse(obj, meta.ReadyCondition, meta.FailedReason, e.Error())
@ -368,8 +355,8 @@ func (r *HelmRepositoryOCIReconciler) reconcile(ctx context.Context, sp *patch.S
conditions.Delete(obj, meta.StalledCondition) conditions.Delete(obj, meta.StalledCondition)
// Attempt to login to the registry if credentials are provided. // Attempt to login to the registry if credentials are provided.
if loginOpt != nil { if clientOpts.MustLoginToRegistry() {
err = chartRepo.Login(loginOpt) err = chartRepo.Login(clientOpts.RegLoginOpts...)
if err != nil { if err != nil {
e := fmt.Errorf("failed to login to registry '%s': %w", obj.Spec.URL, err) e := fmt.Errorf("failed to login to registry '%s': %w", obj.Spec.URL, err)
conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error()) conditions.MarkFalse(obj, meta.ReadyCondition, sourcev1.AuthenticationFailedReason, e.Error())
@ -411,41 +398,6 @@ func (r *HelmRepositoryOCIReconciler) eventLogf(ctx context.Context, obj runtime
r.Eventf(obj, eventType, reason, msg) r.Eventf(obj, eventType, reason, msg)
} }
// authFromSecret returns an authn.Keychain for the given HelmRepository.
// If the HelmRepository does not specify a secretRef, an anonymous keychain is returned.
func authFromSecret(ctx context.Context, client client.Client, obj *helmv1.HelmRepository) (authn.Keychain, error) {
// Attempt to retrieve secret.
name := types.NamespacedName{
Namespace: obj.GetNamespace(),
Name: obj.Spec.SecretRef.Name,
}
var secret corev1.Secret
if err := client.Get(ctx, name, &secret); err != nil {
return nil, fmt.Errorf("failed to get secret '%s': %w", name.String(), err)
}
// Construct login options.
keychain, err := registry.LoginOptionFromSecret(obj.Spec.URL, secret)
if err != nil {
return nil, fmt.Errorf("failed to configure Helm client with secret data: %w", err)
}
return keychain, nil
}
// makeLoginOption returns a registry login option for the given HelmRepository.
// If the HelmRepository does not specify a secretRef, a nil login option is returned.
func makeLoginOption(auth authn.Authenticator, keychain authn.Keychain, registryURL string) (helmreg.LoginOption, error) {
if auth != nil {
return registry.AuthAdaptHelper(auth)
}
if keychain != nil {
return registry.KeychainAdaptHelper(keychain)(registryURL)
}
return nil, nil
}
func conditionsDiff(a, b []string) []string { func conditionsDiff(a, b []string) []string {
bMap := make(map[string]struct{}, len(b)) bMap := make(map[string]struct{}, len(b))
for _, j := range b { for _, j := range b {

View File

@ -205,7 +205,10 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
name string name string
url string url string
registryOpts registryOptions registryOpts registryOptions
insecure bool
secretOpts secretOptions secretOpts secretOptions
secret *corev1.Secret
certsSecret *corev1.Secret
provider string provider string
providerImg string providerImg string
want ctrl.Result want ctrl.Result
@ -222,6 +225,7 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
{ {
name: "HTTP with basic auth secret", name: "HTTP with basic auth secret",
want: ctrl.Result{RequeueAfter: interval}, want: ctrl.Result{RequeueAfter: interval},
insecure: true,
registryOpts: registryOptions{ registryOpts: registryOptions{
withBasicAuth: true, withBasicAuth: true,
}, },
@ -229,6 +233,13 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
username: testRegistryUsername, username: testRegistryUsername,
password: testRegistryPassword, password: testRegistryPassword,
}, },
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "Helm repository is ready"), *conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "Helm repository is ready"),
}, },
@ -237,6 +248,7 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
name: "HTTP registry - basic auth with invalid secret", name: "HTTP registry - basic auth with invalid secret",
want: ctrl.Result{}, want: ctrl.Result{},
wantErr: true, wantErr: true,
insecure: true,
registryOpts: registryOptions{ registryOpts: registryOptions{
withBasicAuth: true, withBasicAuth: true,
}, },
@ -244,6 +256,13 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
username: "wrong-pass", username: "wrong-pass",
password: "wrong-pass", password: "wrong-pass",
}, },
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingWithRetryReason, "processing object: new generation"), *conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingWithRetryReason, "processing object: new generation"),
*conditions.FalseCondition(meta.ReadyCondition, sourcev1.AuthenticationFailedReason, "failed to login to registry"), *conditions.FalseCondition(meta.ReadyCondition, sourcev1.AuthenticationFailedReason, "failed to login to registry"),
@ -252,6 +271,7 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
{ {
name: "with contextual login provider", name: "with contextual login provider",
wantErr: true, wantErr: true,
insecure: true,
provider: "aws", provider: "aws",
providerImg: "oci://123456789000.dkr.ecr.us-east-2.amazonaws.com/test", providerImg: "oci://123456789000.dkr.ecr.us-east-2.amazonaws.com/test",
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
@ -265,15 +285,86 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
registryOpts: registryOptions{ registryOpts: registryOptions{
withBasicAuth: true, withBasicAuth: true,
}, },
insecure: true,
secretOpts: secretOptions{ secretOpts: secretOptions{
username: testRegistryUsername, username: testRegistryUsername,
password: testRegistryPassword, password: testRegistryPassword,
}, },
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
provider: "azure", provider: "azure",
assertConditions: []metav1.Condition{ assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "Helm repository is ready"), *conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "Helm repository is ready"),
}, },
}, },
{
name: "HTTPS With invalid CA cert",
wantErr: true,
registryOpts: registryOptions{
withTLS: true,
withClientCertAuth: true,
},
secretOpts: secretOptions{
username: testRegistryUsername,
password: testRegistryPassword,
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
certsSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "certs-secretref",
},
Data: map[string][]byte{
"caFile": []byte("invalid caFile"),
},
},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingWithRetryReason, "processing object: new generation 0 -> 1"),
*conditions.FalseCondition(meta.ReadyCondition, sourcev1.AuthenticationFailedReason, "cannot append certificate into certificate pool: invalid caFile"),
},
},
{
name: "HTTPS With CA cert",
want: ctrl.Result{RequeueAfter: interval},
registryOpts: registryOptions{
withTLS: true,
withClientCertAuth: true,
},
secretOpts: secretOptions{
username: testRegistryUsername,
password: testRegistryPassword,
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{},
},
certsSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "certs-secretref",
},
Data: map[string][]byte{
"caFile": tlsCA,
"certFile": clientPublicKey,
"keyFile": clientPrivateKey,
},
},
assertConditions: []metav1.Condition{
*conditions.TrueCondition(meta.ReadyCondition, meta.SucceededReason, "Helm repository is ready"),
},
},
} }
for _, tt := range tests { for _, tt := range tests {
@ -285,7 +376,9 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
WithStatusSubresource(&helmv1.HelmRepository{}) WithStatusSubresource(&helmv1.HelmRepository{})
workspaceDir := t.TempDir() workspaceDir := t.TempDir()
if tt.insecure {
tt.registryOpts.disableDNSMocking = true tt.registryOpts.disableDNSMocking = true
}
server, err := setupRegistryServer(ctx, workspaceDir, tt.registryOpts) server, err := setupRegistryServer(ctx, workspaceDir, tt.registryOpts)
g.Expect(err).NotTo(HaveOccurred()) g.Expect(err).NotTo(HaveOccurred())
t.Cleanup(func() { t.Cleanup(func() {
@ -317,28 +410,27 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
} }
if tt.secretOpts.username != "" && tt.secretOpts.password != "" { if tt.secretOpts.username != "" && tt.secretOpts.password != "" {
secret := &corev1.Secret{ tt.secret.Data[".dockerconfigjson"] = []byte(fmt.Sprintf(`{"auths": {%q: {"username": %q, "password": %q}}}`,
ObjectMeta: metav1.ObjectMeta{ server.registryHost, tt.secretOpts.username, tt.secretOpts.password))
Name: "auth-secretref",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{
".dockerconfigjson": []byte(fmt.Sprintf(`{"auths": {%q: {"username": %q, "password": %q}}}`,
server.registryHost, tt.secretOpts.username, tt.secretOpts.password)),
},
} }
clientBuilder.WithObjects(secret) if tt.secret != nil {
clientBuilder.WithObjects(tt.secret)
obj.Spec.SecretRef = &meta.LocalObjectReference{ obj.Spec.SecretRef = &meta.LocalObjectReference{
Name: secret.Name, Name: tt.secret.Name,
}
}
if tt.certsSecret != nil {
clientBuilder.WithObjects(tt.certsSecret)
obj.Spec.CertSecretRef = &meta.LocalObjectReference{
Name: tt.certsSecret.Name,
} }
} }
r := &HelmRepositoryOCIReconciler{ r := &HelmRepositoryOCIReconciler{
Client: clientBuilder.Build(), Client: clientBuilder.Build(),
EventRecorder: record.NewFakeRecorder(32), EventRecorder: record.NewFakeRecorder(32),
Getters: testGetters,
RegistryClientGenerator: registry.ClientGenerator, RegistryClientGenerator: registry.ClientGenerator,
patchOptions: getPatchOptions(helmRepositoryOCIOwnedConditions, "sc"), patchOptions: getPatchOptions(helmRepositoryOCIOwnedConditions, "sc"),
} }
@ -349,7 +441,6 @@ func TestHelmRepositoryOCIReconciler_authStrategy(t *testing.T) {
}() }()
sp := patch.NewSerialPatcher(obj, r.Client) sp := patch.NewSerialPatcher(obj, r.Client)
got, err := r.reconcile(ctx, sp, obj) got, err := r.reconcile(ctx, sp, obj)
g.Expect(err != nil).To(Equal(tt.wantErr)) g.Expect(err != nil).To(Equal(tt.wantErr))
g.Expect(got).To(Equal(tt.want)) g.Expect(got).To(Equal(tt.want))

View File

@ -796,7 +796,7 @@ func TestHelmRepositoryReconciler_reconcileSource(t *testing.T) {
if tt.url != "" { if tt.url != "" {
repoURL = tt.url repoURL = tt.url
} }
tlsConf, serr = getter.TLSClientConfigFromSecret(*secret, repoURL) tlsConf, _, serr = getter.TLSClientConfigFromSecret(*secret, repoURL)
if serr != nil { if serr != nil {
validSecret = false validSecret = false
} }

View File

@ -19,12 +19,15 @@ package controller
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/tls"
"crypto/x509"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
"log" "log"
"math/rand" "math/rand"
"net" "net"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@ -148,15 +151,11 @@ func setupRegistryServer(ctx context.Context, workspaceDir string, opts registry
var out bytes.Buffer var out bytes.Buffer
server.out = &out server.out = &out
// init test client // init test client options
client, err := helmreg.NewClient( clientOpts := []helmreg.ClientOption{
helmreg.ClientOptDebug(true), helmreg.ClientOptDebug(true),
helmreg.ClientOptWriter(server.out), helmreg.ClientOptWriter(server.out),
)
if err != nil {
return nil, fmt.Errorf("failed to create registry client: %s", err)
} }
server.registryClient = client
config := &configuration.Configuration{} config := &configuration.Configuration{}
port, err := freeport.GetFreePort() port, err := freeport.GetFreePort()
@ -218,6 +217,13 @@ func setupRegistryServer(ctx context.Context, workspaceDir string, opts registry
if opts.withClientCertAuth { if opts.withClientCertAuth {
config.HTTP.TLS.ClientCAs = []string{"testdata/certs/ca.pem"} config.HTTP.TLS.ClientCAs = []string{"testdata/certs/ca.pem"}
} }
// add TLS configured HTTP client option to clientOpts
httpClient, err := tlsConfiguredHTTPCLient()
if err != nil {
return nil, fmt.Errorf("failed to create TLS configured HTTP client: %s", err)
}
clientOpts = append(clientOpts, helmreg.ClientOptHTTPClient(httpClient))
} }
// setup logger options // setup logger options
@ -232,12 +238,41 @@ func setupRegistryServer(ctx context.Context, workspaceDir string, opts registry
return nil, fmt.Errorf("failed to create docker registry: %w", err) return nil, fmt.Errorf("failed to create docker registry: %w", err)
} }
// init test client
client, err := helmreg.NewClient(clientOpts...)
if err != nil {
return nil, fmt.Errorf("failed to create registry client: %s", err)
}
server.registryClient = client
// Start Docker registry // Start Docker registry
go dockerRegistry.ListenAndServe() go dockerRegistry.ListenAndServe()
return server, nil return server, nil
} }
func tlsConfiguredHTTPCLient() (*http.Client, error) {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(tlsCA) {
return nil, fmt.Errorf("failed to append CA certificate to pool")
}
cert, err := tls.LoadX509KeyPair("testdata/certs/server.pem", "testdata/certs/server-key.pem")
if err != nil {
return nil, fmt.Errorf("failed to load server certificate: %s", err)
}
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: pool,
Certificates: []tls.Certificate{
cert,
},
},
},
}
return httpClient, nil
}
func (r *registryClientTestServer) Close() { func (r *registryClientTestServer) Close() {
if r.dnsServer != nil { if r.dnsServer != nil {
mockdns.UnpatchNet(net.DefaultResolver) mockdns.UnpatchNet(net.DefaultResolver)
@ -345,7 +380,6 @@ func TestMain(m *testing.M) {
Client: testEnv, Client: testEnv,
EventRecorder: record.NewFakeRecorder(32), EventRecorder: record.NewFakeRecorder(32),
Metrics: testMetricsH, Metrics: testMetricsH,
Getters: testGetters,
RegistryClientGenerator: registry.ClientGenerator, RegistryClientGenerator: registry.ClientGenerator,
}).SetupWithManagerAndOptions(testEnv, HelmRepositoryReconcilerOptions{ }).SetupWithManagerAndOptions(testEnv, HelmRepositoryReconcilerOptions{
RateLimiter: controller.GetDefaultRateLimiter(), RateLimiter: controller.GetDefaultRateLimiter(),

View File

@ -23,6 +23,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"os"
"path"
"github.com/fluxcd/pkg/oci" "github.com/fluxcd/pkg/oci"
"github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/authn"
@ -37,23 +39,47 @@ import (
soci "github.com/fluxcd/source-controller/internal/oci" soci "github.com/fluxcd/source-controller/internal/oci"
) )
const (
certFileName = "cert.pem"
keyFileName = "key.pem"
caFileName = "ca.pem"
)
var ErrDeprecatedTLSConfig = errors.New("TLS configured in a deprecated manner") var ErrDeprecatedTLSConfig = errors.New("TLS configured in a deprecated manner")
// TLSBytes contains the bytes of the TLS files.
type TLSBytes struct {
// CertBytes is the bytes of the certificate file.
CertBytes []byte
// KeyBytes is the bytes of the key file.
KeyBytes []byte
// CABytes is the bytes of the CA file.
CABytes []byte
}
// ClientOpts contains the various options to use while constructing // ClientOpts contains the various options to use while constructing
// a Helm repository client. // a Helm repository client.
type ClientOpts struct { type ClientOpts struct {
Authenticator authn.Authenticator Authenticator authn.Authenticator
Keychain authn.Keychain Keychain authn.Keychain
RegLoginOpt helmreg.LoginOption RegLoginOpts []helmreg.LoginOption
TlsConfig *tls.Config TlsConfig *tls.Config
GetterOpts []helmgetter.Option GetterOpts []helmgetter.Option
} }
// MustLoginToRegistry returns true if the client options contain at least
// one registry login option.
func (o ClientOpts) MustLoginToRegistry() bool {
return len(o.RegLoginOpts) > 0 && o.RegLoginOpts[0] != nil
}
// GetClientOpts uses the provided HelmRepository object and a normalized // GetClientOpts uses the provided HelmRepository object and a normalized
// URL to construct a HelmClientOpts object. If obj is an OCI HelmRepository, // URL to construct a HelmClientOpts object. If obj is an OCI HelmRepository,
// then the returned options object will also contain the required registry // then the returned options object will also contain the required registry
// auth mechanisms. // auth mechanisms.
func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmRepository, url string) (*ClientOpts, error) { // A temporary directory is created to store the certs files if needed and its path is returned along with the options object. It is the
// caller's responsibility to clean up the directory.
func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmRepository, url string) (*ClientOpts, string, error) {
hrOpts := &ClientOpts{ hrOpts := &ClientOpts{
GetterOpts: []helmgetter.Option{ GetterOpts: []helmgetter.Option{
helmgetter.WithURL(url), helmgetter.WithURL(url),
@ -63,18 +89,25 @@ func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmReposit
} }
ociRepo := obj.Spec.Type == helmv1.HelmRepositoryTypeOCI ociRepo := obj.Spec.Type == helmv1.HelmRepositoryTypeOCI
var certSecret *corev1.Secret var (
var err error certSecret *corev1.Secret
tlsBytes *TLSBytes
certFile string
keyFile string
caFile string
dir string
err error
)
// Check `.spec.certSecretRef` first for any TLS auth data. // Check `.spec.certSecretRef` first for any TLS auth data.
if obj.Spec.CertSecretRef != nil { if obj.Spec.CertSecretRef != nil {
certSecret, err = fetchSecret(ctx, c, obj.Spec.CertSecretRef.Name, obj.GetNamespace()) certSecret, err = fetchSecret(ctx, c, obj.Spec.CertSecretRef.Name, obj.GetNamespace())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get TLS authentication secret '%s/%s': %w", obj.GetNamespace(), obj.Spec.CertSecretRef.Name, err) 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) hrOpts.TlsConfig, tlsBytes, err = TLSClientConfigFromSecret(*certSecret, url)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to construct Helm client's TLS config: %w", err) return nil, "", fmt.Errorf("failed to construct Helm client's TLS config: %w", err)
} }
} }
@ -83,22 +116,22 @@ func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmReposit
if obj.Spec.SecretRef != nil { if obj.Spec.SecretRef != nil {
authSecret, err = fetchSecret(ctx, c, obj.Spec.SecretRef.Name, obj.GetNamespace()) authSecret, err = fetchSecret(ctx, c, obj.Spec.SecretRef.Name, obj.GetNamespace())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get authentication secret '%s/%s': %w", obj.GetNamespace(), obj.Spec.SecretRef.Name, err) return nil, "", fmt.Errorf("failed to get authentication secret '%s/%s': %w", obj.GetNamespace(), obj.Spec.SecretRef.Name, err)
} }
// Construct actual Helm client options. // Construct actual Helm client options.
opts, err := GetterOptionsFromSecret(*authSecret) opts, err := GetterOptionsFromSecret(*authSecret)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to configure Helm client: %w", err) return nil, "", fmt.Errorf("failed to configure Helm client: %w", err)
} }
hrOpts.GetterOpts = append(hrOpts.GetterOpts, opts...) hrOpts.GetterOpts = append(hrOpts.GetterOpts, opts...)
// If the TLS config is nil, i.e. one couldn't be constructed using `.spec.certSecretRef` // If the TLS config is nil, i.e. one couldn't be constructed using `.spec.certSecretRef`
// then try to use `.spec.certSecretRef`. // then try to use `.spec.secretRef`.
if hrOpts.TlsConfig == nil { if hrOpts.TlsConfig == nil {
hrOpts.TlsConfig, err = TLSClientConfigFromSecret(*authSecret, url) hrOpts.TlsConfig, tlsBytes, err = TLSClientConfigFromSecret(*authSecret, url)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to construct Helm client's TLS config: %w", err) 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. // Constructing a TLS config using the auth secret is deprecated behavior.
if hrOpts.TlsConfig != nil { if hrOpts.TlsConfig != nil {
@ -109,13 +142,13 @@ func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmReposit
if ociRepo { if ociRepo {
hrOpts.Keychain, err = registry.LoginOptionFromSecret(url, *authSecret) hrOpts.Keychain, err = registry.LoginOptionFromSecret(url, *authSecret)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to configure login options: %w", err) return nil, "", fmt.Errorf("failed to configure login options: %w", err)
} }
} }
} else if obj.Spec.Provider != helmv1.GenericOCIProvider && obj.Spec.Type == helmv1.HelmRepositoryTypeOCI && ociRepo { } else if obj.Spec.Provider != helmv1.GenericOCIProvider && obj.Spec.Type == helmv1.HelmRepositoryTypeOCI && ociRepo {
authenticator, authErr := soci.OIDCAuth(ctx, obj.Spec.URL, obj.Spec.Provider) authenticator, authErr := soci.OIDCAuth(ctx, obj.Spec.URL, obj.Spec.Provider)
if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) { if authErr != nil && !errors.Is(authErr, oci.ErrUnconfiguredProvider) {
return nil, fmt.Errorf("failed to get credential from '%s': %w", obj.Spec.Provider, authErr) return nil, "", fmt.Errorf("failed to get credential from '%s': %w", obj.Spec.Provider, authErr)
} }
if authenticator != nil { if authenticator != nil {
hrOpts.Authenticator = authenticator hrOpts.Authenticator = authenticator
@ -123,16 +156,34 @@ func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmReposit
} }
if ociRepo { if ociRepo {
hrOpts.RegLoginOpt, err = registry.NewLoginOption(hrOpts.Authenticator, hrOpts.Keychain, url) // Persist the certs files to the path if needed.
if tlsBytes != nil {
dir, err = os.MkdirTemp("", "helm-repo-oci-certs")
if err != nil { if err != nil {
return nil, err return nil, "", fmt.Errorf("cannot create temporary directory: %w", err)
}
certFile, keyFile, caFile, err = StoreTLSCertificateFiles(tlsBytes, dir)
if err != nil {
return nil, "", fmt.Errorf("cannot write certs files to path: %w", err)
}
}
loginOpt, err := registry.NewLoginOption(hrOpts.Authenticator, hrOpts.Keychain, url)
if err != nil {
return nil, "", err
}
if loginOpt != nil {
hrOpts.RegLoginOpts = []helmreg.LoginOption{loginOpt}
}
tlsLoginOpt := registry.TLSLoginOption(certFile, keyFile, caFile)
if tlsLoginOpt != nil {
hrOpts.RegLoginOpts = append(hrOpts.RegLoginOpts, tlsLoginOpt)
} }
} }
if deprecatedTLSConfig { if deprecatedTLSConfig {
err = ErrDeprecatedTLSConfig err = ErrDeprecatedTLSConfig
} }
return hrOpts, err return hrOpts, dir, err
} }
func fetchSecret(ctx context.Context, c client.Client, name, namespace string) (*corev1.Secret, error) { func fetchSecret(ctx context.Context, c client.Client, name, namespace string) (*corev1.Secret, error) {
@ -152,13 +203,13 @@ func fetchSecret(ctx context.Context, c client.Client, name, namespace string) (
// //
// Secrets with no certFile, keyFile, AND caFile are ignored, if only a // Secrets with no certFile, keyFile, AND caFile are ignored, if only a
// certBytes OR keyBytes is defined it returns an error. // certBytes OR keyBytes is defined it returns an error.
func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls.Config, error) { func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls.Config, *TLSBytes, error) {
certBytes, keyBytes, caBytes := secret.Data["certFile"], secret.Data["keyFile"], secret.Data["caFile"] certBytes, keyBytes, caBytes := secret.Data["certFile"], secret.Data["keyFile"], secret.Data["caFile"]
switch { switch {
case len(certBytes)+len(keyBytes)+len(caBytes) == 0: case len(certBytes)+len(keyBytes)+len(caBytes) == 0:
return nil, nil return nil, nil, nil
case (len(certBytes) > 0 && len(keyBytes) == 0) || (len(keyBytes) > 0 && len(certBytes) == 0): 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", return nil, nil, fmt.Errorf("invalid '%s' secret data: fields 'certFile' and 'keyFile' require each other's presence",
secret.Name) secret.Name)
} }
@ -166,7 +217,7 @@ func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls
if len(certBytes) > 0 && len(keyBytes) > 0 { if len(certBytes) > 0 && len(keyBytes) > 0 {
cert, err := tls.X509KeyPair(certBytes, keyBytes) cert, err := tls.X509KeyPair(certBytes, keyBytes)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
tlsConf.Certificates = append(tlsConf.Certificates, cert) tlsConf.Certificates = append(tlsConf.Certificates, cert)
} }
@ -174,10 +225,10 @@ func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls
if len(caBytes) > 0 { if len(caBytes) > 0 {
cp, err := x509.SystemCertPool() cp, err := x509.SystemCertPool()
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot retrieve system certificate pool: %w", err) return nil, nil, fmt.Errorf("cannot retrieve system certificate pool: %w", err)
} }
if !cp.AppendCertsFromPEM(caBytes) { if !cp.AppendCertsFromPEM(caBytes) {
return nil, fmt.Errorf("cannot append certificate into certificate pool: invalid caFile") return nil, nil, fmt.Errorf("cannot append certificate into certificate pool: invalid caFile")
} }
tlsConf.RootCAs = cp tlsConf.RootCAs = cp
@ -187,10 +238,50 @@ func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls
u, err := url.Parse(repositoryUrl) u, err := url.Parse(repositoryUrl)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot parse repository URL: %w", err) return nil, nil, fmt.Errorf("cannot parse repository URL: %w", err)
} }
tlsConf.ServerName = u.Hostname() tlsConf.ServerName = u.Hostname()
return tlsConf, nil return tlsConf, &TLSBytes{
CertBytes: certBytes,
KeyBytes: keyBytes,
CABytes: caBytes,
}, nil
}
// StoreTLSCertificateFiles writes the certs files to the given path and returns the files paths.
func StoreTLSCertificateFiles(tlsBytes *TLSBytes, path string) (string, string, string, error) {
var (
certFile string
keyFile string
caFile string
err error
)
if len(tlsBytes.CertBytes) > 0 && len(tlsBytes.KeyBytes) > 0 {
certFile, err = writeToFile(tlsBytes.CertBytes, certFileName, path)
if err != nil {
return "", "", "", err
}
keyFile, err = writeToFile(tlsBytes.KeyBytes, keyFileName, path)
if err != nil {
return "", "", "", err
}
}
if len(tlsBytes.CABytes) > 0 {
caFile, err = writeToFile(tlsBytes.CABytes, caFileName, path)
if err != nil {
return "", "", "", err
}
}
return certFile, keyFile, caFile, nil
}
func writeToFile(data []byte, filename, tmpDir string) (string, error) {
file := path.Join(tmpDir, filename)
err := os.WriteFile(file, data, 0o644)
if err != nil {
return "", err
}
return file, nil
} }

View File

@ -149,7 +149,7 @@ func TestGetClientOpts(t *testing.T) {
} }
c := clientBuilder.Build() c := clientBuilder.Build()
clientOpts, err := GetClientOpts(context.TODO(), c, helmRepo, "https://ghcr.io/dummy") clientOpts, _, err := GetClientOpts(context.TODO(), c, helmRepo, "https://ghcr.io/dummy")
if tt.err != nil { if tt.err != nil {
g.Expect(err).To(Equal(tt.err)) g.Expect(err).To(Equal(tt.err))
} else { } else {
@ -183,7 +183,7 @@ func Test_tlsClientConfigFromSecret(t *testing.T) {
tt.modify(secret) tt.modify(secret)
} }
got, err := TLSClientConfigFromSecret(*secret, "") got, _, err := TLSClientConfigFromSecret(*secret, "")
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("TLSClientConfigFromSecret() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("TLSClientConfigFromSecret() error = %v, wantErr %v", err, tt.wantErr)
return return
@ -196,6 +196,118 @@ func Test_tlsClientConfigFromSecret(t *testing.T) {
} }
} }
func TestGetClientOpts_registryTLSLoginOption(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
loginOptsN int
}{
{
name: "with valid caFile",
certSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "ca-file",
},
Data: map[string][]byte{
"caFile": tlsCA,
},
},
authSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-oci",
},
Data: map[string][]byte{
"username": []byte("user"),
"password": []byte("pass"),
},
},
loginOptsN: 2,
},
{
name: "without caFile",
certSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "ca-file",
},
Data: map[string][]byte{},
},
authSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-oci",
},
Data: map[string][]byte{
"username": []byte("user"),
"password": []byte("pass"),
},
},
loginOptsN: 1,
},
{
name: "without cert secret",
certSecret: nil,
authSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "auth-oci",
},
Data: map[string][]byte{
"username": []byte("user"),
"password": []byte("pass"),
},
},
loginOptsN: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
helmRepo := &helmv1.HelmRepository{
Spec: helmv1.HelmRepositorySpec{
Timeout: &metav1.Duration{
Duration: time.Second,
},
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, tmpDir, err := GetClientOpts(context.TODO(), c, helmRepo, "https://ghcr.io/dummy")
if err != nil {
t.Errorf("GetClientOpts() error = %v", err)
return
}
if tmpDir != "" {
defer os.RemoveAll(tmpDir)
}
if tt.loginOptsN != len(clientOpts.RegLoginOpts) {
// we should have a login option but no TLS option
t.Error("registryTLSLoginOption() != nil")
return
}
})
}
}
// validTlsSecret creates a secret containing key pair and CA certificate that are // validTlsSecret creates a secret containing key pair and CA certificate that are
// valid from a syntax (minimum requirements) perspective. // valid from a syntax (minimum requirements) perspective.
func validTlsSecret(t *testing.T) corev1.Secret { func validTlsSecret(t *testing.T) corev1.Secret {

View File

@ -154,3 +154,13 @@ func NewLoginOption(auth authn.Authenticator, keychain authn.Keychain, registryU
return nil, nil return nil, nil
} }
// TLSLoginOption returns a LoginOption that can be used to configure the TLS client.
// It requires either the caFile or both certFile and keyFile to be not blank.
func TLSLoginOption(certFile, keyFile, caFile string) registry.LoginOption {
if (certFile != "" && keyFile != "") || caFile != "" {
return registry.LoginOptTLSClientConfig(certFile, keyFile, caFile)
}
return nil
}

View File

@ -17,7 +17,9 @@ limitations under the License.
package registry package registry
import ( import (
"crypto/tls"
"io" "io"
"net/http"
"os" "os"
"helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/registry"
@ -27,7 +29,7 @@ import (
// ClientGenerator generates a registry client and a temporary credential file. // ClientGenerator generates a registry client and a temporary credential file.
// The client is meant to be used for a single reconciliation. // The client is meant to be used for a single reconciliation.
// The file is meant to be used for a single reconciliation and deleted after. // The file is meant to be used for a single reconciliation and deleted after.
func ClientGenerator(isLogin bool) (*registry.Client, string, error) { func ClientGenerator(tlsConfig *tls.Config, isLogin bool) (*registry.Client, string, error) {
if isLogin { if isLogin {
// create a temporary file to store the credentials // create a temporary file to store the credentials
// this is needed because otherwise the credentials are stored in ~/.docker/config.json. // this is needed because otherwise the credentials are stored in ~/.docker/config.json.
@ -37,7 +39,7 @@ func ClientGenerator(isLogin bool) (*registry.Client, string, error) {
} }
var errs []error var errs []error
rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard), registry.ClientOptCredentialsFile(credentialsFile.Name())) rClient, err := newClient(credentialsFile.Name(), tlsConfig)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
// attempt to delete the temporary file // attempt to delete the temporary file
@ -52,9 +54,27 @@ func ClientGenerator(isLogin bool) (*registry.Client, string, error) {
return rClient, credentialsFile.Name(), nil return rClient, credentialsFile.Name(), nil
} }
rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard)) rClient, err := newClient("", tlsConfig)
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
return rClient, "", nil return rClient, "", nil
} }
func newClient(credentialsFile string, tlsConfig *tls.Config) (*registry.Client, error) {
opts := []registry.ClientOption{
registry.ClientOptWriter(io.Discard),
}
if tlsConfig != nil {
opts = append(opts, registry.ClientOptHTTPClient(&http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}))
}
if credentialsFile != "" {
opts = append(opts, registry.ClientOptCredentialsFile(credentialsFile))
}
return registry.NewClient(opts...)
}

View File

@ -20,6 +20,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
@ -65,9 +66,13 @@ type OCIChartRepository struct {
// RegistryClient is a client to use while downloading tags or charts from a registry. // RegistryClient is a client to use while downloading tags or charts from a registry.
RegistryClient RegistryClient RegistryClient RegistryClient
// credentialsFile is a temporary credentials file to use while downloading tags or charts from a registry. // credentialsFile is a temporary credentials file to use while downloading tags or charts from a registry.
credentialsFile string credentialsFile string
// certificatesStore is a temporary store to use while downloading tags or charts from a registry.
certificatesStore string
// verifiers is a list of verifiers to use when verifying a chart. // verifiers is a list of verifiers to use when verifying a chart.
verifiers []oci.Verifier verifiers []oci.Verifier
} }
@ -120,6 +125,14 @@ func WithCredentialsFile(credentialsFile string) OCIChartRepositoryOption {
} }
} }
// WithCertificatesStore returns a ChartRepositoryOption that will set the certificates store
func WithCertificatesStore(store string) OCIChartRepositoryOption {
return func(r *OCIChartRepository) error {
r.certificatesStore = store
return nil
}
}
// NewOCIChartRepository constructs and returns a new ChartRepository with // NewOCIChartRepository constructs and returns a new ChartRepository with
// the ChartRepository.Client configured to the getter.Getter for the // the ChartRepository.Client configured to the getter.Getter for the
// repository URL scheme. It returns an error on URL parsing failures. // repository URL scheme. It returns an error on URL parsing failures.
@ -265,14 +278,24 @@ func (r *OCIChartRepository) HasCredentials() bool {
// Clear deletes the OCI registry credentials file. // Clear deletes the OCI registry credentials file.
func (r *OCIChartRepository) Clear() error { func (r *OCIChartRepository) Clear() error {
var errs error
// clean the credentials file if it exists // clean the credentials file if it exists
if r.credentialsFile != "" { if r.credentialsFile != "" {
if err := os.Remove(r.credentialsFile); err != nil { if err := os.Remove(r.credentialsFile); err != nil {
return err errs = errors.Join(errs, err)
} }
} }
r.credentialsFile = "" r.credentialsFile = ""
return nil
// clean the certificates store if it exists
if r.certificatesStore != "" {
if err := os.RemoveAll(r.certificatesStore); err != nil {
errs = errors.Join(errs, err)
}
}
r.certificatesStore = ""
return errs
} }
// getLastMatchingVersionOrConstraint returns the last version that matches the given version string. // getLastMatchingVersionOrConstraint returns the last version that matches the given version string.

View File

@ -198,7 +198,6 @@ func main() {
Client: mgr.GetClient(), Client: mgr.GetClient(),
EventRecorder: eventRecorder, EventRecorder: eventRecorder,
Metrics: metrics, Metrics: metrics,
Getters: getters,
ControllerName: controllerName, ControllerName: controllerName,
RegistryClientGenerator: registry.ClientGenerator, RegistryClientGenerator: registry.ClientGenerator,
}).SetupWithManagerAndOptions(mgr, controller.HelmRepositoryReconcilerOptions{ }).SetupWithManagerAndOptions(mgr, controller.HelmRepositoryReconcilerOptions{