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