#!/usr/bin/env bash # Common shell library for Project-Infra scripts. # # Source this with: . "$(dirname "$0")/../lib/common.sh" # or from tasks/: . "$SCRIPT_DIR/../lib/common.sh" # # Provides: # - strict mode + safe IFS # - log()/warn()/err()/die() to stderr with ISO 8601 + level prefix # - trap_cleanup_path() / trap_cleanup_fn() — EXIT 시 경로 rm -rf 또는 함수 호출 # - require_cmd() / require_env() — preconditions # - confirm() — interactive + CONFIRM=yes env-var gate # - mask_secret() — masks sensitive values in logs # - retry() — retry a command with linear backoff # - require_kube_context() / require_production_gate() — env 타깃 검증 가드 # - kubectl() / helm() — 검증된 context를 모든 하위 호출에 고정 # - ns_exists() / ns_phase() / strip_finalizers_in_ns() / # strip_finalizers_all_ns_resources() / force_finalize_namespace() / # wait_namespace_gone() — namespace teardown 복구 헬퍼 # # All functions emit diagnostic output to stderr; stdout stays clean # so callers can pipe subcommand output normally. # shellcheck shell=bash # ----------------------------------------------------------------------------- # strict mode # ----------------------------------------------------------------------------- set -Eeuo pipefail IFS=$'\n\t' # ----------------------------------------------------------------------------- # logging # ----------------------------------------------------------------------------- _ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } log() { printf '%s [INFO] %s\n' "$(_ts)" "$*" >&2; } warn() { printf '%s [WARN] %s\n' "$(_ts)" "$*" >&2; } err() { printf '%s [ERROR] %s\n' "$(_ts)" "$*" >&2; } die() { err "$*" exit 1 } # ----------------------------------------------------------------------------- # cleanup registration # # eval 기반 문자열 cleanup 은 공통 라이브러리에 두기에 부적절하다 (셸 인젝션 # 경로가 열리기 쉽고, Google shell 가이드 기준 code smell). 대신 두 가지 구체 # 타입만 제공한다: # - trap_cleanup_path : EXIT 시 rm -rf 로 삭제할 경로 # - trap_cleanup_fn : EXIT 시 인자 없이 호출할 함수 이름 # 두 종류 다 LIFO 로 실행되고, 실패해도 전체 종료 코드는 보존된다. # ----------------------------------------------------------------------------- _CLEANUP_PATHS=() _CLEANUP_FNS=() trap_cleanup_path() { local p="$1" [[ -n "$p" ]] || return 0 _CLEANUP_PATHS+=("$p") } trap_cleanup_fn() { local fn="$1" declare -F "$fn" >/dev/null 2>&1 \ || { err "trap_cleanup_fn: 함수를 찾을 수 없음: $fn" return 1 } _CLEANUP_FNS+=("$fn") } _run_cleanups() { local rc="${1:-$?}" local i # Signal handler가 exit할 때 EXIT trap으로 재진입하지 않도록 먼저 해제한다. trap - EXIT INT TERM for ((i = ${#_CLEANUP_FNS[@]} - 1; i >= 0; i--)); do "${_CLEANUP_FNS[$i]}" || true done for ((i = ${#_CLEANUP_PATHS[@]} - 1; i >= 0; i--)); do rm -rf -- "${_CLEANUP_PATHS[$i]}" || true done return "$rc" } _handle_signal() { local signal_number="$1" local rc=$((128 + signal_number)) _run_cleanups "$rc" || true exit "$rc" } trap '_run_cleanups $?' EXIT trap '_handle_signal 2' INT trap '_handle_signal 15' TERM # ----------------------------------------------------------------------------- # context-locked Kubernetes clients # ----------------------------------------------------------------------------- # bootstrap/teardown의 context 확인이 끝나면 KUBE_CONTEXT_LOCKED를 export한다. # 이후 current-context가 다른 프로세스에서 바뀌어도 모든 kubectl/helm 호출은 # 검증한 context를 명시적으로 사용한다. 하위 task도 common.sh를 source하므로 # 같은 lock을 상속한다. kubectl() { local locked_context="${KUBE_CONTEXT_LOCKED:-${KUBE_CONTEXT:-}}" if [[ -n "$locked_context" ]]; then command kubectl --context "$locked_context" "$@" else command kubectl "$@" fi } helm() { local locked_context="${KUBE_CONTEXT_LOCKED:-${KUBE_CONTEXT:-}}" if [[ -n "$locked_context" ]]; then command helm --kube-context "$locked_context" "$@" else command helm "$@" fi } _lock_kube_context() { KUBE_CONTEXT_LOCKED="$1" HELM_KUBECONTEXT="$1" export KUBE_CONTEXT_LOCKED HELM_KUBECONTEXT } # ----------------------------------------------------------------------------- # preconditions # ----------------------------------------------------------------------------- require_cmd() { local cmd for cmd in "$@"; do if declare -F "$cmd" >/dev/null 2>&1; then type -P "$cmd" >/dev/null 2>&1 \ || die "필수 명령어가 PATH 에 없습니다: $cmd" else command -v "$cmd" >/dev/null 2>&1 \ || die "필수 명령어가 PATH 에 없습니다: $cmd" fi done } require_env() { local var for var in "$@"; do if [[ -z "${!var:-}" ]]; then die "필수 환경 변수가 비어있습니다: $var" fi done } # ----------------------------------------------------------------------------- # destructive gate # ----------------------------------------------------------------------------- # Usage: confirm "namespace 'mnt' 의 모든 리소스를 삭제합니다. 계속?" # Returns 0 if the user said yes (interactively or via CONFIRM=yes env). confirm() { local prompt="$1" if [[ "${CONFIRM:-}" == "yes" ]]; then log "CONFIRM=yes → 자동 진행: $prompt" return 0 fi if [[ ! -t 0 ]]; then die "비대화 환경에서는 CONFIRM=yes 환경 변수를 지정하세요: $prompt" fi local answer read -r -p "$prompt [y/N]: " answer [[ "$answer" == "y" || "$answer" == "Y" ]] } # ----------------------------------------------------------------------------- # secret masking (for logs) # ----------------------------------------------------------------------------- # Usage: log "root token = $(mask_secret "$ROOT_TOKEN")" mask_secret() { local s="$1" local n=${#s} if ((n <= 8)); then printf '***' else printf '%s***%s' "${s:0:4}" "${s: -4}" fi } # ----------------------------------------------------------------------------- # retry helper # ----------------------------------------------------------------------------- # Usage: retry 5 2 kubectl wait --for=condition=Ready pod/vault-0 -n mnt --timeout=10s # - $1: max attempts # - $2: sleep seconds between attempts # - $3..: command and arguments retry() { local attempts="$1" shift local delay="$1" shift local i=0 until "$@"; do i=$((i + 1)) if ((i >= attempts)); then err "최대 시도 횟수 ${attempts} 회 초과: $*" return 1 fi warn "실패 ($i/$attempts), ${delay}s 후 재시도: $*" sleep "$delay" done } # ----------------------------------------------------------------------------- # kube-context / env 타깃 검증 # ----------------------------------------------------------------------------- # require_kube_context # # env → 기대 context 매핑을 다음 우선순위로 해결한다: # 1) $KUBE_CONTEXT (명시적으로 주입된 값 — CI 에서 사용) # 2) $KUBE_CONTEXT_ (env 별 매핑 — 쉘 rc 에 선언하면 편함) # # 매칭 실패 시 현재 context 를 출력하고 사용자에게 context 이름을 직접 # 재입력받아 확인한다. 비대화 환경은 die. # # 이 함수를 통과하면 다음이 보장된다: # - kubectl 이 가리키는 cluster 가 env 의 의도된 cluster # - 사용자/CI 가 그 사실을 명시적으로 인지함 (실수 클러스터 apply 방지) require_kube_context() { local env_name="$1" require_cmd kubectl local current current="$(kubectl config current-context 2>/dev/null || true)" [[ -n "$current" ]] || die "kubectl current-context 가 비어있습니다. kubeconfig 를 먼저 설정하세요." local env_upper env_upper="$(printf '%s' "$env_name" | tr '[:lower:]' '[:upper:]')" local mapped_var="KUBE_CONTEXT_${env_upper}" local expected="${KUBE_CONTEXT:-${!mapped_var:-}}" if [[ -n "$expected" ]]; then if [[ "$current" != "$expected" ]]; then die "kube-context 불일치: env=$env_name 기대='$expected' 현재='$current' (KUBE_CONTEXT 또는 ${mapped_var} 와 kubectl 현재 context 가 다름)" fi _lock_kube_context "$current" log "kube-context OK: env=$env_name context='$current'" return 0 fi # 매핑이 없을 때: # - 비대화(CI) → 무조건 die. CONFIRM=yes 로도 우회 불가 (context 는 # destructive 작업의 타깃이라 명시성이 절대 원칙). # - 대화형 TTY → 현재 context 이름 재입력으로 확인. if [[ ! -t 0 ]]; then die "비대화 환경에서는 KUBE_CONTEXT 또는 ${mapped_var} 가 필수입니다 (CONFIRM=yes 로 우회 불가)." fi warn "env=$env_name 의 기대 context 가 지정되지 않았습니다." warn " (권장) export KUBE_CONTEXT_${env_upper}='' 를 쉘 rc 에 선언" warn "현재 context: $current" local typed read -r -p "확인을 위해 현재 context 이름을 그대로 입력하세요 ('$current'): " typed [[ "$typed" == "$current" ]] || die "context 이름 불일치 — 중단" _lock_kube_context "$current" } # require_production_gate # # env=prod 에서 파괴적 작업을 실행하려면 ALLOW_PROD_DESTRUCTIVE=yes 를 요구. # 추가로 namespace 이름 재입력을 강제해서 오타 한 번으로 prod 가 날아가는 것을 막는다. # dev/staging 은 통과. require_production_gate() { local env_name="$1" local ns="$2" [[ "$env_name" == "prod" ]] || return 0 if [[ "${ALLOW_PROD_DESTRUCTIVE:-}" != "yes" ]]; then die "env=prod 파괴적 작업은 ALLOW_PROD_DESTRUCTIVE=yes 환경 변수가 필요합니다." fi if [[ ! -t 0 ]]; then die "env=prod 는 대화형 TTY 에서만 실행 가능합니다 (namespace 재입력 확인 필요)." fi local typed warn "env=prod 파괴적 작업 — namespace '$ns' 를 그대로 재입력하세요." read -r -p "namespace: " typed [[ "$typed" == "$ns" ]] || die "namespace 재입력 불일치 — 중단" } # ----------------------------------------------------------------------------- # namespace / finalizer 정리 헬퍼 # ----------------------------------------------------------------------------- # ns_phase — namespace 의 .status.phase 를 출력. 없으면 빈 문자열. ns_phase() { local ns="$1" kubectl get namespace "$ns" -o jsonpath='{.status.phase}' 2>/dev/null || true } # ns_exists — 존재하면 0, 없으면 1 ns_exists() { kubectl get namespace "$1" >/dev/null 2>&1 } # strip_finalizers_in_ns # 지정한 kind 들의 모든 인스턴스에서 metadata.finalizers 를 제거한다. # kind 가 CRD 여도 동작 (kubectl 이 해당 API 서버에 등록되어 있기만 하면). strip_finalizers_in_ns() { local ns="$1" shift local kind obj for kind in "$@"; do while IFS= read -r obj; do [[ -z "$obj" ]] && continue kubectl -n "$ns" patch "$obj" --type=merge \ -p '{"metadata":{"finalizers":null}}' >/dev/null 2>&1 || true log " finalizer 제거: -n $ns $obj" done < <(kubectl -n "$ns" get "$kind" -o name 2>/dev/null || true) done } # strip_finalizers_all_ns_resources # namespace 에 남아있는 모든 namespaced 리소스의 finalizer 를 일괄 제거. # 최후 수단 — Terminating 에 걸린 리소스들을 떼어낼 때만 사용. strip_finalizers_all_ns_resources() { local ns="$1" local kinds # namespaced=true 리소스 종류만 kinds=$(kubectl api-resources --namespaced=true --verbs=delete -o name 2>/dev/null) local kind for kind in $kinds; do while IFS= read -r obj; do [[ -z "$obj" ]] && continue kubectl -n "$ns" patch "$obj" --type=merge \ -p '{"metadata":{"finalizers":null}}' >/dev/null 2>&1 || true done < <(kubectl -n "$ns" get "$kind" -o name 2>/dev/null || true) done } # force_finalize_namespace # namespace 자체의 spec.finalizers 를 비워서 API 서버가 강제 삭제하도록 한다. # kubectl replace --raw 로 /finalize 엔드포인트 호출. # 전제: kubectl + jq 존재. 주의 — orphaned PV 등이 남을 수 있음. force_finalize_namespace() { local ns="$1" require_cmd jq log " namespace $ns 강제 finalize (API /finalize)" kubectl get namespace "$ns" -o json \ | jq '.spec.finalizers = [] | .metadata.finalizers = []' \ | kubectl replace --raw "/api/v1/namespaces/${ns}/finalize" -f - >/dev/null } # wait_namespace_gone # namespace 가 완전히 사라질 때까지 대기. timeout 초과 시 1 반환. wait_namespace_gone() { local ns="$1" timeout="${2:-60}" i=0 while ns_exists "$ns"; do i=$((i + 1)) if ((i >= timeout)); then return 1 fi sleep 1 done return 0 }