The simple Java VM->OpenShift migration path: Containerfile + Helm + postgres sidecar
Find a file
anaeem fab3489d23 aap: make the build-Job wait survive transient API blips
The 'Wait for the build Job' k8s_info until-conditional crashed ('dict object has
no attribute resources') when a poll hit a transient cluster-API timeout. Guard
with resources | default([]) so a blip is treated as not-done-yet and retried
instead of failing the build. Applied to build.yml + build-capture.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:51:20 +01:00
a2c A2C capture: generalize to WildFly/SpringBoot + IP+SSH-driven scan fan-out 2026-06-23 14:27:29 +01:00
aap aap: make the build-Job wait survive transient API blips 2026-06-23 17:51:20 +01:00
app Handle app system packages: 'packages:' knob → build.sh → apt install in the generic Containerfiles 2026-06-19 12:16:07 +01:00
apps A2C capture: generalize to WildFly/SpringBoot + IP+SSH-driven scan fan-out 2026-06-23 14:27:29 +01:00
containerfiles A2C capture: generalize to WildFly/SpringBoot + IP+SSH-driven scan fan-out 2026-06-23 14:27:29 +01:00
docs A2C capture: generalize to WildFly/SpringBoot + IP+SSH-driven scan fan-out 2026-06-23 14:27:29 +01:00
tasks Flow VM data into the image: overlay (conf + driver jars), auto-packages, javaOpts 2026-06-19 12:45:14 +01:00
templates Flow VM data into the image: overlay (conf + driver jars), auto-packages, javaOpts 2026-06-19 12:45:14 +01:00
.gitignore A2C capture: generalize to WildFly/SpringBoot + IP+SSH-driven scan fan-out 2026-06-23 14:27:29 +01:00
build.sh Flow VM data into the image: overlay (conf + driver jars), auto-packages, javaOpts 2026-06-19 12:45:14 +01:00
discover.yml Flow VM data into the image: overlay (conf + driver jars), auto-packages, javaOpts 2026-06-19 12:45:14 +01:00
README.md docs: bring README/walkthrough/aap up to date with the AAP buildah build path 2026-06-22 14:38:23 +01:00

simple/ — migrate a Java app to OpenShift in one helm install

This is the dumb-proof path. No LLM, no fact schema, no operators to babysit. The idea fits in three sentences:

  1. The app is built from source by a generic, per-runtime Containerfile. build.sh picks the right one from containerfiles/ (springboot / tomcat9 / wildfly) based on the app's detected runtime, builds the source (Maven → small runtime stage), and pushes the image — you write no Containerfile. The cluster just pulls it. Plain, readable, portable — no S2I builder images. (Build it locally with build.sh, or from the AAP simple: build app JT — which runs the same recipe as an in-cluster buildah Job, no laptop podman.)
  2. The database travels with the app. A postgres:16 sidecar runs in the same pod on localhost:5432, seeded from your seed.sql. hostAliases point every database hostname your app hard-codes at the sidecar — so your app runs unmodified, with the exact config it had on the VM. (It is a dev/demo DB: ephemeral emptyDir, trust auth — see Known limitations.)
  3. One small Helm chart (app/) turns a short values file into all of that: Deployment (app + db sidecar), Service, Route. It pulls the prebuilt image — it does not build.
simple/
  app/                     the ONE Helm chart (read app/templates/*)
  apps/<name>/
    values.yaml            describes one app (image, port, health, DB hostAliases)
    seed.sql               roles + database + the app's schema/data
  containerfiles/          3 generic per-runtime Containerfiles (springboot/tomcat9/wildfly)
  build.sh                 build + push an app's image (picks the Containerfile by runtime)
  discover.yml             optional: thin entry that scans a VM and writes a values.yaml
  tasks/discover.yml       the actual detection logic discover.yml includes
  templates/values.yaml.j2 the values.yaml discover writes
  aap/                     run build + deploy (and discover) from AAP — see aap/README.md
  README.md                this file

You do not write a Containerfile: the three in containerfiles/ are generic per runtime (not per app), and build.sh selects the right one from the app's runtime:. They build from the app's source — point build.sh at a checkout.

Documentation

This README is the quickstart. For the full picture:

  • docs/walkthrough.md — start here: the whole solution step by step, with diagrams, for someone new to it.
  • docs/architecture.md — the design (Containerfile → registry → helm-pull + the DB-as-sidecar/hostAliases model) and why.
  • docs/end-to-end.md — the full VM → OpenShift walkthrough (discover → containerize → deploy) with real commands and troubleshooting.
  • docs/reference.md — file-by-file map + the per-app values.yaml/seed.sql contract + the Containerfile shapes.
  • docs/limitations.md — honest maturity: what's automated vs by-hand, known constraints, and the roadmap.
  • aap/README.md — running build + deploy (and discover) from AAP.

Prerequisites

  • The app's image built and pushed to a registry. Two ways: ./build.sh <name> <path-to-app-source> locally (needs podman + a registry login), or the AAP simple: build app JT, which runs the same recipe as an in-cluster buildah Job (no laptop podman). Both pick the generic Containerfile by the app's runtime and push the image. See aap/README.md.
  • A namespace (simple-apps) with a registry pull Secret named oci-arsalan-pull.
  • helm and oc on your laptop, logged in.
export KUBECONFIG=~/.kube/anaeem-kubeconfig   # the anaeem SNO target
oc new-project simple-apps 2>/dev/null || oc project simple-apps

Deploy an app

Two equivalent ways — by hand, or from AAP.

By hand (helm upgrade --install):

cd simple
helm upgrade --install pricing-svc ./app -n simple-apps \
  -f apps/pricing-svc/values.yaml \
  --set-file database.seedSql=apps/pricing-svc/seed.sql

From AAP: launch the simple: deploy app Job Template and pick the app. It runs aap/deploy.yml, which is exactly the same helm upgrade --install (with --wait). See aap/README.md for the AAP setup.

That is the whole command (the image was already built + pushed — by build.sh or the AAP simple: build app JT). What happens next:

  1. The Deployment pulls the prebuilt image and starts the app container.
  2. The db sidecar starts alongside it and seeds itself from seed.sql.
  3. The Route serves the app.

Watch it:

oc get pods  -n simple-apps -w            # app pod -> 2/2 Running
oc get route pricing-svc -n simple-apps   # the URL

Verify (pricing-svc):

URL=https://$(oc get route pricing-svc -n simple-apps -o jsonpath='{.spec.host}')
curl -sk $URL/actuator/health      # {"status":"UP",...,"db":{"status":"UP"}}
curl -sk "$URL/api/price?sku=ELEC-LAPT-01&basePrice=1000"   # computed price from seeded rules

The four sample apps

App Runtime (runtime:) What it exercises
pricing-svc springboot the simplest case: REST CRUD, /actuator/health, datasource on localhost
classic-shop tomcat9 two WARs on one Tomcat, JNDI datasource baked into context.xml, file uploads
report-factory tomcat9 three datasources in one sidecar, a Quartz schema seeded before boot
ledger-ejb wildfly Jakarta EE 10: EJB/JPA + a JMS MDB; WildFly is provisioned inside the build

Each is the same helm install with a different apps/<name>/. They build with the generic per-runtime Containerfiles in containerfiles/ (multi-stage):

  • pricing-svcmaven:…-17 build → eclipse-temurin:17-jre, runs the fat jar.
  • classic-shop / report-factorymaven:…-11 build → tomcat:9-jdk11-temurin, drops the WAR(s) into Tomcat (with a chgrp 0 so Tomcat's work/temp dirs are writable under OpenShift's arbitrary UID). These two compile on Java 11.
  • ledger-ejbmaven:…-17 build where the wildfly-maven-plugin provisions a trimmed WildFly under target/server (with the LedgerDS datasource + LedgerEvents queue baked in) → eclipse-temurin:17-jre runs that server. The one app that carries app-server-specific build config.

What "runs unmodified" did and didn't mean

The app source runs as-is — its baked-in datasource config, hostnames, and credentials are untouched (the sidecar + hostAliases make them resolve). The only adjustments are in the Containerfiles/poms above (build-tooling fits) and two env overrides for pricing-svc (a writable log path and open-in-view for one list endpoint) in apps/pricing-svc/values.yaml — the realistic edges of a migration.

How a new app gets a values.yaml

Either hand-write apps/<name>/values.yaml (copy an existing one — they are short), or run discover.yml against the VM the app runs on and it will write one for you:

ansible-playbook discover.yml -i <vm-host>, -e target_vm=<vm-host> -e app_name=<name>

discover.yml is a thin entry point (assert the args, then include_tasks: tasks/discover.yml). tasks/discover.yml runs read-only over SSH with become (the app-server dirs are 0700) — it reads the running java command line and the jdbc:postgresql://… URLs, and (for Tomcat) tars conf/ + the driver jars into an overlay that the build copies into the image — then derives:

  • runtimewildfly if the process is JBoss/WildFly, springboot for an executable .jar that isn't Catalina, otherwise tomcat9.
  • hostAliases — the unique non-localhost DB hostnames pulled from those JDBC URLs (these become the hostAliases that pin the app's DB hostnames at the sidecar).
  • health path/actuator/health for Spring Boot, else a guessed /<app_name>/health.

It renders templates/values.yaml.j2 into apps/<name>/values.yaml: a deploy-ready file with a flat image: ref (a placeholder oci.arsalan.io/migrated-apps/<app_name>:latest), plus runtime, port, health, route, database.hostAliases, and (when found) javaOpts + packages. You still add the app's apps/<name>/seed.sql and fix anything the heuristics got wrong (see below).

Known limitations / what you still do by hand

This is a demo/tutorial path. discover.yml gets you a starter values.yaml, not a finished migration. Be honest about the edges:

  • The health path is a guess. For non-Spring-Boot apps it emits /<app_name>/health, which is wrong whenever the WAR context path differs from the app name (e.g. classic-shop is served at /storefront, so its real health path is /storefront/health). Eyeball and correct health.path after discover.
  • Discover does not find the source repo or trigger the build. It writes a placeholder flat image: ref and stops. Building is a separate step — run ./build.sh <app> <source-dir> locally (podman) or launch the AAP simple: build app JT (in-cluster buildah); either uses the generic per-runtime Containerfile and pushes oci.arsalan.io/migrated-apps/<app>. You still tell it where the source is, and point image: at what you push.
  • The postgres sidecar is a dev/demo datastore, not production. It uses an ephemeral emptyDir (data is lost when the pod restarts) and POSTGRES_HOST_AUTH_METHOD=trust (no password). For production you would point hostAliases at a real external database instead of enabling the sidecar.
  • PostgreSQL only. Discover only greps for jdbc:postgresql:// URLs and the sidecar is postgres:16. Other databases are not detected or carried.
  • Only three runtimes are detected: springboot, tomcat9, wildfly. Anything else falls through to tomcat9 and will likely be wrong.

So: discover is a good first pass on a real VM, but treat the values.yaml it writes as a draft to review — don't expect to point it at any VM and deploy the output untouched.

Why this is "simple"

It deletes the things that made the original collection hard to read: the Containerfile renderer, the LLM repair loop, the large generated chart, the fact-schema engine, and all the imperative DB provisioning. What's left is one chart you can read in ten minutes, a values file per app you can read in one, and a thin discover playbook.

The full migration.discovery collection (the separate ansible-collection-discovery repo) remains for the deep, do-everything case.