#!/usr/bin/env bash # keycloak 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가 # `env TAG=... PLATFORM=... bash verify.sh` 로 호출한다). # # 마지막 줄에 VERIFY-OK 를 출력하면 통과다. # # 이 이미지에 요구하는 것 # 1. kc.sh 가 의존하는 셸 도구(sed·grep·readlink·dirname·uname)와 java 21 # — bci-micro 에는 sed 가 없어 RUNTIME_PACKAGES 로 명시 설치한다 # 2. 로케일(en_US.UTF-8)·타임존(custom-values.yaml 이 TZ=Asia/Seoul 을 넣는다) # 3. 오버레이한 jar 가 **내용상** 새 버전일 것 # — overlay-jars.sh 가 파일명을 유지하므로 파일명으로는 확인할 수 없다. # trivy 가 보는 것과 같은 근거(jar 내부 META-INF/maven/**/pom.properties)로 확인한다. # 4. bin/client 제거 확인 (overlay-jars.sh 가 지운다) # 5. 실제 기동 — realm 이 만들어지고 admin 토큰이 발급될 것 # (게이트 0건이어도 못 쓰는 이미지는 무의미하다 — .claude/image-authoring.md 5번) set -e TAG="${TAG:?TAG 환경변수가 필요하다}" PLATFORM="${PLATFORM:-linux/amd64}" KEYCLOAK_VERSION="${KEYCLOAK_VERSION:?build.env 에서 전달돼야 한다}" NETTY_OLD="${NETTY_OLD:?}" ; NETTY_VERSION="${NETTY_VERSION:?}" JACKSON_OLD="${JACKSON_OLD:?}" ; JACKSON_VERSION="${JACKSON_VERSION:?}" PGJDBC_OLD="${PGJDBC_OLD:?}" ; PGJDBC_VERSION="${PGJDBC_VERSION:?}" WORK="$(mktemp -d)" CID="" CCID="" cleanup() { [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true [ -n "$CCID" ] && docker rm -f "$CCID" >/dev/null 2>&1 || true rm -rf "$WORK" } trap cleanup EXIT LIBDIR=/opt/keycloak/lib/lib/main # ------------------------------------------------------------------ 1~2, 4단계 docker run --rm -i --platform "$PLATFORM" -e TZ=Asia/Seoul \ -e NETTY_OLD="$NETTY_OLD" --entrypoint sh "$TAG" <<'GUEST' set -e LIB=/opt/keycloak/lib/lib/main echo "== kc.sh 가 쓰는 셸 도구 ==" for b in sh bash sed grep readlink dirname uname java; do p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; } echo " $b -> $p" done echo "== java 21 ==" VER="$(java -version 2>&1 | head -1)" echo " $VER" case "$VER" in *'"21'*) ;; *) echo "FAIL: java 21 이 아니다"; exit 1 ;; esac echo "== 로케일 / 타임존 ==" CHARMAP="$(locale charmap 2>/dev/null || echo '?')" echo " LANG=$LANG charmap=$CHARMAP" [ "$CHARMAP" = "UTF-8" ] || { echo "FAIL: en_US.UTF-8 로케일이 없다 (glibc-locale-base 확인)"; exit 1; } TZNAME="$(date +%Z)" echo " TZ=Asia/Seoul -> $TZNAME" [ "$TZNAME" = "KST" ] || { echo "FAIL: tzdata 가 Asia/Seoul 을 모른다 (got: $TZNAME)"; exit 1; } echo "== bin/client 제거 확인 ==" # jackson 을 shade 로 품은 uber-jar 라 jar 교체로 못 고쳐 통째로 제거했다. if [ -e /opt/keycloak/bin/client ]; then echo "FAIL: bin/client 가 남아 있다 — overlay-jars.sh 의 제거 단계가 동작하지 않았다"; exit 1 fi echo " 없음 (정상)" echo "== 배포본 레이아웃 ==" [ -d "$LIB" ] || { echo "FAIL: $LIB 없음"; exit 1; } n=0; for f in "$LIB"/io.netty.*-"$NETTY_OLD"*.jar; do [ -e "$f" ] && n=$((n+1)); done echo " netty jar 파일 ${n}개 (파일명은 유지되는 것이 정상 — 내용 검증은 호스트에서)" [ "$n" -gt 0 ] || { echo "FAIL: netty jar 가 없다"; exit 1; } GUEST # ------------------------------------------------------------------ 3단계 # 파일명을 유지하는 설계라 파일명으로는 교체 여부를 알 수 없다. jar 를 호스트로 꺼내 # 내부 pom.properties 를 읽는다 — trivy 가 버전을 판정하는 것과 같은 근거다. # (게스트에 unzip 을 넣지 않기 위해 호스트 python3 로 검사한다. build-hardened-image.sh # 가 이미 python3 를 전제한다.) echo "== 오버레이 jar 내용 검증 (jar 내부 메타데이터) ==" CCID="$(docker create --platform "$PLATFORM" "$TAG")" check_jar() { # $1=컨테이너 내 파일명 $2=기대 버전 local name="$1" want="$2" got docker cp "$CCID:$LIBDIR/$name" "$WORK/$name" >/dev/null got="$(python3 - "$WORK/$name" <<'PY' import sys, zipfile, re # 1순위: META-INF/maven/**/pom.properties (netty·jackson 등 대부분) # 2순위: MANIFEST.MF 의 Bundle-Version / Implementation-Version # (pgjdbc 는 pom.properties 를 넣지 않고 OSGi Bundle-Version 만 쓴다 — 실측) with zipfile.ZipFile(sys.argv[1]) as z: for n in z.namelist(): if re.fullmatch(r'META-INF/maven/[^/]+/[^/]+/pom\.properties', n): for line in z.read(n).decode().splitlines(): if line.startswith('version='): print(line.split('=', 1)[1].strip()); sys.exit(0) try: mf = z.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace') except KeyError: print('NOT-FOUND'); sys.exit(0) # MANIFEST 는 72바이트에서 줄바꿈 후 다음 줄을 한 칸 들여쓰기로 이어붙인다 mf = mf.replace('\r\n', '\n').replace('\n ', '') for key in ('Bundle-Version', 'Implementation-Version'): m = re.search(rf'^{key}:\s*(\S+)\s*$', mf, re.M) if m: print(m.group(1)); sys.exit(0) print('NOT-FOUND') PY )" if [ "$got" != "$want" ]; then echo "FAIL: $name 의 내부 버전이 '$got' 다 (기대 '$want') — 오버레이가 적용되지 않았다" exit 1 fi echo " $name -> 내장 버전=$got" } check_jar "io.netty.netty-codec-http-${NETTY_OLD}.jar" "$NETTY_VERSION" check_jar "io.netty.netty-codec-${NETTY_OLD}.jar" "$NETTY_VERSION" check_jar "com.fasterxml.jackson.core.jackson-databind-${JACKSON_OLD}.jar" "$JACKSON_VERSION" check_jar "com.fasterxml.jackson.core.jackson-core-${JACKSON_OLD}.jar" "$JACKSON_VERSION" check_jar "org.postgresql.postgresql-${PGJDBC_OLD}.jar" "$PGJDBC_VERSION" docker rm -f "$CCID" >/dev/null 2>&1 || true CCID="" # ------------------------------------------------------------------ 5단계 echo "== 실기동 (start-dev, 내장 dev-file DB) ==" ADMIN_USER="verify-admin" ADMIN_PASS="verify-$$-$RANDOM" # 호스트 포트는 커널이 고르게 한다(CI 러너에서 고정 포트 충돌을 피한다). CID="$(docker run -d --platform "$PLATFORM" \ -p 127.0.0.1::8080 \ -e KC_BOOTSTRAP_ADMIN_USERNAME="$ADMIN_USER" \ -e KC_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASS" \ -e TZ=Asia/Seoul \ "$TAG" start-dev)" HOSTPORT="$(docker port "$CID" 8080/tcp | head -1 | rev | cut -d: -f1 | rev)" BASE="http://127.0.0.1:${HOSTPORT}" echo " container=${CID:0:12} base=$BASE" # 기본값이 넉넉한 이유: arm64 호스트에서 linux/amd64 를 QEMU 로 돌리면 Quarkus # augmentation 만 ~100초, 기동 전체가 ~300초를 넘는다(2026-08-07 실측 — 180초로는 # liquibase 스키마 생성 중에 잘렸다). 네이티브 amd64 러너에서는 1~2분이면 끝나고, # 루프는 뜨는 즉시 빠져나오므로 큰 값이 느려지는 비용은 없다. BOOT_TIMEOUT="${VERIFY_BOOT_TIMEOUT:-600}" echo "== master realm 기동 대기 (최대 ${BOOT_TIMEOUT}s) ==" ok=0 for i in $(seq 1 "$BOOT_TIMEOUT"); do if curl -fsS "$BASE/realms/master/.well-known/openid-configuration" >"$WORK/disco.json" 2>/dev/null; then ok=1; echo " ${i}s 만에 응답"; break fi if [ -z "$(docker ps -q --filter "id=$CID")" ]; then echo "FAIL: 컨테이너가 죽었다"; docker logs "$CID" 2>&1 | tail -40; exit 1 fi # 30초마다 어디까지 갔는지 남긴다 — 느린 것과 멈춘 것을 로그로 구분하기 위함이다. if [ $((i % 30)) -eq 0 ]; then echo " ...${i}s: $(docker logs "$CID" 2>&1 | tail -1 | cut -c1-140)" fi sleep 1 done if [ "$ok" != 1 ]; then echo "FAIL: ${BOOT_TIMEOUT}초 내에 OIDC discovery 가 응답하지 않았다" docker logs "$CID" 2>&1 | tail -60 exit 1 fi ISSUER="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["issuer"])' "$WORK/disco.json")" echo " issuer=$ISSUER" echo "== admin 토큰 발급 (부트스트랩 계정 + DB 마이그레이션 확인) ==" curl -fsS -X POST "$BASE/realms/master/protocol/openid-connect/token" \ -d "client_id=admin-cli" -d "grant_type=password" \ -d "username=$ADMIN_USER" --data-urlencode "password=$ADMIN_PASS" \ > "$WORK/token.json" python3 - "$WORK/token.json" <<'PY' import json, sys tok = json.load(open(sys.argv[1])) assert tok.get("access_token"), f"access_token 없음: {tok}" print(f" access_token 발급 OK (expires_in={tok.get('expires_in')}s)") PY echo "== admin REST API 호출 (realms 목록) ==" AT="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["access_token"])' "$WORK/token.json")" curl -fsS -H "Authorization: Bearer $AT" "$BASE/admin/realms" > "$WORK/realms.json" python3 - "$WORK/realms.json" <<'PY' import json, sys realms = [r["realm"] for r in json.load(open(sys.argv[1]))] assert "master" in realms, f"master realm 없음: {realms}" print(f" realms={realms}") PY # netty/jackson 오버레이가 실제 HTTP 스택에서 동작했다는 증거 — 위 요청들이 전부 # Quarkus(netty) 위에서 처리되고 jackson 으로 직렬화된 JSON 이다. echo "VERIFY-OK"