#!/usr/bin/env bash set -Eeuo pipefail readonly EXPECTED_HELM_VERSION="v3.19.4" readonly EXPECTED_KUSTOMIZE_VERSION="v5.8.1" readonly KUBE_VERSION="1.36.2" readonly PGADMIN_SHA="0f409becdc6a811e852f384cb53b3f11cfb652a19d77cb952a8a176071694f4c" readonly AISTOR_SHA="50ffa6a4e014cdc48566b237593baab41d039f103cdc50c1b1ac173b8d8bf71e" readonly IMAGE_SHA="sha256:66a300a7ecdcc1f325af0c430315329bca46cd4a7067227d6899802238167c6e" readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)" readonly PG_CACHE="$ROOT/services/pgadmin/.helm/charts/pgadmin4-helm-9.16.0" readonly AI_CACHE="$ROOT/services/minio-aistor/base/deployment/.helm/charts/aistor-objectstore-1.0.16" readonly NAMES="admin-namespace pgadmin-local-pv coredns-custom aistor-admin-oidc pgadmin" fail() { printf 'ERROR: %s\n' "$*" >&2 exit 1 } usage() { cat <<'USAGE' 사용법: PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \ bash scripts/validate/render-admin-services.sh 적용 스크립트 내부 handoff: bash scripts/validate/render-admin-services.sh \ --verified-output-dir /tmp/platform-admin-apply.XXXXXX 고정한 공식 차트의 SHA-256과 렌더 결과를 검사합니다. 변경은 수행하지 않습니다. USAGE } out="" if (( $# == 0 )); then : elif (( $# == 2 )) && [[ "$1" == "--verified-output-dir" ]]; then out="$2" else usage >&2 exit 2 fi for cmd in awk find helm install kubectl rg sed sha256sum stat tar wc tr; do if [[ "$cmd" == helm ]] && [[ -v PLATFORM_HELM_BIN ]]; then continue fi command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다" done if [[ -v PLATFORM_HELM_BIN ]]; then [[ "$PLATFORM_HELM_BIN" == /* && -x "$PLATFORM_HELM_BIN" ]] || \ fail "PLATFORM_HELM_BIN은 실행 가능한 절대 경로여야 합니다" readonly HELM="$PLATFORM_HELM_BIN" else readonly HELM="$(command -v helm)" fi [[ "$("$HELM" version --template '{{.Version}}')" == "$EXPECTED_HELM_VERSION" ]] || \ fail "Helm은 정확히 $EXPECTED_HELM_VERSION이어야 합니다" kv="$(kubectl version --client --output=yaml | sed -n 's/^kustomizeVersion: //p')" [[ "$kv" == "$EXPECTED_KUSTOMIZE_VERSION" ]] || \ fail "Kustomize는 정확히 $EXPECTED_KUSTOMIZE_VERSION이어야 합니다" if [[ -n "$out" ]]; then [[ "$out" == /tmp/platform-admin-apply.* ]] || fail "handoff 경로 형식이 잘못됐습니다" [[ -d "$out" && ! -L "$out" && -O "$out" ]] || fail "handoff 경로가 안전하지 않습니다" [[ "$(stat --format='%a' "$out")" == 700 ]] || fail "handoff 권한은 0700이어야 합니다" [[ -z "$(find "$out" -mindepth 1 -maxdepth 1 -print -quit)" ]] || fail "handoff 경로가 비어 있지 않습니다" fi umask 077 work="$(mktemp -d /tmp/platform-admin-render.XXXXXX)" pg_created=false ai_created=false cleanup() { rc=$? set +e [[ "$pg_created" == true ]] && rm -rf -- "$PG_CACHE" [[ "$ai_created" == true ]] && rm -rf -- "$AI_CACHE" rmdir -- "$ROOT/services/pgadmin/.helm/charts" "$ROOT/services/pgadmin/.helm" 2>/dev/null || true rmdir -- "$ROOT/services/minio-aistor/base/deployment/.helm/charts" \ "$ROOT/services/minio-aistor/base/deployment/.helm" 2>/dev/null || true case "$work" in /tmp/platform-admin-render.*) rm -rf -- "$work" ;; esac exit "$rc" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM prepare() { label="$1" ref="$2" name="$3" version="$4" expected="$5" cache="$6" archive="$work/$name-$version.tgz" [[ ! -e "$cache" && ! -L "$cache" ]] || fail "생성 캐시가 이미 있습니다: $cache" if [[ "$ref" == oci://* ]]; then "$HELM" pull "$ref" --version "$version" --destination "$work" else "$HELM" pull "$name" --repo "$ref" --version "$version" --destination "$work" fi [[ -f "$archive" ]] || fail "$label archive가 없습니다" actual="$(sha256sum "$archive" | awk '{print $1}')" [[ "$actual" == "$expected" ]] || fail "$label SHA-256 불일치: $actual" mkdir -p -- "$cache" [[ "$cache" == "$PG_CACHE" ]] && pg_created=true [[ "$cache" == "$AI_CACHE" ]] && ai_created=true tar -xzf "$archive" -C "$cache" [[ -f "$cache/$name/Chart.yaml" ]] || fail "$label Chart.yaml이 없습니다" printf '검증 %-18s SHA-256 %s\n' "$label" "$actual" } plain() { kubectl kustomize "$ROOT/$2" >"$work/$1.yaml" [[ -s "$work/$1.yaml" ]] || fail "$1 렌더 결과가 비었습니다" } with_helm() { kubectl kustomize --enable-helm --helm-command "$HELM" \ --helm-kube-version "$KUBE_VERSION" "$ROOT/$2" >"$work/$1.yaml" [[ -s "$work/$1.yaml" ]] || fail "$1 렌더 결과가 비었습니다" } cd -- "$ROOT" if rg -n --glob '*.yaml' --glob '*.yml' --glob '!**/.helm/**' \ '^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' infrastructure services bootstrap; then fail "Git 관리 대상에서 Secret manifest를 발견했습니다" fi if rg -n '^[[:space:]]*hostAliases:' services/gitea/profiles/oidc; then fail "Gitea hostAliases가 남아 있습니다" fi prepare pgadmin oci://docker.io/dpage/pgadmin4-helm pgadmin4-helm 9.16.0 "$PGADMIN_SHA" "$PG_CACHE" prepare aistor-objectstore https://helm.min.io/ aistor-objectstore 1.0.16 "$AISTOR_SHA" "$AI_CACHE" plain admin-namespace infrastructure/namespaces/admin-tools plain pgadmin-local-pv infrastructure/storage/pgadmin-local-pv plain coredns-custom infrastructure/networking/private-dns/kubernetes with_helm aistor-admin-oidc services/minio-aistor/profiles/admin-oidc with_helm pgadmin services/pgadmin if rg -n '^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' "$work"/*.yaml; then fail "렌더 결과에 Secret 리소스가 있습니다" fi [[ "$(rg -c -F "image: docker.io/dpage/pgadmin4@$IMAGE_SHA" "$work/pgadmin.yaml")" == 3 ]] || \ fail "pgAdmin main/init 이미지 세 개의 digest 고정이 깨졌습니다" rg -q -F 'type: Recreate' "$work/pgadmin.yaml" || fail "pgAdmin Recreate 전략이 없습니다" rg -q -F 'storageClassName: ssd-local-pgadmin-retain' "$work/pgadmin.yaml" || fail "pgAdmin StorageClass가 다릅니다" [[ "$(rg -c -F 'value: db-admin.learn.hyeonworks.com' "$work/pgadmin.yaml")" == 3 ]] || \ fail "pgAdmin 세 probe의 Host header가 고정되지 않았습니다" rg -q -F 'host: db-admin.learn.hyeonworks.com' "$work/pgadmin.yaml" || fail "pgAdmin Ingress가 없습니다" [[ "$(rg -c -F 'ingressClassName: traefik' "$work/pgadmin.yaml")" == 1 ]] || \ fail "pgAdmin IngressClass가 traefik으로 고정되지 않았습니다" if rg -q -F 'kubernetes.io/ingress.class' "$work/pgadmin.yaml"; then fail "pgAdmin Ingress에 레거시 ingress.class annotation이 남아 있습니다" fi rg -q -F 'namespace: platform-data' "$work/pgadmin.yaml" || fail "PostgreSQL 정책 namespace가 다릅니다" bad_pgadmin_namespaces="$( awk ' function verify() { if (pgadmin_instance && namespace != "platform-admin") { printf "%s/%s=%s\n", kind, name, namespace == "" ? "missing" : namespace } } function reset() { kind="" name="" namespace="" in_metadata=0 in_labels=0 pgadmin_instance=0 } /^---$/ { verify() reset() next } /^kind:/ { kind=$2 next } /^metadata:$/ { in_metadata=1 in_labels=0 next } in_metadata && /^ name:/ && name == "" { name=$2 next } in_metadata && /^ namespace:/ && namespace == "" { namespace=$2 next } in_metadata && /^ labels:$/ { in_labels=1 next } in_labels && /^ app.kubernetes.io\/instance: pgadmin$/ { pgadmin_instance=1 next } in_metadata && /^[^ ]/ { in_metadata=0 in_labels=0 } END { verify() } ' "$work/pgadmin.yaml" )" [[ -z "$bad_pgadmin_namespaces" ]] || \ fail "pgAdmin instance 리소스 namespace 오류: $bad_pgadmin_namespaces" rg -q -F 'host: storage-admin.learn.hyeonworks.com' "$work/aistor-admin-oidc.yaml" || fail "AIStor Console Ingress가 없습니다" rg -q -F 'name: MINIO_IDENTITY_OPENID_CLIENT_SECRET' "$work/aistor-admin-oidc.yaml" || fail "AIStor OIDC Secret 참조가 없습니다" if rg -q -F 'name: MINIO_IDENTITY_OPENID_VENDOR' "$work/aistor-admin-oidc.yaml"; then fail "claim-only OIDC profile에 Keycloak Admin API vendor 설정이 있습니다" fi if rg -q -F 'host: s3.' "$work/aistor-admin-oidc.yaml"; then fail "S3 API Ingress가 생성됐습니다" fi for name in $NAMES; do bytes="$(wc -c <"$work/$name.yaml" | tr -d '[:space:]')" sha="$(sha256sum "$work/$name.yaml" | awk '{print $1}')" printf '렌더 %-20s %8s bytes SHA-256 %s\n' "$name" "$bytes" "$sha" if [[ -n "$out" ]]; then install -m 0600 "$work/$name.yaml" "$out/$name.yaml" fi done if [[ -n "$out" ]]; then count="$(find "$out" -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" [[ "$count" == 5 ]] || fail "handoff 파일 수가 다릅니다" fi printf 'ADMIN SERVICES STATIC RENDER PASS\n'