Skip to content

Commit 67f6cba

Browse files
authored
Merge pull request #1565 from matheuscscp/bucket-gcp-proxy
Add proxy support for GCS buckets
2 parents c41c2d6 + 31ed900 commit 67f6cba

File tree

9 files changed

+256
-33
lines changed

9 files changed

+256
-33
lines changed

api/v1beta2/bucket_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ type BucketSpec struct {
113113
// ProxySecretRef specifies the Secret containing the proxy configuration
114114
// to use while communicating with the Bucket server.
115115
//
116-
// Only supported for the generic provider.
116+
// Only supported for the `generic` and `gcp` providers.
117117
// +optional
118118
ProxySecretRef *meta.LocalObjectReference `json:"proxySecretRef,omitempty"`
119119

config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ spec:
397397
to use while communicating with the Bucket server.
398398
399399
400-
Only supported for the generic provider.
400+
Only supported for the `generic` and `gcp` providers.
401401
properties:
402402
name:
403403
description: Name of the referent.

docs/api/v1beta2/source.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
219219
<em>(Optional)</em>
220220
<p>ProxySecretRef specifies the Secret containing the proxy configuration
221221
to use while communicating with the Bucket server.</p>
222-
<p>Only supported for the generic provider.</p>
222+
<p>Only supported for the <code>generic</code> and <code>gcp</code> providers.</p>
223223
</td>
224224
</tr>
225225
<tr>
@@ -1648,7 +1648,7 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
16481648
<em>(Optional)</em>
16491649
<p>ProxySecretRef specifies the Secret containing the proxy configuration
16501650
to use while communicating with the Bucket server.</p>
1651-
<p>Only supported for the generic provider.</p>
1651+
<p>Only supported for the <code>generic</code> and <code>gcp</code> providers.</p>
16521652
</td>
16531653
</tr>
16541654
<tr>

docs/spec/v1beta2/buckets.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -854,7 +854,7 @@ The Secret can contain three keys:
854854
- `password`, to specify the password to use if the proxy server is protected by
855855
basic authentication. This is an optional key.
856856

857-
This API is only supported for the `generic` [provider](#provider).
857+
This API is only supported for the `generic` and `gcp` [providers](#provider).
858858

859859
Example:
860860

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ replace github.com/fluxcd/source-controller/api => ./api
99
replace github.com/opencontainers/go-digest => github.com/opencontainers/go-digest v1.0.1-0.20220411205349-bde1400a84be
1010

1111
require (
12+
cloud.google.com/go/compute/metadata v0.3.0
1213
cloud.google.com/go/storage v1.39.1
1314
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24
1415
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1
@@ -60,6 +61,7 @@ require (
6061
github.com/sirupsen/logrus v1.9.3
6162
github.com/spf13/pflag v1.0.5
6263
golang.org/x/crypto v0.22.0
64+
golang.org/x/oauth2 v0.19.0
6365
golang.org/x/sync v0.7.0
6466
google.golang.org/api v0.177.0
6567
gotest.tools v2.2.0+incompatible
@@ -77,7 +79,6 @@ require (
7779
cloud.google.com/go v0.112.2 // indirect
7880
cloud.google.com/go/auth v0.3.0 // indirect
7981
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
80-
cloud.google.com/go/compute/metadata v0.3.0 // indirect
8182
cloud.google.com/go/iam v1.1.6 // indirect
8283
dario.cat/mergo v1.0.0 // indirect
8384
filippo.io/edwards25519 v1.1.0 // indirect
@@ -360,7 +361,6 @@ require (
360361
golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
361362
golang.org/x/mod v0.17.0 // indirect
362363
golang.org/x/net v0.24.0 // indirect
363-
golang.org/x/oauth2 v0.19.0 // indirect
364364
golang.org/x/sys v0.19.0 // indirect
365365
golang.org/x/term v0.19.0 // indirect
366366
golang.org/x/text v0.14.0 // indirect

internal/controller/bucket_controller.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,12 @@ func (r *BucketReconciler) reconcileSource(ctx context.Context, sp *patch.Serial
431431
// Return error as the world as observed may change
432432
return sreconcile.ResultEmpty, e
433433
}
434+
proxyURL, err := r.getProxyURL(ctx, obj)
435+
if err != nil {
436+
e := serror.NewGeneric(err, sourcev1.AuthenticationFailedReason)
437+
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e)
438+
return sreconcile.ResultEmpty, e
439+
}
434440

435441
// Construct provider client
436442
var provider BucketProvider
@@ -441,7 +447,14 @@ func (r *BucketReconciler) reconcileSource(ctx context.Context, sp *patch.Serial
441447
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e)
442448
return sreconcile.ResultEmpty, e
443449
}
444-
if provider, err = gcp.NewClient(ctx, secret); err != nil {
450+
var opts []gcp.Option
451+
if secret != nil {
452+
opts = append(opts, gcp.WithSecret(secret))
453+
}
454+
if proxyURL != nil {
455+
opts = append(opts, gcp.WithProxyURL(proxyURL))
456+
}
457+
if provider, err = gcp.NewClient(ctx, opts...); err != nil {
445458
e := serror.NewGeneric(err, "ClientError")
446459
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e)
447460
return sreconcile.ResultEmpty, e
@@ -482,12 +495,6 @@ func (r *BucketReconciler) reconcileSource(ctx context.Context, sp *patch.Serial
482495
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e)
483496
return sreconcile.ResultEmpty, e
484497
}
485-
proxyURL, err := r.getProxyURL(ctx, obj)
486-
if err != nil {
487-
e := serror.NewGeneric(err, sourcev1.AuthenticationFailedReason)
488-
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, e.Error())
489-
return sreconcile.ResultEmpty, e
490-
}
491498
var opts []minio.Option
492499
if secret != nil {
493500
opts = append(opts, minio.WithSecret(secret))

internal/controller/bucket_controller_test.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ func TestBucketReconciler_reconcileSource_generic(t *testing.T) {
445445
assertConditions []metav1.Condition
446446
}{
447447
{
448-
name: "Reconciles GCS source",
448+
name: "Reconciles generic source",
449449
bucketName: "dummy",
450450
bucketObjects: []*s3mock.Object{
451451
{
@@ -972,6 +972,49 @@ func TestBucketReconciler_reconcileSource_gcs(t *testing.T) {
972972
*conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"),
973973
},
974974
},
975+
{
976+
name: "Observes non-existing proxySecretRef",
977+
bucketName: "dummy",
978+
beforeFunc: func(obj *bucketv1.Bucket) {
979+
obj.Spec.ProxySecretRef = &meta.LocalObjectReference{
980+
Name: "dummy",
981+
}
982+
conditions.MarkReconciling(obj, meta.ProgressingReason, "foo")
983+
conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar")
984+
},
985+
want: sreconcile.ResultEmpty,
986+
wantErr: true,
987+
assertIndex: index.NewDigester(),
988+
assertConditions: []metav1.Condition{
989+
*conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "failed to get secret '/dummy': secrets \"dummy\" not found"),
990+
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"),
991+
*conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"),
992+
},
993+
},
994+
{
995+
name: "Observes invalid proxySecretRef",
996+
bucketName: "dummy",
997+
secret: &corev1.Secret{
998+
ObjectMeta: metav1.ObjectMeta{
999+
Name: "dummy",
1000+
},
1001+
},
1002+
beforeFunc: func(obj *bucketv1.Bucket) {
1003+
obj.Spec.ProxySecretRef = &meta.LocalObjectReference{
1004+
Name: "dummy",
1005+
}
1006+
conditions.MarkReconciling(obj, meta.ProgressingReason, "foo")
1007+
conditions.MarkUnknown(obj, meta.ReadyCondition, "foo", "bar")
1008+
},
1009+
want: sreconcile.ResultEmpty,
1010+
wantErr: true,
1011+
assertIndex: index.NewDigester(),
1012+
assertConditions: []metav1.Condition{
1013+
*conditions.TrueCondition(sourcev1.FetchFailedCondition, sourcev1.AuthenticationFailedReason, "invalid proxy secret '/dummy': key 'address' is missing"),
1014+
*conditions.TrueCondition(meta.ReconcilingCondition, meta.ProgressingReason, "foo"),
1015+
*conditions.UnknownCondition(meta.ReadyCondition, "foo", "bar"),
1016+
},
1017+
},
9751018
{
9761019
name: "Observes non-existing bucket name",
9771020
bucketName: "dummy",
@@ -1217,7 +1260,11 @@ func TestBucketReconciler_reconcileSource_gcs(t *testing.T) {
12171260
sp := patch.NewSerialPatcher(obj, r.Client)
12181261

12191262
got, err := r.reconcileSource(context.TODO(), sp, obj, index, tmpDir)
1220-
g.Expect(err != nil).To(Equal(tt.wantErr))
1263+
if tt.wantErr {
1264+
g.Expect(err).To(HaveOccurred())
1265+
} else {
1266+
g.Expect(err).ToNot(HaveOccurred())
1267+
}
12211268
g.Expect(got).To(Equal(tt.want))
12221269

12231270
g.Expect(index.Index()).To(Equal(tt.assertIndex.Index()))

pkg/gcp/gcp.go

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ import (
2121
"errors"
2222
"fmt"
2323
"io"
24+
"net/http"
25+
"net/url"
2426
"os"
2527
"path/filepath"
2628

2729
gcpstorage "cloud.google.com/go/storage"
2830
"github.com/go-logr/logr"
31+
"golang.org/x/oauth2/google"
2932
"google.golang.org/api/iterator"
3033
"google.golang.org/api/option"
34+
htransport "google.golang.org/api/transport/http"
3135
corev1 "k8s.io/api/core/v1"
3236
ctrl "sigs.k8s.io/controller-runtime"
3337
)
@@ -48,24 +52,96 @@ type GCSClient struct {
4852
*gcpstorage.Client
4953
}
5054

51-
// NewClient creates a new GCP storage client. The Client will automatically look for the Google Application
55+
// Option is a functional option for configuring the GCS client.
56+
type Option func(*options)
57+
58+
// WithSecret sets the secret to use for authenticating with GCP.
59+
func WithSecret(secret *corev1.Secret) Option {
60+
return func(o *options) {
61+
o.secret = secret
62+
}
63+
}
64+
65+
// WithProxyURL sets the proxy URL to use for the GCS client.
66+
func WithProxyURL(proxyURL *url.URL) Option {
67+
return func(o *options) {
68+
o.proxyURL = proxyURL
69+
}
70+
}
71+
72+
type options struct {
73+
secret *corev1.Secret
74+
proxyURL *url.URL
75+
76+
// newCustomHTTPClient should create a new HTTP client for interacting with the GCS API.
77+
// This is a test-only option required for mocking the real logic, which requires either
78+
// a valid Google Service Account Key or ADC. Both are not available in tests.
79+
// The real logic is implemented in the newHTTPClient function, which is used when
80+
// constructing the default options object.
81+
newCustomHTTPClient func(context.Context, *options) (*http.Client, error)
82+
}
83+
84+
func newOptions() *options {
85+
return &options{
86+
newCustomHTTPClient: newHTTPClient,
87+
}
88+
}
89+
90+
// NewClient creates a new GCP storage client. The Client will automatically look for the Google Application
5291
// Credential environment variable or look for the Google Application Credential file.
53-
func NewClient(ctx context.Context, secret *corev1.Secret) (*GCSClient, error) {
54-
c := &GCSClient{}
55-
if secret != nil {
56-
client, err := gcpstorage.NewClient(ctx, option.WithCredentialsJSON(secret.Data["serviceaccount"]))
92+
func NewClient(ctx context.Context, opts ...Option) (*GCSClient, error) {
93+
o := newOptions()
94+
for _, opt := range opts {
95+
opt(o)
96+
}
97+
98+
var clientOpts []option.ClientOption
99+
100+
switch {
101+
case o.secret != nil && o.proxyURL == nil:
102+
clientOpts = append(clientOpts, option.WithCredentialsJSON(o.secret.Data["serviceaccount"]))
103+
case o.proxyURL != nil:
104+
httpClient, err := o.newCustomHTTPClient(ctx, o)
57105
if err != nil {
58106
return nil, err
59107
}
60-
c.Client = client
61-
} else {
62-
client, err := gcpstorage.NewClient(ctx)
108+
clientOpts = append(clientOpts, option.WithHTTPClient(httpClient))
109+
}
110+
111+
client, err := gcpstorage.NewClient(ctx, clientOpts...)
112+
if err != nil {
113+
return nil, err
114+
}
115+
116+
return &GCSClient{client}, nil
117+
}
118+
119+
// newHTTPClient creates a new HTTP client for interacting with Google Cloud APIs.
120+
func newHTTPClient(ctx context.Context, o *options) (*http.Client, error) {
121+
baseTransport := http.DefaultTransport.(*http.Transport).Clone()
122+
if o.proxyURL != nil {
123+
baseTransport.Proxy = http.ProxyURL(o.proxyURL)
124+
}
125+
126+
var opts []option.ClientOption
127+
128+
if o.secret != nil {
129+
// Here we can't use option.WithCredentialsJSON() because htransport.NewTransport()
130+
// won't know what scopes to use and yield a 400 Bad Request error when retrieving
131+
// the OAuth token. Instead we use google.CredentialsFromJSON(), which allows us to
132+
// specify the GCS read-only scope.
133+
creds, err := google.CredentialsFromJSON(ctx, o.secret.Data["serviceaccount"], gcpstorage.ScopeReadOnly)
63134
if err != nil {
64-
return nil, err
135+
return nil, fmt.Errorf("failed to create Google credentials from secret: %w", err)
65136
}
66-
c.Client = client
137+
opts = append(opts, option.WithCredentials(creds))
138+
}
139+
140+
transport, err := htransport.NewTransport(ctx, baseTransport, opts...)
141+
if err != nil {
142+
return nil, fmt.Errorf("failed to create Google HTTP transport: %w", err)
67143
}
68-
return c, nil
144+
return &http.Client{Transport: transport}, nil
69145
}
70146

71147
// ValidateSecret validates the credential secret. The provided Secret may

0 commit comments

Comments
 (0)