From 2962fe5c7476d0f9644eca4024ca3f8296e0d5da Mon Sep 17 00:00:00 2001 From: Said Boudjelda Date: Sat, 17 Oct 2026 19:19:20 +0200 Subject: [PATCH] fix(native): the native image registers the API keys file's YAML model for reflection, so it starts with a named keys file Jackson reads kates.api.keys-file into ApiKeyFile.FileModel and EntryModel by reflection, and nothing registered either class for the native image. Deploying the 1.25.1-native image with KATES_API_KEYS_FILE set, which is how MCP agent keys are issued, made readValue throw, so ApiKeys.onStart refused the file as "not YAML of the expected shape" and the pod crash-looped. The JVM image was unaffected, so no JVM test could show it. Both classes now carry @RegisterForReflection, which registers their package-private constructor and fields. The native smoke test never reached this path: it boots with KATES_API_SECURITY_ENABLED=false, and with security off the keys file is never read. After its existing checks it now boots the image again with security on and a keys file of one human read key, written 0644 so the image's non-root user can read it, and checks that GET /api/tests answers 200 with that key and 401 without one. The docker run flags and the liveness wait became start_app and wait_live, shared by both boots. The key is hashed inside the Postgres container, so the host still needs only docker and curl. kates/docs/native-image.md describes the second boot, and says that NativeReflectionRegistryTest scans only public classes in the payload packages, so a package-private model that Jackson reads carries @RegisterForReflection itself. --- kates/docs/native-image.md | 12 ++- .../bmscomp/kates/security/ApiKeyFile.java | 5 +- scripts/native-smoke-test.sh | 86 ++++++++++++++----- 3 files changed, 78 insertions(+), 25 deletions(-) diff --git a/kates/docs/native-image.md b/kates/docs/native-image.md index 82db95d4..ff451ba0 100644 --- a/kates/docs/native-image.md +++ b/kates/docs/native-image.md @@ -95,9 +95,11 @@ IMAGE=kates:native-ci make kates-native-smoke # or any other tag `scripts/native-smoke-test.sh` boots the binary against a throwaway Postgres and calls the paths where AOT breakage shows up: liveness, readiness (which proves Flyway ran, including the non-transactional V20), OpenAPI, the playbook catalog -(classpath YAML), and a domain payload (reflection). CI runs the same script on -every PR that touches `kates/**`, plus nightly — see -`.github/workflows/native.yml`. +(classpath YAML), and a domain payload (reflection). It then boots the binary +again with API security on and a one-key keys file, and checks that +`GET /api/tests` answers 200 with that key and 401 without one, because the keys +file's YAML model needs reflection too. CI runs the same script on every PR that +touches `kates/**`, plus nightly — see `.github/workflows/native.yml`. ## Keeping reflection registration honest @@ -110,6 +112,10 @@ When it fails, add the class to `NativePayloadReflectionConfig`. If the class genuinely never leaves the process, add it to that test's `NOT_SERIALIZED` set with a one-line reason instead. +The test scans only public classes in the payload packages, so a package-private +model that Jackson reads, such as `ApiKeyFile.FileModel`, carries +`@RegisterForReflection` itself. + Registration is needed because almost every endpoint here returns `Response.ok(...)`. Quarkus registers types it can see in a resource method's signature; an opaque `Response` tells it nothing. diff --git a/kates/src/main/java/com/bmscomp/kates/security/ApiKeyFile.java b/kates/src/main/java/com/bmscomp/kates/security/ApiKeyFile.java index 220395dd..ff1ba838 100644 --- a/kates/src/main/java/com/bmscomp/kates/security/ApiKeyFile.java +++ b/kates/src/main/java/com/bmscomp/kates/security/ApiKeyFile.java @@ -14,6 +14,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.quarkus.runtime.annotations.RegisterForReflection; /** * The named API keys file ({@code kates.api.keys-file}): one entry per key, @@ -64,11 +65,13 @@ static final class InvalidKeysFile extends Exception { } } - // Read by Jackson. + // Read by Jackson, so the native image registers them. + @RegisterForReflection static final class FileModel { public List keys; } + @RegisterForReflection static final class EntryModel { public String id; public String name; diff --git a/scripts/native-smoke-test.sh b/scripts/native-smoke-test.sh index a62ca531..3ade2e54 100755 --- a/scripts/native-smoke-test.sh +++ b/scripts/native-smoke-test.sh @@ -21,6 +21,13 @@ # /api/tests a domain payload actually serialises, rather than coming # back as {} because nothing registered it for reflection # +# Then it boots the binary again with API security on and a keys file of one +# named key, the way a deployment issues MCP agent keys: +# +# /api/tests 200 with that key and 401 without it: the binary read +# the keys file (its YAML model needs reflection too) +# and authenticates with it +# # Usage: # scripts/native-smoke-test.sh [image] default: kates:native-local set -euo pipefail @@ -40,6 +47,7 @@ require_cmd curl cleanup() { docker rm -f "${APP}" "${DB}" >/dev/null 2>&1 || true + rm -f "${KEYS_FILE:-}" docker network rm "${NET}" >/dev/null 2>&1 || true } trap cleanup EXIT @@ -144,29 +152,37 @@ step "🚀 Starting the native binary..." # warning, because SmallRye constructs those clients from a StartupEvent # observer. Say PLAINTEXT, and the channels fail to connect asynchronously the # way this test already assumes they will. -docker run -d --name "${APP}" --network "${NET}" \ - -p "${APP_PORT}:8080" \ - -e QUARKUS_DATASOURCE_JDBC_URL="jdbc:postgresql://${DB}:5432/kates" \ - -e QUARKUS_DATASOURCE_USERNAME=kates \ - -e QUARKUS_DATASOURCE_PASSWORD=kates \ - -e KATES_API_SECURITY_ENABLED=false \ - -e KATES_KAFKA_BOOTSTRAP_SERVERS=localhost:9092 \ - -e KATES_HEALTH_KAFKA_REFRESH_INTERVAL=off \ - -e KATES_KAFKA_SECURITY_PROTOCOL=PLAINTEXT \ - "${IMAGE}" >/dev/null +# Both boots take these, plus what each passes in "$@". +start_app() { + docker run -d --name "${APP}" --network "${NET}" \ + -p "${APP_PORT}:8080" \ + -e QUARKUS_DATASOURCE_JDBC_URL="jdbc:postgresql://${DB}:5432/kates" \ + -e QUARKUS_DATASOURCE_USERNAME=kates \ + -e QUARKUS_DATASOURCE_PASSWORD=kates \ + -e KATES_KAFKA_BOOTSTRAP_SERVERS=localhost:9092 \ + -e KATES_HEALTH_KAFKA_REFRESH_INTERVAL=off \ + -e KATES_KAFKA_SECURITY_PROTOCOL=PLAINTEXT \ + "$@" \ + "${IMAGE}" >/dev/null +} BASE="http://localhost:${APP_PORT}" -step "⏳ Waiting for liveness (up to ${BOOT_TIMEOUT}s)..." -for _ in $(seq 1 "${BOOT_TIMEOUT}"); do - if curl -sf "${BASE}/q/health/live" >/dev/null 2>&1; then - break - fi - docker inspect -f '{{.State.Running}}' "${APP}" 2>/dev/null | grep -q true \ - || fail "the container exited during startup" - sleep 1 -done -curl -sf "${BASE}/q/health/live" >/dev/null 2>&1 || fail "never became live within ${BOOT_TIMEOUT}s" -info " ok /q/health/live" +wait_live() { + step "⏳ Waiting for liveness (up to ${BOOT_TIMEOUT}s)..." + for _ in $(seq 1 "${BOOT_TIMEOUT}"); do + if curl -sf "${BASE}/q/health/live" >/dev/null 2>&1; then + break + fi + docker inspect -f '{{.State.Running}}' "${APP}" 2>/dev/null | grep -q true \ + || fail "the container exited during startup" + sleep 1 + done + curl -sf "${BASE}/q/health/live" >/dev/null 2>&1 || fail "never became live within ${BOOT_TIMEOUT}s" + info " ok /q/health/live" +} + +start_app -e KATES_API_SECURITY_ENABLED=false +wait_live # Readiness gates on the database, so this passing means Flyway ran every # migration — including the non-transactional V20. @@ -225,4 +241,32 @@ echo "${NOT_FOUND}" | grep -q '"message"' \ Response was: ${NOT_FOUND}" info " ok error bodies serialise" +# Every check above ran with API security off, and with it off the keys file is +# never read. A keys file whose YAML model is not registered for reflection +# parses on the JVM and throws in the native image, which stops the start. +step "🔐 Restarting the native binary with API security on and a keys file..." +docker rm -f "${APP}" >/dev/null +KEY="kates_smoke001_native-smoke-only-$$" +# Hashed in the Postgres container, so the host needs no sha256sum (macOS has none). +KEY_SHA256="$(printf %s "${KEY}" | docker exec -i "${DB}" sha256sum | cut -d' ' -f1)" +KEYS_FILE="$(mktemp)" +cat >"${KEYS_FILE}" <