Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 9 additions & 3 deletions kates/docs/native-image.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<EntryModel> keys;
}

@RegisterForReflection
static final class EntryModel {
public String id;
public String name;
Expand Down
86 changes: 65 additions & 21 deletions scripts/native-smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}" <<EOF
keys:
- id: smoke001
name: native-smoke
type: human
scopes: [read]
sha256: ${KEY_SHA256}
EOF
# mktemp makes the file 0600, and the image runs as its own non-root user.
chmod 0644 "${KEYS_FILE}"
start_app -e KATES_API_KEYS_FILE=/etc/kates/keys.yaml -v "${KEYS_FILE}:/etc/kates/keys.yaml:ro"
wait_live

WITH_KEY="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${KEY}" "${BASE}/api/tests" || true)"
[ "${WITH_KEY}" = "200" ] || fail "GET /api/tests with the named key answered ${WITH_KEY}, not 200"
WITHOUT_KEY="$(curl -s -o /dev/null -w '%{http_code}' "${BASE}/api/tests" || true)"
[ "${WITHOUT_KEY}" = "401" ] || fail "GET /api/tests without a key answered ${WITHOUT_KEY}, not 401"
info " ok /api/tests (200 with the named key, 401 without one)"

info "✅ Native image smoke test passed: ${IMAGE}"
Loading