#!/usr/bin/env bash # ============================================================================= # deploy-test-app-with-cnpg.sh # 내장 bitnami postgresql 서브차트를 끄고 전용 cnpg-cluster 인스턴스를 외부 DB로 # 쓰도록 바꾼 카탈로그 앱(airflow/lakekeeper/mlflow/superset)이 실제로 그 DB에 # 연결해 기동하는지 격리된 테스트 네임스페이스에서 확인한다. # # scripts/deploy-test/deploy-test-cnpg-cluster.sh 와 같은 원칙을 따른다 — # operator(cloudnative-pg)는 상시 컴포넌트로 재사용하고, 앱/DB 릴리스와 테스트 # 네임스페이스는 매 실행 재생성한다. 정리는 성공/실패와 무관하게 항상 수행한다(trap). # # 이 스크립트가 확인하는 것은 "DB 연결이 되는가"이지 앱의 전체 기능 검증이 아니다. # ingress/cert-manager/OIDC(keycloak) 등 DB와 무관한 의존성 때문에 앱 차트의 # `helm install --wait`가 타임아웃할 수 있어, 앱 설치는 --wait 없이 진행하고 # 파드 상태 + 로그의 DB 연결 실패 패턴 부재로 판단한다. # # 사용: # APP_CHART_DIR=manifests/helm/airflow/1.16.0 \ # APP_RELEASE=airflow \ # DB_CUSTOM_VALUES=manifests/applicationset/airflow/1.16.0/airflow-db-values.yaml \ # DB_SECRET_USER=airflow DB_SECRET_PASSWORD=airflow \ # TEST_NAMESPACE=airflow-test-build \ # bash scripts/deploy-test/deploy-test-app-with-cnpg.sh /tmp/deploy-test-out # # 환경변수: # APP_CHART_DIR (필수) 앱 차트 경로 (예: manifests/helm/airflow/1.16.0) # APP_RELEASE (필수) 앱 helm 릴리스명 # APP_CUSTOM_VALUES 기본 "$APP_CHART_DIR/custom-values.yaml" # DB_CUSTOM_VALUES (필수) cnpg-cluster용 앱 전용 오버레이 — 각 앱의 # manifests/applicationset///-db-values.yaml # (ArgoCD ApplicationSet의 valuesPath와 동일한 파일) # DB_RELEASE 기본 "${APP_RELEASE}-db" # DB_SECRET_NAME 기본 "${DB_RELEASE}-app-secret" (각 앱 -db-values.yaml의 # bootstrap.initdb.secretName과 반드시 일치해야 한다) # DB_SECRET_USER (필수) DB owner 계정명 # DB_SECRET_PASSWORD (필수) DB owner 비밀번호 — 앱 custom-values.yaml의 평문 값과 동일해야 함 # OPERATOR_CHART_DIR 기본 manifests/helm/cloudnative-pg/0.29.0 # OPERATOR_NAMESPACE 기본 cnpg-system (상시 — 이 스크립트가 지우지 않는다) # OPERATOR_RELEASE 기본 cnpg # TEST_NAMESPACE (필수) 격리된 테스트 네임스페이스 (매 실행 재생성) # DB_WAIT_TIMEOUT 기본 300 (초) — Cluster healthy 대기 한도 # APP_WAIT_SECONDS 기본 180 (초) — 앱 파드 상태 관찰 시간 # APP_EXTRA_VALUES 선택. custom-values.yaml 위에 추가로 얹을 배포-테스트 전용 values 파일 # (예: 외부 keycloak 등 이 테스트 환경에 없는 의존성을 끄는 용도). # 카탈로그의 실제 권장 설정(custom-values.yaml)은 건드리지 않는다 — # scripts/deploy-test/fixtures/ 에 보관한다. # APP_POD_SELECTOR (강력 권장) DB에 실제로 연결하는 파드를 가리키는 label selector # (예: airflow "component=webserver", mlflow # "app.kubernetes.io/component=tracking", superset "app=superset", # lakekeeper "app.kubernetes.io/component=catalog"). 지정하면 이 # selector 에 매치되는 파드가 Running+Ready 가 될 때까지만 기다린다 — # git-sync/ingress 등 DB 와 무관한 다른 파드의 상태는 무시한다. # 미지정 시 네임스페이스의 아무 파드나 1개 Running 이면 통과로 간주하는 # 느슨한 판정으로 대체된다(권장하지 않음 — 실제 DB 소비 파드를 못 볼 수 있다). # # 산출물 (OUT_DIR): deploy-test.log # 종료 코드: 0 = DB 연결 성공(파드 Running + 로그에 DB 연결 실패 패턴 없음), 그 외 = 실패. # 정리는 종료 코드와 무관하게 항상 수행된다. # ============================================================================= set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" OUT_DIR="${1:?사용법: deploy-test-app-with-cnpg.sh }" mkdir -p "$OUT_DIR" LOG="$OUT_DIR/deploy-test.log" : > "$LOG" APP_CHART_DIR="${APP_CHART_DIR:?APP_CHART_DIR 환경변수가 필요하다 (앱 차트 경로)}" APP_RELEASE="${APP_RELEASE:?APP_RELEASE 환경변수가 필요하다}" APP_CUSTOM_VALUES="${APP_CUSTOM_VALUES:-$APP_CHART_DIR/custom-values.yaml}" DB_CUSTOM_VALUES="${DB_CUSTOM_VALUES:?DB_CUSTOM_VALUES 환경변수가 필요하다 (cnpg-cluster 앱 전용 오버레이)}" DB_RELEASE="${DB_RELEASE:-${APP_RELEASE}-db}" DB_SECRET_NAME="${DB_SECRET_NAME:-${DB_RELEASE}-app-secret}" DB_SECRET_USER="${DB_SECRET_USER:?DB_SECRET_USER 환경변수가 필요하다}" DB_SECRET_PASSWORD="${DB_SECRET_PASSWORD:?DB_SECRET_PASSWORD 환경변수가 필요하다}" OPERATOR_CHART_DIR="${OPERATOR_CHART_DIR:-$REPO_ROOT/manifests/helm/cloudnative-pg/0.29.0}" OPERATOR_NAMESPACE="${OPERATOR_NAMESPACE:-cnpg-system}" OPERATOR_RELEASE="${OPERATOR_RELEASE:-cnpg}" TEST_NAMESPACE="${TEST_NAMESPACE:?TEST_NAMESPACE 환경변수가 필요하다 (격리된 테스트 네임스페이스)}" DB_WAIT_TIMEOUT="${DB_WAIT_TIMEOUT:-300}" # 이미지가 큰 앱(flowise 등)은 최초 pull 에만 5분 넘게 걸려 300s 로는 ImagePullBackOff 상태에서 # 타임아웃한다(실측). 캐시된 뒤에는 1분 내로 끝나므로 넉넉히 잡아도 손해가 없다. APP_WAIT_SECONDS="${APP_WAIT_SECONDS:-600}" APP_EXTRA_VALUES="${APP_EXTRA_VALUES:-}" APP_POD_SELECTOR="${APP_POD_SELECTOR:-}" CNPG_CLUSTER_CHART_DIR="${CNPG_CLUSTER_CHART_DIR:-$REPO_ROOT/manifests/helm/cnpg-cluster/1.0.0}" log() { echo "$(date -u +%H:%M:%S) $*" | tee -a "$LOG"; } log "== 사전 확인 ==" for t in kubectl helm python3; do command -v "$t" >/dev/null || { log "::error:: $t 없음"; exit 2; } done [ -d "$OPERATOR_CHART_DIR" ] || { log "::error:: operator 차트 없음: $OPERATOR_CHART_DIR"; exit 2; } [ -d "$APP_CHART_DIR" ] || { log "::error:: 앱 차트 없음: $APP_CHART_DIR"; exit 2; } [ -f "$APP_CUSTOM_VALUES" ] || { log "::error:: 앱 custom-values 없음: $APP_CUSTOM_VALUES"; exit 2; } [ -f "$DB_CUSTOM_VALUES" ] || { log "::error:: DB custom-values 없음: $DB_CUSTOM_VALUES"; exit 2; } if ! kubectl get ns >/dev/null 2>>"$LOG"; then log "::error:: 클러스터 연결 실패 — kubeconfig/네트워크 확인" exit 2 fi log " OK (kubectl context: $(kubectl config current-context 2>/dev/null))" # --- operator: 상시 컴포넌트. 없으면 1회 설치, 있으면 재사용 -------------------- ensure_operator() { if helm status "$OPERATOR_RELEASE" -n "$OPERATOR_NAMESPACE" >/dev/null 2>>"$LOG"; then log "== operator: 기존 릴리스 재사용 ($OPERATOR_RELEASE/$OPERATOR_NAMESPACE) ==" return 0 fi if kubectl get validatingwebhookconfiguration cnpg-validating-webhook-configuration >/dev/null 2>&1; then owner_ns="$(kubectl get validatingwebhookconfiguration cnpg-validating-webhook-configuration \ -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-namespace}' 2>/dev/null)" owner_rel="$(kubectl get validatingwebhookconfiguration cnpg-validating-webhook-configuration \ -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null)" if [ "$owner_ns" != "$OPERATOR_NAMESPACE" ] || [ "$owner_rel" != "$OPERATOR_RELEASE" ]; then log "::error:: 다른 CNPG operator 가 이미 클러스터에 있다 (release=$owner_rel ns=$owner_ns)." exit 3 fi fi log "== operator: 신규 설치 ($OPERATOR_RELEASE/$OPERATOR_NAMESPACE, 차트 기본값) ==" helm install "$OPERATOR_RELEASE" "$OPERATOR_CHART_DIR" \ -n "$OPERATOR_NAMESPACE" --create-namespace --wait --timeout 600s \ >>"$LOG" 2>&1 || { log "::error:: operator 설치 실패 — $LOG 확인"; exit 1; } log " OK" } # --- 테스트 네임스페이스 정리 (선-정리·후-정리 공용) -------------------------- cleanup_test_ns() { kubectl get ns "$TEST_NAMESPACE" >/dev/null 2>&1 || return 0 log "== 정리: $TEST_NAMESPACE ==" if helm status "$APP_RELEASE" -n "$TEST_NAMESPACE" >/dev/null 2>&1; then helm uninstall "$APP_RELEASE" -n "$TEST_NAMESPACE" >>"$LOG" 2>&1 fi if helm status "$DB_RELEASE" -n "$TEST_NAMESPACE" >/dev/null 2>&1; then helm uninstall "$DB_RELEASE" -n "$TEST_NAMESPACE" >>"$LOG" 2>&1 fi kubectl -n "$TEST_NAMESPACE" delete pvc --all --wait --timeout=120s >>"$LOG" 2>&1 # storageClass longhorn은 reclaimPolicy Retain이라 PVC 삭제만으로는 볼륨이 안 지워진다 # (.claude/pitfalls.md) — 이 네임스페이스가 남긴 PV만 골라 지운다. local pv for pv in $(kubectl get pv -o json 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin) for p in d.get('items', []): cr = p['spec'].get('claimRef') or {} if cr.get('namespace') == '$TEST_NAMESPACE': print(p['metadata']['name']) " 2>/dev/null); do kubectl delete pv "$pv" --ignore-not-found >>"$LOG" 2>&1 \ || log "::warning:: PV $pv 삭제 실패 — 수동 확인 필요" # k8s PV 오브젝트를 지워도 Longhorn 자체의 Volume 커스텀리소스는 남는다(실측 — # storageScheduled가 계속 누적돼 결국 "insufficient storage"로 다른 배포가 막힘). # PV와 이름이 같은 volumes.longhorn.io를 명시적으로 지워야 실제 디스크가 반환된다. kubectl -n longhorn-system delete volumes.longhorn.io "$pv" --ignore-not-found >>"$LOG" 2>&1 done kubectl delete ns "$TEST_NAMESPACE" --wait --timeout=120s >>"$LOG" 2>&1 log " 완료" } RESULT=1 trap 'cleanup_test_ns' EXIT ensure_operator cleanup_test_ns # 직전 실행이 비정상 종료했을 경우를 대비한 선-정리 log "== 테스트 네임스페이스 생성: $TEST_NAMESPACE ==" kubectl create ns "$TEST_NAMESPACE" >>"$LOG" 2>&1 log "== DB 계정 시크릿 생성: $DB_SECRET_NAME (user=$DB_SECRET_USER) ==" kubectl -n "$TEST_NAMESPACE" create secret generic "$DB_SECRET_NAME" \ --from-literal=username="$DB_SECRET_USER" --from-literal=password="$DB_SECRET_PASSWORD" \ >>"$LOG" 2>&1 || { log "::error:: 시크릿 생성 실패"; exit 1; } log "== DB: cnpg-cluster 설치 ($DB_RELEASE/$TEST_NAMESPACE) ==" # DB_CUSTOM_VALUES는 ArgoCD ApplicationSet용 템플릿(manifests/applicationset/// # -db-values.yaml)이라 secretName이 "$INFISICAL_SECRET" 플레이스홀더로 되어 있다 — # dip-console이 실제 배포 시 렌더링해 치환하는 값이다. 여기서는 위에서 실제로 만든 # $DB_SECRET_NAME 시크릿으로 --set 오버라이드한다. if ! helm install "$DB_RELEASE" "$CNPG_CLUSTER_CHART_DIR" \ -f "$CNPG_CLUSTER_CHART_DIR/custom-values.yaml" -f "$DB_CUSTOM_VALUES" \ --set "bootstrap.initdb.secretName=$DB_SECRET_NAME" \ -n "$TEST_NAMESPACE" --wait --timeout "${DB_WAIT_TIMEOUT}s" \ >>"$LOG" 2>&1; then log "::error:: cnpg-cluster 설치 실패 — $LOG 확인" exit 1 fi log "== DB: Cluster healthy 대기 (최대 ${DB_WAIT_TIMEOUT}s) ==" # FQN 필수 — kubectl get cluster 는 Rancher/CAPI 의 동명 CRD 와 충돌한다(.claude/pitfalls.md) instances="$(kubectl -n "$TEST_NAMESPACE" get clusters.postgresql.cnpg.io "$DB_RELEASE" \ -o jsonpath='{.spec.instances}' 2>/dev/null)" deadline=$((SECONDS + DB_WAIT_TIMEOUT)) phase="" ready=0 while [ "$SECONDS" -lt "$deadline" ]; do phase="$(kubectl -n "$TEST_NAMESPACE" get clusters.postgresql.cnpg.io "$DB_RELEASE" \ -o jsonpath='{.status.phase}' 2>/dev/null)" ready="$(kubectl -n "$TEST_NAMESPACE" get clusters.postgresql.cnpg.io "$DB_RELEASE" \ -o jsonpath='{.status.readyInstances}' 2>/dev/null)" log " phase=$phase ready=${ready:-0}/$instances" [ "$phase" = "Cluster in healthy state" ] && [ "${ready:-0}" = "$instances" ] && break sleep 10 done if [ "$phase" != "Cluster in healthy state" ] || [ "${ready:-0}" != "$instances" ]; then log "::error:: 타임아웃 — DB Cluster 가 healthy 상태에 도달하지 못함" kubectl -n "$TEST_NAMESPACE" get pods -l "cnpg.io/cluster=$DB_RELEASE" >>"$LOG" 2>&1 exit 1 fi log "== DB: 부트스트랩 확인 (role/database) ==" primary_pod="$(kubectl -n "$TEST_NAMESPACE" get pods \ -l "cnpg.io/cluster=$DB_RELEASE,cnpg.io/instanceRole=primary" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)" kubectl -n "$TEST_NAMESPACE" exec "$primary_pod" -c postgres -- \ psql -U postgres -Atc "\du" >>"$LOG" 2>&1 kubectl -n "$TEST_NAMESPACE" exec "$primary_pod" -c postgres -- \ psql -U postgres -Atc "\l" >>"$LOG" 2>&1 log "== 앱: 설치 ($APP_RELEASE/$TEST_NAMESPACE, --wait 없이 진행) ==" # ingress/cert-manager/OIDC 등 DB 와 무관한 의존성 때문에 helm --wait 가 타임아웃할 수 있어 # 설치만 하고 파드 상태는 아래에서 직접 관찰한다. app_values_args=(-f "$APP_CUSTOM_VALUES") if [ -n "$APP_EXTRA_VALUES" ]; then [ -f "$APP_EXTRA_VALUES" ] || { log "::error:: APP_EXTRA_VALUES 파일 없음: $APP_EXTRA_VALUES"; exit 2; } log " 추가 배포-테스트 전용 values 사용: $APP_EXTRA_VALUES" app_values_args+=(-f "$APP_EXTRA_VALUES") fi if ! helm install "$APP_RELEASE" "$APP_CHART_DIR" "${app_values_args[@]}" \ -n "$TEST_NAMESPACE" --timeout 300s \ >>"$LOG" 2>&1; then log "::error:: 앱 차트 설치 실패 — $LOG 확인" exit 1 fi log "== 앱: 파드 상태 관찰 (최대 ${APP_WAIT_SECONDS}s) — DB 연결 여부 판단 ==" if [ -n "$APP_POD_SELECTOR" ]; then log " 대상 파드 selector: $APP_POD_SELECTOR" else log " ::warning:: APP_POD_SELECTOR 미지정 — 네임스페이스의 아무 파드나 1개 Running 이면 통과로 판정한다" fi deadline=$((SECONDS + APP_WAIT_SECONDS)) DB_ERROR_PATTERN='password authentication failed|could not connect to server|connection refused|no pg_hba.conf entry|FATAL:.*database|OperationalError|could not translate host name' db_connected="" while [ "$SECONDS" -lt "$deadline" ]; do kubectl -n "$TEST_NAMESPACE" get pods >>"$LOG" 2>&1 target_ready_count="$(kubectl -n "$TEST_NAMESPACE" get pods ${APP_POD_SELECTOR:+-l "$APP_POD_SELECTOR"} -o json 2>/dev/null | python3 -c " import json,sys d=json.load(sys.stdin) n=0 for p in d.get('items', []): phase = p['status'].get('phase') statuses = p['status'].get('containerStatuses') or [] ready = bool(statuses) and all(c.get('ready') for c in statuses) if phase == 'Running' and ready: n += 1 print(n) " 2>/dev/null)" # DB 관련 여부와 무관하게 네임스페이스 전체 로그에서 연결 실패 패턴을 찾는다(넓게 감지). db_errors="$(kubectl -n "$TEST_NAMESPACE" get pods -o name 2>/dev/null | while read -r pod; do kubectl -n "$TEST_NAMESPACE" logs "$pod" --all-containers --tail=200 2>/dev/null done | grep -Ei "$DB_ERROR_PATTERN" | head -5)" if [ -n "$db_errors" ]; then log "::error:: 파드 로그에서 DB 연결 실패 패턴 발견:" echo "$db_errors" | tee -a "$LOG" db_connected="false" break fi if [ "${target_ready_count:-0}" -ge 1 ]; then log " 대상 파드 Running/Ready ${target_ready_count}개 확인, DB 연결 실패 로그 없음" db_connected="true" break fi sleep 15 done if [ "$db_connected" = "true" ]; then log "== 결과: PASS (DB 연결 확인) ==" RESULT=0 else log "::error:: 타임아웃 또는 DB 연결 실패 — 파드/로그 상세는 $LOG 참고" kubectl -n "$TEST_NAMESPACE" get pods -o wide >>"$LOG" 2>&1 RESULT=1 fi exit "$RESULT"