diff --git a/.agents/skills/docker-and-compose/SKILL.md b/.agents/skills/docker-and-compose/SKILL.md index 97c4488..16854ab 100644 --- a/.agents/skills/docker-and-compose/SKILL.md +++ b/.agents/skills/docker-and-compose/SKILL.md @@ -19,9 +19,26 @@ One multi-stage `Dockerfile` does everything the pipeline needs: | `publish` | Publishes the Presentation project | | `run` | The runtime image: non-root `$APP_UID`, `WORKDIR /app`, data in `/app/data`, a `HEALTHCHECK` on `/health/live` | -`docker-compose.yml` runs the app with `DOTNET_ENVIRONMENT=docker`, the `movie-reviews-data` volume a pinned OpenTelemetry Collector that receives the app's OTLP logs, traces and metrics, configured by `otel-collector.yaml`, and a pinned Aspire dashboard the collector forwards them to (`telemetry`). The dashboard UI is at , and the collector's OTLP ports are published on 4317 and 4318 so a `dotnet run` can send to it too. The dashboard runs with `ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS` and the collector's OTLP ports take anything sent to them, on purpose: the compose file is for a developer's machine, with nothing to sign in to. Keep it that way rather than adding sign-in, and don't offer the compose file for a shared or deployed environment. Images are built by CI but not pushed anywhere; the template ships the Dockerfile so users can. +`docker-compose.yml` runs the app with `DOTNET_ENVIRONMENT=docker`, the `movie-reviews-data` volume a pinned OpenTelemetry Collector that receives the app's OTLP logs, traces and metrics, configured by `otel-collector.yaml`, and a pinned Aspire dashboard the collector forwards them to (`telemetry`). The dashboard UI and the collector's OTLP ports are published on the template's own ports (below), so a `dotnet run` can send to the collector too. The dashboard runs with `ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS` and the collector's OTLP ports take anything sent to them, on purpose: the compose file is for a developer's machine, with nothing to sign in to. Keep it that way rather than adding sign-in, and don't offer the compose file for a shared or deployed environment. Images are built by CI but not pushed anywhere; the template ships the Dockerfile so users can. -The Lambda template's `run` stage is the AWS Lambda .NET base image instead. It runs as user `65534` from `/var/task`, keeps its database in `/tmp`, and has no `HEALTHCHECK`. Its compose file runs the collector and the Aspire dashboard as well. Compose maps the runtime interface emulator to port 9000, so the function can be invoked with a `POST` to `/2015-03-31/functions/function/invocations`. +The Lambda template's `run` stage is the AWS Lambda .NET base image instead. It runs as user `65534` from `/var/task`, keeps its database in `/tmp`, and has no `HEALTHCHECK`. Its compose file runs the collector and the Aspire dashboard as well. Compose maps the runtime interface emulator to 7350, so the function can be invoked with a `POST` to `/2015-03-31/functions/function/invocations`. + +### Ports + +Every port published on the developer's machine, by the compose file or by `launchSettings.json`, sits in the template's own block of ten, so any two Lean Mean Clean solutions run side by side. The blocks follow the template order: Minimal API 7310, MCP 7320, GraphQL 7330, Web API 7340, Lambda Function 7350. Each port has the same place in every block: + +| Offset | Port | +| --- | --- | +| +0 | The app over HTTP, from Docker Compose or `dotnet run` | +| +1 | The app over HTTPS, `dotnet run` only | +| +2 | The Aspire dashboard | +| +3 | The collector's OTLP over gRPC | +| +4 | The collector's OTLP over HTTP | +| +5 | The MCP Inspector | +| +6 | The MCP chat demo | +| +7 to +9 | Spare | + +`docs/RUN.md` lists the ports this solution uses. The app listens on its own port everywhere: under `dotnet run`, in the image (the `Dockerfile` sets `ASPNETCORE_HTTP_PORTS`, `EXPOSE` and the `HEALTHCHECK` to it) and in the compose file, so `docker run -p` maps it to the same number. Change all four together. The Lambda template is the one exception: Lambda's runtime interface emulator always listens on 8080 inside its container, so only the host side of its mapping is ours. The dashboard, the collector and other third-party images keep their usual ports inside (18888, 4317 and 4318), and only the host side of their mappings moves. A new service takes a spare port from the block, never a product's well-known one such as 3000, 5432 or 9000. `docker compose port ` prints where a port ended up. ## Steps @@ -42,7 +59,7 @@ The Lambda template's `run` stage is the AWS Lambda .NET base image instead. It ``` bash docker build . --target run --tag local-check -docker compose up -d --build && curl -fsS http://localhost:8080/health/ready -# Lambda: curl -fsS -X POST http://localhost:9000/2015-03-31/functions/function/invocations -d '{}' +docker compose up -d --build --wait # waits for the image's HEALTHCHECK +# Lambda: curl -fsS -X POST "http://$(docker compose port function 8080)/2015-03-31/functions/function/invocations" -d '{}' docker compose down ``` diff --git a/.agents/skills/expose-a-use-case/SKILL.md b/.agents/skills/expose-a-use-case/SKILL.md index c7369c0..edb25da 100644 --- a/.agents/skills/expose-a-use-case/SKILL.md +++ b/.agents/skills/expose-a-use-case/SKILL.md @@ -65,5 +65,5 @@ public async Task> CreateReview(CreateReviewReques ``` bash dotnet test tests/Presentation.Tests.Integration -dotnet run --project ./src/Presentation # then read the action at http://localhost:8080/scalar +dotnet run --project ./src/Presentation # then read the action at http://localhost:7340/scalar ``` diff --git a/.agents/skills/openapi-and-scalar/SKILL.md b/.agents/skills/openapi-and-scalar/SKILL.md index 38e80fd..b352695 100644 --- a/.agents/skills/openapi-and-scalar/SKILL.md +++ b/.agents/skills/openapi-and-scalar/SKILL.md @@ -26,7 +26,7 @@ Scalar is set up in `ProgramExtensions.ConfigureApplication` with the title, C# 2. Something every operation needs goes in an operation transformer; something about the whole API goes in a document transformer. Register it in `AddOpenApi` in `ProgramExtensions.ConfigureBuilder`. 3. Transformers are classes implementing `IOpenApiDocumentTransformer` or `IOpenApiOperationTransformer`, `sealed`, with XML docs. Unit test them in `tests/Presentation.Tests.Unit/OpenApi/`. 4. When you add an action, update the operation count in `GetOpenApiDocument_ShouldDescribe_EveryOperation`. -5. Run the app and read the result at . +5. Run the app and read the result at . ## Example @@ -69,5 +69,5 @@ _ = app.MapScalarApiReference(options => options ``` bash dotnet test --filter "FullyQualifiedName~OpenApi" -dotnet run --project ./src/Presentation # then open http://localhost:8080/scalar +dotnet run --project ./src/Presentation # then open http://localhost:7340/scalar ``` diff --git a/.agents/skills/serilog/SKILL.md b/.agents/skills/serilog/SKILL.md index a3a583e..b383ba2 100644 --- a/.agents/skills/serilog/SKILL.md +++ b/.agents/skills/serilog/SKILL.md @@ -14,7 +14,7 @@ Serilog is the logger. Application and Infrastructure code log through `Microsof | Environment | Sink | Where to look | | --- | --- | --- | | `local` | Console, with the output template in `appsettings.local.json` | The terminal | -| `docker` | Console, and OTLP through the collector to the Aspire dashboard | , or `docker compose logs` | +| `docker` | Console, and OTLP through the collector to the Aspire dashboard | The Aspire dashboard, on the port `docs/RUN.md` lists, or `docker compose logs` | | Lambda, anywhere | Console, as compact JSON, and OTLP when the endpoint is set | CloudWatch Logs, or `docker compose logs` for the emulator, and the Aspire dashboard in Docker Compose | | Any, when `OTEL_EXPORTER_OTLP_ENDPOINT` is set | Also OTLP, through `Serilog.Sinks.OpenTelemetry` | Whatever receives the OTLP (`telemetry`) | @@ -108,6 +108,6 @@ _ = builder.Services.AddSerilog((services, loggerConfiguration) => ``` bash dotnet run --project ./src/Presentation # watch the console, then make a request -docker compose up -d --build # then open http://localhost:18888 +docker compose up -d --build # then open the Aspire dashboard on the port docs/RUN.md lists dotnet test tests/Application.Tests.Unit # LoggingBehaviourTests check the messages with FakeLogger ``` diff --git a/.agents/skills/telemetry/SKILL.md b/.agents/skills/telemetry/SKILL.md index 34d0eb8..1c7e7ae 100644 --- a/.agents/skills/telemetry/SKILL.md +++ b/.agents/skills/telemetry/SKILL.md @@ -40,7 +40,7 @@ The Lambda template builds its tracer and meter providers once in `Startup` with The app sends to an OpenTelemetry Collector, never straight to a backend, because that's how it should run in production: the collector owns batching, retries, sampling, scrubbing and the choice of backend, so none of that lives in the app. `otel-collector.yaml` holds its configuration: an OTLP receiver on 4317 (gRPC) and 4318 (HTTP), the `memory_limiter` and `batch` processors, and one exporter per backend. -Locally, Docker Compose runs the collector (`otel/opentelemetry-collector`) and the Aspire dashboard (`mcr.microsoft.com/dotnet/aspire-dashboard`), and the collector forwards everything to the dashboard. The dashboard is only a container that receives OTLP and shows logs, traces and metrics at ; it keeps everything in memory, so it's empty after a restart. In production, run a collector beside the app or as a gateway and swap the dashboard exporter for your backend's. +Locally, Docker Compose runs the collector (`otel/opentelemetry-collector`) and the Aspire dashboard (`mcr.microsoft.com/dotnet/aspire-dashboard`), and the collector forwards everything to the dashboard. The dashboard is only a container that receives OTLP and shows logs, traces and metrics on the template's dashboard port (`docker-and-compose`); it keeps everything in memory, so it's empty after a restart. In production, run a collector beside the app or as a gateway and swap the dashboard exporter for your backend's. ## Steps @@ -51,7 +51,7 @@ Locally, Docker Compose runs the collector (`otel/opentelemetry-collector`) and 5. Use stable packages only. When the only instrumentation is a prerelease package, go without that signal and add it to the watch list below. 6. Test spans with an `ActivityListener` that keeps only the test's own trace (`unit-tests`). The integration tests send a `traceparent` header and check the request and use case spans join up. 7. To send telemetry somewhere new, add an exporter to `otel-collector.yaml` and list it in the pipelines. The app's settings don't change. -8. To see it locally, run `docker compose up -d --build` and open . For a `dotnet run`, start the collector with `docker compose up -d otel-collector` and set `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317`. +8. To see it locally, run `docker compose up -d --build` and open the dashboard on the port `docs/RUN.md` lists. For a `dotnet run`, start the collector with `docker compose up -d otel-collector` and set `OTEL_EXPORTER_OTLP_ENDPOINT` to its gRPC port, `http://$(docker compose port otel-collector 4317)`. ## Example @@ -110,5 +110,5 @@ These signals and packages aren't stable for .NET yet. Check them when dependenc ``` bash dotnet test --filter "FullyQualifiedName~Telemetry|FullyQualifiedName~LoggingBehaviour" # use case span and end-to-end trace tests -docker compose up -d --build # then open http://localhost:18888 and make a request +docker compose up -d --build # then open the dashboard on the port docs/RUN.md lists and make a request ``` diff --git a/.github/workflows/build-pipeline.yml b/.github/workflows/build-pipeline.yml index f192cdf..ec1a777 100644 --- a/.github/workflows/build-pipeline.yml +++ b/.github/workflows/build-pipeline.yml @@ -357,16 +357,16 @@ jobs: echo "smoke=failed" >> "$GITHUB_OUTPUT" # The database goes in /app/data, the only folder the non-root user can write to, as it does # in docker-compose.yml. Logs stay on the console so a failure shows up below. - docker run --detach --name api --publish 8080:8080 \ + docker run --detach --name api --publish 7340:7340 \ --env ConnectionStrings__MovieReviews="Data Source=/app/data/movie-reviews.db" \ --env Database__SeedSampleData=true "$IMAGE" for attempt in $(seq 1 30); do - if curl --silent --fail --max-time 5 http://localhost:8080/health/ready > /dev/null; then + if curl --silent --fail --max-time 5 http://localhost:7340/health/ready > /dev/null; then break fi sleep 1 done - RESPONSE=$(curl --silent --fail --max-time 30 "http://localhost:8080/api/movies?limit=2" || true) + RESPONSE=$(curl --silent --fail --max-time 30 "http://localhost:7340/api/movies?limit=2" || true) echo "$RESPONSE" if echo "$RESPONSE" | python3 -c 'import json, sys; sys.exit(0 if json.load(sys.stdin).get("items") else 1)'; then echo "smoke=passed" >> "$GITHUB_OUTPUT" diff --git a/AGENTS.md b/AGENTS.md index 57f1f40..70a717e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ Run these before you say a change is done. They're what CI runs. dotnet build # warnings are errors dotnet test # every test project dotnet format --verify-no-changes # style, whitespace and usings -dotnet run --project ./src/Presentation # the API at http://localhost:8080/scalar +dotnet run --project ./src/Presentation # the API at http://localhost:7340/scalar ``` The `coverage` skill has the command that checks the 95% line coverage gate locally. diff --git a/Dockerfile b/Dockerfile index d5f746a..6d96dc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,8 @@ RUN dotnet publish ./src/**/Presentation.csproj --no-restore -c Release -v quiet # Runtime Image FROM ${BASE_IMAGE_REPO}/${BASE_IMAGE_RUNTIME}:${BASE_IMAGE_RUNTIME_TAG} AS run WORKDIR /app -EXPOSE 8080 +ENV ASPNETCORE_HTTP_PORTS=7340 +EXPOSE 7340 COPY --from=publish /sln/app . # The database lives in /app/data, the only folder the non-root app user can write to. @@ -67,6 +68,6 @@ RUN mkdir -p /app/data && chown $APP_UID /app/data USER $APP_UID HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:8080/health/live || exit 1 + CMD wget -qO- http://localhost:7340/health/live || exit 1 ENTRYPOINT ["dotnet", "Presentation.dll"] diff --git a/NUGET.md b/NUGET.md index fa11c86..9d7e3bb 100644 --- a/NUGET.md +++ b/NUGET.md @@ -45,13 +45,13 @@ Rather click than type? Visual Studio and Rider show the same options in their n docker compose up -d --build # Ask it for a page of movies -curl "http://localhost:8080/api/movies?query=space&limit=2" +curl "http://localhost:7340/api/movies?query=space&limit=2" # Run the tests while you're here dotnet test ``` -Then open [Scalar](https://github.com/scalar/scalar) at to browse and try every endpoint. +Then open [Scalar](https://github.com/scalar/scalar) at to browse and try every endpoint. The API is open unless you create it with `--include-auth --authority` (and usually `--audience`), and then it asks for a JWT bearer token. Leave `--include-auth` out and there's no auth code at all. diff --git a/README.md b/README.md index 73d5cff..ab7b5fb 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ That's three layers, four test projects, a Dockerfile and a CI pipeline. Go and docker compose up -d --build # Ask it for a page of movies -curl "http://localhost:8080/api/movies?query=space&limit=2" +curl "http://localhost:7340/api/movies?query=space&limit=2" ``` ```json @@ -75,7 +75,7 @@ The sample movies are all made up, and the critics are harsh. Every list endpoint returns one page at a time, with its `offset`, `limit` and a `totalCount`, so you always know how much more there is. Movies and authors come with their review count and average stars, worked out by the database rather than in a loop somewhere. Ask for one by id and its reviews come along for the ride. -Open [Scalar](https://github.com/scalar/scalar) at to browse and try every endpoint. Each one is documented with XML comments that the .NET 10 OpenAPI generator reads at build time, so the document at `/openapi/v1.json` can't drift from the code. Its logs, traces and metrics land in the [Aspire dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone) at . +Open [Scalar](https://github.com/scalar/scalar) at to browse and try every endpoint. Each one is documented with XML comments that the .NET 10 OpenAPI generator reads at build time, so the document at `/openapi/v1.json` can't drift from the code. Its logs, traces and metrics land in the [Aspire dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone) at . No Docker? `dotnet run --project src/Presentation` works too. Every other way to run it, from plain `docker run` to stamping a version into the image, is in [docs/RUN.md](docs/RUN.md). diff --git a/docker-compose.yml b/docker-compose.yml index 602da77..deb4211 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: VERSION_PREFIX: 1.0.0.0 VERSION_SUFFIX: docker ports: - - "8080:8080" + - "7340:7340" environment: DOTNET_ENVIRONMENT: docker OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 @@ -23,8 +23,8 @@ services: volumes: - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro ports: - - "4317:4317" - - "4318:4318" + - "7343:4317" + - "7344:4318" networks: - cleanwebapi_network depends_on: @@ -32,7 +32,7 @@ services: aspire-dashboard: image: mcr.microsoft.com/dotnet/aspire-dashboard:13.6.2 ports: - - "18888:18888" + - "7342:18888" environment: ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" networks: diff --git a/docs/RUN.md b/docs/RUN.md index a43bd7e..ee99044 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -21,20 +21,20 @@ dotnet ef migrations add --project ./src/Infrastructure --output-dir Data dotnet run --project ./src/Presentation ``` -It listens on , and on , which redirects there. If your browser or `curl` doesn't trust the .NET development certificate yet, run `dotnet dev-certs https --trust` once. +It listens on , and on , which redirects there. If your browser or `curl` doesn't trust the .NET development certificate yet, run `dotnet dev-certs https --trust` once. ## Docker Compose -`docker-compose.yml` starts the app, and the [Aspire dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone), which shows its logs, traces and metrics. The app sends everything to an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/), and the collector forwards it to the dashboard, the same shape as a production setup. The dashboard is only a container that receives OTLP; nothing in the solution depends on Aspire. The dashboard has no login and the collector accepts OTLP from anyone on ports 4317 and 4318. That's on purpose for a developer's machine, so don't run this compose file anywhere other people can reach. +`docker-compose.yml` starts the app, and the [Aspire dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone), which shows its logs, traces and metrics. The app sends everything to an [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/), and the collector forwards it to the dashboard, the same shape as a production setup. The dashboard is only a container that receives OTLP; nothing in the solution depends on Aspire. The dashboard has no login and the collector accepts OTLP from anyone on ports 7343 and 7344. That's on purpose for a developer's machine, so don't run this compose file anywhere other people can reach. ``` bash docker compose up -d --build ``` -- App: (Scalar API reference) -- OpenAPI document: -- Aspire dashboard: -- Health checks: (the process is up) and (the database can be reached). Both answer without a token. The image's `HEALTHCHECK` uses the live one. +- App: (Scalar API reference) +- OpenAPI document: +- Aspire dashboard: +- Health checks: (the process is up) and (the database can be reached). Both answer without a token. The image's `HEALTHCHECK` uses the live one. The container runs as the image's non-root `app` user. @@ -44,6 +44,20 @@ To shut the containers down: docker compose down ``` +## Ports + +Every Lean Mean Clean template has its own block of ten ports on your machine, so this solution runs beside one made from another template without a clash. This one uses 7340 to 7349: + +| Port | What listens | +| --- | --- | +| 7340 | The app over HTTP, from Docker Compose or `dotnet run` | +| 7341 | The app over HTTPS, `dotnet run` only | +| 7342 | The Aspire dashboard | +| 7343 | The OpenTelemetry Collector, OTLP over gRPC | +| 7344 | The OpenTelemetry Collector, OTLP over HTTP | + +The app listens on 7340 everywhere: under `dotnet run`, inside its container and on your machine, so `docker run -p 7340:7340` works the same way. The dashboard and collector keep their usual ports inside their containers. To move the app, change `applicationUrl` in `launchSettings.json`, `ASPNETCORE_HTTP_PORTS`, `EXPOSE` and the `HEALTHCHECK` in the `Dockerfile`, and its mapping in `docker-compose.yml`. When you deploy the image, point the platform's target port at 7340. 7345 to 7349 are spare for anything you add. + ## Telemetry The app always collects [OpenTelemetry](https://opentelemetry.io) traces and metrics, and exports them, along with its logs, only when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. Every other standard `OTEL_*` variable works too, such as `OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_PROTOCOL` and `OTEL_EXPORTER_OTLP_HEADERS`, so sending it to Grafana, Honeycomb, Seq or a collector is a settings change. @@ -54,14 +68,14 @@ To see a `dotnet run` in the dashboard, start the collector and the dashboard, a ``` bash docker compose up -d otel-collector -OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 dotnet run --project ./src/Presentation +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:7343 dotnet run --project ./src/Presentation ``` Every span, metric and log carries `service.version`, so you can always tell which build sent it. ## HTTPS -The app redirects HTTP to HTTPS whenever it has an HTTPS port, which is why `dotnet run` sends on to . Outside the `local` and `Development` environments it also sends an HSTS header. The container only listens on HTTP, so in production put it behind a proxy or load balancer that ends TLS, and set `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` so the app knows the original request was HTTPS. +The app redirects HTTP to HTTPS whenever it has an HTTPS port, which is why `dotnet run` sends on to . Outside the `local` and `Development` environments it also sends an HSTS header. The container only listens on HTTP, so in production put it behind a proxy or load balancer that ends TLS, and set `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` so the app knows the original request was HTTPS. ## Tests diff --git a/src/Presentation/Properties/launchSettings.json b/src/Presentation/Properties/launchSettings.json index 20fefd9..2e5ef63 100644 --- a/src/Presentation/Properties/launchSettings.json +++ b/src/Presentation/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "scalar", - "applicationUrl": "https://localhost:8081;http://localhost:8080", + "applicationUrl": "https://localhost:7341;http://localhost:7340", "environmentVariables": { "DOTNET_ENVIRONMENT": "local" }