diff --git a/api/v1/bucket_types.go b/api/v1/bucket_types.go
index 96d06c360..764ee1bbf 100644
--- a/api/v1/bucket_types.go
+++ b/api/v1/bucket_types.go
@@ -33,7 +33,8 @@ const (
// BucketProviderGeneric for any S3 API compatible storage Bucket.
BucketProviderGeneric string = "generic"
// BucketProviderAmazon for an AWS S3 object storage Bucket.
- // Provides support for retrieving credentials from the AWS EC2 service.
+ // Provides support for retrieving credentials from the AWS EC2 service
+ // and workload identity authentication.
BucketProviderAmazon string = "aws"
// BucketProviderGoogle for a Google Cloud Storage Bucket.
// Provides support for authentication using a workload identity.
@@ -51,7 +52,7 @@ const (
// +kubebuilder:validation:XValidation:rule="self.provider != 'generic' || !has(self.sts) || self.sts.provider == 'ldap'", message="'ldap' is the only supported STS provider for the 'generic' Bucket provider"
// +kubebuilder:validation:XValidation:rule="!has(self.sts) || self.sts.provider != 'aws' || !has(self.sts.secretRef)", message="spec.sts.secretRef is not required for the 'aws' STS provider"
// +kubebuilder:validation:XValidation:rule="!has(self.sts) || self.sts.provider != 'aws' || !has(self.sts.certSecretRef)", message="spec.sts.certSecretRef is not required for the 'aws' STS provider"
-// +kubebuilder:validation:XValidation:rule="self.provider == 'gcp' || !has(self.serviceAccountName)", message="ServiceAccountName is only supported for the 'gcp' Bucket provider"
+// +kubebuilder:validation:XValidation:rule="self.provider == 'gcp' || self.provider == 'aws' || !has(self.serviceAccountName)", message="ServiceAccountName is only supported for the 'gcp' and 'aws' Bucket providers"
// +kubebuilder:validation:XValidation:rule="!has(self.secretRef) || !has(self.serviceAccountName)", message="cannot set both .spec.secretRef and .spec.serviceAccountName"
type BucketSpec struct {
// Provider of the object storage bucket.
@@ -96,7 +97,8 @@ type BucketSpec struct {
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
// ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
- // the bucket. For more information about workload identity:
+ // the bucket. This field is only supported for the 'gcp' and 'aws' providers.
+ // For more information about workload identity:
// https://fluxcd.io/flux/components/source/buckets/#workload-identity
// +optional
ServiceAccountName string `json:"serviceAccountName,omitempty"`
diff --git a/config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml b/config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml
index 3b1ec05bc..3776b3c13 100644
--- a/config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml
+++ b/config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml
@@ -145,7 +145,8 @@ spec:
serviceAccountName:
description: |-
ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
- the bucket. For more information about workload identity:
+ the bucket. This field is only supported for the 'gcp' and 'aws' providers.
+ For more information about workload identity:
https://fluxcd.io/flux/components/source/buckets/#workload-identity
type: string
sts:
@@ -238,8 +239,9 @@ spec:
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.secretRef)'
- message: spec.sts.certSecretRef is not required for the 'aws' STS provider
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.certSecretRef)'
- - message: ServiceAccountName is only supported for the 'gcp' Bucket provider
- rule: self.provider == 'gcp' || !has(self.serviceAccountName)
+ - message: ServiceAccountName is only supported for the 'gcp' and 'aws'
+ Bucket providers
+ rule: self.provider == 'gcp' || self.provider == 'aws' || !has(self.serviceAccountName)
- message: cannot set both .spec.secretRef and .spec.serviceAccountName
rule: '!has(self.secretRef) || !has(self.serviceAccountName)'
status:
diff --git a/docs/api/v1/source.md b/docs/api/v1/source.md
index dccda7191..772fb1006 100644
--- a/docs/api/v1/source.md
+++ b/docs/api/v1/source.md
@@ -190,7 +190,8 @@ string
(Optional)
ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
-the bucket. For more information about workload identity:
+the bucket. This field is only supported for the ‘gcp’ and ‘aws’ providers.
+For more information about workload identity:
https://fluxcd.io/flux/components/source/buckets/#workload-identity
|
@@ -1646,7 +1647,8 @@ string
(Optional)
ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
-the bucket. For more information about workload identity:
+the bucket. This field is only supported for the ‘gcp’ and ‘aws’ providers.
+For more information about workload identity:
https://fluxcd.io/flux/components/source/buckets/#workload-identity
|
diff --git a/docs/spec/v1/buckets.md b/docs/spec/v1/buckets.md
index d1b60b8d8..2ce2a8842 100644
--- a/docs/spec/v1/buckets.md
+++ b/docs/spec/v1/buckets.md
@@ -273,6 +273,55 @@ data:
secretkey:
```
+##### AWS Controller-Level Workload Identity example
+
+```yaml
+---
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: Bucket
+metadata:
+ name: aws-controller-level-workload-identity
+ namespace: default
+spec:
+ interval: 5m0s
+ provider: aws
+ bucketName: podinfo
+ endpoint: s3.amazonaws.com
+ region: us-east-1
+ timeout: 30s
+```
+
+##### AWS Object-Level Workload Identity example
+
+**Note:** To use Object-Level Workload Identity (`.spec.serviceAccountName` with
+cloud providers), the controller feature gate `ObjectLevelWorkloadIdentity` must
+be enabled.
+
+```yaml
+---
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: Bucket
+metadata:
+ name: aws-object-level-workload-identity
+ namespace: default
+spec:
+ interval: 5m0s
+ provider: aws
+ bucketName: podinfo
+ endpoint: s3.amazonaws.com
+ region: us-east-1
+ serviceAccountName: aws-workload-identity-sa
+ timeout: 30s
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: aws-workload-identity-sa
+ namespace: default
+ annotations:
+ eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/flux-bucket-role
+```
+
#### Azure
When a Bucket's `.spec.provider` is set to `azure`, the source-controller will
diff --git a/internal/bucket/minio/minio.go b/internal/bucket/minio/minio.go
index 6c7da9727..026200a83 100644
--- a/internal/bucket/minio/minio.go
+++ b/internal/bucket/minio/minio.go
@@ -30,6 +30,9 @@ import (
"github.com/minio/minio-go/v7/pkg/s3utils"
corev1 "k8s.io/api/core/v1"
+ "github.com/fluxcd/pkg/auth"
+ awsauth "github.com/fluxcd/pkg/auth/aws"
+
sourcev1 "github.com/fluxcd/source-controller/api/v1"
)
@@ -46,6 +49,7 @@ type options struct {
tlsConfig *tls.Config
stsTLSConfig *tls.Config
proxyURL *url.URL
+ authOpts []auth.Option
}
// Option is a function that configures the Minio client.
@@ -86,8 +90,15 @@ func WithSTSTLSConfig(tlsConfig *tls.Config) Option {
}
}
+// WithAuth sets the auth options for workload identity authentication.
+func WithAuth(authOpts ...auth.Option) Option {
+ return func(o *options) {
+ o.authOpts = authOpts
+ }
+}
+
// NewClient creates a new Minio storage client.
-func NewClient(bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
+func NewClient(ctx context.Context, bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
var o options
for _, opt := range opts {
opt(&o)
@@ -105,7 +116,11 @@ func NewClient(bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
case o.secret != nil:
minioOpts.Creds = newCredsFromSecret(o.secret)
case bucketProvider == sourcev1.BucketProviderAmazon:
- minioOpts.Creds = newAWSCreds(bucket, o.proxyURL)
+ creds, err := newAWSCreds(ctx, &o)
+ if err != nil {
+ return nil, err
+ }
+ minioOpts.Creds = creds
case bucketProvider == sourcev1.BucketProviderGeneric:
minioOpts.Creds = newGenericCreds(bucket, &o)
}
@@ -159,23 +174,30 @@ func newCredsFromSecret(secret *corev1.Secret) *credentials.Credentials {
}
// newAWSCreds creates a new Minio credentials object for `aws` bucket provider.
-func newAWSCreds(bucket *sourcev1.Bucket, proxyURL *url.URL) *credentials.Credentials {
- stsEndpoint := ""
- if sts := bucket.Spec.STS; sts != nil {
- stsEndpoint = sts.Endpoint
- }
-
- creds := credentials.NewIAM(stsEndpoint)
- if proxyURL != nil {
- transport := http.DefaultTransport.(*http.Transport).Clone()
- transport.Proxy = http.ProxyURL(proxyURL)
- client := &http.Client{Transport: transport}
- creds = credentials.New(&credentials.IAM{
- Client: client,
- Endpoint: stsEndpoint,
- })
+//
+// This function is only called when Secret authentication is not available.
+//
+// Uses AWS SDK's config.LoadDefaultConfig() which supports:
+// - Workload Identity (IRSA/EKS Pod Identity)
+// - EC2 instance profiles
+// - Environment variables
+// - Shared credentials files
+// - All other AWS SDK authentication methods
+func newAWSCreds(ctx context.Context, o *options) (*credentials.Credentials, error) {
+ var opts auth.Options
+ opts.Apply(o.authOpts...)
+
+ awsCredsProvider := awsauth.NewCredentialsProvider(ctx, o.authOpts...)
+ awsCreds, err := awsCredsProvider.Retrieve(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("AWS authentication failed: %w", err)
}
- return creds
+
+ return credentials.NewStaticV4(
+ awsCreds.AccessKeyID,
+ awsCreds.SecretAccessKey,
+ awsCreds.SessionToken,
+ ), nil
}
// newGenericCreds creates a new Minio credentials object for the `generic` bucket provider.
diff --git a/internal/bucket/minio/minio_test.go b/internal/bucket/minio/minio_test.go
index abb5eee5b..4f89d341a 100644
--- a/internal/bucket/minio/minio_test.go
+++ b/internal/bucket/minio/minio_test.go
@@ -20,7 +20,6 @@ import (
"context"
"crypto/tls"
"crypto/x509"
- "encoding/json"
"encoding/xml"
"errors"
"fmt"
@@ -76,6 +75,8 @@ var (
testServerCert string
// testServerKey is the path to the server key used to start the Minio and STS servers.
testServerKey string
+ // ctx is the common context used in tests.
+ ctx context.Context
)
var (
@@ -126,6 +127,9 @@ var (
)
func TestMain(m *testing.M) {
+ // Initialize common test context
+ ctx = context.Background()
+
// Uses a sensible default on Windows (TCP/HTTP) and Linux/MacOS (socket)
pool, err := dockertest.NewPool("")
if err != nil {
@@ -173,7 +177,7 @@ func TestMain(m *testing.M) {
testMinioAddress = fmt.Sprintf("127.0.0.1:%v", resource.GetPort("9000/tcp"))
// Construct a Minio client using the address of the Minio server.
- testMinioClient, err = NewClient(bucketStub(bucket, testMinioAddress),
+ testMinioClient, err = NewClient(ctx, bucketStub(bucket, testMinioAddress),
WithSecret(secret.DeepCopy()),
WithTLSConfig(testTLSConfig))
if err != nil {
@@ -197,7 +201,6 @@ func TestMain(m *testing.M) {
log.Fatalf("could not connect to docker: %s", err)
}
- ctx := context.Background()
createBucket(ctx)
addObjectToBucket(ctx)
run := m.Run()
@@ -208,7 +211,7 @@ func TestMain(m *testing.M) {
}
func TestNewClient(t *testing.T) {
- minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
+ minioClient, err := NewClient(ctx, bucketStub(bucket, testMinioAddress),
WithSecret(secret.DeepCopy()),
WithTLSConfig(testTLSConfig))
assert.NilError(t, err)
@@ -216,35 +219,54 @@ func TestNewClient(t *testing.T) {
}
func TestNewClientEmptySecret(t *testing.T) {
- minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
+ minioClient, err := NewClient(ctx, bucketStub(bucket, testMinioAddress),
WithSecret(emptySecret.DeepCopy()),
WithTLSConfig(testTLSConfig))
assert.NilError(t, err)
assert.Assert(t, minioClient != nil)
}
-func TestNewClientAwsProvider(t *testing.T) {
- minioClient, err := NewClient(bucketStub(bucketAwsProvider, testMinioAddress))
- assert.NilError(t, err)
- assert.Assert(t, minioClient != nil)
+func TestNewClientAWSProvider(t *testing.T) {
+ t.Run("with secret", func(t *testing.T) {
+ validSecret := corev1.Secret{
+ ObjectMeta: v1.ObjectMeta{
+ Name: "valid-secret",
+ Namespace: "default",
+ },
+ Data: map[string][]byte{
+ "accesskey": []byte(testMinioRootUser),
+ "secretkey": []byte(testMinioRootPassword),
+ },
+ Type: "Opaque",
+ }
+
+ bucket := bucketStub(bucketAwsProvider, testMinioAddress)
+ minioClient, err := NewClient(ctx, bucket, WithSecret(&validSecret))
+ assert.NilError(t, err)
+ assert.Assert(t, minioClient != nil)
+ })
+
+ t.Run("without secret", func(t *testing.T) {
+ bucket := bucketStub(bucketAwsProvider, testMinioAddress)
+ minioClient, err := NewClient(ctx, bucket)
+ assert.ErrorContains(t, err, "AWS authentication failed")
+ assert.Assert(t, minioClient == nil)
+ })
}
func TestBucketExists(t *testing.T) {
- ctx := context.Background()
exists, err := testMinioClient.BucketExists(ctx, bucketName)
assert.NilError(t, err)
assert.Assert(t, exists)
}
func TestBucketNotExists(t *testing.T) {
- ctx := context.Background()
exists, err := testMinioClient.BucketExists(ctx, "notexistsbucket")
assert.NilError(t, err)
assert.Assert(t, !exists)
}
func TestFGetObject(t *testing.T) {
- ctx := context.Background()
tempDir := t.TempDir()
path := filepath.Join(tempDir, sourceignore.IgnoreFile)
_, err := testMinioClient.FGetObject(ctx, bucketName, objectName, path)
@@ -252,41 +274,7 @@ func TestFGetObject(t *testing.T) {
}
func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
- // start a mock AWS STS server
- awsSTSListener, awsSTSAddr, awsSTSPort := testlistener.New(t)
- awsSTSEndpoint := fmt.Sprintf("http://%s", awsSTSAddr)
- awsSTSHandler := http.NewServeMux()
- awsSTSHandler.HandleFunc("PUT "+credentials.TokenPath,
- func(w http.ResponseWriter, r *http.Request) {
- _, err := w.Write([]byte("mock-token"))
- assert.NilError(t, err)
- })
- awsSTSHandler.HandleFunc("GET "+credentials.DefaultIAMSecurityCredsPath,
- func(w http.ResponseWriter, r *http.Request) {
- token := r.Header.Get(credentials.TokenRequestHeader)
- assert.Equal(t, token, "mock-token")
- _, err := w.Write([]byte("mock-role"))
- assert.NilError(t, err)
- })
var credsRetrieved bool
- awsSTSHandler.HandleFunc("GET "+credentials.DefaultIAMSecurityCredsPath+"mock-role",
- func(w http.ResponseWriter, r *http.Request) {
- token := r.Header.Get(credentials.TokenRequestHeader)
- assert.Equal(t, token, "mock-token")
- err := json.NewEncoder(w).Encode(map[string]any{
- "Code": "Success",
- "AccessKeyID": testMinioRootUser,
- "SecretAccessKey": testMinioRootPassword,
- })
- assert.NilError(t, err)
- credsRetrieved = true
- })
- awsSTSServer := &http.Server{
- Addr: awsSTSAddr,
- Handler: awsSTSHandler,
- }
- go awsSTSServer.Serve(awsSTSListener)
- defer awsSTSServer.Shutdown(context.Background())
// start a mock LDAP STS server
ldapSTSListener, ldapSTSAddr, ldapSTSPort := testlistener.New(t)
@@ -313,7 +301,7 @@ func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
Handler: ldapSTSHandler,
}
go ldapSTSServer.ServeTLS(ldapSTSListener, testServerCert, testServerKey)
- defer ldapSTSServer.Shutdown(context.Background())
+ defer ldapSTSServer.Shutdown(ctx)
// start proxy
proxyAddr, proxyPort := testproxy.New(t)
@@ -327,42 +315,6 @@ func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
ldapPassword string
err string
}{
- {
- name: "with correct aws endpoint",
- provider: "aws",
- stsSpec: &sourcev1.BucketSTSSpec{
- Provider: "aws",
- Endpoint: awsSTSEndpoint,
- },
- },
- {
- name: "with incorrect aws endpoint",
- provider: "aws",
- stsSpec: &sourcev1.BucketSTSSpec{
- Provider: "aws",
- Endpoint: fmt.Sprintf("http://localhost:%d", awsSTSPort+1),
- },
- err: "connection refused",
- },
- {
- name: "with correct aws endpoint and proxy",
- provider: "aws",
- stsSpec: &sourcev1.BucketSTSSpec{
- Provider: "aws",
- Endpoint: awsSTSEndpoint,
- },
- opts: []Option{WithProxyURL(&url.URL{Scheme: "http", Host: proxyAddr})},
- },
- {
- name: "with correct aws endpoint and incorrect proxy",
- provider: "aws",
- stsSpec: &sourcev1.BucketSTSSpec{
- Provider: "aws",
- Endpoint: awsSTSEndpoint,
- },
- opts: []Option{WithProxyURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", proxyPort+1)})},
- err: "connection refused",
- },
{
name: "with correct ldap endpoint",
provider: "generic",
@@ -448,11 +400,10 @@ func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
opts := tt.opts
opts = append(opts, WithTLSConfig(testTLSConfig))
- minioClient, err := NewClient(bucket, opts...)
+ minioClient, err := NewClient(ctx, bucket, opts...)
assert.NilError(t, err)
assert.Assert(t, minioClient != nil)
- ctx := context.Background()
path := filepath.Join(t.TempDir(), sourceignore.IgnoreFile)
_, err = minioClient.FGetObject(ctx, bucketName, objectName, path)
if tt.err != "" {
@@ -487,13 +438,12 @@ func TestNewClientAndFGetObjectWithProxy(t *testing.T) {
// run test
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
+ minioClient, err := NewClient(ctx, bucketStub(bucket, testMinioAddress),
WithSecret(secret.DeepCopy()),
WithTLSConfig(testTLSConfig),
WithProxyURL(tt.proxyURL))
assert.NilError(t, err)
assert.Assert(t, minioClient != nil)
- ctx := context.Background()
tempDir := t.TempDir()
path := filepath.Join(tempDir, sourceignore.IgnoreFile)
_, err = minioClient.FGetObject(ctx, bucketName, objectName, path)
@@ -507,7 +457,6 @@ func TestNewClientAndFGetObjectWithProxy(t *testing.T) {
}
func TestFGetObjectNotExists(t *testing.T) {
- ctx := context.Background()
tempDir := t.TempDir()
badKey := "invalid.txt"
path := filepath.Join(tempDir, badKey)
@@ -530,7 +479,6 @@ func TestVisitObjects(t *testing.T) {
}
func TestVisitObjectsErr(t *testing.T) {
- ctx := context.Background()
badBucketName := "bad-bucket"
err := testMinioClient.VisitObjects(ctx, badBucketName, prefix, func(string, string) error {
return nil
diff --git a/internal/controller/bucket_controller.go b/internal/controller/bucket_controller.go
index c3cf55b84..0c96701f7 100644
--- a/internal/controller/bucket_controller.go
+++ b/internal/controller/bucket_controller.go
@@ -884,6 +884,14 @@ func (r *BucketReconciler) createBucketProvider(ctx context.Context, obj *source
authOpts = append(authOpts, auth.WithProxyURL(*creds.proxyURL))
}
+ if obj.Spec.Region != "" {
+ authOpts = append(authOpts, auth.WithSTSRegion(obj.Spec.Region))
+ }
+
+ if sts := obj.Spec.STS; sts != nil {
+ authOpts = append(authOpts, auth.WithSTSEndpoint(sts.Endpoint))
+ }
+
switch obj.Spec.Provider {
case sourcev1.BucketProviderGoogle:
var opts []gcp.Option
@@ -933,6 +941,8 @@ func (r *BucketReconciler) createBucketProvider(ctx context.Context, obj *source
var opts []minio.Option
if creds.secret != nil {
opts = append(opts, minio.WithSecret(creds.secret))
+ } else if obj.Spec.Provider == sourcev1.BucketProviderAmazon {
+ opts = append(opts, minio.WithAuth(authOpts...))
}
if creds.tlsConfig != nil {
opts = append(opts, minio.WithTLSConfig(creds.tlsConfig))
@@ -946,7 +956,7 @@ func (r *BucketReconciler) createBucketProvider(ctx context.Context, obj *source
if creds.stsTLSConfig != nil {
opts = append(opts, minio.WithSTSTLSConfig(creds.stsTLSConfig))
}
- return minio.NewClient(obj, opts...)
+ return minio.NewClient(ctx, obj, opts...)
}
}