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

325 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -Eeuo pipefail
# 비밀값이 명령 추적, argv 또는 표준 출력에 노출되지 않도록 한다.
set +x
readonly REQUIRED_CONFIRMATION="APPLY AISTOR SECRETS"
readonly -a SECRET_CONTRACTS=(
"aistor/minio-license"
"object-storage/aistor-root-configuration"
)
license_file=""
root_config_file=""
generate_root_config=false
execute_requested=false
license_created=false
root_config_created=false
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'USAGE'
Usage:
bash scripts/bootstrap/create-aistor-secrets.sh \
--license-file /home/donghyeon/.secrets/aistor/minio.license \
--root-config-file /home/donghyeon/.secrets/aistor/root.env \
--generate-root-config \
--execute
Creates exactly these two Secrets only when both are absent:
aistor/minio-license
object-storage/aistor-root-configuration
When both already exist, validates and reuses them unchanged. A partial state
is refused. --generate-root-config creates the local 0600 credential file only
when it is absent; it never overwrites or rotates an existing credential.
USAGE
}
while (( $# > 0 )); do
case "$1" in
--license-file)
(( $# >= 2 )) || fail "--license-file requires a path"
license_file="$2"
shift 2
;;
--root-config-file)
(( $# >= 2 )) || fail "--root-config-file requires a path"
root_config_file="$2"
shift 2
;;
--generate-root-config)
generate_root_config=true
shift
;;
--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
}
[[ "$license_file" == /* ]] || fail "--license-file must be an absolute path"
[[ "$root_config_file" == /* ]] || \
fail "--root-config-file must be an absolute path"
for command_name in awk base64 chmod cmp find install jq kubectl mktemp \
openssl sort stat wc; do
command -v "$command_name" >/dev/null 2>&1 || \
fail "${command_name} is required"
done
validate_private_file() {
local file="$1"
local description="$2"
[[ -f "$file" && ! -L "$file" && -O "$file" && -r "$file" && -s "$file" ]] || \
fail "${description} must be a readable, non-empty, current-user-owned regular file"
[[ "$(stat --format='%a' -- "$file")" == "600" ]] || \
fail "${description} must have mode 0600: ${file}"
}
validate_license_file() {
local payload
validate_private_file "$license_file" "license file"
payload="$(<"$license_file")"
payload="${payload%$'\r'}"
[[ "$payload" =~ ^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]] || \
fail "license file must contain one JWT value beginning with eyJ"
unset payload
}
validate_root_config_file() {
local file="$1"
validate_private_file "$file" "AIStor root configuration file"
awk '
BEGIN {
user_prefix = "export MINIO_ROOT_USER="
password_prefix = "export MINIO_ROOT_PASSWORD="
}
NR == 1 && index($0, user_prefix) == 1 {
value = substr($0, length(user_prefix) + 1)
if (length(value) >= 3 && substr(value, 1, 1) == "\042" && substr(value, length(value), 1) == "\042") {
value = substr(value, 2, length(value) - 2)
users++
user = value
if (length(value) < 8 || value == "minioadmin") bad = 1
next
}
}
NR == 2 && index($0, password_prefix) == 1 {
value = substr($0, length(password_prefix) + 1)
if (length(value) >= 3 && substr(value, 1, 1) == "\042" && substr(value, length(value), 1) == "\042") {
value = substr(value, 2, length(value) - 2)
passwords++
password = value
if (length(value) < 16 || value == "minioadmin") bad = 1
next
}
}
{ bad = 1 }
END {
if (NR != 2 || users != 1 || passwords != 1 ||
user == password || bad) exit 1
}
' "$file" || \
fail "root configuration must contain exactly valid MINIO_ROOT_USER and MINIO_ROOT_PASSWORD exports"
}
generate_root_configuration() {
local parent_dir="${root_config_file%/*}"
local root_user
local root_password
[[ "$parent_dir" != "$root_config_file" ]] || \
fail "root configuration path has no parent directory"
[[ ! -e "$root_config_file" && ! -L "$root_config_file" ]] || \
fail "refusing to overwrite existing root configuration: ${root_config_file}"
install -d -m 0700 -- "$parent_dir"
[[ -d "$parent_dir" && ! -L "$parent_dir" && -O "$parent_dir" ]] || \
fail "root configuration parent must be a current-user-owned directory"
[[ "$(stat --format='%a' -- "$parent_dir")" == "700" ]] || \
fail "root configuration parent must have mode 0700: ${parent_dir}"
root_user="hyeonworks-aistor-$(openssl rand -hex 4)"
root_password="$(openssl rand -hex 24)"
umask 077
{
printf 'export MINIO_ROOT_USER="%s"\n' "$root_user"
printf 'export MINIO_ROOT_PASSWORD="%s"\n' "$root_password"
} >"$root_config_file"
chmod 0600 -- "$root_config_file"
unset root_user root_password
validate_root_config_file "$root_config_file"
printf 'Generated a local AIStor root configuration with mode 0600: %s\n' \
"$root_config_file"
}
validate_license_file
for namespace in aistor object-storage; do
kubectl get namespace "$namespace" >/dev/null 2>&1 || \
fail "namespace ${namespace} does not exist; apply AIStor namespaces first"
done
existing_secret_count=0
for contract in "${SECRET_CONTRACTS[@]}"; do
namespace="${contract%%/*}"
name="${contract#*/}"
if kubectl --namespace "$namespace" get secret "$name" >/dev/null 2>&1; then
(( existing_secret_count += 1 ))
fi
done
if (( existing_secret_count > 0 && existing_secret_count < ${#SECRET_CONTRACTS[@]} )); then
fail "partial AIStor Secret state detected; no Secret was created or rotated"
fi
umask 077
secret_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-aistor-secrets.XXXXXX")"
existing_license_file="${secret_temp_dir}/existing-minio-license"
existing_root_config_file="${secret_temp_dir}/existing-root-config"
cleanup() {
case "$secret_temp_dir" in
/tmp/platform-aistor-secrets.*|"${TMPDIR:-/tmp}"/platform-aistor-secrets.*)
rm -rf -- "$secret_temp_dir"
;;
*)
printf 'WARNING: refusing to remove unexpected temporary directory: %s\n' \
"$secret_temp_dir" >&2
;;
esac
}
trap cleanup EXIT
validate_secret_contract() {
local namespace="$1"
local name="$2"
local expected_key="$3"
local actual_type
local actual_keys
actual_type="$(
kubectl --namespace "$namespace" get secret "$name" \
--output=jsonpath='{.type}'
)"
[[ "$actual_type" == "Opaque" ]] || \
fail "${namespace}/${name} must have type Opaque"
actual_keys="$(
kubectl --namespace "$namespace" get secret "$name" --output=json |
jq -r '.data | keys[]' | sort
)"
[[ "$actual_keys" == "$expected_key" ]] || \
fail "${namespace}/${name} must contain only the ${expected_key} key"
}
if (( existing_secret_count == ${#SECRET_CONTRACTS[@]} )); then
validate_secret_contract aistor minio-license minio.license
validate_secret_contract \
object-storage aistor-root-configuration config.env
kubectl --namespace aistor get secret minio-license \
--output=jsonpath='{.data.minio\.license}' |
base64 --decode >"$existing_license_file"
cmp --silent -- "$license_file" "$existing_license_file" || \
fail "the supplied license differs from the existing Secret; rotation was not performed"
kubectl --namespace object-storage get secret aistor-root-configuration \
--output=jsonpath='{.data.config\.env}' |
base64 --decode >"$existing_root_config_file"
chmod 0600 -- "$existing_root_config_file"
validate_root_config_file "$existing_root_config_file"
if [[ -e "$root_config_file" || -L "$root_config_file" ]]; then
validate_root_config_file "$root_config_file"
cmp --silent -- "$root_config_file" "$existing_root_config_file" || \
fail "local root configuration differs from the existing Secret; rotation was not performed"
else
fail "existing Secret is valid, but the local root configuration file is missing"
fi
printf 'Existing AIStor Secret contracts are valid and were reused unchanged.\n'
printf 'No credential or license rotation was performed.\n'
exit 0
fi
if [[ ! -e "$root_config_file" && ! -L "$root_config_file" ]]; then
[[ "$generate_root_config" == true ]] || \
fail "root configuration is absent; pass --generate-root-config to create it"
generate_root_configuration
else
validate_root_config_file "$root_config_file"
fi
[[ -t 0 ]] || fail "an interactive terminal is required for initial Secret creation"
printf '\nThis will create exactly two AIStor Secrets. Type %s to continue: ' \
"$REQUIRED_CONFIRMATION"
read -r confirmation
[[ "$confirmation" == "$REQUIRED_CONFIRMATION" ]] || fail "cancelled"
rollback_new_secrets() {
set +e
if [[ "$root_config_created" == true ]]; then
kubectl --namespace object-storage delete secret \
aistor-root-configuration --ignore-not-found >/dev/null
fi
if [[ "$license_created" == true ]]; then
kubectl --namespace aistor delete secret \
minio-license --ignore-not-found >/dev/null
fi
if [[ "$root_config_created" == true || "$license_created" == true ]]; then
printf 'ROLLBACK: removed only Secrets created by this failed invocation.\n' >&2
fi
}
on_error() {
local status="$1"
local line="$2"
trap - ERR
rollback_new_secrets
printf 'ERROR: Secret creation failed at line %s (exit %s).\n' \
"$line" "$status" >&2
exit "$status"
}
trap 'on_error "$?" "$LINENO"' ERR
kubectl --namespace aistor create secret generic minio-license \
--type=Opaque \
--from-file="minio.license=${license_file}"
license_created=true
kubectl --namespace object-storage create secret generic \
aistor-root-configuration \
--type=Opaque \
--from-file="config.env=${root_config_file}"
root_config_created=true
validate_secret_contract aistor minio-license minio.license
validate_secret_contract object-storage aistor-root-configuration config.env
printf 'Created both AIStor Secret contracts without printing payloads.\n'
printf 'Local root credentials remain only in: %s\n' "$root_config_file"
printf 'This script does not perform credential or license rotation.\n'