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 ebb9067..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:8080/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 592c662..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 37cb01d..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/.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/.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 3d154f5..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:8080/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 6216a19..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 3f8e586..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:8080/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: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..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: - - "8080: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: - - "4317:4317" - - "4318: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: - - "18888:18888" + - "7332:18888" environment: ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS: "true" networks: diff --git a/docs/RUN.md b/docs/RUN.md index 2599390..3689334 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -21,19 +21,19 @@ 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 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,6 +43,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 7330 to 7339: + +| Port | What listens | +| --- | --- | +| 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 | + +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 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. @@ -53,14 +67,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:7333 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 a9bbcaa..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:8081;http://localhost:8080", + "applicationUrl": "https://localhost:7331;http://localhost:7330", "environmentVariables": { "DOTNET_ENVIRONMENT": "local" }