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

324 lines
12 KiB
Bash
Executable File

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