674 lines
24 KiB
Bash
Executable File
674 lines
24 KiB
Bash
Executable File
#!/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'
|