Repository navigation
feat(backup): support identity-based auth methods for azure #268
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,8 @@ import ( | |
|
|
||
| "github.com/Azure/azure-sdk-for-go/sdk/azcore" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/azidentity" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob" | ||
| "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" | ||
| "github.com/cockroachdb/errors" | ||
|
|
@@ -20,11 +22,7 @@ import ( | |
| ) | ||
|
|
||
| const ( | ||
| azureURL = "core.windows.net" | ||
| azureConnNameKey = "AccountName=%s;AccountKey=%s;" | ||
| blobEndpoint = "BlobEndpoint=%s;" | ||
| blobEndpointScheme = "DefaultEndpointsProtocol=%s;" | ||
| blobEndpointSuffix = "EndpointSuffix=%s;" | ||
| azureURL = "core.windows.net" | ||
| ) | ||
|
|
||
| type service struct { | ||
|
|
@@ -46,35 +44,93 @@ func newService(u *url.URL) (*service, error) { | |
| accountKey := os.Getenv("AZBLOB_ACCOUNT_KEY") | ||
| azureEndpoint := os.Getenv("AZBLOB_ENDPOINT") | ||
|
|
||
| connStr := fmt.Sprintf(azureConnNameKey, accountName, accountKey) | ||
| if azureEndpoint != "" { | ||
| blobEndpointURL := fmt.Sprintf("%s/%s", strings.TrimRight(azureEndpoint, "/"), accountName) | ||
| endPointURL, err := url.Parse(azureEndpoint) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| connStr = fmt.Sprintf(blobEndpointScheme+connStr+blobEndpoint, endPointURL.Scheme, blobEndpointURL) | ||
| if accountName == "" { | ||
| accountName = extractAccountNameFromURL(u) | ||
| } | ||
|
|
||
| if s.EndpointSuffix != azureURL { | ||
| connStr = connStr + fmt.Sprintf(blobEndpointSuffix, s.EndpointSuffix) | ||
| if accountName == "" { | ||
| return nil, fmt.Errorf("AZBLOB_ACCOUNT_NAME is required") | ||
| } | ||
|
|
||
| serviceURL := buildServiceURL(accountName, azureEndpoint, s.EndpointSuffix) | ||
|
|
||
| customCerts := getCustomCerts() | ||
| httpClient, err := http.GetClientWithCustomCerts(customCerts) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| opts := azblobsvc.ClientOptions{ClientOptions: azcore.ClientOptions{Transport: httpClient}} | ||
|
||
| serviceClient, err := azblobsvc.NewClientFromConnectionString(connStr, &opts) | ||
| if err != nil { | ||
| return nil, err | ||
|
|
||
| var serviceClient *azblobsvc.Client | ||
|
|
||
| if accountKey != "" { | ||
| log.Infof("Using SharedKeyCredential for Azure Blob Storage (account: %s)", accountName) | ||
| cred, err := azblob.NewSharedKeyCredential(accountName, accountKey) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create SharedKeyCredential: %w", err) | ||
| } | ||
| serviceClient, err = azblobsvc.NewClientWithSharedKeyCredential(serviceURL, cred, &opts) | ||
|
||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create service client with SharedKeyCredential: %w", err) | ||
| } | ||
| } else { | ||
| // Azure AD authentication via DefaultAzureCredential | ||
| // Automatically chains through: EnvironmentCredential, WorkloadIdentityCredential, | ||
| // ManagedIdentityCredential, AzureCLICredential | ||
| log.Infof("Using DefaultAzureCredential for Azure Blob Storage (account: %s)", accountName) | ||
| cred, err := azidentity.NewDefaultAzureCredential(nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create DefaultAzureCredential: %w", err) | ||
| } | ||
| serviceClient, err = azblobsvc.NewClient(serviceURL, cred, &opts) | ||
|
||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to create service client with DefaultAzureCredential: %w", err) | ||
| } | ||
| } | ||
|
|
||
| s.ContainerClient = serviceClient.NewContainerClient(s.Container) | ||
| return &s, nil | ||
| } | ||
|
|
||
| // extractAccountNameFromURL attempts to extract the storage account name from the backup target URL. | ||
| // For URLs like azblob://container@mystorageaccount.blob.core.windows.net/path, | ||
| // the account name is the first part of the host before ".blob.". | ||
| func extractAccountNameFromURL(u *url.URL) string { | ||
| if u.User != nil && u.Host != "" { | ||
| // Check if host contains ".blob." indicating full endpoint format | ||
| // e.g., "mystorageaccount.blob.core.windows.net" | ||
| if idx := strings.Index(u.Host, ".blob."); idx > 0 { | ||
| return u.Host[:idx] | ||
| } | ||
| } | ||
| return "" | ||
| } | ||
|
Comment on lines
+97
to
+106
|
||
|
|
||
| // isFullBlobEndpoint checks if the host is a full blob endpoint (contains ".blob.") | ||
| // vs just an endpoint suffix like "core.windows.net" or "core.chinacloudapi.cn" | ||
| func isFullBlobEndpoint(host string) bool { | ||
| return strings.Contains(host, ".blob.") | ||
| } | ||
|
|
||
| func buildServiceURL(accountName, customEndpoint, endpointSuffix string) string { | ||
| if customEndpoint != "" { | ||
| // Custom endpoint (e.g., Azurite or sovereign cloud) | ||
| return fmt.Sprintf("%s/%s", strings.TrimRight(customEndpoint, "/"), accountName) | ||
| } | ||
| if endpointSuffix != "" { | ||
| // Check if endpointSuffix is already a full blob endpoint | ||
| // e.g., "mystorageaccount.blob.core.windows.net" | ||
| if isFullBlobEndpoint(endpointSuffix) { | ||
| return fmt.Sprintf("https://%s", endpointSuffix) | ||
| } | ||
| // Legacy format: endpoint suffix like "core.chinacloudapi.cn" | ||
| if endpointSuffix != azureURL { | ||
| return fmt.Sprintf("https://%s.blob.%s", accountName, endpointSuffix) | ||
| } | ||
| } | ||
| // Default Azure public cloud | ||
| return fmt.Sprintf("https://%s.blob.%s", accountName, azureURL) | ||
| } | ||
|
|
||
| func getCustomCerts() []byte { | ||
| // Certificates in PEM format (base64) | ||
| certs := os.Getenv("AZBLOB_CERT") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| package azblob | ||
|
|
||
| import ( | ||
| "net/url" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestExtractAccountNameFromURL(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| urlStr string | ||
| expectedAcc string | ||
| }{ | ||
| { | ||
| name: "standard Azure URL with account in host", | ||
| urlStr: "azblob://mycontainer@mystorageaccount.blob.core.windows.net/backups", | ||
| expectedAcc: "mystorageaccount", | ||
| }, | ||
| { | ||
| name: "sovereign cloud URL", | ||
| urlStr: "azblob://mycontainer@mystorageaccount.blob.core.chinacloudapi.cn/backups", | ||
| expectedAcc: "mystorageaccount", | ||
| }, | ||
| { | ||
| name: "URL without user part (container only)", | ||
| urlStr: "azblob://mycontainer/backups", | ||
| expectedAcc: "", | ||
| }, | ||
| { | ||
| name: "empty host", | ||
| urlStr: "azblob://mycontainer@/backups", | ||
| expectedAcc: "", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| u, err := url.Parse(tt.urlStr) | ||
| if err != nil { | ||
| t.Fatalf("failed to parse URL: %v", err) | ||
| } | ||
|
|
||
| got := extractAccountNameFromURL(u) | ||
| if got != tt.expectedAcc { | ||
| t.Errorf("extractAccountNameFromURL() = %q, want %q", got, tt.expectedAcc) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBuildServiceURL(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| accountName string | ||
| customEndpoint string | ||
| endpointSuffix string | ||
| expected string | ||
| }{ | ||
| { | ||
| name: "default public cloud", | ||
| accountName: "mystorageaccount", | ||
| customEndpoint: "", | ||
| endpointSuffix: "", | ||
| expected: "https://mystorageaccount.blob.core.windows.net", | ||
| }, | ||
| { | ||
| name: "public cloud with core.windows.net suffix", | ||
| accountName: "mystorageaccount", | ||
| customEndpoint: "", | ||
| endpointSuffix: "core.windows.net", | ||
| expected: "https://mystorageaccount.blob.core.windows.net", | ||
| }, | ||
| { | ||
| name: "China sovereign cloud", | ||
| accountName: "mystorageaccount", | ||
| customEndpoint: "", | ||
| endpointSuffix: "core.chinacloudapi.cn", | ||
| expected: "https://mystorageaccount.blob.core.chinacloudapi.cn", | ||
| }, | ||
| { | ||
| name: "US Government cloud", | ||
| accountName: "mystorageaccount", | ||
| customEndpoint: "", | ||
| endpointSuffix: "core.usgovcloudapi.net", | ||
| expected: "https://mystorageaccount.blob.core.usgovcloudapi.net", | ||
| }, | ||
| { | ||
| name: "custom endpoint (Azurite)", | ||
| accountName: "devstoreaccount1", | ||
| customEndpoint: "http://127.0.0.1:10000", | ||
| endpointSuffix: "", | ||
| expected: "http://127.0.0.1:10000/devstoreaccount1", | ||
| }, | ||
| { | ||
| name: "custom endpoint with trailing slash", | ||
| accountName: "devstoreaccount1", | ||
| customEndpoint: "http://127.0.0.1:10000/", | ||
| endpointSuffix: "", | ||
| expected: "http://127.0.0.1:10000/devstoreaccount1", | ||
| }, | ||
| { | ||
| name: "custom endpoint takes precedence over suffix", | ||
| accountName: "mystorageaccount", | ||
| customEndpoint: "https://custom.endpoint.com", | ||
| endpointSuffix: "core.chinacloudapi.cn", | ||
| expected: "https://custom.endpoint.com/mystorageaccount", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := buildServiceURL(tt.accountName, tt.customEndpoint, tt.endpointSuffix) | ||
| if got != tt.expected { | ||
| t.Errorf("buildServiceURL() = %q, want %q", got, tt.expected) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ toolchain go1.24.1 | |
|
|
||
| require ( | ||
| github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 | ||
| github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 | ||
| github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.5.0 | ||
| github.com/aws/aws-sdk-go-v2 v1.39.6 | ||
| github.com/aws/aws-sdk-go-v2/config v1.31.20 | ||
|
|
@@ -16,22 +17,23 @@ require ( | |
| github.com/cockroachdb/errors v1.12.0 | ||
| github.com/gammazero/workerpool v1.1.3 | ||
| github.com/google/uuid v1.6.0 | ||
| github.com/longhorn/go-common-libs v0.0.0-20260120075236-9a9dafa0a7ea | ||
| github.com/pierrec/lz4/v4 v4.1.25 | ||
| github.com/sirupsen/logrus v1.9.4 | ||
| github.com/longhorn/go-common-libs v0.0.0-20260103034008-119bdcf1b2d6 | ||
| github.com/pierrec/lz4/v4 v4.1.23 | ||
| github.com/sirupsen/logrus v1.9.3 | ||
| github.com/slok/goresilience v0.2.0 | ||
| github.com/spf13/afero v1.11.0 | ||
| github.com/stretchr/testify v1.11.1 | ||
| github.com/stretchr/testify v1.10.0 | ||
| github.com/urfave/cli v1.22.17 | ||
| golang.org/x/net v0.49.0 | ||
| golang.org/x/sys v0.40.0 | ||
| golang.org/x/net v0.47.0 | ||
| golang.org/x/sys v0.38.0 | ||
|
Comment on lines
+20
to
+28
|
||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c | ||
| k8s.io/apimachinery v0.28.15 | ||
| k8s.io/mount-utils v0.28.15 | ||
| ) | ||
|
|
||
| require ( | ||
| github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect | ||
| github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect | ||
| github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect | ||
| github.com/aws/aws-sdk-go-v2/credentials v1.18.24 // indirect | ||
| github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect | ||
|
|
@@ -47,7 +49,7 @@ require ( | |
| github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect | ||
| github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect | ||
| github.com/beorn7/perks v1.0.1 // indirect | ||
| github.com/cespare/xxhash/v2 v2.1.2 // indirect | ||
| github.com/cespare/xxhash/v2 v2.3.0 // indirect | ||
| github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect | ||
| github.com/cockroachdb/redact v1.1.5 // indirect | ||
| github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | ||
|
|
@@ -56,11 +58,14 @@ require ( | |
| github.com/getsentry/sentry-go v0.27.0 // indirect | ||
| github.com/go-logr/logr v1.4.3 // indirect | ||
| github.com/gogo/protobuf v1.3.2 // indirect | ||
| github.com/golang-jwt/jwt/v5 v5.3.0 // indirect | ||
| github.com/golang/protobuf v1.5.4 // indirect | ||
| github.com/kr/pretty v0.3.1 // indirect | ||
| github.com/kr/text v0.2.0 // indirect | ||
| github.com/kylelemons/godebug v1.1.0 // indirect | ||
| github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect | ||
| github.com/moby/sys/mountinfo v0.7.2 // indirect | ||
| github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect | ||
| github.com/pkg/errors v0.9.1 // indirect | ||
| github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | ||
| github.com/prometheus/client_golang v1.11.1 // indirect | ||
|
|
@@ -69,7 +74,8 @@ require ( | |
| github.com/prometheus/procfs v0.6.0 // indirect | ||
| github.com/rogpeppe/go-internal v1.12.0 // indirect | ||
| github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||
| golang.org/x/text v0.33.0 // indirect | ||
| golang.org/x/crypto v0.44.0 // indirect | ||
| golang.org/x/text v0.31.0 // indirect | ||
|
Comment on lines
+77
to
+78
|
||
| google.golang.org/protobuf v1.36.11 // indirect | ||
| gopkg.in/inf.v0 v0.9.1 // indirect | ||
| gopkg.in/yaml.v3 v3.0.1 // indirect | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
azblobsvcis referenced but (in the shown import block) the azblob SDK package is imported asazblob. This will not compile unless there is an additional aliased import elsewhere in the file. Align the import name and usages (either importsdk/storage/azblobasazblobsvc, or updateazblobsvc.*references toazblob.*) so the file compiles consistently.