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)