324 lines
12 KiB
Bash
Executable File
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'
|