#!/usr/bin/env bash set -Eeuo pipefail # 호출자가 bash -x로 실행해도 비밀번호가 추적 출력에 노출되지 않도록 한다. set +x readonly REQUIRED_CONFIRMATION="APPLY KEYCLOAK SECRETS" readonly DB_USERNAME="keycloak" readonly TOTAL_SECRET_CONTRACTS=2 readonly -a SECRET_CONTRACTS=( "platform-data/keycloak-db-credentials" "keycloak/keycloak-db-credentials" ) mutation_started=false secret_temp_dir="" report_retained_state() { if [[ "$mutation_started" == true ]]; then printf '%s\n' \ 'SAFE STOP: no Secret or database data was deleted or rolled back.' \ 'Any Secret created before the failure remains in the cluster.' \ 'Inspect Secret names and events without printing Secret data, then rerun after resolving the cause.' >&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/create-keycloak-secrets.sh --execute bash scripts/bootstrap/create-keycloak-secrets.sh --generate --execute Creates exactly these two Secrets only when both are absent: platform-data/keycloak-db-credentials keycloak/keycloak-db-credentials When both already exist, validates and reuses their data unchanged. A partial state is refused. This script does not require or modify an AIStor license, does not rotate credentials, and never prints Secret payloads. --execute prompts twice for a database password and requires the exact confirmation text. --generate --execute is an explicit non-interactive mode: it generates 32 random bytes with OpenSSL in a private 0600 temporary file. USAGE } execute_requested=false generate_requested=false while (( $# > 0 )); do case "$1" in --execute) execute_requested=true shift ;; --generate) generate_requested=true shift ;; -h|--help) usage exit 0 ;; *) usage >&2 fail "unsupported argument: $1" ;; esac done [[ "$execute_requested" == true ]] || { usage >&2 exit 2 } for command_name in kubectl base64 cmp mktemp sort stat tr wc; do command -v "$command_name" >/dev/null 2>&1 || \ fail "${command_name} is required" done if [[ "$generate_requested" == true ]]; then command -v openssl >/dev/null 2>&1 || fail "openssl is required for --generate" fi for namespace in platform-data keycloak; do kubectl get namespace "$namespace" >/dev/null 2>&1 || \ fail "namespace ${namespace} does not exist" done umask 077 secret_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-keycloak-secrets.XXXXXX")" db_user_file="${secret_temp_dir}/expected-username" db_password_file="${secret_temp_dir}/new-password" platform_username_file="${secret_temp_dir}/platform-username" keycloak_username_file="${secret_temp_dir}/keycloak-username" platform_password_file="${secret_temp_dir}/platform-password" keycloak_password_file="${secret_temp_dir}/keycloak-password" cleanup() { unset db_password if [[ -n "$secret_temp_dir" ]]; then rm -f -- \ "$db_user_file" \ "$db_password_file" \ "$platform_username_file" \ "$keycloak_username_file" \ "$platform_password_file" \ "$keycloak_password_file" rmdir -- "$secret_temp_dir" 2>/dev/null || true fi } trap cleanup EXIT trap 'on_error "$?" "$LINENO"' ERR trap 'on_signal 130' INT trap 'on_signal 143' TERM printf '%s' "$DB_USERNAME" >"$db_user_file" secret_data_b64() { local namespace="$1" local name="$2" local key="$3" kubectl --namespace "$namespace" get secret "$name" \ --output="go-template={{ index .data \"${key}\" }}" } validate_secret_type_and_keys() { local namespace="$1" local name="$2" local actual_type local actual_keys local actual_keys_sorted actual_type="$( kubectl --namespace "$namespace" get secret "$name" \ --output='jsonpath={.type}' )" [[ "$actual_type" == "kubernetes.io/basic-auth" ]] || \ fail "${namespace}/${name} must have type kubernetes.io/basic-auth" actual_keys="$( kubectl --namespace "$namespace" get secret "$name" \ --output='go-template={{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' )" actual_keys_sorted="$(printf '%s\n' "$actual_keys" | LC_ALL=C sort)" [[ "$actual_keys_sorted" == $'password\nusername' ]] || \ fail "${namespace}/${name} has an unexpected data key set" } validate_existing_contracts() { validate_secret_type_and_keys platform-data keycloak-db-credentials validate_secret_type_and_keys keycloak keycloak-db-credentials secret_data_b64 platform-data keycloak-db-credentials username \ | base64 --decode >"$platform_username_file" secret_data_b64 keycloak keycloak-db-credentials username \ | base64 --decode >"$keycloak_username_file" cmp --silent -- "$db_user_file" "$platform_username_file" || \ fail "platform-data/keycloak-db-credentials username must be keycloak" cmp --silent -- "$db_user_file" "$keycloak_username_file" || \ fail "keycloak/keycloak-db-credentials username must be keycloak" secret_data_b64 platform-data keycloak-db-credentials password \ | base64 --decode >"$platform_password_file" secret_data_b64 keycloak keycloak-db-credentials password \ | base64 --decode >"$keycloak_password_file" (( $(wc -c <"$platform_password_file") >= 16 )) || \ fail "platform-data/keycloak-db-credentials password must contain at least 16 bytes" (( $(wc -c <"$keycloak_password_file") >= 16 )) || \ fail "keycloak/keycloak-db-credentials password must contain at least 16 bytes" cmp --silent -- "$platform_password_file" "$keycloak_password_file" || \ fail "the two Keycloak DB Secret passwords do not match" } existing_secret_count=0 for contract in "${SECRET_CONTRACTS[@]}"; do namespace="${contract%%/*}" name="${contract#*/}" existing_resource="$( kubectl --namespace "$namespace" get secret "$name" \ --ignore-not-found --output=name )" if [[ -n "$existing_resource" ]]; then ((existing_secret_count += 1)) fi done if (( existing_secret_count > 0 && existing_secret_count < TOTAL_SECRET_CONTRACTS )); then fail "partial Keycloak Secret state detected (${existing_secret_count}/${TOTAL_SECRET_CONTRACTS}); refusing creation or rotation" fi if (( existing_secret_count == TOTAL_SECRET_CONTRACTS )); then validate_existing_contracts reload_label="$( kubectl --namespace platform-data get secret keycloak-db-credentials \ --output='jsonpath={.metadata.labels.cnpg\.io/reload}' )" if [[ "$reload_label" != "true" ]]; then mutation_started=true kubectl --namespace platform-data label secret keycloak-db-credentials \ cnpg.io/reload=true --overwrite mutation_started=false printf 'Repaired cnpg.io/reload=true without changing Secret data.\n' fi printf 'Existing Keycloak Secret contracts are valid and were reused unchanged.\n' printf 'No credential rotation was performed.\n' exit 0 fi read_secret_twice() { local prompt="$1" local first local second read -r -s -p "${prompt}: " first printf '\n' >&2 read -r -s -p "Confirm ${prompt}: " second printf '\n' >&2 [[ "$first" == "$second" ]] || fail "the two values do not match" (( ${#first} >= 16 )) || fail "${prompt} must contain at least 16 characters" [[ "$first" != *$'\n'* && "$first" != *$'\r'* ]] || \ fail "${prompt} contains an unsupported line break" printf '%s' "$first" } if [[ "$generate_requested" == true ]]; then openssl rand -hex 32 | tr -d '\n' >"$db_password_file" [[ "$(stat --format='%a' -- "$db_password_file")" == "600" ]] || \ fail "generated password file must have mode 0600" [[ "$(wc -c <"$db_password_file" | tr -d '[:space:]')" == "64" ]] || \ fail "OpenSSL did not generate the expected 32-byte password" printf '%s\n' \ 'Authorized by explicit --generate --execute flags.' \ 'A 32-byte random database password was generated without printing it.' else [[ -t 0 ]] || fail "an interactive terminal is required for initial Secret creation" db_password="$(read_secret_twice 'Keycloak database password')" printf '%s' "$db_password" >"$db_password_file" unset db_password printf '\nThe script will create exactly two Keycloak DB Secrets.\n' printf 'It will not read or modify any AIStor Secret.\n' printf 'Type %s to continue: ' "$REQUIRED_CONFIRMATION" read -r confirmation [[ "$confirmation" == "$REQUIRED_CONFIRMATION" ]] || fail "cancelled" fi create_basic_auth_secret() { local namespace="$1" kubectl --namespace "$namespace" create secret generic keycloak-db-credentials \ --type=kubernetes.io/basic-auth \ --from-file="username=${db_user_file}" \ --from-file="password=${db_password_file}" \ --dry-run=client \ --output=yaml \ | kubectl create --filename=- } mutation_started=true create_basic_auth_secret platform-data create_basic_auth_secret keycloak kubectl --namespace platform-data label secret keycloak-db-credentials \ cnpg.io/reload=true --overwrite validate_existing_contracts mutation_started=false printf 'Initial Keycloak Secret contracts were created without printing payloads.\n' printf 'This script does not perform credential rotation.\n'