From 0d2a4ce5af62e0c3040ec2775229f5a0510357dd Mon Sep 17 00:00:00 2001 From: Tung Leo Date: Sun, 30 Aug 2026 02:52:35 +0000 Subject: [PATCH] feat: add Azure monitoring dashboard project (APIM + Container Apps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New sample project demonstrating Azure API Management + Azure Container Apps with a .NET and a Java service instrumented for distributed tracing via Azure Monitor, plus an Azure Monitor Workbook giving RED metrics, an SLO tile, and a log-filtering panel for root-cause analysis. Verified locally: both apps build, run, and complete a real cross-service order through a shared Docker network; Terraform validates against the installed provider schema and resolves its full local dependency graph via terraform plan up to the Azure auth boundary; the Workbook JSON is well-formed. Not run through a real terraform apply — no Azure credentials in this environment, matching every other Azure project in this repo. --- .../verify-azure-monitoring-dashboard.yml | 29 ++ README.md | 1 + projects/azure-monitoring-dashboard/README.md | 161 ++++++++++ .../apps/dotnet-orders-api/.dockerignore | 2 + .../apps/dotnet-orders-api/.gitignore | 2 + .../apps/dotnet-orders-api/Dockerfile | 19 ++ .../apps/dotnet-orders-api/Program.cs | 86 +++++ .../Properties/launchSettings.json | 23 ++ .../appsettings.Development.json | 8 + .../apps/dotnet-orders-api/appsettings.json | 9 + .../dotnet-orders-api.csproj | 15 + .../apps/java-inventory-api/.dockerignore | 1 + .../apps/java-inventory-api/.gitattributes | 2 + .../apps/java-inventory-api/.gitignore | 33 ++ .../.mvn/wrapper/maven-wrapper.properties | 3 + .../apps/java-inventory-api/Dockerfile | 34 ++ .../apps/java-inventory-api/mvnw | 295 ++++++++++++++++++ .../apps/java-inventory-api/mvnw.cmd | 189 +++++++++++ .../apps/java-inventory-api/pom.xml | 50 +++ .../inventory/InventoryController.java | 51 +++ .../JavaInventoryApiApplication.java | 13 + .../src/main/resources/application.properties | 6 + .../JavaInventoryApiApplicationTests.java | 13 + .../demo_project.sh | 77 +++++ .../terraform/.terraform.lock.hcl | 43 +++ .../terraform/apim.tf | 103 ++++++ .../terraform/container-apps.tf | 97 ++++++ .../terraform/main.tf | 28 ++ .../terraform/outputs.tf | 25 ++ .../terraform/providers.tf | 18 ++ .../terraform/variables.tf | 31 ++ .../terraform/workbook.tf | 25 ++ .../workbook/slo-dashboard.workbook.json | 177 +++++++++++ 33 files changed, 1669 insertions(+) create mode 100644 .github/workflows/verify-azure-monitoring-dashboard.yml create mode 100644 projects/azure-monitoring-dashboard/README.md create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.dockerignore create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.gitignore create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Dockerfile create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Program.cs create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Properties/launchSettings.json create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.Development.json create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.json create mode 100644 projects/azure-monitoring-dashboard/apps/dotnet-orders-api/dotnet-orders-api.csproj create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/.dockerignore create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitattributes create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitignore create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/.mvn/wrapper/maven-wrapper.properties create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/Dockerfile create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw.cmd create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/pom.xml create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/InventoryController.java create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplication.java create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/resources/application.properties create mode 100644 projects/azure-monitoring-dashboard/apps/java-inventory-api/src/test/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplicationTests.java create mode 100755 projects/azure-monitoring-dashboard/demo_project.sh create mode 100644 projects/azure-monitoring-dashboard/terraform/.terraform.lock.hcl create mode 100644 projects/azure-monitoring-dashboard/terraform/apim.tf create mode 100644 projects/azure-monitoring-dashboard/terraform/container-apps.tf create mode 100644 projects/azure-monitoring-dashboard/terraform/main.tf create mode 100644 projects/azure-monitoring-dashboard/terraform/outputs.tf create mode 100644 projects/azure-monitoring-dashboard/terraform/providers.tf create mode 100644 projects/azure-monitoring-dashboard/terraform/variables.tf create mode 100644 projects/azure-monitoring-dashboard/terraform/workbook.tf create mode 100644 projects/azure-monitoring-dashboard/workbook/slo-dashboard.workbook.json diff --git a/.github/workflows/verify-azure-monitoring-dashboard.yml b/.github/workflows/verify-azure-monitoring-dashboard.yml new file mode 100644 index 0000000..24a3258 --- /dev/null +++ b/.github/workflows/verify-azure-monitoring-dashboard.yml @@ -0,0 +1,29 @@ +name: Verify azure-monitoring-dashboard + +# Only verifies what's possible without a real Azure subscription — both +# sample apps build/run/talk to each other, and the Terraform + Workbook +# JSON are syntactically valid. No `terraform apply` — no Azure credentials +# exist in this repo's CI, same as every other Azure project here. See the +# project's demo_project.sh and README for details. +on: + push: + branches: ['main'] + paths: + - 'projects/azure-monitoring-dashboard/**' + pull_request: + branches: ['main'] + paths: + - 'projects/azure-monitoring-dashboard/**' +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + - name: Build both apps, verify the chain, validate Terraform + Workbook + run: | + cd projects/azure-monitoring-dashboard + chmod +x demo_project.sh + ./demo_project.sh diff --git a/README.md b/README.md index a08c070..b41a506 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ This is the **third** repo of my DevOps trio repositories: [**tungbq/devops-basi | 19 | Microservices Orchestration | [microservices-orchestration](./projects/microservices-orchestration/) | `Kubernetes` `Microservices` `Scaling` | ✔️ Done | | 20 | Deploy Kubernetes using Kubespray | [#70](https://github.com/tungbq/devops-project/issues/70) | `Kubernetes` `Kubespray` | 🚧 Planned | | 21 | Deploy a static website to AWS S3 | [#6](https://github.com/tungbq/devops-project/issues/6) | `AWS` `S3` `Static Website` | 🚧 Planned | +| 22 | Azure Monitoring & Dashboard (APIM + Container Apps) | [azure-monitoring-dashboard](./projects/azure-monitoring-dashboard/) | `Azure` `APIM` `Container Apps` `Monitoring` `Observability` | ✔️ Done | ### Explore our upcoming projects by visiting [this link](https://github.com/tungbq/devops-project/issues?q=is%3Aissue+is%3Aopen+label%3Aproject) ⏩ diff --git a/projects/azure-monitoring-dashboard/README.md b/projects/azure-monitoring-dashboard/README.md new file mode 100644 index 0000000..e9ae6f2 --- /dev/null +++ b/projects/azure-monitoring-dashboard/README.md @@ -0,0 +1,161 @@ +# Project: Azure Monitoring & Dashboard (APIM + Container Apps + Azure Monitor Workbook) + +Two sample apps (.NET and Java) behind Azure API Management, running on Azure Container Apps, fully instrumented for distributed tracing — plus a dashboard-as-code Azure Monitor Workbook giving you SLO tracking, RED metrics (request rate, error rate, response time) per service, and a log-filtering panel for root-cause analysis. + +## Overview + +### Introduction + +- Tech stack: `.NET 9` (ASP.NET Core), `Java 21` (Spring Boot), `Docker`, `Azure Container Apps`, `Azure API Management`, `Azure Monitor` / `Application Insights` (workspace-based), `Azure Monitor Workbooks`, `Terraform` +- **dotnet-orders-api** — the only externally-exposed app, sits behind APIM. Accepts an order, calls java-inventory-api to reserve stock. ~15% of orders fail on purpose (a simulated downstream error), so the error-rate/SLO panels have real, non-zero data to show — this is a demo-data generator, not a bug. +- **java-inventory-api** — internal only, reserves stock for an item. +- Both are instrumented for Azure Monitor, but via **two genuinely different, both-valid patterns**: .NET uses the `Azure.Monitor.OpenTelemetry.AspNetCore` SDK (a few lines of startup code); Java uses the Application Insights Java agent (`-javaagent`, zero code changes — auto-instrumentation is the idiomatic Java-ecosystem approach). Worth comparing the two `Dockerfile`s side by side. +- To get basic concepts of these tools, you could visit: [**devops-basics**](https://github.com/tungbq/devops-basics) + +### Architecture + +``` + ┌──────────────────────┐ + client ───▶ │ Azure API Management │ (Consumption tier, gateway logs → App Insights) + │ /orders/* │ + └───────────┬────────────┘ + │ https + ┌───────────▼────────────┐ internal-only ingress + │ dotnet-orders-api │───────▶ ┌─────────────────────┐ + │ (Container Apps, │ http │ java-inventory-api │ + │ external ingress) │ │ (Container Apps, │ + └───────────┬─────────────┘ │ internal ingress) │ + │ OpenTelemetry SDK └──────────┬───────────┘ + │ -javaagent (auto-instrument) + ▼ │ + ┌─────────────────────────────────────────────────┐ + │ Application Insights (workspace-based) │ + │ → Log Analytics workspace │ + └─────────────────────┬───────────────────────────┘ + │ KQL + ┌─────────▼──────────┐ + │ Azure Monitor │ + │ Workbook │ SLO · RED metrics · log search + └─────────────────────┘ +``` + +One shared Log Analytics workspace backs everything — the apps' own traces (via workspace-based App Insights), the platform's container logs, and APIM's own gateway diagnostic logs all land in the same place, which is what lets one Workbook query across all three. + +### Prerequisite + +- Tools: `docker`, `dotnet` SDK 9, `java` 21 + `maven` (only needed if you want to build the apps outside Docker), `terraform` >=1.5, `az` CLI, an Azure subscription +- Basic knowledge of Docker, Terraform, and either ASP.NET Core or Spring Boot + +## 1-Run it locally first (no Azure needed) + +Confirms both apps actually work and actually call each other correctly before you spend any Azure quota on it: + +```bash +cd projects/azure-monitoring-dashboard +./demo_project.sh +``` + +This builds both images, runs them on a shared Docker network, and places a real order through the full `dotnet-orders-api → java-inventory-api` chain. It also runs `terraform validate`/`fmt` and checks the Workbook JSON is well-formed — everything that can be verified **without** an Azure subscription. This is the exact script CI runs on every push/PR that touches this project. + +**What this does *not* do:** `terraform apply` against real Azure. There's no Azure credential in this repo's CI (same as every other Azure project here — see e.g. [aks-deploy-monitor-app](../aks-deploy-monitor-app/)), and APIM alone can take 15+ minutes to provision even on the cheapest tier, which wouldn't be a reasonable CI check regardless. Section 3 below is the real deploy walkthrough, meant to be run against your own subscription. + +## 2-Run it manually, step by step + +```bash +# terminal 1 +docker build -t java-inventory-api:local ./apps/java-inventory-api +docker run --rm -p 8081:8081 java-inventory-api:local + +# terminal 2 +docker build -t dotnet-orders-api:local ./apps/dotnet-orders-api +docker run --rm -p 8080:8080 -e INVENTORY_URL=http://host.docker.internal:8081 dotnet-orders-api:local + +# terminal 3 +curl -X POST http://localhost:8080/api/orders -H "Content-Type: application/json" -d '{"item":"widget","quantity":2}' +``` + +Neither app needs `APPLICATIONINSIGHTS_CONNECTION_STRING` set to run — both are written to degrade gracefully without it (see the "no crash without Azure" comments in each `Program.cs`/`Dockerfile`), which is also just good practice: a dev environment shouldn't require a cloud subscription to boot the app. + +## 3-Deploy to Azure + +**Heads up on time and cost:** APIM (even Consumption tier) provisions in roughly 15 minutes. The whole `apply` will take a while — this isn't a "wait 30 seconds" project. Consumption-tier APIM is pay-per-call and Container Apps scales to zero when idle, so idle cost is low, but **run `terraform destroy` when you're done** — nothing here is free to leave running indefinitely. + +### 3.1-Push both images to a registry Container Apps can pull from + +Container Apps can't use your local `:local`-tagged images directly — they need to live in a real registry. Using Azure Container Registry here, but any registry Container Apps can reach works: + +```bash +az acr create --resource-group --name --sku Basic +az acr login --name + +docker build -t .azurecr.io/dotnet-orders-api:v1 ./apps/dotnet-orders-api +docker push .azurecr.io/dotnet-orders-api:v1 + +docker build -t .azurecr.io/java-inventory-api:v1 ./apps/java-inventory-api +docker push .azurecr.io/java-inventory-api:v1 +``` + +### 3.2-Deploy the infrastructure + +```bash +cd terraform +terraform init + +terraform apply \ + -var="apim_publisher_name=Your Name" \ + -var="apim_publisher_email=you@example.com" \ + -var="dotnet_orders_api_image=.azurecr.io/dotnet-orders-api:v1" \ + -var="java_inventory_api_image=.azurecr.io/java-inventory-api:v1" +``` + +### 3.3-Generate some traffic + +The dashboard has nothing to show until requests actually flow through it: + +```bash +GATEWAY_URL=$(terraform output -raw apim_gateway_url) +for i in $(seq 1 40); do + curl -s -X POST "${GATEWAY_URL}/orders/api/orders" \ + -H "Content-Type: application/json" \ + -d '{"item":"widget","quantity":1}' -o /dev/null + sleep 2 +done +``` + +Give it a few minutes — Application Insights ingestion isn't instant. + +### 3.4-Open the Workbook + +```bash +terraform output workbook_resource_id +``` + +In the Azure Portal: **Azure Monitor → Workbooks → (Public/Shared) →** find "Service health — SLO, RED metrics, root-cause search" (or open the resource ID directly). Pick a service from the dropdown at the top; every panel below updates. + +### 3.5-Tear down + +```bash +terraform destroy \ + -var="apim_publisher_name=Your Name" \ + -var="apim_publisher_email=you@example.com" \ + -var="dotnet_orders_api_image=.azurecr.io/dotnet-orders-api:v1" \ + -var="java_inventory_api_image=.azurecr.io/java-inventory-api:v1" +``` + +## 4-Reading the dashboard + +- **Request rate / Error rate / Response time percentiles** — the classic RED metrics, per service, over your chosen time range. +- **SLO tile** — "% of requests under 500ms" in the selected window. The 500ms/95% numbers are hardcoded in the Workbook's KQL as a demo target — see the query's own comment for where to change them to a real SLO. +- **Root-cause log search** — type anything (an order ID, an exception type, a message fragment) into the **Log filter** parameter; the table below shows matching traces and exceptions for the selected service. Each row has an `operation_Id` — **copy it and paste it into Application Insights → Investigate → Transaction search** in the Portal to see the complete distributed trace for that request, including the cross-service dotnet→java hop. (There's no verified-working native "click straight from a Workbook row into the transaction view" link — some blog posts claim one, but it isn't in Microsoft's own documented Workbook link-action schema, and this project would rather tell you the honest two-step path than ship a link that might silently 404.) +- **APIM gateway logs vs. app-level traces** are two different views of the same traffic — `ApiManagementGatewayLogs` (what the gateway saw) vs. `AppRequests` (what dotnet-orders-api itself recorded). Comparing them is a real, useful debugging technique (e.g. a request APIM logged but the app never recorded means the app crashed before handling it). + +## What this project deliberately leaves out + +- **A real database or message queue** — same reasoning as [microservices-orchestration](../microservices-orchestration/): in-memory state is enough to demonstrate the observability story without dragging in unrelated lessons (StatefulSets/PVCs, outbox patterns). +- **Alerting** (Azure Monitor Alerts / Action Groups on the SLO burning) — the Workbook shows you the data; wiring alerts on top is a real, separate, equally-valid follow-up project. +- **Custom domain / TLS on APIM** — APIM's default `azure-api.net` domain is fine for a demo; a real deployment would want its own domain and certificate. +- **Azure Managed Grafana** — considered and deliberately not used here; see this project's planning discussion for the reasoning (Workbooks won on cost and on fit for the log-filtering requirement specifically). + +## Honesty note on verification + +Every Terraform resource and the Workbook JSON were checked against the actual installed provider schema (`terraform providers schema -json`) and `terraform validate`/`terraform plan` — not just written from memory or docs prose. `terraform plan` resolves the entire resource graph correctly and only stops at the Azure authentication step (no credentials exist in the environment this was built in) — meaning every reference, interpolation, and attribute name is confirmed structurally correct, right up to the boundary of actually needing a live subscription. It has **not** been run through a real `terraform apply` — if you hit something that doesn't match reality once you do, that's the one part of this project that's genuinely untested; please open an issue. diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.dockerignore b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.dockerignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.gitignore b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Dockerfile b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Dockerfile new file mode 100644 index 0000000..92d7a0f --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Dockerfile @@ -0,0 +1,19 @@ +# Multi-stage build: SDK image compiles, runtime image ships — the final +# image never carries the SDK/compiler, only what's needed to run. +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +COPY dotnet-orders-api.csproj . +RUN dotnet restore + +COPY . . +RUN dotnet publish -c Release -o /app --no-restore + +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +WORKDIR /app +COPY --from=build /app . + +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://+:8080 + +ENTRYPOINT ["dotnet", "dotnet-orders-api.dll"] diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Program.cs b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Program.cs new file mode 100644 index 0000000..435801a --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Program.cs @@ -0,0 +1,86 @@ +using System.Net.Http.Json; +using Azure.Monitor.OpenTelemetry.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(); +builder.Services.AddHttpClient(); + +// Only wires up Azure Monitor when a connection string is actually +// configured — lets this run locally (dotnet run, docker compose) with no +// Azure subscription at all, exactly like a real app shouldn't crash just +// because observability isn't configured for a dev environment. +var appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; +if (!string.IsNullOrWhiteSpace(appInsightsConnectionString)) +{ + builder.Services.AddOpenTelemetry().UseAzureMonitor(); +} + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +// No UseHttpsRedirection(): TLS terminates at APIM/Container Apps ingress +// in the real deployment target — redirecting inside the container itself +// would just break the container-to-container call below. + +app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "dotnet-orders-api" })); + +var orders = new List(); +var random = new Random(); + +app.MapGet("/api/orders", () => Results.Ok(orders)); + +app.MapPost("/api/orders", async (OrderRequest req, IHttpClientFactory httpClientFactory, ILogger logger) => +{ + // Same "config, not code" pattern as the microservices-orchestration + // project's ConfigMap-injected URLs — here it's an env var set by the + // Container Apps Terraform (see ../../terraform/container-apps.tf) + // instead of a K8s ConfigMap, same idea. + var inventoryUrl = Environment.GetEnvironmentVariable("INVENTORY_URL") ?? "http://localhost:8081"; + var client = httpClientFactory.CreateClient(); + + HttpResponseMessage reserveResponse; + try + { + reserveResponse = await client.PostAsJsonAsync( + $"{inventoryUrl}/api/inventory/reserve", + new { item = req.Item, quantity = req.Quantity }); + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "java-inventory-api unreachable at {InventoryUrl}", inventoryUrl); + return Results.Problem(detail: $"inventory service unreachable: {ex.Message}", statusCode: 502); + } + + if (!reserveResponse.IsSuccessStatusCode) + { + var body = await reserveResponse.Content.ReadAsStringAsync(); + logger.LogWarning("Inventory reservation failed with {StatusCode}: {Body}", reserveResponse.StatusCode, body); + return Results.Problem(detail: body, statusCode: (int)reserveResponse.StatusCode); + } + + // ~15% simulated failure rate on the order itself (after stock is + // already reserved) — gives the SLO/error-rate dashboard real, + // non-zero data to show instead of a flat 100% success line. This is + // a demo-data generator, not a bug — see README "What this project + // deliberately leaves out". + if (random.NextDouble() < 0.15) + { + logger.LogError("Simulated order processing failure for item {Item}", req.Item); + return Results.Problem(detail: "simulated order processing failure", statusCode: 500); + } + + var order = new OrderRecord(Guid.NewGuid().ToString(), req.Item, req.Quantity, DateTimeOffset.UtcNow); + orders.Add(order); + logger.LogInformation("Order {OrderId} confirmed for {Quantity}x {Item}", order.Id, req.Quantity, req.Item); + return Results.Created($"/api/orders/{order.Id}", order); +}); + +app.Run(); + +record OrderRequest(string Item, int Quantity); +record OrderRecord(string Id, string Item, int Quantity, DateTimeOffset CreatedAt); diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Properties/launchSettings.json b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Properties/launchSettings.json new file mode 100644 index 0000000..51e78c3 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5297", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7170;http://localhost:5297", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.Development.json b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.json b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/dotnet-orders-api.csproj b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/dotnet-orders-api.csproj new file mode 100644 index 0000000..6a22b49 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/dotnet-orders-api/dotnet-orders-api.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + dotnet_orders_api + + + + + + + + diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/.dockerignore b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.dockerignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitattributes b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitignore b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/.mvn/wrapper/maven-wrapper.properties b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/Dockerfile b/projects/azure-monitoring-dashboard/apps/java-inventory-api/Dockerfile new file mode 100644 index 0000000..39cb799 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/Dockerfile @@ -0,0 +1,34 @@ +# Multi-stage build: Maven+JDK image compiles, JRE-only image ships. +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /src + +COPY pom.xml . +RUN mvn -q -B dependency:go-offline + +COPY src ./src +RUN mvn -q -B package -DskipTests + +# Azure Monitor's Java auto-instrumentation is agent-based, not a library +# dependency (the opposite of the .NET side's SDK approach — a genuinely +# different, both-valid pattern per ecosystem, see README). Downloaded here +# in the build stage (which already has curl via the Maven base image) and +# copied into the runtime stage below, so the final image doesn't need curl +# at all. The release asset name is versioned +# (applicationinsights-agent-X.Y.Z.jar), so a plain `ADD ` can't target +# it without hardcoding a version that goes stale — resolve the actual +# latest asset via the GitHub API instead. +RUN curl -fsSL "https://api.github.com/repos/microsoft/ApplicationInsights-Java/releases/latest" \ + | grep -o '"browser_download_url": *"[^"]*applicationinsights-agent-[^"]*\.jar"' \ + | grep -o 'https://[^"]*' \ + | xargs curl -fsSL -o /src/applicationinsights-agent.jar + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /src/target/*.jar app.jar +COPY --from=build /src/applicationinsights-agent.jar applicationinsights-agent.jar + +EXPOSE 8081 +# Without APPLICATIONINSIGHTS_CONNECTION_STRING set, the agent just logs a +# warning and stays inert — same "no crash without Azure" behavior as +# dotnet-orders-api, just achieved a different way. +ENTRYPOINT ["java", "-javaagent:/app/applicationinsights-agent.jar", "-jar", "app.jar"] diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw b/projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw.cmd b/projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/pom.xml b/projects/azure-monitoring-dashboard/apps/java-inventory-api/pom.xml new file mode 100644 index 0000000..aa84b3e --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/pom.xml @@ -0,0 +1,50 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + com.tungbq.devopsproject + java-inventory-api + 0.0.1-SNAPSHOT + java-inventory-api + Sample inventory API for the azure-monitoring-dashboard project — see its README. + + 21 + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-webmvc + + + + org.springframework.boot + spring-boot-starter-actuator-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/InventoryController.java b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/InventoryController.java new file mode 100644 index 0000000..ad7f70b --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/InventoryController.java @@ -0,0 +1,51 @@ +package com.tungbq.devopsproject.inventory; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class InventoryController { + + private static final Logger log = LoggerFactory.getLogger(InventoryController.class); + + // In-memory stock — a demo sink, not a real inventory system. Same + // pattern as the microservices-orchestration project's + // inventory-service.py, for narrative consistency across the repo. + private final Map stock = new ConcurrentHashMap<>(Map.of( + "widget", 50, + "gadget", 20)); + + public record ReserveRequest(String item, int quantity) { + } + + @PostMapping("/api/inventory/reserve") + public ResponseEntity reserve(@RequestBody ReserveRequest req) { + Integer available = stock.get(req.item()); + if (available == null) { + log.warn("Reservation attempted for unknown item {}", req.item()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "unknown item '" + req.item() + "'")); + } + if (available < req.quantity()) { + log.warn("Insufficient stock for {}: have {}, requested {}", req.item(), available, req.quantity()); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "insufficient stock for '" + req.item() + "'")); + } + + int remaining = available - req.quantity(); + stock.put(req.item(), remaining); + log.info("Reserved {}x {} ({} remaining)", req.quantity(), req.item(), remaining); + return ResponseEntity.ok(Map.of( + "service", "java-inventory-api", + "item", req.item(), + "reserved", req.quantity(), + "remaining", remaining)); + } +} diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplication.java b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplication.java new file mode 100644 index 0000000..fafc36f --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplication.java @@ -0,0 +1,13 @@ +package com.tungbq.devopsproject.inventory; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class JavaInventoryApiApplication { + + public static void main(String[] args) { + SpringApplication.run(JavaInventoryApiApplication.class, args); + } + +} diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/resources/application.properties b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/resources/application.properties new file mode 100644 index 0000000..a8bc8e5 --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/main/resources/application.properties @@ -0,0 +1,6 @@ +spring.application.name=java-inventory-api +server.port=8081 +# Actuator's health endpoint doubles as this app's /health for the demo — +# no need for a hand-written one when Spring already ships a correct one. +management.endpoints.web.exposure.include=health +management.endpoint.health.show-details=never diff --git a/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/test/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplicationTests.java b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/test/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplicationTests.java new file mode 100644 index 0000000..fa0699a --- /dev/null +++ b/projects/azure-monitoring-dashboard/apps/java-inventory-api/src/test/java/com/tungbq/devopsproject/inventory/JavaInventoryApiApplicationTests.java @@ -0,0 +1,13 @@ +package com.tungbq.devopsproject.inventory; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class JavaInventoryApiApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/projects/azure-monitoring-dashboard/demo_project.sh b/projects/azure-monitoring-dashboard/demo_project.sh new file mode 100755 index 0000000..b7feb06 --- /dev/null +++ b/projects/azure-monitoring-dashboard/demo_project.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Verifies everything that CAN be verified without a real Azure subscription: +# both sample apps build, run, and talk to each other correctly (containerized, +# same as they'd run on Azure Container Apps), and the Terraform + Workbook +# JSON are syntactically valid. It does NOT run `terraform apply` — there is +# no Azure credential in CI for this repo (same as every other Azure project +# here), and APIM alone can take 30-45+ minutes to provision even on the +# Consumption tier, which wouldn't be a reasonable CI check regardless. See +# README "Deploying to Azure" for the real `terraform apply` walkthrough, +# meant to be run against your own subscription. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +NETWORK="azmon-demo-verify" +JAVA_CONTAINER="azmon-verify-java" +DOTNET_CONTAINER="azmon-verify-dotnet" + +cleanup() { + docker rm -f "$JAVA_CONTAINER" "$DOTNET_CONTAINER" >/dev/null 2>&1 || true + docker network rm "$NETWORK" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +echo "==> Building images" +docker build -t java-inventory-api:local ./apps/java-inventory-api +docker build -t dotnet-orders-api:local ./apps/dotnet-orders-api + +echo "==> Running both containers on a shared network (no Azure needed for this part)" +docker network create "$NETWORK" >/dev/null +docker run -d --name "$JAVA_CONTAINER" --network "$NETWORK" java-inventory-api:local >/dev/null +docker run -d --name "$DOTNET_CONTAINER" --network "$NETWORK" -p 8180:8080 \ + -e "INVENTORY_URL=http://${JAVA_CONTAINER}:8081" dotnet-orders-api:local >/dev/null + +echo "==> Waiting for both services to be healthy" +for i in $(seq 1 20); do + if curl -sf http://localhost:8180/health >/dev/null 2>&1; then break; fi + sleep 2 +done + +echo "==> Placing a real order through the full chain (dotnet-orders-api -> java-inventory-api)" +# dotnet-orders-api simulates a ~15% failure rate on purpose (see Program.cs) +# so the SLO/error-rate dashboard has real non-zero data to show. That means +# a single request has a real, non-negligible chance of hitting the +# simulated failure — retry a few times so this check verifies the +# dotnet->java chain itself, not the (intentional) failure-injection. +response="" +for attempt in $(seq 1 8); do + if response=$(curl -sf -X POST http://localhost:8180/api/orders \ + -H "Content-Type: application/json" \ + -d '{"item": "widget", "quantity": 2}'); then + break + fi + echo " attempt $attempt hit the simulated failure rate or wasn't ready yet, retrying..." + response="" + sleep 1 +done + +if [ -z "$response" ] || ! echo "$response" | grep -q '"item":"widget"'; then + echo "FAIL: order did not go through the dotnet -> java chain as expected" >&2 + exit 1 +fi +echo "$response" +echo "==> Order confirmed — dotnet-orders-api successfully called java-inventory-api across a real container network." + +echo "==> Validating Terraform (no Azure credentials needed for validate/fmt)" +cd "$SCRIPT_DIR/terraform" +terraform fmt -check -recursive +terraform init -backend=false -input=false >/dev/null +terraform validate + +echo "==> Validating the Workbook JSON is well-formed" +python3 -m json.tool "$SCRIPT_DIR/workbook/slo-dashboard.workbook.json" >/dev/null + +echo "==> Done. Everything that can be verified without an Azure subscription has been verified." +echo " To actually deploy: see README.md '3-Deploy to Azure'." diff --git a/projects/azure-monitoring-dashboard/terraform/.terraform.lock.hcl b/projects/azure-monitoring-dashboard/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..513af14 --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "5.3.0" + constraints = "~> 5.0" + hashes = [ + "h1:PTS4Sc+EsEg0wPljNjxXlOonJk50XPeL7GbjPd/TZ8g=", + "zh:20021796cd5496164cbd01e9d232228eb1859f25b11780f4af208441f78d6692", + "zh:350692492392a60d34efc664baf85546afbfb4a7f09722eff0694f9da9d199e9", + "zh:520402534e3fac422db6e6bcb558e6d659c82b8edb9534a06cabab546692677c", + "zh:6d403bb7598f42cd2233d183dcebface3b60c3ab4b8720af6e76cfbc8e7bf730", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:989af4995c17ed023a4a2bae6d276e06c42a8cb40fa1048f3ec6a7f7a80117d8", + "zh:b552df50814f490506139f65b4243d2eb3809ccf20f42f63f292ec826317a691", + "zh:b8ed82120acec7555305d9c0fb616c17cc213e093a974c56b385477eb9b79460", + "zh:c150c63555af332c355d59a195d5a2e6af9817168d0a068be5a6b66c8a23c61d", + "zh:c9ed834a0e6e1fa4bc6093f317e4d4e1356246f3eb878f5fdac3fbacc19216bb", + "zh:e7bc707a41948754d24e2bb07067ee9e44dc9d8de8c1c5412f24b6951104e15c", + "zh:f92312ae7b57a85b8f3adfb4d733fff3590b11cf3e83d6bc9939a274a7908d00", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:lVDv+0AjDjrLfpmaJbWqUmIw/k3/AHXLc3N4m55SNdo=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", + ] +} diff --git a/projects/azure-monitoring-dashboard/terraform/apim.tf b/projects/azure-monitoring-dashboard/terraform/apim.tf new file mode 100644 index 0000000..c48dde1 --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/apim.tf @@ -0,0 +1,103 @@ +# Consumption tier: serverless, pay-per-call, provisions in ~15 min (vs. +# 30-45+ min for Developer tier) — the right choice for a demo project you +# spin up and tear down, not a production gateway. Trade-off: no VNet +# integration, no built-in caching — irrelevant here. +resource "azurerm_api_management" "main" { + name = "apim-${var.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + publisher_name = var.apim_publisher_name + publisher_email = var.apim_publisher_email + sku_name = "Consumption_0" +} + +# One API, path-based routing to both apps (/orders/* -> dotnet-orders-api, +# handled entirely by dotnet-orders-api's own routes since it's the only +# externally-facing app — java-inventory-api stays internal-only, called +# only by dotnet-orders-api, never directly through APIM). This mirrors a +# realistic "API gateway in front of one public service" shape rather than +# exposing an internal service through the gateway for no reason. +resource "azurerm_api_management_api" "orders" { + name = "orders-api" + resource_group_name = azurerm_resource_group.main.name + api_management_name = azurerm_api_management.main.name + revision = "1" + display_name = "Orders API" + path = "orders" + protocols = ["https"] + service_url = "https://${azurerm_container_app.dotnet_orders_api.ingress[0].fqdn}" +} + +resource "azurerm_api_management_api_operation" "list_orders" { + operation_id = "list-orders" + api_name = azurerm_api_management_api.orders.name + api_management_name = azurerm_api_management.main.name + resource_group_name = azurerm_resource_group.main.name + display_name = "List orders" + method = "GET" + url_template = "/api/orders" +} + +resource "azurerm_api_management_api_operation" "create_order" { + operation_id = "create-order" + api_name = azurerm_api_management_api.orders.name + api_management_name = azurerm_api_management.main.name + resource_group_name = azurerm_resource_group.main.name + display_name = "Create order" + method = "POST" + url_template = "/api/orders" + + request { + representation { + content_type = "application/json" + } + } +} + +resource "azurerm_api_management_api_operation" "health" { + operation_id = "health" + api_name = azurerm_api_management_api.orders.name + api_management_name = azurerm_api_management.main.name + resource_group_name = azurerm_resource_group.main.name + display_name = "Health" + method = "GET" + url_template = "/health" +} + +# Sends every gateway request/response through to Application Insights — +# this is the "APIM-level" RED-metrics source the Workbook queries +# alongside each app's own OpenTelemetry data (ApiManagementGatewayLogs vs. +# AppRequests — two different views of the same traffic, useful for +# comparing "what APIM saw" against "what the app itself recorded"). +resource "azurerm_api_management_logger" "app_insights" { + name = "appinsights-logger" + api_management_name = azurerm_api_management.main.name + resource_group_name = azurerm_resource_group.main.name + + application_insights { + instrumentation_key = azurerm_application_insights.main.instrumentation_key + } +} + +resource "azurerm_api_management_diagnostic" "app_insights" { + identifier = "applicationinsights" + resource_group_name = azurerm_resource_group.main.name + api_management_name = azurerm_api_management.main.name + api_management_logger_id = azurerm_api_management_logger.app_insights.id + sampling_percentage = 100 + always_log_errors = true + http_correlation_protocol = "W3C" + + frontend_request { + body_bytes = 512 + } + frontend_response { + body_bytes = 512 + } + backend_request { + body_bytes = 512 + } + backend_response { + body_bytes = 512 + } +} diff --git a/projects/azure-monitoring-dashboard/terraform/container-apps.tf b/projects/azure-monitoring-dashboard/terraform/container-apps.tf new file mode 100644 index 0000000..d3f155b --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/container-apps.tf @@ -0,0 +1,97 @@ +resource "azurerm_container_app_environment" "main" { + name = "cae-${var.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id +} + +# java-inventory-api — internal only. Nothing outside the Container Apps +# Environment calls it directly; APIM and dotnet-orders-api both reach it +# through its internal ingress FQDN (external_enabled = false still gets a +# resolvable in-environment FQDN, it's just not internet-routable). +resource "azurerm_container_app" "java_inventory_api" { + name = "java-inventory-api" + container_app_environment_id = azurerm_container_app_environment.main.id + resource_group_name = azurerm_resource_group.main.name + revision_mode = "Single" + + # `secret` is a top-level block on this resource, not nested under + # `template` — confirmed against the actual installed provider schema + # (`terraform providers schema -json`), not just docs prose. + secret { + name = "appinsights-connection-string" + value = azurerm_application_insights.main.connection_string + } + + template { + container { + # Replace with your own registry/image once you've pushed one — see + # README "3-Deploy to Azure" step 1. A public placeholder here would + # just be wrong the moment someone actually applies this. + name = "java-inventory-api" + image = var.java_inventory_api_image + cpu = 0.5 + memory = "1Gi" + + env { + name = "APPLICATIONINSIGHTS_CONNECTION_STRING" + secret_name = "appinsights-connection-string" + } + } + } + + ingress { + external_enabled = false + target_port = 8081 + traffic_weight { + latest_revision = true + percentage = 100 + } + } +} + +# dotnet-orders-api — the only externally-exposed app; APIM sits in front +# of this (see apim.tf), matching how the sample microservices-orchestration +# project also keeps only the "front door" service publicly reachable. +resource "azurerm_container_app" "dotnet_orders_api" { + name = "dotnet-orders-api" + container_app_environment_id = azurerm_container_app_environment.main.id + resource_group_name = azurerm_resource_group.main.name + revision_mode = "Single" + + secret { + name = "appinsights-connection-string" + value = azurerm_application_insights.main.connection_string + } + + template { + container { + name = "dotnet-orders-api" + image = var.dotnet_orders_api_image + cpu = 0.5 + memory = "1Gi" + + env { + name = "APPLICATIONINSIGHTS_CONNECTION_STRING" + secret_name = "appinsights-connection-string" + } + env { + # Container Apps' internal DNS resolves other apps in the same + # environment by name — same "config, not code" pattern as the + # microservices-orchestration project's K8s ConfigMap, just + # Container-Apps-flavored. + name = "INVENTORY_URL" + value = "http://${azurerm_container_app.java_inventory_api.name}" + } + } + } + + ingress { + external_enabled = true + target_port = 8080 + traffic_weight { + latest_revision = true + percentage = 100 + } + } +} diff --git a/projects/azure-monitoring-dashboard/terraform/main.tf b/projects/azure-monitoring-dashboard/terraform/main.tf new file mode 100644 index 0000000..5f11578 --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/main.tf @@ -0,0 +1,28 @@ +resource "azurerm_resource_group" "main" { + name = "rg-${var.prefix}" + location = var.location +} + +# Single workspace, shared by Container Apps platform logs, both apps' +# OpenTelemetry traces (via workspace-based Application Insights below), +# and APIM's gateway diagnostic logs — this is what makes one Workbook able +# to query all three in the same place. +resource "azurerm_log_analytics_workspace" "main" { + name = "law-${var.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + sku = "PerGB2018" + retention_in_days = 30 +} + +# workspace_id is set on creation and cannot be changed afterwards without +# replacing this resource (a real azurerm provider constraint) — always +# workspace-based from day one here, never left to default to the legacy +# classic (non-workspace) mode. +resource "azurerm_application_insights" "main" { + name = "appi-${var.prefix}" + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + application_type = "web" + workspace_id = azurerm_log_analytics_workspace.main.id +} diff --git a/projects/azure-monitoring-dashboard/terraform/outputs.tf b/projects/azure-monitoring-dashboard/terraform/outputs.tf new file mode 100644 index 0000000..960f26c --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/outputs.tf @@ -0,0 +1,25 @@ +output "apim_gateway_url" { + description = "Base URL to call the orders API through APIM, e.g. {this}/orders/api/orders" + value = azurerm_api_management.main.gateway_url +} + +output "dotnet_orders_api_url" { + description = "Direct public URL for dotnet-orders-api (bypassing APIM) — useful for confirming the app itself is healthy before debugging APIM routing." + value = "https://${azurerm_container_app.dotnet_orders_api.ingress[0].fqdn}" +} + +output "application_insights_connection_string" { + description = "Connection string both sample apps read from APPLICATIONINSIGHTS_CONNECTION_STRING." + value = azurerm_application_insights.main.connection_string + sensitive = true +} + +output "log_analytics_workspace_id" { + description = "Resource ID of the shared workspace — every KQL query in the Workbook runs against this." + value = azurerm_log_analytics_workspace.main.id +} + +output "workbook_resource_id" { + description = "Resource ID of the deployed Workbook — open it via 'az monitor app-insights ...' or just find it under Azure Monitor > Workbooks in the Portal." + value = azurerm_application_insights_workbook.slo_dashboard.id +} diff --git a/projects/azure-monitoring-dashboard/terraform/providers.tf b/projects/azure-monitoring-dashboard/terraform/providers.tf new file mode 100644 index 0000000..ab7cb08 --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/providers.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">=1.5" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~>5.0" + } + random = { + source = "hashicorp/random" + version = "~>3.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/projects/azure-monitoring-dashboard/terraform/variables.tf b/projects/azure-monitoring-dashboard/terraform/variables.tf new file mode 100644 index 0000000..c524267 --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/variables.tf @@ -0,0 +1,31 @@ +variable "prefix" { + description = "Short prefix applied to every resource name (keep it lowercase, no special chars — some Azure resources like ACR/Storage have strict naming rules even though none are used here today)." + type = string + default = "azmondemo" +} + +variable "location" { + description = "Azure region for every resource in this project." + type = string + default = "eastus" +} + +variable "apim_publisher_name" { + description = "Required by Azure API Management — shown in the developer portal, not used anywhere else in this project." + type = string +} + +variable "apim_publisher_email" { + description = "Required by Azure API Management — used for service notifications (e.g. certificate expiry)." + type = string +} + +variable "dotnet_orders_api_image" { + description = "Full image reference (registry/repo:tag) for dotnet-orders-api — push your build there first, see README '3-Deploy to Azure' step 1. No default: an unset/placeholder default here would silently deploy nothing useful." + type = string +} + +variable "java_inventory_api_image" { + description = "Full image reference (registry/repo:tag) for java-inventory-api — same as dotnet_orders_api_image above." + type = string +} diff --git a/projects/azure-monitoring-dashboard/terraform/workbook.tf b/projects/azure-monitoring-dashboard/terraform/workbook.tf new file mode 100644 index 0000000..6bd2eee --- /dev/null +++ b/projects/azure-monitoring-dashboard/terraform/workbook.tf @@ -0,0 +1,25 @@ +# NOTE: the correct resource name is azurerm_application_insights_workbook +# — azurerm_dashboard_workbook does not exist in the provider (verified +# against the Terraform Registry docs; an earlier draft of this file used +# the wrong name). +# +# `name` must be a valid GUID (verified against the provider docs — a +# friendly string like "slo-dashboard" is rejected) — `random_uuid` +# generates one once and keeps it stable across applies. The human-readable +# name goes in `display_name` instead, which has no such restriction. +resource "random_uuid" "slo_dashboard" {} + +resource "azurerm_application_insights_workbook" "slo_dashboard" { + name = random_uuid.slo_dashboard.result + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + display_name = "Service health — SLO, RED metrics, root-cause search" + category = "workbook" + + # source_id must not contain uppercase letters — a real, documented + # azurerm provider validation quirk (Terraform resource IDs otherwise + # preserve the case you typed for the resource group/name). + source_id = lower(azurerm_log_analytics_workspace.main.id) + + data_json = file("${path.module}/../workbook/slo-dashboard.workbook.json") +} diff --git a/projects/azure-monitoring-dashboard/workbook/slo-dashboard.workbook.json b/projects/azure-monitoring-dashboard/workbook/slo-dashboard.workbook.json new file mode 100644 index 0000000..e7b2b14 --- /dev/null +++ b/projects/azure-monitoring-dashboard/workbook/slo-dashboard.workbook.json @@ -0,0 +1,177 @@ +{ + "$schema": "https://github.com/Microsoft/Application-Insights-Workbooks/blob/master/schema/workbook.json", + "version": "Notebook/1.0", + "isLocked": false, + "items": [ + { + "type": 1, + "content": { + "json": "# Service health — SLO, RED metrics, and root-cause log search\n\nPick a service and time range below. Every panel updates for that selection. See this project's README for what each panel means and how to drill from a log row into the full distributed trace." + }, + "name": "intro" + }, + { + "type": 9, + "content": { + "version": "KqlParameterItem/1.0", + "parameters": [ + { + "id": "b7e6f5b4-1a2b-4c3d-9e8f-1a2b3c4d5e6f", + "version": "KqlParameterItem/1.0", + "name": "Service", + "type": 2, + "isRequired": true, + "query": "AppRequests\n| distinct cloud_RoleName\n| order by cloud_RoleName asc", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "typeSettings": { + "additionalResourceOptions": [], + "showDefault": false + } + }, + { + "id": "c8f7g6c5-2b3c-5d4e-af9a-2b3c4d5e6f7a", + "version": "KqlParameterItem/1.0", + "name": "TimeRange", + "type": 4, + "isRequired": true, + "value": { + "durationMs": 86400000 + }, + "typeSettings": { + "selectableValues": [ + { "durationMs": 3600000 }, + { "durationMs": 14400000 }, + { "durationMs": 86400000 }, + { "durationMs": 604800000 } + ], + "allowCustom": true + }, + "label": "Time range" + }, + { + "id": "d9g8h7d6-3c4d-6e5f-b0ab-3c4d5e6f7a8b", + "version": "KqlParameterItem/1.0", + "name": "SearchText", + "type": 1, + "isRequired": false, + "value": "", + "label": "Log filter (free text, e.g. an order ID or error message fragment)" + } + ], + "style": "pills", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces" + }, + "name": "parameters" + }, + { + "type": 12, + "content": { + "version": "NotebookGroup/1.0", + "groupType": "editable", + "title": "RED metrics — {Service}", + "items": [ + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AppRequests\n| where cloud_RoleName == '{Service}'\n| where timestamp {TimeRange}\n| summarize [\"Requests/min\"] = count() / (toscalar(datetime_diff('minute', max(timestamp), min(timestamp))) + 1) by bin(timestamp, 5m)\n| render timechart", + "size": 0, + "title": "Request rate", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces" + }, + "name": "request-rate" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AppRequests\n| where cloud_RoleName == '{Service}'\n| where timestamp {TimeRange}\n| summarize Total = count(), Failed = countif(success == false) by bin(timestamp, 5m)\n| extend [\"Error rate %\"] = round(100.0 * Failed / Total, 2)\n| project timestamp, [\"Error rate %\"]\n| render timechart", + "size": 0, + "title": "Error rate (%)", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces" + }, + "name": "error-rate" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AppRequests\n| where cloud_RoleName == '{Service}'\n| where timestamp {TimeRange}\n| summarize P50 = percentile(duration, 50), P95 = percentile(duration, 95), P99 = percentile(duration, 99) by bin(timestamp, 5m)\n| render timechart", + "size": 0, + "title": "Response time percentiles (ms)", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces" + }, + "name": "duration-percentiles" + } + ] + }, + "name": "red-metrics-group" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "// SLO definition for this demo: 95% of requests should complete under 500ms.\n// Adjust the 500 / 95 constants below to match a real SLO target.\nAppRequests\n| where cloud_RoleName == '{Service}'\n| where timestamp {TimeRange}\n| summarize Total = count(), UnderTarget = countif(duration < 500)\n| project [\"SLO: % of requests under 500ms\"] = round(100.0 * UnderTarget / Total, 2)", + "size": 3, + "title": "SLO", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "visualization": "tiles", + "tileSettings": { + "showBorder": true, + "numberFormatSettings": { + "unit": 1, + "options": { + "style": "decimal", + "maximumFractionDigits": 2 + } + } + } + }, + "name": "slo-tile" + }, + { + "type": 1, + "content": { + "json": "## Root-cause log search\n\nUse the **Log filter** box above to search recent traces and exceptions for `{Service}` — e.g. an order ID, an item name, or an exception type. Each row below carries an `operation_Id`. Copy it and paste it into **Application Insights → Investigate → Transaction search** in the Azure Portal to see the full distributed trace for that request, including the cross-service dotnet→java call chain." + }, + "name": "root-cause-header" + }, + { + "type": 3, + "content": { + "version": "KqlItem/1.0", + "query": "AppExceptions\n| where cloud_RoleName == '{Service}'\n| where timestamp {TimeRange}\n| where isempty('{SearchText}') or outerMessage has '{SearchText}' or type has '{SearchText}' or operation_Id has '{SearchText}'\n| project timestamp, severityLevel = 3, kind = 'exception', operation_Id, message = strcat(type, ': ', outerMessage)\n| union (\n AppTraces\n | where cloud_RoleName == '{Service}'\n | where timestamp {TimeRange}\n | where isempty('{SearchText}') or message has '{SearchText}' or operation_Id has '{SearchText}'\n | project timestamp, severityLevel, kind = 'trace', operation_Id, message\n)\n| order by timestamp desc\n| take 200", + "size": 0, + "title": "Matching traces and exceptions", + "noDataMessage": "No traces or exceptions match this filter in the selected time range.", + "queryType": 0, + "resourceType": "microsoft.operationalinsights/workspaces", + "visualization": "table", + "gridSettings": { + "formatters": [ + { + "columnMatch": "severityLevel", + "formatter": 18, + "formatOptions": { + "thresholdsOptions": "icons", + "thresholdsGrid": [ + { "operator": ">=", "thresholdValue": "3", "representation": "redHeavy" }, + { "operator": ">=", "thresholdValue": "2", "representation": "yellow" }, + { "operator": "Default", "thresholdValue": null, "representation": "success" } + ] + } + } + ], + "sortBy": [{ "itemKey": "timestamp", "sortOrder": 2 }] + } + }, + "name": "log-search-table" + } + ] +}