diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..6a95c5d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,76 @@
+name: ci
+
+on:
+ push:
+ branches: ["master", "main", "plugin/**", "devin/**"]
+ pull_request:
+ workflow_dispatch:
+
+env:
+ JAVA_VERSION: "21"
+
+jobs:
+ build-test:
+ name: build + unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: ${{ env.JAVA_VERSION }}
+ cache: maven
+ - name: Unit tests
+ run: ./mvnw -B test
+ - name: Package
+ run: ./mvnw -B -DskipTests package
+ - uses: actions/upload-artifact@v4
+ with:
+ name: app-jar
+ path: target/app.jar
+ retention-days: 3
+
+ terraform:
+ name: terraform validate
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: "1.9.8"
+ - run: terraform -chdir=infra/terraform init -backend=false -input=false
+ - run: terraform -chdir=infra/terraform fmt -check -recursive
+ - run: terraform -chdir=infra/terraform validate
+
+ verify:
+ name: infra up + make verify
+ runs-on: ubuntu-latest
+ needs: build-test
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: app-jar
+ path: target
+ - name: Build container image
+ run: docker compose --env-file infra/.env -f infra/docker-compose.yml build app
+ - name: make up
+ run: make up MVN=true
+ - name: Health check via nginx
+ run: make health
+ - name: Smoke test
+ run: make smoke
+ - name: Load test with threshold gate + NFR comparison against demo/BASELINE.json
+ run: make verify P95_MS=250 ERR_RATE=0.01 DURATION=30s
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: k6-summary
+ path: load/results/latest.json
+ if-no-files-found: ignore
+ - name: Stack logs on failure
+ if: failure()
+ run: docker compose --env-file infra/.env -f infra/docker-compose.yml logs --tail=100
+ - name: make down
+ if: always()
+ run: make down
diff --git a/.gitignore b/.gitignore
index 57f1cb2..5f17f02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,8 @@
-/.idea/
\ No newline at end of file
+/.idea/
+target/
+build/
+*.iml
+load/results/
+infra/secrets/app.env
+.terraform/
+*.tfstate*
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
index c954cec..5a8e1c8 100644
--- a/.mvn/wrapper/maven-wrapper.properties
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -1 +1 @@
-distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.3.9/apache-maven-3.3.9-bin.zip
+distributionUrl=https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.zip
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..19541f5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,13 @@
+# Runtime base image is pinned by infra/terraform (var.runtime_image) and infra/.env (RUNTIME_IMAGE).
+ARG BASE_IMAGE=eclipse-temurin:11-jre
+FROM ${BASE_IMAGE}
+
+LABEL com.datadoghq.tags.service="topics-api" \
+ com.datadoghq.tags.env="legacy" \
+ com.datadoghq.tags.version="0.1.0"
+
+WORKDIR /app
+COPY target/app.jar /app/app.jar
+ENV JAVA_OPTS="-Xms128m -Xmx256m"
+EXPOSE 8080
+ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar"]
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..5d51f2d
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,164 @@
+SHELL := /bin/bash
+COMPOSE := docker compose --env-file infra/.env -f infra/docker-compose.yml
+MVN ?= ./mvnw # MVN=true skips the build when target/app.jar is prebuilt (CI)
+LB_URL ?= http://localhost:8088
+PROM_URL ?= http://localhost:9091
+GRAFANA_URL ?= http://localhost:3000
+HEALTH_PATH ?= /health
+LB_INTERNAL ?= http://lb.topics.internal
+K6_IMAGE ?= grafana/k6:0.54.0
+VUS ?= 20
+DURATION ?= 30s
+P95_MS ?= 250
+ERR_RATE ?= 0.01
+RESULTS := $(CURDIR)/load/results
+BASELINE := demo/BASELINE.json
+
+.PHONY: build image up down reset logs health smoke baseline loadtest verify compare psql report
+
+build:
+ $(MVN) -q -DskipTests package
+
+image: build
+ $(COMPOSE) build app
+
+infra/secrets/app.env:
+ cp infra/secrets/.env.example infra/secrets/app.env
+
+up: image infra/secrets/app.env
+ $(COMPOSE) up -d --remove-orphans
+ @echo "waiting for load balancer $(LB_URL)$(HEALTH_PATH) ..."
+ @for i in $$(seq 1 60); do \
+ if curl -fsS $(LB_URL)$(HEALTH_PATH) >/dev/null 2>&1; then echo "stack is up"; exit 0; fi; sleep 2; \
+ done; echo "load balancer never became healthy"; $(COMPOSE) logs --tail=50 app nginx; exit 1
+
+down:
+ $(COMPOSE) down --remove-orphans
+
+reset:
+ $(COMPOSE) down -v --remove-orphans
+ rm -rf $(RESULTS)
+
+logs:
+ $(COMPOSE) logs -f --tail=100
+
+health:
+ @echo "== nginx health check ($(LB_URL)$(HEALTH_PATH)) =="
+ @curl -fsS -w '\nHTTP %{http_code}\n' $(LB_URL)$(HEALTH_PATH)
+
+smoke: health
+ @echo "== smoke: list topics =="
+ @curl -fsS $(LB_URL)/topic | tee /dev/stderr | grep -q '"spring"'
+ @echo "== smoke: stored function via /topic/report =="
+ @curl -fsS $(LB_URL)/topic/report | tee /dev/stderr | grep -q '"bucket"'
+ @echo "== smoke: reporting consumer output =="
+ @$(COMPOSE) exec -T reporting sh -c 'test -s /reports/latest.json && cat /reports/latest.json' | grep -q '"report"'
+ @echo "== smoke: DNS from app network =="
+ @$(COMPOSE) exec -T app sh -c 'getent hosts db.topics.internal app.topics.internal lb.topics.internal'
+ @echo "== smoke: prometheus target up =="
+ @for i in $$(seq 1 12); do \
+ curl -fsS '$(PROM_URL)/api/v1/query?query=up%7Bjob%3D%22topics-api%22%7D' | grep -q '"value":\[[0-9.]*,"1"\]' && break; \
+ [ $$i -eq 12 ] && echo "prometheus target topics-api never came up" && exit 1; sleep 5; \
+ done
+ @echo "smoke OK"
+
+# Run k6 inside the compose network so the LB is reached through the DNS name.
+define run_k6
+ mkdir -p $(RESULTS)
+ docker run --rm --user $$(id -u):$$(id -g) --network topics_topics-net --dns 172.28.0.53 \
+ -v $(CURDIR)/load:/scripts -v $(RESULTS):/results \
+ -e BASE_URL=$(LB_INTERNAL) -e VUS=$(VUS) -e DURATION=$(DURATION) \
+ -e P95_MS=$(P95_MS) -e ERR_RATE=$(ERR_RATE) -e OUT=/results/$(1) \
+ $(K6_IMAGE) run /scripts/topics.js
+endef
+
+baseline:
+ $(call run_k6,baseline.json)
+ cp $(RESULTS)/baseline.json $(BASELINE)
+ @echo "baseline written to $(BASELINE)"
+
+loadtest:
+ $(call run_k6,latest.json)
+
+compare:
+ @python3 infra/scripts/compare_nfr.py $(BASELINE) $(RESULTS)/latest.json
+
+verify: smoke loadtest
+ @if [ -f $(BASELINE) ]; then $(MAKE) --no-print-directory compare; else echo "no $(BASELINE); skipping NFR comparison"; fi
+ @echo "verify OK"
+
+psql:
+ $(COMPOSE) exec db psql -U topics -d topics
+
+report:
+ $(COMPOSE) exec -T reporting cat /reports/latest.json
+
+# ---------------------------------------------------------------------------
+# AWS "real infra" path (infra/aws). Requires AWS credentials in the shell.
+# ---------------------------------------------------------------------------
+AWS_REGION ?= us-east-1
+AWS_DIR := infra/aws
+TF := terraform -chdir=$(AWS_DIR)
+AWS_TAG ?= legacy
+TRACK ?= java
+PY ?= python3 # e.g. PY=.venv/bin/python after: pip install -r tools/discover/requirements.txt
+AWS_BASELINE := demo/BASELINE-aws.json
+
+.PHONY: aws-init aws-push aws-up aws-down aws-status aws-health aws-baseline aws-loadtest aws-discover aws-check-clean
+
+aws-init:
+ @aws sts get-caller-identity --region $(AWS_REGION) --query Account --output text >/dev/null || (echo "aws sts get-caller-identity failed"; exit 1)
+ $(TF) init -input=false
+
+# ECR must exist before the image can be pushed; everything else waits for the image.
+aws-push: aws-init image
+ $(TF) apply -input=false -auto-approve -target=aws_ecr_repository.app
+ @ECR=$$($(TF) output -raw ecr_repository_url) && \
+ aws ecr get-login-password --region $(AWS_REGION) | docker login --username AWS --password-stdin $${ECR%%/*} && \
+ docker tag topics-api:$$(grep ^APP_VERSION infra/.env | cut -d= -f2) $$ECR:$(AWS_TAG) && \
+ docker push $$ECR:$(AWS_TAG)
+
+aws-up: aws-push
+ $(TF) apply -input=false -auto-approve -var app_image_tag=$(AWS_TAG)
+ @ALB=$$($(TF) output -raw alb_url); echo "waiting for $$ALB/manage/health/readiness (RDS + first boot take a few minutes) ..."; \
+ for i in $$(seq 1 90); do \
+ if curl -fsS $$ALB/manage/health/readiness >/dev/null 2>&1; then echo "AWS stack is up: $$ALB"; exit 0; fi; sleep 10; \
+ done; echo "ALB never became healthy"; $(MAKE) --no-print-directory aws-status; exit 1
+
+aws-status:
+ @$(TF) output 2>/dev/null || true
+ @aws elbv2 describe-target-health --region $(AWS_REGION) \
+ --target-group-arn $$(aws elbv2 describe-target-groups --region $(AWS_REGION) --names moddemo-topics-tg --query 'TargetGroups[0].TargetGroupArn' --output text) \
+ --query 'TargetHealthDescriptions[].TargetHealth' --output json
+
+aws-health:
+ @curl -fsS -w '\nHTTP %{http_code}\n' $$($(TF) output -raw alb_url)/manage/health/readiness
+
+define run_k6_aws
+ mkdir -p $(RESULTS)
+ docker run --rm --user $$(id -u):$$(id -g) \
+ -v $(CURDIR)/load:/scripts -v $(RESULTS):/results \
+ -e BASE_URL=$$($(TF) output -raw alb_url) -e VUS=$(VUS) -e DURATION=$(DURATION) \
+ -e P95_MS=$(P95_MS) -e ERR_RATE=$(ERR_RATE) -e OUT=/results/$(1) \
+ $(K6_IMAGE) run /scripts/topics.js
+endef
+
+aws-baseline:
+ $(call run_k6_aws,baseline-aws.json)
+ cp $(RESULTS)/baseline-aws.json $(AWS_BASELINE)
+ @echo "baseline written to $(AWS_BASELINE)"
+
+aws-loadtest:
+ $(call run_k6_aws,latest-aws.json)
+
+# Wiz-style agentless discovery: walk the AWS APIs by tag, emit the dependency graph.
+aws-discover:
+ $(PY) tools/discover/discover.py --region $(AWS_REGION) --project modernization-demo --track $(TRACK) --out demo
+ $(PY) tools/discover/diff.py --graph demo/dependency-graph.json --repo .
+
+aws-down: aws-init
+ $(TF) destroy -input=false -auto-approve
+ $(MAKE) --no-print-directory aws-check-clean
+
+aws-check-clean:
+ infra/scripts/aws_check_clean.sh $(AWS_REGION) modernization-demo $(TRACK)
diff --git a/application.properties b/application.properties
deleted file mode 100644
index d3baf26..0000000
--- a/application.properties
+++ /dev/null
@@ -1 +0,0 @@
-#server.port = 8081
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 09f1083..23bd03b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,32 +1,30 @@
-buildscript {
- repositories {
- mavenCentral()
- }
- dependencies {
- classpath("org.springframework.boot:spring-boot-gradle-plugin:2.0.2.RELEASE")
- }
+plugins {
+ id 'java'
+ id 'org.springframework.boot' version '2.7.18'
+ id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
-apply plugin: 'java'
-apply plugin: 'eclipse'
-apply plugin: 'idea'
-apply plugin: 'org.springframework.boot'
-apply plugin: 'io.spring.dependency-management'
-
-bootJar {
- baseName = 'gs-spring-boot'
- version = '0.1.0'
-}
+group = 'org.springframework'
+version = '0.1.0'
+sourceCompatibility = '11'
repositories {
mavenCentral()
}
-sourceCompatibility = 1.8
-targetCompatibility = 1.8
-
dependencies {
- compile("org.springframework.boot:spring-boot-starter-web")
- testCompile("junit:junit")
+ implementation 'org.springframework.boot:spring-boot-starter-web'
+ implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
+ implementation 'org.springframework.boot:spring-boot-starter-actuator'
+ runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
+ runtimeOnly 'org.postgresql:postgresql'
+ testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
+bootJar {
+ archiveFileName = 'app.jar'
+}
+
+test {
+ useJUnitPlatform()
+}
diff --git a/demo/BASELINE-aws.json b/demo/BASELINE-aws.json
new file mode 100644
index 0000000..54fc2d5
--- /dev/null
+++ b/demo/BASELINE-aws.json
@@ -0,0 +1,28 @@
+{
+ "generated_at": "2026-09-25T08:18:48.620Z",
+ "target": "http://moddemo-topics-alb-1184534545.us-east-1.elb.amazonaws.com",
+ "profile": {
+ "vus": 20,
+ "duration": "30s"
+ },
+ "requests": 7974,
+ "rps": 262.11,
+ "latency_ms": {
+ "p50": 64.82,
+ "p90": 73.16,
+ "p95": 78.37,
+ "p99": 115.75,
+ "avg": 67.09,
+ "max": 468.58
+ },
+ "endpoint_p95_ms": {
+ "list": 77.31,
+ "report": 72.21
+ },
+ "error_rate": 0,
+ "checks_failed": 0,
+ "thresholds": {
+ "http_req_duration: p(95)<250": "pass",
+ "http_req_failed: rate<0.01": "pass"
+ }
+}
diff --git a/demo/BASELINE.json b/demo/BASELINE.json
new file mode 100644
index 0000000..37872cb
--- /dev/null
+++ b/demo/BASELINE.json
@@ -0,0 +1,28 @@
+{
+ "generated_at": "2026-09-25T07:56:35.046Z",
+ "target": "http://lb.topics.internal",
+ "profile": {
+ "vus": 20,
+ "duration": "30s"
+ },
+ "requests": 64242,
+ "rps": 2138.88,
+ "latency_ms": {
+ "p50": 0.82,
+ "p90": 1.44,
+ "p95": 1.7,
+ "p99": 2.45,
+ "avg": 0.91,
+ "max": 20.29
+ },
+ "endpoint_p95_ms": {
+ "list": 1.26,
+ "report": 1.5
+ },
+ "error_rate": 0,
+ "checks_failed": 0,
+ "thresholds": {
+ "http_req_failed: rate<0.01": "pass",
+ "http_req_duration: p(95)<250": "pass"
+ }
+}
diff --git a/demo/PLAYBOOK.md b/demo/PLAYBOOK.md
new file mode 100644
index 0000000..369c8a9
--- /dev/null
+++ b/demo/PLAYBOOK.md
@@ -0,0 +1,96 @@
+# Modernization Verify [v1]
+
+## Overview
+Infra-aware verification of a code-only runtime migration (e.g. Java 8/11 + Spring Boot 2.7 → Java 21 + Spring Boot 3).
+The code transform PR already exists. This playbook discovers the running infrastructure, diffs it against what the
+migrated code now expects, opens an infra PR, deploys, validates NFRs against the pre-migration baseline, and produces
+the Verify report. Only ever create the TODO list for the current phase.
+
+## What's Needed From User
+- Repository and the migration PR/branch (e.g. `plugin/migration`)
+- Environment to verify against: `infra/` (docker-compose) and/or the AWS footprint (`make aws-up`, tag `Project=modernization-demo`)
+- Baseline file (`demo/BASELINE.json`) or permission to capture one from the legacy branch
+- Where to post results (PR comment, Slack channel)
+
+
+## Assess
+
+1. Discover the live footprint, do not infer it from code: run `make aws-discover` (AWS) and read `infra/docker-compose.yml`,
+ `infra/nginx`, `infra/firewall`, `infra/dns`, `infra/secrets`, `infra/terraform`, `Dockerfile` (local). Attach `demo/dependency-graph.png`.
+2. Enumerate database dependencies: schema owner, views/functions, every consumer (app, `infra/reporting`, anything else in the graph).
+3. Check out the migration branch and run `tools/discover/diff.py --repo . --graph demo/dependency-graph.json`; extend the
+ impact matrix with anything the script cannot see (property renames, pool/logging defaults, actuator paths).
+4. Search other repositories in the org for consumers of the same DB, DNS names or secret keys (cross-repo impact).
+
+
+- Dependency graph generated from live APIs/config, attached as PNG, not hand-drawn
+- Impact matrix lists every app↔infra↔DB contract with status OK/DRIFT and expected failure mode
+- Every DB view/function has its consumers listed
+- Cross-repo search performed and results (or "none found") recorded
+
+
+
+
+## Plan
+
+1. Open an **infra PR** on a branch off the migration branch, linked to the plugin PR, containing only the drift fixes:
+ runtime pin (Dockerfile ARG, `infra/terraform`, `infra/aws`), health-check path/port, firewall/DNS entries, secret key names,
+ scrape config, DB contract (revert schema mutation or migrate the view/function with the reporting consumer).
+2. Post the plan as the PR description: impact matrix, ordered fix list, rollback, and the NFR gates that will be enforced.
+3. Do NOT change application code except configuration required to restore the infra contract.
+
+
+- Infra PR exists, references the plugin PR, description ends with the required org trailer
+- Each DRIFT row in the matrix maps to a commit or an explicit "accepted, reason"
+- NFR gates stated numerically (p95, error rate, throughput vs baseline)
+
+
+
+
+## Execute and Verify
+
+1. Run CI on the infra PR; iterate on failures until green — read the job logs, fix, push, re-check. Never disable a gate.
+2. Deploy: `make reset && make up` (local) and/or `make aws-up` with the migrated image tag.
+3. Baseline: if `demo/BASELINE.json` is missing, capture it from the legacy branch with `make baseline` first.
+4. Load: `make loadtest` (same VUs/duration as baseline), then `make compare`. Investigate any regression in Grafana
+ (latency, Hikari pending, log rate) and Prometheus before changing anything; state the root cause with evidence.
+5. Fix, redeploy, re-run until `make verify` passes and `compare` is within tolerance.
+6. Browser verification with screen recording: open the load balancer URL in the browser, exercise `GET /topic`,
+ `GET /topic/report`, the health endpoint through the LB, and the Grafana dashboard; record the session and attach it.
+
+
+- CI green on the infra PR (link to run)
+- `make verify` output attached and passing; `compare` within tolerance against demo/BASELINE.json
+- Root cause of each regression stated with the metric/panel that showed it
+- Screen recording of the browser walkthrough attached
+
+
+
+
+## Complete
+
+1. Fill `demo/VERIFY-REPORT-TEMPLATE.md` completely; every row cites a file, command output or panel.
+2. Present the report in the session (attach the markdown) and post it as a comment on both PRs.
+3. If deployed to AWS for verification, tear down with `make aws-down` and confirm zero tagged resources remain.
+
+
+- Report attached in-session and posted on the plugin PR and infra PR
+- Residual risks table non-empty or explicitly "none"
+- Cloud footprint destroyed (or handed over with an owner named)
+
+
+
+## Specifications
+- Baseline and migrated load runs use identical k6 parameters.
+- Tolerances: p95 ≤ max(1.5× baseline, baseline + 25 ms); error rate ≤ 1 %; throughput ≥ 60 % of baseline.
+- Every commit message contains "feature" or "bug"; every PR description ends with `Devin-Org: engineering`.
+
+## Advice and Pointers
+- Framework upgrades change defaults silently: actuator base path/port, `open-in-view`, Hikari pool size, property names.
+- The reporting cron and the app share the schema; `ddl-auto=update` on one side is a cross-component change.
+- Firewall drops are silent — a 502/timeout from the LB with a healthy container usually means a port is not admitted.
+
+## Forbidden Actions
+- Do not edit `demo/PLANTED-GAPS.md` or the plugin PR's Java sources to make verification pass.
+- Do not loosen k6 thresholds or `compare_nfr.py` tolerances.
+- Do not leave the AWS footprint running after Complete.
diff --git a/demo/RESET.md b/demo/RESET.md
new file mode 100644
index 0000000..3780795
--- /dev/null
+++ b/demo/RESET.md
@@ -0,0 +1,34 @@
+# Reset between demo runs
+
+## Local (docker-compose) — ~1 minute
+
+```bash
+git checkout # legacy code
+make reset # compose down -v: drops Postgres data (the migrated branch ALTERs the schema), Grafana state, load/results
+make up && make smoke # legacy green
+make baseline # optional: refresh demo/BASELINE.json (only if the host changed)
+git checkout plugin/migration # planted-gap branch, untouched
+```
+
+`make reset` is required (not just `make down`) after any run of the `plugin/migration` app: with
+`ddl-auto=update` it adds a `description` column to `topics`, and that change lives in the `pgdata` volume.
+
+Undo any fixes applied live during Act 3 with `git checkout -- infra/ Dockerfile src/main/resources` on the
+migration branch, or reset the branch to the PR head: `git fetch && git reset --keep origin/plugin/migration`.
+
+## AWS footprint — ~10 minutes
+
+```bash
+make aws-down # terraform destroy + tag sweep; must print "clean: 0 tagged resources remain"
+make aws-up # ECR push + apply; RDS is the long pole (~6-8 min)
+make aws-discover # regenerates demo/dependency-graph.{json,md,png}
+```
+
+If `aws-down` reports leftover tagged resources, list them with
+`aws resourcegroupstaggingapi get-resources --tag-filters Key=Project,Values=modernization-demo` and delete by hand;
+Terraform state lives in `infra/aws/terraform.tfstate` (git-ignored) — do not delete it before destroying.
+
+## Devin side
+
+- Close the infra PR Devin opened in Act 2 (or leave it as an artefact and branch from a fresh copy).
+- Re-run with the same prompts from `RUNBOOK.md`; the planted gaps are deterministic.
diff --git a/demo/RUNBOOK.md b/demo/RUNBOOK.md
new file mode 100644
index 0000000..fe7e681
--- /dev/null
+++ b/demo/RUNBOOK.md
@@ -0,0 +1,152 @@
+# Runbook — Java 8/11 + Spring Boot 2.7 → Java 21 + Spring Boot 3 (15 minutes)
+
+**Story.** The Claude Code plugin has already migrated the code: `plugin/migration` compiles on Java 21, unit tests pass,
+PR is open. Devin picks up *after* that and runs the infra-aware phases the laptop tool cannot:
+**Assess → Plan → Execute/Verify → Complete**. Six gaps are planted (see `PLANTED-GAPS.md`); each act maps to them.
+
+| gap | what a repo-only tool cannot see |
+|---|---|
+| 1 | Infrastructure discovery — LB, DNS, firewall, secrets |
+| 2 | Database dependencies |
+| 3 | Cross-component / cross-repo impact |
+| 4 | NFR validation post-deployment vs. a baseline |
+| 5 | Observability baselining before vs. after |
+| 6 | Monitoring pipelines and iterating autonomously to green |
+
+## Before the demo (T-30 min)
+
+```bash
+make aws-up # real footprint: ALB, SGs, Route53 private zone, Secrets Manager, RDS Postgres, EC2 t3.small + ECR (~10 min)
+make aws-discover # refreshes demo/dependency-graph.{json,md,png} from live AWS APIs
+make reset && make up # local compose stack on the legacy branch; Grafana at http://localhost:3000/d/topics-api
+```
+
+Approximate footprint cost: **≈ $0.08 / hour** (ALB $0.0225 + LCU, t3.small $0.0208, db.t4g.micro $0.016 + 20 GB gp3 ≈ $0.003,
+Route53 private zone $0.50/month, Secrets Manager $0.40/month). Destroy with `make aws-down` immediately after — it prints
+`clean: 0 tagged resources remain`.
+
+Have open: the plugin PR (`plugin/migration`), Devin, Grafana, `demo/dependency-graph.png`.
+
+---
+
+## Act 1 — Assess (4 min) · *Ask Devin*
+
+### Beat 1a — the "Wiz-style" discovery
+**Prompt (paste into Ask Devin):**
+> Before we deploy the plugin/migration PR, discover the live footprint of the topics-api in AWS (Project=modernization-demo) using `make aws-discover` and show me the dependency graph. Include the LB, health check, security groups, DNS, secret and database it depends on.
+
+**Expected:** Devin runs the agentless discovery (ELBv2, TGs, EC2, SGs, Route53, Secrets Manager, RDS, ECR — read-only, by tag),
+attaches `dependency-graph.png` and lists the contracts: ALB `:80 → tg :8080`, health check `GET :8080/manage/health/readiness`,
+app SG admits **only 8080** from the ALB SG, `db.topics.demo.internal → RDS postgres 16`, secret `moddemo-topics/db` with key **`DB_PASSWORD`**,
+EC2 runs image built `FROM eclipse-temurin:11-jre`.
+
+**Money moment:** the rendered graph with the edge labels `health_checks path=/manage/health/readiness`, `allows_ingress port=8080`,
+`reads_secret key=DB_PASSWORD`, `runs_image base=eclipse-temurin:11-jre`.
+
+**Presenter note — Wiz contrast:** Wiz maps the *security* graph to find risk. Devin maps the *application dependency graph for change*:
+the same ALB/SG/DNS/secret nodes, but the question is "what breaks when this code changes", not "what is exposed".
+
+### Beat 1b — impact matrix
+**Prompt:**
+> Now check out plugin/migration and diff what that code expects against the graph you just discovered — health path and port, admitted ports, secret key names, base image, DB driver/dialect and schema contract. Also list every consumer of the topics database, including anything outside this repo.
+
+**Expected:** Devin runs `tools/discover/diff.py` and reports **6 DRIFT rows** (see `PLANTED-GAPS.md` §Impact matrix): JDK 21 class files on an
+11 runtime, health check moved to `:8081/actuator`, secret key renamed to `TOPICS_DB_PASSWORD`, `ddl-auto=update` + `@Column(name="description")`
+on a schema shared with the reporting cron, SG missing 8081, pool/logging defaults changed. It names the second DB consumer
+(`infra/reporting/report.sh` reading `topic_summary_v` / `topic_report()`).
+
+**Money moment:** the matrix — one row per contract, `OK`/`DRIFT`, "impact if deployed as-is".
+
+**Takeaway:** *Claude Code sees the repo; Devin sees the repo **and** the ALB, SG, DNS record and secret it depends on.* (gaps 1, 2, 3)
+
+---
+
+## Act 2 — Plan (3 min) · *Ask Devin*
+
+**Prompt:**
+> Open an infra PR on top of plugin/migration that fixes only the drift in your matrix and link it to the plugin PR. Post the plan in the description: ordered fixes, rollback, and the NFR gates you will enforce against demo/BASELINE.json.
+
+**Expected:** a PR touching `Dockerfile`, `infra/terraform`, `infra/aws`, `infra/nginx`, `infra/firewall`, `infra/observability/prometheus.yml`,
+`infra/secrets`, `infra/docker-compose.yml` and the DB contract — no Java changes. Description = impact matrix → fix list → gates
+(p95 ≤ max(1.5×, +25 ms), errors ≤ 1 %, throughput ≥ 60 %). Ends with `Devin-Org: engineering`.
+
+**Money moment:** PR description side-by-side with the plugin PR: "code PR" + "infra PR" as a linked pair.
+
+**Takeaway:** *The migration is two PRs, not one — and the second one needs infrastructure the plugin never saw.* (gap 3)
+
+---
+
+## Act 3 — Execute → Verify (6 min) · *Devin session with playbook*
+
+**Prompt (kicks off the full session):**
+> Run Modernization Verify [v1] @playbook:playbook- on the infra PR against infra/ and the AWS footprint, iterating until CI and `make verify` are green. Compare k6 against demo/BASELINE.json, explain any regression from Grafana, and attach the Verify report.
+
+**Expected sequence (this is the cascade in `PLANTED-GAPS.md`):**
+1. CI runs on the infra PR; Devin watches the jobs, reads logs, pushes fixes until green.
+2. `make reset && make up` on the migrated image → each planted layer surfaces in order and is fixed:
+ `UnsupportedClassVersionError` (gap 1) → password missing (gap 3) → nginx `/health` 404 (gap 2) → 502 from firewall on 8081 +
+ Prometheus target down (gap 5).
+3. Smoke: **GET /topic returns 500 — `column t1_0.description does not exist`** and Hibernate's `ddl-auto=update` is refused by Postgres
+ (`cannot alter type of a column used by a view or rule … topic_summary_v`) — the second DB consumer is what blocked the mutation (gap 4).
+4. `make loadtest` + `make compare` against the legacy baseline: **p95 ≈ 20× and throughput ≈ ⅓** (gap 6). Devin opens Grafana: the
+ *Hikari active/pending* panel is empty — the plugin swapped Hikari for `DriverManagerDataSource` (a new TCP+SCRAM connection per request) and
+ turned SQL logging on. Fixes: restore column mapping + `ddl-auto=validate`; restore Hikari (pool=20, `open-in-view=false`, SQL logging off).
+ Re-run → `verify OK`, `compare` within tolerance.
+5. Browser walkthrough of the LB endpoints and the Grafana dashboard, screen-recorded.
+
+**Money moments:** (a) the `compare_nfr.py` FAIL block — legacy p95 vs migrated p95 side by side; (b) the Grafana *Hikari active/pending* panel
+going blank while *log events/s* spikes at the load-test start; (c) CI going red → green without a human touching it.
+
+**Takeaway:** *A migration that passes unit tests can still fail its baseline — you only find out by deploying, loading, and reading the dashboards.* (gaps 4, 5, 6)
+
+---
+
+## Act 4 — Complete (2 min)
+
+No new prompt — the playbook's final phase produces it. Show the filled `VERIFY-REPORT-TEMPLATE.md` posted on both PRs:
+inventory from discovery, impact matrix with before/after, NFR table (baseline / first run / after fixes), observability deltas,
+residual risks.
+
+**Money moment:** the NFR table's three columns, and "Cloud footprint destroyed: 0 tagged resources remain".
+
+**Takeaway:** *Complete means verified against running infrastructure and a baseline, with evidence — not "it compiled".* (gaps 4, 6)
+
+---
+
+## Sidebar — How this integrates with Wiz
+
+If the customer runs Wiz, Devin can ingest the Wiz Security Graph as an additional Assess input: the same ALB, security groups,
+secrets and databases appear as Wiz graph entities with risk context (exposure, misconfiguration, known vulnerabilities on the
+base image). Devin would query it through the Wiz GraphQL API with a service-account token and merge the results into the
+dependency graph — e.g. flagging that `eclipse-temurin:11-jre` carries CVEs Wiz already tracks, so the runtime pin fix is also a
+risk fix. No implementation is included here; a sample query:
+
+```graphql
+query AppDependencies($project: String!) {
+ graphSearch(
+ query: {
+ type: ["LOAD_BALANCER", "SECURITY_GROUP", "DNS_ZONE", "SECRET", "DATABASE", "VIRTUAL_MACHINE", "CONTAINER_IMAGE"]
+ where: { tags: { EQUALS: [{ key: "Project", value: $project }] } }
+ relationships: [
+ { type: [{ type: ROUTES_TO }, { type: PROTECTS }, { type: USES }, { type: CONNECTED_TO }], with: { type: ["VIRTUAL_MACHINE", "DATABASE", "SECRET"] } }
+ ]
+ }
+ first: 100
+ ) {
+ nodes { entities { id name type properties } }
+ }
+}
+```
+
+## Timing
+
+| act | minutes |
+|---|---|
+| Assess | 4 |
+| Plan | 3 |
+| Execute → Verify | 6 |
+| Complete | 2 |
+
+## After the demo
+
+`make aws-down` (confirm `clean: 0 tagged resources remain`), then `demo/RESET.md`.
diff --git a/demo/VERIFY-REPORT-TEMPLATE.md b/demo/VERIFY-REPORT-TEMPLATE.md
new file mode 100644
index 0000000..043e04a
--- /dev/null
+++ b/demo/VERIFY-REPORT-TEMPLATE.md
@@ -0,0 +1,78 @@
+# Modernization Verify Report — `` (`` → ``)
+
+Produced by Devin at the **Complete** phase. Every row cites a command output, file or dashboard panel.
+
+## 1. Summary
+
+| | |
+|---|---|
+| Code PR (plugin) | `` |
+| Infra PR (Devin) | `` |
+| Verdict | GO / NO-GO |
+| Blocking findings | `` fixed, `` open |
+| Baseline | `demo/BASELINE.json` @ `` |
+
+## 2. Inventory (discovered, not assumed)
+
+Source: `demo/dependency-graph.json` (`make aws-discover`) and `infra/`.
+
+| component | identifier | runtime contract observed |
+|---|---|---|
+| Load balancer | `` | listener :80 → target :`` |
+| Health check | `` | `GET ` on :`` expects 200 |
+| Firewall | `` | admits `` from `` |
+| DNS | `` | `` |
+| Secrets | `` | keys `<...>` |
+| Database | `` | ``; schema owner `infra/db/init`; consumers: app, `reporting` |
+| Runtime image | `` | `` |
+| Observability | `` | scrapes `` on :`` |
+
+## 3. Impact matrix — app ↔ infra ↔ DB
+
+Paste `tools/discover/diff.py` output, then add the resolution column.
+
+| # | component | infra expected | code declared | status before | change made | status after |
+|---|---|---|---|---|---|---|
+| 1 | | | | DRIFT | | OK |
+| … | | | | | | |
+
+## 4. NFR before / after
+
+Source: `demo/BASELINE.json` vs `load/results/latest.json` (`make loadtest`, same VUs/duration), `infra/scripts/compare_nfr.py`.
+
+| metric | legacy baseline | migrated (first run) | migrated (after fixes) | tolerance | result |
+|---|---|---|---|---|---|
+| p50 ms | | | | | |
+| p95 ms | | | | ≤ max(1.5×, +25 ms) | |
+| p99 ms | | | | | |
+| throughput req/s | | | | ≥ 60 % | |
+| error rate | | | | ≤ 1 % | |
+| k6 thresholds | pass | | | pass | |
+
+## 5. Observability deltas
+
+| signal | before | after | note |
+|---|---|---|---|
+| Prometheus target `up{job="topics-api"}` | 1 | | scrape path/port changed? |
+| Grafana panel: p95 latency | | | screenshot links |
+| Grafana panel: Hikari active/pending | | | pool size default |
+| Grafana panel: log events/s | | | SQL logging |
+| Datadog tags (`DD_ENV`, `version`) | | | version bump reflected? |
+
+## 6. Cross-component checks
+
+- Reporting consumer (`infra/reporting/report.sh`): ``
+- Other consumers of the DB / secret / DNS name found by discovery: ``
+
+## 7. Residual risks and follow-ups
+
+| risk | severity | owner | mitigation |
+|---|---|---|---|
+| | | | |
+
+## 8. Evidence
+
+- CI run: ``
+- Screen recording of browser verification against the LB: ``
+- Grafana before/after screenshots: ``
+- Raw k6 summaries: `load/results/*.json`
diff --git a/demo/dependency-graph.dot b/demo/dependency-graph.dot
new file mode 100644
index 0000000..db28e2c
--- /dev/null
+++ b/demo/dependency-graph.dot
@@ -0,0 +1,45 @@
+digraph deps {
+ rankdir=LR; node [shape=box, style="rounded,filled", fontname=Helvetica, fontsize=10]; edge [fontname=Helvetica, fontsize=9];
+ "sg:moddemo-topics-app" [label="SG: moddemo-topics-app\n8080 from sg-0c3cde3ce9ac15173", fillcolor="#f4a6a6"];
+ "sg:moddemo-topics-alb" [label="SG: moddemo-topics-alb\n80 from 0.0.0.0/0", fillcolor="#f4a6a6"];
+ "sg:moddemo-topics-db" [label="SG: moddemo-topics-db\n5432 from sg-090e9d7aacd2257a7", fillcolor="#f4a6a6"];
+ "cidr:0.0.0.0/0" [label="CIDR: 0.0.0.0/0", fillcolor="#eeeeee"];
+ "ec2:moddemo-topics-app" [label="EC2: moddemo-topics-app\nt3.small :8080 base=eclipse-temurin:11-jre", fillcolor="#a8d5ba"];
+ "image:topics-api:legacy" [label="Image: topics-api:legacy", fillcolor="#c8e6c9"];
+ "alb:moddemo-topics-alb" [label="ALB: moddemo-topics-alb", fillcolor="#f9d67a"];
+ "tg:moddemo-topics-tg" [label="TG: moddemo-topics-tg\n:8080 hc=/manage/health/readiness", fillcolor="#fbe7b3"];
+ "rds:moddemo-topics-db" [label="RDS: moddemo-topics-db\npostgres 16.13 :5432", fillcolor="#9ec5fe"];
+ "secret:moddemo-topics/db" [label="Secret: moddemo-topics/db\nkeys=['DB_PASSWORD', 'DB_USER']", fillcolor="#d3b7f7"];
+ "zone:demo.internal" [label="Zone: demo.internal", fillcolor="#cfd8dc"];
+ "dns:app.topics.demo.internal" [label="DNS: app.topics.demo.internal\nA", fillcolor="#e0e7ea"];
+ "dns:db.topics.demo.internal" [label="DNS: db.topics.demo.internal\nCNAME", fillcolor="#e0e7ea"];
+ "dns:lb.topics.demo.internal" [label="DNS: lb.topics.demo.internal\nA", fillcolor="#e0e7ea"];
+ "sg:moddemo-topics-alb" -> "sg:moddemo-topics-app" [label="allows_ingress
+port=8080"];
+ "cidr:0.0.0.0/0" -> "sg:moddemo-topics-alb" [label="allows_ingress
+port=80"];
+ "sg:moddemo-topics-app" -> "sg:moddemo-topics-db" [label="allows_ingress
+port=5432"];
+ "sg:moddemo-topics-app" -> "ec2:moddemo-topics-app" [style=dashed, color=gray, arrowhead=none];
+ "ec2:moddemo-topics-app" -> "image:topics-api:legacy" [label="runs_image"];
+ "sg:moddemo-topics-alb" -> "alb:moddemo-topics-alb" [style=dashed, color=gray, arrowhead=none];
+ "alb:moddemo-topics-alb" -> "tg:moddemo-topics-tg" [label="routes_to
+listener_port=80"];
+ "tg:moddemo-topics-tg" -> "ec2:moddemo-topics-app" [label="routes_to
+port=8080"];
+ "tg:moddemo-topics-tg" -> "ec2:moddemo-topics-app" [label="health_checks
+path=/manage/health/readiness port=8080 state=healthy"];
+ "sg:moddemo-topics-db" -> "rds:moddemo-topics-db" [style=dashed, color=gray, arrowhead=none];
+ "ec2:moddemo-topics-app" -> "secret:moddemo-topics/db" [label="reads_secret
+key=DB_PASSWORD"];
+ "zone:demo.internal" -> "dns:app.topics.demo.internal" [style=dashed, color=gray, arrowhead=none];
+ "dns:app.topics.demo.internal" -> "ec2:moddemo-topics-app" [label="resolves_to"];
+ "zone:demo.internal" -> "dns:db.topics.demo.internal" [style=dashed, color=gray, arrowhead=none];
+ "dns:db.topics.demo.internal" -> "rds:moddemo-topics-db" [label="resolves_to"];
+ "zone:demo.internal" -> "dns:lb.topics.demo.internal" [style=dashed, color=gray, arrowhead=none];
+ "dns:lb.topics.demo.internal" -> "alb:moddemo-topics-alb" [label="resolves_to"];
+ "ec2:moddemo-topics-app" -> "dns:db.topics.demo.internal" [label="connects_to_db
+port=5432"];
+ "ec2:moddemo-topics-app" -> "rds:moddemo-topics-db" [label="connects_to_db
+port=5432"];
+}
\ No newline at end of file
diff --git a/demo/dependency-graph.json b/demo/dependency-graph.json
new file mode 100644
index 0000000..5f98392
--- /dev/null
+++ b/demo/dependency-graph.json
@@ -0,0 +1,327 @@
+{
+ "region": "us-east-1",
+ "project": "modernization-demo",
+ "track": "java",
+ "nodes": [
+ {
+ "id": "sg:moddemo-topics-app",
+ "type": "security_group",
+ "arn": "arn:aws:ec2:us-east-1::security-group/sg-090e9d7aacd2257a7",
+ "attrs": {
+ "group_id": "sg-090e9d7aacd2257a7",
+ "ingress": [
+ {
+ "port": "8080",
+ "from": "sg-0c3cde3ce9ac15173"
+ }
+ ],
+ "description": "topics-api host: only the ALB, only the legacy app port"
+ }
+ },
+ {
+ "id": "sg:moddemo-topics-alb",
+ "type": "security_group",
+ "arn": "arn:aws:ec2:us-east-1::security-group/sg-0c3cde3ce9ac15173",
+ "attrs": {
+ "group_id": "sg-0c3cde3ce9ac15173",
+ "ingress": [
+ {
+ "port": "80",
+ "from": "0.0.0.0/0"
+ }
+ ],
+ "description": "ALB: HTTP from anywhere"
+ }
+ },
+ {
+ "id": "sg:moddemo-topics-db",
+ "type": "security_group",
+ "arn": "arn:aws:ec2:us-east-1::security-group/sg-025f56542bd00152e",
+ "attrs": {
+ "group_id": "sg-025f56542bd00152e",
+ "ingress": [
+ {
+ "port": "5432",
+ "from": "sg-090e9d7aacd2257a7"
+ }
+ ],
+ "description": "RDS: postgres from the app SG only"
+ }
+ },
+ {
+ "id": "cidr:0.0.0.0/0",
+ "type": "cidr",
+ "arn": null,
+ "attrs": {}
+ },
+ {
+ "id": "ec2:moddemo-topics-app",
+ "type": "ec2_instance",
+ "arn": "arn:aws:ec2:us-east-1::instance/i-054e1366126f767af",
+ "attrs": {
+ "instance_id": "i-054e1366126f767af",
+ "instance_type": "t3.small",
+ "private_ip": "10.42.0.27",
+ "state": "running",
+ "runtime_image": "eclipse-temurin:11-jre",
+ "app_image": "374199896615.dkr.ecr.us-east-1.amazonaws.com/moddemo-topics/topics-api:legacy",
+ "secret_key_read": "DB_PASSWORD",
+ "db_host": "db.topics.demo.internal",
+ "host_port": 8080,
+ "container_port": 8080
+ }
+ },
+ {
+ "id": "image:topics-api:legacy",
+ "type": "container_image",
+ "arn": "arn:aws:ecr:us-east-1:374199896615:repository/moddemo-topics/topics-api",
+ "attrs": {
+ "uri": "374199896615.dkr.ecr.us-east-1.amazonaws.com/moddemo-topics/topics-api:legacy",
+ "base_image": "eclipse-temurin:11-jre",
+ "pushed_at": "2026-09-25T08:16:20.126000+00:00",
+ "digest": "sha256:5fc39f9c2ba2eca9081a93e39d22282cad3a31954b5ab9220f532040af723155"
+ }
+ },
+ {
+ "id": "alb:moddemo-topics-alb",
+ "type": "load_balancer",
+ "arn": "arn:aws:elasticloadbalancing:us-east-1:374199896615:loadbalancer/app/moddemo-topics-alb/39ecc5d8380ae56d",
+ "attrs": {
+ "dns_name": "moddemo-topics-alb-1184534545.us-east-1.elb.amazonaws.com",
+ "scheme": "internet-facing",
+ "state": "active"
+ }
+ },
+ {
+ "id": "tg:moddemo-topics-tg",
+ "type": "target_group",
+ "arn": "arn:aws:elasticloadbalancing:us-east-1:374199896615:targetgroup/moddemo-topics-tg/f850f2bc057bd096",
+ "attrs": {
+ "port": 8080,
+ "protocol": "HTTP",
+ "health_check_path": "/manage/health/readiness",
+ "health_check_port": "traffic-port",
+ "matcher": "200"
+ }
+ },
+ {
+ "id": "rds:moddemo-topics-db",
+ "type": "rds_instance",
+ "arn": "arn:aws:rds:us-east-1:374199896615:db:moddemo-topics-db",
+ "attrs": {
+ "engine": "postgres",
+ "engine_version": "16.13",
+ "endpoint": "moddemo-topics-db.c0pwcucu2ddp.us-east-1.rds.amazonaws.com",
+ "port": 5432,
+ "db_name": "topics",
+ "status": "available",
+ "instance_class": "db.t4g.micro"
+ }
+ },
+ {
+ "id": "secret:moddemo-topics/db",
+ "type": "secret",
+ "arn": "arn:aws:secretsmanager:us-east-1:374199896615:secret:moddemo-topics/db-M0tkz2",
+ "attrs": {
+ "keys": [
+ "DB_PASSWORD",
+ "DB_USER"
+ ]
+ }
+ },
+ {
+ "id": "zone:demo.internal",
+ "type": "hosted_zone",
+ "arn": "arn:aws:route53:::hostedzone/Z0683628AED5PYN8CQSZ",
+ "attrs": {
+ "private": true
+ }
+ },
+ {
+ "id": "dns:app.topics.demo.internal",
+ "type": "dns_record",
+ "arn": null,
+ "attrs": {
+ "zone": "zone:demo.internal",
+ "record_type": "A",
+ "target": "10.42.0.27"
+ }
+ },
+ {
+ "id": "dns:db.topics.demo.internal",
+ "type": "dns_record",
+ "arn": null,
+ "attrs": {
+ "zone": "zone:demo.internal",
+ "record_type": "CNAME",
+ "target": "moddemo-topics-db.c0pwcucu2ddp.us-east-1.rds.amazonaws.com"
+ }
+ },
+ {
+ "id": "dns:lb.topics.demo.internal",
+ "type": "dns_record",
+ "arn": null,
+ "attrs": {
+ "zone": "zone:demo.internal",
+ "record_type": "A",
+ "target": "moddemo-topics-alb-1184534545.us-east-1.elb.amazonaws.com"
+ }
+ }
+ ],
+ "edges": [
+ {
+ "from": "sg:moddemo-topics-alb",
+ "to": "sg:moddemo-topics-app",
+ "type": "allows_ingress",
+ "attrs": {
+ "port": 8080,
+ "protocol": "tcp"
+ }
+ },
+ {
+ "from": "cidr:0.0.0.0/0",
+ "to": "sg:moddemo-topics-alb",
+ "type": "allows_ingress",
+ "attrs": {
+ "port": 80,
+ "protocol": "tcp"
+ }
+ },
+ {
+ "from": "sg:moddemo-topics-app",
+ "to": "sg:moddemo-topics-db",
+ "type": "allows_ingress",
+ "attrs": {
+ "port": 5432,
+ "protocol": "tcp"
+ }
+ },
+ {
+ "from": "sg:moddemo-topics-app",
+ "to": "ec2:moddemo-topics-app",
+ "type": "allows_ingress",
+ "attrs": {
+ "scope": "attached"
+ }
+ },
+ {
+ "from": "ec2:moddemo-topics-app",
+ "to": "image:topics-api:legacy",
+ "type": "runs_image",
+ "attrs": {
+ "base_image": "eclipse-temurin:11-jre"
+ }
+ },
+ {
+ "from": "sg:moddemo-topics-alb",
+ "to": "alb:moddemo-topics-alb",
+ "type": "allows_ingress",
+ "attrs": {
+ "scope": "attached"
+ }
+ },
+ {
+ "from": "alb:moddemo-topics-alb",
+ "to": "tg:moddemo-topics-tg",
+ "type": "routes_to",
+ "attrs": {
+ "listener_port": 80,
+ "protocol": "HTTP"
+ }
+ },
+ {
+ "from": "tg:moddemo-topics-tg",
+ "to": "ec2:moddemo-topics-app",
+ "type": "routes_to",
+ "attrs": {
+ "port": 8080
+ }
+ },
+ {
+ "from": "tg:moddemo-topics-tg",
+ "to": "ec2:moddemo-topics-app",
+ "type": "health_checks",
+ "attrs": {
+ "path": "/manage/health/readiness",
+ "port": 8080,
+ "state": "healthy"
+ }
+ },
+ {
+ "from": "sg:moddemo-topics-db",
+ "to": "rds:moddemo-topics-db",
+ "type": "allows_ingress",
+ "attrs": {
+ "scope": "attached"
+ }
+ },
+ {
+ "from": "ec2:moddemo-topics-app",
+ "to": "secret:moddemo-topics/db",
+ "type": "reads_secret",
+ "attrs": {
+ "key": "DB_PASSWORD"
+ }
+ },
+ {
+ "from": "zone:demo.internal",
+ "to": "dns:app.topics.demo.internal",
+ "type": "resolves_to",
+ "attrs": {
+ "scope": "contains"
+ }
+ },
+ {
+ "from": "dns:app.topics.demo.internal",
+ "to": "ec2:moddemo-topics-app",
+ "type": "resolves_to",
+ "attrs": {}
+ },
+ {
+ "from": "zone:demo.internal",
+ "to": "dns:db.topics.demo.internal",
+ "type": "resolves_to",
+ "attrs": {
+ "scope": "contains"
+ }
+ },
+ {
+ "from": "dns:db.topics.demo.internal",
+ "to": "rds:moddemo-topics-db",
+ "type": "resolves_to",
+ "attrs": {}
+ },
+ {
+ "from": "zone:demo.internal",
+ "to": "dns:lb.topics.demo.internal",
+ "type": "resolves_to",
+ "attrs": {
+ "scope": "contains"
+ }
+ },
+ {
+ "from": "dns:lb.topics.demo.internal",
+ "to": "alb:moddemo-topics-alb",
+ "type": "resolves_to",
+ "attrs": {}
+ },
+ {
+ "from": "ec2:moddemo-topics-app",
+ "to": "dns:db.topics.demo.internal",
+ "type": "connects_to_db",
+ "attrs": {
+ "host": "db.topics.demo.internal",
+ "port": 5432
+ }
+ },
+ {
+ "from": "ec2:moddemo-topics-app",
+ "to": "rds:moddemo-topics-db",
+ "type": "connects_to_db",
+ "attrs": {
+ "via": "db.topics.demo.internal",
+ "port": 5432
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/demo/dependency-graph.md b/demo/dependency-graph.md
new file mode 100644
index 0000000..f529d87
--- /dev/null
+++ b/demo/dependency-graph.md
@@ -0,0 +1,60 @@
+# Application dependency graph
+
+Discovered agentlessly from AWS APIs in `us-east-1` for `Project=modernization-demo`, `Track=java` (14 nodes, 19 edges). Regenerate with `make aws-discover`.
+
+
+
+```mermaid
+graph LR
+ n0{{"SG
moddemo-topics-app"}}
+ n1{{"SG
moddemo-topics-alb"}}
+ n2{{"SG
moddemo-topics-db"}}
+ n3("CIDR
0.0.0.0/0")
+ n4["EC2
moddemo-topics-app"]
+ n5>"Image
topics-api:legacy"]
+ n6(["ALB
moddemo-topics-alb"])
+ n7[/"TG
moddemo-topics-tg"/]
+ n8[("RDS
moddemo-topics-db")]
+ n9[["Secret
moddemo-topics/db"]]
+ n10(("Zone
demo.internal"))
+ n11("DNS
app.topics.demo.internal")
+ n12("DNS
db.topics.demo.internal")
+ n13("DNS
lb.topics.demo.internal")
+ n1 -->|allows_ingress port=8080| n0
+ n3 -->|allows_ingress port=80| n1
+ n0 -->|allows_ingress port=5432| n2
+ n0 -.- n4
+ n4 -->|runs_image| n5
+ n1 -.- n6
+ n6 -->|routes_to listener_port=80| n7
+ n7 -->|routes_to port=8080| n4
+ n7 -->|health_checks path=/manage/health/readiness, port=8080| n4
+ n2 -.- n8
+ n4 -->|reads_secret key=DB_PASSWORD| n9
+ n10 -.- n11
+ n11 -->|resolves_to| n4
+ n10 -.- n12
+ n12 -->|resolves_to| n8
+ n10 -.- n13
+ n13 -->|resolves_to| n6
+ n4 -->|connects_to_db port=5432| n12
+ n4 -->|connects_to_db port=5432| n8
+```
+
+## Edges
+
+| from | edge | to | attrs |
+|---|---|---|---|
+| `sg:moddemo-topics-alb` | allows_ingress | `sg:moddemo-topics-app` | {"port": 8080, "protocol": "tcp"} |
+| `cidr:0.0.0.0/0` | allows_ingress | `sg:moddemo-topics-alb` | {"port": 80, "protocol": "tcp"} |
+| `sg:moddemo-topics-app` | allows_ingress | `sg:moddemo-topics-db` | {"port": 5432, "protocol": "tcp"} |
+| `ec2:moddemo-topics-app` | runs_image | `image:topics-api:legacy` | {"base_image": "eclipse-temurin:11-jre"} |
+| `alb:moddemo-topics-alb` | routes_to | `tg:moddemo-topics-tg` | {"listener_port": 80, "protocol": "HTTP"} |
+| `tg:moddemo-topics-tg` | routes_to | `ec2:moddemo-topics-app` | {"port": 8080} |
+| `tg:moddemo-topics-tg` | health_checks | `ec2:moddemo-topics-app` | {"path": "/manage/health/readiness", "port": 8080, "state": "healthy"} |
+| `ec2:moddemo-topics-app` | reads_secret | `secret:moddemo-topics/db` | {"key": "DB_PASSWORD"} |
+| `dns:app.topics.demo.internal` | resolves_to | `ec2:moddemo-topics-app` | {} |
+| `dns:db.topics.demo.internal` | resolves_to | `rds:moddemo-topics-db` | {} |
+| `dns:lb.topics.demo.internal` | resolves_to | `alb:moddemo-topics-alb` | {} |
+| `ec2:moddemo-topics-app` | connects_to_db | `dns:db.topics.demo.internal` | {"host": "db.topics.demo.internal", "port": 5432} |
+| `ec2:moddemo-topics-app` | connects_to_db | `rds:moddemo-topics-db` | {"via": "db.topics.demo.internal", "port": 5432} |
diff --git a/demo/dependency-graph.png b/demo/dependency-graph.png
new file mode 100644
index 0000000..f4120b5
Binary files /dev/null and b/demo/dependency-graph.png differ
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/gs-spring-boot.iml b/gs-spring-boot.iml
deleted file mode 100644
index ad195a8..0000000
--- a/gs-spring-boot.iml
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/infra/.env b/infra/.env
new file mode 100644
index 0000000..0f984ab
--- /dev/null
+++ b/infra/.env
@@ -0,0 +1,7 @@
+# Compose project defaults. RUNTIME_IMAGE must match infra/terraform/variables.tf (runtime_image).
+COMPOSE_PROJECT_NAME=topics
+RUNTIME_IMAGE=eclipse-temurin:11-jre
+APP_VERSION=0.1.0
+DD_ENV=legacy
+PROMETHEUS_PORT=9091
+GRAFANA_PORT=3000
diff --git a/infra/aws/.gitignore b/infra/aws/.gitignore
new file mode 100644
index 0000000..9b5533c
--- /dev/null
+++ b/infra/aws/.gitignore
@@ -0,0 +1,2 @@
+.terraform/
+*.tfstate*
diff --git a/infra/aws/.terraform.lock.hcl b/infra/aws/.terraform.lock.hcl
new file mode 100644
index 0000000..8e0073d
--- /dev/null
+++ b/infra/aws/.terraform.lock.hcl
@@ -0,0 +1,46 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/aws" {
+ version = "5.100.0"
+ constraints = "~> 5.70"
+ hashes = [
+ "h1:edXOJWE4ORX8Fm+dpVpICzMZJat4AX0VRCAy/xkcOc0=",
+ "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
+ "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
+ "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
+ "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
+ "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
+ "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
+ "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
+ "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
+ "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
+ "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
+ "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
+ "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
+ "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
+ "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
+ "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/random" {
+ version = "3.9.1"
+ constraints = "~> 3.6"
+ hashes = [
+ "h1:g40qr7yDmIpaur4SsK5BcOda3HSo1RJ6zHVMqN4EJ+0=",
+ "zh:05f4734c1f0be840b711b3eff259ebc5fca436784c728955b1678078466f48d7",
+ "zh:0b91bf19371d012434eba1deeb6aab77158def9b39601dcbd94450b3974a2a26",
+ "zh:0ee6eacd47ec00183d55d726a4b6c4ce951a199f944bf22f1aa58392ebdfa7a2",
+ "zh:19388a4074b76a89a43a6c8328d7ae8ee2e7de3d346af51e80d3e6d3d12925f1",
+ "zh:23e74d48c5e2ac2e823fd527f49fee9db37d32a1990c9e3bf126ead697b843eb",
+ "zh:3cabf7fbd096c520064aae3aba61aba670af83ab91291a71fa1b1332929c2b7f",
+ "zh:5c0a3b8af0be60be4eca12ddee385cfa8babc1ec8e98cdf9de2f2274c73eabfa",
+ "zh:60b4f8a8ef18f52bf8e19215229dae408bee732825964092db7c989fd2de4097",
+ "zh:7359015acfedcbd6366f2329c854cf8d3c8ca5cd0faa89d2d37db358d6eba6c5",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:7b38758402f0e13a1071162da28994023cd2ac676e54af350c9ffd8dfa73fa7b",
+ "zh:7c7fbb8895eb75bb4de1f933e98553bd99c8d048c89a925ddba490aa5a67f7dc",
+ "zh:8c2b8c6a7ccdec16b73e2fb9f3700ea097f58c592571e4c5de60c93d2301732c",
+ ]
+}
diff --git a/infra/aws/compute.tf b/infra/aws/compute.tf
new file mode 100644
index 0000000..7cf4765
--- /dev/null
+++ b/infra/aws/compute.tf
@@ -0,0 +1,170 @@
+data "aws_caller_identity" "current" {}
+
+data "aws_ssm_parameter" "al2023" {
+ name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
+}
+
+locals {
+ ecr_registry = "${data.aws_caller_identity.current.account_id}.dkr.ecr.${var.region}.amazonaws.com"
+ app_image = "${aws_ecr_repository.app.repository_url}:${var.app_image_tag}"
+ db_host = "db.topics.${var.dns_zone}"
+}
+
+# ---- IAM: pull from ECR, read ONE secret, SSM for shell access (no SSH keys).
+data "aws_iam_policy_document" "assume_ec2" {
+ statement {
+ actions = ["sts:AssumeRole"]
+ principals {
+ type = "Service"
+ identifiers = ["ec2.amazonaws.com"]
+ }
+ }
+}
+
+resource "aws_iam_role" "app" {
+ name = "${var.name}-app"
+ assume_role_policy = data.aws_iam_policy_document.assume_ec2.json
+}
+
+data "aws_iam_policy_document" "app" {
+ statement {
+ actions = ["ecr:GetAuthorizationToken"]
+ resources = ["*"]
+ }
+ statement {
+ actions = ["ecr:BatchGetImage", "ecr:GetDownloadUrlForLayer", "ecr:BatchCheckLayerAvailability"]
+ resources = [aws_ecr_repository.app.arn]
+ }
+ statement {
+ actions = ["secretsmanager:GetSecretValue"]
+ resources = [aws_secretsmanager_secret.db.arn]
+ }
+}
+
+resource "aws_iam_role_policy" "app" {
+ role = aws_iam_role.app.id
+ policy = data.aws_iam_policy_document.app.json
+}
+
+resource "aws_iam_role_policy_attachment" "ssm" {
+ role = aws_iam_role.app.name
+ policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
+}
+
+resource "aws_iam_instance_profile" "app" {
+ name = "${var.name}-app"
+ role = aws_iam_role.app.name
+}
+
+# ---- App host (legacy container, pulled from ECR)
+resource "aws_instance" "app" {
+ ami = data.aws_ssm_parameter.al2023.value
+ instance_type = var.instance_type
+ subnet_id = aws_subnet.public[0].id
+ vpc_security_group_ids = [aws_security_group.app.id]
+ iam_instance_profile = aws_iam_instance_profile.app.name
+ associate_public_ip_address = true
+ user_data_replace_on_change = true
+
+ user_data = templatefile("${path.module}/user-data.sh.tftpl", {
+ region = var.region
+ ecr_registry = local.ecr_registry
+ app_image = local.app_image
+ app_port = var.app_port
+ secret_arn = aws_secretsmanager_secret.db.arn
+ db_secret_key = var.db_secret_key
+ db_host = local.db_host
+ schema_sql = file("${path.module}/../db/init/01-schema.sql")
+ seed_sql = file("${path.module}/../db/init/02-seed.sql")
+ reporting_sql = file("${path.module}/../db/init/03-reporting.sql")
+ })
+
+ metadata_options {
+ http_tokens = "required"
+ }
+
+ tags = {
+ Name = "${var.name}-app"
+ RuntimeImage = var.runtime_image
+ AppImage = local.app_image
+ }
+
+ depends_on = [aws_db_instance.topics, aws_route53_record.db]
+}
+
+# ---- Load balancer
+resource "aws_lb" "app" {
+ name = "${var.name}-alb"
+ load_balancer_type = "application"
+ security_groups = [aws_security_group.alb.id]
+ subnets = aws_subnet.public[*].id
+}
+
+resource "aws_lb_target_group" "app" {
+ name = "${var.name}-tg"
+ port = var.app_port
+ protocol = "HTTP"
+ vpc_id = aws_vpc.main.id
+
+ health_check {
+ path = var.health_check_path
+ port = "traffic-port"
+ matcher = "200"
+ interval = 10
+ timeout = 5
+ healthy_threshold = 2
+ unhealthy_threshold = 3
+ }
+ deregistration_delay = 10
+}
+
+resource "aws_lb_target_group_attachment" "app" {
+ target_group_arn = aws_lb_target_group.app.arn
+ target_id = aws_instance.app.id
+ port = var.app_port
+}
+
+resource "aws_lb_listener" "http" {
+ load_balancer_arn = aws_lb.app.arn
+ port = 80
+ protocol = "HTTP"
+ default_action {
+ type = "forward"
+ target_group_arn = aws_lb_target_group.app.arn
+ }
+}
+
+# ---- DNS: private zone, same names as infra/dns/hosts (suffixed with the zone)
+resource "aws_route53_zone" "private" {
+ name = var.dns_zone
+ vpc {
+ vpc_id = aws_vpc.main.id
+ }
+}
+
+resource "aws_route53_record" "lb" {
+ zone_id = aws_route53_zone.private.zone_id
+ name = "lb.topics.${var.dns_zone}"
+ type = "A"
+ alias {
+ name = aws_lb.app.dns_name
+ zone_id = aws_lb.app.zone_id
+ evaluate_target_health = false
+ }
+}
+
+resource "aws_route53_record" "app" {
+ zone_id = aws_route53_zone.private.zone_id
+ name = "app.topics.${var.dns_zone}"
+ type = "A"
+ ttl = 60
+ records = [aws_instance.app.private_ip]
+}
+
+resource "aws_route53_record" "db" {
+ zone_id = aws_route53_zone.private.zone_id
+ name = local.db_host
+ type = "CNAME"
+ ttl = 60
+ records = [aws_db_instance.topics.address]
+}
diff --git a/infra/aws/data.tf b/infra/aws/data.tf
new file mode 100644
index 0000000..03a489c
--- /dev/null
+++ b/infra/aws/data.tf
@@ -0,0 +1,50 @@
+resource "random_password" "db" {
+ length = 24
+ special = false
+}
+
+# Legacy secret contract: one JSON key, named after the env var the Boot 2.7 app reads.
+resource "aws_secretsmanager_secret" "db" {
+ name = "${var.name}/db"
+ description = "topics-api database credentials (legacy key name)"
+ recovery_window_in_days = 0
+}
+
+resource "aws_secretsmanager_secret_version" "db" {
+ secret_id = aws_secretsmanager_secret.db.id
+ secret_string = jsonencode({
+ (var.db_secret_key) = random_password.db.result
+ DB_USER = "topics"
+ })
+}
+
+resource "aws_db_subnet_group" "db" {
+ name = "${var.name}-db"
+ subnet_ids = aws_subnet.public[*].id
+}
+
+resource "aws_db_instance" "topics" {
+ identifier = "${var.name}-db"
+ engine = "postgres"
+ engine_version = "16"
+ instance_class = var.db_instance_class
+ allocated_storage = 20
+ storage_type = "gp3"
+ db_name = "topics"
+ username = "topics"
+ password = random_password.db.result
+ db_subnet_group_name = aws_db_subnet_group.db.name
+ vpc_security_group_ids = [aws_security_group.db.id]
+ publicly_accessible = false
+ skip_final_snapshot = true
+ deletion_protection = false
+ backup_retention_period = 0
+ apply_immediately = true
+ tags = { Name = "${var.name}-db" }
+}
+
+resource "aws_ecr_repository" "app" {
+ name = "${var.name}/topics-api"
+ image_tag_mutability = "MUTABLE"
+ force_delete = true
+}
diff --git a/infra/aws/network.tf b/infra/aws/network.tf
new file mode 100644
index 0000000..c146c5b
--- /dev/null
+++ b/infra/aws/network.tf
@@ -0,0 +1,100 @@
+data "aws_availability_zones" "available" {
+ state = "available"
+}
+
+resource "aws_vpc" "main" {
+ cidr_block = var.vpc_cidr
+ enable_dns_support = true
+ enable_dns_hostnames = true
+ tags = { Name = "${var.name}-vpc" }
+}
+
+resource "aws_internet_gateway" "igw" {
+ vpc_id = aws_vpc.main.id
+ tags = { Name = "${var.name}-igw" }
+}
+
+resource "aws_subnet" "public" {
+ count = 2
+ vpc_id = aws_vpc.main.id
+ cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
+ availability_zone = data.aws_availability_zones.available.names[count.index]
+ map_public_ip_on_launch = true
+ tags = { Name = "${var.name}-public-${count.index}" }
+}
+
+resource "aws_route_table" "public" {
+ vpc_id = aws_vpc.main.id
+ route {
+ cidr_block = "0.0.0.0/0"
+ gateway_id = aws_internet_gateway.igw.id
+ }
+ tags = { Name = "${var.name}-public" }
+}
+
+resource "aws_route_table_association" "public" {
+ count = 2
+ subnet_id = aws_subnet.public[count.index].id
+ route_table_id = aws_route_table.public.id
+}
+
+# ---- Security groups: the "firewall". Only the LEGACY app port is admitted.
+resource "aws_security_group" "alb" {
+ name = "${var.name}-alb"
+ description = "ALB: HTTP from anywhere"
+ vpc_id = aws_vpc.main.id
+ tags = { Name = "${var.name}-alb" }
+}
+
+resource "aws_vpc_security_group_ingress_rule" "alb_http" {
+ security_group_id = aws_security_group.alb.id
+ cidr_ipv4 = "0.0.0.0/0"
+ from_port = 80
+ to_port = 80
+ ip_protocol = "tcp"
+ description = "public HTTP"
+}
+
+resource "aws_vpc_security_group_egress_rule" "alb_all" {
+ security_group_id = aws_security_group.alb.id
+ cidr_ipv4 = "0.0.0.0/0"
+ ip_protocol = "-1"
+}
+
+resource "aws_security_group" "app" {
+ name = "${var.name}-app"
+ description = "topics-api host: only the ALB, only the legacy app port"
+ vpc_id = aws_vpc.main.id
+ tags = { Name = "${var.name}-app" }
+}
+
+resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
+ security_group_id = aws_security_group.app.id
+ referenced_security_group_id = aws_security_group.alb.id
+ from_port = var.app_port
+ to_port = var.app_port
+ ip_protocol = "tcp"
+ description = "ALB to app :${var.app_port} (traffic and health check)"
+}
+
+resource "aws_vpc_security_group_egress_rule" "app_all" {
+ security_group_id = aws_security_group.app.id
+ cidr_ipv4 = "0.0.0.0/0"
+ ip_protocol = "-1"
+}
+
+resource "aws_security_group" "db" {
+ name = "${var.name}-db"
+ description = "RDS: postgres from the app SG only"
+ vpc_id = aws_vpc.main.id
+ tags = { Name = "${var.name}-db" }
+}
+
+resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
+ security_group_id = aws_security_group.db.id
+ referenced_security_group_id = aws_security_group.app.id
+ from_port = 5432
+ to_port = 5432
+ ip_protocol = "tcp"
+ description = "app to postgres"
+}
diff --git a/infra/aws/outputs.tf b/infra/aws/outputs.tf
new file mode 100644
index 0000000..a01da25
--- /dev/null
+++ b/infra/aws/outputs.tf
@@ -0,0 +1,39 @@
+output "alb_dns_name" {
+ value = aws_lb.app.dns_name
+}
+
+output "alb_url" {
+ value = "http://${aws_lb.app.dns_name}"
+}
+
+output "health_check_url" {
+ value = "http://${aws_lb.app.dns_name}${var.health_check_path}"
+}
+
+output "ecr_repository_url" {
+ value = aws_ecr_repository.app.repository_url
+}
+
+output "app_image" {
+ value = local.app_image
+}
+
+output "app_instance_id" {
+ value = aws_instance.app.id
+}
+
+output "db_endpoint" {
+ value = aws_db_instance.topics.address
+}
+
+output "db_dns_name" {
+ value = local.db_host
+}
+
+output "secret_arn" {
+ value = aws_secretsmanager_secret.db.arn
+}
+
+output "runtime_image" {
+ value = var.runtime_image
+}
diff --git a/infra/aws/user-data.sh.tftpl b/infra/aws/user-data.sh.tftpl
new file mode 100644
index 0000000..f57cef9
--- /dev/null
+++ b/infra/aws/user-data.sh.tftpl
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Boots the LEGACY topics-api container on an EC2 host. Mirrors infra/docker-compose.yml.
+set -euxo pipefail
+exec > >(tee /var/log/user-data.log | logger -t user-data) 2>&1
+
+dnf install -y docker jq
+systemctl enable --now docker
+
+REGION="${region}"
+aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "${ecr_registry}"
+
+# Secret contract: read the DB password from the legacy key name.
+SECRET_JSON=$(aws secretsmanager get-secret-value --region "$REGION" --secret-id "${secret_arn}" --query SecretString --output text)
+export DB_PASSWORD=$(echo "$SECRET_JSON" | jq -r '.${db_secret_key}')
+export DB_USER=$(echo "$SECRET_JSON" | jq -r '.DB_USER')
+
+# Schema is owned by infra (ddl-auto=validate). Apply once, idempotently.
+mkdir -p /opt/db-init
+cat > /opt/db-init/01-schema.sql <<'SQL'
+${schema_sql}
+SQL
+cat > /opt/db-init/02-seed.sql <<'SQL'
+${seed_sql}
+SQL
+cat > /opt/db-init/03-reporting.sql <<'SQL'
+${reporting_sql}
+SQL
+for i in $(seq 1 60); do
+ if docker run --rm -e PGPASSWORD="$DB_PASSWORD" postgres:16-alpine \
+ psql -h "${db_host}" -U "$DB_USER" -d topics -tAc "select 1" >/dev/null 2>&1; then break; fi
+ sleep 5
+done
+if ! docker run --rm -e PGPASSWORD="$DB_PASSWORD" postgres:16-alpine \
+ psql -h "${db_host}" -U "$DB_USER" -d topics -tAc "select 1 from topics limit 1" >/dev/null 2>&1; then
+ for f in 01-schema 02-seed 03-reporting; do
+ docker run --rm -e PGPASSWORD="$DB_PASSWORD" -v /opt/db-init:/init:ro postgres:16-alpine \
+ psql -h "${db_host}" -U "$DB_USER" -d topics -v ON_ERROR_STOP=1 -f /init/$f.sql
+ done
+fi
+
+docker pull "${app_image}"
+docker run -d --name topics-api --restart unless-stopped -p ${app_port}:8080 \
+ -e DB_URL="jdbc:postgresql://${db_host}:5432/topics" \
+ -e DB_USER="$DB_USER" -e DB_PASSWORD="$DB_PASSWORD" \
+ -e DD_SERVICE=topics-api -e DD_ENV=legacy-aws -e DD_VERSION=0.1.0 \
+ -l com.datadoghq.tags.env=legacy-aws \
+ "${app_image}"
diff --git a/infra/aws/variables.tf b/infra/aws/variables.tf
new file mode 100644
index 0000000..bb7ce63
--- /dev/null
+++ b/infra/aws/variables.tf
@@ -0,0 +1,68 @@
+variable "region" {
+ type = string
+ default = "us-east-1"
+}
+
+variable "track" {
+ type = string
+ default = "java"
+}
+
+variable "name" {
+ description = "Resource name prefix."
+ type = string
+ default = "moddemo-topics"
+}
+
+variable "vpc_cidr" {
+ type = string
+ default = "10.42.0.0/16"
+}
+
+variable "dns_zone" {
+ description = "Private hosted zone attached to the VPC."
+ type = string
+ default = "demo.internal"
+}
+
+variable "instance_type" {
+ type = string
+ default = "t3.small"
+}
+
+variable "db_instance_class" {
+ type = string
+ default = "db.t4g.micro"
+}
+
+variable "app_image_tag" {
+ description = "Tag of the image pushed to ECR by `make aws-push`."
+ type = string
+ default = "legacy"
+}
+
+# ---- Runtime contract of the LEGACY app. Everything below is what the migrated
+# ---- (Java 21 / Boot 3) code no longer matches; see demo/PLANTED-GAPS.md.
+variable "runtime_image" {
+ description = "Base image the app container is built FROM (mirrors ../../Dockerfile)."
+ type = string
+ default = "eclipse-temurin:11-jre"
+}
+
+variable "app_port" {
+ description = "Only port the app SG admits from the ALB."
+ type = number
+ default = 8080
+}
+
+variable "health_check_path" {
+ description = "Target-group health check path."
+ type = string
+ default = "/manage/health/readiness"
+}
+
+variable "db_secret_key" {
+ description = "JSON key inside the Secrets Manager secret that the app reads as its DB password env var."
+ type = string
+ default = "DB_PASSWORD"
+}
diff --git a/infra/aws/versions.tf b/infra/aws/versions.tf
new file mode 100644
index 0000000..079e447
--- /dev/null
+++ b/infra/aws/versions.tf
@@ -0,0 +1,24 @@
+terraform {
+ required_version = ">= 1.5"
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.70"
+ }
+ random = {
+ source = "hashicorp/random"
+ version = "~> 3.6"
+ }
+ }
+}
+
+provider "aws" {
+ region = var.region
+ default_tags {
+ tags = {
+ Project = "modernization-demo"
+ Track = var.track
+ Owner = "devin-demo"
+ }
+ }
+}
diff --git a/infra/db/init/01-schema.sql b/infra/db/init/01-schema.sql
new file mode 100644
index 0000000..dd281fc
--- /dev/null
+++ b/infra/db/init/01-schema.sql
@@ -0,0 +1,7 @@
+-- Schema owned by infra/db. The app runs with ddl-auto=validate and never mutates the schema.
+CREATE TABLE topics (
+ id VARCHAR(64) PRIMARY KEY,
+ subject_name VARCHAR(255) NOT NULL,
+ subject_description VARCHAR(1024) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/infra/db/init/02-seed.sql b/infra/db/init/02-seed.sql
new file mode 100644
index 0000000..8aa6bea
--- /dev/null
+++ b/infra/db/init/02-seed.sql
@@ -0,0 +1,4 @@
+INSERT INTO topics (id, subject_name, subject_description) VALUES
+ ('spring', 'Spring Framework', 'Spring Framework Description'),
+ ('java', 'Core Java', 'Java Description'),
+ ('javascript', 'javascript Framework', 'javascript Framework Description');
diff --git a/infra/db/init/03-reporting.sql b/infra/db/init/03-reporting.sql
new file mode 100644
index 0000000..a8f816f
--- /dev/null
+++ b/infra/db/init/03-reporting.sql
@@ -0,0 +1,20 @@
+-- Consumed by BOTH the app (GET /topic/report) and the reporting cron (infra/reporting/report.sh).
+CREATE VIEW topic_summary_v AS
+SELECT id,
+ subject_name,
+ subject_description,
+ length(id) AS id_length,
+ created_at
+FROM topics;
+
+CREATE FUNCTION topic_report()
+RETURNS TABLE (bucket TEXT, topic_count BIGINT, newest_topic TEXT, sample_subject TEXT)
+LANGUAGE sql STABLE AS $$
+ SELECT CASE WHEN id_length > 5 THEN 'long' ELSE 'short' END AS bucket,
+ count(*) AS topic_count,
+ (array_agg(id ORDER BY created_at DESC))[1] AS newest_topic,
+ min(subject_name) AS sample_subject
+ FROM topic_summary_v
+ GROUP BY 1
+ ORDER BY 1
+$$;
diff --git a/infra/dns/Corefile b/infra/dns/Corefile
new file mode 100644
index 0000000..003a87c
--- /dev/null
+++ b/infra/dns/Corefile
@@ -0,0 +1,8 @@
+.:53 {
+ hosts /etc/coredns/hosts {
+ fallthrough
+ }
+ forward . 127.0.0.11
+ log
+ errors
+}
diff --git a/infra/dns/hosts b/infra/dns/hosts
new file mode 100644
index 0000000..be866b3
--- /dev/null
+++ b/infra/dns/hosts
@@ -0,0 +1,7 @@
+# Service names the app and its neighbours depend on (stand-in for Route53 / corporate DNS).
+172.28.0.10 db.topics.internal
+172.28.0.11 reporting.topics.internal
+172.28.0.20 app.topics.internal
+172.28.0.30 lb.topics.internal
+172.28.0.40 metrics.topics.internal
+172.28.0.41 grafana.topics.internal
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
new file mode 100644
index 0000000..847280f
--- /dev/null
+++ b/infra/docker-compose.yml
@@ -0,0 +1,175 @@
+# Stand-in for the real estate: LB (nginx), DNS (CoreDNS), firewall (iptables sidecar),
+# Postgres + a second DB consumer (reporting cron), file-based secrets, Prometheus + Grafana.
+name: topics
+
+networks:
+ topics-net:
+ driver: bridge
+ ipam:
+ config:
+ - subnet: 172.28.0.0/24
+
+volumes:
+ pgdata:
+ reports:
+ grafana-data:
+
+services:
+ coredns:
+ image: coredns/coredns:1.11.3
+ command: ["-conf", "/etc/coredns/Corefile"]
+ volumes:
+ - ./dns/Corefile:/etc/coredns/Corefile:ro
+ - ./dns/hosts:/etc/coredns/hosts:ro
+ networks:
+ topics-net:
+ ipv4_address: 172.28.0.53
+
+ db:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: topics
+ POSTGRES_USER: topics
+ POSTGRES_PASSWORD_FILE: /run/secrets/db_password
+ secrets:
+ - db_password
+ volumes:
+ - pgdata:/var/lib/postgresql/data
+ - ./db/init:/docker-entrypoint-initdb.d:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U topics -d topics"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ networks:
+ topics-net:
+ ipv4_address: 172.28.0.10
+
+ app:
+ build:
+ context: ..
+ dockerfile: Dockerfile
+ args:
+ BASE_IMAGE: ${RUNTIME_IMAGE:-eclipse-temurin:11-jre}
+ image: topics-api:${APP_VERSION:-0.1.0}
+ env_file:
+ - ./secrets/app.env
+ environment:
+ DB_URL: jdbc:postgresql://db.topics.internal:5432/topics
+ DB_USER: topics
+ DD_SERVICE: topics-api
+ DD_ENV: ${DD_ENV:-legacy}
+ DD_VERSION: ${APP_VERSION:-0.1.0}
+ labels:
+ com.datadoghq.tags.service: topics-api
+ com.datadoghq.tags.env: ${DD_ENV:-legacy}
+ com.datadoghq.tags.version: ${APP_VERSION:-0.1.0}
+ dns:
+ - 172.28.0.53
+ depends_on:
+ db:
+ condition: service_healthy
+ coredns:
+ condition: service_started
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://localhost:8080/manage/health/readiness"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+ start_period: 20s
+ networks:
+ topics-net:
+ ipv4_address: 172.28.0.20
+
+ firewall:
+ build: ./firewall
+ network_mode: "service:app"
+ cap_add:
+ - NET_ADMIN
+ volumes:
+ - ./firewall/rules.conf:/etc/firewall/rules.conf:ro
+ depends_on:
+ - app
+
+ nginx:
+ image: nginx:1.27-alpine
+ volumes:
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./nginx/conf.d:/etc/nginx/conf.d:ro
+ - ./firewall/nginx-allowlist.conf:/etc/nginx/allowlist.conf:ro
+ ports:
+ - "8088:80"
+ dns:
+ - 172.28.0.53
+ depends_on:
+ - app
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost/health"]
+ interval: 5s
+ timeout: 3s
+ retries: 30
+ networks:
+ topics-net:
+ ipv4_address: 172.28.0.30
+
+ reporting:
+ image: postgres:16-alpine
+ env_file:
+ - ./secrets/app.env
+ environment:
+ PGHOST: db.topics.internal
+ PGUSER: topics
+ PGDATABASE: topics
+ volumes:
+ - ./reporting/report.sh:/opt/report.sh:ro
+ - ./reporting/crontab:/etc/crontabs/root:ro
+ - reports:/reports
+ command: ["sh", "-c", "sh /opt/report.sh; exec crond -f -l 2"]
+ dns:
+ - 172.28.0.53
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ topics-net:
+ ipv4_address: 172.28.0.11
+
+ prometheus:
+ image: prom/prometheus:v2.54.1
+ volumes:
+ - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
+ command:
+ - --config.file=/etc/prometheus/prometheus.yml
+ - --storage.tsdb.retention.time=2d
+ ports:
+ - "${PROMETHEUS_PORT:-9091}:9090"
+ dns:
+ - 172.28.0.53
+ networks:
+ topics-net:
+ ipv4_address: 172.28.0.40
+
+ grafana:
+ image: grafana/grafana:11.2.0
+ environment:
+ GF_SECURITY_ADMIN_USER: admin
+ GF_SECURITY_ADMIN_PASSWORD: admin
+ GF_AUTH_ANONYMOUS_ENABLED: "true"
+ GF_AUTH_ANONYMOUS_ORG_ROLE: Viewer
+ volumes:
+ - ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
+ - ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
+ - grafana-data:/var/lib/grafana
+ ports:
+ - "${GRAFANA_PORT:-3000}:3000"
+ dns:
+ - 172.28.0.53
+ depends_on:
+ - prometheus
+ networks:
+ topics-net:
+ ipv4_address: 172.28.0.41
+
+secrets:
+ db_password:
+ file: ./secrets/vault-stub/db_password
diff --git a/infra/firewall/Dockerfile b/infra/firewall/Dockerfile
new file mode 100644
index 0000000..2dd90e0
--- /dev/null
+++ b/infra/firewall/Dockerfile
@@ -0,0 +1,4 @@
+FROM alpine:3.20
+RUN apk add --no-cache iptables
+COPY apply.sh /apply.sh
+ENTRYPOINT ["/apply.sh"]
diff --git a/infra/firewall/apply.sh b/infra/firewall/apply.sh
new file mode 100755
index 0000000..1442d2d
--- /dev/null
+++ b/infra/firewall/apply.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+# Applies /etc/firewall/rules.conf to the shared network namespace, then idles.
+set -eu
+RULES=${RULES:-/etc/firewall/rules.conf}
+iptables -F INPUT
+iptables -A INPUT -i lo -j ACCEPT
+iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
+grep -Ev '^\s*(#|$)' "$RULES" | while read -r proto port _; do
+ echo "firewall: allow $proto/$port"
+ iptables -A INPUT -p "$proto" --dport "$port" -j ACCEPT
+done
+iptables -P INPUT DROP
+echo "firewall: default policy DROP"
+iptables -S INPUT
+exec sleep infinity
diff --git a/infra/firewall/nginx-allowlist.conf b/infra/firewall/nginx-allowlist.conf
new file mode 100644
index 0000000..b62b313
--- /dev/null
+++ b/infra/firewall/nginx-allowlist.conf
@@ -0,0 +1,4 @@
+# Edge allowlist for the load balancer (stand-in for a WAF / security group).
+allow 172.28.0.0/24;
+allow 127.0.0.1;
+deny all;
diff --git a/infra/firewall/rules.conf b/infra/firewall/rules.conf
new file mode 100644
index 0000000..ad3be92
--- /dev/null
+++ b/infra/firewall/rules.conf
@@ -0,0 +1,3 @@
+# Inbound allowlist applied inside the app's network namespace (iptables sidecar).
+# Format: . Everything else inbound is DROPPED.
+tcp 8080 topics-api http + actuator (/manage)
diff --git a/infra/nginx/conf.d/topics.conf b/infra/nginx/conf.d/topics.conf
new file mode 100644
index 0000000..798f118
--- /dev/null
+++ b/infra/nginx/conf.d/topics.conf
@@ -0,0 +1,33 @@
+# Load balancer / reverse proxy for topics-api.
+# Health contract: upstream exposes Spring Boot actuator under /manage on the app port (8080).
+upstream topics-api {
+ server app.topics.internal:8080 max_fails=3 fail_timeout=10s;
+ keepalive 32;
+}
+
+server {
+ listen 80;
+ server_name lb.topics.internal;
+
+ include /etc/nginx/allowlist.conf;
+
+ location = /health {
+ proxy_pass http://topics-api/manage/health/readiness;
+ proxy_connect_timeout 2s;
+ proxy_read_timeout 2s;
+ }
+
+ location /manage/ {
+ deny all;
+ }
+
+ location / {
+ proxy_pass http://topics-api;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_connect_timeout 2s;
+ proxy_read_timeout 5s;
+ }
+}
diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf
new file mode 100644
index 0000000..5ac7ca1
--- /dev/null
+++ b/infra/nginx/nginx.conf
@@ -0,0 +1,9 @@
+worker_processes 1;
+events { worker_connections 1024; }
+http {
+ include /etc/nginx/mime.types;
+ log_format main '$remote_addr "$request" $status $body_bytes_sent rt=$request_time urt=$upstream_response_time';
+ access_log /var/log/nginx/access.log main;
+ resolver 172.28.0.53 valid=10s;
+ include /etc/nginx/conf.d/*.conf;
+}
diff --git a/infra/observability/grafana/dashboards/topics-api.json b/infra/observability/grafana/dashboards/topics-api.json
new file mode 100644
index 0000000..c00a22c
--- /dev/null
+++ b/infra/observability/grafana/dashboards/topics-api.json
@@ -0,0 +1,129 @@
+{
+ "uid": "topics-api-nfr",
+ "title": "topics-api / NFR baseline",
+ "tags": ["modernization", "topics-api"],
+ "timezone": "browser",
+ "schemaVersion": 39,
+ "version": 1,
+ "refresh": "5s",
+ "time": { "from": "now-15m", "to": "now" },
+ "templating": {
+ "list": [
+ {
+ "name": "env",
+ "type": "query",
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "query": "label_values(http_server_requests_seconds_count, env)",
+ "includeAll": true,
+ "multi": true,
+ "current": { "text": "All", "value": "$__all" },
+ "refresh": 2
+ }
+ ]
+ },
+ "panels": [
+ {
+ "id": 1, "type": "stat", "title": "Scrape target up",
+ "gridPos": { "h": 4, "w": 4, "x": 0, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "up{job=\"topics-api\"}", "legendFormat": "up" }],
+ "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 1 }] } }, "overrides": [] },
+ "options": { "colorMode": "background", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 2, "type": "stat", "title": "Requests / s",
+ "gridPos": { "h": 4, "w": 5, "x": 4, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "sum(rate(http_server_requests_seconds_count{env=~\"$env\"}[1m]))", "legendFormat": "rps" }],
+ "fieldConfig": { "defaults": { "unit": "reqps", "decimals": 1 }, "overrides": [] },
+ "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 3, "type": "stat", "title": "p95 latency",
+ "gridPos": { "h": 4, "w": 5, "x": 9, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket{env=~\"$env\", uri!~\"/manage.*|/actuator.*\"}[1m])))", "legendFormat": "p95" }],
+ "fieldConfig": { "defaults": { "unit": "s", "decimals": 3, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "orange", "value": 0.25 }, { "color": "red", "value": 0.5 }] } }, "overrides": [] },
+ "options": { "colorMode": "value", "graphMode": "area", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 4, "type": "stat", "title": "5xx ratio",
+ "gridPos": { "h": 4, "w": 5, "x": 14, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "(sum(rate(http_server_requests_seconds_count{env=~\"$env\", status=~\"5..\"}[1m])) or vector(0)) / sum(rate(http_server_requests_seconds_count{env=~\"$env\"}[1m]))", "legendFormat": "5xx" }],
+ "fieldConfig": { "defaults": { "unit": "percentunit", "decimals": 2, "thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }, { "color": "red", "value": 0.01 }] } }, "overrides": [] },
+ "options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 5, "type": "stat", "title": "Hikari pool max",
+ "gridPos": { "h": 4, "w": 5, "x": 19, "y": 0 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "max(hikaricp_connections_max{env=~\"$env\"})", "legendFormat": "max" }],
+ "fieldConfig": { "defaults": { "thresholds": { "mode": "absolute", "steps": [{ "color": "red", "value": null }, { "color": "green", "value": 20 }] } }, "overrides": [] },
+ "options": { "colorMode": "value", "graphMode": "none", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false } }
+ },
+ {
+ "id": 6, "type": "timeseries", "title": "Latency percentiles (non-actuator)",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "expr": "histogram_quantile(0.50, sum by (le, env) (rate(http_server_requests_seconds_bucket{env=~\"$env\", uri!~\"/manage.*|/actuator.*\"}[1m])))", "legendFormat": "p50 {{env}}" },
+ { "expr": "histogram_quantile(0.95, sum by (le, env) (rate(http_server_requests_seconds_bucket{env=~\"$env\", uri!~\"/manage.*|/actuator.*\"}[1m])))", "legendFormat": "p95 {{env}}" },
+ { "expr": "histogram_quantile(0.99, sum by (le, env) (rate(http_server_requests_seconds_bucket{env=~\"$env\", uri!~\"/manage.*|/actuator.*\"}[1m])))", "legendFormat": "p99 {{env}}" }
+ ],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 7, "type": "timeseries", "title": "Throughput by endpoint",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "sum by (uri, status) (rate(http_server_requests_seconds_count{env=~\"$env\", uri!~\"/manage.*|/actuator.*\"}[1m]))", "legendFormat": "{{uri}} {{status}}" }],
+ "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }
+ },
+ {
+ "id": 8, "type": "timeseries", "title": "Hikari connections (active / pending / max)",
+ "gridPos": { "h": 8, "w": 8, "x": 0, "y": 12 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "expr": "sum by (env) (hikaricp_connections_active{env=~\"$env\"})", "legendFormat": "active {{env}}" },
+ { "expr": "sum by (env) (hikaricp_connections_pending{env=~\"$env\"})", "legendFormat": "pending {{env}}" },
+ { "expr": "max by (env) (hikaricp_connections_max{env=~\"$env\"})", "legendFormat": "max {{env}}" }
+ ],
+ "fieldConfig": { "defaults": {}, "overrides": [] }
+ },
+ {
+ "id": 9, "type": "timeseries", "title": "Hikari acquire time p95",
+ "gridPos": { "h": 8, "w": 8, "x": 8, "y": 12 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "sum by (env) (rate(hikaricp_connections_acquire_seconds_sum{env=~\"$env\"}[1m])) / sum by (env) (rate(hikaricp_connections_acquire_seconds_count{env=~\"$env\"}[1m]))", "legendFormat": "avg acquire {{env}}" }],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ },
+ {
+ "id": 10, "type": "timeseries", "title": "JVM heap used / max",
+ "gridPos": { "h": 8, "w": 8, "x": 16, "y": 12 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "expr": "sum by (env) (jvm_memory_used_bytes{env=~\"$env\", area=\"heap\"})", "legendFormat": "used {{env}}" },
+ { "expr": "sum by (env) (jvm_memory_max_bytes{env=~\"$env\", area=\"heap\"})", "legendFormat": "max {{env}}" }
+ ],
+ "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }
+ },
+ {
+ "id": 11, "type": "timeseries", "title": "Tomcat busy threads / log events per second",
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [
+ { "expr": "sum by (env) (tomcat_threads_busy_threads{env=~\"$env\"})", "legendFormat": "busy threads {{env}}" },
+ { "expr": "sum by (env) (rate(logback_events_total{env=~\"$env\"}[1m]))", "legendFormat": "log events/s {{env}}" }
+ ],
+ "fieldConfig": { "defaults": {}, "overrides": [] }
+ },
+ {
+ "id": 12, "type": "timeseries", "title": "GC pause time / s",
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 },
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
+ "targets": [{ "expr": "sum by (env) (rate(jvm_gc_pause_seconds_sum{env=~\"$env\"}[1m]))", "legendFormat": "gc {{env}}" }],
+ "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
+ }
+ ]
+}
diff --git a/infra/observability/grafana/provisioning/dashboards/dashboards.yml b/infra/observability/grafana/provisioning/dashboards/dashboards.yml
new file mode 100644
index 0000000..7ee35fd
--- /dev/null
+++ b/infra/observability/grafana/provisioning/dashboards/dashboards.yml
@@ -0,0 +1,7 @@
+apiVersion: 1
+providers:
+ - name: topics
+ folder: Modernization
+ type: file
+ options:
+ path: /var/lib/grafana/dashboards
diff --git a/infra/observability/grafana/provisioning/datasources/prometheus.yml b/infra/observability/grafana/provisioning/datasources/prometheus.yml
new file mode 100644
index 0000000..6bd6e22
--- /dev/null
+++ b/infra/observability/grafana/provisioning/datasources/prometheus.yml
@@ -0,0 +1,8 @@
+apiVersion: 1
+datasources:
+ - name: Prometheus
+ uid: prometheus
+ type: prometheus
+ access: proxy
+ url: http://metrics.topics.internal:9090
+ isDefault: true
diff --git a/infra/observability/prometheus.yml b/infra/observability/prometheus.yml
new file mode 100644
index 0000000..e7060fa
--- /dev/null
+++ b/infra/observability/prometheus.yml
@@ -0,0 +1,15 @@
+global:
+ scrape_interval: 5s
+ external_labels:
+ env: legacy
+
+scrape_configs:
+ - job_name: topics-api
+ metrics_path: /manage/prometheus
+ static_configs:
+ - targets: ["app.topics.internal:8080"]
+ labels:
+ service: topics-api
+ - job_name: prometheus
+ static_configs:
+ - targets: ["localhost:9090"]
diff --git a/infra/reporting/crontab b/infra/reporting/crontab
new file mode 100644
index 0000000..3dd2f8b
--- /dev/null
+++ b/infra/reporting/crontab
@@ -0,0 +1 @@
+* * * * * sh /opt/report.sh >/proc/1/fd/1 2>&1
diff --git a/infra/reporting/report.sh b/infra/reporting/report.sh
new file mode 100755
index 0000000..833181f
--- /dev/null
+++ b/infra/reporting/report.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+# Second consumer of the topics DB: dumps topic_report() as JSON for downstream BI.
+set -eu
+export PGPASSWORD="${DB_PASSWORD:?DB_PASSWORD not set}"
+OUT=${REPORT_OUT:-/reports/latest.json}
+mkdir -p "$(dirname "$OUT")"
+psql -qAt -v ON_ERROR_STOP=1 -c "
+ SELECT json_build_object(
+ 'generated_at', now(),
+ 'total_topics', (SELECT count(*) FROM topic_summary_v),
+ 'topics', (SELECT coalesce(json_agg(subject_name ORDER BY id), '[]'::json) FROM topic_summary_v),
+ 'report', (SELECT coalesce(json_agg(r), '[]'::json) FROM topic_report() r)
+ )" > "$OUT.tmp"
+mv "$OUT.tmp" "$OUT"
+cat "$OUT"
diff --git a/infra/scripts/aws_check_clean.sh b/infra/scripts/aws_check_clean.sh
new file mode 100755
index 0000000..86686f1
--- /dev/null
+++ b/infra/scripts/aws_check_clean.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Lists resources still tagged Project=,Track=