diff --git a/Directory.Packages.props b/Directory.Packages.props index ea8ccb57..de14c2aa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,6 +12,7 @@ + @@ -21,6 +22,7 @@ + diff --git a/docker-compose.yml b/docker-compose.yml index bba4ad9c..c06c3a06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,22 @@ version: '3.8' services: + localstack: + image: localstack/localstack:4.8 + container_name: adaptiveremote-localstack + ports: + - "4566:4566" + environment: + - SERVICES=lambda,dynamodb,sqs + - DEBUG=1 + - LAMBDA_EXECUTOR=docker + - AWS_DEFAULT_REGION=us-east-1 + volumes: + - localstack-data:/var/lib/localstack + - /var/run/docker.sock:/var/run/docker.sock + networks: + - backend + compiledlayoutservice: build: context: . @@ -14,9 +30,15 @@ services: # See _doc_Auth.md for Cognito dev user pool setup instructions. - Cognito__Authority=${COGNITO_AUTHORITY:-} - Cognito__Audience=${COGNITO_AUDIENCE:-} + - LocalStack__BaseUrl=http://localstack:4566 + depends_on: + - localstack networks: - backend networks: backend: driver: bridge + +volumes: + localstack-data: diff --git a/docs/local-dev.md b/docs/local-dev.md new file mode 100644 index 00000000..06622d97 --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,98 @@ +# Local Backend Development + +This guide covers local backend dependencies for AdaptiveRemote backend services. + +> Current repository state: `AdaptiveRemote.Backend.CompiledLayoutService` is the only +> backend API service currently implemented in `src/`. Apply the same startup and `/scalar` +> checks to additional backend services as they are added. + +## Prerequisites + +1. Install Docker Desktop (or Docker Engine + Docker Compose plugin). +2. Verify tools: + - `docker --version` + - `docker compose version` +3. From the repository root, start local dependencies: + + ```bash + docker compose up -d + ``` + +## Confirm LocalStack health + +LocalStack health endpoint must be reachable: + +```bash +curl http://localhost:4566/_localstack/health +``` + +Expected response contains LocalStack health JSON with either: + +```json +{ "status": "running" } +``` + +or service entries showing required services as available/running, for example: + +```json +{ + "services": { + "dynamodb": "available", + "lambda": "available", + "sqs": "available" + } +} +``` + +## Cognito development credentials + +Set Cognito values for backend services (for `docker-compose` these map to +`COGNITO_AUTHORITY` and `COGNITO_AUDIENCE`): + +- `Cognito__Authority` / `COGNITO_AUTHORITY` +- `Cognito__Audience` / `COGNITO_AUDIENCE` (optional) + +See `src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md` +for full Cognito dev user pool setup. + +## Scalar API browser + +When running backend API services in development, Scalar is available at: + +- `http://localhost:/scalar` + +Scalar is development-only and is not mapped in non-development environments. + +## Lambda local debugging + +Install the Lambda test tool globally (latest .NET 10-compatible package): + +```bash +dotnet tool install -g Amazon.Lambda.TestTool-10.0 +``` + +Use a launch profile that starts the test tool for interactive invocation/debugging. + +## LocalStack Lambda invocation samples + +Use `--endpoint-url http://localhost:4566` for local invocation. + +### LayoutCompilerService + +```bash +aws lambda invoke \ + --endpoint-url http://localhost:4566 \ + --function-name adaptiveremote-layout-compiler-dev \ + --payload '{"id":"00000000-0000-0000-0000-000000000001","userId":"test-user","elements":[]}' \ + response-layout-compiler.json +``` + +### LayoutValidationService + +```bash +aws lambda invoke \ + --endpoint-url http://localhost:4566 \ + --function-name adaptiveremote-layout-validation-dev \ + --payload '{"id":"00000000-0000-0000-0000-000000000001","userId":"test-user","elements":[],"cssDefinitions":[]}' \ + response-layout-validation.json +``` diff --git a/global.json b/global.json index b24aad66..512142d2 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { "version": "10.0.100", - "rollForward": "latestPatch" + "rollForward": "latestFeature" } } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj index a2b8d2e2..8199e544 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -5,10 +5,13 @@ enable enable AdaptiveRemote.Backend.CompiledLayoutService + 3b8e930e-a235-49e8-81b1-db01bf4f9540 + + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs index 761f67ff..a1ff5f46 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs @@ -33,4 +33,10 @@ public static partial class MessageLogger [LoggerMessage(EventId = 1107, Level = LogLevel.Error, Message = "Error processing health check request")] public static partial void ErrorProcessingHealthCheck(this ILogger logger, Exception exception); + + [LoggerMessage( + EventId = 1108, + Level = LogLevel.Error, + Message = "LocalStack dependency check failed at {HealthUrl}: {FailureReason}. LocalStack is required for local development. See docs/local-dev.md for setup instructions")] + public static partial void LocalStackDependencyUnavailable(this ILogger logger, string healthUrl, string failureReason, Exception? exception); } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index e03e5bb2..df0627e8 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -7,6 +7,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Scalar.AspNetCore; +using System.Net.Http; +using System.Text.Json; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); @@ -41,15 +44,28 @@ }); builder.Services.AddAuthorization(); +builder.Services.AddOpenApi(); WebApplication app = builder.Build(); ILogger logger = app.Services.GetRequiredService>(); logger.ServiceStarting(); +if (app.Environment.IsDevelopment()) +{ + await EnsureLocalStackRunningAsync(app, logger).ConfigureAwait(false); +} + app.UseAuthentication(); app.UseAuthorization(); +app.MapOpenApi(); + +if (app.Environment.IsDevelopment()) +{ + app.MapScalarApiReference(); +} + // Map endpoints app.MapHealthEndpoints(); app.MapLayoutEndpoints(); @@ -63,5 +79,111 @@ app.Run(); +static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) +{ + const int LocalStackHealthCheckTimeoutSeconds = 5; + const int LocalStackStartupWaitTimeoutSeconds = 30; + const int LocalStackRetryDelaySeconds = 2; + TimeSpan localStackStartupWaitTimeout = TimeSpan.FromSeconds(LocalStackStartupWaitTimeoutSeconds); + TimeSpan localStackRetryDelay = TimeSpan.FromSeconds(LocalStackRetryDelaySeconds); + string[] requiredServices = ["dynamodb", "lambda", "sqs"]; + + string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; + + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri)) + { + logger.LocalStackDependencyUnavailable(baseUrl, "configuration value is not a valid absolute URL", exception: null); + Environment.Exit(1); + } + + Uri healthUri = new(baseUri, "/_localstack/health"); + + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(LocalStackHealthCheckTimeoutSeconds) }; + Exception? lastException = null; + string? lastFailureReason = null; + DateTime deadlineUtc = DateTime.UtcNow.Add(localStackStartupWaitTimeout); + + while (DateTime.UtcNow < deadlineUtc) + { + try + { + using HttpResponseMessage response = await client.GetAsync(healthUri).ConfigureAwait(false); + string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + lastFailureReason = $"HTTP {(int)response.StatusCode}"; + } + else + { + using JsonDocument json = JsonDocument.Parse(body); + if (IsLocalStackRunning(json.RootElement, requiredServices, out string failureReason)) + { + return; + } + + lastFailureReason = failureReason; + } + + lastException = null; + } + catch (Exception ex) + { + lastException = ex; + lastFailureReason = ex.Message; + } + + await Task.Delay(localStackRetryDelay).ConfigureAwait(false); + } + + logger.LocalStackDependencyUnavailable( + healthUri.ToString(), + $"did not become healthy within {LocalStackStartupWaitTimeoutSeconds}s; last check result: {lastFailureReason ?? "unknown health check failure"}", + lastException); + Environment.Exit(1); +} + +static bool IsLocalStackRunning(JsonElement root, IReadOnlyList requiredServices, out string failureReason) +{ + if (root.TryGetProperty("status", out JsonElement statusElement)) + { + string status = statusElement.GetString() ?? string.Empty; + if (string.Equals(status, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = string.Empty; + return true; + } + + failureReason = $"status='{status}'"; + return false; + } + + if (!root.TryGetProperty("services", out JsonElement servicesElement) || servicesElement.ValueKind != JsonValueKind.Object) + { + failureReason = "health response did not contain a running status or services object"; + return false; + } + + foreach (string service in requiredServices) + { + if (!servicesElement.TryGetProperty(service, out JsonElement serviceStatusElement)) + { + failureReason = $"service '{service}' was missing from health response"; + return false; + } + + string serviceStatus = serviceStatusElement.GetString() ?? string.Empty; + if (!string.Equals(serviceStatus, "available", StringComparison.OrdinalIgnoreCase) && + !string.Equals(serviceStatus, "running", StringComparison.OrdinalIgnoreCase)) + { + failureReason = $"service '{service}' status was '{serviceStatus}'"; + return false; + } + } + + failureReason = string.Empty; + return true; +} + // Make Program visible for testing public partial class Program { } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json index 665343d2..6c3e8839 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Properties/launchSettings.json @@ -1,12 +1,23 @@ { "profiles": { + "Development": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "scalar", + "outputCapture": "None", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:54433;http://localhost:54434" + }, "AdaptiveRemote.Backend.CompiledLayoutService": { "commandName": "Project", "launchBrowser": true, + "launchUrl": "scalar", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, "applicationUrl": "https://localhost:54433;http://localhost:54434" } } -} \ No newline at end of file +} diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md index 843ce8e5..ca4d63f2 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/_doc_Auth.md @@ -22,7 +22,7 @@ The `sub` claim from the validated JWT is used as the `userId` throughout the se - `adaptiveremote-editor` — enable Authorization Code flow; configure allowed callback URL. 3. Create a resource server (custom scope), e.g. `adaptiveremote/layouts.read`. 4. Note the user pool's **Issuer URL** (shown in the pool's details page): - `https://cognito-idp.{region}.amazonaws.com/{userPoolId}` + `https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha` ## Configuring the backend service @@ -30,7 +30,7 @@ Set these environment variables (or values in `appsettings.Development.json` — | Variable | Example | |----------|---------| -| `Cognito__Authority` | `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123` | +| `Cognito__Authority` | `https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha` | | `Cognito__Audience` | `` (optional; leave empty to skip audience validation) | For local development via `docker-compose`, set `COGNITO_AUTHORITY` and `COGNITO_AUDIENCE` in a @@ -45,8 +45,8 @@ Set in `appsettings.Development.json` (non-secret values) and user secrets (secr "backend": { "baseUrl": "http://localhost:8080", "cognito": { - "authority": "https://cognito-idp.{region}.amazonaws.com/{userPoolId}", - "clientId": "YOUR_CLIENT_ID", + "authority": "https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha", + "clientId": "5g6eqq1v1o7lju703enelssl89", "scope": "adaptiveremote/layouts.read" } } @@ -82,6 +82,34 @@ with **Lambda Function URLs**. These URLs are not exposed via API Gateway and ar only from within the ECS cluster (network isolation via VPC/security groups). No bearer token validation is required or expected on internal Lambda endpoints. +## Getting a test token (manual testing / Scalar) + +To test protected endpoints manually (e.g. via the Scalar UI), you need a bearer token from +the `adaptiveremote-client` app client. + +**Option 1 — curl** +```bash +curl -X POST https://us-east-265nkvrlha.auth.us-east-2.amazoncognito.com/oauth2/token \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=client_credentials&client_id=44qanfe7hvaeumffnt5hsk0ojr&client_secret=YOUR_CLIENT_SECRET&scope=adaptiveremote/layouts.read" +``` + +**Option 2 — browser console (no install required)** +```javascript +const resp = await fetch("https://us-east-265nkvrlha.auth.us-east-2.amazoncognito.com/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: "grant_type=client_credentials&client_id=44qanfe7hvaeumffnt5hsk0ojr&client_secret=YOUR_CLIENT_SECRET&scope=adaptiveremote/layouts.read" +}); +console.log(await resp.json()); +``` + +Both return a JSON object containing `access_token`. Set it as `Authorization: Bearer ` +in the request headers. + +If you don't know the token endpoint URL, discover it from the OIDC metadata document: +`https://cognito-idp.us-east-2.amazonaws.com/us-east-2_65NKvrlha/.well-known/openid-configuration` — look for the `token_endpoint` field. + ## API integration tests Tests use a `TestJwtAuthority` (`test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs`), diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json index 3ae5de58..fc09110a 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/appsettings.Development.json @@ -8,5 +8,8 @@ "Cognito": { "Authority": "", "Audience": "" + }, + "LocalStack": { + "BaseUrl": "http://localhost:4566" } } diff --git a/src/_doc_BackendDevelopment.md b/src/_doc_BackendDevelopment.md index 867b0807..e3bb6090 100644 --- a/src/_doc_BackendDevelopment.md +++ b/src/_doc_BackendDevelopment.md @@ -1,18 +1,38 @@ # Backend Development Guide -> **Status:** Stub — to be populated during Task 5 ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)) -> -> See `src/_spec_LayoutCustomizationService.md` Task 5 for the full exit criteria. +This document defines the standing development pattern for backend services introduced by +Task 5 ([ADR-187](https://jodasoft.atlassian.net/browse/ADR-187)). -## Agent Verification Step +## ECS/Fargate-style API services -After every change to a backend service, verify the development environment still works: +All backend API services must follow this local development pattern: -1. **With LocalStack running:** `dotnet run` (or F5 in VS) → confirm the service starts cleanly, log output appears in a console window, and `/scalar` is reachable in a browser -2. **With LocalStack stopped:** `dotnet run` → confirm the process exits with a non-zero code and the console names LocalStack as the missing dependency with a reference to `docs/local-dev.md` +1. Register OpenAPI and map Scalar UI only in development (`/scalar`). +2. Include a `Development` launch profile with `"outputCapture": "None"` so F5 opens a + separate console window in Visual Studio. +3. On startup (development), check `/_localstack/health` on the configured LocalStack base URL. + If unavailable or not `running`, log an error that names LocalStack and references + `docs/local-dev.md`, then exit non-zero immediately. -For Lambda functions: -1. Confirm F5 in VS opens the Lambda Test Tool UI -2. Confirm `aws lambda invoke --endpoint-url http://localhost:4566` returns a valid response +## Lambda services -> This section will be expanded with full setup details and patterns once Task 5 is implemented. +All backend Lambda projects must include: + +1. A launch profile that starts the Lambda Test Tool for interactive local debugging. +2. LocalStack deployment support through `docker-compose`. +3. Documented `aws lambda invoke --endpoint-url http://localhost:4566` sample commands. + +## Agent Verification Step (required after backend changes) + +After every backend service change: + +1. **With LocalStack running:** run the service and confirm clean startup plus `/scalar` availability. +2. **With LocalStack stopped:** run the service and confirm non-zero exit with the LocalStack + dependency error message that includes `docs/local-dev.md`. + +For Lambda services: + +1. Confirm the Lambda Test Tool profile launches successfully. +2. Confirm `aws lambda invoke --endpoint-url http://localhost:4566` returns a valid response. + +See `docs/local-dev.md` for setup and invocation details. diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs index 25b9f59b..959e73e6 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/ServiceFixture.cs @@ -86,6 +86,8 @@ public async Task StartServiceAsync() ["ASPNETCORE_URLS"] = ServiceUrl, // Point the service at the local test JWT authority. ["Cognito__Authority"] = _jwtAuthority.Authority, + // Use the same local test authority host for LocalStack health checks. + ["LocalStack__BaseUrl"] = _jwtAuthority.Authority, } }; @@ -148,7 +150,7 @@ public async Task StartServiceAsync() { lock (_logLock) { - _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] Exception polling {ServiceUrl}/health: {ex.GetType().Name}: {ex.Message}"); + _logOutput.AppendLine($"[HealthCheck attempt {i + 1}] Request failed polling {ServiceUrl}/health: {ex.Message}"); } } diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs index 226f948a..dcba9a7c 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs @@ -11,9 +11,10 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// A minimal local OIDC/JWKS authority used by API integration tests to issue and /// validate JWTs without a real Cognito user pool. /// -/// Exposes two endpoints on a dynamically-assigned localhost port: +/// Exposes three endpoints on a dynamically-assigned localhost port: /// GET /.well-known/openid-configuration — OIDC discovery document /// GET /.well-known/jwks.json — RSA public key in JWK format +/// GET /_localstack/health — LocalStack-compatible health response /// /// The service under test is configured to use this authority via the /// Cognito__Authority environment variable so that bearer token validation @@ -118,6 +119,7 @@ private void HandleRequest(HttpListenerContext context) { "/.well-known/openid-configuration" => BuildDiscoveryDocument(), "/.well-known/jwks.json" => BuildJwks(), + "/_localstack/health" => BuildLocalStackHealth(), _ => BuildNotFound(context), }; @@ -168,6 +170,15 @@ private static byte[] BuildNotFound(HttpListenerContext context) return System.Text.Encoding.UTF8.GetBytes("{}"); } + private static byte[] BuildLocalStackHealth() + { + string json = JsonSerializer.Serialize(new + { + status = "running", + }); + return System.Text.Encoding.UTF8.GetBytes(json); + } + private static int GetFreePort() { using System.Net.Sockets.TcpListener listener = new(IPAddress.Loopback, 0); diff --git a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj index 808f3c5d..f80a6b55 100644 --- a/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj +++ b/test/AdaptiveRemote.EndToEndTests.Host.Wpf/AdaptiveRemote.EndToEndTests.Host.Wpf.csproj @@ -6,6 +6,7 @@ enable enable Exe + true diff --git a/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj b/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj index b7ccd9b2..6e2f5b6e 100644 --- a/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj +++ b/test/AdaptiveRemote.EndtoEndTests.Host.Console/AdaptiveRemote.EndToEndTests.Host.Console.csproj @@ -6,6 +6,7 @@ enable enable Exe + true