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}" <