#!/usr/bin/env bash set -Eeuo pipefail # 호출자가 bash -x로 실행해도 향후 Secret 입력이 추적 출력에 노출되지 않도록 한다. set +x readonly EXPECTED_KUSTOMIZE_VERSION="v5.8.1" readonly TARGET_NODE="donghyeon-system-product-name" readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)" readonly -a VERIFIED_MANIFEST_NAMES=( keycloak-namespace platform-postgres-keycloak keycloak-operator keycloak ) readonly -a KEYCLOAK_CRDS=( customresourcedefinition/keycloaks.k8s.keycloak.org customresourcedefinition/keycloakrealmimports.k8s.keycloak.org customresourcedefinition/keycloakoidcclients.k8s.keycloak.org customresourcedefinition/keycloaksamlclients.k8s.keycloak.org ) mutation_started=false current_step="preflight" report_retained_state() { if [[ "$mutation_started" == true ]]; then printf '%s\n' \ "SAFE STOP during ${current_step}." \ 'No Namespace, Secret, DatabaseRole, Database, Operator, or Keycloak resource was deleted.' \ 'Database reclaim policies remain Retain. Diagnose the failed wait or apply, then rerun this script.' >&2 fi } fail() { printf 'ERROR: %s\n' "$*" >&2 report_retained_state exit 1 } on_error() { local status="$1" local line="$2" trap - ERR set +e printf 'ERROR: command failed at line %s (exit %s).\n' "$line" "$status" >&2 report_retained_state exit "$status" } on_signal() { local status="$1" trap - INT TERM set +e printf 'INTERRUPTED: stopping without deleting cluster state.\n' >&2 report_retained_state exit "$status" } usage() { cat <<'USAGE' Usage: bash scripts/bootstrap/apply-keycloak.sh --execute Renders, verifies, and applies only the Keycloak path in this order: Keycloak Namespace two Keycloak DB Secrets CloudNativePG DatabaseRole, Database, and NetworkPolicy Keycloak Operator Keycloak custom resource and HTTP Ingress AIStor namespaces, Secrets, storage, Operator, and ObjectStore are not required or applied. Host Nginx and Gitea OIDC configuration are separate cutovers. USAGE } [[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || { usage exit 2 } for command_name in kubectl curl find mktemp rg sed sha256sum stat wc; do command -v "$command_name" >/dev/null 2>&1 || \ fail "${command_name} is required" done kustomize_version="$( kubectl version --client --output=yaml | sed -n 's/^kustomizeVersion: //p' )" [[ "$kustomize_version" == "$EXPECTED_KUSTOMIZE_VERSION" ]] || \ fail "expected Kustomize ${EXPECTED_KUSTOMIZE_VERSION}, found ${kustomize_version:-unknown}" umask 077 render_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-keycloak-apply.XXXXXX")" cleanup() { case "$render_temp_dir" in /tmp/platform-keycloak-apply.*|"${TMPDIR:-/tmp}"/platform-keycloak-apply.*) rm -rf -- "$render_temp_dir" ;; *) printf 'WARNING: refusing to remove unexpected render directory: %s\n' \ "$render_temp_dir" >&2 ;; esac } trap cleanup EXIT trap 'on_error "$?" "$LINENO"' ERR trap 'on_signal 130' INT trap 'on_signal 143' TERM assert_regex_count() { local file="$1" local pattern="$2" local expected="$3" local description="$4" local actual actual="$(rg --count --no-filename -- "$pattern" "$file" || true)" actual="${actual:-0}" [[ "$actual" == "$expected" ]] || \ fail "${description}: expected ${expected}, found ${actual}" } render_kustomization() { local name="$1" local relative_path="$2" local output="${render_temp_dir}/${name}.yaml" kubectl kustomize "${REPOSITORY_ROOT}/${relative_path}" >"$output" [[ -s "$output" ]] || fail "${name} rendered an empty manifest" printf 'Rendered %-30s %8s bytes\n' \ "$name" "$(wc -c <"$output" | tr -d '[:space:]')" } cd -- "$REPOSITORY_ROOT" kubectl create \ --dry-run=client \ --filename=infrastructure/namespaces/phase2/keycloak.yaml \ --output=yaml >"${render_temp_dir}/keycloak-namespace.yaml" render_kustomization \ platform-postgres-keycloak services/platform-postgres-keycloak render_kustomization \ keycloak-operator infrastructure/controllers/keycloak-operator render_kustomization keycloak services/keycloak if rg --line-number \ '^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' \ "${render_temp_dir}"/*.yaml; then fail "a rendered Keycloak manifest unexpectedly contains a Secret" fi assert_regex_count \ "${render_temp_dir}/keycloak-namespace.yaml" \ '^kind:[[:space:]]Namespace$' 1 \ "Keycloak namespace resource count" assert_regex_count \ "${render_temp_dir}/keycloak-namespace.yaml" \ '^[[:space:]]*name:[[:space:]]keycloak$' 1 \ "Keycloak namespace name" assert_regex_count \ "${render_temp_dir}/platform-postgres-keycloak.yaml" \ '^kind:[[:space:]]DatabaseRole$' 1 \ "Keycloak DatabaseRole count" assert_regex_count \ "${render_temp_dir}/platform-postgres-keycloak.yaml" \ '^kind:[[:space:]]Database$' 1 \ "Keycloak Database count" assert_regex_count \ "${render_temp_dir}/platform-postgres-keycloak.yaml" \ '^kind:[[:space:]]NetworkPolicy$' 1 \ "Keycloak PostgreSQL NetworkPolicy count" assert_regex_count \ "${render_temp_dir}/platform-postgres-keycloak.yaml" \ '^[[:space:]]*namespace:[[:space:]]platform-data$' 3 \ "Keycloak PostgreSQL resource namespace count" assert_regex_count \ "${render_temp_dir}/platform-postgres-keycloak.yaml" \ '^[[:space:]]*(databaseRoleReclaimPolicy|databaseReclaimPolicy):[[:space:]]retain$' 2 \ "Keycloak database Retain policy count" assert_regex_count \ "${render_temp_dir}/platform-postgres-keycloak.yaml" \ '^[[:space:]]*name:[[:space:]]keycloak-db-credentials$' 1 \ "Keycloak DatabaseRole Secret reference" assert_regex_count \ "${REPOSITORY_ROOT}/infrastructure/controllers/keycloak-operator/kustomization.yaml" \ 'github\.com/keycloak/keycloak-k8s-resources/kubernetes\?ref=26\.7\.0' 1 \ "Keycloak Operator 26.7.0 source pin" assert_regex_count \ "${render_temp_dir}/keycloak-operator.yaml" \ '^kind:[[:space:]]CustomResourceDefinition$' 4 \ "Keycloak Operator CRD count" assert_regex_count \ "${render_temp_dir}/keycloak-operator.yaml" \ '^kind:[[:space:]]Deployment$' 1 \ "Keycloak Operator Deployment count" assert_regex_count \ "${render_temp_dir}/keycloak-operator.yaml" \ '^[[:space:]]*image:[[:space:]]quay\.io/keycloak/keycloak-operator:26\.7\.0$' 1 \ "Keycloak Operator image" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^kind:[[:space:]]Keycloak$' 1 \ "Keycloak custom resource count" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^kind:[[:space:]]Ingress$' 1 \ "Keycloak Ingress count" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^[[:space:]]*namespace:[[:space:]]keycloak$' 2 \ "Keycloak service resource namespace count" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^[[:space:]]*instances:[[:space:]]1$' 1 \ "Keycloak instance count" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^[[:space:]]*hostname:[[:space:]]https://id\.learn\.hyeonworks\.com$' 1 \ "Keycloak external hostname" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^[[:space:]]*headers:[[:space:]]xforwarded$' 1 \ "Keycloak forwarded header mode" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^[[:space:]]*ingressClassName:[[:space:]]traefik$' 1 \ "Keycloak Ingress class" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^[[:space:]]*name:[[:space:]]keycloak-service$' 1 \ "Keycloak Ingress backend" assert_regex_count \ "${render_temp_dir}/keycloak.yaml" \ '^[[:space:]]*number:[[:space:]]8080$' 1 \ "Keycloak Ingress backend port" if rg --quiet \ '^[[:space:]]*namespace:[[:space:]](aistor|object-storage)[[:space:]]*$' \ "${render_temp_dir}"/*.yaml; then fail "the Keycloak-only render contains an AIStor namespace" fi if rg --quiet \ '^[[:space:]]*(serviceType|type):[[:space:]]*(NodePort|LoadBalancer)[[:space:]]*$' \ "${render_temp_dir}/keycloak.yaml"; then fail "Keycloak must not render NodePort or LoadBalancer exposure" fi declare -A verified_manifest_sha256=() 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_path}" [[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \ fail "verified manifest must have mode 0600: ${manifest_path}" checksum_output="$(sha256sum -- "$manifest_path")" verified_manifest_sha256["$manifest_name"]="${checksum_output%% *}" done verified_entry_count="$( find "$render_temp_dir" -mindepth 1 -maxdepth 1 | wc -l | tr -d '[:space:]' )" [[ "$verified_entry_count" == "${#VERIFIED_MANIFEST_NAMES[@]}" ]] || \ fail "verified handoff must contain exactly four manifest files" verify_manifest_unchanged() { local manifest_name="$1" local manifest_path="${render_temp_dir}/${manifest_name}.yaml" local checksum_output local actual_sha256 [[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \ fail "verified manifest became missing or unsafe: ${manifest_path}" checksum_output="$(sha256sum -- "$manifest_path")" actual_sha256="${checksum_output%% *}" [[ "$actual_sha256" == "${verified_manifest_sha256[$manifest_name]}" ]] || \ fail "verified manifest changed before apply: ${manifest_name}.yaml" } current_context="$(kubectl config current-context)" api_server="$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')" kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \ fail "target node is missing from the selected cluster: ${TARGET_NODE}" kubectl get namespace platform-data >/dev/null 2>&1 || \ fail "Phase 1 namespace platform-data is missing" kubectl get customresourcedefinition \ clusters.postgresql.cnpg.io \ databaseroles.postgresql.cnpg.io \ databases.postgresql.cnpg.io >/dev/null postgres_ready="$( kubectl --namespace platform-data get cluster platform-postgres \ --output='go-template={{range .status.conditions}}{{if and (eq .type "Ready") (eq .status "True")}}true{{end}}{{end}}' )" [[ "$postgres_ready" == "true" ]] || \ fail "platform-data/platform-postgres is not Ready" printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \ "$current_context" "$api_server" "$TARGET_NODE" printf '%s\n' \ 'Scope: Keycloak namespace, two DB Secrets, DB Role/Database/NetworkPolicy, Operator, Keycloak, and Ingress.' \ 'Excluded: AIStor resources, Host Nginx, Gitea OIDC source, and all data deletion.' \ 'Rollback boundary: applied state is retained on failure; rerunning is the recovery path.' [[ -t 0 ]] || fail "an interactive terminal is required" printf 'Type APPLY to start the Keycloak cluster mutation: ' read -r confirmation [[ "$confirmation" == "APPLY" ]] || fail "cancelled" assert_cluster_identity() { [[ "$(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" kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \ fail "target node disappeared after confirmation: ${TARGET_NODE}" } assert_cluster_identity for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do verify_manifest_unchanged "$manifest_name" done mutation_started=true current_step="[1/6] Keycloak namespace" printf '\n%s\n' "$current_step" assert_cluster_identity verify_manifest_unchanged keycloak-namespace kubectl apply --dry-run=server \ --filename="${render_temp_dir}/keycloak-namespace.yaml" >/dev/null kubectl apply --filename="${render_temp_dir}/keycloak-namespace.yaml" current_step="[2/6] Keycloak database Secret contracts" printf '\n%s\n' "$current_step" assert_cluster_identity bash scripts/bootstrap/create-keycloak-secrets.sh --execute current_step="[3/6] Keycloak DatabaseRole, Database, and NetworkPolicy" printf '\n%s\n' "$current_step" assert_cluster_identity verify_manifest_unchanged platform-postgres-keycloak kubectl apply --server-side --dry-run=server \ --filename="${render_temp_dir}/platform-postgres-keycloak.yaml" >/dev/null kubectl apply --server-side \ --filename="${render_temp_dir}/platform-postgres-keycloak.yaml" kubectl --namespace platform-data wait \ --for=jsonpath='{.status.applied}'=true \ databaserole/platform-postgres-keycloak \ database/platform-postgres-keycloak \ --timeout=3m current_step="[4/6] Keycloak Operator and CRDs" printf '\n%s\n' "$current_step" assert_cluster_identity verify_manifest_unchanged keycloak-operator kubectl apply --server-side --dry-run=server \ --filename="${render_temp_dir}/keycloak-operator.yaml" >/dev/null kubectl apply --server-side \ --filename="${render_temp_dir}/keycloak-operator.yaml" kubectl wait --for=condition=Established \ "${KEYCLOAK_CRDS[@]}" \ --timeout=3m kubectl --namespace keycloak rollout status \ deployment/keycloak-operator --timeout=5m current_step="[5/6] Keycloak instance and HTTP Ingress" printf '\n%s\n' "$current_step" assert_cluster_identity verify_manifest_unchanged keycloak kubectl apply --server-side --dry-run=server \ --filename="${render_temp_dir}/keycloak.yaml" >/dev/null kubectl apply --server-side \ --filename="${render_temp_dir}/keycloak.yaml" kubectl --namespace keycloak wait \ --for=condition=Ready keycloak/keycloak --timeout=15m kubectl --namespace keycloak wait \ --for=jsonpath='{.endpoints[0].conditions.ready}'=true \ endpointslice \ --selector=kubernetes.io/service-name=keycloak-service \ --timeout=2m current_step="[6/6] OIDC discovery through Traefik HTTP NodePort" printf '\n%s\n' "$current_step" discovery_file="${render_temp_dir}/keycloak-discovery.json" curl --fail --silent --show-error \ --retry 24 \ --retry-all-errors \ --retry-connrefused \ --retry-delay 5 \ --retry-max-time 120 \ --max-time 10 \ --header 'Host: id.learn.hyeonworks.com' \ --output "$discovery_file" \ http://127.0.0.1:30080/realms/master/.well-known/openid-configuration rg --quiet \ '"issuer"[[:space:]]*:[[:space:]]*"https://id\.learn\.hyeonworks\.com/realms/master"' \ "$discovery_file" || \ fail "OIDC discovery issuer does not match the public Keycloak hostname" mutation_started=false printf '\nKeycloak cluster resources and internal OIDC discovery are ready.\n' printf 'No AIStor resource, Host Nginx configuration, or Gitea OIDC source was changed.\n'