Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 75 additions & 19 deletions azblob/azblob_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment on lines 11 to +14

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

azblobsvc is referenced but (in the shown import block) the azblob SDK package is imported as azblob. This will not compile unless there is an additional aliased import elsewhere in the file. Align the import name and usages (either import sdk/storage/azblob as azblobsvc, or update azblobsvc.* references to azblob.*) so the file compiles consistently.

Copilot uses AI. Check for mistakes.
"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"
Expand All @@ -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 {
Expand All @@ -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}}

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

azblobsvc is referenced but (in the shown import block) the azblob SDK package is imported as azblob. This will not compile unless there is an additional aliased import elsewhere in the file. Align the import name and usages (either import sdk/storage/azblob as azblobsvc, or update azblobsvc.* references to azblob.*) so the file compiles consistently.

Copilot uses AI. Check for mistakes.
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)

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

azblobsvc is referenced but (in the shown import block) the azblob SDK package is imported as azblob. This will not compile unless there is an additional aliased import elsewhere in the file. Align the import name and usages (either import sdk/storage/azblob as azblobsvc, or update azblobsvc.* references to azblob.*) so the file compiles consistently.

Copilot uses AI. Check for mistakes.
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)

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

azblobsvc is referenced but (in the shown import block) the azblob SDK package is imported as azblob. This will not compile unless there is an additional aliased import elsewhere in the file. Align the import name and usages (either import sdk/storage/azblob as azblobsvc, or update azblobsvc.* references to azblob.*) so the file compiles consistently.

Copilot uses AI. Check for mistakes.
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

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The account-name extraction is gated on u.User != nil, but the logic actually parses the account name from u.Host. This unnecessarily prevents extraction for valid URLs that omit userinfo (e.g., URLs that embed container in the path rather than container@host). Remove the u.User != nil condition (keep the u.Host != "" check) and add a test case covering a URL with a host like mystorageaccount.blob.core.windows.net but no user part.

Copilot uses AI. Check for mistakes.

// 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")
Expand Down
118 changes: 118 additions & 0 deletions azblob/azblob_service_test.go
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)
}
})
}
}
22 changes: 14 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is focused on Azure identity auth, but go.mod also changes multiple unrelated dependencies (including apparent downgrades like logrus, testify, x/net, x/sys, x/text). That increases review surface and risk of regressions unrelated to the feature. Consider reverting the unrelated version changes (keeping only the new Azure identity deps), or splitting dependency churn into a separate PR with its own rationale/release notes.

Copilot uses AI. Check for mistakes.
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Copilot AI Apr 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is focused on Azure identity auth, but go.mod also changes multiple unrelated dependencies (including apparent downgrades like logrus, testify, x/net, x/sys, x/text). That increases review surface and risk of regressions unrelated to the feature. Consider reverting the unrelated version changes (keeping only the new Azure identity deps), or splitting dependency churn into a separate PR with its own rationale/release notes.

Copilot uses AI. Check for mistakes.
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
Loading