Skip to content

Add .spec.serviceAccountName to HelmRepository #1195

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/v1beta2/helmrepository_types.go
Original file line number Diff line number Diff line change
@@ -104,6 +104,13 @@ type HelmRepositorySpec struct {
// +optional
Type string `json:"type,omitempty"`

// ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
// the OCI image pull if the service account has attached pull secrets. For more information:
// https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#add-imagepullsecrets-to-a-service-account
// +optional
// This field is only considered for Helm Repositories of type oci
ServiceAccountName string `json:"serviceAccountName,omitempty"`

// Provider used for authentication, can be 'aws', 'azure', 'gcp' or 'generic'.
// This field is optional, and only taken into account if the .spec.type field is set to 'oci'.
// When not specified, defaults to 'generic'.
Original file line number Diff line number Diff line change
@@ -345,6 +345,11 @@ spec:
required:
- name
type: object
serviceAccountName:
description: 'ServiceAccountName is the name of the Kubernetes ServiceAccount
used to authenticate the OCI image pull if the service account has
attached pull secrets. For more information: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#add-imagepullsecrets-to-a-service-account'
type: string
suspend:
description: Suspend tells the controller to suspend the reconciliation
of this HelmRepository.
10 changes: 10 additions & 0 deletions docs/spec/v1beta2/helmrepositories.md
Original file line number Diff line number Diff line change
@@ -509,6 +509,16 @@ data:
caFile: <BASE64>
```


### Service Account Name

*Note:* This field is only taken into account for Helm Repository of
type `oci`.

`.spec.serviceAccountName` is an optional field to specify a name of a
ServiceAccount in the same namespace as the HelmRepository, which has image
pull secrets that can be used for authentication to the OCI image repository.

### Pass credentials

`.spec.passCredentials` is an optional field to allow the credentials from the
73 changes: 64 additions & 9 deletions internal/helm/getter/client_opts.go
Original file line number Diff line number Diff line change
@@ -24,14 +24,15 @@ import (
"fmt"
"net/url"

"github.com/fluxcd/pkg/oci"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/authn/k8schain"
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"

"github.com/fluxcd/pkg/oci"
helmv1 "github.com/fluxcd/source-controller/api/v1beta2"
"github.com/fluxcd/source-controller/internal/helm/registry"
soci "github.com/fluxcd/source-controller/internal/oci"
@@ -80,6 +81,7 @@ func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmReposit

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 {
@@ -112,17 +114,38 @@ func GetClientOpts(ctx context.Context, c client.Client, obj *helmv1.HelmReposit
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 {
if obj.Spec.ServiceAccountName != "" {
keychain, err := getKeychainFromSAImagePullSecrets(ctx, c, obj.GetNamespace(), obj.Spec.ServiceAccountName)
if err != nil {
return nil, fmt.Errorf("failed to get keychain from service account: %w", err)
}

if hrOpts.Keychain != nil {
hrOpts.Keychain = authn.NewMultiKeychain(hrOpts.Keychain, keychain)
} else {
hrOpts.Keychain = keychain
}
}

var hasKeychain bool
if hrOpts.Keychain != nil {
_, ok := hrOpts.Keychain.(soci.Anonymous)
hasKeychain = !ok
}

if !hasKeychain && obj.Spec.Provider != helmv1.GenericOCIProvider {
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
}
}

hrOpts.RegLoginOpt, err = registry.NewLoginOption(hrOpts.Authenticator, hrOpts.Keychain, url)
if err != nil {
return nil, err
@@ -194,3 +217,35 @@ func TLSClientConfigFromSecret(secret corev1.Secret, repositoryUrl string) (*tls

return tlsConf, nil
}

// getKeychainFromSAImagePullSecrets returns an authn.Keychain gotten from the image pull secrets attached to a
// service account.
func getKeychainFromSAImagePullSecrets(ctx context.Context, c client.Client, ns, saName string) (authn.Keychain, error) {
serviceAccount := corev1.ServiceAccount{}
// Lookup service account
if err := c.Get(ctx, types.NamespacedName{
Namespace: ns,
Name: saName,
}, &serviceAccount); err != nil {
return nil, fmt.Errorf("failed to get serviceaccout: %s", err)
}

if len(serviceAccount.ImagePullSecrets) > 0 {
imagePullSecrets := make([]corev1.Secret, len(serviceAccount.ImagePullSecrets))
for i, ips := range serviceAccount.ImagePullSecrets {
var saAuthSecret corev1.Secret
if err := c.Get(ctx, types.NamespacedName{
Namespace: ns,
Name: ips.Name,
}, &saAuthSecret); err != nil {
return nil, fmt.Errorf("failed to get image pull secret '%s' for serviceaccount '%s': %w",
ips.Name, saName, err)
}
imagePullSecrets[i] = saAuthSecret
}

return k8schain.NewFromPullSecrets(ctx, imagePullSecrets)
}

return nil, nil
}
90 changes: 84 additions & 6 deletions internal/helm/getter/client_opts_test.go
Original file line number Diff line number Diff line change
@@ -44,12 +44,15 @@ func TestGetClientOpts(t *testing.T) {
}

tests := []struct {
name string
certSecret *corev1.Secret
authSecret *corev1.Secret
afterFunc func(t *WithT, hcOpts *ClientOpts)
oci bool
err error
name string
certSecret *corev1.Secret
authSecret *corev1.Secret
imagePullSecret *corev1.Secret
serviceAccount *corev1.ServiceAccount
provider string
afterFunc func(t *WithT, hcOpts *ClientOpts)
oci bool
err error
}{
{
name: "HelmRepository with certSecretRef discards TLS config in secretRef",
@@ -117,6 +120,73 @@ func TestGetClientOpts(t *testing.T) {
},
oci: true,
},
{
name: "OCI HelmRepository with serviceaccount name",
serviceAccount: &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sa",
},
ImagePullSecrets: []corev1.LocalObjectReference{
{
Name: "pull-secret",
},
},
},
imagePullSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pull-secret",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{
corev1.DockerConfigJsonKey: []byte(`{"auths":{"ghcr.io":{"username":"user","password":"pass","auth":"dXNlcjpwYXNz"}}}`),
},
},
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,
},
{
name: "OCI HelmRepository with serviceaccount name and provider (serviceaccount takes precedence)",
provider: helmv1.AzureOCIProvider,
serviceAccount: &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "test-sa",
},
ImagePullSecrets: []corev1.LocalObjectReference{
{
Name: "pull-secret",
},
},
},
imagePullSecret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "pull-secret",
},
Type: corev1.SecretTypeDockerConfigJson,
Data: map[string][]byte{
corev1.DockerConfigJsonKey: []byte(`{"auths":{"ghcr.io":{"username":"user","password":"pass","auth":"dXNlcjpwYXNz"}}}`),
},
},
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 {
@@ -125,6 +195,7 @@ func TestGetClientOpts(t *testing.T) {

helmRepo := &helmv1.HelmRepository{
Spec: helmv1.HelmRepositorySpec{
Provider: tt.provider,
Timeout: &metav1.Duration{
Duration: time.Second,
},
@@ -147,6 +218,13 @@ func TestGetClientOpts(t *testing.T) {
Name: tt.certSecret.Name,
}
}
if tt.imagePullSecret != nil {
clientBuilder.WithObjects(tt.imagePullSecret.DeepCopy())
}
if tt.serviceAccount != nil {
clientBuilder.WithObjects(tt.serviceAccount.DeepCopy())
helmRepo.Spec.ServiceAccountName = tt.serviceAccount.Name
}
c := clientBuilder.Build()

clientOpts, err := GetClientOpts(context.TODO(), c, helmRepo, "https://ghcr.io/dummy")