Add proxy support for Azure buckets

Signed-off-by: Matheus Pimenta <matheuscscp@gmail.com>
This commit is contained in:
Matheus Pimenta 2024-08-05 13:01:00 +01:00
parent f494cf8574
commit b6bd2abe2d
8 changed files with 191 additions and 28 deletions

View File

@ -113,7 +113,7 @@ type BucketSpec struct {
// ProxySecretRef specifies the Secret containing the proxy configuration // ProxySecretRef specifies the Secret containing the proxy configuration
// to use while communicating with the Bucket server. // to use while communicating with the Bucket server.
// //
// Only supported for the `generic` and `gcp` providers. // Only supported for the `generic`, `gcp` and `azure` providers.
// +optional // +optional
ProxySecretRef *meta.LocalObjectReference `json:"proxySecretRef,omitempty"` ProxySecretRef *meta.LocalObjectReference `json:"proxySecretRef,omitempty"`

View File

@ -397,7 +397,7 @@ spec:
to use while communicating with the Bucket server. to use while communicating with the Bucket server.
Only supported for the `generic` and `gcp` providers. Only supported for the `generic`, `gcp` and `azure` providers.
properties: properties:
name: name:
description: Name of the referent. description: Name of the referent.

View File

@ -219,7 +219,7 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
<em>(Optional)</em> <em>(Optional)</em>
<p>ProxySecretRef specifies the Secret containing the proxy configuration <p>ProxySecretRef specifies the Secret containing the proxy configuration
to use while communicating with the Bucket server.</p> to use while communicating with the Bucket server.</p>
<p>Only supported for the <code>generic</code> and <code>gcp</code> providers.</p> <p>Only supported for the <code>generic</code>, <code>gcp</code> and <code>azure</code> providers.</p>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -1648,7 +1648,7 @@ github.com/fluxcd/pkg/apis/meta.LocalObjectReference
<em>(Optional)</em> <em>(Optional)</em>
<p>ProxySecretRef specifies the Secret containing the proxy configuration <p>ProxySecretRef specifies the Secret containing the proxy configuration
to use while communicating with the Bucket server.</p> to use while communicating with the Bucket server.</p>
<p>Only supported for the <code>generic</code> and <code>gcp</code> providers.</p> <p>Only supported for the <code>generic</code>, <code>gcp</code> and <code>azure</code> providers.</p>
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -854,7 +854,7 @@ The Secret can contain three keys:
- `password`, to specify the password to use if the proxy server is protected by - `password`, to specify the password to use if the proxy server is protected by
basic authentication. This is an optional key. basic authentication. This is an optional key.
This API is only supported for the `generic` and `gcp` [providers](#provider). This API is only supported for the `generic`, `gcp` and `azure` [providers](#provider).
Example: Example:

View File

@ -465,7 +465,14 @@ func (r *BucketReconciler) reconcileSource(ctx context.Context, sp *patch.Serial
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e)
return sreconcile.ResultEmpty, e return sreconcile.ResultEmpty, e
} }
if provider, err = azure.NewClient(obj, secret); err != nil { var opts []azure.Option
if secret != nil {
opts = append(opts, azure.WithSecret(secret))
}
if proxyURL != nil {
opts = append(opts, azure.WithProxyURL(proxyURL))
}
if provider, err = azure.NewClient(obj, opts...); err != nil {
e := serror.NewGeneric(err, "ClientError") e := serror.NewGeneric(err, "ClientError")
conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e) conditions.MarkTrue(obj, sourcev1.FetchFailedCondition, e.Reason, "%s", e)
return sreconcile.ResultEmpty, e return sreconcile.ResultEmpty, e

View File

@ -22,6 +22,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
@ -64,6 +65,48 @@ type BlobClient struct {
*azblob.Client *azblob.Client
} }
// Option configures the BlobClient.
type Option func(*options)
// WithSecret sets the Secret to use for the BlobClient.
func WithSecret(secret *corev1.Secret) Option {
return func(o *options) {
o.secret = secret
}
}
// WithProxyURL sets the proxy URL to use for the BlobClient.
func WithProxyURL(proxyURL *url.URL) Option {
return func(o *options) {
o.proxyURL = proxyURL
}
}
type options struct {
secret *corev1.Secret
proxyURL *url.URL
withoutCredentials bool
withoutRetries bool
}
// withoutCredentials forces the BlobClient to not use any credentials.
// This is a test-only option useful for testing the client with HTTP
// endpoints (without TLS) alongside all the other options unrelated to
// credentials.
func withoutCredentials() Option {
return func(o *options) {
o.withoutCredentials = true
}
}
// withoutRetries sets the BlobClient to not retry requests.
// This is a test-only option useful for testing connection errors.
func withoutRetries() Option {
return func(o *options) {
o.withoutRetries = true
}
}
// NewClient creates a new Azure Blob storage client. // NewClient creates a new Azure Blob storage client.
// The credential config on the client is set based on the data from the // The credential config on the client is set based on the data from the
// Bucket and Secret. It detects credentials in the Secret in the following // Bucket and Secret. It detects credentials in the Secret in the following
@ -87,56 +130,80 @@ type BlobClient struct {
// //
// If no credentials are found, and the azidentity.ChainedTokenCredential can // If no credentials are found, and the azidentity.ChainedTokenCredential can
// not be established. A simple client without credentials is returned. // not be established. A simple client without credentials is returned.
func NewClient(obj *sourcev1.Bucket, secret *corev1.Secret) (c *BlobClient, err error) { func NewClient(obj *sourcev1.Bucket, opts ...Option) (c *BlobClient, err error) {
c = &BlobClient{} c = &BlobClient{}
var o options
for _, opt := range opts {
opt(&o)
}
clientOpts := &azblob.ClientOptions{}
if o.proxyURL != nil {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = http.ProxyURL(o.proxyURL)
clientOpts.ClientOptions.Transport = &http.Client{Transport: transport}
}
if o.withoutRetries {
clientOpts.ClientOptions.Retry.ShouldRetry = func(resp *http.Response, err error) bool {
return false
}
}
if o.withoutCredentials {
c.Client, err = azblob.NewClientWithNoCredential(obj.Spec.Endpoint, clientOpts)
return
}
var token azcore.TokenCredential var token azcore.TokenCredential
if secret != nil && len(secret.Data) > 0 { if o.secret != nil && len(o.secret.Data) > 0 {
// Attempt AAD Token Credential options first. // Attempt AAD Token Credential options first.
if token, err = tokenCredentialFromSecret(secret); err != nil { if token, err = tokenCredentialFromSecret(o.secret); err != nil {
err = fmt.Errorf("failed to create token credential from '%s' Secret: %w", secret.Name, err) err = fmt.Errorf("failed to create token credential from '%s' Secret: %w", o.secret.Name, err)
return return
} }
if token != nil { if token != nil {
c.Client, err = azblob.NewClient(obj.Spec.Endpoint, token, nil) c.Client, err = azblob.NewClient(obj.Spec.Endpoint, token, clientOpts)
return return
} }
// Fallback to Shared Key Credential. // Fallback to Shared Key Credential.
var cred *azblob.SharedKeyCredential var cred *azblob.SharedKeyCredential
if cred, err = sharedCredentialFromSecret(obj.Spec.Endpoint, secret); err != nil { if cred, err = sharedCredentialFromSecret(obj.Spec.Endpoint, o.secret); err != nil {
return return
} }
if cred != nil { if cred != nil {
c.Client, err = azblob.NewClientWithSharedKeyCredential(obj.Spec.Endpoint, cred, &azblob.ClientOptions{}) c.Client, err = azblob.NewClientWithSharedKeyCredential(obj.Spec.Endpoint, cred, clientOpts)
return return
} }
var fullPath string var fullPath string
if fullPath, err = sasTokenFromSecret(obj.Spec.Endpoint, secret); err != nil { if fullPath, err = sasTokenFromSecret(obj.Spec.Endpoint, o.secret); err != nil {
return return
} }
c.Client, err = azblob.NewClientWithNoCredential(fullPath, &azblob.ClientOptions{}) c.Client, err = azblob.NewClientWithNoCredential(fullPath, clientOpts)
return return
} }
// Compose token chain based on environment. // Compose token chain based on environment.
// This functions as a replacement for azidentity.NewDefaultAzureCredential // This functions as a replacement for azidentity.NewDefaultAzureCredential
// to not shell out. // to not shell out.
token, err = chainCredentialWithSecret(secret) token, err = chainCredentialWithSecret(o.secret)
if err != nil { if err != nil {
err = fmt.Errorf("failed to create environment credential chain: %w", err) err = fmt.Errorf("failed to create environment credential chain: %w", err)
return nil, err return nil, err
} }
if token != nil { if token != nil {
c.Client, err = azblob.NewClient(obj.Spec.Endpoint, token, nil) c.Client, err = azblob.NewClient(obj.Spec.Endpoint, token, clientOpts)
return return
} }
// Fallback to simple client. // Fallback to simple client.
c.Client, err = azblob.NewClientWithNoCredential(obj.Spec.Endpoint, nil) c.Client, err = azblob.NewClientWithNoCredential(obj.Spec.Endpoint, clientOpts)
return return
} }

View File

@ -94,7 +94,7 @@ func TestMain(m *testing.M) {
func TestBlobClient_BucketExists(t *testing.T) { func TestBlobClient_BucketExists(t *testing.T) {
g := NewWithT(t) g := NewWithT(t)
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())
@ -120,7 +120,7 @@ func TestBlobClient_BucketExists(t *testing.T) {
func TestBlobClient_BucketNotExists(t *testing.T) { func TestBlobClient_BucketNotExists(t *testing.T) {
g := NewWithT(t) g := NewWithT(t)
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())
@ -140,7 +140,7 @@ func TestBlobClient_FGetObject(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())
@ -180,7 +180,7 @@ func TestBlobClientSASKey_FGetObject(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
// create a client with the shared key // create a client with the shared key
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())
@ -221,7 +221,7 @@ func TestBlobClientSASKey_FGetObject(t *testing.T) {
}, },
} }
sasKeyClient, err := NewClient(testBucket.DeepCopy(), testSASKeySecret.DeepCopy()) sasKeyClient, err := NewClient(testBucket.DeepCopy(), WithSecret(testSASKeySecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
// Test if bucket and blob exists using sasKey. // Test if bucket and blob exists using sasKey.
@ -246,7 +246,7 @@ func TestBlobClientContainerSASKey_BucketExists(t *testing.T) {
g := NewWithT(t) g := NewWithT(t)
// create a client with the shared key // create a client with the shared key
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())
@ -286,7 +286,7 @@ func TestBlobClientContainerSASKey_BucketExists(t *testing.T) {
}, },
} }
sasKeyClient, err := NewClient(testBucket.DeepCopy(), testSASKeySecret.DeepCopy()) sasKeyClient, err := NewClient(testBucket.DeepCopy(), WithSecret(testSASKeySecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
ctx, timeout = context.WithTimeout(context.Background(), testTimeout) ctx, timeout = context.WithTimeout(context.Background(), testTimeout)
@ -308,7 +308,7 @@ func TestBlobClientContainerSASKey_BucketExists(t *testing.T) {
func TestBlobClient_FGetObject_NotFoundErr(t *testing.T) { func TestBlobClient_FGetObject_NotFoundErr(t *testing.T) {
g := NewWithT(t) g := NewWithT(t)
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())
@ -335,7 +335,7 @@ func TestBlobClient_FGetObject_NotFoundErr(t *testing.T) {
func TestBlobClient_VisitObjects(t *testing.T) { func TestBlobClient_VisitObjects(t *testing.T) {
g := NewWithT(t) g := NewWithT(t)
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())
@ -375,7 +375,7 @@ func TestBlobClient_VisitObjects(t *testing.T) {
func TestBlobClient_VisitObjects_CallbackErr(t *testing.T) { func TestBlobClient_VisitObjects_CallbackErr(t *testing.T) {
g := NewWithT(t) g := NewWithT(t)
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy()) client, err := NewClient(testBucket.DeepCopy(), WithSecret(testSecret.DeepCopy()))
g.Expect(err).ToNot(HaveOccurred()) g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil()) g.Expect(client).ToNot(BeNil())

View File

@ -18,6 +18,7 @@ package azure
import ( import (
"bytes" "bytes"
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
@ -25,6 +26,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/big" "math/big"
"net/http"
"net/url" "net/url"
"testing" "testing"
@ -34,8 +36,95 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
. "github.com/onsi/gomega" . "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
testlistener "github.com/fluxcd/source-controller/tests/listener"
testproxy "github.com/fluxcd/source-controller/tests/proxy"
) )
func TestNewClientAndBucketExistsWithProxy(t *testing.T) {
g := NewWithT(t)
proxyAddr, proxyPort := testproxy.New(t)
// start mock bucket server
bucketListener, bucketAddr, _ := testlistener.New(t)
bucketEndpoint := fmt.Sprintf("http://%s", bucketAddr)
bucketHandler := http.NewServeMux()
bucketHandler.HandleFunc("GET /podinfo", func(w http.ResponseWriter, r *http.Request) {
// verify query params comp=list&maxresults=1&restype=container
q := r.URL.Query()
g.Expect(q.Get("comp")).To(Equal("list"))
g.Expect(q.Get("maxresults")).To(Equal("1"))
g.Expect(q.Get("restype")).To(Equal("container"))
// the azure library does not expose the struct for this response
// and copying its definition yields a strange "unsupported type"
// error when marshaling to xml, so we just hardcode a valid response
// here
resp := fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<EnumerationResults ContainerName="%s/podinfo">
<MaxResults>1</MaxResults>
<Blobs />
<NextMarker />
</EnumerationResults>`, bucketEndpoint)
_, err := w.Write([]byte(resp))
g.Expect(err).ToNot(HaveOccurred())
})
bucketServer := &http.Server{
Addr: bucketAddr,
Handler: bucketHandler,
}
go bucketServer.Serve(bucketListener)
defer bucketServer.Shutdown(context.Background())
tests := []struct {
name string
endpoint string
proxyURL *url.URL
err string
}{
{
name: "with correct proxy",
endpoint: bucketEndpoint,
proxyURL: &url.URL{Scheme: "http", Host: proxyAddr},
},
{
name: "with incorrect proxy",
endpoint: bucketEndpoint,
proxyURL: &url.URL{Scheme: "http", Host: fmt.Sprintf("localhost:%d", proxyPort+1)},
err: "connection refused",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := NewWithT(t)
bucket := &sourcev1.Bucket{
Spec: sourcev1.BucketSpec{
Endpoint: tt.endpoint,
},
}
client, err := NewClient(bucket,
WithProxyURL(tt.proxyURL),
withoutCredentials(),
withoutRetries())
g.Expect(err).ToNot(HaveOccurred())
g.Expect(client).ToNot(BeNil())
ok, err := client.BucketExists(context.Background(), "podinfo")
if tt.err != "" {
g.Expect(err.Error()).To(ContainSubstring(tt.err))
g.Expect(ok).To(BeFalse())
} else {
g.Expect(err).ToNot(HaveOccurred())
g.Expect(ok).To(BeTrue())
}
})
}
}
func TestValidateSecret(t *testing.T) { func TestValidateSecret(t *testing.T) {
tests := []struct { tests := []struct {
name string name string