From 3ca1dabb0ed0c74557e6f79321de6110b3cba502 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Wed, 22 Apr 2026 16:49:27 -0700 Subject: [PATCH 01/10] Update global.json to allow installed .NET SDKs in different environments --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" } } From 91e7356c7b0180dcff790b0163041456ffc9868e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:21:49 +0000 Subject: [PATCH 02/10] Implement ADR-187 backend dev environment foundations Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/b1ffa40a-c569-45a6-ad54-bf0f1a3681b0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- Directory.Packages.props | 2 + docker-compose.yml | 22 +++++ docs/local-dev.md | 82 +++++++++++++++++++ ...emote.Backend.CompiledLayoutService.csproj | 2 + .../Logging/MessageLogger.cs | 6 ++ .../Program.cs | 62 ++++++++++++++ .../Properties/launchSettings.json | 13 ++- .../appsettings.Development.json | 3 + src/_doc_BackendDevelopment.md | 42 +++++++--- .../Support/ServiceFixture.cs | 4 +- .../Support/TestJwtAuthority.cs | 11 +++ 11 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 docs/local-dev.md 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..b5a95cea --- /dev/null +++ b/docs/local-dev.md @@ -0,0 +1,82 @@ +# Local Backend Development + +This guide covers local backend dependencies for AdaptiveRemote backend services. + +## 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 must report `status: running`: + +```bash +curl http://localhost:4566/_localstack/health +``` + +Expected response contains: + +```json +{ "status": "running" } +``` + +## 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/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj index a2b8d2e2..e12f761b 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -9,6 +9,8 @@ + + diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs index 761f67ff..49cce7b8 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); } diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index e03e5bb2..0b6e39f7 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,51 @@ app.Run(); +static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) +{ + string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; + + if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri)) + { + logger.LocalStackDependencyUnavailable(baseUrl, "invalid LocalStack base URL"); + Environment.Exit(1); + return; + } + + Uri healthUri = new(baseUri, "/_localstack/health"); + + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(5) }; + + try + { + using HttpResponseMessage response = await client.GetAsync(healthUri).ConfigureAwait(false); + string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + logger.LocalStackDependencyUnavailable(healthUri.ToString(), $"HTTP {(int)response.StatusCode}"); + Environment.Exit(1); + return; + } + + using JsonDocument json = JsonDocument.Parse(body); + string status = json.RootElement.TryGetProperty("status", out JsonElement statusElement) + ? statusElement.GetString() ?? string.Empty + : string.Empty; + + if (!string.Equals(status, "running", StringComparison.OrdinalIgnoreCase)) + { + logger.LocalStackDependencyUnavailable(healthUri.ToString(), $"status='{status}'"); + Environment.Exit(1); + return; + } + } + catch (Exception ex) + { + logger.LocalStackDependencyUnavailable(healthUri.ToString(), ex.Message); + Environment.Exit(1); + } +} + // 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/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..3eb07a55 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs @@ -14,6 +14,7 @@ namespace AdaptiveRemote.Backend.ApiTests.Support; /// Exposes two 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); From a2fe5d5336832ce2746137f302fc3a61fc4d347d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:26:04 +0000 Subject: [PATCH 03/10] Enable windows targeting for non-headless E2E host projects Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/b1ffa40a-c569-45a6-ad54-bf0f1a3681b0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../AdaptiveRemote.EndToEndTests.Host.Wpf.csproj | 1 + .../AdaptiveRemote.EndToEndTests.Host.Console.csproj | 1 + 2 files changed, 2 insertions(+) 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 From a5089f1ff23cdd257f9667f1b0c4da5cd423e760 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 19:32:00 +0000 Subject: [PATCH 04/10] Refine LocalStack health check diagnostics in backend startup Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/b1ffa40a-c569-45a6-ad54-bf0f1a3681b0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Program.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index 0b6e39f7..9d16a2d3 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -81,18 +81,19 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) { + const int LocalStackHealthCheckTimeoutSeconds = 5; + string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri)) { - logger.LocalStackDependencyUnavailable(baseUrl, "invalid LocalStack base URL"); + logger.LocalStackDependencyUnavailable(baseUrl, "configuration value is not a valid absolute URL"); Environment.Exit(1); - return; } Uri healthUri = new(baseUri, "/_localstack/health"); - using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(5) }; + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(LocalStackHealthCheckTimeoutSeconds) }; try { @@ -103,7 +104,6 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge { logger.LocalStackDependencyUnavailable(healthUri.ToString(), $"HTTP {(int)response.StatusCode}"); Environment.Exit(1); - return; } using JsonDocument json = JsonDocument.Parse(body); @@ -115,7 +115,6 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge { logger.LocalStackDependencyUnavailable(healthUri.ToString(), $"status='{status}'"); Environment.Exit(1); - return; } } catch (Exception ex) From 4a575e4046cedcd8b00a2b307a97a55e448613f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:02:02 +0000 Subject: [PATCH 05/10] Fix LocalStack health parsing for dotnet run startup verification Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/d15fa3e9-7842-4324-aade-379b3c18dde0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- docs/local-dev.md | 18 ++++++- .../Program.cs | 51 ++++++++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/docs/local-dev.md b/docs/local-dev.md index b5a95cea..98a5e542 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -2,6 +2,10 @@ 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). @@ -22,12 +26,24 @@ LocalStack must report `status: running`: curl http://localhost:4566/_localstack/health ``` -Expected response contains: +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 diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index 9d16a2d3..4ac8091e 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -82,6 +82,7 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) { const int LocalStackHealthCheckTimeoutSeconds = 5; + string[] requiredServices = ["dynamodb", "lambda", "sqs"]; string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; @@ -107,13 +108,9 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge } using JsonDocument json = JsonDocument.Parse(body); - string status = json.RootElement.TryGetProperty("status", out JsonElement statusElement) - ? statusElement.GetString() ?? string.Empty - : string.Empty; - - if (!string.Equals(status, "running", StringComparison.OrdinalIgnoreCase)) + if (!IsLocalStackRunning(json.RootElement, requiredServices, out string failureReason)) { - logger.LocalStackDependencyUnavailable(healthUri.ToString(), $"status='{status}'"); + logger.LocalStackDependencyUnavailable(healthUri.ToString(), failureReason); Environment.Exit(1); } } @@ -124,5 +121,47 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge } } +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 { } From 4db4e79acf7fc2da1153f0de0482bf4b9ed815d8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 20:02:19 +0000 Subject: [PATCH 06/10] Clarify LocalStack health response docs Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/d15fa3e9-7842-4324-aade-379b3c18dde0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- docs/local-dev.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/local-dev.md b/docs/local-dev.md index 98a5e542..06622d97 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -20,7 +20,7 @@ This guide covers local backend dependencies for AdaptiveRemote backend services ## Confirm LocalStack health -LocalStack must report `status: running`: +LocalStack health endpoint must be reachable: ```bash curl http://localhost:4566/_localstack/health From 0c169244e389bb8a0d70c3f026a47f1f0b842ef7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:12:06 +0000 Subject: [PATCH 07/10] Add LocalStack startup retries and exception-aware dependency logging Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/af738fc3-eb45-4437-8bb9-b96b3afb41f0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Logging/MessageLogger.cs | 2 +- .../Program.cs | 56 +++++++++++++------ .../Support/TestJwtAuthority.cs | 2 +- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs index 49cce7b8..a1ff5f46 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Logging/MessageLogger.cs @@ -38,5 +38,5 @@ public static partial class MessageLogger 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); + 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 4ac8091e..8aa9a080 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -82,43 +82,63 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) { const int LocalStackHealthCheckTimeoutSeconds = 5; + TimeSpan localStackStartupWaitTimeout = TimeSpan.FromSeconds(30); + TimeSpan localStackRetryDelay = TimeSpan.FromSeconds(2); 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"); + 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 = "unknown health check failure"; + DateTime deadlineUtc = DateTime.UtcNow.Add(localStackStartupWaitTimeout); - try + while (DateTime.UtcNow < deadlineUtc) { - using HttpResponseMessage response = await client.GetAsync(healthUri).ConfigureAwait(false); - string body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - - if (!response.IsSuccessStatusCode) + try { - logger.LocalStackDependencyUnavailable(healthUri.ToString(), $"HTTP {(int)response.StatusCode}"); - Environment.Exit(1); + 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; } - - using JsonDocument json = JsonDocument.Parse(body); - if (!IsLocalStackRunning(json.RootElement, requiredServices, out string failureReason)) + catch (Exception ex) { - logger.LocalStackDependencyUnavailable(healthUri.ToString(), failureReason); - Environment.Exit(1); + lastException = ex; + lastFailureReason = ex.Message; } + + await Task.Delay(localStackRetryDelay).ConfigureAwait(false); } - catch (Exception ex) - { - logger.LocalStackDependencyUnavailable(healthUri.ToString(), ex.Message); - Environment.Exit(1); - } + + logger.LocalStackDependencyUnavailable( + healthUri.ToString(), + $"did not become healthy within {localStackStartupWaitTimeout.TotalSeconds:0}s; last check result: {lastFailureReason}", + lastException); + Environment.Exit(1); } static bool IsLocalStackRunning(JsonElement root, IReadOnlyList requiredServices, out string failureReason) diff --git a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs index 3eb07a55..dcba9a7c 100644 --- a/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs +++ b/test/AdaptiveRemote.Backend.ApiTests/Support/TestJwtAuthority.cs @@ -11,7 +11,7 @@ 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 From 995895c0429e0d123f3768cca183a989779b3ed3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:15:13 +0000 Subject: [PATCH 08/10] Address reviewer nits in LocalStack startup checks Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/af738fc3-eb45-4437-8bb9-b96b3afb41f0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Program.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index 8aa9a080..f399ad8a 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -82,8 +82,8 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) { const int LocalStackHealthCheckTimeoutSeconds = 5; - TimeSpan localStackStartupWaitTimeout = TimeSpan.FromSeconds(30); - TimeSpan localStackRetryDelay = TimeSpan.FromSeconds(2); + TimeSpan LocalStackStartupWaitTimeout = TimeSpan.FromSeconds(30); + TimeSpan LocalStackRetryDelay = TimeSpan.FromSeconds(2); string[] requiredServices = ["dynamodb", "lambda", "sqs"]; string baseUrl = app.Configuration["LocalStack:BaseUrl"] ?? "http://localhost:4566"; @@ -98,8 +98,8 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(LocalStackHealthCheckTimeoutSeconds) }; Exception? lastException = null; - string lastFailureReason = "unknown health check failure"; - DateTime deadlineUtc = DateTime.UtcNow.Add(localStackStartupWaitTimeout); + string? lastFailureReason = null; + DateTime deadlineUtc = DateTime.UtcNow.Add(LocalStackStartupWaitTimeout); while (DateTime.UtcNow < deadlineUtc) { @@ -131,12 +131,12 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge lastFailureReason = ex.Message; } - await Task.Delay(localStackRetryDelay).ConfigureAwait(false); + await Task.Delay(LocalStackRetryDelay).ConfigureAwait(false); } logger.LocalStackDependencyUnavailable( healthUri.ToString(), - $"did not become healthy within {localStackStartupWaitTimeout.TotalSeconds:0}s; last check result: {lastFailureReason}", + $"did not become healthy within {LocalStackStartupWaitTimeout.TotalSeconds:0}s; last check result: {lastFailureReason ?? "unknown health check failure"}", lastException); Environment.Exit(1); } From 3f658c016feb42c0c15957d71500c35d03c3a561 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:17:11 +0000 Subject: [PATCH 09/10] Refine LocalStack timeout constants and naming consistency Agent-Logs-Url: https://github.com/jodavis/AdaptiveRemote/sessions/af738fc3-eb45-4437-8bb9-b96b3afb41f0 Co-authored-by: jodavis <6740581+jodavis@users.noreply.github.com> --- .../Program.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs index f399ad8a..df0627e8 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs @@ -82,8 +82,10 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logger) { const int LocalStackHealthCheckTimeoutSeconds = 5; - TimeSpan LocalStackStartupWaitTimeout = TimeSpan.FromSeconds(30); - TimeSpan LocalStackRetryDelay = TimeSpan.FromSeconds(2); + 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"; @@ -99,7 +101,7 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(LocalStackHealthCheckTimeoutSeconds) }; Exception? lastException = null; string? lastFailureReason = null; - DateTime deadlineUtc = DateTime.UtcNow.Add(LocalStackStartupWaitTimeout); + DateTime deadlineUtc = DateTime.UtcNow.Add(localStackStartupWaitTimeout); while (DateTime.UtcNow < deadlineUtc) { @@ -131,12 +133,12 @@ static async Task EnsureLocalStackRunningAsync(WebApplication app, ILogger logge lastFailureReason = ex.Message; } - await Task.Delay(LocalStackRetryDelay).ConfigureAwait(false); + await Task.Delay(localStackRetryDelay).ConfigureAwait(false); } logger.LocalStackDependencyUnavailable( healthUri.ToString(), - $"did not become healthy within {LocalStackStartupWaitTimeout.TotalSeconds:0}s; last check result: {lastFailureReason ?? "unknown health check failure"}", + $"did not become healthy within {LocalStackStartupWaitTimeoutSeconds}s; last check result: {lastFailureReason ?? "unknown health check failure"}", lastException); Environment.Exit(1); } From 8844b71a75d117bd6d5046dcc557d1b6a2e6d257 Mon Sep 17 00:00:00 2001 From: Joe Davis Date: Wed, 22 Apr 2026 16:31:48 -0700 Subject: [PATCH 10/10] Update _doc_Auth.md with details on how to get an access token for scalar testing. --- ...emote.Backend.CompiledLayoutService.csproj | 1 + .../_doc_Auth.md | 36 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj index e12f761b..8199e544 100644 --- a/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj +++ b/src/AdaptiveRemote.Backend.CompiledLayoutService/AdaptiveRemote.Backend.CompiledLayoutService.csproj @@ -5,6 +5,7 @@ enable enable AdaptiveRemote.Backend.CompiledLayoutService + 3b8e930e-a235-49e8-81b1-db01bf4f9540 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`),