Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.22" />
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="8.0.100" />
<!-- Microsoft Extensions -->
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.6" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Ini" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.3" />
<!-- OpenTelemetry -->
Expand All @@ -21,6 +22,7 @@
<!-- Third-party Libraries -->
<PackageVersion Include="I8Beef.TiVo" Version="1.0.0.14" />
<PackageVersion Include="Microsoft.Playwright" Version="1.58.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.1" />
<PackageVersion Include="StreamJsonRpc" Version="2.24.84" />
<PackageVersion Include="System.Speech" Version="8.0.0" />
<PackageVersion Include="System.Text.Json" Version="8.0.5" />
Expand Down
22 changes: 22 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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: .
Expand All @@ -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:
98 changes: 98 additions & 0 deletions docs/local-dev.md
Original file line number Diff line number Diff line change
@@ -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:<port>/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
```
2 changes: 1 addition & 1 deletion global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestPatch"
"rollForward": "latestFeature"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>AdaptiveRemote.Backend.CompiledLayoutService</RootNamespace>
<UserSecretsId>3b8e930e-a235-49e8-81b1-db01bf4f9540</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Scalar.AspNetCore" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
122 changes: 122 additions & 0 deletions src/AdaptiveRemote.Backend.CompiledLayoutService/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -41,15 +44,28 @@
});

builder.Services.AddAuthorization();
builder.Services.AddOpenApi();

WebApplication app = builder.Build();

ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
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();
Expand All @@ -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<string> 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 { }
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Loading