Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2b469e4
Add Azure Blob Storage Gen2 (ADLS HNS) support to BlobFileStore
Skrypt Mar 16, 2026
1f5d2a0
Add BlobFileStore integration tests for Gen1 and Gen2 code paths
Skrypt Mar 16, 2026
06a1192
Add DFS endpoint support and fix Gen2 error handling in BlobFileStore
Skrypt Mar 17, 2026
7be25b8
Update unit tests after updating Azurite
Skrypt Mar 18, 2026
71ced65
Update GH workflows with temp Azurite package repo
Skrypt Mar 18, 2026
256835f
skipApiVersionCheck
Skrypt Mar 18, 2026
121557d
Merge branch 'main' into skrypt/media-azure-gen2
Piedone Mar 24, 2026
03c0980
Merge branch 'main' into skrypt/media-azure-gen2
Skrypt Mar 28, 2026
8504bb4
fix: Replace sync-over-async EnsureCapabilitiesAsync call in Azure me…
Skrypt Mar 28, 2026
7d16f4f
fix: Remove StorageProvider from test broken by IFileStoreCapabilitie…
Skrypt Mar 28, 2026
858be10
fix: Set correct capabilities on FileSystemStore to reflect actual fi…
Skrypt Mar 29, 2026
d8c9ebb
Add IFileStore.StorageName property for runtime storage provider iden…
Skrypt Mar 29, 2026
aa477e6
fix build
Skrypt Mar 29, 2026
56b294a
add documentation on added configuration
Skrypt Mar 29, 2026
89e7656
Move Azure Blob integration tests to a dedicated OrchardCore.Tests.In…
Skrypt Apr 2, 2026
7c01e5c
Merge branch 'main' into skrypt/media-azure-gen2
Skrypt Apr 2, 2026
3a44b79
Merge branch 'main' into skrypt/media-azure-gen2
Skrypt Apr 14, 2026
c0c186b
Remove IFileStorageCapabilities
Skrypt Apr 30, 2026
08c3cf8
Serve DFS on blob port; remove DfsEndpoint from BlobStorageOptions
Skrypt Apr 30, 2026
f0e47b9
Simplify integration tests to single Azurite instance and connection …
Skrypt Apr 30, 2026
fb6bcb6
Fix CA2254: split ternary log message into separate LogInformation calls
Skrypt Apr 30, 2026
d3442fc
Create Gen2 test containers via DataLake SDK
Skrypt Apr 30, 2026
fa2c62f
Add marker-file tests to prove Gen1 vs Gen2 storage behavior
Skrypt Apr 30, 2026
d91f49b
Merge branch 'main' into skrypt/media-azure-gen2
Skrypt Apr 30, 2026
f572bd2
Fix dotnet test syntax: use --project flag
Skrypt May 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/integration_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,25 @@ jobs:
S3_EMULATOR_URL: http://127.0.0.1:9090
run: |
dotnet test --project ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build
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 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;"
run: |
dotnet test --project ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build

1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
<PackageVersion Include="Azure.Search.Documents" Version="11.7.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageVersion Include="Azure.Storage.Files.DataLake" Version="12.19.1" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="Castle.Core" Version="5.2.1" />
<PackageVersion Include="DocumentFormat.OpenXml" Version="3.5.1" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,25 +15,30 @@ 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;

