From a5e574fa00dc0fa7baee98858a25a3af40434f0f Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:14:49 +0000 Subject: [PATCH 1/3] Give each template its own block of local ports Every template bound 8080, 4317, 4318 and 18888 on the host, so two stacks couldn't run side by side. Each template now publishes everything in its own block of ten (Minimal API 8710, MCP 8720, GraphQL 8730, Web API 8740, Lambda Function 8750), with the same offsets in every block. Inside the containers the app stays on 8080. --- .agents/skills/docker-and-compose/SKILL.md | 25 +++++++++++++++--- .agents/skills/expose-a-use-case/SKILL.md | 2 +- .agents/skills/openapi-and-scalar/SKILL.md | 4 +-- .agents/skills/serilog/SKILL.md | 4 +-- .agents/skills/telemetry/SKILL.md | 6 ++--- AGENTS.md | 2 +- NUGET.md | 4 +-- README.md | 4 +-- docker-compose.yml | 8 +++--- docs/RUN.md | 26 ++++++++++++++----- .../Properties/launchSettings.json | 2 +- 11 files changed, 59 insertions(+), 28 deletions(-) diff --git a/.agents/skills/docker-and-compose/SKILL.md b/.agents/skills/docker-and-compose/SKILL.md index 97c4488..d552c44 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 8750, 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 8710, MCP 8720, GraphQL 8730, Web API 8740, Lambda Function 8750. 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. Inside the containers nothing moves: the app listens on 8080, the .NET default, the dashboard on 18888 and the collector on 4317 and 4318, so only the left-hand side of a mapping is ours and the image deploys anywhere unchanged. 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 && curl -fsS "http://$(docker compose port api 8080)/health/ready" +# 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..5f5b89f 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:8740/scalar ``` diff --git a/.agents/skills/openapi-and-scalar/SKILL.md b/.agents/skills/openapi-and-scalar/SKILL.md index 38e80fd..b03477e 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:8740/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/AGENTS.md b/AGENTS.md index 57f1f40..4c4abb7 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:8740/scalar ``` The `coverage` skill has the command that checks the 95% line coverage gate locally. diff --git a/NUGET.md b/NUGET.md index fa11c86..7aa1961 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:8740/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..9c02790 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:8740/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..d6099a1 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" + - "8740:8080" 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" + - "8743:4317" + - "8744: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" + - "8742:18888" environment: ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" networks: diff --git a/docs/RUN.md b/docs/RUN.md index eb06d97..22470c1 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -23,16 +23,16 @@ dotnet run --project ./src/Presentation ## 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 8743 and 8744. 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. @@ -42,6 +42,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 8740 to 8749: + +| Port | What listens | +| --- | --- | +| 8740 | The app over HTTP, from Docker Compose or `dotnet run` | +| 8741 | The app over HTTPS, `dotnet run` only | +| 8742 | The Aspire dashboard | +| 8743 | The OpenTelemetry Collector, OTLP over gRPC | +| 8744 | The OpenTelemetry Collector, OTLP over HTTP | + +Inside the containers nothing moves: the app listens on 8080, the .NET default, so the image runs anywhere without changes, and the dashboard and collector keep their usual ports. To move a port, change the left-hand side of its mapping in `docker-compose.yml`, or `applicationUrl` in `launchSettings.json`. 8745 to 8749 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. @@ -52,7 +66,7 @@ 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:8743 dotnet run --project ./src/Presentation ``` Every span, metric and log carries `service.version`, so you can always tell which build sent it. diff --git a/src/Presentation/Properties/launchSettings.json b/src/Presentation/Properties/launchSettings.json index 20fefd9..c760966 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:8741;http://localhost:8740", "environmentVariables": { "DOTNET_ENVIRONMENT": "local" } From 8f81efb65ef6df3d1dac5eb618ae9ecd9b92e8dc Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:49:46 +0000 Subject: [PATCH 2/3] Move to the 73x0 blocks and use the same port inside the container 87x0 had no room past six templates, so the blocks now start at 7310, which leaves space for fifteen. The app also listens on its own port inside its container, so dotnet run, docker run and Docker Compose all use one number. Lambda's emulator stays on 8080 inside, because it can't be moved. --- .agents/skills/docker-and-compose/SKILL.md | 8 +++--- .agents/skills/expose-a-use-case/SKILL.md | 2 +- .agents/skills/openapi-and-scalar/SKILL.md | 4 +-- .github/workflows/build-pipeline.yml | 6 ++--- AGENTS.md | 2 +- Dockerfile | 5 ++-- NUGET.md | 4 +-- README.md | 4 +-- docker-compose.yml | 8 +++--- docs/RUN.md | 26 +++++++++---------- .../Properties/launchSettings.json | 2 +- 11 files changed, 36 insertions(+), 35 deletions(-) diff --git a/.agents/skills/docker-and-compose/SKILL.md b/.agents/skills/docker-and-compose/SKILL.md index d552c44..16854ab 100644 --- a/.agents/skills/docker-and-compose/SKILL.md +++ b/.agents/skills/docker-and-compose/SKILL.md @@ -21,11 +21,11 @@ One multi-stage `Dockerfile` does everything the pipeline needs: `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 8750, 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 8710, MCP 8720, GraphQL 8730, Web API 8740, Lambda Function 8750. Each port has the same place in every block: +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 | | --- | --- | @@ -38,7 +38,7 @@ Every port published on the developer's machine, by the compose file or by `laun | +6 | The MCP chat demo | | +7 to +9 | Spare | -`docs/RUN.md` lists the ports this solution uses. Inside the containers nothing moves: the app listens on 8080, the .NET default, the dashboard on 18888 and the collector on 4317 and 4318, so only the left-hand side of a mapping is ours and the image deploys anywhere unchanged. 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. +`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 @@ -59,7 +59,7 @@ Every port published on the developer's machine, by the compose file or by `laun ``` bash docker build . --target run --tag local-check -docker compose up -d --build && curl -fsS "http://$(docker compose port api 8080)/health/ready" +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 5f5b89f..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:8740/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 b03477e..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:8740/scalar +dotnet run --project ./src/Presentation # then open http://localhost:7340/scalar ``` 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 4c4abb7..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:8740/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 7aa1961..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:8740/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 9c02790..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:8740/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 d6099a1..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: - - "8740: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: - - "8743:4317" - - "8744: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: - - "8742:18888" + - "7342:18888" environment: ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" networks: diff --git a/docs/RUN.md b/docs/RUN.md index 22470c1..635ec9a 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -23,16 +23,16 @@ dotnet run --project ./src/Presentation ## 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 8743 and 8744. 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,17 +44,17 @@ 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 8740 to 8749: +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 | | --- | --- | -| 8740 | The app over HTTP, from Docker Compose or `dotnet run` | -| 8741 | The app over HTTPS, `dotnet run` only | -| 8742 | The Aspire dashboard | -| 8743 | The OpenTelemetry Collector, OTLP over gRPC | -| 8744 | The OpenTelemetry Collector, OTLP over HTTP | +| 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 | -Inside the containers nothing moves: the app listens on 8080, the .NET default, so the image runs anywhere without changes, and the dashboard and collector keep their usual ports. To move a port, change the left-hand side of its mapping in `docker-compose.yml`, or `applicationUrl` in `launchSettings.json`. 8745 to 8749 are spare for anything you add. +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 @@ -66,7 +66,7 @@ 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:8743 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. diff --git a/src/Presentation/Properties/launchSettings.json b/src/Presentation/Properties/launchSettings.json index c760966..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:8741;http://localhost:8740", + "applicationUrl": "https://localhost:7341;http://localhost:7340", "environmentVariables": { "DOTNET_ENVIRONMENT": "local" } From 13809cec7f79b7382e5d0145d04e1db5e7ba7678 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Sat, 7 Nov 2026 02:59:42 +0000 Subject: [PATCH 3/3] Use the block ports in the dotnet run HTTPS lines --- docs/RUN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/RUN.md b/docs/RUN.md index fdf5057..ee99044 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -21,7 +21,7 @@ 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 @@ -75,7 +75,7 @@ Every span, metric and log carries `service.version`, so you can always tell whi ## 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