duplicati/Duplicati/Library/Backend/AzureBlob/AzureBlobWrapper.cs
2025-11-13 11:58:13 +01:00

221 lines
10 KiB
C#

// Copyright (C) 2025, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using System.Runtime.CompilerServices;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Interface;
using Uri = System.Uri;
using Duplicati.Library.Utility.Options;
using Duplicati.Library.Utility;
using Azure.Core.Pipeline;
namespace Duplicati.Library.Backend.AzureBlob
{
/// <summary>
/// Azure blob storage facade.
/// </summary>
public class AzureBlobWrapper
{
private readonly BlobContainerClient _container;
private readonly TimeoutOptionsHelper.Timeouts _timeouts;
private readonly IReadOnlySet<AccessTier> _archiveClasses;
private readonly AccessTier? _accessTier;
/// <summary>
/// Gets an array of DNS names associated with the blob container.
/// </summary>
/// <returns>An array of DNS hostnames for the primary and secondary URIs of the container.</returns>
public string[] DnsNames
{
get
{
var lst = new List<string>();
if (_container != null && _container.Uri != null) lst.Add(_container.Uri.Host);
return lst.ToArray();
}
}
/// <summary>
/// Initializes a new instance of the AzureBlobWrapper class.
/// </summary>
/// <param name="accountName">The Azure storage account name.</param>
/// <param name="accessKey">The access key for the storage account.</param>
/// <param name="sasToken">The Shared Access Signature (SAS) token for authentication.</param>
/// <param name="containerName">The name of the blob container.</param>
/// <param name="accessTier">The access tier assigned to blobs on upload.</param>
/// <param name="archiveClasses">The storage classes that are considered archive classes.</param>
/// <param name="timeouts">The timeout options.</param>
/// <param name="maxRetries">The maximum number of retries for Azure operations.</param>
public AzureBlobWrapper(string accountName, string? accessKey, string? sasToken, string containerName, AccessTier? accessTier, IReadOnlySet<AccessTier> archiveClasses, TimeoutOptionsHelper.Timeouts timeouts, int maxRetries)
{
BlobServiceClient blobServiceClient;
var maxTicks = timeouts.ReadWriteTimeout.Ticks - TimeSpan.FromSeconds(1).Ticks;
// If not infinite, then share the timeout across retries
var delay = maxTicks <= 0
? TimeSpan.FromSeconds(1)
: TimeSpan.FromTicks(maxTicks / Math.Max(1, maxRetries));
var maxDelay = maxTicks <= 0
? TimeSpan.FromSeconds(10)
: TimeSpan.FromTicks(maxTicks);
var blobServiceOptions = new BlobClientOptions()
{
Retry =
{
MaxRetries = maxRetries,
Mode = Azure.Core.RetryMode.Exponential,
Delay = delay,
MaxDelay = maxDelay
},
Transport = new HttpClientTransport(new HttpClient { Timeout = Timeout.InfiniteTimeSpan })
};
if (sasToken != null)
{
var sasUri = new Uri($"https://{accountName}.blob.core.windows.net/?{sasToken}");
blobServiceClient = new BlobServiceClient(sasUri, blobServiceOptions);
}
else
{
var connectionString = $"DefaultEndpointsProtocol=https;AccountName={accountName};AccountKey={accessKey};EndpointSuffix=core.windows.net";
blobServiceClient = new BlobServiceClient(connectionString, blobServiceOptions);
}
_accessTier = accessTier;
_archiveClasses = archiveClasses;
_container = blobServiceClient.GetBlobContainerClient(containerName);
_timeouts = timeouts;
}
/// <summary>
/// Creates a new blob container asynchronously and sets its access permissions to private.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AddContainerAsync(CancellationToken cancellationToken)
{
await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct =>
// Even though PublicAccessType.None is by default, we set it explicitly to highlight it.
await _container.CreateAsync(PublicAccessType.None, cancellationToken: ct).ConfigureAwait(false)
).ConfigureAwait(false);
}
/// <summary>
/// Downloads a blob to a stream asynchronously.
/// </summary>
/// <param name="keyName">The name of the blob to download.</param>
/// <param name="target">The stream to download the blob to.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous download operation.</returns>
public async Task GetFileStreamAsync(string keyName, Stream target, CancellationToken cancellationToken)
{
var blobClient = _container.GetBlobClient(keyName);
using var timeoutStream = target.ObserveWriteTimeout(_timeouts.ReadWriteTimeout, false);
await blobClient.DownloadToAsync(target, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Uploads a stream to a blob asynchronously.
/// </summary>
/// <param name="keyName">The name to give the uploaded blob.</param>
/// <param name="source">The stream containing the data to upload.</param>
/// <param name="cancelToken">A token to monitor for cancellation requests.</param>
/// <returns>A task that represents the asynchronous upload operation.</returns>
public async Task AddFileStream(string keyName, Stream source, CancellationToken cancelToken)
{
var blobClient = _container.GetBlobClient(keyName);
using var timeoutStream = source.ObserveReadTimeout(_timeouts.ReadWriteTimeout, false);
var options = new BlobUploadOptions()
{
Conditions = null, // Overwrite any existing blob
AccessTier = _accessTier
};
await blobClient.UploadAsync(timeoutStream, options, cancelToken).ConfigureAwait(false);
}
/// <summary>
/// Deletes a blob if it exists asynchronously.
/// </summary>
/// <param name="keyName">The name of the blob to delete.</param>
/// <param name="cancelToken">A token to monitor for cancellation requests.</param>
public async Task DeleteObjectAsync(string keyName, CancellationToken cancelToken)
{
var blobClient = _container.GetBlobClient(keyName);
await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancelToken, async ct =>
await blobClient.DeleteIfExistsAsync(cancellationToken: ct).ConfigureAwait(false)
).ConfigureAwait(false);
}
/// <summary>
/// List container files.
/// </summary>
/// <param name="cancelToken">A token to monitor for cancellation requests.</param>
/// <returns></returns>
/// <exception cref="FolderMissingException">Thrown when the container is not found</exception>
public virtual async IAsyncEnumerable<IFileEntry> ListContainerEntriesAsync([EnumeratorCancellation] CancellationToken cancelToken)
{
await using var blobEnumerator = _container.GetBlobsAsync().GetAsyncEnumerator(cancelToken);
while (true)
{
bool hasNext;
try
{
hasNext = await Utility.Utility.WithTimeout(_timeouts.ListTimeout, cancelToken, async ct
=> await blobEnumerator.MoveNextAsync().ConfigureAwait(false)
).ConfigureAwait(false);
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
throw new FolderMissingException(ex);
}
if (!hasNext) break;
cancelToken.ThrowIfCancellationRequested();
if (blobEnumerator.Current is { } blob)
{
var blobName = Uri.UnescapeDataString(blob.Name.Replace("+", "%2B"));
var lastModified = blob.Properties.LastModified?.UtcDateTime ?? DateTime.UtcNow;
var isArchive = blob.Properties.AccessTier.HasValue && _archiveClasses.Contains(blob.Properties.AccessTier.Value);
yield return new FileEntry(
blobName,
blob.Properties.ContentLength ?? 0,
lastModified,
lastModified
)
{
IsArchived = isArchive,
IsFolder = false,
};
}
}
}
}
}