diff --git a/build.gradle b/build.gradle
index 23bd03b..deb65ac 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,12 +1,16 @@
plugins {
id 'java'
- id 'org.springframework.boot' version '2.7.18'
- id 'io.spring.dependency-management' version '1.0.15.RELEASE'
+ id 'org.springframework.boot' version '3.3.5'
+ id 'io.spring.dependency-management' version '1.1.6'
}
group = 'org.springframework'
version = '0.1.0'
-sourceCompatibility = '11'
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(21)
+ }
+}
repositories {
mavenCentral()
diff --git a/demo/PLANTED-GAPS.md b/demo/PLANTED-GAPS.md
new file mode 100644
index 0000000..386e078
--- /dev/null
+++ b/demo/PLANTED-GAPS.md
@@ -0,0 +1,103 @@
+# Planted gaps — `plugin/migration`
+
+This branch is what a code-only transform plugin produces for Java 11/Boot 2.7 → **Java 21/Boot 3.3.5**: `javax`→`jakarta`, new
+property names, Boot 3 actuator defaults, a "modernised" datasource. **It compiles and all 9 unit tests pass** (`./mvnw test`), yet
+`make verify` fails at the first layer and keeps failing as each layer is peeled back. Nothing outside `pom.xml`, `build.gradle`,
+`Topic.java` and `application.properties` was touched — that is the point. **Do not fix these on this branch**; Devin fixes them in the
+Execute/Verify act (on the infra PR, not here). Reset: `demo/RESET.md`.
+
+| # | gap (maps to the six capability gaps) | where the plugin changed code | what still assumes the legacy contract |
+|---|---|---|---|
+| 1 | Runtime pin drifts (infra discovery) | `pom.xml` `java.version=21`, Boot 3.3.5 → JDK 21 class files (61.0) | `Dockerfile` `ARG BASE_IMAGE=eclipse-temurin:11-jre`, `infra/terraform/variables.tf` `runtime_image`, `infra/aws/variables.tf` `runtime_image`, `infra/.env` |
+| 2 | Health path/port mismatch (infra discovery) | `management.server.port=8081`, base path `/actuator` (Boot 3 default; legacy was `/manage` on :8080) | `infra/nginx/conf.d/topics.conf` `/health → :8080/manage/health/readiness`, `docker-compose.yml` healthcheck, AWS target group `/manage/health/readiness`, Prometheus scrape `/manage/prometheus` |
+| 3 | Secret key renamed (secrets) | `spring.datasource.password=${TOPICS_DB_PASSWORD}` (`TOPICS_DB_URL`, `TOPICS_DB_USER`) | `infra/secrets/.env.example` + `app.env` (`DB_PASSWORD`), compose `DB_URL`/`DB_USER`, Secrets Manager JSON key `DB_PASSWORD`, `user-data.sh.tftpl` `.DB_PASSWORD`, `reporting/report.sh` |
+| 4 | DB dependency breaks (database) | `@Column(name = "description")` on `subjectDescription`; `ddl-auto=update` (was `validate`) | `infra/db/init/01-schema.sql` column `subject_description NOT NULL`; view `topic_summary_v` + `topic_report()` and the reporting cron read `subject_description` and pin `topics.id`'s type |
+| 5 | Firewall/DNS missing for the new port (infra discovery) | actuator moved to **:8081** | `infra/firewall/rules.conf` allows only `tcp 8080`; nginx `allowlist` + `deny /manage/`; no route for `/actuator`; `infra/dns/hosts`/Route53 have no metrics name; AWS app SG allows ALB→:8080 only |
+| 6 | Load-only NFR regression (NFR + observability) | `spring.datasource.type=DriverManagerDataSource` (no pool), Hikari `maximum-pool-size=20/minimum-idle=5` dropped, `open-in-view` back to default `true`, `spring.jpa.show-sql=true` + bind TRACE | `demo/BASELINE.json` (Hikari pool 20, p95 1.7 ms, 2139 rps); Grafana *Hikari active/pending* panel; `compare_nfr.py` tolerances |
+
+## Impact matrix (Assess act artifact)
+
+Produced by `python tools/discover/diff.py --graph demo/dependency-graph.json --repo . --no-fail` on this branch against the live
+`Track=java` AWS footprint captured in `demo/dependency-graph.json`:
+
+### Impact matrix — live infra (`us-east-1`, `Project=modernization-demo`, `Track=java`) vs. code
+
+Code declares: Spring Boot `3.3.5`, Java `21`, `jakarta.persistence`, health `:8081/actuator/health/readiness`, secret key `TOPICS_DB_PASSWORD`, ddl-auto `update`.
+
+| # | component | infra expects | code declares | status | impact if deployed as-is |
+|---|---|---|---|---|---|
+| 1 | ec2:moddemo-topics-app base image | eclipse-temurin:11-jre (JDK 11) | java.version=21, Dockerfile ARG BASE_IMAGE=eclipse-temurin:11-jre, Boot 3.3.5 | **DRIFT** | container exits with UnsupportedClassVersionError; target never healthy |
+| 2 | tg:moddemo-topics-tg health check | GET :8080/manage/health/readiness -> 200 | GET :8081/actuator/health/readiness | **DRIFT** | ALB marks target unhealthy (404/timeout); all traffic 503 |
+| 3 | secret:moddemo-topics/db key | keys=['DB_PASSWORD', 'DB_USER']; host reads .DB_PASSWORD | spring.datasource.password=${TOPICS_DB_PASSWORD} | **DRIFT** | password resolves empty; Postgres rejects auth; app fails to start |
+| 4 | rds:moddemo-topics-db schema contract | postgres 16.13 owned by infra/db/init (ddl-auto=validate expected) | ddl-auto=update, jakarta.persistence, driver=managed-by-boot, @Column(name=) overrides=['description'] | **DRIFT** | app mutates a schema shared with the reporting consumer (view/function break or NOT NULL violations) |
+| 5 | app SG ingress :8080 | ALB -> app allowed on [8080] | app listens on [8080, 8081] | **OK** | health/metrics port silently dropped by the security group |
+| 5 | app SG ingress :8081 | ALB -> app allowed on [8080] | app listens on [8080, 8081] | **DRIFT** | health/metrics port silently dropped by the security group |
+| 5 | DB DNS name | records=['app.topics.demo.internal', 'db.topics.demo.internal', 'lb.topics.demo.internal'] | default DB host db.topics.internal (overridable by DB_URL) | **OK** | name does not resolve inside the VPC |
+| 6 | connection pool / logging | baseline: Hikari pool=20, open-in-view=false, no SQL logging | datasource=DriverManagerDataSource, pool=none (connection per request), open-in-view=true (framework default), show-sql=true | **DRIFT** | latency/throughput regression under load only -> compare k6 vs demo/BASELINE.json |
+
+**6 drift item(s), 2 OK.** (Same command on the legacy branch: `0 drift item(s), 7 OK.`)
+
+## Observed failure cascade (`make verify` on this branch, JDK 21 build)
+
+Each step below was reproduced by temporarily unblocking only the previous layer (image override, then a `TOPICS_DB_PASSWORD` line in
+`infra/secrets/app.env`, then the column mapping) so the next one surfaces. None of those workarounds are committed.
+
+**Layer 1 — `make up` fails, `make verify` never reaches the LB (gap 1):**
+```
+waiting for load balancer http://localhost:8088/health ...
+load balancer never became healthy
+app-1 | Error: LinkageError occurred while loading main class org.springframework.boot.loader.launch.JarLauncher
+app-1 | java.lang.UnsupportedClassVersionError: org/springframework/boot/loader/launch/JarLauncher has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 55.0
+make: *** [Makefile:31: up] Error 1
+```
+`make verify` alone (stack down): `curl: (7) Failed to connect to localhost port 8088 … make: *** [Makefile:47: health] Error 7`.
+
+**Layer 2 — with `RUNTIME_IMAGE=eclipse-temurin:21-jre` (gap 3):**
+```
+app-1 | ERROR … o.h.engine.jdbc.spi.SqlExceptionHelper : FATAL: password authentication failed for user "topics"
+app-1 | Caused by: org.hibernate.service.spi.ServiceException: Unable to create requested service [org.hibernate.engine.jdbc.env.spi.JdbcEnvironment] due to: Unable to determine Dialect without JDBC metadata
+load balancer never became healthy
+```
+
+**Layer 3 — with the secret also provided under the new key (gaps 2, 5, 4):**
+```
+$ curl -s -o /dev/null -w '%{http_code}' localhost:8088/health -> 404 # nginx proxies to :8080/manage/…, actuator is on :8081/actuator
+nginx-1 | upstream: "http://172.28.0.20:8080/manage/health/readiness" … 404
+$ (from prometheus container) wget http://app.topics.internal:8081/actuator/health/readiness
+wget: download timed out # firewall sidecar allows only tcp 8080
+$ curl -s localhost:8088/topic
+{"status":500,"error":"Internal Server Error","path":"/topic"}
+app-1 | Hibernate: alter table if exists topics alter column id set data type varchar(255)
+app-1 | org.postgresql.util.PSQLException: ERROR: cannot alter type of a column used by a view or rule
+app-1 | Detail: rule _RETURN on view topic_summary_v depends on column "id"
+app-1 | ERROR: column t1_0.description does not exist
+app-1 | … JDBC exception executing SQL [select t1_0.id,t1_0.description,t1_0.subject_name from topics t1_0]
+```
+`make verify` stops at `== nginx health check == … HTTP 404 … Error 22`; Prometheus target `topics-api` is `down`
+(scrape `:8080/manage/prometheus` → 404).
+
+**Layer 4 — with the column mapping also restored, `make loadtest && make compare` (gap 6):**
+```
+NFR comparison FAILED against baseline
+metric baseline candidate verdict
+p50 ms 0.82 16.52
+p95 ms 1.7 32.5 REGRESSION
+p99 ms 2.45 48.24
+rps 2138.88 744.77 REGRESSION
+error rate 0 0 ok
+report p95 ms 1.5 21.38
+k6 threshold http_req_duration: p(95)<250: pass
+k6 threshold http_req_failed: rate<0.01: pass
+make: *** [Makefile:84: compare] Error 1
+```
+Grafana: *Hikari active/pending* and *Hikari max* panels go blank (no `hikaricp_*` series without Hikari); *log events/s* spikes with
+`show-sql` + bind TRACE. The k6 thresholds still pass — only the baseline comparison catches it.
+
+## Expected Devin findings (per gap)
+
+1. Image/Terraform pin JDK 11 while the artifact is JDK 21 → bump `runtime_image`/`BASE_IMAGE` to `eclipse-temurin:21-jre` in compose, `infra/terraform`, `infra/aws`.
+2. Health contract moved to `:8081/actuator/health/readiness` → update nginx `/health`, compose healthcheck, AWS target group, Prometheus scrape path/port (or pin `management.endpoints.web.base-path=/manage`, port 8080, and record that as a decision).
+3. `TOPICS_DB_*` keys → update `.env.example`/`app.env`, compose env, Secrets Manager key + `user-data` reader (or map old→new in the deployment, never both).
+4. `ddl-auto=update` + renamed column vs. view/function/reporting cron → restore `validate` and the `subject_description` mapping; flag the schema as shared with a second consumer.
+5. `:8081` unreachable → firewall `rules.conf`, nginx allowlist/route, app SG rule, Prometheus target, DNS name for metrics.
+6. `DriverManagerDataSource` / pool + logging defaults → restore Hikari (pool 20, `open-in-view=false`, SQL logging off); prove with `make compare` back within tolerance and the Hikari panels repopulated.
diff --git a/pom.xml b/pom.xml
index 0f629da..63e4d4c 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,12 +11,12 @@
org.springframework.boot
spring-boot-starter-parent
- 2.7.18
+ 3.3.5
- 11
+ 21
diff --git a/src/main/java/hello/model/Topic.java b/src/main/java/hello/model/Topic.java
index 2b70378..6c87554 100644
--- a/src/main/java/hello/model/Topic.java
+++ b/src/main/java/hello/model/Topic.java
@@ -1,9 +1,9 @@
package hello.model;
-import javax.persistence.Column;
-import javax.persistence.Entity;
-import javax.persistence.Id;
-import javax.persistence.Table;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
@Entity
@Table(name = "topics")
@@ -14,7 +14,7 @@ public class Topic {
@Column(nullable = false)
private String subjectName;
- @Column(nullable = false)
+ @Column(name = "description", nullable = false)
private String subjectDescription;
public Topic() {
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 7257eba..c5df714 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -1,29 +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
+# Datasource (Postgres). Spring Boot 3 conventions: TOPICS_DB_* env keys.
+spring.datasource.url=${TOPICS_DB_URL:jdbc:postgresql://db.topics.internal:5432/topics}
+spring.datasource.username=${TOPICS_DB_USER:topics}
+spring.datasource.password=${TOPICS_DB_PASSWORD}
+# Plain JDBC datasource; pooling is handled by the platform (PgBouncer) in the target environment.
+spring.datasource.type=org.springframework.jdbc.datasource.DriverManagerDataSource
-# 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
+# Hibernate 6: let the ORM keep the schema in sync with the entity model.
+spring.jpa.hibernate.ddl-auto=update
+spring.jpa.show-sql=true
-# Actuator: legacy contract consumed by nginx health check + Prometheus scrape.
-management.endpoints.web.base-path=/manage
+# Actuator on its own management port (Boot 3 default base path /actuator).
+management.server.port=8081
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}
+management.observations.key-values.application=${DD_SERVICE:topics-api}
+management.observations.key-values.env=${DD_ENV:migrated}
+management.observations.key-values.version=${DD_VERSION:0.2.0}
logging.level.root=INFO
+logging.level.org.hibernate.SQL=DEBUG
+logging.level.org.hibernate.orm.jdbc.bind=TRACE
server.tomcat.mbeanregistry.enabled=true