Add platform infrastructure configuration
This commit is contained in:
Executable
+673
@@ -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'
|
||||
Reference in New Issue
Block a user