This PR adds support for locking files if the backend supports it. To activate locking, set the option `--file-lock-duration=30D` and the backup will lock the files. If the database is rebuilt with the intention of continuing the backups, use the option `--repair-refresh-lock-info` which will update lock information in the database after recreating the database. The locking works by asking the backend to lock files after a backup has completed. The implementation keeps track of which files are currently assigned a lock and prevents attempting to delete the files that are currently locked. Note that the bucket should not have a default lock policy as Duplicati needs to finish the backup before the locking is applied. In this initial version, Azure Blob Storage, B2, S3 and iDrive are supported with locking. The CLI is updated to allow setting locks on a specific version. The backend tool is updated to allow setting locks on specific files.
278 lines
13 KiB
C#
278 lines
13 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;
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
public async Task<DateTime?> GetObjectLockUntilAsync(string keyName, CancellationToken cancellationToken)
|
|
{
|
|
var blobClient = _container.GetBlobClient(keyName);
|
|
|
|
try
|
|
{
|
|
var properties = await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, ct
|
|
=> blobClient.GetPropertiesAsync(cancellationToken: ct)
|
|
).ConfigureAwait(false);
|
|
|
|
return properties.Value.ImmutabilityPolicy?.ExpiresOn?.UtcDateTime;
|
|
}
|
|
catch (RequestFailedException e) when (e.Status == 404
|
|
|| e.ErrorCode == BlobErrorCode.BlobNotFound
|
|
|| e.ErrorCode == BlobErrorCode.ResourceNotFound)
|
|
{
|
|
throw new FileMissingException(e.Message, e);
|
|
}
|
|
catch (RequestFailedException e) when (e.ErrorCode == BlobErrorCode.ContainerNotFound
|
|
|| e.ErrorCode == BlobErrorCode.ContainerBeingDeleted
|
|
|| e.ErrorCode == BlobErrorCode.ContainerDisabled)
|
|
{
|
|
throw new FolderMissingException(e.Message, e);
|
|
}
|
|
}
|
|
|
|
public async Task SetObjectLockUntilAsync(string keyName, DateTime lockUntilUtc, CancellationToken cancellationToken)
|
|
{
|
|
var blobClient = _container.GetBlobClient(keyName);
|
|
var policy = new BlobImmutabilityPolicy
|
|
{
|
|
ExpiresOn = new DateTimeOffset(lockUntilUtc.ToUniversalTime(), TimeSpan.Zero),
|
|
PolicyMode = BlobImmutabilityPolicyMode.Unlocked
|
|
};
|
|
|
|
try
|
|
{
|
|
await Utility.Utility.WithTimeout(_timeouts.ShortTimeout, cancellationToken, async ct
|
|
=> await blobClient.SetImmutabilityPolicyAsync(policy, cancellationToken: ct).ConfigureAwait(false)
|
|
).ConfigureAwait(false);
|
|
}
|
|
catch (RequestFailedException e) when (e.Status == 404
|
|
|| e.ErrorCode == BlobErrorCode.BlobNotFound
|
|
|| e.ErrorCode == BlobErrorCode.ResourceNotFound)
|
|
{
|
|
throw new FileMissingException(e.Message, e);
|
|
}
|
|
catch (RequestFailedException e) when (e.ErrorCode == BlobErrorCode.ContainerNotFound
|
|
|| e.ErrorCode == BlobErrorCode.ContainerBeingDeleted
|
|
|| e.ErrorCode == BlobErrorCode.ContainerDisabled)
|
|
{
|
|
throw new FolderMissingException(e.Message, e);
|
|
}
|
|
}
|
|
}
|
|
}
|