diff --git a/.github/workflows/provider-tests.yml b/.github/workflows/provider-tests.yml
index 5d5e13802..a1377d108 100644
--- a/.github/workflows/provider-tests.yml
+++ b/.github/workflows/provider-tests.yml
@@ -21,6 +21,7 @@ jobs:
KOPIA_AZURE_TEST_CONTAINER: ${{ secrets.KOPIA_AZURE_TEST_CONTAINER }}
KOPIA_AZURE_TEST_STORAGE_ACCOUNT: ${{ secrets.KOPIA_AZURE_TEST_STORAGE_ACCOUNT }}
KOPIA_AZURE_TEST_STORAGE_KEY: ${{ secrets.KOPIA_AZURE_TEST_STORAGE_KEY }}
+ KOPIA_AZURE_TEST_SAS_TOKEN: ${{ secrets.KOPIA_AZURE_TEST_SAS_TOKEN }}
# used in B2 tests
KOPIA_B2_TEST_BUCKET: ${{ secrets.KOPIA_B2_TEST_BUCKET }}
diff --git a/cli/storage_azure.go b/cli/storage_azure.go
index 04eca12e9..65578c7e0 100644
--- a/cli/storage_azure.go
+++ b/cli/storage_azure.go
@@ -15,8 +15,10 @@ type storageAzureFlags struct {
func (c *storageAzureFlags) setup(_ storageProviderServices, cmd *kingpin.CmdClause) {
cmd.Flag("container", "Name of the Azure blob container").Required().StringVar(&c.azOptions.Container)
- cmd.Flag("storage-account", "Azure storage account name(overrides AZURE_STORAGE_ACCOUNT environment variable)").Required().Envar("AZURE_STORAGE_ACCOUNT").StringVar(&c.azOptions.StorageAccount)
- cmd.Flag("storage-key", "Azure storage account key(overrides AZURE_STORAGE_KEY environment variable)").Required().Envar("AZURE_STORAGE_KEY").StringVar(&c.azOptions.StorageKey)
+ cmd.Flag("storage-account", "Azure storage account name (overrides AZURE_STORAGE_ACCOUNT environment variable)").Required().Envar("AZURE_STORAGE_ACCOUNT").StringVar(&c.azOptions.StorageAccount)
+ cmd.Flag("storage-key", "Azure storage account key (overrides AZURE_STORAGE_KEY environment variable)").Envar("AZURE_STORAGE_KEY").StringVar(&c.azOptions.StorageKey)
+ cmd.Flag("storage-domain", "Azure storage domain").Envar("AZURE_STORAGE_DOMAIN").StringVar(&c.azOptions.StorageDomain)
+ cmd.Flag("sas-token", "Azure SAS Token").Envar("AZURE_STORAGE_SAS_TOKEN").StringVar(&c.azOptions.SASToken)
cmd.Flag("prefix", "Prefix to use for objects in the bucket").StringVar(&c.azOptions.Prefix)
cmd.Flag("max-download-speed", "Limit the download speed.").PlaceHolder("BYTES_PER_SEC").IntVar(&c.azOptions.MaxDownloadSpeedBytesPerSecond)
cmd.Flag("max-upload-speed", "Limit the upload speed.").PlaceHolder("BYTES_PER_SEC").IntVar(&c.azOptions.MaxUploadSpeedBytesPerSecond)
diff --git a/go.mod b/go.mod
index a48da00d1..b242295af 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.16
require (
cloud.google.com/go/storage v1.15.0
contrib.go.opencensus.io/exporter/prometheus v0.3.0
+ github.com/Azure/azure-pipeline-go v0.2.3 // indirect
github.com/Azure/azure-storage-blob-go v0.13.0
github.com/GehirnInc/crypt v0.0.0-20200316065508-bb7000b8a962 // indirect
github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46 // indirect
diff --git a/htmlui/src/SetupAzure.js b/htmlui/src/SetupAzure.js
index d684b1722..64ad58342 100644
--- a/htmlui/src/SetupAzure.js
+++ b/htmlui/src/SetupAzure.js
@@ -13,18 +13,22 @@ export class SetupAzure extends Component {
}
validate() {
- return validateRequiredFields(this, ["container", "storageAccount", "storageKey"])
+ return validateRequiredFields(this, ["container", "storageAccount"])
}
render() {
return <>
{RequiredField(this, "Container", "container", { autoFocus: true, placeholder: "enter container name" })}
- {OptionalField(this, "Object Name Prefix", "prefix", { placeholder: "enter object name prefix or leave empty", type: "password" })}
+ {OptionalField(this, "Object Name Prefix", "prefix", { placeholder: "enter object name prefix or leave empty" })}
- {RequiredField(this, "Access Key ID", "storageAccount", { placeholder: "enter access key ID" })}
- {RequiredField(this, "Secret Access Key", "storageKey", { placeholder: "enter secret access key", type: "password" })}
+ {RequiredField(this, "Storage Account", "storageAccount", { placeholder: "enter access key ID" })}
+ {OptionalField(this, "Access Key", "storageKey", { placeholder: "enter secret access key", type: "password" })}
+
+
+ {OptionalField(this, "Azure Storage Domain", "storageDomain", { placeholder: "enter storage domain or leave empty for default 'blob.core.windows.net'" })}
+ {OptionalField(this, "SAS Token", "sasToken", { placeholder: "enter secret SAS Token", type: "password" })}
>;
}
diff --git a/htmlui/src/tests/SetupAzure.test.js b/htmlui/src/tests/SetupAzure.test.js
index 6bd834723..0741208f8 100644
--- a/htmlui/src/tests/SetupAzure.test.js
+++ b/htmlui/src/tests/SetupAzure.test.js
@@ -11,9 +11,11 @@ it('can set fields', async () => {
// required
changeControlValue(getByTestId("control-container"), "some-container");
changeControlValue(getByTestId("control-storageAccount"), "some-storageAccount");
- changeControlValue(getByTestId("control-storageKey"), "some-storageKey");
expect(ref.current.validate()).toBe(true);
// optional
+ changeControlValue(getByTestId("control-storageKey"), "some-storageKey");
+ changeControlValue(getByTestId("control-sasToken"), "some-sas-token");
+ changeControlValue(getByTestId("control-storageDomain"), "some-storage-domain");
changeControlValue(getByTestId("control-prefix"), "some-prefix");
expect(ref.current.validate()).toBe(true);
@@ -22,5 +24,7 @@ it('can set fields', async () => {
"container": "some-container",
"prefix": "some-prefix",
"storageKey": "some-storageKey",
+ "sasToken": "some-sas-token",
+ "storageDomain": "some-storage-domain",
});
});
diff --git a/repo/blob/azure/azure_options.go b/repo/blob/azure/azure_options.go
index 921b72e18..0cfd8ea8a 100644
--- a/repo/blob/azure/azure_options.go
+++ b/repo/blob/azure/azure_options.go
@@ -12,6 +12,11 @@ type Options struct {
StorageAccount string `json:"storageAccount"`
StorageKey string `json:"storageKey" kopia:"sensitive"`
+ // Alternatively provide SAS Token
+ SASToken string `json:"sasToken" kopia:"sensitive"`
+
+ StorageDomain string `json:"storageDomain,omitempty"`
+
MaxUploadSpeedBytesPerSecond int `json:"maxUploadSpeedBytesPerSecond,omitempty"`
MaxDownloadSpeedBytesPerSecond int `json:"maxDownloadSpeedBytesPerSecond,omitempty"`
}
diff --git a/repo/blob/azure/azure_storage.go b/repo/blob/azure/azure_storage.go
index 493dd8c85..13773e9f4 100644
--- a/repo/blob/azure/azure_storage.go
+++ b/repo/blob/azure/azure_storage.go
@@ -9,6 +9,7 @@
"net/http"
"time"
+ "github.com/Azure/azure-pipeline-go/pipeline"
"github.com/Azure/azure-storage-blob-go/azblob"
"github.com/efarrer/iothrottler"
"github.com/pkg/errors"
@@ -218,17 +219,36 @@ func New(ctx context.Context, opt *Options) (blob.Storage, error) {
return nil, errors.New("container name must be specified")
}
- // create a credentials object.
- credential, err := azureblob.NewCredential(azureblob.AccountName(opt.StorageAccount), azureblob.AccountKey(opt.StorageKey))
- if err != nil {
- return nil, errors.Wrap(err, "unable to initialize credentials")
+ var (
+ abo azureblob.Options
+ pl pipeline.Pipeline
+ pipelineOpts azblob.PipelineOptions
+ account = azureblob.AccountName(opt.StorageAccount)
+ )
+
+ if opt.SASToken != "" {
+ abo.SASToken = azureblob.SASToken(opt.SASToken)
+ // don't set abo.Credential
+ pl = azureblob.NewPipeline(azblob.NewAnonymousCredential(), pipelineOpts)
+ } else {
+ if opt.StorageKey == "" {
+ return nil, errors.Errorf("either storage key or SAS token must be provided")
+ }
+
+ // create a credentials object.
+ cred, err := azureblob.NewCredential(account, azureblob.AccountKey(opt.StorageKey))
+ if err != nil {
+ return nil, errors.Wrap(err, "unable to initialize credentials")
+ }
+
+ abo.Credential = cred
+ pl = azureblob.NewPipeline(cred, pipelineOpts)
}
- // create a Pipeline with credentials.
- pipeline := azureblob.NewPipeline(credential, azblob.PipelineOptions{})
+ abo.StorageDomain = azureblob.StorageDomain(opt.StorageDomain)
// create a *blob.Bucket.
- bucket, err := azureblob.OpenBucket(ctx, pipeline, azureblob.AccountName(opt.StorageAccount), opt.Container, &azureblob.Options{Credential: credential})
+ bucket, err := azureblob.OpenBucket(ctx, pl, account, opt.Container, &abo)
if err != nil {
return nil, errors.Wrap(err, "unable to open bucket")
}
diff --git a/repo/blob/azure/azure_storage_test.go b/repo/blob/azure/azure_storage_test.go
index 19ceef737..16e019ac8 100644
--- a/repo/blob/azure/azure_storage_test.go
+++ b/repo/blob/azure/azure_storage_test.go
@@ -20,9 +20,10 @@
)
const (
- testContainerEnv = "KOPIA_AZURE_TEST_CONTAINER"
- testStorageAccountEnv = "KOPIA_AZURE_TEST_STORAGE_ACCOUNT"
- testStorageKeyEnv = "KOPIA_AZURE_TEST_STORAGE_KEY"
+ testContainerEnv = "KOPIA_AZURE_TEST_CONTAINER"
+ testStorageAccountEnv = "KOPIA_AZURE_TEST_STORAGE_ACCOUNT"
+ testStorageKeyEnv = "KOPIA_AZURE_TEST_STORAGE_KEY"
+ testStorageSASTokenEnv = "KOPIA_AZURE_TEST_SAS_TOKEN"
)
func getEnvOrSkip(t *testing.T, name string) string {
@@ -117,6 +118,50 @@ func TestAzureStorage(t *testing.T) {
}
}
+func TestAzureStorageSASToken(t *testing.T) {
+ t.Parallel()
+ testutil.ProviderTest(t)
+
+ container := getEnvOrSkip(t, testContainerEnv)
+ storageAccount := getEnvOrSkip(t, testStorageAccountEnv)
+ sasToken := getEnvOrSkip(t, testStorageSASTokenEnv)
+
+ data := make([]byte, 8)
+ rand.Read(data)
+
+ ctx := testlogging.Context(t)
+
+ st, err := azure.New(ctx, &azure.Options{
+ Container: container,
+ StorageAccount: storageAccount,
+ SASToken: sasToken,
+ Prefix: fmt.Sprintf("sastest-%v-%x-", clock.Now().Unix(), data),
+ })
+ if err != nil {
+ t.Fatalf("unable to connect to Azure: %v", err)
+ }
+
+ if err := st.ListBlobs(ctx, "", func(bm blob.Metadata) error {
+ return st.DeleteBlob(ctx, bm.BlobID)
+ }); err != nil {
+ t.Fatalf("unable to clear Azure blob container: %v", err)
+ }
+
+ blobtesting.VerifyStorage(ctx, t, st)
+ blobtesting.AssertConnectionInfoRoundTrips(ctx, t, st)
+
+ // delete everything again
+ if err := st.ListBlobs(ctx, "", func(bm blob.Metadata) error {
+ return st.DeleteBlob(ctx, bm.BlobID)
+ }); err != nil {
+ t.Fatalf("unable to clear Azure blob container: %v", err)
+ }
+
+ if err := st.Close(ctx); err != nil {
+ t.Fatalf("err: %v", err)
+ }
+}
+
func TestAzureStorageInvalidBlob(t *testing.T) {
testutil.ProviderTest(t)