From 2a39a18354499e193f2443a79326f4a67e2aa097 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:14:47 +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/nitro/SKILL.md | 8 +++--- .agents/skills/schema-snapshot/SKILL.md | 2 +- .agents/skills/serilog/SKILL.md | 4 +-- .agents/skills/telemetry/SKILL.md | 6 ++--- AGENTS.md | 2 +- NUGET.md | 2 +- README.md | 6 ++--- docker-compose.yml | 8 +++--- docs/RUN.md | 24 ++++++++++++++---- .../Properties/launchSettings.json | 2 +- 12 files changed, 61 insertions(+), 30 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 ebb9067..ba06163 100644 --- a/.agents/skills/expose-a-use-case/SKILL.md +++ b/.agents/skills/expose-a-use-case/SKILL.md @@ -100,5 +100,5 @@ public static async Task UpdateReview( ``` bash dotnet build dotnet test tests/Presentation.Tests.Integration -dotnet run --project ./src/Presentation # then try it in Nitro at http://localhost:8080/graphql +dotnet run --project ./src/Presentation # then try it in Nitro at http://localhost:8730/graphql ``` diff --git a/.agents/skills/nitro/SKILL.md b/.agents/skills/nitro/SKILL.md index 592c662..3f7fa56 100644 --- a/.agents/skills/nitro/SKILL.md +++ b/.agents/skills/nitro/SKILL.md @@ -9,13 +9,13 @@ metadata: ## The rule -`MapGraphQL()` serves Nitro, Hot Chocolate's GraphQL IDE, at when a browser asks for it. It's served embedded (`ServeMode.Embedded`) so it works offline and never loads from a CDN. The schema and Nitro stay open when authentication is on; every field, `version` included, asks for a token. +`MapGraphQL()` serves Nitro, Hot Chocolate's GraphQL IDE, at when a browser asks for it. It's served embedded (`ServeMode.Embedded`) so it works offline and never loads from a CDN. The schema and Nitro stay open when authentication is on; every field, `version` included, asks for a token. | What | Address | | --- | --- | -| Nitro | | -| Schema as SDL | | -| Aspire dashboard (logs, traces and metrics, in Docker) | | +| Nitro | | +| Schema as SDL | | +| Aspire dashboard (logs, traces and metrics, in Docker) | | ## Steps diff --git a/.agents/skills/schema-snapshot/SKILL.md b/.agents/skills/schema-snapshot/SKILL.md index 37cb01d..c699186 100644 --- a/.agents/skills/schema-snapshot/SKILL.md +++ b/.agents/skills/schema-snapshot/SKILL.md @@ -17,7 +17,7 @@ The test ignores lines that start with `#`, which GraphQL treats as comments. In 1. Make the schema change. 2. Run the tests. The snapshot test fails and prints the new schema. -3. Copy the new SDL into `schema.graphql`. Either take it from the test output, or run the app and open . +3. Copy the new SDL into `schema.graphql`. Either take it from the test output, or run the app and open . 4. Read the diff. A removed field, a renamed one, or a field that became non-null breaks clients, so say so in the pull request. 5. Run the tests again. diff --git a/.agents/skills/serilog/SKILL.md b/.agents/skills/serilog/SKILL.md index 06cf8de..9f0c53f 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 3d154f5..ce9beec 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 # Nitro at http://localhost:8080/graphql +dotnet run --project ./src/Presentation # Nitro at http://localhost:8730/graphql ``` The `coverage` skill has the command that checks the 95% line coverage gate locally. diff --git a/NUGET.md b/NUGET.md index 6216a19..4862837 100644 --- a/NUGET.md +++ b/NUGET.md @@ -48,7 +48,7 @@ docker compose up -d --build dotnet test ``` -Then open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at and ask it for some movies. +Then open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at and ask it for some movies. The API is open unless you create it with `--include-auth --authority` (and usually `--audience`), and then the queries and mutations ask 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 3f8e586..1ac3e84 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/graphql \ +curl http://localhost:8730/graphql \ -H 'Content-Type: application/json' \ -d '{ "query": "{ movies(query: \"space\", first: 2) { totalCount nodes { title reviewCount averageStars } } }" }' ``` @@ -75,9 +75,9 @@ curl http://localhost:8080/graphql \ The sample movies are all made up, and the critics are harsh. -Typing GraphQL into curl is a character-building exercise. Open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at instead, where the schema is documented from the same XML comments as the code. +Typing GraphQL into curl is a character-building exercise. Open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at instead, where the schema is documented from the same XML comments as the code. -The `movies`, `authors` and `reviews` queries use Relay-style cursor paging (`first`, `after`, `pageInfo`, `totalCount`). Movies and authors come with their review count and average stars, worked out by the database rather than in a loop somewhere. Ask for their reviews and a [DataLoader](https://chillicream.com/docs/hotchocolate/fetching-data/dataloader) fetches them for the whole page at once, so 50 movies take three queries, not 52. Its logs, traces and metrics land in the [Aspire dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone) at . +The `movies`, `authors` and `reviews` queries use Relay-style cursor paging (`first`, `after`, `pageInfo`, `totalCount`). Movies and authors come with their review count and average stars, worked out by the database rather than in a loop somewhere. Ask for their reviews and a [DataLoader](https://chillicream.com/docs/hotchocolate/fetching-data/dataloader) fetches them for the whole page at once, so 50 movies take three queries, not 52. 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 e110c61..ba7b86e 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" + - "8730: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" + - "8733:4317" + - "8734:4318" networks: - cleangraphqlapi_network depends_on: @@ -32,7 +32,7 @@ services: aspire-dashboard: image: mcr.microsoft.com/dotnet/aspire-dashboard:13.6.2 ports: - - "18888:18888" + - "8732:18888" environment: ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" networks: diff --git a/docs/RUN.md b/docs/RUN.md index 608068f..8050d20 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -23,15 +23,15 @@ 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 8733 and 8734. 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: (Nitro) -- 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: (Nitro) +- 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. @@ -41,6 +41,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 8730 to 8739: + +| Port | What listens | +| --- | --- | +| 8730 | The app over HTTP, from Docker Compose or `dotnet run` | +| 8731 | The app over HTTPS, `dotnet run` only | +| 8732 | The Aspire dashboard | +| 8733 | The OpenTelemetry Collector, OTLP over gRPC | +| 8734 | 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`. 8735 to 8739 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. @@ -51,7 +65,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:8733 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 a9bbcaa..eb6d9a7 100644 --- a/src/Presentation/Properties/launchSettings.json +++ b/src/Presentation/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "graphql", - "applicationUrl": "https://localhost:8081;http://localhost:8080", + "applicationUrl": "https://localhost:8731;http://localhost:8730", "environmentVariables": { "DOTNET_ENVIRONMENT": "local" } From 4a166b3ce29edaf067a2af67471111c9094106c9 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 23:49:44 +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/nitro/SKILL.md | 8 +++---- .agents/skills/schema-snapshot/SKILL.md | 2 +- .github/workflows/build-pipeline.yml | 6 ++--- AGENTS.md | 2 +- Dockerfile | 5 ++-- NUGET.md | 2 +- README.md | 6 ++--- docker-compose.yml | 8 +++---- docs/RUN.md | 24 +++++++++---------- .../Properties/launchSettings.json | 2 +- 12 files changed, 38 insertions(+), 37 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 ba06163..6cf5530 100644 --- a/.agents/skills/expose-a-use-case/SKILL.md +++ b/.agents/skills/expose-a-use-case/SKILL.md @@ -100,5 +100,5 @@ public static async Task UpdateReview( ``` bash dotnet build dotnet test tests/Presentation.Tests.Integration -dotnet run --project ./src/Presentation # then try it in Nitro at http://localhost:8730/graphql +dotnet run --project ./src/Presentation # then try it in Nitro at http://localhost:7330/graphql ``` diff --git a/.agents/skills/nitro/SKILL.md b/.agents/skills/nitro/SKILL.md index 3f7fa56..77f790b 100644 --- a/.agents/skills/nitro/SKILL.md +++ b/.agents/skills/nitro/SKILL.md @@ -9,13 +9,13 @@ metadata: ## The rule -`MapGraphQL()` serves Nitro, Hot Chocolate's GraphQL IDE, at when a browser asks for it. It's served embedded (`ServeMode.Embedded`) so it works offline and never loads from a CDN. The schema and Nitro stay open when authentication is on; every field, `version` included, asks for a token. +`MapGraphQL()` serves Nitro, Hot Chocolate's GraphQL IDE, at when a browser asks for it. It's served embedded (`ServeMode.Embedded`) so it works offline and never loads from a CDN. The schema and Nitro stay open when authentication is on; every field, `version` included, asks for a token. | What | Address | | --- | --- | -| Nitro | | -| Schema as SDL | | -| Aspire dashboard (logs, traces and metrics, in Docker) | | +| Nitro | | +| Schema as SDL | | +| Aspire dashboard (logs, traces and metrics, in Docker) | | ## Steps diff --git a/.agents/skills/schema-snapshot/SKILL.md b/.agents/skills/schema-snapshot/SKILL.md index c699186..b546fc3 100644 --- a/.agents/skills/schema-snapshot/SKILL.md +++ b/.agents/skills/schema-snapshot/SKILL.md @@ -17,7 +17,7 @@ The test ignores lines that start with `#`, which GraphQL treats as comments. In 1. Make the schema change. 2. Run the tests. The snapshot test fails and prints the new schema. -3. Copy the new SDL into `schema.graphql`. Either take it from the test output, or run the app and open . +3. Copy the new SDL into `schema.graphql`. Either take it from the test output, or run the app and open . 4. Read the diff. A removed field, a renamed one, or a field that became non-null breaks clients, so say so in the pull request. 5. Run the tests again. diff --git a/.github/workflows/build-pipeline.yml b/.github/workflows/build-pipeline.yml index 07214a3..6573a22 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 7330:7330 \ --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:7330/health/ready > /dev/null; then break fi sleep 1 done - RESPONSE=$(curl --silent --fail --max-time 30 http://localhost:8080/graphql \ + RESPONSE=$(curl --silent --fail --max-time 30 http://localhost:7330/graphql \ --header 'Content-Type: application/json' \ --data '{ "query": "{ movies(first: 2) { nodes { id title } } }" }' || true) echo "$RESPONSE" diff --git a/AGENTS.md b/AGENTS.md index ce9beec..105bf37 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 # Nitro at http://localhost:8730/graphql +dotnet run --project ./src/Presentation # Nitro at http://localhost:7330/graphql ``` The `coverage` skill has the command that checks the 95% line coverage gate locally. diff --git a/Dockerfile b/Dockerfile index d5f746a..655dcfc 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=7330 +EXPOSE 7330 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:7330/health/live || exit 1 ENTRYPOINT ["dotnet", "Presentation.dll"] diff --git a/NUGET.md b/NUGET.md index 4862837..9cdb1e9 100644 --- a/NUGET.md +++ b/NUGET.md @@ -48,7 +48,7 @@ docker compose up -d --build dotnet test ``` -Then open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at and ask it for some movies. +Then open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at and ask it for some movies. The API is open unless you create it with `--include-auth --authority` (and usually `--audience`), and then the queries and mutations ask 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 1ac3e84..99c99b8 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:8730/graphql \ +curl http://localhost:7330/graphql \ -H 'Content-Type: application/json' \ -d '{ "query": "{ movies(query: \"space\", first: 2) { totalCount nodes { title reviewCount averageStars } } }" }' ``` @@ -75,9 +75,9 @@ curl http://localhost:8730/graphql \ The sample movies are all made up, and the critics are harsh. -Typing GraphQL into curl is a character-building exercise. Open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at instead, where the schema is documented from the same XML comments as the code. +Typing GraphQL into curl is a character-building exercise. Open [Nitro](https://chillicream.com/docs/nitro), the GraphQL IDE, at instead, where the schema is documented from the same XML comments as the code. -The `movies`, `authors` and `reviews` queries use Relay-style cursor paging (`first`, `after`, `pageInfo`, `totalCount`). Movies and authors come with their review count and average stars, worked out by the database rather than in a loop somewhere. Ask for their reviews and a [DataLoader](https://chillicream.com/docs/hotchocolate/fetching-data/dataloader) fetches them for the whole page at once, so 50 movies take three queries, not 52. Its logs, traces and metrics land in the [Aspire dashboard](https://learn.microsoft.com/dotnet/aspire/fundamentals/dashboard/standalone) at . +The `movies`, `authors` and `reviews` queries use Relay-style cursor paging (`first`, `after`, `pageInfo`, `totalCount`). Movies and authors come with their review count and average stars, worked out by the database rather than in a loop somewhere. Ask for their reviews and a [DataLoader](https://chillicream.com/docs/hotchocolate/fetching-data/dataloader) fetches them for the whole page at once, so 50 movies take three queries, not 52. 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 ba7b86e..8f40cc0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: VERSION_PREFIX: 1.0.0.0 VERSION_SUFFIX: docker ports: - - "8730:8080" + - "7330:7330" 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: - - "8733:4317" - - "8734:4318" + - "7333:4317" + - "7334:4318" networks: - cleangraphqlapi_network depends_on: @@ -32,7 +32,7 @@ services: aspire-dashboard: image: mcr.microsoft.com/dotnet/aspire-dashboard:13.6.2 ports: - - "8732:18888" + - "7332:18888" environment: ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" networks: diff --git a/docs/RUN.md b/docs/RUN.md index 8050d20..365202e 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -23,15 +23,15 @@ 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 8733 and 8734. 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 7333 and 7334. 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: (Nitro) -- 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: (Nitro) +- 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. @@ -43,17 +43,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 8730 to 8739: +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 7330 to 7339: | Port | What listens | | --- | --- | -| 8730 | The app over HTTP, from Docker Compose or `dotnet run` | -| 8731 | The app over HTTPS, `dotnet run` only | -| 8732 | The Aspire dashboard | -| 8733 | The OpenTelemetry Collector, OTLP over gRPC | -| 8734 | The OpenTelemetry Collector, OTLP over HTTP | +| 7330 | The app over HTTP, from Docker Compose or `dotnet run` | +| 7331 | The app over HTTPS, `dotnet run` only | +| 7332 | The Aspire dashboard | +| 7333 | The OpenTelemetry Collector, OTLP over gRPC | +| 7334 | 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`. 8735 to 8739 are spare for anything you add. +The app listens on 7330 everywhere: under `dotnet run`, inside its container and on your machine, so `docker run -p 7330:7330` 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 7330. 7335 to 7339 are spare for anything you add. ## Telemetry @@ -65,7 +65,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:8733 dotnet run --project ./src/Presentation +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:7333 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 eb6d9a7..b6386d8 100644 --- a/src/Presentation/Properties/launchSettings.json +++ b/src/Presentation/Properties/launchSettings.json @@ -6,7 +6,7 @@ "dotnetRunMessages": true, "launchBrowser": true, "launchUrl": "graphql", - "applicationUrl": "https://localhost:8731;http://localhost:8730", + "applicationUrl": "https://localhost:7331;http://localhost:7330", "environmentVariables": { "DOTNET_ENVIRONMENT": "local" } From b76c7648638d0310ae7a356c5b096667b0356be0 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 ab31e50..3689334 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 @@ -74,7 +74,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