package azure import ( "bytes" "context" "crypto/md5" "encoding/base64" "fmt" "hash" "io" "net/http" "path" "strings" "github.com/restic/restic/internal/backend" "github.com/restic/restic/internal/backend/layout" "github.com/restic/restic/internal/backend/location" "github.com/restic/restic/internal/backend/util" "github.com/restic/restic/internal/debug" "github.com/restic/restic/internal/errors" "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/bloberror" "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blockblob" azContainer "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/container" ) // Backend stores data on an azure endpoint. type Backend struct { cfg Config container *azContainer.Client connections uint prefix string listMaxItems int layout.Layout } const saveLargeSize = 256 * 1024 * 1024 const defaultListMaxItems = 5000 // make sure that *Backend implements backend.Backend var _ backend.Backend = &Backend{} func NewFactory() location.Factory { return location.NewHTTPBackendFactory("azure", ParseConfig, location.NoPassword, Create, Open) } func open(cfg Config, rt http.RoundTripper) (*Backend, error) { debug.Log("open, config %#v", cfg) var client *azContainer.Client var err error var endpointSuffix string if cfg.EndpointSuffix != "" { endpointSuffix = cfg.EndpointSuffix } else { endpointSuffix = "core.windows.net" } url := fmt.Sprintf("https://%s.blob.%s/%s", cfg.AccountName, endpointSuffix, cfg.Container) opts := &azContainer.ClientOptions{ ClientOptions: azcore.ClientOptions{ Transport: &http.Client{Transport: rt}, }, } if cfg.AccountKey.String() != "" { // We have an account key value, find the BlobServiceClient // from with a BasicClient debug.Log(" - using account key") cred, err := azblob.NewSharedKeyCredential(cfg.AccountName, cfg.AccountKey.Unwrap()) if err != nil { return nil, errors.Wrap(err, "NewSharedKeyCredential") } client, err = azContainer.NewClientWithSharedKeyCredential(url, cred, opts) if err != nil { return nil, errors.Wrap(err, "NewClientWithSharedKeyCredential") } } else if cfg.AccountSAS.String() != "" { // Get the client using the SAS Token as authentication, this // is longer winded than above because the SDK wants a URL for the Account // if your using a SAS token, and not just the account name // we (as per the SDK ) assume the default Azure portal. // https://github.com/Azure/azure-storage-blob-go/issues/130 debug.Log(" - using sas token") sas := cfg.AccountSAS.Unwrap() // strip query sign prefix if sas[0] == '?' { sas = sas[1:] } urlWithSAS := fmt.Sprintf("%s?%s", url, sas) client, err = azContainer.NewClientWithNoCredential(urlWithSAS, opts) if err != nil { return nil, errors.Wrap(err, "NewAccountSASClientFromEndpointToken") } } else { var cred azcore.TokenCredential if cfg.ForceCliCredential { debug.Log(" - using AzureCLICredential") cred, err = azidentity.NewAzureCLICredential(nil) if err != nil { return nil, errors.Wrap(err, "NewAzureCLICredential") } } else { debug.Log(" - using DefaultAzureCredential") cred, err = azidentity.NewDefaultAzureCredential(nil) if err != nil { return nil, errors.Wrap(err, "NewDefaultAzureCredential") } } client, err = azContainer.NewClient(url, cred, opts) if err != nil { return nil, errors.Wrap(err, "NewClient") } } be := &Backend{ container: client, cfg: cfg, connections: cfg.Connections, Layout: &layout.DefaultLayout{ Path: cfg.Prefix, Join: path.Join, }, listMaxItems: defaultListMaxItems, } return be, nil } // Open opens the Azure backend at specified container. func Open(_ context.Context, cfg Config, rt http.RoundTripper) (*Backend, error) { return open(cfg, rt) } // Create opens the Azure backend at specified container and creates the container if // it does not exist yet. func Create(ctx context.Context, cfg Config, rt http.RoundTripper) (*Backend, error) { be, err := open(cfg, rt) if err != nil { return nil, errors.Wrap(err, "open") } _, err = be.container.GetProperties(ctx, &azContainer.GetPropertiesOptions{}) if err != nil && bloberror.HasCode(err, bloberror.ContainerNotFound) { _, err = be.container.Create(ctx, &azContainer.CreateOptions{}) if err != nil { return nil, errors.Wrap(err, "container.Create") } } else if err != nil && bloberror.HasCode(err, bloberror.AuthorizationFailure) { // We ignore this Auth. Failure, as the failure is related to the type // of SAS/SAT, not an actual real failure. If the token is invalid, we // fail later on anyway. // For details see Issue #4004. debug.Log("Ignoring AuthorizationFailure when calling GetProperties") } else if err != nil { return be, errors.Wrap(err, "container.GetProperties") } return be, nil } // SetListMaxItems sets the number of list items to load per request. func (be *Backend) SetListMaxItems(i int) { be.listMaxItems = i } // IsNotExist returns true if the error is caused by a not existing file. func (be *Backend) IsNotExist(err error) bool { return bloberror.HasCode(err, bloberror.BlobNotFound) } func (be *Backend) IsPermanentError(err error) bool { if be.IsNotExist(err) { return true } var aerr *azcore.ResponseError if errors.As(err, &aerr) { if aerr.StatusCode == http.StatusRequestedRangeNotSatisfiable || aerr.StatusCode == http.StatusUnauthorized || aerr.StatusCode == http.StatusForbidden { return true } } return false } // Join combines path components with slashes. func (be *Backend) Join(p ...string) string { return path.Join(p...) } func (be *Backend) Connections() uint { return be.connections } // Hasher may return a hash function for calculating a content hash for the backend func (be *Backend) Hasher() hash.Hash { return md5.New() } // HasAtomicReplace returns whether Save() can atomically replace files func (be *Backend) HasAtomicReplace() bool { return true } // Path returns the path in the bucket that is used for this backend. func (be *Backend) Path() string { return be.prefix } // Save stores data in the backend at the handle. func (be *Backend) Save(ctx context.Context, h backend.Handle, rd backend.RewindReader) error { objName := be.Filename(h) debug.Log("InsertObject(%v, %v)", be.cfg.AccountName, objName) var err error if rd.Length() < saveLargeSize { // if it's smaller than 256miB, then just create the file directly from the reader err = be.saveSmall(ctx, objName, rd) } else { // otherwise use the more complicated method err = be.saveLarge(ctx, objName, rd) } return err } func (be *Backend) saveSmall(ctx context.Context, objName string, rd backend.RewindReader) error { blockBlobClient := be.container.NewBlockBlobClient(objName) // upload it as a new "block", use the base64 hash for the ID id := base64.StdEncoding.EncodeToString(rd.Hash()) buf := make([]byte, rd.Length()) _, err := io.ReadFull(rd, buf) if err != nil { return errors.Wrap(err, "ReadFull") } reader := bytes.NewReader(buf) _, err = blockBlobClient.StageBlock(ctx, id, streaming.NopCloser(reader), &blockblob.StageBlockOptions{ TransactionalValidation: blob.TransferValidationTypeMD5(rd.Hash()), }) if err != nil { return errors.Wrap(err, "StageBlock") } blocks := []string{id} _, err = blockBlobClient.CommitBlockList(ctx, blocks, &blockblob.CommitBlockListOptions{}) return errors.Wrap(err, "CommitBlockList") } func (be *Backend) saveLarge(ctx context.Context, objName string, rd backend.RewindReader) error { blockBlobClient := be.container.NewBlockBlobClient(objName) buf := make([]byte, 100*1024*1024) blocks := []string{} uploadedBytes := 0 for { n, err := io.ReadFull(rd, buf) if err == io.ErrUnexpectedEOF { err = nil } if err == io.EOF { // end of file reached, no bytes have been read at all break } if err != nil { return errors.Wrap(err, "ReadFull") } buf = buf[:n] uploadedBytes += n // upload it as a new "block", use the base64 hash for the ID h := md5.Sum(buf) id := base64.StdEncoding.EncodeToString(h[:]) reader := bytes.NewReader(buf) debug.Log("StageBlock %v with %d bytes", id, len(buf)) _, err = blockBlobClient.StageBlock(ctx, id, streaming.NopCloser(reader), &blockblob.StageBlockOptions{ TransactionalValidation: blob.TransferValidationTypeMD5(h[:]), }) if err != nil { return errors.Wrap(err, "StageBlock") } blocks = append(blocks, id) } // sanity check if uploadedBytes != int(rd.Length()) { return errors.Errorf("wrote %d bytes instead of the expected %d bytes", uploadedBytes, rd.Length()) } _, err := blockBlobClient.CommitBlockList(ctx, blocks, &blockblob.CommitBlockListOptions{}) debug.Log("uploaded %d parts: %v", len(blocks), blocks) return errors.Wrap(err, "CommitBlockList") } // Load runs fn with a reader that yields the contents of the file at h at the // given offset. func (be *Backend) Load(ctx context.Context, h backend.Handle, length int, offset int64, fn func(rd io.Reader) error) error { return util.DefaultLoad(ctx, h, length, offset, be.openReader, fn) } func (be *Backend) openReader(ctx context.Context, h backend.Handle, length int, offset int64) (io.ReadCloser, error) { objName := be.Filename(h) blockBlobClient := be.container.NewBlobClient(objName) resp, err := blockBlobClient.DownloadStream(ctx, &blob.DownloadStreamOptions{ Range: azblob.HTTPRange{ Offset: offset, Count: int64(length), }, }) if err != nil { return nil, err } if length > 0 && (resp.ContentLength == nil || *resp.ContentLength != int64(length)) { _ = resp.Body.Close() return nil, &azcore.ResponseError{ErrorCode: "restic-file-too-short", StatusCode: http.StatusRequestedRangeNotSatisfiable} } return resp.Body, err } // Stat returns information about a blob. func (be *Backend) Stat(ctx context.Context, h backend.Handle) (backend.FileInfo, error) { objName := be.Filename(h) blobClient := be.container.NewBlobClient(objName) props, err := blobClient.GetProperties(ctx, nil) if err != nil { return backend.FileInfo{}, errors.Wrap(err, "blob.GetProperties") } fi := backend.FileInfo{ Size: *props.ContentLength, Name: h.Name, } return fi, nil } // Remove removes the blob with the given name and type. func (be *Backend) Remove(ctx context.Context, h backend.Handle) error { objName := be.Filename(h) blob := be.container.NewBlobClient(objName) _, err := blob.Delete(ctx, &azblob.DeleteBlobOptions{}) if be.IsNotExist(err) { return nil } return errors.Wrap(err, "client.RemoveObject") } // List runs fn for each file in the backend which has the type t. When an // error occurs (or fn returns an error), List stops and returns it. func (be *Backend) List(ctx context.Context, t backend.FileType, fn func(backend.FileInfo) error) error { prefix, _ := be.Basedir(t) // make sure prefix ends with a slash if !strings.HasSuffix(prefix, "/") { prefix += "/" } max := int32(be.listMaxItems) opts := &azContainer.ListBlobsFlatOptions{ MaxResults: &max, Prefix: &prefix, } lister := be.container.NewListBlobsFlatPager(opts) for lister.More() { resp, err := lister.NextPage(ctx) if err != nil { return err } debug.Log("got %v objects", len(resp.Segment.BlobItems)) for _, item := range resp.Segment.BlobItems { m := strings.TrimPrefix(*item.Name, prefix) if m == "" { continue } fi := backend.FileInfo{ Name: path.Base(m), Size: *item.Properties.ContentLength, } if ctx.Err() != nil { return ctx.Err() } err := fn(fi) if err != nil { return err } if ctx.Err() != nil { return ctx.Err() } } } return ctx.Err() } // Delete removes all restic keys in the bucket. It will not remove the bucket itself. func (be *Backend) Delete(ctx context.Context) error { return util.DefaultDelete(ctx, be) } // Close does nothing func (be *Backend) Close() error { return nil }