#!/usr/bin/env bash set -Eeuo pipefail # Do not inherit xtrace: redirect headers contain an OIDC state value. set +x umask 077 readonly EXPECTED_HELM_VERSION="v3.19.4" readonly TARGET_NODE="donghyeon-system-product-name" readonly EXPECTED_NODE_INTERNAL_IP="192.168.0.107" readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" readonly GITEA_NAMESPACE="gitea" readonly GITEA_DEPLOYMENT="gitea" readonly GITEA_SERVICE="gitea-http" readonly GITEA_HOST="git.learn.hyeonworks.com" readonly GITEA_ROOT_URL="https://${GITEA_HOST}" readonly GITEA_HEALTH_URL="${GITEA_ROOT_URL}/api/healthz" readonly GITEA_LOGIN_URL="${GITEA_ROOT_URL}/user/login" readonly GITEA_SIGNUP_URL="${GITEA_ROOT_URL}/user/sign_up" readonly GITEA_OIDC_START_URL="${GITEA_ROOT_URL}/user/oauth2/keycloak" readonly GITEA_OIDC_CALLBACK_ENCODED="https%3A%2F%2Fgit.learn.hyeonworks.com%2Fuser%2Foauth2%2Fkeycloak%2Fcallback" readonly KEYCLOAK_HOST="id.learn.hyeonworks.com" readonly KEYCLOAK_ISSUER="https://${KEYCLOAK_HOST}/realms/hyeonworks" readonly KEYCLOAK_DISCOVERY_URL="${KEYCLOAK_ISSUER}/.well-known/openid-configuration" readonly KEYCLOAK_AUTH_ENDPOINT="${KEYCLOAK_ISSUER}/protocol/openid-connect/auth" readonly OIDC_SECRET_NAME="gitea-keycloak-oidc" readonly -a VERIFIED_MANIFEST_NAMES=( namespaces ssd-local-pv cnpg-operator platform-postgres gitea gitea-oidc ) render_temp_dir="" runtime_temp_dir="" gitea_manifest_sha256="not-rendered" apply_started=0 fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } usage() { cat <<'USAGE' Usage: bash scripts/bootstrap/apply-gitea-oidc.sh --execute Keycloak 공개 discovery, Gitea OIDC Secret 계약, 현재 Gitea 상태를 먼저 검사합니다. 고정된 Chart SHA를 검증하는 render-phase1.sh의 0600 handoff에서 gitea-oidc.yaml 하나만 적용한 뒤 OIDC, 외부 인증 전용 가입 정책, 브랜딩을 확인합니다. 실패해도 Kubernetes 리소스를 삭제하거나 이전 버전으로 롤백하지 않습니다. Secret 값, 토큰, OIDC state가 포함된 전체 Location은 출력하지 않습니다. USAGE } cleanup() { local cleanup_rc=$? trap - EXIT set +e if (( cleanup_rc != 0 )); then if (( apply_started == 1 )); then printf '\nRETAINED STATE: Gitea apply가 시작된 뒤 검증에 실패했습니다.\n' >&2 printf '자동 삭제와 롤백은 수행하지 않았으며 현재 클러스터 상태를 보존했습니다.\n' >&2 printf '적용 대상으로 고정했던 gitea-oidc.yaml SHA-256: %s\n' \ "$gitea_manifest_sha256" >&2 printf '확인: kubectl --namespace gitea get deployment,pod,service,endpointslice,ingress\n' >&2 else printf '\nNO MUTATION: 사전 검사 또는 렌더링 단계에서 중단되어 Gitea를 적용하지 않았습니다.\n' >&2 fi fi if [[ -n "$render_temp_dir" ]]; then case "$render_temp_dir" in /tmp/platform-phase1-apply.*) rm -rf -- "$render_temp_dir" ;; *) printf 'WARNING: refusing to remove unexpected render path: %s\n' \ "$render_temp_dir" >&2 ;; esac fi if [[ -n "$runtime_temp_dir" ]]; then case "$runtime_temp_dir" in /tmp/gitea-oidc-apply.*) rm -rf -- "$runtime_temp_dir" ;; *) printf 'WARNING: refusing to remove unexpected runtime path: %s\n' \ "$runtime_temp_dir" >&2 ;; esac fi exit "$cleanup_rc" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM [[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || { usage exit 2 } [[ -t 0 ]] || fail "an interactive terminal is required" [[ "$(pwd -P)" == "$REPOSITORY_ROOT" ]] || \ fail "run from ${REPOSITORY_ROOT}" for command_name in \ kubectl curl jq rg sha256sum stat find wc tr sort mktemp chmod mkdir rm awk; do command -v "$command_name" >/dev/null 2>&1 || \ fail "${command_name} is required" done if [[ -n "${PLATFORM_HELM_BIN:-}" ]]; then [[ "$PLATFORM_HELM_BIN" == /* ]] || \ fail "PLATFORM_HELM_BIN must be an absolute path" [[ -f "$PLATFORM_HELM_BIN" && -x "$PLATFORM_HELM_BIN" ]] || \ fail "PLATFORM_HELM_BIN is not executable: ${PLATFORM_HELM_BIN}" readonly HELM_BIN="$PLATFORM_HELM_BIN" else HELM_BIN="$(command -v helm 2>/dev/null)" || \ fail "Helm ${EXPECTED_HELM_VERSION} is required" readonly HELM_BIN fi [[ "$("$HELM_BIN" version --template '{{.Version}}')" == "$EXPECTED_HELM_VERSION" ]] || \ fail "Helm must be exactly ${EXPECTED_HELM_VERSION}" readonly CURRENT_CONTEXT="$(kubectl config current-context)" readonly API_SERVER="$( kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}' )" [[ -n "$CURRENT_CONTEXT" ]] || fail "kubectl current-context is empty" [[ -n "$API_SERVER" ]] || fail "the selected Kubernetes API server is empty" https_get() { local host="$1" local url="$2" local output_file="$3" curl \ --disable \ --silent \ --show-error \ --fail-with-body \ --noproxy '*' \ --resolve "${host}:443:127.0.0.1" \ --connect-timeout 3 \ --max-time 20 \ --header 'Cache-Control: no-cache' \ --output "$output_file" \ "$url" } check_target_node() { local internal_ip local ready_status kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \ fail "target node is missing: ${TARGET_NODE}" ready_status="$( kubectl get node "$TARGET_NODE" \ --output=jsonpath='{.status.conditions[?(@.type=="Ready")].status}' )" [[ "$ready_status" == "True" ]] || \ fail "target node is not Ready: ${TARGET_NODE}" internal_ip="$( kubectl get node "$TARGET_NODE" \ --output=jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}' )" [[ "$internal_ip" == "$EXPECTED_NODE_INTERNAL_IP" ]] || \ fail "target node InternalIP is ${internal_ip:-missing}, expected ${EXPECTED_NODE_INTERNAL_IP}" } check_oidc_secret_contract() { local secret_type local secret_keys # These output expressions inspect only the Secret type and data key names. # They never select, decode, compare, or print either data value. secret_type="$( kubectl --namespace "$GITEA_NAMESPACE" get secret "$OIDC_SECRET_NAME" \ --output=jsonpath='{.type}' )" secret_keys="$( kubectl --namespace "$GITEA_NAMESPACE" get secret "$OIDC_SECRET_NAME" \ --output=go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' \ | LC_ALL=C sort )" [[ "$secret_type" == "Opaque" ]] || \ fail "${GITEA_NAMESPACE}/${OIDC_SECRET_NAME} type must be Opaque" [[ "$secret_keys" == $'key\nsecret' ]] || \ fail "${GITEA_NAMESPACE}/${OIDC_SECRET_NAME} must contain exactly key and secret" } check_public_discovery() { local discovery_file="$1" https_get "$KEYCLOAK_HOST" "$KEYCLOAK_DISCOVERY_URL" "$discovery_file" jq --exit-status \ --arg issuer "$KEYCLOAK_ISSUER" \ --arg authorization_endpoint "$KEYCLOAK_AUTH_ENDPOINT" \ 'type == "object" and .issuer == $issuer and .authorization_endpoint == $authorization_endpoint and (.token_endpoint | type == "string" and startswith($issuer + "/"))' \ "$discovery_file" >/dev/null 2>&1 || \ fail "local-SNI Keycloak discovery is not the expected JSON issuer" } check_gitea_health() { local health_file="$1" kubectl --namespace "$GITEA_NAMESPACE" wait \ --for=condition=Available "deployment/${GITEA_DEPLOYMENT}" \ --timeout=30s >/dev/null kubectl --namespace "$GITEA_NAMESPACE" wait \ --for=jsonpath='{.endpoints[0].conditions.ready}'=true \ endpointslice \ --selector="kubernetes.io/service-name=${GITEA_SERVICE}" \ --timeout=30s >/dev/null https_get "$GITEA_HOST" "$GITEA_HEALTH_URL" "$health_file" jq --exit-status '.status == "pass"' "$health_file" >/dev/null || \ fail "Gitea public health response is not status=pass JSON" } verify_rendered_manifest_unchanged() { local manifest_path="${render_temp_dir}/gitea-oidc.yaml" local checksum_output local actual_sha256 [[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \ fail "verified Gitea manifest is missing or unsafe" [[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \ fail "verified Gitea manifest must have mode 0600" checksum_output="$(sha256sum -- "$manifest_path")" actual_sha256="${checksum_output%% *}" [[ "$actual_sha256" == "$gitea_manifest_sha256" ]] || \ fail "verified gitea-oidc.yaml changed after confirmation" } check_auth_source() { local auth_list_file="$1" local auth_error_file="$2" local keycloak_count local active_oauth2_count if ! kubectl --namespace "$GITEA_NAMESPACE" exec \ "deployment/${GITEA_DEPLOYMENT}" \ --container gitea \ -- gitea admin auth list \ --vertical-bars \ --min-width 1 \ --tab-width 1 \ --padding 0 \ --pad-char ' ' \ >"$auth_list_file" 2>"$auth_error_file"; then fail "gitea admin auth list failed; its output was retained only in the private temp directory" fi read -r keycloak_count active_oauth2_count < <( awk -F '|' ' function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) return value } NF == 4 { name = trim($2) type = trim($3) enabled = trim($4) if (name == "keycloak") { keycloak_count++ if (type == "OAuth2" && enabled == "true") { active_oauth2_count++ } } } END { print keycloak_count + 0, active_oauth2_count + 0 } ' "$auth_list_file" ) [[ "$keycloak_count" == "1" && "$active_oauth2_count" == "1" ]] || \ fail "exactly one active OAuth2 auth source named keycloak was not found" } check_app_ini_policy() { local app_ini_error_file="$1" if ! kubectl --namespace "$GITEA_NAMESPACE" exec \ "deployment/${GITEA_DEPLOYMENT}" \ --container gitea \ -- awk ' function trim(value) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) return value } /^[[:space:]]*\[/ { section = $0 gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", section) section = tolower(section) next } /^[[:space:]]*[#;]/ || /^[[:space:]]*$/ { next } { split($0, pair, "=") key = toupper(trim(pair[1])) value = $0 sub(/^[^=]*=/, "", value) value = trim(value) if (section == "service" && key == "DISABLE_REGISTRATION" && tolower(value) == "false") disabled++ if (section == "service" && key == "ALLOW_ONLY_EXTERNAL_REGISTRATION" && tolower(value) == "true") external_only++ if (section == "service" && key == "SHOW_REGISTRATION_BUTTON" && tolower(value) == "false") button_hidden++ if (section == "service" && key == "ENABLE_PASSWORD_SIGNIN_FORM" && tolower(value) == "true") password_signin++ if (section == "oauth2_client" && key == "ENABLE_AUTO_REGISTRATION" && tolower(value) == "true") oidc_jit++ if (section == "oauth2_client" && key == "USERNAME" && value == "preferred_username") username_claim++ if (section == "oauth2_client" && key == "ACCOUNT_LINKING" && value == "login") account_linking++ if (section == "oauth2_client" && key == "OPENID_CONNECT_SCOPES" && value == "profile email") oidc_scopes++ } END { exit !(disabled == 1 && external_only == 1 && button_hidden == 1 && password_signin == 1 && oidc_jit == 1 && username_claim == 1 && account_linking == 1 && oidc_scopes == 1) } ' /data/gitea/conf/app.ini >/dev/null 2>"$app_ini_error_file"; then fail "live app.ini does not satisfy the external-registration-only OIDC policy" fi } check_login_html() { local login_html_file="$1" local signup_html_file="$2" local signup_headers_file="$3" local signup_status local header_line local field_name local signup_location="" local signup_location_count=0 https_get "$GITEA_HOST" "$GITEA_LOGIN_URL" "$login_html_file" rg --quiet --fixed-strings 'href="/user/oauth2/keycloak"' "$login_html_file" || \ fail "Gitea login HTML does not contain the Keycloak OIDC link" rg --quiet --fixed-strings 'href="/assets/css/hyeonworks.css"' "$login_html_file" || \ fail "Gitea login HTML does not contain the Hyeonworks stylesheet" rg --quiet --fixed-strings 'name="theme-color" content="#0f172a"' "$login_html_file" || \ fail "Gitea login HTML does not contain the Hyeonworks theme marker" rg --quiet --fixed-strings 'hw-brand-link' "$login_html_file" || \ fail "Gitea login HTML does not contain the Hyeonworks navigation marker" if rg --quiet --fixed-strings 'href="/user/sign_up"' "$login_html_file"; then fail "Gitea login HTML still exposes a local sign-up link" fi if ! signup_status="$( curl \ --disable \ --silent \ --show-error \ --noproxy '*' \ --resolve "${GITEA_HOST}:443:127.0.0.1" \ --connect-timeout 3 \ --max-time 20 \ --header 'Cache-Control: no-cache' \ --output "$signup_html_file" \ --dump-header "$signup_headers_file" \ --write-out '%{http_code}' \ "$GITEA_SIGNUP_URL" )"; then fail "Gitea sign-up endpoint transport check failed" fi case "$signup_status" in 200) for field_name in user_name email password retype; do if rg --quiet --fixed-strings "name=\"${field_name}\"" "$signup_html_file"; then fail "Gitea sign-up HTML still exposes a local registration input" fi done ;; 404) ;; 301|302|303|307|308) while IFS= read -r header_line; do header_line="${header_line%$'\r'}" case "$header_line" in [Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:*) signup_location="${header_line#*:}" signup_location="${signup_location#"${signup_location%%[![:space:]]*}"}" ((signup_location_count += 1)) ;; esac done <"$signup_headers_file" [[ "$signup_location_count" == "1" ]] || \ fail "Gitea sign-up redirect must contain exactly one Location header" case "$signup_location" in /user/login|"${GITEA_ROOT_URL}/user/login") ;; *) fail "Gitea sign-up redirect does not target the same-origin login page" ;; esac ;; *) fail "Gitea sign-up endpoint returned an unexpected HTTP status: ${signup_status}" ;; esac } check_oidc_redirect() { local headers_file="$1" local status local header_line local location="" local location_count=0 local query local parameter local parameter_name local parameter_value local -a query_parameters=() local client_id_count=0 local response_type_count=0 local redirect_uri_count=0 local state_count=0 status="$( curl \ --disable \ --silent \ --show-error \ --noproxy '*' \ --resolve "${GITEA_HOST}:443:127.0.0.1" \ --connect-timeout 3 \ --max-time 20 \ --output /dev/null \ --dump-header "$headers_file" \ --write-out '%{http_code}' \ "$GITEA_OIDC_START_URL" )" case "$status" in 302|303|307) ;; *) fail "Gitea OIDC start returned HTTP ${status}, expected 302, 303, or 307" ;; esac while IFS= read -r header_line; do header_line="${header_line%$'\r'}" case "$header_line" in [Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:*) location="${header_line#*:}" location="${location#"${location%%[![:space:]]*}"}" ((location_count += 1)) ;; esac done <"$headers_file" [[ "$location_count" == "1" && -n "$location" ]] || \ fail "Gitea OIDC start did not return exactly one non-empty Location header" [[ "$location" != *'#'* ]] || \ fail "Gitea OIDC Location unexpectedly contains a fragment" case "$location" in "${KEYCLOAK_AUTH_ENDPOINT}"\?*) ;; *) fail "Gitea OIDC Location does not target the expected Keycloak authorization endpoint" ;; esac query="${location#*\?}" IFS='&' read -r -a query_parameters <<<"$query" for parameter in "${query_parameters[@]}"; do [[ "$parameter" == *=* ]] || continue parameter_name="${parameter%%=*}" parameter_value="${parameter#*=}" case "$parameter_name" in client_id) ((client_id_count += 1)) [[ "$parameter_value" == "gitea" ]] || \ fail "OIDC Location client_id is not gitea" ;; response_type) ((response_type_count += 1)) [[ "$parameter_value" == "code" ]] || \ fail "OIDC Location response_type is not code" ;; redirect_uri) ((redirect_uri_count += 1)) [[ "$parameter_value" == "$GITEA_OIDC_CALLBACK_ENCODED" ]] || \ fail "OIDC Location callback is not the exact public Gitea callback" ;; state) ((state_count += 1)) [[ -n "$parameter_value" ]] || fail "OIDC Location state is empty" ;; esac done [[ "$client_id_count" == "1" ]] || fail "OIDC Location must contain one client_id" [[ "$response_type_count" == "1" ]] || fail "OIDC Location must contain one response_type" [[ "$redirect_uri_count" == "1" ]] || fail "OIDC Location must contain one redirect_uri" [[ "$state_count" == "1" ]] || fail "OIDC Location must contain one non-empty state" # Deliberately do not print $location or any parsed state value. } check_branding_hashes() { local remote_dir="$1" local index local source_path local remote_path local checksum_output local local_sha256 local remote_sha256 local -a source_paths=( "${REPOSITORY_ROOT}/services/gitea/branding/public/assets/css/hyeonworks.css" "${REPOSITORY_ROOT}/services/gitea/branding/public/assets/img/logo.svg" "${REPOSITORY_ROOT}/services/gitea/branding/public/assets/img/favicon.svg" ) local -a public_paths=( "/assets/css/hyeonworks.css" "/assets/img/logo.svg" "/assets/img/favicon.svg" ) local -a labels=( "hyeonworks.css" "logo.svg" "favicon.svg" ) for index in "${!source_paths[@]}"; do source_path="${source_paths[$index]}" remote_path="${remote_dir}/${labels[$index]}" [[ -f "$source_path" && ! -L "$source_path" ]] || \ fail "branding source is missing or symlinked: ${source_path}" https_get \ "$GITEA_HOST" \ "${GITEA_ROOT_URL}${public_paths[$index]}" \ "$remote_path" checksum_output="$(sha256sum -- "$source_path")" local_sha256="${checksum_output%% *}" checksum_output="$(sha256sum -- "$remote_path")" remote_sha256="${checksum_output%% *}" [[ "$remote_sha256" == "$local_sha256" ]] || \ fail "public branding hash differs from local source: ${labels[$index]}" printf '브랜딩 해시 일치: %-16s %s\n' \ "${labels[$index]}" "$local_sha256" done } runtime_temp_dir="$(mktemp -d /tmp/gitea-oidc-apply.XXXXXX)" chmod 0700 "$runtime_temp_dir" render_temp_dir="$(mktemp -d /tmp/platform-phase1-apply.XXXXXX)" chmod 0700 "$render_temp_dir" readonly DISCOVERY_FILE="${runtime_temp_dir}/keycloak-discovery.json" readonly HEALTH_FILE="${runtime_temp_dir}/gitea-health.json" readonly AUTH_LIST_FILE="${runtime_temp_dir}/gitea-auth-list.txt" readonly AUTH_ERROR_FILE="${runtime_temp_dir}/gitea-auth-list.err" readonly APP_INI_ERROR_FILE="${runtime_temp_dir}/gitea-app-ini.err" readonly LOGIN_HTML_FILE="${runtime_temp_dir}/gitea-login.html" readonly SIGNUP_HTML_FILE="${runtime_temp_dir}/gitea-signup.html" readonly SIGNUP_HEADERS_FILE="${runtime_temp_dir}/gitea-signup-headers" readonly OIDC_HEADERS_FILE="${runtime_temp_dir}/gitea-oidc-headers" readonly BRANDING_REMOTE_DIR="${runtime_temp_dir}/branding-remote" mkdir -m 0700 -- "$BRANDING_REMOTE_DIR" printf '[1/8] 현재 context, 노드, Gitea 상태 확인\n' check_target_node kubectl --namespace "$GITEA_NAMESPACE" get "deployment/${GITEA_DEPLOYMENT}" >/dev/null check_gitea_health "$HEALTH_FILE" printf '[2/8] Host Nginx 로컬 SNI 경로의 Keycloak discovery JSON 확인\n' check_public_discovery "$DISCOVERY_FILE" printf '[3/8] Gitea OIDC Secret의 type과 key 이름만 확인\n' check_oidc_secret_contract printf '[4/8] 고정 Chart SHA 검증 후 Phase 1 manifest 렌더링\n' cd -- "$REPOSITORY_ROOT" PLATFORM_HELM_BIN="$HELM_BIN" \ bash scripts/validate/render-phase1.sh \ --verified-output-dir "$render_temp_dir" verified_entry_count="$( find "$render_temp_dir" -mindepth 1 -maxdepth 1 -type f \ -name '*.yaml' | wc -l | tr -d '[:space:]' )" [[ "$verified_entry_count" == "${#VERIFIED_MANIFEST_NAMES[@]}" ]] || \ fail "verified handoff must contain exactly ${#VERIFIED_MANIFEST_NAMES[@]} YAML manifests" for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do manifest_path="${render_temp_dir}/${manifest_name}.yaml" [[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \ fail "verified manifest is missing or unsafe: ${manifest_name}.yaml" [[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \ fail "verified manifest must have mode 0600: ${manifest_name}.yaml" done checksum_output="$(sha256sum -- "${render_temp_dir}/gitea-oidc.yaml")" gitea_manifest_sha256="${checksum_output%% *}" readonly gitea_manifest_sha256 rg --quiet --fixed-strings 'gitea-keycloak-oidc' "${render_temp_dir}/gitea-oidc.yaml" || \ fail "rendered Gitea manifest does not reference the OIDC Secret" rg --quiet --fixed-strings "$KEYCLOAK_DISCOVERY_URL" "${render_temp_dir}/gitea-oidc.yaml" || \ fail "rendered Gitea manifest does not contain the exact discovery URL" printf '\nKubernetes context: %s\n' "$CURRENT_CONTEXT" printf 'API server: %s\n' "$API_SERVER" printf 'Target node: %s\n' "$TARGET_NODE" printf 'gitea-oidc.yaml SHA-256: %s\n' "$gitea_manifest_sha256" printf '적용 범위: 검증된 gitea-oidc.yaml 하나\n' printf 'Type APPLY %s GITEA-OIDC %s to continue: ' \ "$CURRENT_CONTEXT" "$gitea_manifest_sha256" read -r confirmation [[ "$confirmation" == "APPLY ${CURRENT_CONTEXT} GITEA-OIDC ${gitea_manifest_sha256}" ]] || \ fail "cancelled" [[ "$(kubectl config current-context)" == "$CURRENT_CONTEXT" ]] || \ fail "kubectl context changed after confirmation" [[ "$( kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}' )" == "$API_SERVER" ]] || \ fail "Kubernetes API server changed after confirmation" check_target_node check_public_discovery "$DISCOVERY_FILE" check_oidc_secret_contract check_gitea_health "$HEALTH_FILE" verify_rendered_manifest_unchanged printf '\n[5/8] 검증된 gitea-oidc.yaml 하나만 적용\n' apply_started=1 kubectl apply --filename="${render_temp_dir}/gitea-oidc.yaml" verify_rendered_manifest_unchanged printf '[6/8] Deployment rollout과 ready EndpointSlice 대기\n' kubectl --namespace "$GITEA_NAMESPACE" rollout status \ "deployment/${GITEA_DEPLOYMENT}" --timeout=10m kubectl --namespace "$GITEA_NAMESPACE" wait \ --for=jsonpath='{.endpoints[0].conditions.ready}'=true \ endpointslice \ --selector="kubernetes.io/service-name=${GITEA_SERVICE}" \ --timeout=2m check_gitea_health "$HEALTH_FILE" printf '[7/8] 활성 OAuth2 source, app.ini 정책, 로그인/OIDC 흐름 확인\n' check_auth_source "$AUTH_LIST_FILE" "$AUTH_ERROR_FILE" check_app_ini_policy "$APP_INI_ERROR_FILE" check_login_html "$LOGIN_HTML_FILE" "$SIGNUP_HTML_FILE" "$SIGNUP_HEADERS_FILE" check_oidc_redirect "$OIDC_HEADERS_FILE" check_public_discovery "$DISCOVERY_FILE" printf '[8/8] 공개 브랜딩 자산과 로컬 소스 SHA-256 비교\n' check_branding_hashes "$BRANDING_REMOTE_DIR" printf '\nGITEA OIDC APPLY SUCCESS\n' printf '적용 manifest SHA-256: %s\n' "$gitea_manifest_sha256" printf 'Keycloak discovery, 활성 OAuth2 source, 외부 인증 전용 가입 정책, OIDC redirect를 확인했습니다.\n' printf 'OIDC Secret 값, 토큰, 전체 Location/state는 출력하지 않았습니다.\n' printf '실제 realm 사용자 login/callback/logout은 별도 수동 수용 시험으로 남습니다.\n'