Add platform infrastructure configuration
This commit is contained in:
Executable
+407
@@ -0,0 +1,407 @@
|
||||
#!/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'
|
||||
Reference in New Issue
Block a user