Add platform infrastructure configuration

This commit is contained in:
donghyeon-ka
2026-08-28 17:35:41 +09:00
parent fa76531e5b
commit 16c337bcc9
302 changed files with 83259 additions and 1 deletions
+673
View File
@@ -0,0 +1,673 @@
#!/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'