3aa69e5eda
k8s 1.33 에서 Deployment/ReplicaSet 에 추가된 .status.terminatingReplicas 를
ArgoCD v2.14.5(k8s 라이브러리 0.31/0.32)가 몰라, 클러스터가 1.33 이상이면
ServerSideApply=true 인 Application 이 전부 아래 오류로 죽는다.
ComparisonError: failed to calculate diff: error calculating structured
merge diff: .status.terminatingReplicas: field not declared in schema
v3.5.1 은 k8s 라이브러리 0.36.1 을 써서 해소된다. dev 클러스터(1.35.7+rke2r1)
에서 실제 발생·해소를 확인했다.
- manifests/helm/argo-cd/10.4.0/ 추가 (chart_updater 로 생성)
- custom-values.yaml 을 실제 배포 기준(dipup argo-cd-values.yaml.tpl)에 맞춤:
kong → apisix, server.extraArgs(--insecure), configs.cm/rbac 추가
- configs.params.controller.resource.health.persist=true 지정 —
ArgoCD v3.0 부터 리소스 health 를 CR 에 저장하지 않는 것이 기본값인데,
dip-console-api 가 .status.resources[].health 를 읽는다
- CUSTOM-README.md 는 현재 차트 기준 배포 가이드로 재작성.
승계된 cert-manager 예시가 10.4.0 에 없는 구버전 스키마(hosts/tls 리스트)라
Ingress 가 생성되지 않는 상태였던 것도 함께 수정
- breaking_change_check: breaking=false (제거 26건이 전부 미사용 key)
- scripts/deploy-test/deploy-test-argo-cd.sh 신규
같은 클러스터에 두 번째 argo-cd 를 띄우려면 crds.install=false 가 필수다 —
CRD 3종이 클러스터 전역이고 운영 릴리스가 소유해 Helm 이 ownership 충돌로
거부한다. Ingress 도 항상 끈다(운영과 host 충돌).
검증: 워크로드 롤아웃 / health.persist / api 버전 / admin 로그인
- argo-cd/7.8.11/BUILD-README.md 에 `helm repo add argo ...` 추가
chart_version_detector 가 이 한 줄을 정규식으로 뽑아 업스트림 레포를 정하는데,
argo-cd 에는 없어서 신규 버전 자동 감지가 조용히 실패하고 있었다
(repo: null → latest_version: null). CLAUDE.md 의 "변경 금지" 규정도
실제 파싱 계약에 맞게 정정했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
268 lines
10 KiB
Bash
Executable File
268 lines
10 KiB
Bash
Executable File
#!/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 <OUT_DIR>
|
|
# 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 <OUT_DIR>}"
|
|
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"
|