From 2b469e401e6234d39e195851ca29760d1ecfa124 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Mon, 16 Mar 2026 13:33:56 -0400 Subject: [PATCH 01/20] Add Azure Blob Storage Gen2 (ADLS HNS) support to BlobFileStore Make BlobFileStore adaptive: it now auto-detects whether the storage account has Hierarchical Namespace (HNS) enabled and uses native DataLake APIs for atomic moves, real directories, and efficient listing when available. Falls back to standard flat-namespace blob operations otherwise. HNS detection can be overridden via configuration. - Add IFileStoreCapabilities interface and FileStoreCapabilities impl - Add GetFilesAsync, GetDirectoriesAsync, Capabilities to IFileStore - Add UseHierarchicalNamespace option to BlobStorageOptions - Add Azure.Storage.Files.DataLake package dependency - Add MediaCreatedFileAsync and MediaCopiedFileAsync event hooks - Wire up EnsureCapabilitiesAsync at startup in Media.Azure module --- Directory.Packages.props | 1 + .../MediaBlobStorageOptionsConfiguration.cs | 1 + .../OrchardCore.Media.Azure/Startup.cs | 4 +- .../IFileStore.cs | 45 +++ .../IFileStoreCapabilities.cs | 52 ++++ .../BlobFileStore.cs | 273 ++++++++++++++++-- .../BlobStorageOptions.cs | 7 + .../OrchardCore.FileStorage.AzureBlob.csproj | 1 + .../Events/IMediaEventHandler.cs | 2 + .../DefaultMediaFileStore.cs | 26 +- 10 files changed, 385 insertions(+), 27 deletions(-) create mode 100644 src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 42834838ba4..e88119d4793 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobStorageOptionsConfiguration.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobStorageOptionsConfiguration.cs index c75ba02d3b4..a2e7d36ebc7 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobStorageOptionsConfiguration.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobStorageOptionsConfiguration.cs @@ -27,5 +27,6 @@ protected override void FurtherConfigure(MediaBlobStorageOptions rawOptions, Med { options.RemoveContainer = rawOptions.RemoveContainer; options.RemoveFilesFromBasePath = rawOptions.RemoveFilesFromBasePath; + options.UseHierarchicalNamespace = rawOptions.UseHierarchicalNamespace; } } diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs index 903d45f22f3..edacadd6d51 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs @@ -97,7 +97,9 @@ public override void ConfigureServices(IServiceCollection services) var mediaCreatingEventHandlers = serviceProvider.GetServices(); var logger = serviceProvider.GetRequiredService>(); - var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider); + var blobLogger = serviceProvider.GetRequiredService>(); + var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider, blobLogger); + fileStore.EnsureCapabilitiesAsync().GetAwaiter().GetResult(); var mediaUrlBase = "/" + fileStore.Combine(shellSettings.RequestUrlPrefix, mediaOptions.AssetsRequestPath); var originalPathBase = serviceProvider.GetRequiredService().HttpContext diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs index 3092f9bf6f1..9fdfc390af3 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs @@ -41,6 +41,46 @@ public interface IFileStore /// IAsyncEnumerable GetDirectoryContentAsync(string path = null, bool includeSubDirectories = false); + /// + /// Enumerates only the files (not directories) in a given directory within the file store. + /// + /// The path of the directory to enumerate, or null to enumerate the root of the file store. + /// The list of files in the given directory. + /// + /// Default implementation filters . Implementations + /// may override this to avoid enumerating directories for better performance. + /// + async IAsyncEnumerable GetFilesAsync(string path = null) + { + await foreach (var entry in GetDirectoryContentAsync(path)) + { + if (!entry.IsDirectory) + { + yield return entry; + } + } + } + + /// + /// Enumerates only the subdirectories in a given directory within the file store. + /// + /// The path of the directory to enumerate, or null to enumerate the root of the file store. + /// The list of subdirectories in the given directory. + /// + /// Default implementation filters . Implementations + /// may override this to avoid enumerating files for better performance. + /// + async IAsyncEnumerable GetDirectoriesAsync(string path = null) + { + await foreach (var entry in GetDirectoryContentAsync(path)) + { + if (entry.IsDirectory) + { + yield return entry; + } + } + } + /// /// Creates a directory in the file store if it doesn't already exist. /// @@ -113,6 +153,11 @@ public interface IFileStore /// /// The usable space in bytes, or if the space is unlimited. Task GetPermittedStorageAsync() => Task.FromResult(null); + + /// + /// Gets the capabilities supported by this file store. + /// + IFileStoreCapabilities Capabilities => FileStoreCapabilities.Default; } public static class IFileStoreExtensions diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs new file mode 100644 index 00000000000..8a4fece4897 --- /dev/null +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs @@ -0,0 +1,52 @@ +namespace OrchardCore.FileStorage; + +/// +/// Describes the capabilities supported by a specific implementation. +/// +public interface IFileStoreCapabilities +{ + /// + /// Gets a value indicating whether the store has a true hierarchical namespace + /// (i.e. directories are first-class objects, not simulated via path prefixes). + /// + bool HasHierarchicalNamespace { get; } + + /// + /// Gets a value indicating whether the store supports atomic (server-side) move / rename + /// that does not require a copy-then-delete round-trip. + /// + bool SupportsAtomicMove { get; } + + /// + /// Gets a human-readable name identifying the storage provider (e.g. "Local", "Azure Blob"). + /// + string StorageProvider => "Unknown"; +} + +/// +/// Provides a default instance where all capabilities are false. +/// +public sealed class FileStoreCapabilities : IFileStoreCapabilities +{ + /// + /// A shared instance that returns false for every capability. + /// + public static readonly IFileStoreCapabilities Default = new FileStoreCapabilities(); + + public FileStoreCapabilities() + { + } + + public FileStoreCapabilities(bool hasHierarchicalNamespace, bool supportsAtomicMove, string storageProvider = "Unknown") + { + HasHierarchicalNamespace = hasHierarchicalNamespace; + SupportsAtomicMove = supportsAtomicMove; + StorageProvider = storageProvider; + } + + public bool HasHierarchicalNamespace { get; } + + public bool SupportsAtomicMove { get; } + + public string StorageProvider { get; } = "Unknown"; +} diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index 56395297ab1..2231e3c6b51 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -3,7 +3,9 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; +using Azure.Storage.Files.DataLake; using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Logging; using OrchardCore.Modules; namespace OrchardCore.FileStorage.AzureBlob; @@ -12,24 +14,25 @@ namespace OrchardCore.FileStorage.AzureBlob; /// Provides an implementation that targets an underlying Azure Blob Storage account. /// /// -/// Azure Blob Storage has very different semantics for directories compared to a local file system, and -/// some special consideration is required for make this provider conform to the semantics of the -/// interface and behave in an expected way. +/// This store supports both flat-namespace (Gen1) and hierarchical-namespace / ADLS Gen2 storage accounts. +/// When HNS is detected (or forced via configuration), operations use native DataLake APIs for +/// atomic moves, real directories, and efficient listing. Otherwise, standard blob operations are used +/// with virtual directory semantics. /// -/// Directories have no physical manifestation in blob storage; we can obtain a reference to them, but +/// Directories have no physical manifestation in flat blob storage; we can obtain a reference to them, but /// that reference can be created regardless of whether the directory exists, and it can only be used /// as a scoping container to operate on blobs within that directory namespace. /// -/// As a consequence, this provider generally behaves as if any given directory always exists. To -/// simulate "creating" a directory (which cannot technically be done in blob storage) this provider creates -/// a marker file inside the directory, which makes the directory "exist" and appear when listing contents -/// subsequently. This marker file is ignored (excluded) when listing directory contents. +/// As a consequence, in flat-namespace mode this provider generally behaves as if any given directory always +/// exists. To simulate "creating" a directory this provider creates a marker file inside the directory, +/// which makes the directory "exist" and appear when listing contents subsequently. This marker file is +/// ignored (excluded) when listing directory contents. /// /// Note that the Blob Container is not created automatically, and existence of the Container is not verified. /// /// Create the Blob Container before enabling a Blob File Store. /// -/// Azure Blog Storage will create the BasePath inside the container during the upload of the first file. +/// Azure Blob Storage will create the BasePath inside the container during the upload of the first file. /// public class BlobFileStore : IFileStore { @@ -39,19 +42,34 @@ public class BlobFileStore : IFileStore private readonly BlobStorageOptions _options; private readonly IClock _clock; private readonly BlobContainerClient _blobContainer; + private readonly BlobServiceClient _blobServiceClient; + private readonly DataLakeFileSystemClient _dataLakeFileSystemClient; private readonly IContentTypeProvider _contentTypeProvider; + private readonly ILogger _logger; + private readonly bool? _useHierarchicalNamespaceOverride; + private readonly SemaphoreSlim _capabilitiesLock = new(1, 1); + private IFileStoreCapabilities _capabilities; private readonly string _basePrefix; + public IFileStoreCapabilities Capabilities => _capabilities ?? FileStoreCapabilities.Default; + public BlobFileStore( BlobStorageOptions options, IClock clock, - IContentTypeProvider contentTypeProvider) + IContentTypeProvider contentTypeProvider, + ILogger logger = null) { _options = options; _clock = clock; _contentTypeProvider = contentTypeProvider; + _logger = logger; _blobContainer = new BlobContainerClient(_options.ConnectionString, _options.ContainerName); + _blobServiceClient = new BlobServiceClient(_options.ConnectionString); + _useHierarchicalNamespaceOverride = options.UseHierarchicalNamespace; + + var serviceClient = new DataLakeServiceClient(_options.ConnectionString); + _dataLakeFileSystemClient = serviceClient.GetFileSystemClient(_options.ContainerName); if (!string.IsNullOrEmpty(_options.BasePath)) { @@ -59,6 +77,63 @@ public BlobFileStore( } } + /// + /// Probes the storage account to determine whether Hierarchical Namespace (HNS) is enabled. + /// Must be called once at startup; after completion returns the detected values. + /// + public async Task EnsureCapabilitiesAsync() + { + if (_capabilities is not null) + { + return; + } + + await _capabilitiesLock.WaitAsync(); + try + { + if (_capabilities is not null) + { + return; + } + + var hnsEnabled = _useHierarchicalNamespaceOverride; + + if (!hnsEnabled.HasValue) + { + try + { + var accountInfo = await _blobServiceClient.GetAccountInfoAsync(); + hnsEnabled = accountInfo.Value.IsHierarchicalNamespaceEnabled; + } + catch (Exception ex) + { + // If we cannot determine HNS status (e.g., insufficient permissions on SAS token), + // default to false (flat namespace behavior). + _logger?.LogWarning(ex, "Unable to detect Azure Blob Storage Hierarchical Namespace status. Falling back to flat namespace behavior."); + hnsEnabled = false; + } + } + + _capabilities = new FileStoreCapabilities( + hasHierarchicalNamespace: hnsEnabled.Value, + supportsAtomicMove: hnsEnabled.Value, + storageProvider: hnsEnabled.Value ? "Azure Blob (Gen2)" : "Azure Blob (Gen1)"); + + if (hnsEnabled.Value) + { + _logger?.LogInformation("Azure Blob Storage Hierarchical Namespace (ADLS Gen2) detected. Using native directory and atomic move operations."); + } + else + { + _logger?.LogInformation("Azure Blob Storage flat namespace detected. Using standard blob operations with virtual directories."); + } + } + finally + { + _capabilitiesLock.Release(); + } + } + public async Task GetFileInfoAsync(string path) { try @@ -82,6 +157,31 @@ public async Task GetFileInfoAsync(string path) public async Task GetDirectoryInfoAsync(string path) { + if (Capabilities.HasHierarchicalNamespace) + { + try + { + if (path == string.Empty) + { + return new BlobDirectory(path, _clock.UtcNow); + } + + var prefix = this.Combine(_basePrefix, path); + var directoryClient = _dataLakeFileSystemClient.GetDirectoryClient(prefix); + + if (await directoryClient.ExistsAsync()) + { + return new BlobDirectory(path, _clock.UtcNow); + } + + return null; + } + catch (Exception ex) + { + throw new FileStoreException($"Cannot get directory info with path '{path}'.", ex); + } + } + try { if (path == string.Empty) @@ -143,18 +243,30 @@ private async IAsyncEnumerable GetDirectoryContentByHierarchyAs } folderPath = folderPath.Trim('/'); - yield return new BlobDirectory(folderPath, _clock.UtcNow); - } - else - { - var itemName = Path.GetFileName(WebUtility.UrlDecode(blob.Blob.Name)).Trim('/'); - // Ignore directory marker files. - if (itemName != DirectoryMarkerFileName) + if (blob.Blob is not null && blob.Blob.Properties is not null) + { + yield return new BlobDirectory( + folderPath, + blob.Blob.Properties.LastModified.HasValue + ? blob.Blob.Properties.LastModified.Value.DateTime + : _clock.UtcNow); + } + else { - var itemPath = this.Combine(path?.Trim('/'), itemName); - yield return new BlobFile(itemPath, blob.Blob.Properties.ContentLength, blob.Blob.Properties.LastModified); + yield return new BlobDirectory(folderPath, _clock.UtcNow); } + + continue; + } + + var itemName = Path.GetFileName(WebUtility.UrlDecode(blob.Blob.Name)).Trim('/'); + + // Ignore directory marker files. + if (!string.Equals(itemName, DirectoryMarkerFileName, StringComparison.Ordinal)) + { + var itemPath = this.Combine(path?.Trim('/'), itemName); + yield return new BlobFile(itemPath, blob.Blob.Properties.ContentLength, blob.Blob.Properties.LastModified); } } } @@ -163,14 +275,62 @@ private async IAsyncEnumerable GetDirectoryContentFlatAsync(str { path = this.NormalizePath(path); - // Folders are considered case sensitive in blob storage. - var directories = new HashSet(); - var prefix = this.Combine(_basePrefix, path); prefix = NormalizePrefix(prefix); - var page = _blobContainer.GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix, CancellationToken.None); - await foreach (var blob in page) + if (Capabilities.HasHierarchicalNamespace) + { + var page = _blobContainer.GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix, CancellationToken.None); + await foreach (var blob in page) + { + var name = blob.Name; + + if (blob.Metadata.TryGetValue("hdi_isfolder", out var value) && + value.Equals("true", StringComparison.OrdinalIgnoreCase)) + { + var directoryName = name; + if (!string.IsNullOrEmpty(_basePrefix)) + { + directoryName = directoryName[(_basePrefix.Length - 1)..]; + } + + if (blob.Properties is not null) + { + yield return new BlobDirectory( + directoryName, + blob.Properties.LastModified.HasValue + ? blob.Properties.LastModified.Value.DateTime + : _clock.UtcNow); + } + else + { + yield return new BlobDirectory(directoryName, _clock.UtcNow); + } + + continue; + } + + if (name.EndsWith(DirectoryMarkerFileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!string.IsNullOrEmpty(_basePrefix)) + { + name = name[(_basePrefix.Length - 1)..]; + } + + yield return new BlobFile(name, blob.Properties.ContentLength, blob.Properties.LastModified); + } + + yield break; + } + + // Flat namespace: infer directory hierarchy from blob paths. + var directories = new HashSet(); + + var flatPage = _blobContainer.GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix, CancellationToken.None); + await foreach (var blob in flatPage) { var name = WebUtility.UrlDecode(blob.Name); @@ -209,6 +369,23 @@ private async IAsyncEnumerable GetDirectoryContentFlatAsync(str public async Task TryCreateDirectoryAsync(string path) { + if (Capabilities.HasHierarchicalNamespace) + { + try + { + var prefix = this.Combine(_basePrefix, path); + var directoryClient = _dataLakeFileSystemClient.GetDirectoryClient(prefix); + var response = await directoryClient.CreateIfNotExistsAsync(); + + // CreateIfNotExistsAsync returns null if it already existed. + return response is not null; + } + catch (Exception ex) + { + throw new FileStoreException($"Cannot create directory '{path}'.", ex); + } + } + // Since directories are only created implicitly when creating blobs, we // simply pretend like we created the directory, unless there is already // a blob with the same path. @@ -255,6 +432,36 @@ public async Task TryDeleteFileAsync(string path) public async Task TryDeleteDirectoryAsync(string path) { + if (Capabilities.HasHierarchicalNamespace) + { + try + { + if (string.IsNullOrEmpty(path)) + { + throw new FileStoreException("Cannot delete the root directory."); + } + + var prefix = this.Combine(_basePrefix, path); + var directoryClient = _dataLakeFileSystemClient.GetDirectoryClient(prefix); + + if (!await directoryClient.ExistsAsync()) + { + return false; + } + + await directoryClient.DeleteAsync(recursive: true); + return true; + } + catch (FileStoreException) + { + throw; + } + catch (Exception ex) + { + throw new FileStoreException($"Cannot delete directory '{path}'.", ex); + } + } + try { if (string.IsNullOrEmpty(path)) @@ -288,6 +495,24 @@ public async Task TryDeleteDirectoryAsync(string path) public async Task MoveFileAsync(string oldPath, string newPath) { + if (Capabilities.SupportsAtomicMove) + { + try + { + var oldFullPath = this.Combine(_basePrefix, oldPath); + var newFullPath = this.Combine(_basePrefix, newPath); + + var fileClient = _dataLakeFileSystemClient.GetFileClient(oldFullPath); + await fileClient.RenameAsync(newFullPath); + } + catch (Exception ex) + { + throw new FileStoreException($"Cannot move file '{oldPath}' to '{newPath}'.", ex); + } + + return; + } + try { await CopyFileAsync(oldPath, newPath); diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs index fd2d4f627fd..cad8c5987a0 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs @@ -17,6 +17,13 @@ public abstract class BlobStorageOptions /// public string BasePath { get; set; } = ""; + /// + /// Overrides auto-detection of Hierarchical Namespace (HNS / ADLS Gen2) support. + /// Set to true to force HNS-aware behavior, false to force flat-namespace behavior, + /// or leave null to auto-detect from the storage account at startup. + /// + public bool? UseHierarchicalNamespace { get; set; } + /// /// Returns a value indicating whether the basic state of the configuration is valid. /// diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj index 71c90e93f7e..ececdce2685 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/OrchardCore.FileStorage.AzureBlob.csproj @@ -16,6 +16,7 @@ + diff --git a/src/OrchardCore/OrchardCore.Media.Abstractions/Events/IMediaEventHandler.cs b/src/OrchardCore/OrchardCore.Media.Abstractions/Events/IMediaEventHandler.cs index cbea7e55b35..67a0d587d1a 100644 --- a/src/OrchardCore/OrchardCore.Media.Abstractions/Events/IMediaEventHandler.cs +++ b/src/OrchardCore/OrchardCore.Media.Abstractions/Events/IMediaEventHandler.cs @@ -13,5 +13,7 @@ public interface IMediaEventHandler Task MediaMovedAsync(MediaMoveContext context) => Task.CompletedTask; Task MediaCreatingDirectoryAsync(MediaCreatingContext context) => Task.CompletedTask; Task MediaCreatedDirectoryAsync(MediaCreatedContext context) => Task.CompletedTask; + Task MediaCreatedFileAsync(MediaCreatedContext context) => Task.CompletedTask; + Task MediaCopiedFileAsync(MediaMoveContext context) => Task.CompletedTask; Task MediaPermittedStorageAsync(MediaPermittedStorageContext context) => Task.CompletedTask; } diff --git a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs index e761efba438..d098b6a5711 100644 --- a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs +++ b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs @@ -57,6 +57,16 @@ public virtual IAsyncEnumerable GetDirectoryContentAsync(string return _fileStore.GetDirectoryContentAsync(path, includeSubDirectories); } + public virtual IAsyncEnumerable GetFilesAsync(string path = null) + { + return _fileStore.GetFilesAsync(path); + } + + public virtual IAsyncEnumerable GetDirectoriesAsync(string path = null) + { + return _fileStore.GetDirectoriesAsync(path); + } + public virtual async Task TryCreateDirectoryAsync(string path) { var creatingContext = new MediaCreatingContext @@ -146,6 +156,8 @@ public virtual async Task CopyFileAsync(string srcPath, string dstPath) } await _fileStore.CopyFileAsync(srcPath, dstPath); + + await _mediaEventHandlers.InvokeAsync((handler, ctx) => handler.MediaCopiedFileAsync(ctx), new MediaMoveContext { OldPath = srcPath, NewPath = dstPath }, _logger); } public virtual Task GetFileStreamAsync(string path) @@ -194,7 +206,11 @@ public virtual async Task CreateFileFromStreamAsync(string path, Stream await ValidateAvailableStorageAsync(outputStream.Length); - return await _fileStore.CreateFileFromStreamAsync(context.Path, outputStream, overwrite); + var result = await _fileStore.CreateFileFromStreamAsync(context.Path, outputStream, overwrite); + + await _mediaEventHandlers.InvokeAsync((handler, ctx) => handler.MediaCreatedFileAsync(ctx), new MediaCreatedContext { Path = result }, _logger); + + return result; } finally { @@ -206,7 +222,11 @@ public virtual async Task CreateFileFromStreamAsync(string path, Stream { await ValidateAvailableStorageAsync(inputStream.Length); - return await _fileStore.CreateFileFromStreamAsync(path, inputStream, overwrite); + var result = await _fileStore.CreateFileFromStreamAsync(path, inputStream, overwrite); + + await _mediaEventHandlers.InvokeAsync((handler, ctx) => handler.MediaCreatedFileAsync(ctx), new MediaCreatedContext { Path = result }, _logger); + + return result; } } @@ -238,6 +258,8 @@ public virtual string MapPathToPublicUrl(string path) return context.PermittedStorage; } + public IFileStoreCapabilities Capabilities => _fileStore.Capabilities; + private void ValidateRequestBasePath(HttpContext httpContext) { var originalPathBase = httpContext.Features.Get()?.OriginalPathBase ?? PathString.Empty; From 1f5d2a09f91d7fd2c9544399ff9138fb65c38395 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Mon, 16 Mar 2026 15:07:09 -0400 Subject: [PATCH 02/20] Add BlobFileStore integration tests for Gen1 and Gen2 code paths Add integration tests that run against Azurite to verify BlobFileStore behavior in both flat-namespace (Gen1) and hierarchical-namespace (Gen2) modes. Tests cover capabilities detection, file CRUD, directory operations, move/copy, and directory content listing. Gen2 tests that use DataLake APIs will fail until Azurite supports the DFS endpoint (--dfsPort flag). This is intentional to track when a newer Azurite version enables full Gen2 testing. - Add abstract BlobFileStoreTestsBase with 26 test methods - Add BlobFileStoreGen1Tests and BlobFileStoreGen2Tests subclasses - Add AzuriteFactAttribute to skip tests when Azurite is unavailable - Add OrchardCore.FileStorage.AzureBlob reference to test project - Start Azurite in main_ci and pr_ci workflows on ubuntu runners --- .github/workflows/main_ci.yml | 6 + .github/workflows/pr_ci.yml | 6 + .../BlobFileStoreGen1Tests.cs | 9 + .../BlobFileStoreGen2Tests.cs | 10 + .../BlobFileStoreTestsBase.cs | 376 ++++++++++++++++++ .../OrchardCore.Tests.csproj | 1 + 6 files changed, 408 insertions(+) create mode 100644 test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen1Tests.cs create mode 100644 test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs create mode 100644 test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index 99decdfcc51..3023a1f707e 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -33,7 +33,13 @@ jobs: # See pr_ci.yml for the reason why we disable NuGet audit warnings. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false + - name: Start Azurite + if: matrix.os == 'ubuntu-24.04' + run: | + docker run -d --name azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --loose --skipApiVersionCheck - name: Unit Tests + env: + AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} run: | dotnet test --project ./test/OrchardCore.Tests/OrchardCore.Tests.csproj -c Release --no-build - name: Functional Tests diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index 909b05184d1..f802b19848c 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -30,7 +30,13 @@ jobs: # warnings and other better approaches don't work, see https://github.com/OrchardCMS/OrchardCore/pull/16317. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false + - name: Start Azurite + if: matrix.os == 'ubuntu-24.04' + run: | + docker run -d --name azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --loose --skipApiVersionCheck - name: Unit Tests + env: + AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} run: | dotnet test --project ./test/OrchardCore.Tests/OrchardCore.Tests.csproj -c Release --no-build - name: Functional Tests diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen1Tests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen1Tests.cs new file mode 100644 index 00000000000..adf89e609cd --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen1Tests.cs @@ -0,0 +1,9 @@ +namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; + +/// +/// Runs all tests with flat-namespace (Gen1) behavior. +/// +public sealed class BlobFileStoreGen1Tests : BlobFileStoreTestsBase +{ + protected override bool IsHnsEnabled => false; +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs new file mode 100644 index 00000000000..0aebf0adb2f --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs @@ -0,0 +1,10 @@ +namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; + +/// +/// Runs all tests with hierarchical-namespace (Gen2 / ADLS) behavior. +/// Uses UseHierarchicalNamespace = true to force Gen2 code paths in Azurite. +/// +public sealed class BlobFileStoreGen2Tests : BlobFileStoreTestsBase +{ + protected override bool IsHnsEnabled => true; +} diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs new file mode 100644 index 00000000000..e06db1f60a8 --- /dev/null +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs @@ -0,0 +1,376 @@ +using Azure.Storage.Blobs; +using Microsoft.AspNetCore.StaticFiles; +using Moq; +using OrchardCore.FileStorage; +using OrchardCore.FileStorage.AzureBlob; +using OrchardCore.Modules; +using Xunit; + +namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; + +/// +/// Integration tests for that run against Azurite. +/// Subclasses set to exercise Gen1 (flat) or Gen2 (HNS) code paths. +/// +public abstract class BlobFileStoreTestsBase : IAsyncLifetime +{ + private const string EnvVar = "AZURITE_CONNECTION_STRING"; + + protected abstract bool IsHnsEnabled { get; } + + private BlobFileStore _store; + private BlobContainerClient _containerClient; + private string _containerName; + + protected static string GetConnectionString() + => System.Environment.GetEnvironmentVariable(EnvVar); + + public async ValueTask InitializeAsync() + { + var connectionString = GetConnectionString(); + _containerName = $"test-{Guid.NewGuid():N}"; + + var options = new TestBlobStorageOptions + { + ConnectionString = connectionString, + ContainerName = _containerName, + BasePath = "", + UseHierarchicalNamespace = IsHnsEnabled, + }; + + _containerClient = new BlobContainerClient(connectionString, _containerName); + await _containerClient.CreateIfNotExistsAsync(); + + var clock = Mock.Of(c => c.UtcNow == DateTime.UtcNow); + var contentTypeProvider = new FileExtensionContentTypeProvider(); + + _store = new BlobFileStore(options, clock, contentTypeProvider); + await _store.EnsureCapabilitiesAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_containerClient is not null) + { + await _containerClient.DeleteIfExistsAsync(); + } + } + + private async Task CreateTestFileAsync(string path, string content = "test content") + { + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); + return await _store.CreateFileFromStreamAsync(path, stream); + } + + private async Task ReadFileContentAsync(string path) + { + using var stream = await _store.GetFileStreamAsync(path); + using var reader = new StreamReader(stream); + return await reader.ReadToEndAsync(); + } + + // -- Capabilities -- + + [AzuriteFact] + public void EnsureCapabilities_SetsCorrectProvider() + { + var expected = IsHnsEnabled ? "Azure Blob (Gen2)" : "Azure Blob (Gen1)"; + Assert.Equal(expected, _store.Capabilities.StorageProvider); + } + + [AzuriteFact] + public void EnsureCapabilities_SetsHnsFlag() + { + Assert.Equal(IsHnsEnabled, _store.Capabilities.HasHierarchicalNamespace); + } + + [AzuriteFact] + public void EnsureCapabilities_SetsAtomicMoveFlag() + { + Assert.Equal(IsHnsEnabled, _store.Capabilities.SupportsAtomicMove); + } + + // -- File operations -- + + [AzuriteFact] + public async Task CreateFile_ReturnsPath() + { + var result = await CreateTestFileAsync("folder/file.txt"); + Assert.Equal("folder/file.txt", result); + } + + [AzuriteFact] + public async Task GetFileInfo_ReturnsCorrectMetadata() + { + var content = "hello world"; + await CreateTestFileAsync("info-test.txt", content); + + var info = await _store.GetFileInfoAsync("info-test.txt"); + + Assert.NotNull(info); + Assert.Equal("info-test.txt", info.Path); + Assert.Equal("info-test.txt", info.Name); + Assert.Equal(content.Length, info.Length); + Assert.False(info.IsDirectory); + } + + [AzuriteFact] + public async Task GetFileInfo_NonExistent_ReturnsNull() + { + var info = await _store.GetFileInfoAsync("does-not-exist.txt"); + Assert.Null(info); + } + + [AzuriteFact] + public async Task GetFileStream_ReturnsContent() + { + var expected = "stream content test"; + await CreateTestFileAsync("stream-test.txt", expected); + + var actual = await ReadFileContentAsync("stream-test.txt"); + + Assert.Equal(expected, actual); + } + + [AzuriteFact] + public async Task GetFileStream_NonExistent_Throws() + { + await Assert.ThrowsAsync( + () => _store.GetFileStreamAsync("no-such-file.txt")); + } + + [AzuriteFact] + public async Task DeleteFile_ReturnsTrue() + { + await CreateTestFileAsync("delete-me.txt"); + + var result = await _store.TryDeleteFileAsync("delete-me.txt"); + + Assert.True(result); + Assert.Null(await _store.GetFileInfoAsync("delete-me.txt")); + } + + [AzuriteFact] + public async Task DeleteFile_NonExistent_ReturnsFalse() + { + var result = await _store.TryDeleteFileAsync("ghost.txt"); + Assert.False(result); + } + + [AzuriteFact] + public async Task CopyFile_CreatesNewFile() + { + var content = "copy me"; + await CreateTestFileAsync("original.txt", content); + + await _store.CopyFileAsync("original.txt", "copied.txt"); + + Assert.Equal(content, await ReadFileContentAsync("original.txt")); + Assert.Equal(content, await ReadFileContentAsync("copied.txt")); + } + + [AzuriteFact] + public async Task CopyFile_SamePath_Throws() + { + await CreateTestFileAsync("same.txt"); + + await Assert.ThrowsAsync( + () => _store.CopyFileAsync("same.txt", "same.txt")); + } + + [AzuriteFact] + public async Task CreateFile_OverwriteTrue_Succeeds() + { + await CreateTestFileAsync("overwrite.txt", "v1"); + + using var stream = new MemoryStream("v2"u8.ToArray()); + await _store.CreateFileFromStreamAsync("overwrite.txt", stream, overwrite: true); + + var content = await ReadFileContentAsync("overwrite.txt"); + Assert.Equal("v2", content); + } + + [AzuriteFact] + public async Task CreateFile_OverwriteFalse_Throws() + { + await CreateTestFileAsync("no-overwrite.txt"); + + using var stream = new MemoryStream("v2"u8.ToArray()); + await Assert.ThrowsAsync( + () => _store.CreateFileFromStreamAsync("no-overwrite.txt", stream, overwrite: false)); + } + + // -- Directory operations -- + + [AzuriteFact] + public async Task GetDirectoryInfo_Root_ReturnsEntry() + { + var info = await _store.GetDirectoryInfoAsync(string.Empty); + + Assert.NotNull(info); + Assert.True(info.IsDirectory); + } + + [AzuriteFact] + public async Task GetDirectoryInfo_Existing_ReturnsEntry() + { + await _store.TryCreateDirectoryAsync("my-folder"); + + var info = await _store.GetDirectoryInfoAsync("my-folder"); + + Assert.NotNull(info); + Assert.True(info.IsDirectory); + Assert.Equal("my-folder", info.Path); + } + + [AzuriteFact] + public async Task GetDirectoryInfo_NonExistent_ReturnsNull() + { + var info = await _store.GetDirectoryInfoAsync("no-such-folder"); + + // Gen1 flat namespace: a directory "exists" if any blobs match the prefix. + // Since there are none, it returns null. + // Gen2 HNS: DataLake directory doesn't exist, returns null. + Assert.Null(info); + } + + [AzuriteFact] + public async Task CreateDirectory_NewDirectory_Succeeds() + { + var result = await _store.TryCreateDirectoryAsync("new-dir"); + + Assert.True(result); + + var info = await _store.GetDirectoryInfoAsync("new-dir"); + Assert.NotNull(info); + } + + [AzuriteFact] + public async Task DeleteDirectory_WithContents_DeletesAll() + { + await _store.TryCreateDirectoryAsync("dir-to-delete"); + await CreateTestFileAsync("dir-to-delete/file1.txt"); + await CreateTestFileAsync("dir-to-delete/file2.txt"); + + var result = await _store.TryDeleteDirectoryAsync("dir-to-delete"); + + Assert.True(result); + Assert.Null(await _store.GetFileInfoAsync("dir-to-delete/file1.txt")); + Assert.Null(await _store.GetFileInfoAsync("dir-to-delete/file2.txt")); + } + + [AzuriteFact] + public async Task DeleteDirectory_NonExistent_ReturnsFalse() + { + var result = await _store.TryDeleteDirectoryAsync("phantom-dir"); + Assert.False(result); + } + + [AzuriteFact] + public async Task DeleteDirectory_Root_Throws() + { + await Assert.ThrowsAsync( + () => _store.TryDeleteDirectoryAsync(string.Empty)); + } + + // -- Move -- + + [AzuriteFact] + public async Task MoveFile_MovesToNewPath() + { + var content = "move me"; + await CreateTestFileAsync("src.txt", content); + + await _store.MoveFileAsync("src.txt", "dst.txt"); + + Assert.Null(await _store.GetFileInfoAsync("src.txt")); + Assert.Equal(content, await ReadFileContentAsync("dst.txt")); + } + + [AzuriteFact] + public async Task MoveFile_AcrossDirectories() + { + await _store.TryCreateDirectoryAsync("dir-a"); + await _store.TryCreateDirectoryAsync("dir-b"); + await CreateTestFileAsync("dir-a/moved.txt", "data"); + + await _store.MoveFileAsync("dir-a/moved.txt", "dir-b/moved.txt"); + + Assert.Null(await _store.GetFileInfoAsync("dir-a/moved.txt")); + Assert.Equal("data", await ReadFileContentAsync("dir-b/moved.txt")); + } + + // -- Directory content listing -- + + [AzuriteFact] + public async Task GetDirectoryContent_ListsFilesAndDirs() + { + await CreateTestFileAsync("root-file.txt"); + await _store.TryCreateDirectoryAsync("sub-dir"); + await CreateTestFileAsync("sub-dir/nested.txt"); + + var entries = new List(); + await foreach (var entry in _store.GetDirectoryContentAsync()) + { + entries.Add(entry); + } + + Assert.Contains(entries, e => e.Name == "root-file.txt" && !e.IsDirectory); + Assert.Contains(entries, e => e.Name == "sub-dir" && e.IsDirectory); + } + + [AzuriteFact] + public async Task GetDirectoryContent_ExcludesMarkerFiles() + { + await _store.TryCreateDirectoryAsync("marker-test"); + + var entries = new List(); + await foreach (var entry in _store.GetDirectoryContentAsync("marker-test")) + { + entries.Add(entry); + } + + // The marker file (OrchardCore.Media.txt) used in Gen1 should never appear in listings. + Assert.DoesNotContain(entries, e => e.Name == "OrchardCore.Media.txt"); + } + + [AzuriteFact] + public async Task GetDirectoryContent_Flat_ListsNestedContent() + { + await CreateTestFileAsync("flat/a.txt"); + await CreateTestFileAsync("flat/sub/b.txt"); + + var entries = new List(); + await foreach (var entry in _store.GetDirectoryContentAsync("flat", includeSubDirectories: true)) + { + entries.Add(entry); + } + + Assert.Contains(entries, e => !e.IsDirectory && e.Name == "a.txt"); + Assert.Contains(entries, e => !e.IsDirectory && e.Name == "b.txt"); + } +} + +/// +/// Concrete for testing (the base class is abstract). +/// +internal sealed class TestBlobStorageOptions : BlobStorageOptions; + +/// +/// Skips the test when the Azurite connection string is not configured. +/// Set the AZURITE_CONNECTION_STRING environment variable to run these tests. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +internal sealed class AzuriteFactAttribute : FactAttribute +{ + public AzuriteFactAttribute( + [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = null, + [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) + : base(sourceFilePath, sourceLineNumber) + { + if (string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("AZURITE_CONNECTION_STRING"))) + { + Skip = "Azurite is not configured. Set AZURITE_CONNECTION_STRING to run this test."; + } + } +} diff --git a/test/OrchardCore.Tests/OrchardCore.Tests.csproj b/test/OrchardCore.Tests/OrchardCore.Tests.csproj index 38bd749eac8..72b8fd5e4f9 100644 --- a/test/OrchardCore.Tests/OrchardCore.Tests.csproj +++ b/test/OrchardCore.Tests/OrchardCore.Tests.csproj @@ -55,6 +55,7 @@ + From 06a1192f9906d50d860ae3cf2090ab1c7e213906 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Mon, 16 Mar 2026 23:12:59 -0400 Subject: [PATCH 03/20] Add DFS endpoint support and fix Gen2 error handling in BlobFileStore Add DfsEndpoint option to BlobStorageOptions for local emulators where the DFS endpoint runs on a separate port. Parse storage credentials from the connection string to construct the DataLakeServiceClient when an explicit DFS endpoint is configured. Fix Gen2 code paths to handle 404 RequestFailedException from DataLakePathClient.ExistsAsync, which can throw instead of returning false when the path does not exist. --- .../BlobFileStore.cs | 50 ++++++++++++++++++- .../BlobStorageOptions.cs | 8 +++ .../BlobFileStoreTestsBase.cs | 2 + 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index 2231e3c6b51..2b0937ceb20 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -3,6 +3,7 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; +using Azure.Storage; using Azure.Storage.Files.DataLake; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Logging; @@ -68,8 +69,19 @@ public BlobFileStore( _blobServiceClient = new BlobServiceClient(_options.ConnectionString); _useHierarchicalNamespaceOverride = options.UseHierarchicalNamespace; - var serviceClient = new DataLakeServiceClient(_options.ConnectionString); - _dataLakeFileSystemClient = serviceClient.GetFileSystemClient(_options.ContainerName); + if (!string.IsNullOrEmpty(_options.DfsEndpoint)) + { + // Use explicit DFS endpoint (required for local emulators like Azurite + // where the DFS endpoint runs on a separate port). + var credential = ParseCredentialsFromConnectionString(_options.ConnectionString); + var serviceClient = new DataLakeServiceClient(new Uri(_options.DfsEndpoint), credential); + _dataLakeFileSystemClient = serviceClient.GetFileSystemClient(_options.ContainerName); + } + else + { + var serviceClient = new DataLakeServiceClient(_options.ConnectionString); + _dataLakeFileSystemClient = serviceClient.GetFileSystemClient(_options.ContainerName); + } if (!string.IsNullOrEmpty(_options.BasePath)) { @@ -176,6 +188,10 @@ public async Task GetDirectoryInfoAsync(string path) return null; } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } catch (Exception ex) { throw new FileStoreException($"Cannot get directory info with path '{path}'.", ex); @@ -456,6 +472,10 @@ public async Task TryDeleteDirectoryAsync(string path) { throw; } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } catch (Exception ex) { throw new FileStoreException($"Cannot delete directory '{path}'.", ex); @@ -673,6 +693,32 @@ private async Task CreateDirectoryAsync(string path) await placeholderBlob.UploadAsync(stream); } + private static StorageSharedKeyCredential ParseCredentialsFromConnectionString(string connectionString) + { + string accountName = null; + string accountKey = null; + + foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var kvp = part.Split('=', 2); + if (kvp.Length == 2) + { + if (kvp[0].Equals("AccountName", StringComparison.OrdinalIgnoreCase)) + { + accountName = kvp[1]; + } + else if (kvp[0].Equals("AccountKey", StringComparison.OrdinalIgnoreCase)) + { + accountKey = kvp[1]; + } + } + } + + return new StorageSharedKeyCredential( + accountName ?? throw new FileStoreException("AccountName not found in connection string."), + accountKey ?? throw new FileStoreException("AccountKey not found in connection string.")); + } + /// /// Blob prefix requires a trailing slash except when loading the root of the container. /// diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs index cad8c5987a0..77b9d329645 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs @@ -24,6 +24,14 @@ public abstract class BlobStorageOptions /// public bool? UseHierarchicalNamespace { get; set; } + /// + /// Optional DFS (Data Lake Storage) endpoint URL for Gen2 operations. + /// When set, the uses this + /// endpoint instead of deriving one from the connection string. + /// Required for local emulators (e.g. Azurite) where the DFS endpoint runs on a separate port. + /// + public string DfsEndpoint { get; set; } + /// /// Returns a value indicating whether the basic state of the configuration is valid. /// diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs index e06db1f60a8..040e63f3486 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs @@ -15,6 +15,7 @@ namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; public abstract class BlobFileStoreTestsBase : IAsyncLifetime { private const string EnvVar = "AZURITE_CONNECTION_STRING"; + private const string DfsEnvVar = "AZURITE_DFS_ENDPOINT"; protected abstract bool IsHnsEnabled { get; } @@ -36,6 +37,7 @@ public async ValueTask InitializeAsync() ContainerName = _containerName, BasePath = "", UseHierarchicalNamespace = IsHnsEnabled, + DfsEndpoint = System.Environment.GetEnvironmentVariable(DfsEnvVar), }; _containerClient = new BlobContainerClient(connectionString, _containerName); From 7be25b8229b0ff0ff9ecc3d7520f479848dabcb0 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Wed, 18 Mar 2026 04:11:44 -0400 Subject: [PATCH 04/20] Update unit tests after updating Azurite --- .../BlobFileStoreGen2Tests.cs | 78 +++++++++++++++ .../BlobFileStoreTestsBase.cs | 99 ++++++++++++++++++- 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs index 0aebf0adb2f..f9df1f78f8a 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs @@ -1,3 +1,6 @@ +using OrchardCore.FileStorage; +using Xunit; + namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; /// @@ -7,4 +10,79 @@ namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; public sealed class BlobFileStoreGen2Tests : BlobFileStoreTestsBase { protected override bool IsHnsEnabled => true; + + [AzuriteFact] + public async Task CreateDirectory_Nested_CreatesIntermediateDirectories() + { + await TryCreateDirectoryAsync("a/b/c"); + + Assert.NotNull(await GetDirectoryInfoAsync("a/b/c")); + Assert.NotNull(await GetDirectoryInfoAsync("a/b")); + Assert.NotNull(await GetDirectoryInfoAsync("a")); + } + + [AzuriteFact] + public async Task GetDirectoryContent_Flat_IncludesGen2Directories() + { + await TryCreateDirectoryAsync("flat-gen2"); + await CreateTestFileAsync("flat-gen2/file.txt"); + await TryCreateDirectoryAsync("flat-gen2/subdir"); + await CreateTestFileAsync("flat-gen2/subdir/nested.txt"); + + var entries = new List(); + await foreach (var entry in GetDirectoryContentAsync("flat-gen2", includeSubDirectories: true)) + { + entries.Add(entry); + } + + // Gen2 flat listing should detect directories via hdi_isfolder metadata. + Assert.Contains(entries, e => e.IsDirectory && e.Name == "subdir"); + Assert.Contains(entries, e => !e.IsDirectory && e.Name == "file.txt"); + Assert.Contains(entries, e => !e.IsDirectory && e.Name == "nested.txt"); + } + + [AzuriteFact] + public async Task MoveFile_IsAtomic() + { + // Gen2 move uses DataLake RenameAsync which is an atomic server-side operation. + Assert.True(Capabilities.SupportsAtomicMove); + + await CreateTestFileAsync("atomic-src.txt", "atomic"); + + await MoveFileAsync("atomic-src.txt", "atomic-dst.txt"); + + // Source should not exist and destination should have the content. + Assert.Null(await GetFileInfoAsync("atomic-src.txt")); + Assert.Equal("atomic", await ReadFileContentAsync("atomic-dst.txt")); + } + + [AzuriteFact] + public async Task GetDirectoryInfo_AfterDeletingDirectory_ReturnsNull() + { + await TryCreateDirectoryAsync("temp-dir"); + Assert.NotNull(await GetDirectoryInfoAsync("temp-dir")); + + await TryDeleteDirectoryAsync("temp-dir"); + + Assert.Null(await GetDirectoryInfoAsync("temp-dir")); + } + + [AzuriteFact] + public async Task CreateDirectory_EmptyDirectory_ExistsWithNoContent() + { + await TryCreateDirectoryAsync("empty-gen2-dir"); + + var info = await GetDirectoryInfoAsync("empty-gen2-dir"); + Assert.NotNull(info); + Assert.True(info.IsDirectory); + + var entries = new List(); + await foreach (var entry in GetDirectoryContentAsync("empty-gen2-dir")) + { + entries.Add(entry); + } + + // A real Gen2 directory should exist even with no files inside. + Assert.Empty(entries); + } } diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs index 040e63f3486..a7e7545a111 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs @@ -58,19 +58,39 @@ public async ValueTask DisposeAsync() } } - private async Task CreateTestFileAsync(string path, string content = "test content") + protected IFileStoreCapabilities Capabilities => _store.Capabilities; + + protected async Task CreateTestFileAsync(string path, string content = "test content") { using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); return await _store.CreateFileFromStreamAsync(path, stream); } - private async Task ReadFileContentAsync(string path) + protected async Task ReadFileContentAsync(string path) { using var stream = await _store.GetFileStreamAsync(path); using var reader = new StreamReader(stream); return await reader.ReadToEndAsync(); } + protected Task TryCreateDirectoryAsync(string path) + => _store.TryCreateDirectoryAsync(path); + + protected Task TryDeleteDirectoryAsync(string path) + => _store.TryDeleteDirectoryAsync(path); + + protected Task GetDirectoryInfoAsync(string path) + => _store.GetDirectoryInfoAsync(path); + + protected Task GetFileInfoAsync(string path) + => _store.GetFileInfoAsync(path); + + protected IAsyncEnumerable GetDirectoryContentAsync(string path = null, bool includeSubDirectories = false) + => _store.GetDirectoryContentAsync(path, includeSubDirectories); + + protected Task MoveFileAsync(string oldPath, string newPath) + => _store.MoveFileAsync(oldPath, newPath); + // -- Capabilities -- [AzuriteFact] @@ -351,6 +371,81 @@ public async Task GetDirectoryContent_Flat_ListsNestedContent() Assert.Contains(entries, e => !e.IsDirectory && e.Name == "a.txt"); Assert.Contains(entries, e => !e.IsDirectory && e.Name == "b.txt"); } + + [AzuriteFact] + public async Task CreateDirectory_AlreadyExists_ReturnsFalse() + { + await _store.TryCreateDirectoryAsync("existing-dir"); + + // Creating the same directory again should indicate it already existed. + var result = await _store.TryCreateDirectoryAsync("existing-dir"); + + // Gen1 always returns true (no real directory to check). + // Gen2 returns false because the directory already exists. + if (IsHnsEnabled) + { + Assert.False(result); + } + else + { + Assert.True(result); + } + } + + [AzuriteFact] + public async Task MoveFile_NonExistent_Throws() + { + await Assert.ThrowsAsync( + () => _store.MoveFileAsync("no-such-file.txt", "destination.txt")); + } + + [AzuriteFact] + public async Task DeleteDirectory_WithNestedSubdirectories_DeletesAll() + { + await _store.TryCreateDirectoryAsync("parent"); + await _store.TryCreateDirectoryAsync("parent/child"); + await CreateTestFileAsync("parent/top.txt"); + await CreateTestFileAsync("parent/child/deep.txt"); + + var result = await _store.TryDeleteDirectoryAsync("parent"); + + Assert.True(result); + Assert.Null(await _store.GetFileInfoAsync("parent/top.txt")); + Assert.Null(await _store.GetFileInfoAsync("parent/child/deep.txt")); + Assert.Null(await _store.GetDirectoryInfoAsync("parent")); + } + + [AzuriteFact] + public async Task GetDirectoryContent_Subdirectory_ListsOnlyDirectChildren() + { + await _store.TryCreateDirectoryAsync("listing"); + await CreateTestFileAsync("listing/file-a.txt"); + await _store.TryCreateDirectoryAsync("listing/inner"); + await CreateTestFileAsync("listing/inner/file-b.txt"); + + var entries = new List(); + await foreach (var entry in _store.GetDirectoryContentAsync("listing")) + { + entries.Add(entry); + } + + Assert.Contains(entries, e => e.Name == "file-a.txt" && !e.IsDirectory); + Assert.Contains(entries, e => e.Name == "inner" && e.IsDirectory); + // file-b.txt is nested inside "inner", should not appear at this level. + Assert.DoesNotContain(entries, e => e.Name == "file-b.txt"); + } + + [AzuriteFact] + public async Task MoveFile_PreservesContent() + { + var content = "preserve this content across move"; + await CreateTestFileAsync("move-preserve.txt", content); + + await _store.MoveFileAsync("move-preserve.txt", "moved-preserve.txt"); + + var actual = await ReadFileContentAsync("moved-preserve.txt"); + Assert.Equal(content, actual); + } } /// From 71ced658b60a8fd5407675aa3ffd3efe3a463e9e Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Wed, 18 Mar 2026 04:27:47 -0400 Subject: [PATCH 05/20] Update GH workflows with temp Azurite package repo --- .github/workflows/main_ci.yml | 11 +++++++---- .github/workflows/pr_ci.yml | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index 3023a1f707e..25f8e16b872 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -22,6 +22,12 @@ jobs: fail-fast: false matrix: os: [ubuntu-24.04, windows-2022] + services: + azurite: + image: ${{ matrix.os == 'ubuntu-24.04' && 'ghcr.io/skrypt/azurite-adls-gen2:latest' || '' }} + ports: + - 10000:10000 + - 10004:10004 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -33,13 +39,10 @@ jobs: # See pr_ci.yml for the reason why we disable NuGet audit warnings. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false - - name: Start Azurite - if: matrix.os == 'ubuntu-24.04' - run: | - docker run -d --name azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --loose --skipApiVersionCheck - name: Unit Tests env: AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} + AZURITE_DFS_ENDPOINT: ${{ matrix.os == 'ubuntu-24.04' && 'http://127.0.0.1:10004/devstoreaccount1' || '' }} run: | dotnet test --project ./test/OrchardCore.Tests/OrchardCore.Tests.csproj -c Release --no-build - name: Functional Tests diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index f802b19848c..b65312d7534 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -16,6 +16,12 @@ jobs: matrix: os: [ubuntu-24.04, windows-2022] name: Build & Test + services: + azurite: + image: ${{ matrix.os == 'ubuntu-24.04' && 'ghcr.io/skrypt/azurite-adls-gen2:latest' || '' }} + ports: + - 10000:10000 + - 10004:10004 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -30,13 +36,10 @@ jobs: # warnings and other better approaches don't work, see https://github.com/OrchardCMS/OrchardCore/pull/16317. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false - - name: Start Azurite - if: matrix.os == 'ubuntu-24.04' - run: | - docker run -d --name azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --loose --skipApiVersionCheck - name: Unit Tests env: AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} + AZURITE_DFS_ENDPOINT: ${{ matrix.os == 'ubuntu-24.04' && 'http://127.0.0.1:10004/devstoreaccount1' || '' }} run: | dotnet test --project ./test/OrchardCore.Tests/OrchardCore.Tests.csproj -c Release --no-build - name: Functional Tests From 256835f29a66f3af1dc88d4786a6be0df60e836d Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Wed, 18 Mar 2026 04:35:08 -0400 Subject: [PATCH 06/20] skipApiVersionCheck Reverted both workflows back to docker run steps (instead of services) since that lets us pass --skipApiVersionCheck directly. The command now uses your image with the correct flags: --- .github/workflows/main_ci.yml | 10 ++++------ .github/workflows/pr_ci.yml | 10 ++++------ 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index 25f8e16b872..3b7656431bd 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -22,12 +22,6 @@ jobs: fail-fast: false matrix: os: [ubuntu-24.04, windows-2022] - services: - azurite: - image: ${{ matrix.os == 'ubuntu-24.04' && 'ghcr.io/skrypt/azurite-adls-gen2:latest' || '' }} - ports: - - 10000:10000 - - 10004:10004 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -39,6 +33,10 @@ jobs: # See pr_ci.yml for the reason why we disable NuGet audit warnings. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false + - name: Start Azurite + if: matrix.os == 'ubuntu-24.04' + run: | + docker run -d --name azurite -p 10000:10000 -p 10004:10004 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --dfsHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck - name: Unit Tests env: AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index b65312d7534..6ec91947ddd 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -16,12 +16,6 @@ jobs: matrix: os: [ubuntu-24.04, windows-2022] name: Build & Test - services: - azurite: - image: ${{ matrix.os == 'ubuntu-24.04' && 'ghcr.io/skrypt/azurite-adls-gen2:latest' || '' }} - ports: - - 10000:10000 - - 10004:10004 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -36,6 +30,10 @@ jobs: # warnings and other better approaches don't work, see https://github.com/OrchardCMS/OrchardCore/pull/16317. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false + - name: Start Azurite + if: matrix.os == 'ubuntu-24.04' + run: | + docker run -d --name azurite -p 10000:10000 -p 10004:10004 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --dfsHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck - name: Unit Tests env: AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} From 8504bb46c73a66c144c3267a5344ef6b24dece95 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Sat, 28 Mar 2026 01:56:04 -0400 Subject: [PATCH 07/20] fix: Replace sync-over-async EnsureCapabilitiesAsync call in Azure media startup Move BlobFileStore.EnsureCapabilitiesAsync() from the DI singleton factory (where it blocked via GetAwaiter().GetResult()) into MediaBlobContainerTenantEvents.ActivatingAsync(), which already runs asynchronously at tenant startup. BlobFileStore is now registered as its own singleton so it can be injected. Also remove the unused StorageProvider member from IFileStoreCapabilities and FileStoreCapabilities, and remove the default implementation of IFileStore.Capabilities so every provider must declare capabilities explicitly. FileSystemStore and AwsFileStore updated accordingly. --- .../MediaBlobContainerTenantEvents.cs | 6 ++++++ .../OrchardCore.Media.Azure/Startup.cs | 19 ++++++++++++------- .../IFileStore.cs | 2 +- .../IFileStoreCapabilities.cs | 9 +-------- .../AwsFileStorage.cs | 2 ++ .../BlobFileStore.cs | 3 +-- .../FileSystemStore.cs | 2 ++ 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobContainerTenantEvents.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobContainerTenantEvents.cs index 6f844a8bbf4..a3ebe9746d0 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobContainerTenantEvents.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Services/MediaBlobContainerTenantEvents.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Options; using OrchardCore.Environment.Shell; using OrchardCore.Environment.Shell.Removing; +using OrchardCore.FileStorage.AzureBlob; using OrchardCore.Modules; namespace OrchardCore.Media.Azure.Services; @@ -14,6 +15,7 @@ public sealed class MediaBlobContainerTenantEvents : ModularTenantEvents { private readonly MediaBlobStorageOptions _options; private readonly ShellSettings _shellSettings; + private readonly BlobFileStore _blobFileStore; private readonly ILogger _logger; internal readonly IStringLocalizer S; @@ -21,18 +23,22 @@ public sealed class MediaBlobContainerTenantEvents : ModularTenantEvents public MediaBlobContainerTenantEvents( IOptions options, ShellSettings shellSettings, + BlobFileStore blobFileStore, IStringLocalizer localizer, ILogger logger ) { _options = options.Value; _shellSettings = shellSettings; + _blobFileStore = blobFileStore; S = localizer; _logger = logger; } public override async Task ActivatingAsync() { + await _blobFileStore.EnsureCapabilitiesAsync(); + // Only create container if options are valid. if (_shellSettings.IsUninitialized() || !_options.IsConfigured() || !_options.CreateContainer) { diff --git a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs index edacadd6d51..98c6c1c4148 100644 --- a/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs @@ -84,22 +84,27 @@ public override void ConfigureServices(IServiceCollection services) services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); + // Register the blob file store as a singleton so it can be injected for async initialization. + services.AddSingleton(serviceProvider => + { + var blobStorageOptions = serviceProvider.GetRequiredService>().Value; + var clock = serviceProvider.GetRequiredService(); + var contentTypeProvider = serviceProvider.GetRequiredService(); + var blobLogger = serviceProvider.GetRequiredService>(); + + return new BlobFileStore(blobStorageOptions, clock, contentTypeProvider, blobLogger); + }); + // Replace the default media file store with a blob file store. services.Replace(ServiceDescriptor.Singleton(serviceProvider => { - var blobStorageOptions = serviceProvider.GetRequiredService>().Value; - var shellOptions = serviceProvider.GetRequiredService>(); + var fileStore = serviceProvider.GetRequiredService(); var shellSettings = serviceProvider.GetRequiredService(); var mediaOptions = serviceProvider.GetRequiredService>().Value; - var clock = serviceProvider.GetRequiredService(); - var contentTypeProvider = serviceProvider.GetRequiredService(); var mediaEventHandlers = serviceProvider.GetServices(); var mediaCreatingEventHandlers = serviceProvider.GetServices(); var logger = serviceProvider.GetRequiredService>(); - var blobLogger = serviceProvider.GetRequiredService>(); - var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider, blobLogger); - fileStore.EnsureCapabilitiesAsync().GetAwaiter().GetResult(); var mediaUrlBase = "/" + fileStore.Combine(shellSettings.RequestUrlPrefix, mediaOptions.AssetsRequestPath); var originalPathBase = serviceProvider.GetRequiredService().HttpContext diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs index 9fdfc390af3..942a59a2422 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs @@ -157,7 +157,7 @@ async IAsyncEnumerable GetDirectoriesAsync(string path = null) /// /// Gets the capabilities supported by this file store. /// - IFileStoreCapabilities Capabilities => FileStoreCapabilities.Default; + IFileStoreCapabilities Capabilities { get; } } public static class IFileStoreExtensions diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs index 8a4fece4897..ce0415fdcda 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs @@ -17,10 +17,6 @@ public interface IFileStoreCapabilities /// bool SupportsAtomicMove { get; } - /// - /// Gets a human-readable name identifying the storage provider (e.g. "Local", "Azure Blob"). - /// - string StorageProvider => "Unknown"; } /// @@ -37,16 +33,13 @@ public FileStoreCapabilities() { } - public FileStoreCapabilities(bool hasHierarchicalNamespace, bool supportsAtomicMove, string storageProvider = "Unknown") + public FileStoreCapabilities(bool hasHierarchicalNamespace, bool supportsAtomicMove) { HasHierarchicalNamespace = hasHierarchicalNamespace; SupportsAtomicMove = supportsAtomicMove; - StorageProvider = storageProvider; } public bool HasHierarchicalNamespace { get; } public bool SupportsAtomicMove { get; } - - public string StorageProvider { get; } = "Unknown"; } diff --git a/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs b/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs index a801465c244..d6af89d8977 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs @@ -15,6 +15,8 @@ public class AwsFileStore : IFileStore private readonly string _basePrefix; private readonly IAmazonS3 _amazonS3Client; + public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: false, supportsAtomicMove: false); + public AwsFileStore(IClock clock, AwsStorageOptions options, IAmazonS3 amazonS3Client) { _clock = clock; diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index 2b0937ceb20..552b2b694cb 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -128,8 +128,7 @@ public async Task EnsureCapabilitiesAsync() _capabilities = new FileStoreCapabilities( hasHierarchicalNamespace: hnsEnabled.Value, - supportsAtomicMove: hnsEnabled.Value, - storageProvider: hnsEnabled.Value ? "Azure Blob (Gen2)" : "Azure Blob (Gen1)"); + supportsAtomicMove: hnsEnabled.Value); if (hnsEnabled.Value) { diff --git a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs index 92cadbf0489..b26e795e9df 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs @@ -8,6 +8,8 @@ public class FileSystemStore : IFileStore private readonly ILogger _logger; private readonly string _fileSystemPath; + public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: false, supportsAtomicMove: false); + public FileSystemStore(string fileSystemPath, ILogger logger) { _logger = logger; From 7d16f4f7106ed7b86ae5b88f6a0d2ba9359f824d Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Sat, 28 Mar 2026 02:04:02 -0400 Subject: [PATCH 08/20] fix: Remove StorageProvider from test broken by IFileStoreCapabilities cleanup --- .../OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs index a7e7545a111..ee82a9ba7f6 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs @@ -93,13 +93,6 @@ protected Task MoveFileAsync(string oldPath, string newPath) // -- Capabilities -- - [AzuriteFact] - public void EnsureCapabilities_SetsCorrectProvider() - { - var expected = IsHnsEnabled ? "Azure Blob (Gen2)" : "Azure Blob (Gen1)"; - Assert.Equal(expected, _store.Capabilities.StorageProvider); - } - [AzuriteFact] public void EnsureCapabilities_SetsHnsFlag() { From 858be10c18c30ba570b2ba142b230a1b98a2dc97 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Sun, 29 Mar 2026 12:19:16 -0400 Subject: [PATCH 09/20] fix: Set correct capabilities on FileSystemStore to reflect actual filesystem behavior FileSystemStore was configured with hasHierarchicalNamespace: false and supportsAtomicMove: false, which does not match its actual implementation. The local filesystem uses real Directory.* APIs for first-class directory operations and File.Move() which maps to an atomic OS rename syscall. Updated both flags to true to align with the implementation and be consistent with how BlobFileStore Gen2 reports the same native behaviors. --- .../OrchardCore.FileStorage.FileSystem/FileSystemStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs index b26e795e9df..0b487d279a5 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs @@ -8,7 +8,7 @@ public class FileSystemStore : IFileStore private readonly ILogger _logger; private readonly string _fileSystemPath; - public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: false, supportsAtomicMove: false); + public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: true, supportsAtomicMove: true); public FileSystemStore(string fileSystemPath, ILogger logger) { From d8c9ebbbf3d768e2fdedf0b4df3689d5bfe3ba71 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Sun, 29 Mar 2026 13:20:59 -0400 Subject: [PATCH 10/20] Add IFileStore.StorageName property for runtime storage provider identification Add a StorageName default interface member to IFileStore that returns a human-readable name for the underlying storage backend. Implementations: FileSystemStore ("Local"), BlobFileStore ("Azure Blob (Gen1)"/"Azure Blob (Gen2)" based on detected HNS), AwsFileStore ("Amazon S3"). DefaultMediaFileStore delegates to the inner store. This enables the media UI to display which storage provider is active at runtime, including distinguishing Azure Gen1 from Gen2. --- .../OrchardCore.FileStorage.Abstractions/IFileStore.cs | 5 +++++ .../OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs | 2 ++ .../OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs | 4 ++++ .../OrchardCore.FileStorage.FileSystem/FileSystemStore.cs | 2 ++ .../OrchardCore.Media.Core/DefaultMediaFileStore.cs | 2 ++ 5 files changed, 15 insertions(+) diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs index 942a59a2422..c29cca7cab7 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs @@ -158,6 +158,11 @@ async IAsyncEnumerable GetDirectoriesAsync(string path = null) /// Gets the capabilities supported by this file store. /// IFileStoreCapabilities Capabilities { get; } + + /// + /// Gets a human-readable name for this file store (e.g. "Local", "Azure Blob (Gen2)", "Amazon S3"). + /// + string StorageName => "File Store"; } public static class IFileStoreExtensions diff --git a/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs b/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs index d6af89d8977..e262ed27016 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs @@ -15,6 +15,8 @@ public class AwsFileStore : IFileStore private readonly string _basePrefix; private readonly IAmazonS3 _amazonS3Client; + public string StorageName => "Amazon S3"; + public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: false, supportsAtomicMove: false); public AwsFileStore(IClock clock, AwsStorageOptions options, IAmazonS3 amazonS3Client) diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index 552b2b694cb..6fb21f98ff8 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -55,6 +55,10 @@ public class BlobFileStore : IFileStore public IFileStoreCapabilities Capabilities => _capabilities ?? FileStoreCapabilities.Default; + public string StorageName => _capabilities is not null && _capabilities.HasHierarchicalNamespace + ? "Azure Blob (Gen2)" + : "Azure Blob (Gen1)"; + public BlobFileStore( BlobStorageOptions options, IClock clock, diff --git a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs index 0b487d279a5..ffb740d8d72 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs @@ -8,6 +8,8 @@ public class FileSystemStore : IFileStore private readonly ILogger _logger; private readonly string _fileSystemPath; + public string StorageName => "Local"; + public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: true, supportsAtomicMove: true); public FileSystemStore(string fileSystemPath, ILogger logger) diff --git a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs index 2e4e363be8e..0ae39696079 100644 --- a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs +++ b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs @@ -260,6 +260,8 @@ public virtual string MapPathToPublicUrl(string path) public IFileStoreCapabilities Capabilities => _fileStore.Capabilities; + public string StorageName => _fileStore.StorageName; + private void ValidateRequestBasePath(HttpContext httpContext) { var originalPathBase = httpContext.Features.Get()?.OriginalPathBase ?? PathString.Empty; From aa477e6e63a870b5762b7cceba9fe0a6e02c1212 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Sun, 29 Mar 2026 13:28:25 -0400 Subject: [PATCH 11/20] fix build --- .../OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index 6fb21f98ff8..b81c69932a6 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -49,7 +49,7 @@ public class BlobFileStore : IFileStore private readonly ILogger _logger; private readonly bool? _useHierarchicalNamespaceOverride; private readonly SemaphoreSlim _capabilitiesLock = new(1, 1); - private IFileStoreCapabilities _capabilities; + private FileStoreCapabilities _capabilities; private readonly string _basePrefix; From 56b294aee7749ece38205122e7ac981c03b72d28 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Sun, 29 Mar 2026 13:44:59 -0400 Subject: [PATCH 12/20] add documentation on added configuration --- .../BlobStorageOptions.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs index 77b9d329645..446233180d5 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs @@ -20,8 +20,30 @@ public abstract class BlobStorageOptions /// /// Overrides auto-detection of Hierarchical Namespace (HNS / ADLS Gen2) support. /// Set to true to force HNS-aware behavior, false to force flat-namespace behavior, - /// or leave null to auto-detect from the storage account at startup. + /// or leave null (default) to auto-detect from the storage account at startup. /// + /// + /// Auto-detection calls GetAccountInfoAsync(), which requires storage account-level + /// permissions. You must set this explicitly when: + /// + /// + /// + /// SAS tokens — A container-scoped SAS token does not have permission to call + /// GetAccountInfo, so detection fails and falls back to flat-namespace behavior. + /// Set to true if the account is actually Gen2/HNS-enabled. + /// + /// + /// + /// + /// Local emulators (Azurite) — Azurite does not implement the GetAccountInfo + /// endpoint. Set to true together with to enable + /// HNS simulation in local development. + /// + /// + /// + /// Without this override there is no way to use HNS-dependent features (atomic moves, real + /// directory operations) in either of those scenarios. + /// public bool? UseHierarchicalNamespace { get; set; } /// From 89e7656b623ecda21f7896f07cc67ad33c674a17 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 2 Apr 2026 02:30:20 -0400 Subject: [PATCH 13/20] Move Azure Blob integration tests to a dedicated OrchardCore.Tests.Integration project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract BlobFileStore Gen1/Gen2 tests from OrchardCore.Tests into a new OrchardCore.Tests.Integration project. This separates integration tests that require external services (Azurite) from unit tests, avoiding unnecessary Docker container startup and extra execution time on every PR commit. The new project is built and run by a dedicated integration_tests.yml workflow that only triggers on review submission, push to main/release, or manual dispatch — matching the same pattern as functional_all_db.yml. Removed Azurite setup from pr_ci.yml and main_ci.yml. --- .github/workflows/integration_tests.yml | 43 +++++++++++++++++++ .github/workflows/main_ci.yml | 7 --- .github/workflows/pr_ci.yml | 7 --- OrchardCore.slnx | 1 + .../AzureBlob}/BlobFileStoreGen1Tests.cs | 2 +- .../AzureBlob}/BlobFileStoreGen2Tests.cs | 2 +- .../AzureBlob}/BlobFileStoreTestsBase.cs | 2 +- .../OrchardCore.Tests.Integration.csproj | 21 +++++++++ .../OrchardCore.Tests.csproj | 1 - 9 files changed, 68 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/integration_tests.yml rename test/{OrchardCore.Tests/Modules/OrchardCore.Media.Azure => OrchardCore.Tests.Integration/AzureBlob}/BlobFileStoreGen1Tests.cs (80%) rename test/{OrchardCore.Tests/Modules/OrchardCore.Media.Azure => OrchardCore.Tests.Integration/AzureBlob}/BlobFileStoreGen2Tests.cs (98%) rename test/{OrchardCore.Tests/Modules/OrchardCore.Media.Azure => OrchardCore.Tests.Integration/AzureBlob}/BlobFileStoreTestsBase.cs (99%) create mode 100644 test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 00000000000..74d686cc4da --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,43 @@ +name: Integration Tests + +on: + # Manual trigger. + workflow_dispatch: + pull_request_review: + types: [submitted] + # Run it on main and release pushes too, in case we merge from a branch that's not up-to-date with the target branch + # and breaks something after merge (or if we push to main). + push: + paths-ignore: + - '**/*.md' + - 'mkdocs.yml' + - 'src/docs/**/*' + branches: [ main, release/** ] + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +jobs: + test_azure_blob: + name: Integration Tests - Azure Blob Storage (Azurite) + if: github.event_name == 'workflow_dispatch' || + github.event_name == 'push' || + github.event.review.state == 'APPROVED' || + github.event.review.state == 'CHANGES_REQUESTED' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-dotnet + - name: Build + run: | + dotnet build -c Release ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj /p:NuGetAudit=false + - name: Start Azurite + run: | + docker run -d --name azurite -p 10000:10000 -p 10004:10004 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --dfsHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck + - name: Azure Blob Integration Tests + env: + AZURITE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" + AZURITE_DFS_ENDPOINT: "http://127.0.0.1:10004/devstoreaccount1" + run: | + dotnet test ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index 3b7656431bd..99decdfcc51 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -33,14 +33,7 @@ jobs: # See pr_ci.yml for the reason why we disable NuGet audit warnings. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false - - name: Start Azurite - if: matrix.os == 'ubuntu-24.04' - run: | - docker run -d --name azurite -p 10000:10000 -p 10004:10004 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --dfsHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck - name: Unit Tests - env: - AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} - AZURITE_DFS_ENDPOINT: ${{ matrix.os == 'ubuntu-24.04' && 'http://127.0.0.1:10004/devstoreaccount1' || '' }} run: | dotnet test --project ./test/OrchardCore.Tests/OrchardCore.Tests.csproj -c Release --no-build - name: Functional Tests diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index 6ec91947ddd..909b05184d1 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -30,14 +30,7 @@ jobs: # warnings and other better approaches don't work, see https://github.com/OrchardCMS/OrchardCore/pull/16317. run: | dotnet build -c Release /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false - - name: Start Azurite - if: matrix.os == 'ubuntu-24.04' - run: | - docker run -d --name azurite -p 10000:10000 -p 10004:10004 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --dfsHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck - name: Unit Tests - env: - AZURITE_CONNECTION_STRING: ${{ matrix.os == 'ubuntu-24.04' && 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;' || '' }} - AZURITE_DFS_ENDPOINT: ${{ matrix.os == 'ubuntu-24.04' && 'http://127.0.0.1:10004/devstoreaccount1' || '' }} run: | dotnet test --project ./test/OrchardCore.Tests/OrchardCore.Tests.csproj -c Release --no-build - name: Functional Tests diff --git a/OrchardCore.slnx b/OrchardCore.slnx index ddb4c52e87e..9a481512bd2 100644 --- a/OrchardCore.slnx +++ b/OrchardCore.slnx @@ -290,6 +290,7 @@ + diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen1Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs similarity index 80% rename from test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen1Tests.cs rename to test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs index adf89e609cd..f24903d88cf 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen1Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs @@ -1,4 +1,4 @@ -namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; +namespace OrchardCore.Tests.Integration.AzureBlob; /// /// Runs all tests with flat-namespace (Gen1) behavior. diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs similarity index 98% rename from test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs rename to test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs index f9df1f78f8a..801b5b41565 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreGen2Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs @@ -1,7 +1,7 @@ using OrchardCore.FileStorage; using Xunit; -namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; +namespace OrchardCore.Tests.Integration.AzureBlob; /// /// Runs all tests with hierarchical-namespace (Gen2 / ADLS) behavior. diff --git a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs similarity index 99% rename from test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs rename to test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs index ee82a9ba7f6..06fa974fcea 100644 --- a/test/OrchardCore.Tests/Modules/OrchardCore.Media.Azure/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs @@ -6,7 +6,7 @@ using OrchardCore.Modules; using Xunit; -namespace OrchardCore.Tests.Modules.OrchardCore.Media.Azure; +namespace OrchardCore.Tests.Integration.AzureBlob; /// /// Integration tests for that run against Azurite. diff --git a/test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj b/test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj new file mode 100644 index 00000000000..e07aab78abb --- /dev/null +++ b/test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj @@ -0,0 +1,21 @@ + + + + $(CommonTargetFrameworks) + Exe + enable + $(NoWarn);CA1707;EnableGenerateDocumentationFile + + + + + + + + + + + + + + diff --git a/test/OrchardCore.Tests/OrchardCore.Tests.csproj b/test/OrchardCore.Tests/OrchardCore.Tests.csproj index 72b8fd5e4f9..38bd749eac8 100644 --- a/test/OrchardCore.Tests/OrchardCore.Tests.csproj +++ b/test/OrchardCore.Tests/OrchardCore.Tests.csproj @@ -55,7 +55,6 @@ - From c0c186b887208795ad900500546eff1ca9330c6b Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 30 Apr 2026 15:11:26 -0400 Subject: [PATCH 14/20] Remove IFileStorageCapabilities Not necessary for this PR. --- .../IFileStore.cs | 12 +---- .../IFileStoreCapabilities.cs | 45 ------------------- .../AwsFileStorage.cs | 4 -- .../BlobFileStore.cs | 30 +++++-------- .../FileSystemStore.cs | 4 -- .../DefaultMediaFileStore.cs | 4 -- .../AzureBlob/BlobFileStoreGen2Tests.cs | 2 - .../AzureBlob/BlobFileStoreTestsBase.cs | 16 ------- 8 files changed, 12 insertions(+), 105 deletions(-) delete mode 100644 src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs index c29cca7cab7..062f8f6135f 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStore.cs @@ -149,20 +149,10 @@ async IAsyncEnumerable GetDirectoriesAsync(string path = null) Task CreateFileFromStreamAsync(string path, Stream inputStream, bool overwrite = false); /// - /// Calculates the free space available in this file store. + /// Calculates the free space available in this file store. /// /// The usable space in bytes, or if the space is unlimited. Task GetPermittedStorageAsync() => Task.FromResult(null); - - /// - /// Gets the capabilities supported by this file store. - /// - IFileStoreCapabilities Capabilities { get; } - - /// - /// Gets a human-readable name for this file store (e.g. "Local", "Azure Blob (Gen2)", "Amazon S3"). - /// - string StorageName => "File Store"; } public static class IFileStoreExtensions diff --git a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs b/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs deleted file mode 100644 index ce0415fdcda..00000000000 --- a/src/OrchardCore/OrchardCore.FileStorage.Abstractions/IFileStoreCapabilities.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace OrchardCore.FileStorage; - -/// -/// Describes the capabilities supported by a specific implementation. -/// -public interface IFileStoreCapabilities -{ - /// - /// Gets a value indicating whether the store has a true hierarchical namespace - /// (i.e. directories are first-class objects, not simulated via path prefixes). - /// - bool HasHierarchicalNamespace { get; } - - /// - /// Gets a value indicating whether the store supports atomic (server-side) move / rename - /// that does not require a copy-then-delete round-trip. - /// - bool SupportsAtomicMove { get; } - -} - -/// -/// Provides a default instance where all capabilities are false. -/// -public sealed class FileStoreCapabilities : IFileStoreCapabilities -{ - /// - /// A shared instance that returns false for every capability. - /// - public static readonly IFileStoreCapabilities Default = new FileStoreCapabilities(); - - public FileStoreCapabilities() - { - } - - public FileStoreCapabilities(bool hasHierarchicalNamespace, bool supportsAtomicMove) - { - HasHierarchicalNamespace = hasHierarchicalNamespace; - SupportsAtomicMove = supportsAtomicMove; - } - - public bool HasHierarchicalNamespace { get; } - - public bool SupportsAtomicMove { get; } -} diff --git a/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs b/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs index f6511df69f9..bc0c7a4b2c4 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AmazonS3/AwsFileStorage.cs @@ -15,10 +15,6 @@ public class AwsFileStore : IFileStore private readonly string _basePrefix; private readonly IAmazonS3 _amazonS3Client; - public string StorageName => "Amazon S3"; - - public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: false, supportsAtomicMove: false); - public AwsFileStore(IClock clock, AwsStorageOptions options, IAmazonS3 amazonS3Client) { _clock = clock; diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index b81c69932a6..e8a8f9ccfd3 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -49,16 +49,10 @@ public class BlobFileStore : IFileStore private readonly ILogger _logger; private readonly bool? _useHierarchicalNamespaceOverride; private readonly SemaphoreSlim _capabilitiesLock = new(1, 1); - private FileStoreCapabilities _capabilities; + private bool? _hnsEnabled; private readonly string _basePrefix; - public IFileStoreCapabilities Capabilities => _capabilities ?? FileStoreCapabilities.Default; - - public string StorageName => _capabilities is not null && _capabilities.HasHierarchicalNamespace - ? "Azure Blob (Gen2)" - : "Azure Blob (Gen1)"; - public BlobFileStore( BlobStorageOptions options, IClock clock, @@ -95,11 +89,11 @@ public BlobFileStore( /// /// Probes the storage account to determine whether Hierarchical Namespace (HNS) is enabled. - /// Must be called once at startup; after completion returns the detected values. + /// Must be called once at startup. /// public async Task EnsureCapabilitiesAsync() { - if (_capabilities is not null) + if (_hnsEnabled.HasValue) { return; } @@ -107,7 +101,7 @@ public async Task EnsureCapabilitiesAsync() await _capabilitiesLock.WaitAsync(); try { - if (_capabilities is not null) + if (_hnsEnabled.HasValue) { return; } @@ -130,11 +124,9 @@ public async Task EnsureCapabilitiesAsync() } } - _capabilities = new FileStoreCapabilities( - hasHierarchicalNamespace: hnsEnabled.Value, - supportsAtomicMove: hnsEnabled.Value); + _hnsEnabled = hnsEnabled.Value; - if (hnsEnabled.Value) + if (_hnsEnabled.Value) { _logger?.LogInformation("Azure Blob Storage Hierarchical Namespace (ADLS Gen2) detected. Using native directory and atomic move operations."); } @@ -172,7 +164,7 @@ public async Task GetFileInfoAsync(string path) public async Task GetDirectoryInfoAsync(string path) { - if (Capabilities.HasHierarchicalNamespace) + if (_hnsEnabled == true) { try { @@ -297,7 +289,7 @@ private async IAsyncEnumerable GetDirectoryContentFlatAsync(str var prefix = this.Combine(_basePrefix, path); prefix = NormalizePrefix(prefix); - if (Capabilities.HasHierarchicalNamespace) + if (_hnsEnabled == true) { var page = _blobContainer.GetBlobsAsync(BlobTraits.Metadata, BlobStates.None, prefix, CancellationToken.None); await foreach (var blob in page) @@ -388,7 +380,7 @@ private async IAsyncEnumerable GetDirectoryContentFlatAsync(str public async Task TryCreateDirectoryAsync(string path) { - if (Capabilities.HasHierarchicalNamespace) + if (_hnsEnabled == true) { try { @@ -451,7 +443,7 @@ public async Task TryDeleteFileAsync(string path) public async Task TryDeleteDirectoryAsync(string path) { - if (Capabilities.HasHierarchicalNamespace) + if (_hnsEnabled == true) { try { @@ -518,7 +510,7 @@ public async Task TryDeleteDirectoryAsync(string path) public async Task MoveFileAsync(string oldPath, string newPath) { - if (Capabilities.SupportsAtomicMove) + if (_hnsEnabled == true) { try { diff --git a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs index ffb740d8d72..92cadbf0489 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.FileSystem/FileSystemStore.cs @@ -8,10 +8,6 @@ public class FileSystemStore : IFileStore private readonly ILogger _logger; private readonly string _fileSystemPath; - public string StorageName => "Local"; - - public IFileStoreCapabilities Capabilities { get; } = new FileStoreCapabilities(hasHierarchicalNamespace: true, supportsAtomicMove: true); - public FileSystemStore(string fileSystemPath, ILogger logger) { _logger = logger; diff --git a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs index 0ae39696079..46aa6f3d86a 100644 --- a/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs +++ b/src/OrchardCore/OrchardCore.Media.Core/DefaultMediaFileStore.cs @@ -258,10 +258,6 @@ public virtual string MapPathToPublicUrl(string path) return context.PermittedStorage; } - public IFileStoreCapabilities Capabilities => _fileStore.Capabilities; - - public string StorageName => _fileStore.StorageName; - private void ValidateRequestBasePath(HttpContext httpContext) { var originalPathBase = httpContext.Features.Get()?.OriginalPathBase ?? PathString.Empty; diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs index 801b5b41565..42303d7c2e7 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs @@ -45,8 +45,6 @@ public async Task GetDirectoryContent_Flat_IncludesGen2Directories() public async Task MoveFile_IsAtomic() { // Gen2 move uses DataLake RenameAsync which is an atomic server-side operation. - Assert.True(Capabilities.SupportsAtomicMove); - await CreateTestFileAsync("atomic-src.txt", "atomic"); await MoveFileAsync("atomic-src.txt", "atomic-dst.txt"); diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs index 06fa974fcea..cfef61cd629 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs @@ -58,8 +58,6 @@ public async ValueTask DisposeAsync() } } - protected IFileStoreCapabilities Capabilities => _store.Capabilities; - protected async Task CreateTestFileAsync(string path, string content = "test content") { using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); @@ -91,20 +89,6 @@ protected IAsyncEnumerable GetDirectoryContentAsync(string path protected Task MoveFileAsync(string oldPath, string newPath) => _store.MoveFileAsync(oldPath, newPath); - // -- Capabilities -- - - [AzuriteFact] - public void EnsureCapabilities_SetsHnsFlag() - { - Assert.Equal(IsHnsEnabled, _store.Capabilities.HasHierarchicalNamespace); - } - - [AzuriteFact] - public void EnsureCapabilities_SetsAtomicMoveFlag() - { - Assert.Equal(IsHnsEnabled, _store.Capabilities.SupportsAtomicMove); - } - // -- File operations -- [AzuriteFact] From 08c3cf85198dd4648b26571f73b75a4801b917c3 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 30 Apr 2026 19:23:27 -0400 Subject: [PATCH 15/20] Serve DFS on blob port; remove DfsEndpoint from BlobStorageOptions The Azurite fork now serves DFS requests on the same port as blob, matching how Azure Blob Storage works in production (single endpoint, different hostnames). DataLakeServiceClient is created directly from the connection string, eliminating the need for a separate DfsEndpoint. - Remove DfsEndpoint from BlobStorageOptions and BlobFileStore - Remove ParseCredentialsFromConnectionString (no longer needed) - Update integration tests: auto-detect HNS via GetAccountInfo for Gen2, use UseHierarchicalNamespaceOverride=false only for Gen1 - Update CI: single port per Azurite container, no separate DFS port Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration_tests.yml | 9 +- .../BlobFileStore.cs | 102 ++++++++---------- .../BlobStorageOptions.cs | 36 ++----- .../AzureBlob/BlobFileStoreGen1Tests.cs | 2 +- .../AzureBlob/BlobFileStoreGen2Tests.cs | 16 +-- .../AzureBlob/BlobFileStoreTestsBase.cs | 42 ++++---- 6 files changed, 87 insertions(+), 120 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index f0b3b29ea33..93f41389349 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -78,13 +78,16 @@ jobs: - name: Build run: | dotnet build -c Release ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj /p:NuGetAudit=false - - name: Start Azurite + - name: Start Azurite (Gen1 — flat namespace) run: | - docker run -d --name azurite -p 10000:10000 -p 10004:10004 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --dfsHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --skipApiVersionCheck + docker run -d --name azurite-gen1 -p 10000:10000 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --skipApiVersionCheck --enableHierarchicalNamespace=false + - name: Start Azurite (Gen2 — hierarchical namespace) + run: | + docker run -d --name azurite-gen2 -p 11000:10000 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --skipApiVersionCheck --enableHierarchicalNamespace=true - name: Azure Blob Integration Tests env: AZURITE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" - AZURITE_DFS_ENDPOINT: "http://127.0.0.1:10004/devstoreaccount1" + AZURITE_GEN2_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:11000/devstoreaccount1;" run: | dotnet test ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index e8a8f9ccfd3..bd99f309ad0 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -3,7 +3,6 @@ using Azure; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using Azure.Storage; using Azure.Storage.Files.DataLake; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Logging; @@ -67,19 +66,8 @@ public BlobFileStore( _blobServiceClient = new BlobServiceClient(_options.ConnectionString); _useHierarchicalNamespaceOverride = options.UseHierarchicalNamespace; - if (!string.IsNullOrEmpty(_options.DfsEndpoint)) - { - // Use explicit DFS endpoint (required for local emulators like Azurite - // where the DFS endpoint runs on a separate port). - var credential = ParseCredentialsFromConnectionString(_options.ConnectionString); - var serviceClient = new DataLakeServiceClient(new Uri(_options.DfsEndpoint), credential); - _dataLakeFileSystemClient = serviceClient.GetFileSystemClient(_options.ContainerName); - } - else - { - var serviceClient = new DataLakeServiceClient(_options.ConnectionString); - _dataLakeFileSystemClient = serviceClient.GetFileSystemClient(_options.ContainerName); - } + var dataLakeServiceClient = new DataLakeServiceClient(_options.ConnectionString); + _dataLakeFileSystemClient = dataLakeServiceClient.GetFileSystemClient(_options.ContainerName); if (!string.IsNullOrEmpty(_options.BasePath)) { @@ -106,33 +94,53 @@ public async Task EnsureCapabilitiesAsync() return; } - var hnsEnabled = _useHierarchicalNamespaceOverride; - - if (!hnsEnabled.HasValue) + try { - try - { - var accountInfo = await _blobServiceClient.GetAccountInfoAsync(); - hnsEnabled = accountInfo.Value.IsHierarchicalNamespaceEnabled; - } - catch (Exception ex) + var accountInfo = await _blobServiceClient.GetAccountInfoAsync(); + var detectedHns = accountInfo.Value.IsHierarchicalNamespaceEnabled; + + if (_useHierarchicalNamespaceOverride.HasValue && _useHierarchicalNamespaceOverride.Value != detectedHns) { - // If we cannot determine HNS status (e.g., insufficient permissions on SAS token), - // default to false (flat namespace behavior). - _logger?.LogWarning(ex, "Unable to detect Azure Blob Storage Hierarchical Namespace status. Falling back to flat namespace behavior."); - hnsEnabled = false; - } - } + if (_useHierarchicalNamespaceOverride.Value) + { + // Claiming Gen2 on a Gen1 account — DataLake API calls will fail at runtime. + throw new FileStoreException( + "'UseHierarchicalNamespace' is set to 'true' but the storage account does not have " + + "Hierarchical Namespace enabled. Correct the configuration or use a Gen2 storage account."); + } - _hnsEnabled = hnsEnabled.Value; + // Override=false on a Gen2 account is safe but suboptimal. + _logger?.LogWarning( + "'UseHierarchicalNamespace' is set to 'false' but the storage account has Hierarchical Namespace enabled. " + + "Flat-namespace operations will be used, which means moves are not atomic and directory operations are less efficient. " + + "Remove the setting to use native Gen2 operations."); + } - if (_hnsEnabled.Value) + _hnsEnabled = _useHierarchicalNamespaceOverride ?? detectedHns; + _logger?.LogInformation(_hnsEnabled.Value + ? "Azure Blob Storage Hierarchical Namespace (ADLS Gen2) detected. Using native directory and atomic move operations." + : "Azure Blob Storage flat namespace detected. Using standard blob operations with virtual directories."); + } + catch (FileStoreException) { - _logger?.LogInformation("Azure Blob Storage Hierarchical Namespace (ADLS Gen2) detected. Using native directory and atomic move operations."); + throw; } - else + catch (Exception ex) when (_useHierarchicalNamespaceOverride.HasValue) { - _logger?.LogInformation("Azure Blob Storage flat namespace detected. Using standard blob operations with virtual directories."); + // GetAccountInfo failed (e.g. container-scoped SAS token) but an explicit override + // is configured, so trust it and proceed. + _hnsEnabled = _useHierarchicalNamespaceOverride.Value; + _logger?.LogWarning(ex, + "Unable to validate the Azure Blob Storage account type. " + + "Proceeding with 'UseHierarchicalNamespace' set to '{HnsEnabled}' from configuration.", + _hnsEnabled.Value); + } + catch (Exception ex) + { + throw new FileStoreException( + "Unable to determine the Azure Blob Storage account type (Gen1 flat namespace or Gen2 Hierarchical Namespace). " + + "This is required to select the correct storage operations. " + + "If you are using a container-scoped SAS token, set 'UseHierarchicalNamespace' explicitly in your configuration.", ex); } } finally @@ -688,32 +696,6 @@ private async Task CreateDirectoryAsync(string path) await placeholderBlob.UploadAsync(stream); } - private static StorageSharedKeyCredential ParseCredentialsFromConnectionString(string connectionString) - { - string accountName = null; - string accountKey = null; - - foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) - { - var kvp = part.Split('=', 2); - if (kvp.Length == 2) - { - if (kvp[0].Equals("AccountName", StringComparison.OrdinalIgnoreCase)) - { - accountName = kvp[1]; - } - else if (kvp[0].Equals("AccountKey", StringComparison.OrdinalIgnoreCase)) - { - accountKey = kvp[1]; - } - } - } - - return new StorageSharedKeyCredential( - accountName ?? throw new FileStoreException("AccountName not found in connection string."), - accountKey ?? throw new FileStoreException("AccountKey not found in connection string.")); - } - /// /// Blob prefix requires a trailing slash except when loading the root of the container. /// diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs index 446233180d5..8588e12e971 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobStorageOptions.cs @@ -19,41 +19,17 @@ public abstract class BlobStorageOptions /// /// Overrides auto-detection of Hierarchical Namespace (HNS / ADLS Gen2) support. - /// Set to true to force HNS-aware behavior, false to force flat-namespace behavior, - /// or leave null (default) to auto-detect from the storage account at startup. + /// Leave null (default) to auto-detect via GetAccountInfo at startup. + /// Set to true or false to skip detection and force the behavior explicitly. /// /// - /// Auto-detection calls GetAccountInfoAsync(), which requires storage account-level - /// permissions. You must set this explicitly when: - /// - /// - /// - /// SAS tokens — A container-scoped SAS token does not have permission to call - /// GetAccountInfo, so detection fails and falls back to flat-namespace behavior. - /// Set to true if the account is actually Gen2/HNS-enabled. - /// - /// - /// - /// - /// Local emulators (Azurite) — Azurite does not implement the GetAccountInfo - /// endpoint. Set to true together with to enable - /// HNS simulation in local development. - /// - /// - /// - /// Without this override there is no way to use HNS-dependent features (atomic moves, real - /// directory operations) in either of those scenarios. + /// Auto-detection requires storage account-level permissions. If you are using a + /// container-scoped SAS token, detection will fail and startup will be blocked. + /// In that case, set this to true for a Gen2 (HNS-enabled) account, or + /// false for a Gen1 (flat namespace) account. /// public bool? UseHierarchicalNamespace { get; set; } - /// - /// Optional DFS (Data Lake Storage) endpoint URL for Gen2 operations. - /// When set, the uses this - /// endpoint instead of deriving one from the connection string. - /// Required for local emulators (e.g. Azurite) where the DFS endpoint runs on a separate port. - /// - public string DfsEndpoint { get; set; } - /// /// Returns a value indicating whether the basic state of the configuration is valid. /// diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs index f24903d88cf..71c81cc3ed5 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs @@ -5,5 +5,5 @@ namespace OrchardCore.Tests.Integration.AzureBlob; /// public sealed class BlobFileStoreGen1Tests : BlobFileStoreTestsBase { - protected override bool IsHnsEnabled => false; + protected override bool? UseHierarchicalNamespaceOverride => false; } diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs index 42303d7c2e7..f9c382b7502 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs @@ -4,14 +4,14 @@ namespace OrchardCore.Tests.Integration.AzureBlob; /// -/// Runs all tests with hierarchical-namespace (Gen2 / ADLS) behavior. -/// Uses UseHierarchicalNamespace = true to force Gen2 code paths in Azurite. +/// Runs all tests against the Gen2 Azurite instance. +/// Auto-detection via GetAccountInfo is used — no UseHierarchicalNamespace override. /// public sealed class BlobFileStoreGen2Tests : BlobFileStoreTestsBase { - protected override bool IsHnsEnabled => true; + protected override string ConnectionStringOverrideEnvVar => "AZURITE_GEN2_CONNECTION_STRING"; - [AzuriteFact] + [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] public async Task CreateDirectory_Nested_CreatesIntermediateDirectories() { await TryCreateDirectoryAsync("a/b/c"); @@ -21,7 +21,7 @@ public async Task CreateDirectory_Nested_CreatesIntermediateDirectories() Assert.NotNull(await GetDirectoryInfoAsync("a")); } - [AzuriteFact] + [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] public async Task GetDirectoryContent_Flat_IncludesGen2Directories() { await TryCreateDirectoryAsync("flat-gen2"); @@ -41,7 +41,7 @@ public async Task GetDirectoryContent_Flat_IncludesGen2Directories() Assert.Contains(entries, e => !e.IsDirectory && e.Name == "nested.txt"); } - [AzuriteFact] + [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] public async Task MoveFile_IsAtomic() { // Gen2 move uses DataLake RenameAsync which is an atomic server-side operation. @@ -54,7 +54,7 @@ public async Task MoveFile_IsAtomic() Assert.Equal("atomic", await ReadFileContentAsync("atomic-dst.txt")); } - [AzuriteFact] + [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] public async Task GetDirectoryInfo_AfterDeletingDirectory_ReturnsNull() { await TryCreateDirectoryAsync("temp-dir"); @@ -65,7 +65,7 @@ public async Task GetDirectoryInfo_AfterDeletingDirectory_ReturnsNull() Assert.Null(await GetDirectoryInfoAsync("temp-dir")); } - [AzuriteFact] + [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] public async Task CreateDirectory_EmptyDirectory_ExistsWithNoContent() { await TryCreateDirectoryAsync("empty-gen2-dir"); diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs index cfef61cd629..06f9dca778a 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs @@ -14,21 +14,28 @@ namespace OrchardCore.Tests.Integration.AzureBlob; /// public abstract class BlobFileStoreTestsBase : IAsyncLifetime { - private const string EnvVar = "AZURITE_CONNECTION_STRING"; - private const string DfsEnvVar = "AZURITE_DFS_ENDPOINT"; + private const string ConnectionStringEnvVar = "AZURITE_CONNECTION_STRING"; + private const string Gen2ConnectionStringEnvVar = "AZURITE_GEN2_CONNECTION_STRING"; - protected abstract bool IsHnsEnabled { get; } + /// + /// Override for . + /// null (default) lets auto-detect + /// via GetAccountInfo. Set to false to force flat-namespace (Gen1) behavior. + /// + protected virtual bool? UseHierarchicalNamespaceOverride => null; + + /// + /// Override to use the Gen2-specific connection string env var. + /// + protected virtual string ConnectionStringOverrideEnvVar => ConnectionStringEnvVar; private BlobFileStore _store; private BlobContainerClient _containerClient; private string _containerName; - protected static string GetConnectionString() - => System.Environment.GetEnvironmentVariable(EnvVar); - public async ValueTask InitializeAsync() { - var connectionString = GetConnectionString(); + var connectionString = System.Environment.GetEnvironmentVariable(ConnectionStringOverrideEnvVar); _containerName = $"test-{Guid.NewGuid():N}"; var options = new TestBlobStorageOptions @@ -36,8 +43,7 @@ public async ValueTask InitializeAsync() ConnectionString = connectionString, ContainerName = _containerName, BasePath = "", - UseHierarchicalNamespace = IsHnsEnabled, - DfsEndpoint = System.Environment.GetEnvironmentVariable(DfsEnvVar), + UseHierarchicalNamespace = UseHierarchicalNamespaceOverride, }; _containerClient = new BlobContainerClient(connectionString, _containerName); @@ -357,15 +363,15 @@ public async Task CreateDirectory_AlreadyExists_ReturnsFalse() // Creating the same directory again should indicate it already existed. var result = await _store.TryCreateDirectoryAsync("existing-dir"); - // Gen1 always returns true (no real directory to check). - // Gen2 returns false because the directory already exists. - if (IsHnsEnabled) + // Gen1 (flat namespace) always returns true — no real directory object to check against. + // Gen2 (HNS) returns false because the directory already exists as a first-class object. + if (UseHierarchicalNamespaceOverride == false) { - Assert.False(result); + Assert.True(result); } else { - Assert.True(result); + Assert.False(result); } } @@ -431,20 +437,20 @@ public async Task MoveFile_PreservesContent() internal sealed class TestBlobStorageOptions : BlobStorageOptions; /// -/// Skips the test when the Azurite connection string is not configured. -/// Set the AZURITE_CONNECTION_STRING environment variable to run these tests. +/// Skips the test when the required Azurite connection string env var is not set. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] internal sealed class AzuriteFactAttribute : FactAttribute { public AzuriteFactAttribute( + string connectionStringEnvVar = "AZURITE_CONNECTION_STRING", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = null, [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) : base(sourceFilePath, sourceLineNumber) { - if (string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("AZURITE_CONNECTION_STRING"))) + if (string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable(connectionStringEnvVar))) { - Skip = "Azurite is not configured. Set AZURITE_CONNECTION_STRING to run this test."; + Skip = $"Azurite is not configured. Set {connectionStringEnvVar} to run this test."; } } } From f0e47b967d6edab15d6df435236bf5d2145a90e4 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 30 Apr 2026 19:31:14 -0400 Subject: [PATCH 16/20] Simplify integration tests to single Azurite instance and connection string Gen1 tests use UseHierarchicalNamespaceOverride=false against an HNS-enabled Azurite, which triggers the expected warning and forces flat-namespace code paths. Gen2 tests rely on auto-detection. No separate port or connection string needed. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration_tests.yml | 8 ++------ .../AzureBlob/BlobFileStoreGen2Tests.cs | 14 ++++++-------- .../AzureBlob/BlobFileStoreTestsBase.cs | 18 ++++++------------ 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 93f41389349..b9da0df83dc 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -78,16 +78,12 @@ jobs: - name: Build run: | dotnet build -c Release ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj /p:NuGetAudit=false - - name: Start Azurite (Gen1 — flat namespace) + - name: Start Azurite run: | - docker run -d --name azurite-gen1 -p 10000:10000 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --skipApiVersionCheck --enableHierarchicalNamespace=false - - name: Start Azurite (Gen2 — hierarchical namespace) - run: | - docker run -d --name azurite-gen2 -p 11000:10000 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --skipApiVersionCheck --enableHierarchicalNamespace=true + docker run -d --name azurite -p 10000:10000 ghcr.io/skrypt/azurite-adls-gen2:latest azurite --blobHost 0.0.0.0 --skipApiVersionCheck --enableHierarchicalNamespace=true - name: Azure Blob Integration Tests env: AZURITE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" - AZURITE_GEN2_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:11000/devstoreaccount1;" run: | dotnet test ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs index f9c382b7502..4b92e5af84f 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs @@ -4,14 +4,12 @@ namespace OrchardCore.Tests.Integration.AzureBlob; /// -/// Runs all tests against the Gen2 Azurite instance. +/// Runs all tests with Gen2 (HNS) behavior. /// Auto-detection via GetAccountInfo is used — no UseHierarchicalNamespace override. /// public sealed class BlobFileStoreGen2Tests : BlobFileStoreTestsBase { - protected override string ConnectionStringOverrideEnvVar => "AZURITE_GEN2_CONNECTION_STRING"; - - [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] + [AzuriteFact] public async Task CreateDirectory_Nested_CreatesIntermediateDirectories() { await TryCreateDirectoryAsync("a/b/c"); @@ -21,7 +19,7 @@ public async Task CreateDirectory_Nested_CreatesIntermediateDirectories() Assert.NotNull(await GetDirectoryInfoAsync("a")); } - [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] + [AzuriteFact] public async Task GetDirectoryContent_Flat_IncludesGen2Directories() { await TryCreateDirectoryAsync("flat-gen2"); @@ -41,7 +39,7 @@ public async Task GetDirectoryContent_Flat_IncludesGen2Directories() Assert.Contains(entries, e => !e.IsDirectory && e.Name == "nested.txt"); } - [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] + [AzuriteFact] public async Task MoveFile_IsAtomic() { // Gen2 move uses DataLake RenameAsync which is an atomic server-side operation. @@ -54,7 +52,7 @@ public async Task MoveFile_IsAtomic() Assert.Equal("atomic", await ReadFileContentAsync("atomic-dst.txt")); } - [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] + [AzuriteFact] public async Task GetDirectoryInfo_AfterDeletingDirectory_ReturnsNull() { await TryCreateDirectoryAsync("temp-dir"); @@ -65,7 +63,7 @@ public async Task GetDirectoryInfo_AfterDeletingDirectory_ReturnsNull() Assert.Null(await GetDirectoryInfoAsync("temp-dir")); } - [AzuriteFact("AZURITE_GEN2_CONNECTION_STRING")] + [AzuriteFact] public async Task CreateDirectory_EmptyDirectory_ExistsWithNoContent() { await TryCreateDirectoryAsync("empty-gen2-dir"); diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs index 06f9dca778a..9c44bdb0ca1 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs @@ -15,27 +15,22 @@ namespace OrchardCore.Tests.Integration.AzureBlob; public abstract class BlobFileStoreTestsBase : IAsyncLifetime { private const string ConnectionStringEnvVar = "AZURITE_CONNECTION_STRING"; - private const string Gen2ConnectionStringEnvVar = "AZURITE_GEN2_CONNECTION_STRING"; /// /// Override for . /// null (default) lets auto-detect - /// via GetAccountInfo. Set to false to force flat-namespace (Gen1) behavior. + /// via GetAccountInfo. Set to false to force flat-namespace (Gen1) behavior + /// against an HNS-enabled Azurite instance. /// protected virtual bool? UseHierarchicalNamespaceOverride => null; - /// - /// Override to use the Gen2-specific connection string env var. - /// - protected virtual string ConnectionStringOverrideEnvVar => ConnectionStringEnvVar; - private BlobFileStore _store; private BlobContainerClient _containerClient; private string _containerName; public async ValueTask InitializeAsync() { - var connectionString = System.Environment.GetEnvironmentVariable(ConnectionStringOverrideEnvVar); + var connectionString = System.Environment.GetEnvironmentVariable(ConnectionStringEnvVar); _containerName = $"test-{Guid.NewGuid():N}"; var options = new TestBlobStorageOptions @@ -437,20 +432,19 @@ public async Task MoveFile_PreservesContent() internal sealed class TestBlobStorageOptions : BlobStorageOptions; /// -/// Skips the test when the required Azurite connection string env var is not set. +/// Skips the test when AZURITE_CONNECTION_STRING is not set. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] internal sealed class AzuriteFactAttribute : FactAttribute { public AzuriteFactAttribute( - string connectionStringEnvVar = "AZURITE_CONNECTION_STRING", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = null, [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) : base(sourceFilePath, sourceLineNumber) { - if (string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable(connectionStringEnvVar))) + if (string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("AZURITE_CONNECTION_STRING"))) { - Skip = $"Azurite is not configured. Set {connectionStringEnvVar} to run this test."; + Skip = "Azurite is not configured. Set AZURITE_CONNECTION_STRING to run this test."; } } } From fb6bcb62aef7c8fd6508817ef91bb9c3fa6fe842 Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 30 Apr 2026 19:35:27 -0400 Subject: [PATCH 17/20] Fix CA2254: split ternary log message into separate LogInformation calls Co-Authored-By: Claude Sonnet 4.6 --- .../BlobFileStore.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs index bd99f309ad0..67c7d85032c 100644 --- a/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs +++ b/src/OrchardCore/OrchardCore.FileStorage.AzureBlob/BlobFileStore.cs @@ -117,9 +117,14 @@ public async Task EnsureCapabilitiesAsync() } _hnsEnabled = _useHierarchicalNamespaceOverride ?? detectedHns; - _logger?.LogInformation(_hnsEnabled.Value - ? "Azure Blob Storage Hierarchical Namespace (ADLS Gen2) detected. Using native directory and atomic move operations." - : "Azure Blob Storage flat namespace detected. Using standard blob operations with virtual directories."); + if (_hnsEnabled.Value) + { + _logger?.LogInformation("Azure Blob Storage Hierarchical Namespace (ADLS Gen2) detected. Using native directory and atomic move operations."); + } + else + { + _logger?.LogInformation("Azure Blob Storage flat namespace detected. Using standard blob operations with virtual directories."); + } } catch (FileStoreException) { From d3442fc4c2c3a1e748afca14502fb92337cd746f Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 30 Apr 2026 19:45:57 -0400 Subject: [PATCH 18/20] Create Gen2 test containers via DataLake SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gen2 tests now create their containers using DataLakeFileSystemClient, which sends x-ms-namespace-enabled: true and stamps the container as an HNS filesystem — matching how Gen2 filesystems are created in production Azure. Gen1 tests continue using BlobContainerClient. Co-Authored-By: Claude Sonnet 4.6 --- .../AzureBlob/BlobFileStoreGen2Tests.cs | 6 ++++++ .../AzureBlob/BlobFileStoreTestsBase.cs | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs index 4b92e5af84f..ffa638bcdb6 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs @@ -1,3 +1,4 @@ +using Azure.Storage.Files.DataLake; using OrchardCore.FileStorage; using Xunit; @@ -9,6 +10,11 @@ namespace OrchardCore.Tests.Integration.AzureBlob; /// public sealed class BlobFileStoreGen2Tests : BlobFileStoreTestsBase { + protected override async Task CreateContainerAsync(string connectionString, string containerName) + => await new DataLakeServiceClient(connectionString) + .GetFileSystemClient(containerName) + .CreateIfNotExistsAsync(); + [AzuriteFact] public async Task CreateDirectory_Nested_CreatesIntermediateDirectories() { diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs index 9c44bdb0ca1..7e7ceaeb351 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs @@ -24,6 +24,13 @@ public abstract class BlobFileStoreTestsBase : IAsyncLifetime /// protected virtual bool? UseHierarchicalNamespaceOverride => null; + /// + /// Creates the test container. Gen1 uses the Blob SDK (flat namespace). + /// Gen2 subclasses override to use the DataLake SDK, which stamps the container as an HNS filesystem. + /// + protected virtual async Task CreateContainerAsync(string connectionString, string containerName) + => await new BlobContainerClient(connectionString, containerName).CreateIfNotExistsAsync(); + private BlobFileStore _store; private BlobContainerClient _containerClient; private string _containerName; @@ -41,8 +48,8 @@ public async ValueTask InitializeAsync() UseHierarchicalNamespace = UseHierarchicalNamespaceOverride, }; + await CreateContainerAsync(connectionString, _containerName); _containerClient = new BlobContainerClient(connectionString, _containerName); - await _containerClient.CreateIfNotExistsAsync(); var clock = Mock.Of(c => c.UtcNow == DateTime.UtcNow); var contentTypeProvider = new FileExtensionContentTypeProvider(); From fa2c62fcdccd36fea2580db1229382f7bb5549fb Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 30 Apr 2026 19:50:30 -0400 Subject: [PATCH 19/20] Add marker-file tests to prove Gen1 vs Gen2 storage behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gen1: TryCreateDirectoryAsync writes a marker blob (OrchardCore.Media.txt) to simulate directories in flat namespace storage. Gen2: TryCreateDirectoryAsync uses the DataLake API to create a real directory object — no marker blob is written. These tests assert the raw blob listing directly, proving Azurite is routing to the correct pipeline for each storage generation. Co-Authored-By: Claude Sonnet 4.6 --- .../AzureBlob/BlobFileStoreGen1Tests.cs | 18 ++++++++++++++++++ .../AzureBlob/BlobFileStoreGen2Tests.cs | 15 +++++++++++++++ .../AzureBlob/BlobFileStoreTestsBase.cs | 2 ++ 3 files changed, 35 insertions(+) diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs index 71c81cc3ed5..6ea301c36a5 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen1Tests.cs @@ -1,3 +1,5 @@ +using Xunit; + namespace OrchardCore.Tests.Integration.AzureBlob; /// @@ -6,4 +8,20 @@ namespace OrchardCore.Tests.Integration.AzureBlob; public sealed class BlobFileStoreGen1Tests : BlobFileStoreTestsBase { protected override bool? UseHierarchicalNamespaceOverride => false; + + [AzuriteFact] + public async Task CreateDirectory_UsesMarkerFile() + { + await TryCreateDirectoryAsync("gen1-dir"); + + // Gen1 simulates directories with a marker blob — verify it exists. + var blobs = new List(); + await foreach (var blob in ContainerClient.GetBlobsAsync(Azure.Storage.Blobs.Models.BlobTraits.None, Azure.Storage.Blobs.Models.BlobStates.None, "gen1-dir/", CancellationToken.None)) + { + blobs.Add(blob.Name); + } + + Assert.Single(blobs); + Assert.EndsWith("OrchardCore.Media.txt", blobs[0]); + } } diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs index ffa638bcdb6..772c7dd73ac 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreGen2Tests.cs @@ -15,6 +15,21 @@ protected override async Task CreateContainerAsync(string connectionString, stri .GetFileSystemClient(containerName) .CreateIfNotExistsAsync(); + [AzuriteFact] + public async Task CreateDirectory_UsesNoMarkerFile() + { + await TryCreateDirectoryAsync("gen2-dir"); + + // Gen2 creates real directory objects via the DataLake API — no marker blob. + var blobs = new List(); + await foreach (var blob in ContainerClient.GetBlobsAsync(Azure.Storage.Blobs.Models.BlobTraits.None, Azure.Storage.Blobs.Models.BlobStates.None, "gen2-dir/", CancellationToken.None)) + { + blobs.Add(blob.Name); + } + + Assert.Empty(blobs); + } + [AzuriteFact] public async Task CreateDirectory_Nested_CreatesIntermediateDirectories() { diff --git a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs index 7e7ceaeb351..e7d7c538c7f 100644 --- a/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs +++ b/test/OrchardCore.Tests.Integration/AzureBlob/BlobFileStoreTestsBase.cs @@ -35,6 +35,8 @@ protected virtual async Task CreateContainerAsync(string connectionString, strin private BlobContainerClient _containerClient; private string _containerName; + protected BlobContainerClient ContainerClient => _containerClient; + public async ValueTask InitializeAsync() { var connectionString = System.Environment.GetEnvironmentVariable(ConnectionStringEnvVar); From f572bd250b51b7ec8c8eb90a3f87bb83edb259ea Mon Sep 17 00:00:00 2001 From: Jasmin Savard Date: Thu, 30 Apr 2026 20:25:27 -0400 Subject: [PATCH 20/20] Fix dotnet test syntax: use --project flag Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/integration_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index b9da0df83dc..91a3a9a4b81 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -85,5 +85,5 @@ jobs: env: AZURITE_CONNECTION_STRING: "DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;" run: | - dotnet test ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build + dotnet test --project ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build