From 43fefa2e13ae564a018ab323cb929a306b01afd4 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Wed, 30 Sep 2026 22:03:27 -0500 Subject: [PATCH 1/3] feat: use S3 media storage and MinIO for local development --- README.md | 13 +- .../MediaStorageConfigurationTests.cs | 98 ++++++++++ ...ma.Umbraco.StorageProviders.AzureBlob.json | 170 ------------------ SgfDevs.Tests/appsettings-schema.json | 3 - SgfDevs/Program.cs | 10 -- SgfDevs/Properties/launchSettings.json | 10 +- SgfDevs/SgfDevs.csproj | 4 +- ...ma.Umbraco.StorageProviders.AzureBlob.json | 170 ------------------ SgfDevs/appsettings-schema.json | 3 - SgfDevs/appsettings.Development.json | 13 +- SgfDevs/appsettings.json | 17 +- SgfDevs/appsettings.json.bak | 3 - compose.yaml | 39 ++++ 13 files changed, 188 insertions(+), 365 deletions(-) create mode 100644 SgfDevs.Tests/MediaStorageConfigurationTests.cs delete mode 100755 SgfDevs.Tests/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json delete mode 100755 SgfDevs/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json create mode 100644 compose.yaml diff --git a/README.md b/README.md index dbe5b902..0fb247a3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ There are a couple of ways to run this project depending on if you have a .NET I ### Environment Specific Steps +Local media uses MinIO. With Docker Compose installed, run from the repo root: + +```sh +docker compose up -d minio +docker compose run --rm minio-init +``` + +The development launch profiles supply local-only credentials. The console is at +`http://localhost:9001`, using `sgf-dev-local` / `sgf-dev-local-password`. +Media persists across restarts; `docker compose down --volumes` deletes it. + - Create an `Umbraco.sqlite.db` file in the `./SgfDevs/umbraco/Data` directory - Mac OS/Linux `mkdir -p ./SgfDevs/umbraco/Data && touch ./SgfDevs/umbraco/Data/Umbraco.sqlite.db` - Windows `New-Item -ItemType Directory -Force -Path .\SgfDevs\umbraco\Data; New-Item -ItemType File -Force -Path .\SgfDevs\umbraco\Data\Umbraco.sqlite.db` @@ -20,7 +31,7 @@ There are a couple of ways to run this project depending on if you have a .NET I - Navigate to the SgfDevs project folder `cd SgfDevs` - Use the `dotnet user-secrets` command to set your connection string - `dotnet user-secrets set "ConnectionStrings:umbracoDbDSN" "Data Source=|DataDirectory|/Umbraco.sqlite.db;Cache=Shared;Foreign Keys=True;Pooling=True;Default Timeout=60"` -- `dotnet run` +- `dotnet run --launch-profile Umbraco.Web.UI` - Open the URL that's printed in the console in your browser #### .NET IDE e.g. Rider or Visual Studio (Windows) diff --git a/SgfDevs.Tests/MediaStorageConfigurationTests.cs b/SgfDevs.Tests/MediaStorageConfigurationTests.cs new file mode 100644 index 00000000..5e480c44 --- /dev/null +++ b/SgfDevs.Tests/MediaStorageConfigurationTests.cs @@ -0,0 +1,98 @@ +using AF.Umbraco.S3.Media.Storage.Options; +using Amazon; +using Amazon.Runtime; +using Amazon.Runtime.Credentials; +using Amazon.S3; +using Amazon.SecurityToken; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace SgfDevs.Tests; + +[CollectionDefinition("AWS environment", DisableParallelization = true)] +public class AwsEnvironmentCollection; + +[Collection("AWS environment")] +public class MediaStorageConfigurationTests +{ + [Fact] + public void ProductionRequiresAnExplicitBucketAndUsesStandardAwsEndpoints() + { + var configuration = LoadConfiguration(development: false); + var options = configuration.GetSection("Umbraco:Storage:AWSS3:Media").Get()!; + + Assert.True(string.IsNullOrEmpty(options.BucketName)); + Assert.Equal("us-east-2", options.Region); + Assert.Equal("media", options.MediaBucketPrefix); + Assert.Equal("cache", options.CacheBucketPrefix); + Assert.False(options.CacheRetention.Enabled); + Assert.Null(options.CannedACL); + Assert.Null(configuration["AWS:ServiceURL"]); + Assert.Null(configuration["AWS:Profile"]); + Assert.Null(configuration["SGFDevs:AzureBlobStorageKey"]); + } + + [Fact] + public void DevelopmentUsesItsOwnMinioBucketWithPathStyleAddressing() + { + var configuration = LoadConfiguration(development: true); + + Assert.Equal("sgf-dev-local", configuration["Umbraco:Storage:AWSS3:Media:BucketName"]); + Assert.Equal("http://localhost:9000", configuration["AWS:ServiceURL"]); + Assert.True(configuration.GetValue("AWS:ForcePathStyle")); + Assert.Equal("us-east-2", configuration["AWS:Region"]); + } + + [Fact] + public void DefaultCredentialChainSelectsWebIdentityWithoutAccessKeys() + { + var tokenFile = Path.GetTempFileName(); + var variables = new Dictionary + { + ["AWS_ACCESS_KEY_ID"] = null, + ["AWS_SECRET_ACCESS_KEY"] = null, + ["AWS_SESSION_TOKEN"] = null, + ["AWS_WEB_IDENTITY_TOKEN_FILE"] = tokenFile, + ["AWS_ROLE_ARN"] = "arn:aws:iam::123456789012:role/sgfdevs-k3s/sgf-dev-test", + ["AWS_ROLE_SESSION_NAME"] = "sgf-dev-test" + }; + var originals = variables.Keys.ToDictionary(key => key, Environment.GetEnvironmentVariable); + + try + { + foreach (var variable in variables) + { + Environment.SetEnvironmentVariable(variable.Key, variable.Value); + } + + var credentials = DefaultAWSCredentialsIdentityResolver.GetCredentials( + new AmazonS3Config { RegionEndpoint = RegionEndpoint.USEast2 }); + var webIdentity = Assert.IsType(credentials); + Assert.Equal(tokenFile, webIdentity.WebIdentityTokenFile); + Assert.Equal(variables["AWS_ROLE_ARN"], webIdentity.RoleArn); + Assert.Equal("sgf-dev-test", webIdentity.RoleSessionName); + // Loading STS here catches a missing deployment dependency without calling AWS. + Assert.NotNull(typeof(AmazonSecurityTokenServiceClient).Assembly); + } + finally + { + foreach (var original in originals) + { + Environment.SetEnvironmentVariable(original.Key, original.Value); + } + File.Delete(tokenFile); + } + } + + private static IConfiguration LoadConfiguration(bool development) + { + var builder = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json"); + if (development) + { + builder.AddJsonFile("appsettings.Development.json"); + } + return builder.Build(); + } +} diff --git a/SgfDevs.Tests/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json b/SgfDevs.Tests/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json deleted file mode 100755 index 96bd03f3..00000000 --- a/SgfDevs.Tests/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "UmbracoStorageProvidersAzureBlobSchema", - "type": "object", - "description": "Defines the appsettings JSON schema for Umbraco Storage Providers - Azure Blob Storage configuration.", - "properties": { - "Umbraco": { - "description": "Configuration container for all Umbraco products.", - "oneOf": [ - { - "$ref": "#/definitions/UmbracoDefinition" - } - ] - } - }, - "definitions": { - "UmbracoDefinition": { - "type": "object", - "description": "Represents the configuration container for all Umbraco products.", - "properties": { - "Storage": { - "description": "Configuration of Umbraco Storage Providers.", - "oneOf": [ - { - "$ref": "#/definitions/StorageDefinition" - } - ] - } - } - }, - "StorageDefinition": { - "type": "object", - "description": "Represents the configuration of Umbraco Storage Providers.", - "properties": { - "AzureBlob": { - "type": "object", - "description": "Configuration of Umbraco Storage Providers - Azure Blob Storage, keyed by file system name (e.g. \"Media\").", - "additionalProperties": { - "$ref": "#/definitions/AzureBlobFileSystemOptions" - } - } - } - }, - "AzureBlobFileSystemOptions": { - "type": "object", - "description": "The Azure Blob File System options.", - "required": [ - "ConnectionString", - "ContainerName", - "VirtualPath" - ], - "properties": { - "ConnectionString": { - "type": "string", - "description": "Gets or sets the storage account connection string.", - "minLength": 1 - }, - "ContainerName": { - "type": "string", - "description": "Gets or sets the container name.", - "minLength": 1 - }, - "ContainerRootPath": { - "type": [ - "null", - "string" - ], - "description": "Gets or sets the root path of the container." - }, - "VirtualPath": { - "type": "string", - "description": "Gets or sets the virtual path.", - "minLength": 1 - }, - "Cache": { - "description": "Gets or sets the in-memory cache settings applied to blob metadata lookups by the read-only file provider.", - "oneOf": [ - { - "$ref": "#/definitions/AzureBlobFileSystemCacheOptions" - } - ] - }, - "Retry": { - "description": "Gets or sets the retry and timeout settings applied to the default Blob client.", - "oneOf": [ - { - "$ref": "#/definitions/AzureBlobFileSystemRetryOptions" - } - ] - } - } - }, - "AzureBlobFileSystemCacheOptions": { - "type": "object", - "description": "Cache settings applied to blob metadata lookups performed by the read-only file provider.", - "properties": { - "Enabled": { - "type": "boolean", - "description": "Gets or sets a value indicating whether blob metadata caching is enabled.", - "default": true - }, - "HitDuration": { - "type": "string", - "description": "Gets or sets how long a successful metadata lookup is cached.", - "format": "duration", - "default": "00:00:30" - }, - "MissDuration": { - "type": "string", - "description": "Gets or sets how long a not-found metadata result is cached.", - "format": "duration", - "default": "00:00:05" - } - } - }, - "AzureBlobFileSystemRetryOptions": { - "type": "object", - "description": "Retry and timeout settings applied to the default BlobContainerClient created from AzureBlobFileSystemOptions.", - "properties": { - "MaxRetries": { - "type": "integer", - "description": "Gets or sets the maximum number of retry attempts before giving up.", - "format": "int32", - "default": 2, - "maximum": 2147483647.0, - "minimum": 0.0 - }, - "NetworkTimeout": { - "type": "string", - "description": "Gets or sets the timeout applied to an individual network operation.", - "format": "duration", - "default": "00:00:30" - }, - "Mode": { - "description": "Gets or sets the approach to use for calculating retry delays.", - "default": "Exponential", - "oneOf": [ - { - "$ref": "#/definitions/RetryMode" - } - ] - }, - "Delay": { - "type": "string", - "description": "Gets or sets the delay between retry attempts for a fixed approach or the delay on which to base\ncalculations for a backoff-based approach.", - "format": "duration", - "default": "00:00:00.8000000" - }, - "MaxDelay": { - "type": "string", - "description": "Gets or sets the maximum permissible delay between retry attempts when using a backoff approach.", - "format": "duration", - "default": "00:00:05" - } - } - }, - "RetryMode": { - "type": "string", - "description": "", - "x-enumNames": [ - "Fixed", - "Exponential" - ], - "enum": [ - "Fixed", - "Exponential" - ] - } - } -} \ No newline at end of file diff --git a/SgfDevs.Tests/appsettings-schema.json b/SgfDevs.Tests/appsettings-schema.json index dff39b51..0acb60fb 100644 --- a/SgfDevs.Tests/appsettings-schema.json +++ b/SgfDevs.Tests/appsettings-schema.json @@ -12,9 +12,6 @@ }, { "$ref": "appsettings-schema.Umbraco.StorageProviders.json#" - }, - { - "$ref": "appsettings-schema.Umbraco.StorageProviders.AzureBlob.json#" } ] } \ No newline at end of file diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index ae6a10de..1b935544 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -32,16 +32,6 @@ .AddDeliveryApi() .AddComposers(); -var blobStorageKey = builder.Configuration["SGFDevs:AzureBlobStorageKey"]; -if (!string.IsNullOrEmpty(blobStorageKey)) -{ - umbracoBuilder.AddAzureBlobMediaFileSystem(options => - { - options.ConnectionString = $"DefaultEndpointsProtocol=https;AccountName=sgfdevs;AccountKey={blobStorageKey};EndpointSuffix=core.windows.net"; - options.ContainerName = "website"; - }); -} - if (!string.IsNullOrEmpty(builder.Configuration["Umbraco:Storage:Cdn:Url"])) { umbracoBuilder.AddCdnMediaUrlProvider(); diff --git a/SgfDevs/Properties/launchSettings.json b/SgfDevs/Properties/launchSettings.json index f22e43ac..5378773e 100644 --- a/SgfDevs/Properties/launchSettings.json +++ b/SgfDevs/Properties/launchSettings.json @@ -4,14 +4,20 @@ "commandName": "IISExpress", "launchBrowser": true, "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "AWS_ACCESS_KEY_ID": "sgf-dev-local", + "AWS_SECRET_ACCESS_KEY": "sgf-dev-local-password", + "AWS_SESSION_TOKEN": "" } }, "Umbraco.Web.UI": { "commandName": "Project", "launchBrowser": true, "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "AWS_ACCESS_KEY_ID": "sgf-dev-local", + "AWS_SECRET_ACCESS_KEY": "sgf-dev-local-password", + "AWS_SESSION_TOKEN": "" }, "dotnetRunMessages": true, "applicationUrl": "https://localhost:44307;http://localhost:10600" diff --git a/SgfDevs/SgfDevs.csproj b/SgfDevs/SgfDevs.csproj index ea26e713..ec1a424d 100644 --- a/SgfDevs/SgfDevs.csproj +++ b/SgfDevs/SgfDevs.csproj @@ -3,6 +3,8 @@ net10.0 + + @@ -13,7 +15,7 @@ - + diff --git a/SgfDevs/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json b/SgfDevs/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json deleted file mode 100755 index 96bd03f3..00000000 --- a/SgfDevs/appsettings-schema.Umbraco.StorageProviders.AzureBlob.json +++ /dev/null @@ -1,170 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "title": "UmbracoStorageProvidersAzureBlobSchema", - "type": "object", - "description": "Defines the appsettings JSON schema for Umbraco Storage Providers - Azure Blob Storage configuration.", - "properties": { - "Umbraco": { - "description": "Configuration container for all Umbraco products.", - "oneOf": [ - { - "$ref": "#/definitions/UmbracoDefinition" - } - ] - } - }, - "definitions": { - "UmbracoDefinition": { - "type": "object", - "description": "Represents the configuration container for all Umbraco products.", - "properties": { - "Storage": { - "description": "Configuration of Umbraco Storage Providers.", - "oneOf": [ - { - "$ref": "#/definitions/StorageDefinition" - } - ] - } - } - }, - "StorageDefinition": { - "type": "object", - "description": "Represents the configuration of Umbraco Storage Providers.", - "properties": { - "AzureBlob": { - "type": "object", - "description": "Configuration of Umbraco Storage Providers - Azure Blob Storage, keyed by file system name (e.g. \"Media\").", - "additionalProperties": { - "$ref": "#/definitions/AzureBlobFileSystemOptions" - } - } - } - }, - "AzureBlobFileSystemOptions": { - "type": "object", - "description": "The Azure Blob File System options.", - "required": [ - "ConnectionString", - "ContainerName", - "VirtualPath" - ], - "properties": { - "ConnectionString": { - "type": "string", - "description": "Gets or sets the storage account connection string.", - "minLength": 1 - }, - "ContainerName": { - "type": "string", - "description": "Gets or sets the container name.", - "minLength": 1 - }, - "ContainerRootPath": { - "type": [ - "null", - "string" - ], - "description": "Gets or sets the root path of the container." - }, - "VirtualPath": { - "type": "string", - "description": "Gets or sets the virtual path.", - "minLength": 1 - }, - "Cache": { - "description": "Gets or sets the in-memory cache settings applied to blob metadata lookups by the read-only file provider.", - "oneOf": [ - { - "$ref": "#/definitions/AzureBlobFileSystemCacheOptions" - } - ] - }, - "Retry": { - "description": "Gets or sets the retry and timeout settings applied to the default Blob client.", - "oneOf": [ - { - "$ref": "#/definitions/AzureBlobFileSystemRetryOptions" - } - ] - } - } - }, - "AzureBlobFileSystemCacheOptions": { - "type": "object", - "description": "Cache settings applied to blob metadata lookups performed by the read-only file provider.", - "properties": { - "Enabled": { - "type": "boolean", - "description": "Gets or sets a value indicating whether blob metadata caching is enabled.", - "default": true - }, - "HitDuration": { - "type": "string", - "description": "Gets or sets how long a successful metadata lookup is cached.", - "format": "duration", - "default": "00:00:30" - }, - "MissDuration": { - "type": "string", - "description": "Gets or sets how long a not-found metadata result is cached.", - "format": "duration", - "default": "00:00:05" - } - } - }, - "AzureBlobFileSystemRetryOptions": { - "type": "object", - "description": "Retry and timeout settings applied to the default BlobContainerClient created from AzureBlobFileSystemOptions.", - "properties": { - "MaxRetries": { - "type": "integer", - "description": "Gets or sets the maximum number of retry attempts before giving up.", - "format": "int32", - "default": 2, - "maximum": 2147483647.0, - "minimum": 0.0 - }, - "NetworkTimeout": { - "type": "string", - "description": "Gets or sets the timeout applied to an individual network operation.", - "format": "duration", - "default": "00:00:30" - }, - "Mode": { - "description": "Gets or sets the approach to use for calculating retry delays.", - "default": "Exponential", - "oneOf": [ - { - "$ref": "#/definitions/RetryMode" - } - ] - }, - "Delay": { - "type": "string", - "description": "Gets or sets the delay between retry attempts for a fixed approach or the delay on which to base\ncalculations for a backoff-based approach.", - "format": "duration", - "default": "00:00:00.8000000" - }, - "MaxDelay": { - "type": "string", - "description": "Gets or sets the maximum permissible delay between retry attempts when using a backoff approach.", - "format": "duration", - "default": "00:00:05" - } - } - }, - "RetryMode": { - "type": "string", - "description": "", - "x-enumNames": [ - "Fixed", - "Exponential" - ], - "enum": [ - "Fixed", - "Exponential" - ] - } - } -} \ No newline at end of file diff --git a/SgfDevs/appsettings-schema.json b/SgfDevs/appsettings-schema.json index 0e5a0454..4e4bcd5c 100755 --- a/SgfDevs/appsettings-schema.json +++ b/SgfDevs/appsettings-schema.json @@ -3889,9 +3889,6 @@ }, { "$ref": "appsettings-schema.Umbraco.StorageProviders.json#" - }, - { - "$ref": "appsettings-schema.Umbraco.StorageProviders.AzureBlob.json#" } ] } \ No newline at end of file diff --git a/SgfDevs/appsettings.Development.json b/SgfDevs/appsettings.Development.json index d98c770c..bc1a4554 100644 --- a/SgfDevs/appsettings.Development.json +++ b/SgfDevs/appsettings.Development.json @@ -1,5 +1,9 @@ { - "$schema": "./umbraco/config/appsettings-schema.json", + "$schema": "./appsettings-schema.json", + "AWS": { + "ServiceURL": "http://localhost:9000", + "ForcePathStyle": true + }, "Serilog": { "MinimumLevel": { "Default": "Information" @@ -22,6 +26,13 @@ ] }, "Umbraco": { + "Storage": { + "AWSS3": { + "Media": { + "BucketName": "sgf-dev-local" + } + } + }, "CMS": { "Content": { "MacroErrors": "Throw" diff --git a/SgfDevs/appsettings.json b/SgfDevs/appsettings.json index 4f0caa62..639017b4 100644 --- a/SgfDevs/appsettings.json +++ b/SgfDevs/appsettings.json @@ -2,7 +2,6 @@ "$schema": "./appsettings-schema.json", "SGFDevs": { "EventSyncEnabled": false, - "AzureBlobStorageKey": "", "NewsletterEndpoint": "", "NewsletterListId": "", "EventTimeZoneId": "America/Chicago", @@ -59,7 +58,23 @@ "umbracoDbDSN": "", "umbracoDbDSN_ProviderName": "Microsoft.Data.Sqlite" }, + "AWS": { + "Region": "us-east-2" + }, "Umbraco": { + "Storage": { + "AWSS3": { + "Media": { + "BucketName": "", + "Region": "us-east-2", + "MediaBucketPrefix": "media", + "CacheBucketPrefix": "cache", + "CacheRetention": { + "Enabled": false + } + } + } + }, "CMS": { "Global": { "Id": "eb41b267-4c02-43fd-b315-cee566710902", diff --git a/SgfDevs/appsettings.json.bak b/SgfDevs/appsettings.json.bak index 4f1c64c6..0af5dd82 100644 --- a/SgfDevs/appsettings.json.bak +++ b/SgfDevs/appsettings.json.bak @@ -1,8 +1,5 @@ { "$schema": "./appsettings-schema.json", - "SGFDevs": { - "AzureBlobStorageKey": "" - }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..cdb27ba8 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,39 @@ +name: sgf-dev-local + +services: + minio: + # Community build of upstream MinIO, pinned for amd64 and arm64. + image: ghcr.io/golithus/minio@sha256:8793e960474071520bdb91bb9a6d1793eb229fdbc9dcb7ec75ad836b69bbab40 + command: [server, /data, --console-address, ":9001"] + environment: + MINIO_ROOT_USER: sgf-dev-local + MINIO_ROOT_PASSWORD: sgf-dev-local-password + MINIO_REGION_NAME: us-east-2 + ports: + - "127.0.0.1:9000:9000" + - "127.0.0.1:9001:9001" + volumes: + - minio-data:/data + + minio-init: + image: ghcr.io/golithus/mc@sha256:dde55ccc0bc9e65dc582c8e652d652eea1438c6e60d774524f4da15d7b26a900 + depends_on: + minio: + condition: service_started + entrypoint: [/bin/sh, -ec] + command: + - | + attempts=0 + until mc alias set local http://minio:9000 sgf-dev-local sgf-dev-local-password; do + attempts=$$((attempts + 1)) + if [ "$$attempts" -ge 30 ]; then + echo "MinIO did not become ready within 60 seconds." >&2 + exit 1 + fi + sleep 2 + done + mc mb --ignore-existing local/sgf-dev-local + mc anonymous set none local/sgf-dev-local + +volumes: + minio-data: From d1a20da7875d84d3fd9a532c3776e7e73a62b8db Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Wed, 30 Sep 2026 22:12:02 -0500 Subject: [PATCH 2/3] feat: use SeaweedFS for local S3 storage --- README.md | 9 ++--- .../MediaStorageConfigurationTests.cs | 4 +- SgfDevs/appsettings.Development.json | 2 +- compose.yaml | 40 +++++-------------- 4 files changed, 16 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 0fb247a3..4b09226e 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,14 @@ There are a couple of ways to run this project depending on if you have a .NET I ### Environment Specific Steps -Local media uses MinIO. With Docker Compose installed, run from the repo root: +Local media uses SeaweedFS. With Docker Compose installed, run from the repo root: ```sh -docker compose up -d minio -docker compose run --rm minio-init +docker compose up -d ``` -The development launch profiles supply local-only credentials. The console is at -`http://localhost:9001`, using `sgf-dev-local` / `sgf-dev-local-password`. +The local bucket is created automatically at `http://localhost:8333`. +The development launch profiles supply local-only credentials. Media persists across restarts; `docker compose down --volumes` deletes it. - Create an `Umbraco.sqlite.db` file in the `./SgfDevs/umbraco/Data` directory diff --git a/SgfDevs.Tests/MediaStorageConfigurationTests.cs b/SgfDevs.Tests/MediaStorageConfigurationTests.cs index 5e480c44..4ab38a38 100644 --- a/SgfDevs.Tests/MediaStorageConfigurationTests.cs +++ b/SgfDevs.Tests/MediaStorageConfigurationTests.cs @@ -33,12 +33,12 @@ public void ProductionRequiresAnExplicitBucketAndUsesStandardAwsEndpoints() } [Fact] - public void DevelopmentUsesItsOwnMinioBucketWithPathStyleAddressing() + public void DevelopmentUsesItsOwnSeaweedFsBucketWithPathStyleAddressing() { var configuration = LoadConfiguration(development: true); Assert.Equal("sgf-dev-local", configuration["Umbraco:Storage:AWSS3:Media:BucketName"]); - Assert.Equal("http://localhost:9000", configuration["AWS:ServiceURL"]); + Assert.Equal("http://localhost:8333", configuration["AWS:ServiceURL"]); Assert.True(configuration.GetValue("AWS:ForcePathStyle")); Assert.Equal("us-east-2", configuration["AWS:Region"]); } diff --git a/SgfDevs/appsettings.Development.json b/SgfDevs/appsettings.Development.json index bc1a4554..a1197d33 100644 --- a/SgfDevs/appsettings.Development.json +++ b/SgfDevs/appsettings.Development.json @@ -1,7 +1,7 @@ { "$schema": "./appsettings-schema.json", "AWS": { - "ServiceURL": "http://localhost:9000", + "ServiceURL": "http://localhost:8333", "ForcePathStyle": true }, "Serilog": { diff --git a/compose.yaml b/compose.yaml index cdb27ba8..da57c16f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,39 +1,17 @@ name: sgf-dev-local services: - minio: - # Community build of upstream MinIO, pinned for amd64 and arm64. - image: ghcr.io/golithus/minio@sha256:8793e960474071520bdb91bb9a6d1793eb229fdbc9dcb7ec75ad836b69bbab40 - command: [server, /data, --console-address, ":9001"] + seaweedfs: + image: chrislusf/seaweedfs:4.48@sha256:4e61d15fd35994cb1e43e1e553dff106794841fd9a99ade2fc8c8bfce4d7872d + command: [mini, -dir=/data] environment: - MINIO_ROOT_USER: sgf-dev-local - MINIO_ROOT_PASSWORD: sgf-dev-local-password - MINIO_REGION_NAME: us-east-2 + AWS_ACCESS_KEY_ID: sgf-dev-local + AWS_SECRET_ACCESS_KEY: sgf-dev-local-password + S3_BUCKET: sgf-dev-local ports: - - "127.0.0.1:9000:9000" - - "127.0.0.1:9001:9001" + - "127.0.0.1:8333:8333" volumes: - - minio-data:/data - - minio-init: - image: ghcr.io/golithus/mc@sha256:dde55ccc0bc9e65dc582c8e652d652eea1438c6e60d774524f4da15d7b26a900 - depends_on: - minio: - condition: service_started - entrypoint: [/bin/sh, -ec] - command: - - | - attempts=0 - until mc alias set local http://minio:9000 sgf-dev-local sgf-dev-local-password; do - attempts=$$((attempts + 1)) - if [ "$$attempts" -ge 30 ]; then - echo "MinIO did not become ready within 60 seconds." >&2 - exit 1 - fi - sleep 2 - done - mc mb --ignore-existing local/sgf-dev-local - mc anonymous set none local/sgf-dev-local + - seaweedfs-data:/data volumes: - minio-data: + seaweedfs-data: From d450380da362a209f13eb342d4e126d1ce077c58 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Wed, 30 Sep 2026 22:15:29 -0500 Subject: [PATCH 3/3] test: remove redundant storage configuration tests --- .../MediaStorageConfigurationTests.cs | 98 ------------------- 1 file changed, 98 deletions(-) delete mode 100644 SgfDevs.Tests/MediaStorageConfigurationTests.cs diff --git a/SgfDevs.Tests/MediaStorageConfigurationTests.cs b/SgfDevs.Tests/MediaStorageConfigurationTests.cs deleted file mode 100644 index 4ab38a38..00000000 --- a/SgfDevs.Tests/MediaStorageConfigurationTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -using AF.Umbraco.S3.Media.Storage.Options; -using Amazon; -using Amazon.Runtime; -using Amazon.Runtime.Credentials; -using Amazon.S3; -using Amazon.SecurityToken; -using Microsoft.Extensions.Configuration; -using Xunit; - -namespace SgfDevs.Tests; - -[CollectionDefinition("AWS environment", DisableParallelization = true)] -public class AwsEnvironmentCollection; - -[Collection("AWS environment")] -public class MediaStorageConfigurationTests -{ - [Fact] - public void ProductionRequiresAnExplicitBucketAndUsesStandardAwsEndpoints() - { - var configuration = LoadConfiguration(development: false); - var options = configuration.GetSection("Umbraco:Storage:AWSS3:Media").Get()!; - - Assert.True(string.IsNullOrEmpty(options.BucketName)); - Assert.Equal("us-east-2", options.Region); - Assert.Equal("media", options.MediaBucketPrefix); - Assert.Equal("cache", options.CacheBucketPrefix); - Assert.False(options.CacheRetention.Enabled); - Assert.Null(options.CannedACL); - Assert.Null(configuration["AWS:ServiceURL"]); - Assert.Null(configuration["AWS:Profile"]); - Assert.Null(configuration["SGFDevs:AzureBlobStorageKey"]); - } - - [Fact] - public void DevelopmentUsesItsOwnSeaweedFsBucketWithPathStyleAddressing() - { - var configuration = LoadConfiguration(development: true); - - Assert.Equal("sgf-dev-local", configuration["Umbraco:Storage:AWSS3:Media:BucketName"]); - Assert.Equal("http://localhost:8333", configuration["AWS:ServiceURL"]); - Assert.True(configuration.GetValue("AWS:ForcePathStyle")); - Assert.Equal("us-east-2", configuration["AWS:Region"]); - } - - [Fact] - public void DefaultCredentialChainSelectsWebIdentityWithoutAccessKeys() - { - var tokenFile = Path.GetTempFileName(); - var variables = new Dictionary - { - ["AWS_ACCESS_KEY_ID"] = null, - ["AWS_SECRET_ACCESS_KEY"] = null, - ["AWS_SESSION_TOKEN"] = null, - ["AWS_WEB_IDENTITY_TOKEN_FILE"] = tokenFile, - ["AWS_ROLE_ARN"] = "arn:aws:iam::123456789012:role/sgfdevs-k3s/sgf-dev-test", - ["AWS_ROLE_SESSION_NAME"] = "sgf-dev-test" - }; - var originals = variables.Keys.ToDictionary(key => key, Environment.GetEnvironmentVariable); - - try - { - foreach (var variable in variables) - { - Environment.SetEnvironmentVariable(variable.Key, variable.Value); - } - - var credentials = DefaultAWSCredentialsIdentityResolver.GetCredentials( - new AmazonS3Config { RegionEndpoint = RegionEndpoint.USEast2 }); - var webIdentity = Assert.IsType(credentials); - Assert.Equal(tokenFile, webIdentity.WebIdentityTokenFile); - Assert.Equal(variables["AWS_ROLE_ARN"], webIdentity.RoleArn); - Assert.Equal("sgf-dev-test", webIdentity.RoleSessionName); - // Loading STS here catches a missing deployment dependency without calling AWS. - Assert.NotNull(typeof(AmazonSecurityTokenServiceClient).Assembly); - } - finally - { - foreach (var original in originals) - { - Environment.SetEnvironmentVariable(original.Key, original.Value); - } - File.Delete(tokenFile); - } - } - - private static IConfiguration LoadConfiguration(bool development) - { - var builder = new ConfigurationBuilder() - .SetBasePath(AppContext.BaseDirectory) - .AddJsonFile("appsettings.json"); - if (development) - { - builder.AddJsonFile("appsettings.Development.json"); - } - return builder.Build(); - } -}