Files
platform-core/scripts/bootstrap/create-keycloak-secrets.sh
T

299 lines
9.3 KiB
Bash
Executable File

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