Files
platform-core/scripts/bootstrap/apply-keycloak.sh
T

408 lines
14 KiB
Bash
Executable File

#!/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'