#!/usr/bin/env bash set -Eeuo pipefail # 비밀값이 명령 추적, argv 또는 표준 출력에 노출되지 않도록 한다. set +x readonly REQUIRED_CONFIRMATION="APPLY PHASE 2 SECRETS" readonly DB_USERNAME="keycloak" readonly TOTAL_SECRET_CONTRACTS=4 readonly -a SECRET_CONTRACTS=( "platform-data/keycloak-db-credentials" "keycloak/keycloak-db-credentials" "aistor/minio-license" "object-storage/aistor-root-configuration" ) fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } usage() { cat <<'USAGE' Usage: bash scripts/bootstrap/create-phase2-secrets.sh \ --license-file /absolute/path/to/minio.license \ --execute Creates all four Phase 2 Secrets only when all four are absent. When all four already exist, validates and reuses them unchanged. A partial state is refused. This script never rotates credentials or the AIStor license. USAGE } license_file="" execute_requested=false while (( $# > 0 )); do case "$1" in --license-file) (( $# >= 2 )) || fail "--license-file requires a path" license_file="$2" shift 2 ;; --execute) execute_requested=true shift ;; -h|--help) usage exit 0 ;; *) usage >&2 fail "unsupported argument: $1" ;; esac done [[ "$execute_requested" == true ]] || { usage >&2 exit 2 } [[ -n "$license_file" ]] || fail "--license-file is required" [[ "$license_file" == /* ]] || fail "--license-file must be an absolute path" [[ -f "$license_file" && -r "$license_file" && -s "$license_file" ]] || \ fail "license file must be a readable, non-empty regular file" license_payload="$(<"$license_file")" license_payload="${license_payload%$'\r'}" [[ "$license_payload" =~ ^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]] || \ fail "license file must contain one decoded JWT value beginning with eyJ" unset license_payload command -v kubectl >/dev/null 2>&1 || fail "kubectl is required" command -v base64 >/dev/null 2>&1 || fail "base64 is required" command -v cmp >/dev/null 2>&1 || fail "cmp is required" command -v wc >/dev/null 2>&1 || fail "wc is required" for namespace in platform-data keycloak aistor object-storage; do kubectl get namespace "$namespace" >/dev/null 2>&1 || \ fail "namespace ${namespace} does not exist; apply Phase 2 namespaces first" done 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 Phase 2 Secret state detected (${existing_secret_count}/${TOTAL_SECRET_CONTRACTS}); refusing creation or rotation" fi umask 077 secret_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-phase2-secrets.XXXXXX")" db_user_file="${secret_temp_dir}/db-username" db_password_file="${secret_temp_dir}/db-password" root_config_file="${secret_temp_dir}/config.env" existing_license_file="${secret_temp_dir}/existing-minio-license" existing_root_config_file="${secret_temp_dir}/existing-config.env" existing_platform_db_password_file="${secret_temp_dir}/existing-platform-db-password" existing_keycloak_db_password_file="${secret_temp_dir}/existing-keycloak-db-password" cleanup() { unset db_password root_user root_password rm -f -- \ "$db_user_file" "$db_password_file" "$root_config_file" \ "$existing_license_file" "$existing_root_config_file" \ "$existing_platform_db_password_file" \ "$existing_keycloak_db_password_file" rmdir -- "$secret_temp_dir" 2>/dev/null || true } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM 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 expected_type="$3" local expected_keys="$4" local actual_type local actual_keys local actual_keys_sorted local expected_keys_sorted actual_type="$( kubectl --namespace "$namespace" get secret "$name" \ --output='jsonpath={.type}' )" [[ "$actual_type" == "$expected_type" ]] || \ fail "${namespace}/${name} must have type ${expected_type}" 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)" expected_keys_sorted="$(printf '%s\n' "$expected_keys" | LC_ALL=C sort)" [[ "$actual_keys_sorted" == "$expected_keys_sorted" ]] || \ fail "${namespace}/${name} has an unexpected data key set" } validate_root_config_file() { local file="$1" awk ' BEGIN { user_prefix = "export MINIO_ROOT_USER=\042" password_prefix = "export MINIO_ROOT_PASSWORD=\042" } NR == 1 && index($0, user_prefix) == 1 && substr($0, length($0), 1) == "\042" { value = substr($0, length(user_prefix) + 1, length($0) - length(user_prefix) - 1) users++ user = value if (length(value) < 8 || value == "minioadmin" || index(value, "\042") > 0) bad = 1 next } NR == 2 && index($0, password_prefix) == 1 && substr($0, length($0), 1) == "\042" { value = substr($0, length(password_prefix) + 1, length($0) - length(password_prefix) - 1) passwords++ password = value if (length(value) < 16 || value == "minioadmin" || index(value, "\042") > 0) bad = 1 next } { bad = 1 } END { if (NR != 2 || users != 1 || passwords != 1 || user == password || bad) exit 1 } ' "$file" || fail "object-storage/aistor-root-configuration has an invalid config.env contract" } if (( existing_secret_count == TOTAL_SECRET_CONTRACTS )); then validate_secret_type_and_keys \ platform-data keycloak-db-credentials kubernetes.io/basic-auth $'password\nusername' validate_secret_type_and_keys \ keycloak keycloak-db-credentials kubernetes.io/basic-auth $'password\nusername' validate_secret_type_and_keys aistor minio-license Opaque 'minio.license' validate_secret_type_and_keys \ object-storage aistor-root-configuration Opaque 'config.env' platform_username_b64="$(secret_data_b64 platform-data keycloak-db-credentials username)" keycloak_username_b64="$(secret_data_b64 keycloak keycloak-db-credentials username)" expected_username_b64="$(printf '%s' "$DB_USERNAME" | base64)" [[ "$platform_username_b64" == "$expected_username_b64" ]] || \ fail "platform-data/keycloak-db-credentials username must be keycloak" [[ "$keycloak_username_b64" == "$expected_username_b64" ]] || \ fail "keycloak/keycloak-db-credentials username must be keycloak" [[ "$platform_username_b64" == "$keycloak_username_b64" ]] || \ fail "the two Keycloak DB Secret usernames do not match" secret_data_b64 platform-data keycloak-db-credentials password \ | base64 --decode >"$existing_platform_db_password_file" secret_data_b64 keycloak keycloak-db-credentials password \ | base64 --decode >"$existing_keycloak_db_password_file" (( $(wc -c <"$existing_platform_db_password_file") >= 16 )) || \ fail "platform-data/keycloak-db-credentials password must contain at least 16 bytes" (( $(wc -c <"$existing_keycloak_db_password_file") >= 16 )) || \ fail "keycloak/keycloak-db-credentials password must contain at least 16 bytes" cmp --silent -- "$existing_platform_db_password_file" \ "$existing_keycloak_db_password_file" || \ fail "the two Keycloak DB Secret passwords do not match" reload_label="$( kubectl --namespace platform-data get secret keycloak-db-credentials \ --output='jsonpath={.metadata.labels.cnpg\.io/reload}' )" [[ "$reload_label" == "true" ]] || \ fail "platform-data/keycloak-db-credentials must have cnpg.io/reload=true" secret_data_b64 aistor minio-license minio.license \ | base64 --decode >"$existing_license_file" [[ -s "$existing_license_file" ]] || \ fail "aistor/minio-license has an empty minio.license payload" cmp --silent -- "$license_file" "$existing_license_file" || \ fail "the supplied license file differs from the existing Secret; rotation was not performed" secret_data_b64 object-storage aistor-root-configuration config.env \ | base64 --decode >"$existing_root_config_file" [[ -s "$existing_root_config_file" ]] || \ fail "object-storage/aistor-root-configuration has an empty config.env payload" validate_root_config_file "$existing_root_config_file" unset platform_username_b64 keycloak_username_b64 expected_username_b64 printf 'Existing Phase 2 Secret contracts are valid and were reused unchanged.\n' printf 'No credential or license rotation was performed.\n' exit 0 fi [[ -t 0 ]] || fail "an interactive terminal is required for initial Secret creation" read_secret_twice() { local prompt="$1" local minimum_length="$2" 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} >= minimum_length )) || \ fail "${prompt} must contain at least ${minimum_length} characters" [[ "$first" != *$'\n'* && "$first" != *$'\r'* && "$first" != *"'"* ]] || \ fail "${prompt} contains a character unsupported by config.env" printf '%s' "$first" } db_password="$(read_secret_twice 'Keycloak database password' 16)" root_user="$(read_secret_twice 'AIStor root username' 8)" root_password="$(read_secret_twice 'AIStor root password' 16)" [[ "$root_user" != "minioadmin" ]] || fail "do not use the default root username" [[ "$root_password" != "minioadmin" ]] || fail "do not use the default root password" [[ "$root_user" != "$root_password" ]] || fail "root username and password must differ" printf '%s' "$DB_USERNAME" >"$db_user_file" printf '%s' "$db_password" >"$db_password_file" printf 'export MINIO_ROOT_USER="%s"\n' "$root_user" >"$root_config_file" printf 'export MINIO_ROOT_PASSWORD="%s"\n' "$root_password" >>"$root_config_file" validate_root_config_file "$root_config_file" unset db_password root_user root_password printf '\nThis will create exactly four Phase 2 Secrets. Type %s to continue: ' \ "$REQUIRED_CONFIRMATION" read -r confirmation [[ "$confirmation" == "$REQUIRED_CONFIRMATION" ]] || fail "cancelled" create_secret_from_files() { local namespace="$1" local name="$2" local type="$3" shift 3 kubectl --namespace "$namespace" create secret generic "$name" \ --type="$type" "$@" \ --dry-run=client --output=yaml \ | kubectl create --filename=- } create_secret_from_files \ platform-data keycloak-db-credentials kubernetes.io/basic-auth \ --from-file="username=${db_user_file}" \ --from-file="password=${db_password_file}" kubectl --namespace platform-data label secret keycloak-db-credentials \ cnpg.io/reload=true --overwrite create_secret_from_files \ keycloak keycloak-db-credentials kubernetes.io/basic-auth \ --from-file="username=${db_user_file}" \ --from-file="password=${db_password_file}" create_secret_from_files \ aistor minio-license Opaque \ --from-file="minio.license=${license_file}" create_secret_from_files \ object-storage aistor-root-configuration Opaque \ --from-file="config.env=${root_config_file}" printf 'Initial Phase 2 Secret contracts were created without printing payloads.\n' printf 'This script does not perform credential or license rotation.\n'