public MediaBlobContainerTenantEvents(
IOptions<MediaBlobStorageOptions> options,
ShellSettings shellSettings,
BlobFileStore blobFileStore,
IStringLocalizer<MediaBlobContainerTenantEvents> localizer,
ILogger<MediaBlobContainerTenantEvents> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,6 @@ protected override void FurtherConfigure(MediaBlobStorageOptions rawOptions, Med
{
options.RemoveContainer = rawOptions.RemoveContainer;
options.RemoveFilesFromBasePath = rawOptions.RemoveFilesFromBasePath;
options.UseHierarchicalNamespace = rawOptions.UseHierarchicalNamespace;
Comment thread
Skrypt marked this conversation as resolved.
}
}
17 changes: 12 additions & 5 deletions src/OrchardCore.Modules/OrchardCore.Media.Azure/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,20 +84,27 @@ public override void ConfigureServices(IServiceCollection services)
services.AddSingleton<IMediaFileStoreCache>(serviceProvider =>
serviceProvider.GetRequiredService<IMediaFileStoreCacheFileProvider>());

// Register the blob file store as a singleton so it can be injected for async initialization.
services.AddSingleton(serviceProvider =>
{
var blobStorageOptions = serviceProvider.GetRequiredService<IOptions<MediaBlobStorageOptions>>().Value;
var clock = serviceProvider.GetRequiredService<IClock>();
var contentTypeProvider = serviceProvider.GetRequiredService<IContentTypeProvider>();
var blobLogger = serviceProvider.GetRequiredService<ILogger<BlobFileStore>>();

return new BlobFileStore(blobStorageOptions, clock, contentTypeProvider, blobLogger);
});

// Replace the default media file store with a blob file store.
services.Replace(ServiceDescriptor.Singleton<IMediaFileStore>(serviceProvider =>
{
var blobStorageOptions = serviceProvider.GetRequiredService<IOptions<MediaBlobStorageOptions>>().Value;
var shellOptions = serviceProvider.GetRequiredService<IOptions<ShellOptions>>();
var fileStore = serviceProvider.GetRequiredService<BlobFileStore>();
var shellSettings = serviceProvider.GetRequiredService<ShellSettings>();
var mediaOptions = serviceProvider.GetRequiredService<IOptions<MediaOptions>>().Value;
var clock = serviceProvider.GetRequiredService<IClock>();
var contentTypeProvider = serviceProvider.GetRequiredService<IContentTypeProvider>();
var mediaEventHandlers = serviceProvider.GetServices<IMediaEventHandler>();
var mediaCreatingEventHandlers = serviceProvider.GetServices<IMediaCreatingEventHandler>();
var logger = serviceProvider.GetRequiredService<ILogger<DefaultMediaFileStore>>();

var fileStore = new BlobFileStore(blobStorageOptions, clock, contentTypeProvider);
var mediaUrlBase = "/" + fileStore.Combine(shellSettings.RequestUrlPrefix, mediaOptions.AssetsRequestPath);

var originalPathBase = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,46 @@ public interface IFileStore
/// </remarks>
IAsyncEnumerable<IFileStoreEntry> GetDirectoryContentAsync(string path = null, bool includeSubDirectories = false);

/// <summary>
/// Enumerates only the files (not directories) in a given directory within the file store.
/// </summary>
/// <param name="path">The path of the directory to enumerate, or <c>null</c> to enumerate the root of the file store.</param>
/// <returns>The list of files in the given directory.</returns>
/// <remarks>
/// Default implementation filters <see cref="GetDirectoryContentAsync"/>. Implementations
/// may override this to avoid enumerating directories for better performance.
/// </remarks>
async IAsyncEnumerable<IFileStoreEntry> GetFilesAsync(string path = null)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is supposed to be optimized in the implementations. For instance if a storage contains 1M files and 1 folder , we can now get the folder without listing the files.

However this is not implemented in concrete implementations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the implementation details that are different in a table on the PR description. It will be easier for you to review that way.

{
await foreach (var entry in GetDirectoryContentAsync(path))
{
if (!entry.IsDirectory)
{
yield return entry;
}
}
}

/// <summary>
/// Enumerates only the subdirectories in a given directory within the file store.
/// </summary>
/// <param name="path">The path of the directory to enumerate, or <c>null</c> to enumerate the root of the file store.</param>
/// <returns>The list of subdirectories in the given directory.</returns>
/// <remarks>
/// Default implementation filters <see cref="GetDirectoryContentAsync"/>. Implementations
/// may override this to avoid enumerating files for better performance.
/// </remarks>
async IAsyncEnumerable<IFileStoreEntry> GetDirectoriesAsync(string path = null)
{
await foreach (var entry in GetDirectoryContentAsync(path))
{
if (entry.IsDirectory)
{
yield return entry;
}
}
}

/// <summary>
/// Creates a directory in the file store if it doesn't already exist.
/// </summary>
Expand Down Expand Up @@ -109,7 +149,7 @@ public interface IFileStore
Task<string> CreateFileFromStreamAsync(string path, Stream inputStream, bool overwrite = false);

/// <summary>
/// Calculates the free space available in this file store.
/// Calculates the free space available in this file store.
/// </summary>
/// <returns>The usable space in bytes, or <see langword="null"/> if the space is unlimited.</returns>
Task<long?> GetPermittedStorageAsync() => Task.FromResult<long?>(null);
Expand Down
Loading
Loading