#!/usr/bin/env bash # ============================================================================= # deploy-test-argo-cd.sh # argo-cd 차트가 지정한 values 로 실제 Kubernetes 배포 수준에서 정상 동작하는지 # 확인한다. helm template/lint 은 "렌더가 되는가"만 본다. 이 스크립트는 # "실제로 뜨고, API 가 응답하고, 로그인이 되는가"를 본다 — # .claude/deploy-test-procedure.md 의 원칙. # # 운영 argo-cd 를 교체하기 전에 같은 클러스터에서 격리 검증하는 용도다. # # ── 같은 클러스터에 두 번째 argo-cd 를 띄울 때의 제약 (실측) ────────────── # CRD 3종(applications/applicationsets/appprojects)은 클러스터 전역이고 이미 # 운영 릴리스가 소유한다(meta.helm.sh/release-name). 그대로 설치하면 Helm 이 # ownership 충돌로 거부하므로 반드시 crds.install=false 로 끈다. 대신 CRD 가 # 클러스터에 이미 있어야 하며, 스크립트가 사전에 확인한다. # ClusterRole/Binding 은 릴리스명 접두라 충돌하지 않는다 # (운영 argo-cd-argocd-* vs 테스트 argocd-build-*). # application-controller 는 자기 네임스페이스의 Application 만 감시하므로 # 운영 Application 을 건드리지 않는다. # # Ingress 는 항상 끈다 — 운영과 같은 host 로 Ingress 가 뜨면 트래픽을 가로챈다 # (.claude/pitfalls.md 의 ingress host 충돌). # # 사용: # bash scripts/deploy-test/deploy-test-argo-cd.sh # VALUES_FILE=/path/to/values.yaml ADMIN_PASSWORD=xxx \ # bash scripts/deploy-test/deploy-test-argo-cd.sh /tmp/deploy-test-argocd # # 환경변수: # CHART_DIR 기본 manifests/helm/argo-cd/10.4.0 # VALUES_FILE (선택) 검증할 values 파일. 미지정 시 차트 기본값만 사용 # ADMIN_PASSWORD (선택) 지정 시 admin 로그인까지 검증한다. 비밀번호를 승계해 # 재설치하는 경우 이 검증이 핵심이다(해시가 맞는지 확인) # TEST_NAMESPACE 기본 argocd-test-build (매 실행 재생성) # RELEASE 기본 argocd-build # LOCAL_PORT 기본 18080 (port-forward 로컬 포트) # WAIT_TIMEOUT 기본 420 (초) — 워크로드 롤아웃 대기 한도 # EXPECT_VERSION 기본 차트 Chart.yaml 의 appVersion (예: v3.5.1) # # 산출물 (OUT_DIR): # deploy-test.log 전체 실행 로그 # # 종료 코드: 0 = 기능 확인 통과, 그 외 = 실패. # 정리는 종료 코드와 무관하게 항상 수행된다. # ============================================================================= set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" OUT_DIR="${1:?사용법: deploy-test-argo-cd.sh }" mkdir -p "$OUT_DIR" LOG="$OUT_DIR/deploy-test.log" : > "$LOG" CHART_DIR="${CHART_DIR:-$REPO_ROOT/manifests/helm/argo-cd/10.4.0}" VALUES_FILE="${VALUES_FILE:-}" ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" TEST_NAMESPACE="${TEST_NAMESPACE:-argocd-test-build}" RELEASE="${RELEASE:-argocd-build}" LOCAL_PORT="${LOCAL_PORT:-18080}" WAIT_TIMEOUT="${WAIT_TIMEOUT:-420}" log() { echo "$(date -u +%H:%M:%S) $*" | tee -a "$LOG"; } log "== 사전 확인 ==" for t in kubectl helm python3 curl; do command -v "$t" >/dev/null || { log "::error:: $t 없음"; exit 2; } done [ -d "$CHART_DIR" ] || { log "::error:: 차트 없음: $CHART_DIR"; exit 2; } if [ -n "$VALUES_FILE" ] && [ ! -f "$VALUES_FILE" ]; then log "::error:: VALUES_FILE 없음: $VALUES_FILE"; exit 2 fi if ! kubectl get ns >/dev/null 2>>"$LOG"; then log "::error:: 클러스터 연결 실패 — kubeconfig/네트워크 확인" exit 2 fi EXPECT_VERSION="${EXPECT_VERSION:-$(python3 -c " import sys,re for line in open('$CHART_DIR/Chart.yaml'): m = re.match(r'appVersion:\s*[\"\x27]?([^\"\x27\s]+)', line) if m: print(m.group(1)); break " 2>/dev/null)}" log " OK (kubectl context: $(kubectl config current-context 2>/dev/null))" log " chart=$CHART_DIR" log " values=${VALUES_FILE:-<차트 기본값>}" log " expect appVersion=$EXPECT_VERSION" log " admin 로그인 검증=$([ -n "$ADMIN_PASSWORD" ] && echo 예 || echo '아니오 (ADMIN_PASSWORD 미지정)')" # CRD 선행 확인 — crds.install=false 로 설치하므로 이미 있어야 한다. log "== CRD 선행 확인 (crds.install=false 로 설치) ==" missing=0 for crd in applications.argoproj.io applicationsets.argoproj.io appprojects.argoproj.io; do if kubectl get crd "$crd" >/dev/null 2>&1; then log " 있음: $crd" else log "::error:: 없음: $crd" missing=1 fi done if [ "$missing" != "0" ]; then log "::error:: ArgoCD CRD 가 클러스터에 없다. 운영 argo-cd 가 설치된 클러스터에서 실행하거나," log " CRD 를 먼저 적용한 뒤 재실행한다 (이 스크립트는 CRD 를 만들지 않는다 —" log " 클러스터 전역 리소스라 운영 릴리스 소유권을 침범하기 때문)." exit 2 fi # --- 정리 (선-정리·후-정리 공용) --------------------------------------------- PF_PID="" cleanup() { if [ -n "$PF_PID" ] && kill -0 "$PF_PID" 2>/dev/null; then kill "$PF_PID" 2>/dev/null wait "$PF_PID" 2>/dev/null fi PF_PID="" kubectl get ns "$TEST_NAMESPACE" >/dev/null 2>&1 || return 0 log "== 정리: $TEST_NAMESPACE ==" if helm status "$RELEASE" -n "$TEST_NAMESPACE" >/dev/null 2>&1; then helm uninstall "$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 삭제 실패 — 수동 확인 필요" done kubectl delete ns "$TEST_NAMESPACE" --wait --timeout=180s >>"$LOG" 2>&1 log " 완료" } RESULT=1 trap 'cleanup' EXIT cleanup # 직전 실행이 비정상 종료했을 경우를 대비한 선-정리 log "== argo-cd: 설치 ($RELEASE/$TEST_NAMESPACE) ==" kubectl create ns "$TEST_NAMESPACE" >>"$LOG" 2>&1 helm_args=( install "$RELEASE" "$CHART_DIR" -n "$TEST_NAMESPACE" # 클러스터 전역 CRD 는 운영 릴리스 소유 — 건드리지 않는다 --set crds.install=false # 운영과 같은 host 로 Ingress 가 뜨면 트래픽을 가로챈다 --set server.ingress.enabled=false # 검증용이므로 단일 레플리카로 축소 --set controller.replicas=1 --set server.replicas=1 --set repoServer.replicas=1 --set applicationSet.replicas=1 --wait --timeout "${WAIT_TIMEOUT}s" ) [ -n "$VALUES_FILE" ] && helm_args+=(-f "$VALUES_FILE") if ! helm "${helm_args[@]}" >>"$LOG" 2>&1; then log "::error:: 차트 설치 실패 — $LOG 확인" kubectl -n "$TEST_NAMESPACE" get pods >>"$LOG" 2>&1 kubectl -n "$TEST_NAMESPACE" describe pods >>"$LOG" 2>&1 exit 1 fi log "== 워크로드 롤아웃 확인 ==" rollout_fail=0 for kind in deployment statefulset; do for name in $(kubectl -n "$TEST_NAMESPACE" get "$kind" -o jsonpath='{.items[*].metadata.name}' 2>/dev/null); do if kubectl -n "$TEST_NAMESPACE" rollout status "$kind/$name" --timeout="${WAIT_TIMEOUT}s" >>"$LOG" 2>&1; then log " OK $kind/$name" else log "::error:: 롤아웃 실패 $kind/$name" rollout_fail=1 fi done done if [ "$rollout_fail" != "0" ]; then kubectl -n "$TEST_NAMESPACE" get pods -o wide >>"$LOG" 2>&1 kubectl -n "$TEST_NAMESPACE" describe pods >>"$LOG" 2>&1 exit 1 fi # 이 카탈로그가 기본 지정하는 값이 실제 ConfigMap 까지 도달했는지 확인한다. log "== argocd-cmd-params-cm 확인 ==" persist="$(kubectl -n "$TEST_NAMESPACE" get cm argocd-cmd-params-cm \ -o jsonpath='{.data.controller\.resource\.health\.persist}' 2>/dev/null)" log " controller.resource.health.persist='${persist:-<미설정>}'" if [ -n "$VALUES_FILE" ] && [ "$persist" != "true" ]; then log "::warning:: health.persist 가 true 가 아니다 — dip-console-api 의 리소스 health 조회가 빈다" fi log "== port-forward (localhost:$LOCAL_PORT) ==" SERVER_SVC="$(kubectl -n "$TEST_NAMESPACE" get svc -l app.kubernetes.io/name=argocd-server \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null)" if [ -z "$SERVER_SVC" ]; then log "::error:: argocd-server Service 를 찾지 못함" exit 1 fi kubectl -n "$TEST_NAMESPACE" port-forward "svc/$SERVER_SVC" "${LOCAL_PORT}:80" >>"$LOG" 2>&1 & PF_PID=$! ready=0 for _ in $(seq 1 30); do sleep 1 if curl -sk --max-time 3 "http://localhost:${LOCAL_PORT}/healthz" >/dev/null 2>&1; then ready=1; break fi done if [ "$ready" != "1" ]; then log "::error:: port-forward 로 서버에 접속하지 못했다" exit 1 fi log " 연결 OK" log "== 기능 확인: /api/version ==" ver_json="$(curl -sk --max-time 10 "http://localhost:${LOCAL_PORT}/api/version" 2>>"$LOG")" got_version="$(printf '%s' "$ver_json" | python3 -c " import json,sys try: print(json.load(sys.stdin).get('Version','')) except Exception: print('') " 2>/dev/null)" log " Version=$got_version (기대 $EXPECT_VERSION)" if [ -z "$got_version" ]; then log "::error:: /api/version 응답을 파싱하지 못함: $ver_json" exit 1 fi case "$got_version" in "$EXPECT_VERSION"*) : ;; *) log "::error:: appVersion 불일치 — 기대 $EXPECT_VERSION, 실제 $got_version"; exit 1 ;; esac if [ -n "$ADMIN_PASSWORD" ]; then log "== 기능 확인: admin 로그인 ==" token="$(curl -sk --max-time 10 -X POST "http://localhost:${LOCAL_PORT}/api/v1/session" \ -H 'Content-Type: application/json' \ -d "$(python3 -c " import json,os # dict() 로 만든다 — {'a':1,'b':2} 리터럴은 bash brace expansion 에 쪼개진다(실측). print(json.dumps(dict(username='admin', password=os.environ['ADMIN_PASSWORD']))) ")" 2>>"$LOG" | python3 -c " import json,sys try: print(json.load(sys.stdin).get('token','')) except Exception: print('') " 2>/dev/null)" if [ -z "$token" ]; then log "::error:: admin 로그인 실패 — 비밀번호(bcrypt 해시)가 맞지 않거나 admin 계정이 비활성" exit 1 fi log " 로그인 OK (token 길이 ${#token})" else log "== admin 로그인 검증 생략 (ADMIN_PASSWORD 미지정) ==" fi log "== 결과: PASS ==" RESULT=0 exit "$RESULT"