diff --git a/README.md b/README.md index f68cabc..839f05e 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,9 @@ builder.AddLocalStack(configureContainer: container => // Eagerly load specific services for faster startup container.EagerLoadedServices = [AwsService.Sqs, AwsService.DynamoDB, AwsService.S3]; - // Recommended: Clean up container when application stops - container.Lifetime = ContainerLifetime.Session; + // Optional: Use Persistent lifetime for container reuse between runs + // (Default is Session - container cleaned up when application stops) + container.Lifetime = ContainerLifetime.Persistent; // Optional: Enable verbose logging for troubleshooting container.DebugLevel = 1; @@ -112,10 +113,13 @@ builder.AddLocalStack(configureContainer: container => **Available Options:** - **`EagerLoadedServices`** - Pre-load specific AWS services at startup (reduces cold start latency) -- **`Lifetime`** - Container lifecycle: `Persistent` (survives restarts) or `Session` (cleaned up on stop) +- **`Lifetime`** - Container lifecycle: `Session` (default - cleaned up on stop) or `Persistent` (survives restarts) - **`DebugLevel`** - LocalStack debug verbosity (0 = default, 1 = verbose) - **`LogLevel`** - Log level control (Error, Warn, Info, Debug, Trace, etc.) -- **`Port`** - Static port mapping for LocalStack container. If set, LocalStack will be mapped to this static port on the host. If not set, a dynamic port will be used unless the container lifetime is persistent, in which case the default LocalStack port (4566) is used. Useful for avoiding port conflicts or for predictable endpoint URLs +- **`Port`** - Static port mapping for LocalStack container. If not set, Session lifetime uses dynamic ports (avoids conflicts) and Persistent lifetime uses port 4566 (default LocalStack port). Set explicitly for predictable endpoint URLs +- **`ContainerRegistry`** - Custom container registry (default: `docker.io`). Use when pulling from private registries +- **`ContainerImage`** - Custom image name (default: `localstack/localstack`). Use when image is mirrored with different path +- **`ContainerImageTag`** - Custom image tag/version (default: package version). Use to pin to specific LocalStack version - **`AdditionalEnvironmentVariables`** - Custom environment variables for advanced scenarios For detailed configuration guide and best practices, see [Configuration Documentation](docs/CONFIGURATION.md). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0b5b2fc..f7ed280 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -7,7 +7,11 @@ This guide covers configuration options for customizing LocalStack container beh | Option | Type | Default | Description | |--------|------|---------|-------------| | `EagerLoadedServices` | `IReadOnlyCollection` | `[]` (empty) | AWS services to pre-load at container startup | -| `Lifetime` | `ContainerLifetime` | `Persistent` | Container lifecycle behavior | +| `Lifetime` | `ContainerLifetime` | `Session` | Container lifecycle behavior | +| `Port` | `int?` | `null` | Static port mapping for LocalStack container | +| `ContainerRegistry` | `string?` | `null` (`docker.io`) | Custom container registry | +| `ContainerImage` | `string?` | `null` (`localstack/localstack`) | Custom container image name | +| `ContainerImageTag` | `string?` | `null` (package version) | Custom container image tag/version | | `EnableDockerSocket` | `bool` | `false` | Mount Docker socket for Lambda support | | `DebugLevel` | `int` | `0` | LocalStack DEBUG flag (0 or 1) | | `LogLevel` | `LocalStackLogLevel` | `Error` | LocalStack LS_LOG level | @@ -75,7 +79,23 @@ For a complete list of supported services, check the [LocalStack health endpoint Controls when the LocalStack container is created and destroyed. -### Persistent (Default) +### Session (Default - Recommended) + +Container is created when the application starts and destroyed when it stops. This is the default lifetime and aligns with Aspire's conventions. + +```csharp +// Default - no need to set explicitly +container.Lifetime = ContainerLifetime.Session; +``` + +**When to use:** + +- CI/CD pipelines (clean slate for each run) +- Integration tests (isolated test runs) +- When you want guaranteed clean state +- Most development scenarios + +### Persistent Container survives application restarts. Data may persist between debugging sessions depending on your configuration. @@ -85,24 +105,175 @@ container.Lifetime = ContainerLifetime.Persistent; **When to use:** -- Local development (container reuse between runs) +- Local development when you want container reuse between runs - When combined with [LocalStack persistence](https://docs.localstack.cloud/aws/capabilities/state-management/persistence/) +- When working with large infrastructure that's slow to provision + +## Port Configuration + +Controls how LocalStack's port is mapped from the container to the host machine. -### Session +### Default Behavior -Container is created when the application starts and destroyed when it stops. +By default, port mapping depends on container lifetime: ```csharp +// Session lifetime (Default) - uses dynamic port assignment container.Lifetime = ContainerLifetime.Session; +// Port will be: random available port + +// Persistent lifetime - uses default LocalStack port (4566) +container.Lifetime = ContainerLifetime.Persistent; +// Port will be: 4566 ``` -**When to use:** +### Static Port Mapping -- CI/CD pipelines (clean slate for each run) -- Integration tests (isolated test runs) -- When you want guaranteed clean state +You can explicitly specify a port to ensure predictable endpoint URLs: + +```csharp +container.Port = 4566; // Always use port 4566 +``` + +### When to Use Static Ports + +**Use static ports when:** + +- You need predictable endpoint URLs across runs +- External tools need to connect to a known port +- You're integrating with legacy systems expecting specific ports +- Debugging network issues and need consistency + +**Use dynamic ports when:** + +- Running multiple instances simultaneously (tests, parallel development) +- Avoiding port conflicts with other services +- In CI/CD environments with parallel builds + +### Port Conflict Resolution + +If you encounter port conflicts: + +```csharp +// Option 1: Use a different static port +container.Port = 4567; + +// Option 2: Switch to Session lifetime for dynamic port assignment +container.Lifetime = ContainerLifetime.Session; +// Dynamic ports are used automatically when Lifetime = Session and Port is not set +``` + +## Custom Container Registry + +Configure LocalStack to pull from private registries or container mirrors. + +### Why Use Custom Registries? + +Organizations often need to pull images from: + +- **Private registries** (Artifactory, Harbor) for compliance/security +- **Container mirrors** to avoid Docker Hub rate limits +- **Internal registries** (Azure Container Registry, AWS ECR) for air-gapped environments +- **Custom builds** with organization-specific configurations + +### Configuration Properties + +Three properties work together to specify the complete image location: + +```csharp +builder.AddLocalStack(configureContainer: container => +{ + container.ContainerRegistry = "artifactory.company.com"; // Where to pull from + container.ContainerImage = "docker-mirrors/localstack/localstack"; // Image path + container.ContainerImageTag = "4.10.0"; // Specific version +}); +``` + +**Defaults:** + +- `ContainerRegistry`: `docker.io` (Docker Hub) +- `ContainerImage`: `localstack/localstack` +- `ContainerImageTag`: Matches package version (e.g., `4.10.0`) + +### Common Scenarios + +#### Artifactory + +```csharp +container.ContainerRegistry = "artifactory.company.com"; +container.ContainerImage = "docker-local/localstack/localstack"; +container.ContainerImageTag = "4.10.0"; +// Pulls: artifactory.company.com/docker-local/localstack/localstack:4.10.0 +``` -**Recommendation:** Use `Session` for CI/CD and integration tests, `Persistent` for local development. +#### Azure Container Registry (ACR) + +```csharp +container.ContainerRegistry = "mycompany.azurecr.io"; +container.ContainerImage = "localstack/localstack"; +container.ContainerImageTag = "4.10.0"; +// Pulls: mycompany.azurecr.io/localstack/localstack:4.10.0 +``` + +#### AWS Elastic Container Registry (ECR) + +```csharp +container.ContainerRegistry = "123456789012.dkr.ecr.us-west-2.amazonaws.com"; +container.ContainerImage = "localstack/localstack"; +container.ContainerImageTag = "4.10.0"; +// Pulls: 123456789012.dkr.ecr.us-west-2.amazonaws.com/localstack/localstack:4.10.0 +``` + +#### GitHub Container Registry (GHCR) + +```csharp +container.ContainerRegistry = "ghcr.io"; +container.ContainerImage = "myorg/localstack"; +container.ContainerImageTag = "custom-build-123"; +// Pulls: ghcr.io/myorg/localstack:custom-build-123 +``` + +#### Pin to Specific Version + +```csharp +// Only override the tag to use a different LocalStack version +container.ContainerImageTag = "3.8.1"; +// Pulls: docker.io/localstack/localstack:3.8.1 +``` + +### Authentication + +Container registry authentication is handled by Docker/Podman on the host machine. Ensure you're logged in before running: + +```bash +# Docker Hub +docker login + +# Private registry +docker login artifactory.company.com + +# Azure Container Registry +az acr login --name mycompany + +# AWS ECR +aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-west-2.amazonaws.com +``` + +### Backward Compatibility + +All three properties are optional and default to the public Docker Hub image: + +```csharp +// These are equivalent: +builder.AddLocalStack(); + +builder.AddLocalStack(configureContainer: container => +{ + container.ContainerRegistry = "docker.io"; + container.ContainerImage = "localstack/localstack"; + container.ContainerImageTag = "4.10.0"; // Package version +}); +``` ## Logging and Debugging @@ -185,23 +356,34 @@ container.AdditionalEnvironmentVariables["PERSISTENCE"] = "1"; ## Configuration Patterns -### Development +### Development (Fast Iteration) ```csharp builder.AddLocalStack(configureContainer: container => { - container.Lifetime = ContainerLifetime.Persistent; + // Default Session lifetime is fine for most development container.LogLevel = LocalStackLogLevel.Warn; // Use lazy loading for faster startup }); ``` +### Development (Container Reuse) + +```csharp +builder.AddLocalStack(configureContainer: container => +{ + // Use Persistent to reuse container between runs + container.Lifetime = ContainerLifetime.Persistent; + container.LogLevel = LocalStackLogLevel.Warn; +}); +``` + ### CI/CD ```csharp builder.AddLocalStack(configureContainer: container => { - container.Lifetime = ContainerLifetime.Session; + // Default Session lifetime is perfect for CI/CD container.LogLevel = LocalStackLogLevel.Error; // Eagerly load all services used in tests @@ -209,15 +391,36 @@ builder.AddLocalStack(configureContainer: container => }); ``` +### Enterprise with Private Registry + +```csharp +builder.AddLocalStack(configureContainer: container => +{ + // Pull from private Artifactory + container.ContainerRegistry = "artifactory.company.com"; + container.ContainerImage = "docker-local/localstack/localstack"; + container.ContainerImageTag = "4.10.0"; + + container.Lifetime = ContainerLifetime.Persistent; + container.LogLevel = LocalStackLogLevel.Warn; + + // Use static port for consistency with other tools + container.Port = 4566; +}); +``` + ### Debugging ```csharp builder.AddLocalStack(configureContainer: container => { - container.Lifetime = ContainerLifetime.Session; + // Default Session lifetime - clean state for each debug session container.LogLevel = LocalStackLogLevel.Debug; container.DebugLevel = 1; + // Use static port for easier debugging + container.Port = 4566; + // Eagerly load to see startup issues container.EagerLoadedServices = [AwsService.Sqs]; }); @@ -228,9 +431,11 @@ builder.AddLocalStack(configureContainer: container => ```csharp builder.AddLocalStack(configureContainer: container => { - container.Lifetime = ContainerLifetime.Session; + // Default Session lifetime - perfect for isolated test runs container.LogLevel = LocalStackLogLevel.Error; + // Dynamic ports by default - allows parallel test runs without conflicts + // Eagerly load services to avoid cold-start variance container.EagerLoadedServices = [AwsService.Sqs, AwsService.DynamoDB]; }); diff --git a/src/Aspire.Hosting.LocalStack/Container/LocalStackContainerOptions.cs b/src/Aspire.Hosting.LocalStack/Container/LocalStackContainerOptions.cs index 882d87b..847ed12 100644 --- a/src/Aspire.Hosting.LocalStack/Container/LocalStackContainerOptions.cs +++ b/src/Aspire.Hosting.LocalStack/Container/LocalStackContainerOptions.cs @@ -16,10 +16,50 @@ public sealed class LocalStackContainerOptions /// Gets or sets the container lifetime behavior. /// /// - /// - : Container survives application restarts (default for databases) - /// - : Container is cleaned up when application stops (recommended for LocalStack) + /// (Default - Recommended): + /// + /// Container is cleaned up when application stops + /// Uses dynamic port assignment by default (unless is explicitly set) + /// Best for: CI/CD pipelines, integration tests, clean state guarantees + /// + /// : + /// + /// Container survives application restarts + /// Uses static port 4566 by default (unless is explicitly set) + /// Best for: Local development with container reuse + /// /// - public ContainerLifetime Lifetime { get; set; } = ContainerLifetime.Persistent; + public ContainerLifetime Lifetime { get; set; } = ContainerLifetime.Session; + + /// + /// Gets or sets the container registry to pull the LocalStack image from. + /// + /// + /// Defaults to "docker.io" if not specified. + /// Override this when using a private registry or mirror (e.g., Artifactory, Azure Container Registry). + /// Examples: "artifactory.company.com", "myregistry.azurecr.io", "ghcr.io" + /// + public string? ContainerRegistry { get; set; } + + /// + /// Gets or sets the LocalStack container image name. + /// + /// + /// Defaults to "localstack/localstack" if not specified. + /// Override when using a custom image path in your registry. + /// Examples: "my-team/localstack", "mirrors/localstack/localstack", "docker-local/localstack" + /// + public string? ContainerImage { get; set; } + + /// + /// Gets or sets the LocalStack container image tag/version. + /// + /// + /// Defaults to the version bundled with this package if not specified. + /// Override to use a specific version. + /// Examples: "latest", "4.9.2", "4.10.0", "custom-build-123" + /// + public string? ContainerImageTag { get; set; } /// /// Gets or sets the DEBUG environment variable value for LocalStack. @@ -67,8 +107,13 @@ public sealed class LocalStackContainerOptions /// Gets or sets the port to expose LocalStack on the host machine. /// /// - /// If set, LocalStack will be mapped to this static port on the host. If not set, a dynamic port will be used unless the container lifetime is persistent, - /// in which case the default LocalStack port is used. Useful for avoiding port conflicts or for predictable endpoint URLs. + /// Controls the host port mapping for the LocalStack container. Interacts with : + /// + /// Port = null + Lifetime = Session (Default): Uses dynamic port assignment (avoids conflicts) + /// Port = null + Lifetime = Persistent: Uses static port 4566 (default LocalStack port) + /// Port = 4566 (or any value): Always uses the specified static port (overrides lifetime defaults) + /// + /// Use static ports for predictable URLs and external tool integration. Use dynamic ports for parallel testing and CI/CD. /// public int? Port { get; set; } } diff --git a/src/Aspire.Hosting.LocalStack/LocalStackResourceBuilderExtensions.cs b/src/Aspire.Hosting.LocalStack/LocalStackResourceBuilderExtensions.cs index a4654c3..0c75497 100644 --- a/src/Aspire.Hosting.LocalStack/LocalStackResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.LocalStack/LocalStackResourceBuilderExtensions.cs @@ -168,12 +168,12 @@ public static IDistributedApplicationBuilder UseLocalStack(this IDistributedAppl var resource = new LocalStackResource(name, options); var resourceBuilder = builder.AddResource(resource) - .WithImage(LocalStackContainerImageTags.Image) - .WithImageRegistry(LocalStackContainerImageTags.Registry) - .WithImageTag(LocalStackContainerImageTags.Tag) - .WithHttpEndpoint(targetPort: Constants.DefaultContainerPort, - // Map to a static port if specified or if using a persistent lifetime; otherwise, use dynamic port mapping + .WithImage(containerOptions.ContainerImage ?? LocalStackContainerImageTags.Image) + .WithImageRegistry(containerOptions.ContainerRegistry ?? LocalStackContainerImageTags.Registry) + .WithImageTag(containerOptions.ContainerImageTag ?? LocalStackContainerImageTags.Tag) + .WithHttpEndpoint( // Map to a static port if specified or if using a persistent lifetime; otherwise, use dynamic port mapping port: containerOptions.Port ?? (containerOptions.Lifetime == ContainerLifetime.Persistent ? Constants.DefaultContainerPort : null), + targetPort: Constants.DefaultContainerPort, name: LocalStackResource.PrimaryEndpointName) .WithLifetime(containerOptions.Lifetime) .WithEnvironment("DEBUG", containerOptions.DebugLevel.ToString(CultureInfo.InvariantCulture)) diff --git a/tests/Aspire.Hosting.LocalStack.Unit.Tests/Container/LocalStackContainerOptionsTests.cs b/tests/Aspire.Hosting.LocalStack.Unit.Tests/Container/LocalStackContainerOptionsTests.cs index f4a318f..4359d6c 100644 --- a/tests/Aspire.Hosting.LocalStack.Unit.Tests/Container/LocalStackContainerOptionsTests.cs +++ b/tests/Aspire.Hosting.LocalStack.Unit.Tests/Container/LocalStackContainerOptionsTests.cs @@ -11,7 +11,7 @@ public void Constructor_Should_Set_Correct_Defaults() { var options = new LocalStackContainerOptions(); - Assert.Equal(ContainerLifetime.Persistent, options.Lifetime); + Assert.Equal(ContainerLifetime.Session, options.Lifetime); Assert.Equal(0, options.DebugLevel); Assert.Equal(LocalStackLogLevel.Error, options.LogLevel); Assert.NotNull(options.AdditionalEnvironmentVariables); @@ -117,4 +117,61 @@ public void Port_Should_Be_Settable() Assert.Equal(1234, options.Port); } + + [Fact] + public void ContainerRegistry_Should_Default_To_Null() + { + var options = new LocalStackContainerOptions(); + + Assert.Null(options.ContainerRegistry); + } + + [Fact] + public void ContainerRegistry_Should_Be_Settable() + { + var options = new LocalStackContainerOptions + { + ContainerRegistry = "artifactory.company.com", + }; + + Assert.Equal("artifactory.company.com", options.ContainerRegistry); + } + + [Fact] + public void ContainerImage_Should_Default_To_Null() + { + var options = new LocalStackContainerOptions(); + + Assert.Null(options.ContainerImage); + } + + [Fact] + public void ContainerImage_Should_Be_Settable() + { + var options = new LocalStackContainerOptions + { + ContainerImage = "custom/localstack", + }; + + Assert.Equal("custom/localstack", options.ContainerImage); + } + + [Fact] + public void ContainerImageTag_Should_Default_To_Null() + { + var options = new LocalStackContainerOptions(); + + Assert.Null(options.ContainerImageTag); + } + + [Fact] + public void ContainerImageTag_Should_Be_Settable() + { + var options = new LocalStackContainerOptions + { + ContainerImageTag = "4.9.2", + }; + + Assert.Equal("4.9.2", options.ContainerImageTag); + } } diff --git a/tests/Aspire.Hosting.LocalStack.Unit.Tests/Extensions/ResourceBuilderExtensionsTests/AddLocalStackTests.cs b/tests/Aspire.Hosting.LocalStack.Unit.Tests/Extensions/ResourceBuilderExtensionsTests/AddLocalStackTests.cs index 1cd100c..de73787 100644 --- a/tests/Aspire.Hosting.LocalStack.Unit.Tests/Extensions/ResourceBuilderExtensionsTests/AddLocalStackTests.cs +++ b/tests/Aspire.Hosting.LocalStack.Unit.Tests/Extensions/ResourceBuilderExtensionsTests/AddLocalStackTests.cs @@ -319,4 +319,114 @@ public void AddLocalStack_Should_Set_Endpoint_Port(ContainerLifetime lifetime, i Assert.NotNull(httpEndpoint); Assert.Equal(expectedPort, httpEndpoint.Port); } + + [Fact] + public void AddLocalStack_Should_Use_Default_Container_Image_Values_When_Not_Specified() + { + var builder = DistributedApplication.CreateBuilder([]); + var (localStackOptions, _, _) = TestDataBuilders.CreateMockLocalStackOptions(useLocalStack: true); + + var result = builder.AddLocalStack(localStackOptions: localStackOptions); + + Assert.NotNull(result); + var resource = result.Resource; + Assert.NotNull(resource); + + // Verify default image annotations + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal("docker.io", imageAnnotation.Registry); + Assert.Equal("localstack/localstack", imageAnnotation.Image); + Assert.Equal("4.10.0", imageAnnotation.Tag); + } + + [Fact] + public void AddLocalStack_Should_Use_Custom_Container_Registry_When_Specified() + { + var builder = DistributedApplication.CreateBuilder([]); + var (localStackOptions, _, _) = TestDataBuilders.CreateMockLocalStackOptions(useLocalStack: true); + const string customRegistry = "artifactory.company.com"; + + var result = builder.AddLocalStack( + localStackOptions: localStackOptions, + configureContainer: container => container.ContainerRegistry = customRegistry); + + Assert.NotNull(result); + var resource = result.Resource; + Assert.NotNull(resource); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal(customRegistry, imageAnnotation.Registry); + Assert.Equal("localstack/localstack", imageAnnotation.Image); // Default image + Assert.Equal("4.10.0", imageAnnotation.Tag); // Default tag + } + + [Fact] + public void AddLocalStack_Should_Use_Custom_Container_Image_When_Specified() + { + var builder = DistributedApplication.CreateBuilder([]); + var (localStackOptions, _, _) = TestDataBuilders.CreateMockLocalStackOptions(useLocalStack: true); + const string customImage = "custom/localstack"; + + var result = builder.AddLocalStack( + localStackOptions: localStackOptions, + configureContainer: container => container.ContainerImage = customImage); + + Assert.NotNull(result); + var resource = result.Resource; + Assert.NotNull(resource); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal("docker.io", imageAnnotation.Registry); // Default registry + Assert.Equal(customImage, imageAnnotation.Image); + Assert.Equal("4.10.0", imageAnnotation.Tag); // Default tag + } + + [Fact] + public void AddLocalStack_Should_Use_Custom_Container_ImageTag_When_Specified() + { + var builder = DistributedApplication.CreateBuilder([]); + var (localStackOptions, _, _) = TestDataBuilders.CreateMockLocalStackOptions(useLocalStack: true); + const string customTag = "4.9.2"; + + var result = builder.AddLocalStack( + localStackOptions: localStackOptions, + configureContainer: container => container.ContainerImageTag = customTag); + + Assert.NotNull(result); + var resource = result.Resource; + Assert.NotNull(resource); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal("docker.io", imageAnnotation.Registry); // Default registry + Assert.Equal("localstack/localstack", imageAnnotation.Image); // Default image + Assert.Equal(customTag, imageAnnotation.Tag); + } + + [Fact] + public void AddLocalStack_Should_Use_All_Custom_Container_Image_Values_When_Specified() + { + var builder = DistributedApplication.CreateBuilder([]); + var (localStackOptions, _, _) = TestDataBuilders.CreateMockLocalStackOptions(useLocalStack: true); + const string customRegistry = "artifactory.company.com"; + const string customImage = "docker-mirrors/localstack/localstack"; + const string customTag = "4.9.2"; + + var result = builder.AddLocalStack( + localStackOptions: localStackOptions, + configureContainer: container => + { + container.ContainerRegistry = customRegistry; + container.ContainerImage = customImage; + container.ContainerImageTag = customTag; + }); + + Assert.NotNull(result); + var resource = result.Resource; + Assert.NotNull(resource); + + var imageAnnotation = resource.Annotations.OfType().Single(); + Assert.Equal(customRegistry, imageAnnotation.Registry); + Assert.Equal(customImage, imageAnnotation.Image); + Assert.Equal(customTag, imageAnnotation.Tag); + } }