299 lines
9.3 KiB
Bash
Executable File
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'
|