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