[RFC-0010] Add multi-tenant workload identity support for GCP Bucket
Signed-off-by: cappyzawa <cappyzawa@gmail.com>
This commit is contained in:
parent
1469073055
commit
3733163358
|
|
@ -51,6 +51,8 @@ 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="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.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="!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="!has(self.secretRef) || !has(self.serviceAccountName)", message="cannot set both .spec.secretRef and .spec.serviceAccountName"
|
||||||
type BucketSpec struct {
|
type BucketSpec struct {
|
||||||
// Provider of the object storage bucket.
|
// Provider of the object storage bucket.
|
||||||
// Defaults to 'generic', which expects an S3 (API) compatible object
|
// Defaults to 'generic', which expects an S3 (API) compatible object
|
||||||
|
|
@ -93,6 +95,12 @@ type BucketSpec struct {
|
||||||
// +optional
|
// +optional
|
||||||
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
|
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:
|
||||||
|
// https://fluxcd.io/flux/components/source/buckets/#workload-identity
|
||||||
|
// +optional
|
||||||
|
ServiceAccountName string `json:"serviceAccountName,omitempty"`
|
||||||
|
|
||||||
// CertSecretRef can be given the name of a Secret containing
|
// CertSecretRef can be given the name of a Secret containing
|
||||||
// either or both of
|
// either or both of
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,12 @@ spec:
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
type: object
|
type: object
|
||||||
|
serviceAccountName:
|
||||||
|
description: |-
|
||||||
|
ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
|
||||||
|
the bucket. For more information about workload identity:
|
||||||
|
https://fluxcd.io/flux/components/source/buckets/#workload-identity
|
||||||
|
type: string
|
||||||
sts:
|
sts:
|
||||||
description: |-
|
description: |-
|
||||||
STS specifies the required configuration to use a Security Token
|
STS specifies the required configuration to use a Security Token
|
||||||
|
|
@ -232,6 +238,10 @@ spec:
|
||||||
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.secretRef)'
|
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.secretRef)'
|
||||||
- message: spec.sts.certSecretRef is not required for the 'aws' STS provider
|
- message: spec.sts.certSecretRef is not required for the 'aws' STS provider
|
||||||
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.certSecretRef)'
|
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: cannot set both .spec.secretRef and .spec.serviceAccountName
|
||||||
|
rule: '!has(self.secretRef) || !has(self.serviceAccountName)'
|
||||||
status:
|
status:
|
||||||
default:
|
default:
|
||||||
observedGeneration: -1
|
observedGeneration: -1
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ rules:
|
||||||
- ""
|
- ""
|
||||||
resources:
|
resources:
|
||||||
- secrets
|
- secrets
|
||||||
|
- serviceaccounts
|
||||||
verbs:
|
verbs:
|
||||||
- get
|
- get
|
||||||
- list
|
- list
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,20 @@ for the Bucket.</p>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
<code>serviceAccountName</code><br>
|
||||||
|
<em>
|
||||||
|
string
|
||||||
|
</em>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<em>(Optional)</em>
|
||||||
|
<p>ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
|
||||||
|
the bucket. For more information about workload identity:
|
||||||
|
<a href="https://fluxcd.io/flux/components/source/buckets/#workload-identity">https://fluxcd.io/flux/components/source/buckets/#workload-identity</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
<code>certSecretRef</code><br>
|
<code>certSecretRef</code><br>
|
||||||
<em>
|
<em>
|
||||||
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
|
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
|
||||||
|
|
@ -1624,6 +1638,20 @@ for the Bucket.</p>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
<code>serviceAccountName</code><br>
|
||||||
|
<em>
|
||||||
|
string
|
||||||
|
</em>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<em>(Optional)</em>
|
||||||
|
<p>ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
|
||||||
|
the bucket. For more information about workload identity:
|
||||||
|
<a href="https://fluxcd.io/flux/components/source/buckets/#workload-identity">https://fluxcd.io/flux/components/source/buckets/#workload-identity</a></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
<code>certSecretRef</code><br>
|
<code>certSecretRef</code><br>
|
||||||
<em>
|
<em>
|
||||||
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
|
<a href="https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta#LocalObjectReference">
|
||||||
|
|
|
||||||
|
|
@ -647,29 +647,16 @@ Refer to the [Azure documentation](https://learn.microsoft.com/en-us/rest/api/st
|
||||||
|
|
||||||
#### GCP
|
#### GCP
|
||||||
|
|
||||||
When a Bucket's `.spec.provider` is set to `gcp`, the source-controller will
|
For detailed setup instructions, see: https://fluxcd.io/flux/integrations/gcp/#for-google-cloud-storage
|
||||||
attempt to communicate with the specified [Endpoint](#endpoint) using the
|
|
||||||
[Google Client SDK](https://github.com/googleapis/google-api-go-client).
|
|
||||||
|
|
||||||
Without a [Secret reference](#secret-reference), authorization using a
|
##### GCP Controller-Level Workload Identity example
|
||||||
workload identity is attempted by default. The workload identity is obtained
|
|
||||||
using the `GOOGLE_APPLICATION_CREDENTIALS` environment variable, falling back
|
|
||||||
to the Google Application Credential file in the config directory.
|
|
||||||
When a reference is specified, it expects a Secret with a `.data.serviceaccount`
|
|
||||||
value with a GCP service account JSON file.
|
|
||||||
|
|
||||||
The Provider allows for specifying the
|
|
||||||
[Bucket location](https://cloud.google.com/storage/docs/locations) using the
|
|
||||||
[`.spec.region` field](#region).
|
|
||||||
|
|
||||||
##### GCP example
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
apiVersion: source.toolkit.fluxcd.io/v1
|
apiVersion: source.toolkit.fluxcd.io/v1
|
||||||
kind: Bucket
|
kind: Bucket
|
||||||
metadata:
|
metadata:
|
||||||
name: gcp-workload-identity
|
name: gcp-controller-level-workload-identity
|
||||||
namespace: default
|
namespace: default
|
||||||
spec:
|
spec:
|
||||||
interval: 5m0s
|
interval: 5m0s
|
||||||
|
|
@ -680,6 +667,37 @@ spec:
|
||||||
timeout: 30s
|
timeout: 30s
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### GCP 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: gcp-object-level-workload-identity
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
interval: 5m0s
|
||||||
|
provider: gcp
|
||||||
|
bucketName: podinfo
|
||||||
|
endpoint: storage.googleapis.com
|
||||||
|
region: us-east-1
|
||||||
|
serviceAccountName: gcp-workload-identity-sa
|
||||||
|
timeout: 30s
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: gcp-workload-identity-sa
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
iam.gke.io/gcp-service-account: <identity-name>
|
||||||
|
```
|
||||||
|
|
||||||
##### GCP static auth example
|
##### GCP static auth example
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
@ -959,6 +977,29 @@ credentials for the object storage. For some `.spec.provider` implementations
|
||||||
the presence of the field is required, see [Provider](#provider) for more
|
the presence of the field is required, see [Provider](#provider) for more
|
||||||
details and examples.
|
details and examples.
|
||||||
|
|
||||||
|
### Service Account reference
|
||||||
|
|
||||||
|
`.spec.serviceAccountName` is an optional field to specify a Service Account
|
||||||
|
in the same namespace as Bucket with purpose depending on the value of
|
||||||
|
the `.spec.provider` field:
|
||||||
|
|
||||||
|
- When `.spec.provider` is set to `generic`, the controller will fetch the image
|
||||||
|
pull secrets attached to the Service Account and use them for authentication.
|
||||||
|
- When `.spec.provider` is set to `aws`, `azure`, or `gcp`, the Service Account
|
||||||
|
will be used for Workload Identity authentication. In this case, the controller
|
||||||
|
feature gate `ObjectLevelWorkloadIdentity` must be enabled, otherwise the
|
||||||
|
controller will error out.
|
||||||
|
|
||||||
|
**Note:** that for a publicly accessible object storage, you don't need to
|
||||||
|
provide a `secretRef` nor `serviceAccountName`.
|
||||||
|
|
||||||
|
**Important:** `.spec.secretRef` and `.spec.serviceAccountName` are mutually
|
||||||
|
exclusive and cannot be set at the same time. This constraint is enforced
|
||||||
|
at the CRD level.
|
||||||
|
|
||||||
|
For a complete guide on how to set up authentication for cloud providers,
|
||||||
|
see the integration [docs](/flux/integrations/).
|
||||||
|
|
||||||
### Prefix
|
### Prefix
|
||||||
|
|
||||||
`.spec.prefix` is an optional field to enable server-side filtering
|
`.spec.prefix` is an optional field to enable server-side filtering
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ 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/auth"
|
||||||
|
"github.com/fluxcd/pkg/cache"
|
||||||
"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/jitter"
|
"github.com/fluxcd/pkg/runtime/jitter"
|
||||||
|
|
@ -116,6 +118,8 @@ var bucketFailConditions = []string{
|
||||||
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=buckets/status,verbs=get;update;patch
|
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=buckets/status,verbs=get;update;patch
|
||||||
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=buckets/finalizers,verbs=get;create;update;patch;delete
|
// +kubebuilder:rbac:groups=source.toolkit.fluxcd.io,resources=buckets/finalizers,verbs=get;create;update;patch;delete
|
||||||
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
|
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
|
||||||
|
// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch
|
||||||
|
// +kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create
|
||||||
|
|
||||||
// BucketReconciler reconciles a v1.Bucket object.
|
// BucketReconciler reconciles a v1.Bucket object.
|
||||||
type BucketReconciler struct {
|
type BucketReconciler struct {
|
||||||
|
|
@ -125,6 +129,7 @@ type BucketReconciler struct {
|
||||||
|
|
||||||
Storage *Storage
|
Storage *Storage
|
||||||
ControllerName string
|
ControllerName string
|
||||||
|
TokenCache *cache.TokenCache
|
||||||
|
|
||||||
patchOptions []patch.Option
|
patchOptions []patch.Option
|
||||||
}
|
}
|
||||||
|
|
@ -430,6 +435,18 @@ func (r *BucketReconciler) reconcileStorage(ctx context.Context, sp *patch.Seria
|
||||||
// the provider. If this fails, it records v1.FetchFailedCondition=True on
|
// the provider. If this fails, it records v1.FetchFailedCondition=True on
|
||||||
// the object and returns early.
|
// the object and returns early.
|
||||||
func (r *BucketReconciler) reconcileSource(ctx context.Context, sp *patch.SerialPatcher, obj *sourcev1.Bucket, index *index.Digester, dir string) (sreconcile.Result, error) {
|
func (r *BucketReconciler) reconcileSource(ctx context.Context, sp *patch.SerialPatcher, obj *sourcev1.Bucket, index *index.Digester, dir string) (sreconcile.Result, error) {
|
||||||
|
usesObjectLevelWorkloadIdentity := obj.Spec.Provider != "" && obj.Spec.Provider != sourcev1.BucketProviderGeneric && obj.Spec.ServiceAccountName != ""
|
||||||
|
if usesObjectLevelWorkloadIdentity {
|
||||||
|
if !auth.IsObjectLevelWorkloadIdentityEnabled() {
|
||||||
|
const gate = auth.FeatureGateObjectLevelWorkloadIdentity
|
||||||
|
const msgFmt = "to use spec.serviceAccountName for provider authentication please enable the %s feature gate in the controller"
|
||||||
|
err := fmt.Errorf(msgFmt, gate)
|
||||||
|
e := serror.NewStalling(err, meta.FeatureGateDisabledReason)
|
||||||
|
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e)
|
||||||
|
return sreconcile.ResultEmpty, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
creds, err := r.setupCredentials(ctx, obj)
|
creds, err := r.setupCredentials(ctx, obj)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e := serror.NewGeneric(err, sourcev1.AuthenticationFailedReason)
|
e := serror.NewGeneric(err, sourcev1.AuthenticationFailedReason)
|
||||||
|
|
@ -590,6 +607,10 @@ func (r *BucketReconciler) reconcileDelete(ctx context.Context, obj *sourcev1.Bu
|
||||||
// Remove our finalizer from the list
|
// Remove our finalizer from the list
|
||||||
controllerutil.RemoveFinalizer(obj, sourcev1.SourceFinalizer)
|
controllerutil.RemoveFinalizer(obj, sourcev1.SourceFinalizer)
|
||||||
|
|
||||||
|
// Cleanup caches.
|
||||||
|
r.TokenCache.DeleteEventsForObject(sourcev1.BucketKind,
|
||||||
|
obj.GetName(), obj.GetNamespace(), cache.OperationReconcile)
|
||||||
|
|
||||||
// Stop reconciliation as the object is being deleted
|
// Stop reconciliation as the object is being deleted
|
||||||
return sreconcile.ResultEmpty, nil
|
return sreconcile.ResultEmpty, nil
|
||||||
}
|
}
|
||||||
|
|
@ -838,19 +859,47 @@ func (r *BucketReconciler) setupCredentials(ctx context.Context, obj *sourcev1.B
|
||||||
// createBucketProvider creates a provider-specific bucket client using the given credentials and configuration.
|
// createBucketProvider creates a provider-specific bucket client using the given credentials and configuration.
|
||||||
// It handles different bucket providers (AWS, GCP, Azure, generic) and returns the appropriate client.
|
// It handles different bucket providers (AWS, GCP, Azure, generic) and returns the appropriate client.
|
||||||
func (r *BucketReconciler) createBucketProvider(ctx context.Context, obj *sourcev1.Bucket, creds *bucketCredentials) (BucketProvider, error) {
|
func (r *BucketReconciler) createBucketProvider(ctx context.Context, obj *sourcev1.Bucket, creds *bucketCredentials) (BucketProvider, error) {
|
||||||
|
var authOpts []auth.Option
|
||||||
|
|
||||||
|
if obj.Spec.ServiceAccountName != "" {
|
||||||
|
serviceAccount := client.ObjectKey{
|
||||||
|
Name: obj.Spec.ServiceAccountName,
|
||||||
|
Namespace: obj.GetNamespace(),
|
||||||
|
}
|
||||||
|
authOpts = append(authOpts, auth.WithServiceAccount(serviceAccount, r.Client))
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.TokenCache != nil {
|
||||||
|
involvedObject := cache.InvolvedObject{
|
||||||
|
Kind: sourcev1.BucketKind,
|
||||||
|
Name: obj.GetName(),
|
||||||
|
Namespace: obj.GetNamespace(),
|
||||||
|
Operation: cache.OperationReconcile,
|
||||||
|
}
|
||||||
|
authOpts = append(authOpts, auth.WithCache(*r.TokenCache, involvedObject))
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.proxyURL != nil {
|
||||||
|
authOpts = append(authOpts, auth.WithProxyURL(*creds.proxyURL))
|
||||||
|
}
|
||||||
|
|
||||||
switch obj.Spec.Provider {
|
switch obj.Spec.Provider {
|
||||||
case sourcev1.BucketProviderGoogle:
|
case sourcev1.BucketProviderGoogle:
|
||||||
if err := gcp.ValidateSecret(creds.secret); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var opts []gcp.Option
|
var opts []gcp.Option
|
||||||
if creds.secret != nil {
|
|
||||||
opts = append(opts, gcp.WithSecret(creds.secret))
|
|
||||||
}
|
|
||||||
if creds.proxyURL != nil {
|
if creds.proxyURL != nil {
|
||||||
opts = append(opts, gcp.WithProxyURL(creds.proxyURL))
|
opts = append(opts, gcp.WithProxyURL(creds.proxyURL))
|
||||||
}
|
}
|
||||||
return gcp.NewClient(ctx, opts...)
|
|
||||||
|
if creds.secret != nil {
|
||||||
|
if err := gcp.ValidateSecret(creds.secret); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts = append(opts, gcp.WithSecret(creds.secret))
|
||||||
|
} else {
|
||||||
|
opts = append(opts, gcp.WithAuth(authOpts...))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gcp.NewClient(ctx, obj, opts...)
|
||||||
|
|
||||||
case sourcev1.BucketProviderAzure:
|
case sourcev1.BucketProviderAzure:
|
||||||
if err := azure.ValidateSecret(creds.secret); err != nil {
|
if err := azure.ValidateSecret(creds.secret); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -437,6 +437,7 @@ func TestBucketReconciler_reconcileSource_generic(t *testing.T) {
|
||||||
bucketObjects []*s3mock.Object
|
bucketObjects []*s3mock.Object
|
||||||
middleware http.Handler
|
middleware http.Handler
|
||||||
secret *corev1.Secret
|
secret *corev1.Secret
|
||||||
|
serviceAccount *corev1.ServiceAccount
|
||||||
beforeFunc func(obj *sourcev1.Bucket)
|
beforeFunc func(obj *sourcev1.Bucket)
|
||||||
want sreconcile.Result
|
want sreconcile.Result
|
||||||
wantErr bool
|
wantErr bool
|
||||||
|
|
@ -910,6 +911,10 @@ func TestBucketReconciler_reconcileSource_generic(t *testing.T) {
|
||||||
clientBuilder.WithObjects(tt.secret)
|
clientBuilder.WithObjects(tt.secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tt.serviceAccount != nil {
|
||||||
|
clientBuilder.WithObjects(tt.serviceAccount)
|
||||||
|
}
|
||||||
|
|
||||||
r := &BucketReconciler{
|
r := &BucketReconciler{
|
||||||
EventRecorder: record.NewFakeRecorder(32),
|
EventRecorder: record.NewFakeRecorder(32),
|
||||||
Client: clientBuilder.Build(),
|
Client: clientBuilder.Build(),
|
||||||
|
|
@ -976,11 +981,13 @@ func TestBucketReconciler_reconcileSource_gcs(t *testing.T) {
|
||||||
bucketName string
|
bucketName string
|
||||||
bucketObjects []*gcsmock.Object
|
bucketObjects []*gcsmock.Object
|
||||||
secret *corev1.Secret
|
secret *corev1.Secret
|
||||||
|
serviceAccount *corev1.ServiceAccount
|
||||||
beforeFunc func(obj *sourcev1.Bucket)
|
beforeFunc func(obj *sourcev1.Bucket)
|
||||||
want sreconcile.Result
|
want sreconcile.Result
|
||||||
wantErr bool
|
wantErr bool
|
||||||
assertIndex *index.Digester
|
assertIndex *index.Digester
|
||||||
assertConditions []metav1.Condition
|
assertConditions []metav1.Condition
|
||||||
|
disableObjectLevelWorkloadIdentity bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Reconciles GCS source",
|
name: "Reconciles GCS source",
|
||||||
|
|
@ -1283,6 +1290,80 @@ func TestBucketReconciler_reconcileSource_gcs(t *testing.T) {
|
||||||
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new upstream revision 'sha256:b4c2a60ce44b67f5b659a95ce4e4cc9e2a86baf13afb72bd397c5384cbc0e479'"),
|
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new upstream revision 'sha256:b4c2a60ce44b67f5b659a95ce4e4cc9e2a86baf13afb72bd397c5384cbc0e479'"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "GCS Object-Level Workload Identity (no secret)",
|
||||||
|
bucketName: "dummy",
|
||||||
|
bucketObjects: []*gcsmock.Object{
|
||||||
|
{
|
||||||
|
Key: "test.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Content: []byte("test"),
|
||||||
|
Generation: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceAccount: &corev1.ServiceAccount{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-sa",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beforeFunc: func(obj *sourcev1.Bucket) {
|
||||||
|
obj.Spec.ServiceAccountName = "test-sa"
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultSuccess,
|
||||||
|
assertIndex: index.NewDigester(index.WithIndex(map[string]string{
|
||||||
|
"test.txt": "098f6bcd4621d373cade4e832627b4f6",
|
||||||
|
})),
|
||||||
|
assertConditions: []metav1.Condition{
|
||||||
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new upstream revision 'sha256:b4c2a60ce44b67f5b659a95ce4e4cc9e2a86baf13afb72bd397c5384cbc0e479'"),
|
||||||
|
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new upstream revision 'sha256:b4c2a60ce44b67f5b659a95ce4e4cc9e2a86baf13afb72bd397c5384cbc0e479'"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GCS Controller-Level Workload Identity (no secret, no SA)",
|
||||||
|
bucketName: "dummy",
|
||||||
|
bucketObjects: []*gcsmock.Object{
|
||||||
|
{
|
||||||
|
Key: "test.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Content: []byte("test"),
|
||||||
|
Generation: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beforeFunc: func(obj *sourcev1.Bucket) {
|
||||||
|
// ServiceAccountName は設定しない (Controller-Level)
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultSuccess,
|
||||||
|
assertIndex: index.NewDigester(index.WithIndex(map[string]string{
|
||||||
|
"test.txt": "098f6bcd4621d373cade4e832627b4f6",
|
||||||
|
})),
|
||||||
|
assertConditions: []metav1.Condition{
|
||||||
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "building artifact: new upstream revision 'sha256:b4c2a60ce44b67f5b659a95ce4e4cc9e2a86baf13afb72bd397c5384cbc0e479'"),
|
||||||
|
*conditions.UnknownCondition(meta.ReadyCondition, meta.ProgressingReason, "building artifact: new upstream revision 'sha256:b4c2a60ce44b67f5b659a95ce4e4cc9e2a86baf13afb72bd397c5384cbc0e479'"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GCS Object-Level fails when feature gate disabled",
|
||||||
|
bucketName: "dummy",
|
||||||
|
serviceAccount: &corev1.ServiceAccount{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: "test-sa",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
beforeFunc: func(obj *sourcev1.Bucket) {
|
||||||
|
obj.Spec.ServiceAccountName = "test-sa"
|
||||||
|
conditions.MarkReconciling(obj, meta.ProgressingReason, "foo")
|
||||||
|
conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar")
|
||||||
|
},
|
||||||
|
want: sreconcile.ResultEmpty,
|
||||||
|
wantErr: true,
|
||||||
|
assertIndex: index.NewDigester(),
|
||||||
|
assertConditions: []metav1.Condition{
|
||||||
|
*conditions.TrueCondition(sourcev1.FetchFailedCondition, meta.FeatureGateDisabledReason, "to use spec.serviceAccountName for provider authentication please enable the ObjectLevelWorkloadIdentity feature gate in the controller"),
|
||||||
|
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"),
|
||||||
|
*conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"),
|
||||||
|
},
|
||||||
|
disableObjectLevelWorkloadIdentity: true,
|
||||||
|
},
|
||||||
// TODO: Middleware for mock server to test authentication using secret.
|
// TODO: Middleware for mock server to test authentication using secret.
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
@ -1297,12 +1378,24 @@ func TestBucketReconciler_reconcileSource_gcs(t *testing.T) {
|
||||||
clientBuilder.WithObjects(tt.secret)
|
clientBuilder.WithObjects(tt.secret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tt.serviceAccount != nil {
|
||||||
|
clientBuilder.WithObjects(tt.serviceAccount)
|
||||||
|
}
|
||||||
|
|
||||||
r := &BucketReconciler{
|
r := &BucketReconciler{
|
||||||
EventRecorder: record.NewFakeRecorder(32),
|
EventRecorder: record.NewFakeRecorder(32),
|
||||||
Client: clientBuilder.Build(),
|
Client: clientBuilder.Build(),
|
||||||
Storage: testStorage,
|
Storage: testStorage,
|
||||||
patchOptions: getPatchOptions(bucketReadyCondition.Owned, "sc"),
|
patchOptions: getPatchOptions(bucketReadyCondition.Owned, "sc"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle ObjectLevelWorkloadIdentity feature gate environment variable
|
||||||
|
if tt.disableObjectLevelWorkloadIdentity {
|
||||||
|
t.Setenv("ENABLE_OBJECT_LEVEL_WORKLOAD_IDENTITY", "false")
|
||||||
|
} else if tt.serviceAccount != nil {
|
||||||
|
t.Setenv("ENABLE_OBJECT_LEVEL_WORKLOAD_IDENTITY", "true")
|
||||||
|
}
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Test bucket object.
|
// Test bucket object.
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -272,6 +272,7 @@ func main() {
|
||||||
Metrics: metrics,
|
Metrics: metrics,
|
||||||
Storage: storage,
|
Storage: storage,
|
||||||
ControllerName: controllerName,
|
ControllerName: controllerName,
|
||||||
|
TokenCache: tokenCache,
|
||||||
}).SetupWithManagerAndOptions(mgr, controller.BucketReconcilerOptions{
|
}).SetupWithManagerAndOptions(mgr, controller.BucketReconcilerOptions{
|
||||||
RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
|
RateLimiter: helper.GetRateLimiter(rateLimiterOptions),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ import (
|
||||||
htransport "google.golang.org/api/transport/http"
|
htransport "google.golang.org/api/transport/http"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
|
||||||
|
"github.com/fluxcd/pkg/auth"
|
||||||
|
gcpauth "github.com/fluxcd/pkg/auth/gcp"
|
||||||
|
|
||||||
|
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -69,13 +74,21 @@ func WithProxyURL(proxyURL *url.URL) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAuth sets the auth options for workload identity authentication.
|
||||||
|
func WithAuth(authOpts ...auth.Option) Option {
|
||||||
|
return func(o *options) {
|
||||||
|
o.authOpts = authOpts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type options struct {
|
type options struct {
|
||||||
secret *corev1.Secret
|
secret *corev1.Secret
|
||||||
proxyURL *url.URL
|
proxyURL *url.URL
|
||||||
|
authOpts []auth.Option
|
||||||
|
|
||||||
// newCustomHTTPClient should create a new HTTP client for interacting with the GCS API.
|
// newCustomHTTPClient should create a new HTTP client for interacting with the GCS API.
|
||||||
// This is a test-only option required for mocking the real logic, which requires either
|
// This is a test-only option required for mocking the real logic, which requires either
|
||||||
// a valid Google Service Account Key or ADC. Both are not available in tests.
|
// a valid Google Service Account Key or Controller-Level Workload Identity. Both are not available in tests.
|
||||||
// The real logic is implemented in the newHTTPClient function, which is used when
|
// The real logic is implemented in the newHTTPClient function, which is used when
|
||||||
// constructing the default options object.
|
// constructing the default options object.
|
||||||
newCustomHTTPClient func(context.Context, *options) (*http.Client, error)
|
newCustomHTTPClient func(context.Context, *options) (*http.Client, error)
|
||||||
|
|
@ -89,7 +102,7 @@ func newOptions() *options {
|
||||||
|
|
||||||
// NewClient creates a new GCP storage client. The Client will automatically look for the Google Application
|
// NewClient creates a new GCP storage client. The Client will automatically look for the Google Application
|
||||||
// Credential environment variable or look for the Google Application Credential file.
|
// Credential environment variable or look for the Google Application Credential file.
|
||||||
func NewClient(ctx context.Context, opts ...Option) (*GCSClient, error) {
|
func NewClient(ctx context.Context, bucket *sourcev1.Bucket, opts ...Option) (*GCSClient, error) {
|
||||||
o := newOptions()
|
o := newOptions()
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(o)
|
opt(o)
|
||||||
|
|
@ -100,7 +113,10 @@ func NewClient(ctx context.Context, opts ...Option) (*GCSClient, error) {
|
||||||
switch {
|
switch {
|
||||||
case o.secret != nil && o.proxyURL == nil:
|
case o.secret != nil && o.proxyURL == nil:
|
||||||
clientOpts = append(clientOpts, option.WithCredentialsJSON(o.secret.Data["serviceaccount"]))
|
clientOpts = append(clientOpts, option.WithCredentialsJSON(o.secret.Data["serviceaccount"]))
|
||||||
case o.proxyURL != nil:
|
case o.secret == nil && o.proxyURL == nil:
|
||||||
|
tokenSource := gcpauth.NewTokenSource(ctx, o.authOpts...)
|
||||||
|
clientOpts = append(clientOpts, option.WithTokenSource(tokenSource))
|
||||||
|
default: // o.proxyURL != nil:
|
||||||
httpClient, err := o.newCustomHTTPClient(ctx, o)
|
httpClient, err := o.newCustomHTTPClient(ctx, o)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -135,6 +151,9 @@ func newHTTPClient(ctx context.Context, o *options) (*http.Client, error) {
|
||||||
return nil, fmt.Errorf("failed to create Google credentials from secret: %w", err)
|
return nil, fmt.Errorf("failed to create Google credentials from secret: %w", err)
|
||||||
}
|
}
|
||||||
opts = append(opts, option.WithCredentials(creds))
|
opts = append(opts, option.WithCredentials(creds))
|
||||||
|
} else { // Workload Identity.
|
||||||
|
tokenSource := gcpauth.NewTokenSource(ctx, o.authOpts...)
|
||||||
|
opts = append(opts, option.WithTokenSource(tokenSource))
|
||||||
}
|
}
|
||||||
|
|
||||||
transport, err := htransport.NewTransport(ctx, baseTransport, opts...)
|
transport, err := htransport.NewTransport(ctx, baseTransport, opts...)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import (
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
sourcev1 "github.com/fluxcd/source-controller/api/v1"
|
||||||
testproxy "github.com/fluxcd/source-controller/tests/proxy"
|
testproxy "github.com/fluxcd/source-controller/tests/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -82,6 +83,22 @@ var (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// createTestBucket creates a test bucket for testing purposes
|
||||||
|
func createTestBucket() *sourcev1.Bucket {
|
||||||
|
return &sourcev1.Bucket{
|
||||||
|
ObjectMeta: v1.ObjectMeta{
|
||||||
|
Name: "test-bucket",
|
||||||
|
Namespace: "default",
|
||||||
|
},
|
||||||
|
Spec: sourcev1.BucketSpec{
|
||||||
|
BucketName: bucketName,
|
||||||
|
Endpoint: "storage.googleapis.com",
|
||||||
|
Provider: sourcev1.BucketProviderGoogle,
|
||||||
|
Interval: v1.Duration{Duration: time.Minute * 5},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
func TestMain(m *testing.M) {
|
||||||
hc, host, close = newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
hc, host, close = newTestServer(func(w http.ResponseWriter, r *http.Request) {
|
||||||
io.Copy(io.Discard, r.Body)
|
io.Copy(io.Discard, r.Body)
|
||||||
|
|
@ -147,7 +164,8 @@ func TestMain(m *testing.M) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewClientWithSecretErr(t *testing.T) {
|
func TestNewClientWithSecretErr(t *testing.T) {
|
||||||
gcpClient, err := NewClient(context.Background(), WithSecret(secret.DeepCopy()))
|
bucket := createTestBucket()
|
||||||
|
gcpClient, err := NewClient(context.Background(), bucket, WithSecret(secret.DeepCopy()))
|
||||||
t.Log(err)
|
t.Log(err)
|
||||||
assert.Error(t, err, "dialing: invalid character 'e' looking for beginning of value")
|
assert.Error(t, err, "dialing: invalid character 'e' looking for beginning of value")
|
||||||
assert.Assert(t, gcpClient == nil)
|
assert.Assert(t, gcpClient == nil)
|
||||||
|
|
@ -158,31 +176,29 @@ func TestNewClientWithProxyErr(t *testing.T) {
|
||||||
assert.Assert(t, !envADCIsSet)
|
assert.Assert(t, !envADCIsSet)
|
||||||
assert.Assert(t, !metadata.OnGCE())
|
assert.Assert(t, !metadata.OnGCE())
|
||||||
|
|
||||||
tests := []struct {
|
t.Run("with secret", func(t *testing.T) {
|
||||||
name string
|
g := NewWithT(t)
|
||||||
opts []Option
|
bucket := createTestBucket()
|
||||||
err string
|
gcpClient, err := NewClient(context.Background(), bucket,
|
||||||
}{
|
WithProxyURL(&url.URL{}),
|
||||||
{
|
WithSecret(secret.DeepCopy()))
|
||||||
name: "invalid secret",
|
g.Expect(err).To(HaveOccurred())
|
||||||
opts: []Option{WithSecret(secret.DeepCopy())},
|
g.Expect(gcpClient).To(BeNil())
|
||||||
err: "failed to create Google credentials from secret: invalid character 'e' looking for beginning of value",
|
g.Expect(err.Error()).To(Equal("failed to create Google credentials from secret: invalid character 'e' looking for beginning of value"))
|
||||||
},
|
})
|
||||||
{
|
|
||||||
name: "attempts default credentials",
|
t.Run("without secret", func(t *testing.T) {
|
||||||
err: "failed to create Google HTTP transport: google: could not find default credentials. See https://cloud.google.com/docs/authentication/external/set-up-adc for more information",
|
g := NewWithT(t)
|
||||||
},
|
bucket := createTestBucket()
|
||||||
}
|
gcpClient, err := NewClient(context.Background(), bucket,
|
||||||
|
WithProxyURL(&url.URL{}))
|
||||||
for _, tt := range tests {
|
g.Expect(err).NotTo(HaveOccurred())
|
||||||
tt := tt
|
g.Expect(gcpClient).NotTo(BeNil())
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
bucketAttrs, err := gcpClient.Client.Bucket("some-bucket").Attrs(context.Background())
|
||||||
opts := append([]Option{WithProxyURL(&url.URL{})}, tt.opts...)
|
g.Expect(err).To(HaveOccurred())
|
||||||
gcpClient, err := NewClient(context.Background(), opts...)
|
g.Expect(bucketAttrs).To(BeNil())
|
||||||
assert.Error(t, err, tt.err)
|
g.Expect(err.Error()).To(ContainSubstring("failed to create provider access token"))
|
||||||
assert.Assert(t, gcpClient == nil)
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProxy(t *testing.T) {
|
func TestProxy(t *testing.T) {
|
||||||
|
|
@ -224,7 +240,8 @@ func TestProxy(t *testing.T) {
|
||||||
return &http.Client{Transport: transport}, nil
|
return &http.Client{Transport: transport}, nil
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
gcpClient, err := NewClient(context.Background(), opts...)
|
bucket := createTestBucket()
|
||||||
|
gcpClient, err := NewClient(context.Background(), bucket, opts...)
|
||||||
assert.NilError(t, err)
|
assert.NilError(t, err)
|
||||||
assert.Assert(t, gcpClient != nil)
|
assert.Assert(t, gcpClient != nil)
|
||||||
gcpClient.Client.SetRetry(gcpstorage.WithMaxAttempts(1))
|
gcpClient.Client.SetRetry(gcpstorage.WithMaxAttempts(1))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue