From 9b9b853498856cf8b1af23196926fcb98dba04e9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:19:22 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feature:=20legacy=20modernization=20demo=20?= =?UTF-8?q?fixture=20=E2=80=94=20infra/,=20infra/aws,=20tools/discover,=20?= =?UTF-8?q?CI,=20demo=20runbook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Ian Moritz --- .github/workflows/ci.yml | 76 ++++ .gitignore | 9 +- .mvn/wrapper/maven-wrapper.properties | 2 +- Dockerfile | 13 + Makefile | 163 ++++++++ application.properties | 1 - build.gradle | 42 +-- demo/BASELINE-aws.json | 28 ++ demo/BASELINE.json | 28 ++ demo/PLAYBOOK.md | 96 +++++ demo/RESET.md | 34 ++ demo/RUNBOOK.md | 149 ++++++++ demo/VERIFY-REPORT-TEMPLATE.md | 78 ++++ demo/dependency-graph.dot | 45 +++ demo/dependency-graph.json | 327 ++++++++++++++++ demo/dependency-graph.md | 60 +++ demo/dependency-graph.png | Bin 0 -> 117967 bytes gradlew | 0 gs-spring-boot.iml | 58 --- infra/.env | 7 + infra/aws/.gitignore | 2 + infra/aws/.terraform.lock.hcl | 46 +++ infra/aws/compute.tf | 170 +++++++++ infra/aws/data.tf | 50 +++ infra/aws/network.tf | 100 +++++ infra/aws/outputs.tf | 39 ++ infra/aws/user-data.sh.tftpl | 47 +++ infra/aws/variables.tf | 68 ++++ infra/aws/versions.tf | 24 ++ infra/db/init/01-schema.sql | 7 + infra/db/init/02-seed.sql | 4 + infra/db/init/03-reporting.sql | 20 + infra/dns/Corefile | 8 + infra/dns/hosts | 7 + infra/docker-compose.yml | 175 +++++++++ infra/firewall/Dockerfile | 4 + infra/firewall/apply.sh | 15 + infra/firewall/nginx-allowlist.conf | 4 + infra/firewall/rules.conf | 3 + infra/nginx/conf.d/topics.conf | 33 ++ infra/nginx/nginx.conf | 9 + .../grafana/dashboards/topics-api.json | 129 +++++++ .../provisioning/dashboards/dashboards.yml | 7 + .../provisioning/datasources/prometheus.yml | 8 + infra/observability/prometheus.yml | 15 + infra/reporting/crontab | 1 + infra/reporting/report.sh | 15 + infra/scripts/compare_nfr.py | 48 +++ infra/secrets/.env.example | 2 + infra/secrets/README.md | 10 + infra/secrets/vault-stub/db_password | 1 + infra/terraform/.gitignore | 2 + infra/terraform/.terraform.lock.hcl | 23 ++ infra/terraform/main.tf | 38 ++ infra/terraform/variables.tf | 27 ++ infra/terraform/versions.tf | 11 + load/topics.js | 87 +++++ mvnw | 0 pom.xml | 37 +- src/main/java/hello/Application.java | 80 +--- .../hello/controller/TopicController.java | 84 ++--- src/main/java/hello/model/Customer.java | 45 --- src/main/java/hello/model/Quote.java | 34 -- src/main/java/hello/model/Topic.java | 26 +- src/main/java/hello/model/Value.java | 33 -- .../hello/repository/TopicRepository.java | 7 + src/main/java/hello/service/TopicService.java | 170 +++------ src/main/resources/application.properties | 29 ++ src/main/resources/public/index.html | 10 - .../hello/controller/TopicControllerTest.java | 60 +++ .../java/hello/service/TopicServiceTest.java | 83 +++++ target/classes/hello/Application.class | Bin 6460 -> 0 bytes .../hello/controller/GreetingController.class | Bin 1219 -> 0 bytes .../hello/controller/HelloController.class | Bin 4496 -> 0 bytes .../hello/controller/TopicController.class | Bin 2344 -> 0 bytes .../hello/declaration/CustomPredicate.class | Bin 248 -> 0 bytes .../hello/declaration/TimeClient.class | Bin 1383 -> 0 bytes target/classes/hello/model/Customer.class | Bin 1407 -> 0 bytes target/classes/hello/model/Greeting.class | Bin 588 -> 0 bytes target/classes/hello/model/Quote.class | Bin 1128 -> 0 bytes .../hello/model/SimpleTimeClient.class | Bin 1720 -> 0 bytes target/classes/hello/model/Topic.class | Bin 1121 -> 0 bytes target/classes/hello/model/Value.class | Bin 1111 -> 0 bytes .../classes/hello/service/TopicService.class | Bin 13054 -> 0 bytes target/classes/public/index.html | 10 - .../discover/__pycache__/diff.cpython-310.pyc | Bin 0 -> 10363 bytes .../__pycache__/discover.cpython-310.pyc | Bin 0 -> 14708 bytes tools/discover/diff.py | 209 +++++++++++ tools/discover/discover.py | 352 ++++++++++++++++++ tools/discover/requirements.txt | 1 + 90 files changed, 3256 insertions(+), 489 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 Makefile delete mode 100644 application.properties create mode 100644 demo/BASELINE-aws.json create mode 100644 demo/BASELINE.json create mode 100644 demo/PLAYBOOK.md create mode 100644 demo/RESET.md create mode 100644 demo/RUNBOOK.md create mode 100644 demo/VERIFY-REPORT-TEMPLATE.md create mode 100644 demo/dependency-graph.dot create mode 100644 demo/dependency-graph.json create mode 100644 demo/dependency-graph.md create mode 100644 demo/dependency-graph.png mode change 100644 => 100755 gradlew delete mode 100644 gs-spring-boot.iml create mode 100644 infra/.env create mode 100644 infra/aws/.gitignore create mode 100644 infra/aws/.terraform.lock.hcl create mode 100644 infra/aws/compute.tf create mode 100644 infra/aws/data.tf create mode 100644 infra/aws/network.tf create mode 100644 infra/aws/outputs.tf create mode 100644 infra/aws/user-data.sh.tftpl create mode 100644 infra/aws/variables.tf create mode 100644 infra/aws/versions.tf create mode 100644 infra/db/init/01-schema.sql create mode 100644 infra/db/init/02-seed.sql create mode 100644 infra/db/init/03-reporting.sql create mode 100644 infra/dns/Corefile create mode 100644 infra/dns/hosts create mode 100644 infra/docker-compose.yml create mode 100644 infra/firewall/Dockerfile create mode 100755 infra/firewall/apply.sh create mode 100644 infra/firewall/nginx-allowlist.conf create mode 100644 infra/firewall/rules.conf create mode 100644 infra/nginx/conf.d/topics.conf create mode 100644 infra/nginx/nginx.conf create mode 100644 infra/observability/grafana/dashboards/topics-api.json create mode 100644 infra/observability/grafana/provisioning/dashboards/dashboards.yml create mode 100644 infra/observability/grafana/provisioning/datasources/prometheus.yml create mode 100644 infra/observability/prometheus.yml create mode 100644 infra/reporting/crontab create mode 100755 infra/reporting/report.sh create mode 100644 infra/scripts/compare_nfr.py create mode 100644 infra/secrets/.env.example create mode 100644 infra/secrets/README.md create mode 100644 infra/secrets/vault-stub/db_password create mode 100644 infra/terraform/.gitignore create mode 100644 infra/terraform/.terraform.lock.hcl create mode 100644 infra/terraform/main.tf create mode 100644 infra/terraform/variables.tf create mode 100644 infra/terraform/versions.tf create mode 100644 load/topics.js mode change 100644 => 100755 mvnw delete mode 100644 src/main/java/hello/model/Customer.java delete mode 100644 src/main/java/hello/model/Quote.java delete mode 100644 src/main/java/hello/model/Value.java create mode 100644 src/main/java/hello/repository/TopicRepository.java create mode 100644 src/main/resources/application.properties delete mode 100644 src/main/resources/public/index.html create mode 100644 src/test/java/hello/controller/TopicControllerTest.java create mode 100644 src/test/java/hello/service/TopicServiceTest.java delete mode 100644 target/classes/hello/Application.class delete mode 100644 target/classes/hello/controller/GreetingController.class delete mode 100644 target/classes/hello/controller/HelloController.class delete mode 100644 target/classes/hello/controller/TopicController.class delete mode 100644 target/classes/hello/declaration/CustomPredicate.class delete mode 100644 target/classes/hello/declaration/TimeClient.class delete mode 100644 target/classes/hello/model/Customer.class delete mode 100644 target/classes/hello/model/Greeting.class delete mode 100644 target/classes/hello/model/Quote.class delete mode 100644 target/classes/hello/model/SimpleTimeClient.class delete mode 100644 target/classes/hello/model/Topic.class delete mode 100644 target/classes/hello/model/Value.class delete mode 100644 target/classes/hello/service/TopicService.class delete mode 100644 target/classes/public/index.html create mode 100644 tools/discover/__pycache__/diff.cpython-310.pyc create mode 100644 tools/discover/__pycache__/discover.cpython-310.pyc create mode 100644 tools/discover/diff.py create mode 100644 tools/discover/discover.py create mode 100644 tools/discover/requirements.txt 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..b49f6f7 --- /dev/null +++ b/Makefile @@ -0,0 +1,163 @@ +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 ==" + @curl -fsS '$(PROM_URL)/api/v1/query?query=up%7Bjob%3D%22topics-api%22%7D' | grep -q '"value":\[[0-9.]*,"1"\]' + @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: + @echo "== tagged resources remaining (Project=modernization-demo, Track=$(TRACK)) ==" + @aws resourcegroupstaggingapi get-resources --region $(AWS_REGION) \ + --tag-filters Key=Project,Values=modernization-demo Key=Track,Values=$(TRACK) --query 'ResourceTagMappingList[].ResourceARN' --output json | tee /dev/stderr | grep -q '^\[\]$$' && echo "clean: 0 tagged resources remain" 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..92e399c --- /dev/null +++ b/demo/RUNBOOK.md @@ -0,0 +1,149 @@ +# 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. `make loadtest` + `make compare` against the legacy baseline: **POST /topic returns 500 — `null value in column "subject_description"`** (gap 4) + and **p95 regression** with throughput collapse (gap 6). Devin opens Grafana: Hikari pending connections and log events/s spike, pool is 10 not 20. +4. Fixes: revert schema mutation / restore column mapping; restore 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 panel pair +*Hikari active/pending* and *log events/s* jumping 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`. + +![dependency graph](dependency-graph.png) + +```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 0000000000000000000000000000000000000000..f4120b5bc8ed9592cda25f04451573c72ced744f GIT binary patch literal 117967 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ 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/compare_nfr.py b/infra/scripts/compare_nfr.py new file mode 100644 index 0000000..95468c3 --- /dev/null +++ b/infra/scripts/compare_nfr.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Compare a k6 summary against the captured baseline; exit 1 on NFR regression.""" +import json +import sys + +P95_TOLERANCE = 1.5 # candidate p95 may be at most 1.5x baseline ... +P95_FLOOR_MS = 25.0 # ... or baseline + 25ms, whichever is larger (absorbs sub-ms noise) +ERR_TOLERANCE = 0.01 # absolute error-rate ceiling +RPS_TOLERANCE = 0.6 # candidate must keep >= 60% of baseline throughput + + +def load(path): + with open(path) as fh: + return json.load(fh) + + +def main(baseline_path, candidate_path): + base, cand = load(baseline_path), load(candidate_path) + rows = [ + ("p50 ms", base["latency_ms"]["p50"], cand["latency_ms"]["p50"], None), + ("p95 ms", base["latency_ms"]["p95"], cand["latency_ms"]["p95"], + cand["latency_ms"]["p95"] <= max(base["latency_ms"]["p95"] * P95_TOLERANCE, + base["latency_ms"]["p95"] + P95_FLOOR_MS)), + ("p99 ms", base["latency_ms"]["p99"], cand["latency_ms"]["p99"], None), + ("rps", base["rps"], cand["rps"], cand["rps"] >= base["rps"] * RPS_TOLERANCE), + ("error rate", base["error_rate"], cand["error_rate"], cand["error_rate"] <= ERR_TOLERANCE), + ("report p95 ms", base["endpoint_p95_ms"]["report"], cand["endpoint_p95_ms"]["report"], None), + ] + print(f"{'metric':<15}{'baseline':>12}{'candidate':>12} verdict") + failed = False + for name, b, c, ok in rows: + verdict = "" if ok is None else ("ok" if ok else "REGRESSION") + failed |= ok is False + print(f"{name:<15}{b:>12}{c:>12} {verdict}") + for name, status in cand.get("thresholds", {}).items(): + print(f"k6 threshold {name}: {status}") + failed |= status != "pass" + if failed: + print("NFR comparison FAILED against baseline", file=sys.stderr) + sys.exit(1) + print("NFR comparison passed") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("usage: compare_nfr.py BASELINE.json latest.json", file=sys.stderr) + sys.exit(2) + main(sys.argv[1], sys.argv[2]) diff --git a/infra/secrets/.env.example b/infra/secrets/.env.example new file mode 100644 index 0000000..3463c6d --- /dev/null +++ b/infra/secrets/.env.example @@ -0,0 +1,2 @@ +# Copied to infra/secrets/app.env by `make up` if missing. Keys the app + reporting job read. +DB_PASSWORD=topics-dev-password diff --git a/infra/secrets/README.md b/infra/secrets/README.md new file mode 100644 index 0000000..36e465a --- /dev/null +++ b/infra/secrets/README.md @@ -0,0 +1,10 @@ +# secrets/ + +File-based stand-in for Vault / AWS Secrets Manager. + +| Path | Consumer | Key | +|------|----------|-----| +| `vault-stub/db_password` | Postgres container (`POSTGRES_PASSWORD_FILE`) | n/a | +| `app.env` (from `.env.example`) | `app` (`spring.datasource.password=${DB_PASSWORD}`), `reporting` (`PGPASSWORD`) | `DB_PASSWORD` | + +Rotating: change both files, `make reset`. diff --git a/infra/secrets/vault-stub/db_password b/infra/secrets/vault-stub/db_password new file mode 100644 index 0000000..e999351 --- /dev/null +++ b/infra/secrets/vault-stub/db_password @@ -0,0 +1 @@ +topics-dev-password \ No newline at end of file diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 0000000..9b5533c --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,2 @@ +.terraform/ +*.tfstate* diff --git a/infra/terraform/.terraform.lock.hcl b/infra/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..a11a96b --- /dev/null +++ b/infra/terraform/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/kreuzwerker/docker" { + version = "3.9.0" + constraints = "~> 3.0" + hashes = [ + "h1:EAdNh5KgGPJT5jm848MRIfNfHUVJeTBdKKcFLax5g38=", + "zh:0ead8281830e9b9496651282235d9a139ba1b1b6ff79e395eb8c78658dc446b9", + "zh:0f17d37d8d3872df3fb75c68b5272e0c981343f53b506a9675b4405191edd3ef", + "zh:11d50b37323874427c6d2a08b737d3c7707c8301fdd236c94485cf2828d0b14b", + "zh:32f6f9b847446054e2db3d72886ef2f1d1aa51a6d0dac42340b07dad18e3f28f", + "zh:5ea5c67668b5dcbda560dc6104b788a9bfc974d52f02f7886889b77cc0e5d248", + "zh:5fb19a0b07edc344cd3ddeeb9cfb3d183089deb7a6a94a7b22a583aa1712596b", + "zh:602a7ece444e2a142ec5245abb98e7a1a990a68afae2df63b6c85ec084f0c5d7", + "zh:693dce278524ad8a6d6c9dd7a01bcd63bb85189639198f8d0b044ab0e5099401", + "zh:72e9911568103576c6a78fa38841cfd45eeb88ad22a2c649eb140a377a5b3c26", + "zh:956b62b6857cbb467b50158601f01b1203daa34cbd447dcc7f044c327e878b68", + "zh:9d372bac0d4479868b34485fb4966ba7bb525938f818b6a625f4977004ea83f9", + "zh:e06658a51427f9f53dbdb06263406fc1bc56d1a4fb5e7eb660d7cdfc22f596bd", + "zh:eee38dadf672b946419af25160eae7c03fc2afbb14f39f2f1d2a7404d647e2f7", + ] +} diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf new file mode 100644 index 0000000..5a5a6c3 --- /dev/null +++ b/infra/terraform/main.tf @@ -0,0 +1,38 @@ +# Pins the runtime for topics-api. `terraform plan` is enough for the demo; apply builds the same +# image docker-compose does (docker provider). + +resource "docker_image" "runtime" { + name = var.runtime_image + keep_locally = true +} + +resource "docker_image" "app" { + name = "topics-api:${var.app_version}" + build { + context = "${path.module}/../.." + dockerfile = "Dockerfile" + build_args = { + BASE_IMAGE = docker_image.runtime.name + } + label = { + "com.datadoghq.tags.service" = "topics-api" + "com.datadoghq.tags.version" = var.app_version + "runtime.java.major" = tostring(var.runtime_java_major) + } + } + triggers = { + jar = filesha256("${path.module}/../../target/app.jar") + } +} + +output "runtime_image" { + value = var.runtime_image +} + +output "health_check_url" { + value = "http://app.topics.internal:${var.app_port}${var.actuator_base_path}/health/readiness" +} + +output "metrics_url" { + value = "http://app.topics.internal:${var.app_port}${var.actuator_base_path}/prometheus" +} diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf new file mode 100644 index 0000000..775c34d --- /dev/null +++ b/infra/terraform/variables.tf @@ -0,0 +1,27 @@ +variable "runtime_image" { + description = "JRE base image for topics-api. Must match infra/.env RUNTIME_IMAGE and the Dockerfile default." + type = string + default = "eclipse-temurin:11-jre" +} + +variable "runtime_java_major" { + description = "Java major version the runtime image provides; class files above this fail with UnsupportedClassVersionError." + type = number + default = 11 +} + +variable "app_version" { + type = string + default = "0.1.0" +} + +variable "app_port" { + description = "Port the LB, firewall and Prometheus expect the app (and its actuator) on." + type = number + default = 8080 +} + +variable "actuator_base_path" { + type = string + default = "/manage" +} diff --git a/infra/terraform/versions.tf b/infra/terraform/versions.tf new file mode 100644 index 0000000..b6a9e52 --- /dev/null +++ b/infra/terraform/versions.tf @@ -0,0 +1,11 @@ +terraform { + required_version = ">= 1.5" + required_providers { + docker = { + source = "kreuzwerker/docker" + version = "~> 3.0" + } + } +} + +provider "docker" {} diff --git a/load/topics.js b/load/topics.js new file mode 100644 index 0000000..3af4aeb --- /dev/null +++ b/load/topics.js @@ -0,0 +1,87 @@ +// k6 load profile for topics-api, run through the load balancer. +// Env: BASE_URL, VUS, DURATION, P95_MS (threshold), ERR_RATE (threshold), OUT (summary path) +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; + +const BASE_URL = __ENV.BASE_URL || 'http://lb.topics.internal'; +const P95_MS = Number(__ENV.P95_MS || 250); +const ERR_RATE = Number(__ENV.ERR_RATE || 0.01); +const OUT = __ENV.OUT || '/results/latest.json'; + +const listLatency = new Trend('topic_list_ms', true); +const reportLatency = new Trend('topic_report_ms', true); + +export const options = { + vus: Number(__ENV.VUS || 20), + duration: __ENV.DURATION || '30s', + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(50)', 'p(90)', 'p(95)', 'p(99)'], + thresholds: { + http_req_failed: [`rate<${ERR_RATE}`], + http_req_duration: [`p(95)<${P95_MS}`], + }, +}; + +export default function () { + const list = http.get(`${BASE_URL}/topic`, { tags: { name: 'GET /topic' } }); + listLatency.add(list.timings.duration); + check(list, { 'list 200': (r) => r.status === 200 }); + + const one = http.get(`${BASE_URL}/topic/spring`, { tags: { name: 'GET /topic/{id}' } }); + check(one, { 'get 200': (r) => r.status === 200 }); + + const sorted = http.get(`${BASE_URL}/topic/sort`, { tags: { name: 'GET /topic/sort' } }); + check(sorted, { 'sort 200': (r) => r.status === 200 }); + + const report = http.get(`${BASE_URL}/topic/report`, { tags: { name: 'GET /topic/report' } }); + reportLatency.add(report.timings.duration); + check(report, { 'report 200': (r) => r.status === 200 }); + + const id = `load-${__VU}-${__ITER}`; + const created = http.post(`${BASE_URL}/topic`, + JSON.stringify({ id, subjectName: 'Load', subjectDescription: 'k6' }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'POST /topic' } }); + check(created, { 'post 201': (r) => r.status === 201 }); + + const deleted = http.del(`${BASE_URL}/topic/${id}`, null, { tags: { name: 'DELETE /topic/{id}' } }); + check(deleted, { 'delete 204': (r) => r.status === 204 }); + + sleep(0.05); +} + +function pct(metric, p) { + if (!metric || !metric.values || metric.values[p] === undefined) return null; + return Number(metric.values[p].toFixed(2)); +} + +export function handleSummary(data) { + const d = data.metrics.http_req_duration; + const thresholds = {}; + for (const [name, m] of Object.entries(data.metrics)) { + if (m.thresholds) { + for (const [expr, t] of Object.entries(m.thresholds)) thresholds[`${name}: ${expr}`] = t.ok ? 'pass' : 'FAIL'; + } + } + const summary = { + generated_at: new Date().toISOString(), + target: BASE_URL, + profile: { vus: options.vus, duration: options.duration }, + requests: data.metrics.http_reqs.values.count, + rps: Number(data.metrics.http_reqs.values.rate.toFixed(2)), + latency_ms: { + p50: pct(d, 'p(50)'), p90: pct(d, 'p(90)'), p95: pct(d, 'p(95)'), p99: pct(d, 'p(99)'), + avg: pct(d, 'avg'), max: pct(d, 'max'), + }, + endpoint_p95_ms: { + list: pct(data.metrics.topic_list_ms, 'p(95)'), + report: pct(data.metrics.topic_report_ms, 'p(95)'), + }, + error_rate: Number(data.metrics.http_req_failed.values.rate.toFixed(4)), + checks_failed: data.metrics.checks ? data.metrics.checks.values.fails : 0, + thresholds, + }; + const out = {}; + out[OUT] = JSON.stringify(summary, null, 2) + '\n'; + out.stdout = `\n== k6 summary ==\n${JSON.stringify(summary, null, 2)}\n`; + return out; +} diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/pom.xml b/pom.xml index 63f5cbd..0f629da 100644 --- a/pom.xml +++ b/pom.xml @@ -5,41 +5,52 @@ org.springframework gs-spring-boot - pom + jar 0.1.0 org.springframework.boot spring-boot-starter-parent - 2.0.2.RELEASE + 2.7.18 + + + 11 + + org.springframework.boot - spring-boot-properties-migrator - runtime + spring-boot-starter-web org.springframework.boot - spring-boot-starter-web + spring-boot-starter-data-jpa org.springframework.boot - spring-boot-starter-jdbc + spring-boot-starter-actuator - com.h2database - h2 + io.micrometer + micrometer-registry-prometheus + runtime + + + org.postgresql + postgresql + runtime + + + org.springframework.boot + spring-boot-starter-test + test - - 1.8 - - - + app org.springframework.boot diff --git a/src/main/java/hello/Application.java b/src/main/java/hello/Application.java index 7cf8faf..5abd411 100644 --- a/src/main/java/hello/Application.java +++ b/src/main/java/hello/Application.java @@ -1,88 +1,12 @@ package hello; -import java.util.Arrays; -import java.util.List; -import java.util.stream.Collectors; - -import hello.model.Customer; -import hello.model.Quote; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.web.client.RestTemplateBuilder; -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.Bean; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.web.client.RestTemplate; @SpringBootApplication -public class Application implements CommandLineRunner { - - private static final Logger log = LoggerFactory.getLogger(Application.class); +public class Application { public static void main(String[] args) { - - ApplicationContext ctx = SpringApplication.run(Application.class, args); - - System.out.println("Let's inspect the beans provided by Spring Boot:"); - - String[] beanNames = ctx.getBeanDefinitionNames(); - Arrays.sort(beanNames); - for (String beanName : beanNames) { - System.out.println(beanName); - } - - RestTemplate restTemplate = new RestTemplate(); - Quote quote = restTemplate.getForObject("http://gturnquist-quoters.cfapps.io/api/random", Quote.class); - log.info(quote.toString()); - } - - - @Bean - public RestTemplate restTemplate(RestTemplateBuilder builder) { - return builder.build(); - } - - @Bean - public CommandLineRunner run(RestTemplate restTemplate) throws Exception { - return args -> { - Quote quote = restTemplate.getForObject( - "http://gturnquist-quoters.cfapps.io/api/random", Quote.class); - log.info(quote.toString()); - }; - } - - - @Autowired - JdbcTemplate jdbcTemplate; - - @Override - public void run(String... args) throws Exception { - log.info("Creating tables"); - - jdbcTemplate.execute("DROP TABLE customers IF EXISTS"); - jdbcTemplate.execute("CREATE TABLE customers(id SERIAL, first_name VARCHAR(255), last_name VARCHAR(255))"); - - // Split up the array of whole names into an array of first/last names - List splitUpNames = Arrays.asList("John Woo", "Jeff Dean", "Josh Bloch", "Josh Long") - .stream() - .map(name -> name.split(" ")) - .collect(Collectors.toList()); - - // Use a Java 8 stream to print out each tuple of the list - splitUpNames.forEach(name -> log.info(String.format("Inserting customer record for %s %s", name[0], name[1]))); - - // Uses JdbcTemplate's batchUpdate operation to bulk load data - jdbcTemplate.batchUpdate("INSERT INTO customers(first_name, last_name) VALUES (?,?)", splitUpNames); - - log.info("Querying for customer records where first_name = 'Josh':"); - jdbcTemplate.query( - "SELECT id, first_name, last_name FROM customers WHERE first_name = ?", new Object[]{"Josh"}, - (rs, rowNum) -> new Customer(rs.getLong("id"), rs.getString("first_name"), rs.getString("last_name")) - ).forEach(customer -> log.info(customer.toString())); - + SpringApplication.run(Application.class, args); } } diff --git a/src/main/java/hello/controller/TopicController.java b/src/main/java/hello/controller/TopicController.java index c2e8273..fa686b7 100644 --- a/src/main/java/hello/controller/TopicController.java +++ b/src/main/java/hello/controller/TopicController.java @@ -2,89 +2,65 @@ import hello.model.Topic; import hello.service.TopicService; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Map; @RestController public class TopicController { - @Autowired - private TopicService topicService; + private final TopicService topicService; + public TopicController(TopicService topicService) { + this.topicService = topicService; + } - /** - * Get all Topic - * @return - */ - @RequestMapping("/topic") + @GetMapping("/topic") public List getAllTopics() { return topicService.getAllTopics(); } - /** - * get Topic with ID - * @param id - * @return - */ - @RequestMapping("/topic/{id}") - public Topic getTopicWithID(@PathVariable String id) { - return topicService.getTopicWithId(id); + @GetMapping("/topic/{id}") + public ResponseEntity getTopicWithID(@PathVariable String id) { + return topicService.getTopicWithId(id) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); } - /** - * Add a new topic in list - * @param topic - */ - @RequestMapping(method = RequestMethod.POST, value = "/topic") - public void addTopic(@RequestBody Topic topic) { - topicService.addTopic(topic); + @PostMapping("/topic") + @ResponseStatus(HttpStatus.CREATED) + public Topic addTopic(@RequestBody Topic topic) { + return topicService.addTopic(topic); } - /** - * Update Topic in List with id - * @param id - * @param topic - */ - @RequestMapping(method = RequestMethod.PUT, value = "/topic/{id}") - public void updateTopic(@PathVariable String id, @RequestBody Topic topic) { - topicService.updateTopic(id, topic); + @PutMapping("/topic/{id}") + public ResponseEntity updateTopic(@PathVariable String id, @RequestBody Topic topic) { + return topicService.updateTopic(id, topic) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); } - - /** - * Delete a topic with ID - * @param id - */ - @RequestMapping(method = RequestMethod.DELETE, value = "/topic/{id}") + @DeleteMapping("/topic/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteTopic(@PathVariable String id) { topicService.deleteTopic(id); } - /** - * Get all topics with Id length greater then minimum length - * @param minLength - * @return - */ - @RequestMapping(value = "/topic/minimum/length/{minLength}") + @GetMapping("/topic/minimum/length/{minLength}") public List filterMinimumLengthForId(@PathVariable Integer minLength) { return topicService.filterMinimumLengthForId(minLength); } - - /** - * Sort with Id - * @return - */ - @RequestMapping("/topic/sort") + @GetMapping("/topic/sort") public List sortTopicsWithID() { return topicService.sortTopicsWithID(); } - - - - - + @GetMapping("/topic/report") + public List> topicReport() { + return topicService.topicReport(); + } } diff --git a/src/main/java/hello/model/Customer.java b/src/main/java/hello/model/Customer.java deleted file mode 100644 index a6df80e..0000000 --- a/src/main/java/hello/model/Customer.java +++ /dev/null @@ -1,45 +0,0 @@ -package hello.model; - -public class Customer { - private long id; - private String firstName, lastName; - - @Override - public String toString() { - return "Customer{" + - "id=" + id + - ", firstName='" + firstName + '\'' + - ", lastName='" + lastName + '\'' + - '}'; - } - - public Customer(long id, String firstName, String lastName) { - this.id = id; - this.firstName = firstName; - this.lastName = lastName; - } - - public long getId() { - return id; - } - - public void setId(long id) { - this.id = id; - } - - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } -} diff --git a/src/main/java/hello/model/Quote.java b/src/main/java/hello/model/Quote.java deleted file mode 100644 index 2178a6d..0000000 --- a/src/main/java/hello/model/Quote.java +++ /dev/null @@ -1,34 +0,0 @@ -package hello.model; - -public class Quote { - private String type; - private Value value; - - @Override - public String toString() { - return "Quote{" + - "type='" + type + '\'' + - ", value=" + value + - '}'; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public Value getValue() { - return value; - } - - public void setValue(Value value) { - this.value = value; - } - - public Quote() { - - } -} diff --git a/src/main/java/hello/model/Topic.java b/src/main/java/hello/model/Topic.java index 483a4a7..2b70378 100644 --- a/src/main/java/hello/model/Topic.java +++ b/src/main/java/hello/model/Topic.java @@ -1,14 +1,31 @@ package hello.model; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; +@Entity +@Table(name = "topics") public class Topic { + @Id private String id; + + @Column(nullable = false) private String subjectName; + + @Column(nullable = false) private String subjectDescription; public Topic() { } + public Topic(String id, String subjectName, String subjectDescription) { + this.id = id; + this.subjectName = subjectName; + this.subjectDescription = subjectDescription; + } + public String getId() { return id; } @@ -33,11 +50,8 @@ public void setSubjectDescription(String subjectDescription) { this.subjectDescription = subjectDescription; } - public Topic(String id, String subjectName, String subjectDescription) { - super(); - this.id = id; - this.subjectName = subjectName; - this.subjectDescription = subjectDescription; + @Override + public String toString() { + return "Topic{id='" + id + "', subjectName='" + subjectName + "', subjectDescription='" + subjectDescription + "'}"; } - } diff --git a/src/main/java/hello/model/Value.java b/src/main/java/hello/model/Value.java deleted file mode 100644 index 272be70..0000000 --- a/src/main/java/hello/model/Value.java +++ /dev/null @@ -1,33 +0,0 @@ -package hello.model; - -public class Value { - private Long id; - private String quote; - - public Value() { - } - - @Override - public String toString() { - return "Value{" + - "id=" + id + - ", quote='" + quote + '\'' + - '}'; - } - - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getQuote() { - return quote; - } - - public void setQuote(String quote) { - this.quote = quote; - } -} diff --git a/src/main/java/hello/repository/TopicRepository.java b/src/main/java/hello/repository/TopicRepository.java new file mode 100644 index 0000000..3014c7a --- /dev/null +++ b/src/main/java/hello/repository/TopicRepository.java @@ -0,0 +1,7 @@ +package hello.repository; + +import hello.model.Topic; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TopicRepository extends JpaRepository { +} diff --git a/src/main/java/hello/service/TopicService.java b/src/main/java/hello/service/TopicService.java index feffb92..892a7fe 100644 --- a/src/main/java/hello/service/TopicService.java +++ b/src/main/java/hello/service/TopicService.java @@ -2,6 +2,8 @@ import hello.declaration.CustomPredicate; import hello.model.Topic; +import hello.repository.TopicRepository; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import java.io.BufferedReader; @@ -9,79 +11,64 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.*; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.regex.Pattern; import java.util.stream.Collectors; -import java.util.stream.IntStream; import java.util.stream.Stream; @Service public class TopicService { + private final TopicRepository topicRepository; + private final JdbcTemplate jdbcTemplate; - private List topics = new ArrayList<>(Arrays.asList( - new Topic("spring", "Spring Framework", "Spring Framework Description"), - new Topic("java", "Core Java", "Java Description"), - new Topic("javascript", "javascript Framework", "javascript Framework Description") - )); + public TopicService(TopicRepository topicRepository, JdbcTemplate jdbcTemplate) { + this.topicRepository = topicRepository; + this.jdbcTemplate = jdbcTemplate; + } public List getAllTopics() { - return topics; + return topicRepository.findAll(); } /** - * Strean Example - * - * @param id - * @return + * Optional example */ - public Topic getTopicWithId(String id) { - return topics.stream().filter(topic -> topic.getId().equals(id)).findFirst().get(); + public Optional getTopicWithId(String id) { + return topicRepository.findById(id); } - public void addTopic(Topic topic) { - topics.add(topic); + public Topic addTopic(Topic topic) { + return topicRepository.save(topic); } - /** - * IntStream examples - * - * @param id - * @param topic - */ - public void updateTopic(String id, Topic topic) { - OptionalInt indexOfElement = IntStream.range(0, topics.size()).filter(index -> id.equals(topics.get(index).getId())).findFirst(); - if (indexOfElement.isPresent()) topics.set(indexOfElement.getAsInt(), topic); + public Optional updateTopic(String id, Topic topic) { + return topicRepository.findById(id).map(existing -> { + existing.setSubjectName(topic.getSubjectName()); + existing.setSubjectDescription(topic.getSubjectDescription()); + return topicRepository.save(existing); + }); } /** - * Lamda Expressions - * - * @param id + * Lambda expression */ public void deleteTopic(String id) { - topics.removeIf(topic -> topic.getId().equals(id)); + topicRepository.findById(id).ifPresent(topicRepository::delete); } /** - * Calling fucntional Interface - * - * @param minLength - * @return + * Calling functional interface */ public List filterMinimumLengthForId(Integer minLength) { - return printTopicsWithPredicate(topics, topic -> topic.getId().length() > minLength); + return filterTopicsWithPredicate(getAllTopics(), topic -> topic.getId().length() > minLength); } - - /** - * Functional Interface example With ForEach - * - * @param topicList - * @param tester - * @return - */ - private static List printTopicsWithPredicate(List topicList, CustomPredicate tester) { + private static List filterTopicsWithPredicate(List topicList, CustomPredicate tester) { List resultTopic = new ArrayList<>(); topicList.forEach(topic -> { if (tester.test(topic)) resultTopic.add(topic); @@ -89,35 +76,25 @@ private static List printTopicsWithPredicate(List topicList, Custo return resultTopic; } - /** * Using Comparator to sort - * - * @return */ public List sortTopicsWithID() { - topics.sort(Comparator.comparing(Topic::getId)); - return topics; + return getAllTopics().stream().sorted(Comparator.comparing(Topic::getId)).collect(Collectors.toList()); } - /** - * Join List of Strings - * - * @return + * Cross-component report: reads the DB-side aggregation the reporting job also consumes. */ + public List> topicReport() { + return jdbcTemplate.queryForList("SELECT * FROM topic_report()"); + } + public String returnAllTopicIDWithStringSlicing() { - List topicIds = topics.stream().map(topic -> topic.getId()).collect(Collectors.toList()); + List topicIds = getAllTopics().stream().map(Topic::getId).collect(Collectors.toList()); return String.join(":", topicIds); } - - /** - * Use of MapToObject and distinct - * - * @param join - * @return - */ public String makeDistinctAndSortCharacters(String join) { return join.chars().distinct() .mapToObj(id -> String.valueOf((char) id)) @@ -125,13 +102,6 @@ public String makeDistinctAndSortCharacters(String join) { .collect(Collectors.joining()); } - - /** - * Use of Pattern Class with stream - * - * @param join - * @return - */ public String splitAllIdWithColonSelectIDWithJavaKeywordThenSortThenJoin(String join) { return Pattern.compile(":") .splitAsStream(join) @@ -140,101 +110,55 @@ public String splitAllIdWithColonSelectIDWithJavaKeywordThenSortThenJoin(String .collect(Collectors.joining(":")); } - - /** - * Apply Regex as Predicate with Stream - * @return - */ public String findIdHavingCharacter() { Pattern pattern = Pattern.compile(".*g.*"); - Object[] topicIdObjectList = topics.stream().map(topic -> topic.getId()).collect(Collectors.toList()).toArray(); - - String[] topicIdList = Arrays.stream(topicIdObjectList).toArray(String[]::new); - + String[] topicIdList = getAllTopics().stream().map(Topic::getId).toArray(String[]::new); return Stream.of(topicIdList) .filter(pattern.asPredicate()) .collect(Collectors.toList()) .toString(); } - - /** - * NIO Java API - * Use Streams with files - * Try with resource, "Autoclose" - * - * @return - */ public String findAllFilesInPathAndSort() { try (Stream stream = Files.list(Paths.get(""))) { - String joined = stream + return stream .map(String::valueOf) .filter(path -> !path.startsWith(".")) .sorted() .collect(Collectors.joining("; ")); - return joined; } catch (IOException e) { return " Error in IO"; } } - /** - * Using File.find function to find file - * @return - */ public String findParticularFileInPathAndSort() { - Path start = Paths.get(""); - int maxDepth = 25; - try (Stream stream = Files.find(start, maxDepth, (path, attr) -> + try (Stream stream = Files.find(Paths.get(""), 25, (path, attr) -> String.valueOf(path).startsWith("grad"))) { - String joined = stream - .sorted() - .map(String::valueOf) - .collect(Collectors.joining("; ")); - return joined; + return stream.sorted().map(String::valueOf).collect(Collectors.joining("; ")); } catch (IOException e) { return " IO exception "; } } - - /** - * Using Files.Walk Function to find File - * @return - */ public String findParticularFileInPathAndSortWithWalkFunction() { - Path start = Paths.get(""); - int maxDepth = 5; - try (Stream stream = Files.walk(start, maxDepth)) { - String joined = stream + try (Stream stream = Files.walk(Paths.get(""), 5)) { + return stream .map(String::valueOf) .filter(path -> path.startsWith("grad")) .sorted() .collect(Collectors.joining("; ")); - return joined; } catch (IOException e) { return " IO exception "; } } - - /** - * Use BufferedReader with Stream functions - * @return - */ public String readFileWithStreamFunction() { - Path path = Paths.get("temp.txt"); - System.out.println(); - try (BufferedReader reader = Files.newBufferedReader(path)) { - String lines = reader - .lines() - .filter(line->line.contains("print")) - .map(line->line.substring("print".length())) - .collect(Collectors.joining(",")); - return lines; + try (BufferedReader reader = Files.newBufferedReader(Paths.get("temp.txt"))) { + return reader.lines() + .filter(line -> line.contains("print")) + .collect(Collectors.joining(System.lineSeparator())); } catch (IOException e) { return " IO exception "; } } - } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..7257eba --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,29 @@ +spring.application.name=topics-api +server.port=8080 + +# Datasource (Postgres). Secrets come from infra/secrets (DB_PASSWORD). +spring.datasource.url=${DB_URL:jdbc:postgresql://db.topics.internal:5432/topics} +spring.datasource.username=${DB_USER:topics} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.hikari.maximum-pool-size=20 +spring.datasource.hikari.minimum-idle=5 +spring.datasource.hikari.connection-timeout=2000 + +# Schema is owned by infra/db (init.sql); the app only validates it. +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.hibernate.naming.physical-strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy +spring.jpa.open-in-view=false + +# Actuator: legacy contract consumed by nginx health check + Prometheus scrape. +management.endpoints.web.base-path=/manage +management.endpoints.web.exposure.include=health,info,prometheus,metrics +management.endpoint.health.probes.enabled=true +management.endpoint.health.show-details=always +management.metrics.distribution.percentiles-histogram.http.server.requests=true +management.metrics.tags.application=${DD_SERVICE:topics-api} +management.metrics.tags.env=${DD_ENV:legacy} +management.metrics.tags.version=${DD_VERSION:0.1.0} + +logging.level.root=INFO + +server.tomcat.mbeanregistry.enabled=true diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html deleted file mode 100644 index 566549b..0000000 --- a/src/main/resources/public/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/src/test/java/hello/controller/TopicControllerTest.java b/src/test/java/hello/controller/TopicControllerTest.java new file mode 100644 index 0000000..8254534 --- /dev/null +++ b/src/test/java/hello/controller/TopicControllerTest.java @@ -0,0 +1,60 @@ +package hello.controller; + +import hello.model.Topic; +import hello.service.TopicService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Arrays; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(TopicController.class) +class TopicControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private TopicService topicService; + + @Test + void listsTopics() throws Exception { + when(topicService.getAllTopics()).thenReturn(Arrays.asList( + new Topic("spring", "Spring Framework", "desc"), + new Topic("java", "Core Java", "desc"))); + + mockMvc.perform(get("/topic")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value("spring")) + .andExpect(jsonPath("$[1].subjectName").value("Core Java")); + } + + @Test + void returns404ForUnknownTopic() throws Exception { + when(topicService.getTopicWithId("nope")).thenReturn(Optional.empty()); + mockMvc.perform(get("/topic/nope")).andExpect(status().isNotFound()); + } + + @Test + void createsTopic() throws Exception { + Topic topic = new Topic("k8s", "Kubernetes", "desc"); + when(topicService.addTopic(any(Topic.class))).thenReturn(topic); + + mockMvc.perform(post("/topic") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":\"k8s\",\"subjectName\":\"Kubernetes\",\"subjectDescription\":\"desc\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("k8s")); + } +} diff --git a/src/test/java/hello/service/TopicServiceTest.java b/src/test/java/hello/service/TopicServiceTest.java new file mode 100644 index 0000000..5b3d41b --- /dev/null +++ b/src/test/java/hello/service/TopicServiceTest.java @@ -0,0 +1,83 @@ +package hello.service; + +import hello.model.Topic; +import hello.repository.TopicRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TopicServiceTest { + + private TopicRepository repository; + private JdbcTemplate jdbcTemplate; + private TopicService service; + + private final List topics = Arrays.asList( + new Topic("spring", "Spring Framework", "Spring Framework Description"), + new Topic("java", "Core Java", "Java Description"), + new Topic("javascript", "javascript Framework", "javascript Framework Description")); + + @BeforeEach + void setUp() { + repository = mock(TopicRepository.class); + jdbcTemplate = mock(JdbcTemplate.class); + when(repository.findAll()).thenReturn(topics); + service = new TopicService(repository, jdbcTemplate); + } + + @Test + void getTopicWithIdReturnsEmptyOptionalWhenMissing() { + when(repository.findById("nope")).thenReturn(Optional.empty()); + assertThat(service.getTopicWithId("nope")).isEmpty(); + } + + @Test + void updateTopicOnlyTouchesExistingRows() { + when(repository.findById("nope")).thenReturn(Optional.empty()); + assertThat(service.updateTopic("nope", new Topic("nope", "x", "y"))).isEmpty(); + verify(repository, never()).save(any()); + } + + @Test + void filterMinimumLengthForIdUsesCustomPredicate() { + List filtered = service.filterMinimumLengthForId(5); + assertThat(filtered).extracting(Topic::getId).containsExactly("spring", "javascript"); + } + + @Test + void sortTopicsWithIdSortsAlphabetically() { + assertThat(service.sortTopicsWithID()).extracting(Topic::getId) + .containsExactly("java", "javascript", "spring"); + } + + @Test + void stringOperationsMatchLegacyBehaviour() { + String joined = service.returnAllTopicIDWithStringSlicing(); + assertThat(joined).isEqualTo("spring:java:javascript"); + assertThat(service.makeDistinctAndSortCharacters(joined)).isEqualTo(":acgijnprstv"); + assertThat(service.splitAllIdWithColonSelectIDWithJavaKeywordThenSortThenJoin(joined)) + .isEqualTo("java:javascript"); + assertThat(service.findIdHavingCharacter()).isEqualTo("[spring]"); + } + + @Test + void topicReportDelegatesToStoredFunction() { + Map row = Collections.singletonMap("total_topics", 3L); + when(jdbcTemplate.queryForList("SELECT * FROM topic_report()")) + .thenReturn(Collections.singletonList(row)); + assertThat(service.topicReport()).containsExactly(row); + } +} diff --git a/target/classes/hello/Application.class b/target/classes/hello/Application.class deleted file mode 100644 index 245a020cdb693ce4216257a9f5aa3bd787671f48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6460 zcmb7Jd3+RS8GgQ8v)K%gE+mwDxHloNlu{_91w%+A=>|$RKoM&@*_|Wejybqa!877`CZ1J~>+$Ruo)gCncy0rphv&!f0=!Vci{hxqbuqkH!AlgpR6gBU zk1D(@4iz^w;N^Hl95>^Z1ckmfhS$k=uaDskal8?4is8))-V(!GW4JYjx5e;wDfJx< zSc7*aAn=|Bych3_+w|D8Xh+6b^-NxZZJDwj282{YY4RYG)3Di3=i4*sBzk|PoyMGDr?zBsy9SU z?nV8jf@_W%hfT*E%o`nrg5~P2X%!rSOCv=M8hXJ=4e43evd2<-nIY9ta;?**ZR9An z?lQAry1Qt2vgNQqO{bNkZS&G*!Ps9K9W?9#o!UH)vU>ioZkzHsSgdhJOsb!s9x?KH zE7egf=FO~U6eZV;>SjTpIe9F7TtB6!@_J!7m2suJ?NU`t+P9y6OW`vp$gkVOQg{SA ztJO1qRPD)< zB@;R6{wPD2PaTwtd^;!O>e&;0deMuOf&&V^EHILcR5EB;ZfX`ydrD?LM*y`%a?Zj; z1C&9ZQD8?T=i|{S5FM1s5QjjuhuNgoYP-awAB%Q#He;h*^9<~?Mo0BRPU4XTQ1A*V z-gPEx6g})K_zLsr5O?mOq68Du+UTRH2PMpY^9Vg@i@*rH3AHPRZmI$MNmubh z{7A*+*rnpf_=$p_s`!~S_U9^ofrrTl_IGs* zbWJBsnmH}g)!);RZqtTL+i|av(A5ri^mp#-=ud9B zzOQw(wT>zhVm^DIriukPrHCd~G>e4-tx=^Fz0t6;qKGHAzEGP8rVCC2Q&m*nq6C_} zRoUA!2h*YT*=_OLL`Z|cV7TRWG9*rWRCkPQcatX5POsa^Vhjln%;zo_>H?WD$2Hh2 zs;!bs7yP}3jpRzsZ!QbR&_~JOJ6yTU$wRg-H_sAk3R@j)3bKPY4;!u=81@=NvXOI& z4fgJ)WJ`owla_f!)M4BD827&#$FgafN>SaG?RPDK-UyAp@!n=NGjfw#V2G%^?ZsSc zvPUdPwuC4GS7_JtIiGf!g&}s5?NQ+IkTy#v59U11mEPbckfO>cA+zwe$Hj$WQ5;ob z@hmuxK3gzf#u+19lA~;$?#PKrU~R?JUKyiuDb#EV6{6 zB!|J^9UEkrm-BF1Zr|n6oB?Imk;A|&Ev}c_7AgF22}H#(obMp40?B0W%q}x?MP{jv zDIL|B{T+`l4w3q5y^|B`XnGs#_P-kVZunm?WK#&4;&;-ZLxn$dC}M>wG_gj-X`E3} zlGD*iIqe+hP=Fyh2&|fdgjqOcoiI{q?|8*YQV+gY5Qd{uPl{MakJFZQqQt6Jd*ngT zGeDmA9t@im$n$5?eVlkXcdqdn1K5V~67r{laeybpF!|$u>8t$Tyx~*?k~{T$K4bF3 zfg;v3&@s;Zr?&aRpb%Eb96BOyQc5sA@fj>hztcNz{&` zZo>o=fxfnLh_&tCc;B{~>TR`6wM{j*V&TT7+ATZlNVuqugg4ZR+a7!P9@HN_i}?PH ziH3w)eJ|#WWA6P>ch=mAd5dbs(Kvw%svwQ4_Ke#^AAqG)y&UtP(ae?fcoo0Zt;VIi z?!r3Kt;Z20kwc5;#4ftD88M{M$2*#g{W!p@!qdx@I7o+@`P|Px8GseYV1U|=U@i{f zFx@;t+Qq1Pj4J9BT&3Ws_j{FKoF3sDL5?SpSH^S)pQ?DR-7t>C?Ys%cbdzUJvriDj zl%zO@CzGv%)H3#0^D5-QibhD0?SV*T!+FdHrVG6|BvdI2RXi`)JSm`t0`v;;*Hioz zzQr7y>!=90g%M9I@WS1CH<}X7<5=k3T*w>cUMxCVomiYXT9a6kIa-@onmJmRSe7}5 z3Ih|?j|_q{`q7XZet) z)UTX5G{^)fgiVpmQpf;-Cn2KUgUv@<6BkWj3*(cxcmiAH^%C!T7TcyLdLWc&4S9@G z2Bn|?i$-0`duecyy4ZzzCw1+kE@|*`8T9S7UZh*Aw=j*ZHCvdyOCvortHD>t= diff --git a/target/classes/hello/controller/GreetingController.class b/target/classes/hello/controller/GreetingController.class deleted file mode 100644 index 1149660667b8bd33f1ed25bd2d4d556a0184f111..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1219 zcmb7DYflqF6g|^-OLKy#_<+zlxxYx|+pLNS!% z@K8IhW&6?&l@d~0T>GA5TWis;=0_lz$BuOLDnqhdsWYV3{U$*T)*LCGhh9UdSG?gy z41Alrb*>y^cbqA`?*t5UwSD2bzA2@Z@47-+8%hXGG>KxzMTB#$j+dd@q1fF~r32#; z1@{zz@=}s}L}7f3+H*WncLIkvtV!u>t{oDA;bF~JEh}gnY4#L_ANlI7btD>A!;wvk zpT=5S;$0{L{fxKUv5e`%SUzSk%=DIc$qf`q+EZ)!ytN`2>Z8~cdpvZ1zgPOTuiPd> zVcQRtEuJ_==drWOEt$7JUyFh6626EFC^5`OBlORiC}I#p48!r0_P#|Cw{eGI_CKDR z+Inh+VsUCNm}VIIGruny2g24_+%3{h+&AQwY2X}bE8)?UuF2+x(9t|@_T6H*_d$YJ`L{RaAwBD4`Y72|k}VvH}nYdEC!4K(-JWfYr$d+wjzujAq7}jAk4~Of#YPfGtv&s&a7u< zB}5MAuyfz{?cDcaJC*>G*faD{qV)P=sBg)cP7Aj~0;N)8Ro zXRwgNHc@(A=%So(BZm*;BUyZO13rdVgze*5e4-nl#HZxr)hu4i;nVnx44)O=&t>uX z9KL`rX7QygzAR#2$>FP_Xd7-dHY-<087GJIQx@5u078NMgO_cQoG20zrG zU-fKv%Bs~JBeXQ+C$1XvM$s|cxneo=ZFlathDU0~HEYZcLffr`Bd%HY{BX2t_(mnP z{Gcg(GN?OtIN~@Z^O7A_M?J@L%a&tR!qS*5j8pZq*5ZQan^RTG6*WS~S6Q%U)^^R3 zdB&LM;pr*OLwaSOXPmMfD=4|=jj)=S)a2B+p6c^0!xYWJLRVQvZ7q`?dUd-}w)}a!LhtULs9KKW6@z%8IMuRCKkvNIa6`Lh zU9+C085 zvAp8Bq3=14?_ zu0hz6jXK8gehmdFE{wBc#ZYlTRcWk0Pz$`ZYJ9SEPSvci>Y2kz$CDV+aQa~D-)SpU z21QF_s*+Z`K@~(*O_@>?E97uZ6|kDcT1#Uml%l%#inBzHheceU`$n`8rn2m_0RPyirV3@ z*mqpyo8qT!$bx@*YOMdQp4d@66r@O8hSO)_bI<%^KQ&oDuZ9?_%&YF z@f-YB!?9Fk)g+boW>wv{=54PLs2V#+RV^3+mHbY}@9_s6f5e|!Gjo-aQlO+IDMLAy zQCEwW;j3p9h5szxdPB#XcuQdXg>`I|VEQp;%^D3SRKa4vII0_-%j7kg-5I>CV+qR| z`cvlSM4FH!`KaO8v!3tT#<0@xz^LIGrcoSa-us55<0fupa9hXwFp|MLI__XX$3^^A z$0ZqFkk2bJyePv5WOxY^8dRJM_inHnHaGKNa^@;;*h*@wZHa95jIV}WE3J4f3%lle zHbKiZ>Fa^D4SQ`5PtuNp+0cuN=_;dEmNHRCot)LMzg2H+^YOKMsW7<-?cV}CWX=j# zwS1aTcN)Q%vB<~C?t$?{XN;?~)g(Y|2g*aivX-# zu0nqO5;BE+b_v~ud~OLF3VD4A8w>ddmawUi-@Jq^Z&I)Wr#bGAjNL)yJ;-4fHe)xo zV=qUAJ^~uxcnJG&hP--wAW9hLmxgCCK`mm;ztDwsq_5pUe)=Z1PTazSXYXJe=iBo; zmhsS|%h-7fJ)Grt$>(kCW@as8PqO%MbMYN1}!O$#%bp zj$!S)3zK9gCR6d1{q-JiwLb_bAXZsBw-!0i!$X#%{=#Jqxs*tmMxx0>LlQ*h65U%+kV i%8Q(Jkb8u@0Sv6Q}7zpJZ!eFO?-%S|!6U z{ZH*oC!Og-e?Wg!r{AvRON2ay2kmO5bI$jD=d%C&``6z@v`kiosx{>a}|Mwj*?Pl_%- z5TR(gyzY6vw59O9kkKQ{587tf3xwA`4D2pH_Jg0zCbzxNJhWTV58j&gz`?9X(mxgf zcM!I?COje68Rct>2aF0Ez5_02E#dL+qi&N2`!>)RON>o#)gSYw*%Y2LDtwRs67f*JvU@#b3a%4Jwi^MgXzE%pngUBr@kb#$JDZ9| zOw+Z!w%L#>!>fxKL>T3Sqtc{BjaoCR#?j4p!ub{1I$g+JTNO<5;8aB)WL7jw)2=N$ z{jxw&*>)T)fYEF%gQqI;L?U)3<3Dl%HXR>Y9)2)~eqOaBNECKoH})B=d~AT{zVjCD z)u`v#k|#2185PJNu7u@xe*kP1?SX_Q?iVuLm2Giz%i7xCVl;OsT%`XM=AavOE$+2t zXWI{Ukb#?{zB``eZ62Wd@Aqw2c(Eg+nUN0YyD)nEA(6&4_(IY@Kv5J;S93Jq4N%cl z=R!Y-J2&jNZolo+A4Zji9|bMGEz~rh*U1^q=RGCq<0dJTL-$pK&e10Z8FZ#ZFAcg# za|V4!*9^Kzw+#A%?iutYePz(sv~18fG{z|E z_i+{XGe%P}!#;&m5&K!}W$Yl0bs8rVJHtM)^e3_3@sgt%tjd@Wx^Y~WLD({|9F%7*8Nh=z9KDHc4tt3+6 zO^QTL=QK^iOi0A?&*TR3V=Cy*gi1OIr8JXuB;o0pBn*?D#=n0Ge(j~_PCTdfyJDG;u(~xoN|9|T~M)Frt#vH8;X6*^le`Q|( AuK)l5 diff --git a/target/classes/hello/declaration/CustomPredicate.class b/target/classes/hello/declaration/CustomPredicate.class deleted file mode 100644 index e35803f64936c9dac92044b2074162814cb240fe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 248 zcmZXPu?~Vj5JYDYLD0fNKfn$fudz~NV`62Z2~>9Hals=eByjj~7Jh&qWjwLNVw0Vh z$?WXw{dfXcV&o(AF(kM~)`l>R4mrPbrg@RE<0Y5EtmkJ!5UZ@;(I#2_ z+qeIS5XNOw3AtC=226HMZOYrJlBp8h$i??0VRn^Tmn@Y+^NJf)7OZ0=jJxeT0>QDr W0OS&SaINlJ<-oJ^+k6aK5_|y1tUr+e diff --git a/target/classes/hello/declaration/TimeClient.class b/target/classes/hello/declaration/TimeClient.class deleted file mode 100644 index a077c3b314ee91fac7a75e48cd1d292b119702c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1383 zcma)6YflqF6g|_H-L@%Al)A1LI*PW0fBdOc3!3FdX=bSyCF6(M)+l3>uxd_F(D7n;_%BvUsz)o$6fLQFzo z>agBJAq!rSI|>oHN6hKMF+bs1NsQTZ&KyI60^WMA$Xg6kW(ah+dzjskzU?07+G9f; z1;ti9i z66kH0!O*oMdGXyQF9)ntVg{A0(quIl8D{?@g(h5OB9H6}!+7CHIF6UKMA6|sSE912 zdaI5tT&eW!cvZhBHf)t;Ps=r@l0krnj*M1BHXX-8?5~{&qxI+6+h$2F(nW=%D7Cs~T9kDlG z(2>&4pl9ew#p!j1#2Jz`be>WiQK5sPq8OolicTQD4&u|uavftBCCeZoG)&NGJcu!I z6=||xqelZ<*U85)N%&C73gMNK?#x#h3^O%!)zE!PmPqg&ilO~vlbevZfhn@7o#JNn JyGf@A%-=-ZS;_za diff --git a/target/classes/hello/model/Customer.class b/target/classes/hello/model/Customer.class deleted file mode 100644 index 06e7307b01f2d26b0448a3cb8b12af11a1a0577c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1407 zcmaJ-=K;NhX@e zNHp;W_@j*HY4<`#F->}(mwV51&bjx`-=Dt$tYJNcN(Kt1j%8V^X-r~8qE#K1Bo8Hd zq~oz9Pb68(U(KW0?Y1jjrWSF=$vkjHiD&=#Y*Ft`(GOiQDHA}=M%M}^#o#=C=8O20guu=%g$(4FY=QCj;J1FG7u-b$1Ex^CQe=$b)Di7p^XJWSK;&R}hh_5hA7F4$A)kZ~E($ diff --git a/target/classes/hello/model/Greeting.class b/target/classes/hello/model/Greeting.class deleted file mode 100644 index f875277914303b411d510e45ee094e441fbdc76c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 588 zcmZuu%SyvQ6g`tiV{7^_t@Vw%YXh2%JH<^wu>@UI3NF)hXs4tJ#MHkM6fA-ZKfsR? z?0D}nny=iIsZ`u_L?aE5~tbZix&VOyFV1G@(H7!)yNU|j~okHUn934`TMyeH2I zyl~|7l30YJbB4l&2t{(q&}?;A`|Uo1)`^Ckq3Vi|-_E839^ZR|fGU<7`CicXVj=Uq zs3l|ZNNnzy2SMaa2^%=qG3QC9r;m7YL#wH+HZ518th3m!(Q2=5Kr_8)7W@24NX_zs z>QuhPL<##Q3NTEpp~%qqPnDte_wc*Hg!^<1((mJ#E~e8N6sn zK)FV~YQKZohjS?Q$sFn{l@yf8T4~!LE8#^{2wb815djHCUyZzsqzp?VIVoi}2%X6| s#3p;X{RZWkf{eV710R diff --git a/target/classes/hello/model/Quote.class b/target/classes/hello/model/Quote.class deleted file mode 100644 index 3ec6c6c7d3ee5544ff04f2ad4753558e032a5fd4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1128 zcmZ`%?@!ZE6g_XWYgdbm!3OFO5OCf2Q5C)+e2~OXi^f1^{Mc>HC@JkSS`uUYpES{t zXyPB>A7#Avt+LsbGLW4Yq(1eeAmerRIfm?byB(^z74U;03)y0rlGPW4c^up1Zyu+LvM1_3aiX z=~j=zQKi75RnO{IPN+d(uIUBtoA|Qr4$hpmFWJJ5& zZgeJS`KV^%8J?TCk2yZQAhA1Z(&Zx)Ma*+@-$V%u z0`rr?C5Aw8O!c;X;dUaD>Gb<<(53&HWyh35i8ivdYMo5~wej_Ta3iwAyX&z|Z)}#{ zVvatVPM&BW+Vk}0Xdqe_DSA)$g7)2?5Fc)kE6og{G`>Nltymi(U(!YieTYKwGbM&F zzDT%o0tOUNp|d#x!V;D#5_oV+VpGH}>2h@KX*oGIinRk8T5GR;+Hc>#d&NqAkt8#ZQb%-NV%80vbdzZiAbf}XF?Sb6Tf^xTwzyApT2@Gei~XilL#A4EaA zpipehJW(hu#a&kHT?r!p(N=HWPu9HkkUL|#@QIfM5}VCZx*7Bp4zFzbVHmqT)`sqC z(0joQ!Y_q^AEhL*`+k~9DYurFmo?F5yp<@ZWlHD;(N;>1a^LU7QJ3;NTS>xF(Wy|+ zlTXx&G_)44`e{y%X-}9llw#L=tzh-yDBWbAuYHg774gYH{3OkU4x7S(i4{?9#E)$F z>2F3VOtfb5WEvSx8n32F5N#;z|Ctzi(T1DtvYF~C{pk4j12Mb4+rx7*z#8^q#Kz4U z7BEx89t_*KRl{v8+PG8067I_5o;>c$_JbPQIIS@Gujwd^=*ywiJYIk9cj%_6==z<| zOT09QBUc*9=BY6Lm+Qn!6veL;_T`+@@@GlhQ~#EoKThUy z-K0;uv9oCv;%5qn^xfKlu191haBnB=V0Lr4*}2>XC!a*F7{&c>ZyFoZ{D}`ip~i1P z#>@YJGS{3spQDTxxUcxGwLe3(w^8_r;(NvgjPW^)qMV_`+95{!bfojKA9Y3w2QY51 z-Y~n&y{YyG6t_{DElo^LZNr+i+Kp1f+D7>|@G-)IG!?>*5^j=!La>m57y`oKAR1ca z6b^A!5DH9iRb;KyO%Z97G7qB(ifgs5S(W_PU^U6(8XO*?p!EXAL2_w zw|Y7X&Zgr*kevI2P?~%3&<`*CM9V!dTIrRZDD3vGf-sD|F?&PrJigIG4lj*@^fQ$; z+MOb8rOEtrv%UURl+@VXzb(JUhNVJtfmnJ{a{*kczb%-wK6||)YMg#NO@_fIExT~C zC%%$}yQso-QAWkZh6D!{h5Fh)6t)-se926~Ax*StuL(m4crt_|%cNnhfF8nS+~n;w zvtllFe?ooFU^PaEZD6Ct(-uzxCkWf9Gt)WjG75pmJSX@8;~NVGguu>^XiySz;Xw+e-rt3sNELhcm{F;|7`QOJWkgvfG5$o)bg z)~b;GLLqy_LabFG;_lHMLd0DWQl-?nH6gcVak~xiHrsIb#SCWmCujCoiFXb8J^8;WJmk&5qkoNMrN96H diff --git a/target/classes/hello/model/Value.class b/target/classes/hello/model/Value.class deleted file mode 100644 index 862a68f17d303c4c370907d46f8531959e3c75f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1111 zcmZ`%?@!ZU5PsgW{+dO`3QPrM0=jN6s=_yf50aReEE}@YKL1C#6$o%YBJ@^a$};#&e+Cd*F{P*H$}6^e`$gbMD^N$^I< za)bAT*PwcA&R9hWQMr)l?o*AAZEku$3vQwf|nt1$5ipn}LHC`MKULUE=6Va!M gtj;7-rMpO1H9K@9)jq+1Z0b|0o}wnfKoBzTWS> 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 diff --git a/target/classes/public/index.html b/target/classes/public/index.html deleted file mode 100644 index 566549b..0000000 --- a/target/classes/public/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - Title - - - - - \ No newline at end of file diff --git a/tools/discover/__pycache__/diff.cpython-310.pyc b/tools/discover/__pycache__/diff.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84880a42353fb67d0cd6e5fc4ecf98c59e051518 GIT binary patch literal 10363 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 YJ)KDR;Wv(7D*a6QbUK&bpT%AOfAn|c(EtDd literal 0 HcmV?d00001 diff --git a/tools/discover/__pycache__/discover.cpython-310.pyc b/tools/discover/__pycache__/discover.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bbd537fb9c82a79b0933420adda69e82eaee82dc GIT binary patch literal 14708 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 literal 0 HcmV?d00001 diff --git a/tools/discover/diff.py b/tools/discover/diff.py new file mode 100644 index 0000000..5172e40 --- /dev/null +++ b/tools/discover/diff.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Impact matrix: live infrastructure (dependency-graph.json) vs. what the code declares. + +Reads the checked-out repo (any branch/worktree via --repo) and compares the runtime +contract the code now expects with what the discovered infrastructure provides: + + health check path + port <- application.properties (management.*) vs. target group + admitted port <- server/management port vs. app security-group ingress + secret key name <- spring.datasource.password=${KEY} vs. Secrets Manager keys + base image / JDK <- Dockerfile ARG + pom.xml java.version vs. EC2 RuntimeImage tag + DB driver / dialect <- pom.xml postgresql version, javax/jakarta, ddl-auto vs. RDS engine + DNS <- DB host in the code vs. Route53 records + +Exit 1 when drift is found (use --no-fail to always exit 0). Markdown goes to stdout +and, with --out, to a file so it can be pasted into demo/PLANTED-GAPS.md. +""" +import argparse +import json +import os +import re +import sys + + +def read(path): + with open(path, encoding="utf-8") as f: + return f.read() + + +def props(text): + out = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip() + return out + + +def placeholder_key(value): + m = re.match(r"\$\{([A-Za-z0-9_.-]+)(?::[^}]*)?\}", value or "") + return m.group(1) if m else None + + +def declared(repo): + p = props(read(os.path.join(repo, "src/main/resources/application.properties"))) + pom = read(os.path.join(repo, "pom.xml")) + dockerfile = read(os.path.join(repo, "Dockerfile")) + entity_dir = os.path.join(repo, "src/main/java/hello/model") + entity_src = "".join(read(os.path.join(entity_dir, f)) for f in os.listdir(entity_dir) if f.endswith(".java")) + + server_port = int(p.get("server.port", "8080")) + mgmt_port = int(p.get("management.server.port", server_port)) + base_path = p.get("management.endpoints.web.base-path", "/actuator") + boot = re.search(r"spring-boot-starter-parent\s*([^<]+)", pom) + java = re.search(r"([^<]+)", pom) + pg = re.search(r"postgresql\s*(?:([^<]+))?", pom) + base_img = re.search(r"^ARG\s+BASE_IMAGE=(\S+)", dockerfile, re.M) + db_url = p.get("spring.datasource.url", "") + db_host = re.search(r"jdbc:postgresql://([^:/]+)", db_url) + return { + "spring_boot": boot.group(1) if boot else None, + "java_version": java.group(1) if java else None, + "persistence_api": "jakarta.persistence" if "jakarta.persistence" in entity_src else "javax.persistence", + "postgres_driver": (pg.group(1) if pg and pg.group(1) else "managed-by-boot") if pg else None, + "dockerfile_base_image": base_img.group(1) if base_img else None, + "server_port": server_port, + "management_port": mgmt_port, + "health_path": f"{base_path.rstrip('/')}/health/readiness", + "metrics_path": f"{base_path.rstrip('/')}/prometheus", + "db_password_key": placeholder_key(p.get("spring.datasource.password")), + "db_url_key": placeholder_key(db_url), + "db_host_default": db_host.group(1) if db_host else None, + "ddl_auto": p.get("spring.jpa.hibernate.ddl-auto"), + "hikari_max_pool": p.get("spring.datasource.hikari.maximum-pool-size", "10 (framework default)"), + "open_in_view": p.get("spring.jpa.open-in-view", "true (framework default)"), + "sql_logging": p.get("spring.jpa.show-sql", "false"), + } + + +def jdk_major(image): + m = re.search(r"temurin:(\d+)", image or "") + return int(m.group(1)) if m else None + + +def java_major(v): + if not v: + return None + v = v.strip() + return 8 if v in ("1.8", "8") else int(v) + + +def observed(graph): + nodes = {n["id"]: n for n in graph["nodes"]} + edges = graph["edges"] + obs = {"target_groups": [], "app_sg_ports": [], "secret_keys": {}, "ec2": [], "rds": [], "dns": []} + for n in nodes.values(): + a = n["attrs"] + if n["type"] == "target_group": + obs["target_groups"].append({"id": n["id"], "port": a.get("port"), "health_check_path": a.get("health_check_path"), + "health_check_port": a.get("health_check_port"), "matcher": a.get("matcher")}) + elif n["type"] == "secret": + obs["secret_keys"][n["id"]] = a.get("keys") or [] + elif n["type"] == "ec2_instance": + obs["ec2"].append({"id": n["id"], "runtime_image": a.get("runtime_image"), "host_port": a.get("host_port"), + "secret_key_read": a.get("secret_key_read"), "db_host": a.get("db_host")}) + elif n["type"] == "rds_instance": + obs["rds"].append({"id": n["id"], "engine": a.get("engine"), "engine_version": a.get("engine_version"), "port": a.get("port")}) + elif n["type"] == "dns_record": + obs["dns"].append(n["id"].split(":", 1)[1]) + # ports the ALB is allowed to reach on the instance(s) + for e in edges: + if e["type"] == "allows_ingress" and e["from"].startswith("sg:") and "alb" in e["from"] and e["attrs"].get("port"): + obs["app_sg_ports"].append(e["attrs"]["port"]) + health_states = [e["attrs"].get("state") for e in edges if e["type"] == "health_checks"] + obs["health_states"] = health_states + return obs + + +def matrix(dec, obs): + rows = [] # (component, infra expects, code declares, status, impact) + + def row(component, infra, code, ok, impact, gap=None): + rows.append({"component": component, "infra": infra, "code": code, "status": "OK" if ok else "DRIFT", "impact": impact, "gap": gap}) + + # 1. runtime / base image + for i in obs["ec2"]: + infra_jdk = jdk_major(i["runtime_image"]) + code_jdk = java_major(dec["java_version"]) + ok = infra_jdk is not None and code_jdk is not None and infra_jdk >= code_jdk + row(f"{i['id']} base image", f"{i['runtime_image']} (JDK {infra_jdk})", + f"java.version={dec['java_version']}, Dockerfile ARG BASE_IMAGE={dec['dockerfile_base_image']}, Boot {dec['spring_boot']}", + ok, "container exits with UnsupportedClassVersionError; target never healthy", gap=1) + + # 2. health check + for tg in obs["target_groups"]: + hc_port = tg["port"] if tg["health_check_port"] == "traffic-port" else int(tg["health_check_port"]) + ok = tg["health_check_path"] == dec["health_path"] and hc_port == dec["management_port"] + row(f"{tg['id']} health check", f"GET :{hc_port}{tg['health_check_path']} -> {tg['matcher']}", + f"GET :{dec['management_port']}{dec['health_path']}", ok, + "ALB marks target unhealthy (404/timeout); all traffic 503", gap=2) + + # 3. secret key name + for sid, keys in obs["secret_keys"].items(): + reader = next((i["secret_key_read"] for i in obs["ec2"]), None) + ok = dec["db_password_key"] in keys and (reader is None or reader == dec["db_password_key"]) + row(f"{sid} key", f"keys={keys}; host reads .{reader}", f"spring.datasource.password=${{{dec['db_password_key']}}}", ok, + "password resolves empty; Postgres rejects auth; app fails to start", gap=3) + + # 4. DB dependency + for db in obs["rds"]: + legacy_contract = dec["ddl_auto"] == "validate" + row(f"{db['id']} schema contract", f"{db['engine']} {db['engine_version']} owned by infra/db/init (ddl-auto=validate expected)", + f"ddl-auto={dec['ddl_auto']}, {dec['persistence_api']}, driver={dec['postgres_driver']}", legacy_contract, + "app mutates a schema shared with the reporting consumer (view/function break or NOT NULL violations)", gap=4) + + # 5. firewall / DNS + ports_needed = sorted({dec["server_port"], dec["management_port"]}) + for p in ports_needed: + ok = p in obs["app_sg_ports"] + row(f"app SG ingress :{p}", f"ALB -> app allowed on {sorted(set(obs['app_sg_ports']))}", f"app listens on {ports_needed}", ok, + "health/metrics port silently dropped by the security group", gap=5) + if dec["db_host_default"]: + ok = dec["db_host_default"] in obs["dns"] or any(i["db_host"] for i in obs["ec2"]) + row("DB DNS name", f"records={obs['dns']}", f"default DB host {dec['db_host_default']} (overridable by DB_URL)", ok, + "name does not resolve inside the VPC", gap=5) + + # 6. NFR-relevant defaults (not observable statically on infra; flagged for the load test) + nfr_ok = str(dec["hikari_max_pool"]).startswith("20") and str(dec["open_in_view"]).startswith("false") and dec["sql_logging"] == "false" + row("connection pool / logging", "baseline: pool=20, open-in-view=false, no SQL logging", + f"pool={dec['hikari_max_pool']}, open-in-view={dec['open_in_view']}, show-sql={dec['sql_logging']}", nfr_ok, + "latency/throughput regression under load only -> compare k6 vs demo/BASELINE.json", gap=6) + return rows + + +def render(rows, dec, graph): + scope = f"`Project={graph['project']}`" + (f", `Track={graph['track']}`" if graph.get("track") else "") + out = [f"### Impact matrix — live infra (`{graph['region']}`, {scope}) vs. code", + "", f"Code declares: Spring Boot `{dec['spring_boot']}`, Java `{dec['java_version']}`, `{dec['persistence_api']}`, " + f"health `:{dec['management_port']}{dec['health_path']}`, secret key `{dec['db_password_key']}`, ddl-auto `{dec['ddl_auto']}`.", + "", "| # | component | infra expects | code declares | status | impact if deployed as-is |", "|---|---|---|---|---|---|"] + for r in rows: + out.append(f"| {r['gap'] or ''} | {r['component']} | {r['infra']} | {r['code']} | **{r['status']}** | {r['impact']} |") + drift = [r for r in rows if r["status"] == "DRIFT"] + out += ["", f"**{len(drift)} drift item(s), {len(rows) - len(drift)} OK.**"] + return "\n".join(out) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--graph", default="demo/dependency-graph.json") + ap.add_argument("--repo", default=".") + ap.add_argument("--out") + ap.add_argument("--no-fail", action="store_true") + args = ap.parse_args() + graph = json.loads(read(args.graph)) + dec = declared(args.repo) + rows = matrix(dec, observed(graph)) + md = render(rows, dec, graph) + print(md) + if args.out: + with open(args.out, "w") as f: + f.write(md + "\n") + drift = any(r["status"] == "DRIFT" for r in rows) + sys.exit(1 if drift and not args.no_fail else 0) + + +if __name__ == "__main__": + main() diff --git a/tools/discover/discover.py b/tools/discover/discover.py new file mode 100644 index 0000000..e4a2b10 --- /dev/null +++ b/tools/discover/discover.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Agentless, tag-scoped discovery of the application dependency graph on AWS. + +Walks ELBv2, target groups, EC2, security groups, Route53, Secrets Manager, RDS and +ECR for every resource tagged Project= and emits: + + /dependency-graph.json nodes {id,type,arn,attrs} + edges {from,to,type,attrs} + /dependency-graph.md Mermaid rendering + /dependency-graph.dot Graphviz source + /dependency-graph.png rendered with `dot` when available + +Edge types: routes_to, health_checks, allows_ingress, resolves_to, reads_secret, +connects_to_db, runs_image. + +Only read-only Describe*/List*/Get* calls are made. Secret values are fetched solely to +enumerate their JSON key names (the contract) and are discarded, never written. +""" +import argparse +import base64 +import json +import os +import re +import shutil +import subprocess +import sys + +import boto3 + +PROJECT_TAG = "Project" +TRACK_TAG = "Track" + + +class Graph: + def __init__(self): + self.nodes = {} + self.edges = [] + + def node(self, nid, ntype, arn=None, **attrs): + n = self.nodes.setdefault(nid, {"id": nid, "type": ntype, "arn": arn, "attrs": {}}) + if arn: + n["arn"] = arn + n["attrs"].update({k: v for k, v in attrs.items() if v is not None}) + return n + + def edge(self, src, dst, etype, **attrs): + e = {"from": src, "to": dst, "type": etype, "attrs": {k: v for k, v in attrs.items() if v is not None}} + if e not in self.edges: + self.edges.append(e) + + +def tags(lst): + return {t["Key"]: t["Value"] for t in (lst or [])} + + +def has_project(tag_map, project, track=None): + return tag_map.get(PROJECT_TAG) == project and (track is None or tag_map.get(TRACK_TAG) == track) + + +def tag_filters(project, track): + f = [{"Name": f"tag:{PROJECT_TAG}", "Values": [project]}] + if track: + f.append({"Name": f"tag:{TRACK_TAG}", "Values": [track]}) + return f + + +def discover(region, project, track=None): + s = boto3.session.Session(region_name=region) + g = Graph() + + # ---- Security groups (the firewall) + ec2 = s.client("ec2") + sgs = ec2.describe_security_groups(Filters=tag_filters(project, track))["SecurityGroups"] + sg_by_id = {} + for sg in sgs: + nid = f"sg:{sg['GroupName']}" + sg_by_id[sg["GroupId"]] = nid + rules = [] + for p in sg["IpPermissions"]: + ports = "all" if p.get("IpProtocol") == "-1" else f"{p.get('FromPort')}-{p.get('ToPort')}" if p.get("FromPort") != p.get("ToPort") else str(p.get("FromPort")) + for r in p.get("IpRanges", []): + rules.append({"port": ports, "from": r["CidrIp"]}) + for r in p.get("UserIdGroupPairs", []): + rules.append({"port": ports, "from": r["GroupId"]}) + g.node(nid, "security_group", arn=f"arn:aws:ec2:{region}::security-group/{sg['GroupId']}", + group_id=sg["GroupId"], ingress=rules, description=sg.get("Description")) + for sg in sgs: + for p in sg["IpPermissions"]: + port = p.get("FromPort") + for r in p.get("UserIdGroupPairs", []): + if r["GroupId"] in sg_by_id: + g.edge(sg_by_id[r["GroupId"]], sg_by_id[sg["GroupId"]], "allows_ingress", port=port, protocol=p.get("IpProtocol")) + for r in p.get("IpRanges", []): + g.node(f"cidr:{r['CidrIp']}", "cidr") + g.edge(f"cidr:{r['CidrIp']}", sg_by_id[sg["GroupId"]], "allows_ingress", port=port, protocol=p.get("IpProtocol")) + + # ---- EC2 instances + insts = ec2.describe_instances(Filters=tag_filters(project, track) + + [{"Name": "instance-state-name", "Values": ["pending", "running", "stopping", "stopped"]}]) + inst_by_id = {} + for res in insts["Reservations"]: + for i in res["Instances"]: + t = tags(i.get("Tags")) + nid = f"ec2:{t.get('Name', i['InstanceId'])}" + inst_by_id[i["InstanceId"]] = nid + g.node(nid, "ec2_instance", arn=f"arn:aws:ec2:{region}::instance/{i['InstanceId']}", + instance_id=i["InstanceId"], instance_type=i["InstanceType"], private_ip=i.get("PrivateIpAddress"), + state=i["State"]["Name"], runtime_image=t.get("RuntimeImage"), app_image=t.get("AppImage")) + for sg in i.get("SecurityGroups", []): + if sg["GroupId"] in sg_by_id: + g.edge(sg_by_id[sg["GroupId"]], nid, "allows_ingress", scope="attached") + if t.get("AppImage"): + img = t["AppImage"] + g.node(f"image:{img.split('/')[-1]}", "container_image", uri=img, base_image=t.get("RuntimeImage")) + g.edge(nid, f"image:{img.split('/')[-1]}", "runs_image", base_image=t.get("RuntimeImage")) + # user-data is where the app's env contract lives (which secret key, which DB host) + try: + ud = ec2.describe_instance_attribute(InstanceId=i["InstanceId"], Attribute="userData")["UserData"].get("Value") + if ud: + text = base64.b64decode(ud).decode("utf-8", "replace") + key = None + for line in text.splitlines(): + if "DB_PASSWORD=" in line and "jq -r" in line: + mm = re.search(r"jq -r '\.(\w+)'", line) + key = mm.group(1) if mm else None + dbh = re.search(r"jdbc:postgresql://([^:/]+)", text) + ports = re.search(r"-p\s+(\d+):(\d+)", text) + g.nodes[nid]["attrs"].update({ + "secret_key_read": key, + "db_host": dbh.group(1) if dbh else None, + "host_port": int(ports.group(1)) if ports else None, + "container_port": int(ports.group(2)) if ports else None, + }) + except Exception as exc: # noqa: BLE001 + print(f"warn: user-data for {i['InstanceId']}: {exc}", file=sys.stderr) + + # ---- ELBv2 + target groups + elb = s.client("elbv2") + lbs = elb.describe_load_balancers()["LoadBalancers"] + lb_arns = [lb["LoadBalancerArn"] for lb in lbs] + lb_tags = {} + for i in range(0, len(lb_arns), 20): + for d in elb.describe_tags(ResourceArns=lb_arns[i:i + 20])["TagDescriptions"]: + lb_tags[d["ResourceArn"]] = tags(d["Tags"]) + tg_by_arn = {} + for lb in lbs: + if not has_project(lb_tags.get(lb["LoadBalancerArn"], {}), project, track): + continue + lb_id = f"alb:{lb['LoadBalancerName']}" + g.node(lb_id, "load_balancer", arn=lb["LoadBalancerArn"], dns_name=lb["DNSName"], scheme=lb["Scheme"], state=lb["State"]["Code"]) + for sgid in lb.get("SecurityGroups", []): + if sgid in sg_by_id: + g.edge(sg_by_id[sgid], lb_id, "allows_ingress", scope="attached") + for lst in elb.describe_listeners(LoadBalancerArn=lb["LoadBalancerArn"])["Listeners"]: + for a in lst["DefaultActions"]: + if a["Type"] != "forward": + continue + tg_arn = a["TargetGroupArn"] + tg = elb.describe_target_groups(TargetGroupArns=[tg_arn])["TargetGroups"][0] + tg_id = f"tg:{tg['TargetGroupName']}" + tg_by_arn[tg_arn] = tg_id + g.node(tg_id, "target_group", arn=tg_arn, port=tg["Port"], protocol=tg["Protocol"], + health_check_path=tg.get("HealthCheckPath"), health_check_port=tg.get("HealthCheckPort"), + matcher=tg.get("Matcher", {}).get("HttpCode")) + g.edge(lb_id, tg_id, "routes_to", listener_port=lst["Port"], protocol=lst["Protocol"]) + for th in elb.describe_target_health(TargetGroupArn=tg_arn)["TargetHealthDescriptions"]: + tid = th["Target"]["Id"] + if tid in inst_by_id: + g.edge(tg_id, inst_by_id[tid], "routes_to", port=th["Target"].get("Port")) + g.edge(tg_id, inst_by_id[tid], "health_checks", path=tg.get("HealthCheckPath"), + port=th["Target"].get("Port") if tg.get("HealthCheckPort") == "traffic-port" else tg.get("HealthCheckPort"), + state=th["TargetHealth"]["State"], reason=th["TargetHealth"].get("Reason")) + + # ---- RDS + rds = s.client("rds") + db_by_addr = {} + for db in rds.describe_db_instances()["DBInstances"]: + if not has_project(tags(db.get("TagList")), project, track): + continue + nid = f"rds:{db['DBInstanceIdentifier']}" + addr = db.get("Endpoint", {}).get("Address") + db_by_addr[addr] = nid + g.node(nid, "rds_instance", arn=db["DBInstanceArn"], engine=db["Engine"], engine_version=db["EngineVersion"], + endpoint=addr, port=db.get("Endpoint", {}).get("Port"), db_name=db.get("DBName"), status=db["DBInstanceStatus"], + instance_class=db["DBInstanceClass"]) + for sg in db.get("VpcSecurityGroups", []): + if sg["VpcSecurityGroupId"] in sg_by_id: + g.edge(sg_by_id[sg["VpcSecurityGroupId"]], nid, "allows_ingress", scope="attached") + + # ---- Secrets Manager (names + key names only; values are never fetched) + sm = s.client("secretsmanager") + for sec in sm.list_secrets(Filters=[{"Key": "tag-key", "Values": [PROJECT_TAG]}])["SecretList"]: + if not has_project(tags(sec.get("Tags")), project, track): + continue + nid = f"secret:{sec['Name']}" + keys = None + try: + # key names are part of the contract; values are discarded immediately + val = sm.get_secret_value(SecretId=sec["ARN"])["SecretString"] + keys = sorted(json.loads(val).keys()) + del val + except Exception: # noqa: BLE001 + keys = None + g.node(nid, "secret", arn=sec["ARN"], keys=keys) + for iid, inode in inst_by_id.items(): + g.edge(inode, nid, "reads_secret", key=g.nodes[inode]["attrs"].get("secret_key_read")) + + # ---- Route53 (private zones) + r53 = s.client("route53") + for z in r53.list_hosted_zones()["HostedZones"]: + ztags = tags(r53.list_tags_for_resource(ResourceType="hostedzone", ResourceId=z["Id"].split("/")[-1])["ResourceTagSet"]["Tags"]) + if not has_project(ztags, project, track): + continue + zid = f"zone:{z['Name'].rstrip('.')}" + g.node(zid, "hosted_zone", arn=f"arn:aws:route53:::hostedzone/{z['Id'].split('/')[-1]}", private=z["Config"]["PrivateZone"]) + for rr in r53.list_resource_record_sets(HostedZoneId=z["Id"])["ResourceRecordSets"]: + if rr["Type"] not in ("A", "CNAME"): + continue + name = rr["Name"].rstrip(".") + rid = f"dns:{name}" + target = rr["AliasTarget"]["DNSName"].rstrip(".") if "AliasTarget" in rr else rr["ResourceRecords"][0]["Value"] + g.node(rid, "dns_record", zone=zid, record_type=rr["Type"], target=target) + g.edge(zid, rid, "resolves_to", scope="contains") + for n in list(g.nodes.values()): + a = n["attrs"] + if n["type"] == "load_balancer" and a.get("dns_name", "").lower() == target.lower(): + g.edge(rid, n["id"], "resolves_to") + if n["type"] == "rds_instance" and a.get("endpoint") == target: + g.edge(rid, n["id"], "resolves_to") + if n["type"] == "ec2_instance" and a.get("private_ip") == target: + g.edge(rid, n["id"], "resolves_to") + + # ---- connects_to_db: instance user-data names a DB host; resolve through DNS + for iid, inode in inst_by_id.items(): + host = g.nodes[inode]["attrs"].get("db_host") + if not host: + continue + rid = f"dns:{host}" + if rid in g.nodes: + g.edge(inode, rid, "connects_to_db", host=host, port=5432) + for e in list(g.edges): + if e["from"] == rid and e["type"] == "resolves_to" and e["to"].startswith("rds:"): + g.edge(inode, e["to"], "connects_to_db", via=host, port=5432) + + # ---- ECR + ecr = s.client("ecr") + for repo in ecr.describe_repositories()["repositories"]: + rtags = tags(ecr.list_tags_for_resource(resourceArn=repo["repositoryArn"])["tags"]) + if not has_project(rtags, project, track): + continue + imgs = ecr.describe_images(repositoryName=repo["repositoryName"]).get("imageDetails", []) + for img in imgs: + for t in img.get("imageTags", []): + nid = f"image:{repo['repositoryName'].split('/')[-1]}:{t}" + g.node(nid, "container_image", arn=repo["repositoryArn"], uri=f"{repo['repositoryUri']}:{t}", + pushed_at=img["imagePushedAt"].isoformat(), digest=img["imageDigest"]) + return g + + +SHAPES = { + "load_balancer": ("ALB", "([{label}])"), "target_group": ("TG", "[/{label}/]"), "ec2_instance": ("EC2", "[{label}]"), + "security_group": ("SG", "{{{{{label}}}}}"), "rds_instance": ("RDS", "[({label})]"), "secret": ("Secret", "[[{label}]]"), + "hosted_zone": ("Zone", "(({label}))"), "dns_record": ("DNS", "({label})"), "container_image": ("Image", ">{label}]"), "cidr": ("CIDR", "({label})"), +} + + +def mermaid(g): + out = ["```mermaid", "graph LR"] + ids = {nid: f"n{i}" for i, nid in enumerate(g.nodes)} + for nid, n in g.nodes.items(): + kind, shape = SHAPES.get(n["type"], (n["type"], "[{label}]")) + label = f"{kind}
{nid.split(':', 1)[1]}".replace('"', "'") + quoted = '"' + label + '"' + out.append(f' {ids[nid]}{shape.format(label=quoted)}') + for e in g.edges: + if e["attrs"].get("scope") in ("attached", "contains"): + out.append(f" {ids[e['from']]} -.- {ids[e['to']]}") + continue + extra = ", ".join(f"{k}={v}" for k, v in e["attrs"].items() if k in ("port", "path", "key", "listener_port")) + out.append(f" {ids[e['from']]} -->|{e['type']}{(' ' + extra) if extra else ''}| {ids[e['to']]}") + out.append("```") + return "\n".join(out) + + +COLORS = {"load_balancer": "#f9d67a", "target_group": "#fbe7b3", "ec2_instance": "#a8d5ba", "security_group": "#f4a6a6", + "rds_instance": "#9ec5fe", "secret": "#d3b7f7", "hosted_zone": "#cfd8dc", "dns_record": "#e0e7ea", "container_image": "#c8e6c9", "cidr": "#eeeeee"} + + +def dot(g): + out = ["digraph deps {", ' rankdir=LR; node [shape=box, style="rounded,filled", fontname=Helvetica, fontsize=10]; edge [fontname=Helvetica, fontsize=9];'] + for nid, n in g.nodes.items(): + kind = SHAPES.get(n["type"], (n["type"],))[0] + a = n["attrs"] + detail = "" + if n["type"] == "target_group": + detail = f"\\n:{a.get('port')} hc={a.get('health_check_path')}" + elif n["type"] == "security_group": + detail = "".join(f"\\n{r['port']} from {r['from']}" for r in a.get("ingress", [])) + elif n["type"] == "ec2_instance": + detail = f"\\n{a.get('instance_type')} :{a.get('host_port')} base={a.get('runtime_image')}" + elif n["type"] == "secret": + detail = f"\\nkeys={a.get('keys')}" + elif n["type"] == "rds_instance": + detail = f"\\n{a.get('engine')} {a.get('engine_version')} :{a.get('port')}" + elif n["type"] == "dns_record": + detail = f"\\n{a.get('record_type')}" + label = f"{kind}: {nid.split(':', 1)[1]}{detail}".replace('"', "'") + out.append(f' "{nid}" [label="{label}", fillcolor="{COLORS.get(n["type"], "#ffffff")}"];') + for e in g.edges: + if e["attrs"].get("scope") in ("attached", "contains"): + out.append(f' "{e["from"]}" -> "{e["to"]}" [style=dashed, color=gray, arrowhead=none];') + continue + extra = " ".join(f"{k}={v}" for k, v in e["attrs"].items() if k in ("port", "path", "key", "listener_port", "state")) + out.append(f' "{e["from"]}" -> "{e["to"]}" [label="{e["type"]}{(chr(10) + extra) if extra else ""}"];') + out.append("}") + return "\n".join(out) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--region", default=os.environ.get("AWS_REGION", "us-east-1")) + ap.add_argument("--project", default="modernization-demo") + ap.add_argument("--track", default=None, help="also require Track= (java|dotnet)") + ap.add_argument("--out", default="demo") + args = ap.parse_args() + + g = discover(args.region, args.project, args.track) + os.makedirs(args.out, exist_ok=True) + graph = {"region": args.region, "project": args.project, "track": args.track, "nodes": list(g.nodes.values()), "edges": g.edges} + with open(os.path.join(args.out, "dependency-graph.json"), "w") as f: + json.dump(graph, f, indent=2, default=str) + md = ["# Application dependency graph", "", + f"Discovered agentlessly from AWS APIs in `{args.region}` for `Project={args.project}`{f', `Track={args.track}`' if args.track else ''} " + f"({len(g.nodes)} nodes, {len(g.edges)} edges). Regenerate with `make aws-discover`.", "", + "![dependency graph](dependency-graph.png)", "", mermaid(g), "", "## Edges", "", "| from | edge | to | attrs |", "|---|---|---|---|"] + for e in g.edges: + if e["attrs"].get("scope") in ("attached", "contains"): + continue + md.append(f"| `{e['from']}` | {e['type']} | `{e['to']}` | {json.dumps(e['attrs'], default=str)} |") + with open(os.path.join(args.out, "dependency-graph.md"), "w") as f: + f.write("\n".join(md) + "\n") + dot_path = os.path.join(args.out, "dependency-graph.dot") + with open(dot_path, "w") as f: + f.write(dot(g)) + if shutil.which("dot"): + subprocess.run(["dot", "-Tpng", dot_path, "-o", os.path.join(args.out, "dependency-graph.png")], check=True) + else: + print("warn: graphviz `dot` not found; PNG not rendered", file=sys.stderr) + print(f"{len(g.nodes)} nodes, {len(g.edges)} edges -> {args.out}/dependency-graph.{{json,md,dot,png}}") + + +if __name__ == "__main__": + main() diff --git a/tools/discover/requirements.txt b/tools/discover/requirements.txt new file mode 100644 index 0000000..aeffd82 --- /dev/null +++ b/tools/discover/requirements.txt @@ -0,0 +1 @@ +boto3>=1.35 From d936d8e81c25dce120b332096ce48f61939d80d8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:21:04 +0000 Subject: [PATCH 2/6] bug: terraform validate must not require a built jar (CI) Co-Authored-By: Ian Moritz --- infra/terraform/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf index 5a5a6c3..4ec25cf 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -21,7 +21,7 @@ resource "docker_image" "app" { } } triggers = { - jar = filesha256("${path.module}/../../target/app.jar") + jar = fileexists("${path.module}/../../target/app.jar") ? filesha256("${path.module}/../../target/app.jar") : "missing" } } From bbce6a2e989dc04887436504440b4d22eb0eaf4c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:27:07 +0000 Subject: [PATCH 3/6] bug: smoke waits for the first Prometheus scrape before asserting target up Co-Authored-By: Ian Moritz --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b49f6f7..adc8ed0 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,10 @@ smoke: health @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 ==" - @curl -fsS '$(PROM_URL)/api/v1/query?query=up%7Bjob%3D%22topics-api%22%7D' | grep -q '"value":\[[0-9.]*,"1"\]' + @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. From 49acf7af1b7ec6384ba6c14c97ff279d4778022f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:57:23 +0000 Subject: [PATCH 4/6] feature: diff.py flags datasource type and @Column overrides in the impact matrix Co-Authored-By: Ian Moritz --- tools/discover/diff.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tools/discover/diff.py b/tools/discover/diff.py index 5172e40..9402534 100644 --- a/tools/discover/diff.py +++ b/tools/discover/diff.py @@ -72,6 +72,8 @@ def declared(repo): "db_url_key": placeholder_key(db_url), "db_host_default": db_host.group(1) if db_host else None, "ddl_auto": p.get("spring.jpa.hibernate.ddl-auto"), + "column_overrides": re.findall(r'@Column\(\s*name\s*=\s*"([^"]+)"', entity_src), + "datasource_type": p.get("spring.datasource.type", "HikariDataSource (framework default)"), "hikari_max_pool": p.get("spring.datasource.hikari.maximum-pool-size", "10 (framework default)"), "open_in_view": p.get("spring.jpa.open-in-view", "true (framework default)"), "sql_logging": p.get("spring.jpa.show-sql", "false"), @@ -149,9 +151,10 @@ def row(component, infra, code, ok, impact, gap=None): # 4. DB dependency for db in obs["rds"]: - legacy_contract = dec["ddl_auto"] == "validate" + legacy_contract = dec["ddl_auto"] == "validate" and not dec["column_overrides"] + cols = f", @Column(name=) overrides={dec['column_overrides']}" if dec["column_overrides"] else "" row(f"{db['id']} schema contract", f"{db['engine']} {db['engine_version']} owned by infra/db/init (ddl-auto=validate expected)", - f"ddl-auto={dec['ddl_auto']}, {dec['persistence_api']}, driver={dec['postgres_driver']}", legacy_contract, + f"ddl-auto={dec['ddl_auto']}, {dec['persistence_api']}, driver={dec['postgres_driver']}{cols}", legacy_contract, "app mutates a schema shared with the reporting consumer (view/function break or NOT NULL violations)", gap=4) # 5. firewall / DNS @@ -166,9 +169,11 @@ def row(component, infra, code, ok, impact, gap=None): "name does not resolve inside the VPC", gap=5) # 6. NFR-relevant defaults (not observable statically on infra; flagged for the load test) - nfr_ok = str(dec["hikari_max_pool"]).startswith("20") and str(dec["open_in_view"]).startswith("false") and dec["sql_logging"] == "false" - row("connection pool / logging", "baseline: pool=20, open-in-view=false, no SQL logging", - f"pool={dec['hikari_max_pool']}, open-in-view={dec['open_in_view']}, show-sql={dec['sql_logging']}", nfr_ok, + pooled = "Hikari" in dec["datasource_type"] + nfr_ok = pooled and str(dec["hikari_max_pool"]).startswith("20") and str(dec["open_in_view"]).startswith("false") and dec["sql_logging"] == "false" + row("connection pool / logging", "baseline: Hikari pool=20, open-in-view=false, no SQL logging", + f"datasource={dec['datasource_type'].rsplit('.', 1)[-1]}, pool={dec['hikari_max_pool'] if pooled else 'none (connection per request)'}, " + f"open-in-view={dec['open_in_view']}, show-sql={dec['sql_logging']}", nfr_ok, "latency/throughput regression under load only -> compare k6 vs demo/BASELINE.json", gap=6) return rows From efc6936f994b8ec1b9e3a31870439f4a8c66e3da Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:57:51 +0000 Subject: [PATCH 5/6] feature: runbook act 3 cascade matches the observed migration failures Co-Authored-By: Ian Moritz --- demo/RUNBOOK.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/demo/RUNBOOK.md b/demo/RUNBOOK.md index 92e399c..fe7e681 100644 --- a/demo/RUNBOOK.md +++ b/demo/RUNBOOK.md @@ -86,13 +86,16 @@ on a schema shared with the reporting cron, SG missing 8081, pool/logging defaul 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. `make loadtest` + `make compare` against the legacy baseline: **POST /topic returns 500 — `null value in column "subject_description"`** (gap 4) - and **p95 regression** with throughput collapse (gap 6). Devin opens Grafana: Hikari pending connections and log events/s spike, pool is 10 not 20. -4. Fixes: revert schema mutation / restore column mapping; restore pool=20, `open-in-view=false`, SQL logging off. Re-run → `verify OK`, `compare` within tolerance. +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 panel pair -*Hikari active/pending* and *log events/s* jumping at the load-test start; (c) CI going red → green without a human touching it. +**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) From 8c4dfaa195a1a01c93b7a8b3ac29bf32bd7cadb5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:09:38 +0000 Subject: [PATCH 6/6] bug: aws-check-clean ignores stale Tagging API index entries for terminated/deleted EC2 resources Co-Authored-By: Ian Moritz --- Makefile | 4 +--- infra/scripts/aws_check_clean.sh | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) create mode 100755 infra/scripts/aws_check_clean.sh diff --git a/Makefile b/Makefile index adc8ed0..5d51f2d 100644 --- a/Makefile +++ b/Makefile @@ -161,6 +161,4 @@ aws-down: aws-init $(MAKE) --no-print-directory aws-check-clean aws-check-clean: - @echo "== tagged resources remaining (Project=modernization-demo, Track=$(TRACK)) ==" - @aws resourcegroupstaggingapi get-resources --region $(AWS_REGION) \ - --tag-filters Key=Project,Values=modernization-demo Key=Track,Values=$(TRACK) --query 'ResourceTagMappingList[].ResourceARN' --output json | tee /dev/stderr | grep -q '^\[\]$$' && echo "clean: 0 tagged resources remain" + infra/scripts/aws_check_clean.sh $(AWS_REGION) modernization-demo $(TRACK) 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=. The Resource Groups Tagging API +# index is eventually consistent: terminated instances, deleted volumes and deleted SG rules stay +# in it for up to an hour, so each ARN is re-checked against the owning service before it counts. +set -euo pipefail +REGION=${1:?region} PROJECT=${2:?project} TRACK=${3:?track} + +echo "== tagged resources remaining (Project=$PROJECT, Track=$TRACK, region=$REGION) ==" +mapfile -t ARNS < <(aws resourcegroupstaggingapi get-resources --region "$REGION" \ + --tag-filters "Key=Project,Values=$PROJECT" "Key=Track,Values=$TRACK" \ + --query 'ResourceTagMappingList[].ResourceARN' --output text | tr '\t' '\n' | sed '/^$/d') + +live=0 +for arn in "${ARNS[@]:-}"; do + [ -z "$arn" ] && continue + id=${arn##*/} + case "$arn" in + *:ec2:*:instance/*) + state=$(aws ec2 describe-instances --region "$REGION" --instance-ids "$id" \ + --query 'Reservations[].Instances[].State.Name' --output text 2>/dev/null || echo gone) + [ "$state" = terminated ] || [ "$state" = gone ] && { echo "stale index entry (instance $state): $arn"; continue; } ;; + *:ec2:*:volume/*) + aws ec2 describe-volumes --region "$REGION" --volume-ids "$id" >/dev/null 2>&1 || { echo "stale index entry (volume deleted): $arn"; continue; } ;; + *:ec2:*:security-group-rule/*) + aws ec2 describe-security-group-rules --region "$REGION" --security-group-rule-ids "$id" >/dev/null 2>&1 || { echo "stale index entry (rule deleted): $arn"; continue; } ;; + esac + echo "LIVE: $arn" + live=$((live + 1)) +done + +if [ "$live" -eq 0 ]; then + echo "clean: 0 tagged resources remain" +else + echo "$live tagged resource(s) still exist" + exit 1 +fi