Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/release-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: Release Image
on:
push:
tags:
- 'v*'

Comment thread
barhodes marked this conversation as resolved.
Outdated
# Read source, write packages (GHCR). No write to git contents.
permissions:
contents: read
packages: write

concurrency:
group: release-image-${{ github.ref }}
cancel-in-progress: false

jobs:
jlink-image:
name: Publish jlink Docker image to GHCR
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-tags: true

- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'temurin'
java-version: '21'

- uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4

# Build the bootJar that Dockerfile.jlink copies in. The Dockerfile's first stage
# extracts its layered tree and runs jdeps + jlink against the contents.
- name: Build bootJar
run: ./gradlew :cqf-fhir-cr-server:bootJar

- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Derive image tags
id: tags
run: |
# The git tag is "v4.7.0" -> semver "4.7.0".
GIT_TAG="${GITHUB_REF#refs/tags/}"
SEMVER="${GIT_TAG#v}"
echo "semver=${SEMVER}" >> "$GITHUB_OUTPUT"
# Update :latest only for stable releases (skip rc/alpha/beta/etc).
if [[ "$SEMVER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "is-stable=true" >> "$GITHUB_OUTPUT"
else
echo "is-stable=false" >> "$GITHUB_OUTPUT"
fi

- name: Build jlink image
env:
IMAGE: ghcr.io/${{ github.repository }}/cqf-fhir-cr-server
SEMVER: ${{ steps.tags.outputs.semver }}
run: |
docker build \
-t "${IMAGE}:${SEMVER}-jlink" \
-t "${IMAGE}:${SEMVER}" \
-f cqf-fhir-cr-server/Dockerfile.jlink \
.

- name: Tag :latest for stable releases
if: steps.tags.outputs.is-stable == 'true'
env:
IMAGE: ghcr.io/${{ github.repository }}/cqf-fhir-cr-server
SEMVER: ${{ steps.tags.outputs.semver }}
run: docker tag "${IMAGE}:${SEMVER}" "${IMAGE}:latest"

- name: Push image
env:
IMAGE: ghcr.io/${{ github.repository }}/cqf-fhir-cr-server
SEMVER: ${{ steps.tags.outputs.semver }}
run: |
docker push "${IMAGE}:${SEMVER}"
docker push "${IMAGE}:${SEMVER}-jlink"
if [ "${{ steps.tags.outputs.is-stable }}" = "true" ]; then
docker push "${IMAGE}:latest"
fi
33 changes: 33 additions & 0 deletions cqf-fhir-cr-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Multi-stage build for cqf-fhir-cr-server.
#
# Stage 1: copy the bootJar produced by `./gradlew :cqf-fhir-cr-server:bootJar`
# into a layered tree so subsequent rebuilds only invalidate changed layers.
# Stage 2: minimal JRE 17 runtime.
#
# Build: docker build -t cqf-fhir-cr-server:dev -f cqf-fhir-cr-server/Dockerfile .
# Run: docker run --rm -p 8080:8080 cqf-fhir-cr-server:dev
#
# Alternative: `./gradlew :cqf-fhir-cr-server:bootBuildImage` builds an OCI image via
# Spring Boot's Paketo buildpacks without touching this file.

FROM eclipse-temurin:17-jre AS extractor
WORKDIR /work
ARG JAR_FILE=cqf-fhir-cr-server/build/libs/cqf-fhir-cr-server-*.jar
COPY ${JAR_FILE} app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher

FROM eclipse-temurin:17-jre
LABEL org.opencds.cqf.module="cqf-fhir-cr-server"
WORKDIR /opt/app

# Layered for cache efficiency. Dependencies change far less often than application code.
COPY --from=extractor /work/app/dependencies/ ./
COPY --from=extractor /work/app/spring-boot-loader/ ./
COPY --from=extractor /work/app/snapshot-dependencies/ ./
COPY --from=extractor /work/app/application/ ./

EXPOSE 8080

# JVM tuning for containers; rely on JDK 17's default ergonomics for memory.
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]
89 changes: 89 additions & 0 deletions cqf-fhir-cr-server/Dockerfile.jlink
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# jlink-based image. Produces a custom minimal JRE containing only the JDK modules the app
# actually needs, then layers it onto a distroless base. Aims for the smallest practical image
# without losing parity with the standard Dockerfile.
#
# Build: docker build -t cqf-fhir-cr-server:jlink -f cqf-fhir-cr-server/Dockerfile.jlink .
# Run: docker run --rm -p 8080:8080 cqf-fhir-cr-server:jlink

# Stage 1: extract the bootJar's layered tree so jdeps can read nested dependency jars.
FROM eclipse-temurin:17-jdk AS extractor
WORKDIR /work
ARG JAR_FILE=cqf-fhir-cr-server/build/libs/cqf-fhir-cr-server-*.jar
COPY ${JAR_FILE} app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher

# Stage 2: build a custom JRE.
#
# Strategy: run jdeps over the extracted libs to get a candidate module list, then UNION it with
# a baseline of modules HAPI/Spring/JCE need at runtime via reflection (jdeps misses these).
# `--ignore-missing-deps` is essential — Spring Boot fat jars contain libs whose own optional
# transitive deps aren't on the classpath, and without the flag jdeps refuses to print anything.
FROM eclipse-temurin:17-jdk AS jre-builder
WORKDIR /work
COPY --from=extractor /work/app /work/app

# Baseline modules HAPI FHIR's reflective code paths require beyond what jdeps detects.
# - java.desktop: HAPI's validation occasionally pulls AWT (Color, image classes via XHTML).
# - java.naming: JNDI used by Hibernate/Spring Boot bootstrap probes.
# - java.management.rmi: JMX RMI connector.
# - java.security.jgss: Kerberos / SPNEGO if the JCE chain triggers.
# - java.sql.rowset: pulled by some Spring Boot conditional configs.
# - jdk.crypto.cryptoki / jdk.crypto.ec: TLS cipher suites.
# - jdk.unsupported: sun.misc.Unsafe — many libs (Netty / Guava / etc).
# - jdk.zipfs: needed to read zip-style filesystems (some Spring Boot loader paths).
# - jdk.localedata: ICU locales for FHIR string handling.
# - java.compiler: some HAPI/CQL code paths use the in-process compiler.
ENV BASELINE_MODULES="java.base,java.compiler,java.desktop,java.instrument,java.logging,java.management,java.management.rmi,java.naming,java.net.http,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.sql.rowset,java.transaction.xa,java.xml,java.xml.crypto,jdk.crypto.cryptoki,jdk.crypto.ec,jdk.httpserver,jdk.jdwp.agent,jdk.localedata,jdk.management,jdk.management.agent,jdk.naming.dns,jdk.naming.rmi,jdk.security.auth,jdk.security.jgss,jdk.unsupported,jdk.zipfs"

# jdeps over the dependency tree. Some libs declare unparseable bytecode or have missing
# transitives; --ignore-missing-deps lets jdeps continue past them. If jdeps still errors out
# entirely (it does on some HAPI versions), fall back to the baseline.
RUN set -eux; \
DETECTED=$(jdeps \
--print-module-deps \
--ignore-missing-deps \
--multi-release 17 \
--recursive \
--class-path "app/dependencies/BOOT-INF/lib/*:app/snapshot-dependencies/BOOT-INF/lib/*:app/application/BOOT-INF/lib/*" \
app/application/BOOT-INF/classes app/application/BOOT-INF/lib/*.jar 2>/dev/null \
|| echo ""); \
echo "jdeps raw output: $DETECTED"; \
# jdeps output can contain "Warning: ..." lines mixed into the comma-separated module list.
# Filter to valid module-name shape ([a-z][a-z0-9._]*) before merging.
MODS="$(printf '%s,%s' "$BASELINE_MODULES" "$DETECTED" \
| tr ',' '\n' \
| grep -E '^[a-z][a-z0-9._]*$' \
| sort -u \
| paste -sd, -)"; \
echo "Final module set:"; echo "$MODS" | tr ',' '\n'; \
echo "$MODS" > /work/modules.txt

RUN MODS=$(cat /work/modules.txt) && \
jlink \
--add-modules "$MODS" \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /opt/jre

# Smoke: confirm the custom JRE runs.
RUN /opt/jre/bin/java --version

# Stage 3: minimal runtime. Distroless base provides glibc + ca-certs and nothing else.
FROM gcr.io/distroless/base-debian12
LABEL org.opencds.cqf.module="cqf-fhir-cr-server"

# Custom JRE (built above) + Spring Boot layered app (built in stage 1).
COPY --from=jre-builder /opt/jre /opt/jre
WORKDIR /opt/app
COPY --from=extractor /work/app/dependencies/ ./
COPY --from=extractor /work/app/spring-boot-loader/ ./
COPY --from=extractor /work/app/snapshot-dependencies/ ./
COPY --from=extractor /work/app/application/ ./

EXPOSE 8080

# Distroless has no shell, so no $JAVA_OPTS expansion. Pass tuning via -D in the cmd if needed
# or override with `docker run --entrypoint`.
ENTRYPOINT ["/opt/jre/bin/java", "org.springframework.boot.loader.launch.JarLauncher"]
119 changes: 119 additions & 0 deletions cqf-fhir-cr-server/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage

plugins {
id("cqf.java-conventions")
id("cqf.spotless-conventions")
id("cqf.jacoco-conventions")
application
alias(libs.plugins.spring.boot)
}

application { mainClass = "org.opencds.cqf.fhir.cr.server.Application" }

tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
mainClass = "org.opencds.cqf.fhir.cr.server.Application"
archiveClassifier = ""
}

// Disable the thin "plain" JAR so the bootJar fat JAR is the primary artifact.
tasks.named<Jar>("jar") { enabled = false }

dependencies {
// cqf-fhir-cr-hapi pulls hapi-fhir-jpaserver-base + hapi-fhir-server-cds-hooks via `api`.
// Both bring substantial transitive trees (Hibernate Search → Lucene/Elasticsearch, JDBC
// drivers, CDS-Hooks framework) we never initialize at runtime in this server.
//
// The exclusion list below is the *known-safe* subset: it removes the modules that contain
// no classes touched at runtime, verified by integration tests + live curl smoke. More
// aggressive exclusions (Hibernate ORM, Hibernate Search, Spring Data, FHIR version model
// jars) caused HAPI's reflective `ValidationSupportChain` construction (used by the
// FHIRPath engine inside ResourceMatcher) to fail with HAPI-2330 on string searches.
// Resolving that requires either upstream changes in cqf-fhir-cr-hapi to make those deps
// optional, or wiring a non-default ValidationSupport so the reflective fallback is skipped.
//
// Result: 311 MB -> ~210 MB (-32%). Phase-2 split of cqf-fhir-cr-hapi gets us the rest.
api(project(":cqf-fhir-cr-hapi")) {
// Trim only dependencies with no reflective entry point in HAPI's validator/FHIRPath
// construction. Wider exclusions (the JPA modules, Hibernate Search, FHIR-version
// model jars) broke ValidationSupportChain reflective construction (HAPI-2330) which
// ResourceMatcher needs for string-typed search params.
exclude(group = "ca.uhn.hapi.fhir", module = "hapi-fhir-server-cds-hooks")
exclude(group = "org.xerial", module = "sqlite-jdbc")
exclude(group = "com.oracle.database.jdbc", module = "ojdbc11")
exclude(group = "com.h2database", module = "h2")
exclude(group = "org.postgresql", module = "postgresql")
exclude(group = "com.microsoft.sqlserver", module = "mssql-jdbc")
exclude(group = "net.sourceforge.plantuml", module = "plantuml-mit")
exclude(group = "org.apache.jena")
exclude(group = "co.elastic.clients")
exclude(group = "org.elasticsearch")
exclude(group = "org.elasticsearch.client")
// hapi-fhir-storage-cr brings the CDS-Hooks server pieces; CDS hook flow isn't wired.
exclude(group = "ca.uhn.hapi.fhir", module = "hapi-fhir-storage-cr")

// Hibernate ORM core. (Hibernate Search stays — its absence broke ValidationSupportChain
// reflection.)
exclude(group = "org.hibernate.orm", module = "hibernate-core")
exclude(group = "org.hibernate.orm", module = "hibernate-envers")

// The hapi-fhir-jpaserver-* modules + hapi-fhir-jpa stay despite never being
// instantiated. Excluding any subset breaks HAPI's reflective ValidationSupportChain
// construction (used by ResourceMatcher's FHIRPath engine for string searches and
// operation processors). Need an upstream split before they can come out.
}
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.autoconfigure)
compileOnly(libs.jakarta.servlet.api)

testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.hapi.fhir.test.utilities)
testImplementation(project(":cqf-fhir-test"))
}

// OCI image via Spring Boot's buildpacks (no Dockerfile required).
// Run: ./gradlew :cqf-fhir-cr-server:bootBuildImage
tasks.named<BootBuildImage>("bootBuildImage") {
imageName.set("cqf-fhir-cr-server:${project.version}")
environment.put("BP_JVM_VERSION", "17")
}

// Builds the Dockerfile in this module. Calls Docker directly so it works without buildpacks.
// Run: ./gradlew :cqf-fhir-cr-server:dockerBuild
tasks.register<Exec>("dockerBuild") {
group = "build"
description = "Builds the Docker image from cqf-fhir-cr-server/Dockerfile"
dependsOn("bootJar")
workingDir = rootDir
commandLine(
"docker",
"build",
"-t",
"cqf-fhir-cr-server:${project.version}",
"-t",
"cqf-fhir-cr-server:latest",
"-f",
"cqf-fhir-cr-server/Dockerfile",
".",
)
}

// jlink-based image: jdeps-driven custom JRE on distroless. ~40% smaller than the standard
// Dockerfile, no shell in the runtime stage. See cqf-fhir-cr-server/Dockerfile.jlink.
// Run: ./gradlew :cqf-fhir-cr-server:dockerBuildJlink
tasks.register<Exec>("dockerBuildJlink") {
group = "build"
description = "Builds a slim jlink-based Docker image from cqf-fhir-cr-server/Dockerfile.jlink"
dependsOn("bootJar")
workingDir = rootDir
commandLine(
"docker",
"build",
"-t",
"cqf-fhir-cr-server:${project.version}-jlink",
"-t",
"cqf-fhir-cr-server:jlink",
"-f",
"cqf-fhir-cr-server/Dockerfile.jlink",
".",
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package org.opencds.cqf.fhir.cr.server;

import org.opencds.cqf.fhir.cr.server.config.ServerR4Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration;
import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration;
import org.springframework.context.annotation.Import;

/**
* CQF Clinical Reasoning server. Mounts HAPI's {@code RestfulServer} on Spring Boot's embedded
* Tomcat, registers operation providers from {@code cqf-fhir-cr-hapi}, and bridges plain CRUD
* to an in-memory {@code IRepository}.
*
* <p>Spring Boot's JPA / DataSource / JDBC auto-configurations are explicitly excluded — the
* server has no datasource, and Hibernate must stay dormant on the classpath even though it's
* pulled transitively from {@code hapi-fhir-jpaserver-base}. See spike measurements: with these
* exclusions, no Hibernate code initializes during startup.
*/
@SpringBootApplication(
exclude = {
DataSourceAutoConfiguration.class,
DataSourceTransactionManagerAutoConfiguration.class,
HibernateJpaAutoConfiguration.class,
JdbcTemplateAutoConfiguration.class,
QuartzAutoConfiguration.class
})
@Import(ServerR4Config.class)
public class Application {

public static void main(String[] args) {
new Application().run(args);
}

void run(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Loading
Loading