Skip to content
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

Add .spec.serviceAccountName to HelmRepository #1195

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
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
Expand Up @@ -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'.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/spec/v1beta2/helmrepositories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 64 additions & 9 deletions internal/helm/getter/client_opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Up @@ -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",
Expand Down Expand Up @@ -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 {
Expand All @@ -125,6 +195,7 @@ func TestGetClientOpts(t *testing.T) {

helmRepo := &helmv1.HelmRepository{
Spec: helmv1.HelmRepositorySpec{
Provider: tt.provider,
Timeout: &metav1.Duration{
Duration: time.Second,
},
Expand All @@ -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")
Expand Down