267 lines
11 KiB
Bash
Executable File
267 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -Eeuo pipefail
|
|
set +x
|
|
umask 077
|
|
|
|
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
|
readonly TARGET_NODE="donghyeon-system-product-name"
|
|
readonly PG_PATH="/srv/k3s/ssd/pgadmin"
|
|
readonly PG_SECRET="pgadmin-bootstrap"
|
|
readonly PG_OIDC_SECRET="pgadmin-keycloak-oidc"
|
|
readonly AI_OIDC_SECRET="aistor-keycloak-oidc"
|
|
readonly NAMES="admin-namespace pgadmin-local-pv coredns-custom aistor-admin-oidc pgadmin"
|
|
execute=false
|
|
password_file=""
|
|
work=""
|
|
rollback_armed=false
|
|
bootstrap_existed=false
|
|
|
|
fail() {
|
|
printf 'ERROR: %s\n' "$*" >&2
|
|
exit 1
|
|
}
|
|
|
|
usage() {
|
|
cat <<'USAGE'
|
|
사용법:
|
|
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
|
|
bash scripts/bootstrap/apply-admin-services.sh
|
|
|
|
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
|
|
bash scripts/bootstrap/apply-admin-services.sh \
|
|
--execute \
|
|
--pgadmin-password-file /home/donghyeon/.secrets/pgadmin/bootstrap-password
|
|
|
|
인자 없이 실행하면 공식 차트 hash와 manifest만 검증합니다.
|
|
--execute는 Local PV, pgAdmin bootstrap Secret, pgAdmin, AIStor OIDC profile을
|
|
적용합니다. 비밀번호와 OIDC Secret 값은 출력하지 않습니다.
|
|
USAGE
|
|
}
|
|
|
|
while (( $# > 0 )); do
|
|
case "$1" in
|
|
--execute)
|
|
execute=true
|
|
shift
|
|
;;
|
|
--pgadmin-password-file)
|
|
(( $# >= 2 )) || fail "--pgadmin-password-file 값이 필요합니다"
|
|
password_file="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
usage >&2
|
|
fail "지원하지 않는 인자: $1"
|
|
;;
|
|
esac
|
|
done
|
|
|
|
[[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요"
|
|
for cmd in awk curl find findmnt install jq kubectl mktemp od rg seq sha256sum sleep sort stat tail tr wc; do
|
|
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
|
|
done
|
|
|
|
work="$(mktemp -d /tmp/platform-admin-apply.XXXXXX)"
|
|
|
|
rollback() {
|
|
set +e
|
|
printf '\nROLLBACK: 관리 UI 트래픽을 내리고 AIStor 이전 spec을 복원합니다.\n' >&2
|
|
kubectl -n platform-admin scale deployment/pgadmin --replicas=0 >/dev/null 2>&1 || true
|
|
kubectl -n platform-admin delete ingress pgadmin --ignore-not-found >/dev/null 2>&1
|
|
kubectl -n object-storage delete ingress minio-aistor-console --ignore-not-found >/dev/null 2>&1
|
|
kubectl apply -f "$work/objectstore-before.json" >/dev/null 2>&1 || true
|
|
for statefulset_name in $(
|
|
kubectl -n object-storage get objectstore minio-aistor \
|
|
-o jsonpath='{range .status.pools[*]}{.ssName}{"\n"}{end}' 2>/dev/null
|
|
); do
|
|
kubectl -n object-storage rollout restart "statefulset/$statefulset_name" >/dev/null 2>&1 || true
|
|
kubectl -n object-storage rollout status "statefulset/$statefulset_name" \
|
|
--timeout=300s >/dev/null 2>&1 || true
|
|
done
|
|
if [[ "$bootstrap_existed" == true ]]; then
|
|
kubectl apply -f "$work/pgadmin-bootstrap-before.yaml" >/dev/null 2>&1 || true
|
|
else
|
|
kubectl -n platform-admin delete secret "$PG_SECRET" --ignore-not-found >/dev/null 2>&1
|
|
fi
|
|
rollback_armed=false
|
|
printf 'ROLLBACK complete. pgAdmin PVC/PV와 AIStor 데이터는 삭제하지 않았습니다.\n' >&2
|
|
}
|
|
|
|
cleanup() {
|
|
rc=$?
|
|
trap - EXIT INT TERM
|
|
if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then
|
|
rollback
|
|
fi
|
|
case "$work" in
|
|
/tmp/platform-admin-apply.*) rm -rf -- "$work" ;;
|
|
esac
|
|
exit "$rc"
|
|
}
|
|
trap cleanup EXIT
|
|
trap 'exit 130' INT
|
|
trap 'exit 143' TERM
|
|
unexpected_error() {
|
|
rc=$?
|
|
line="$1"
|
|
trap - ERR
|
|
printf 'ERROR: 예상하지 못한 명령 실패(line=%s, exit=%s)\n' "$line" "$rc" >&2
|
|
exit "$rc"
|
|
}
|
|
trap 'unexpected_error "$LINENO"' ERR
|
|
|
|
if [[ ! -v PLATFORM_HELM_BIN ]]; then
|
|
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm
|
|
fi
|
|
PLATFORM_HELM_BIN="$PLATFORM_HELM_BIN" \
|
|
bash "$ROOT/scripts/validate/render-admin-services.sh" --verified-output-dir "$work"
|
|
|
|
for name in $NAMES; do
|
|
file="$work/$name.yaml"
|
|
[[ -f "$file" && ! -L "$file" && -O "$file" && -s "$file" ]] || fail "안전하지 않은 handoff: $file"
|
|
[[ "$(stat -c '%a' "$file")" == 600 ]] || fail "handoff 권한이 0600이 아닙니다"
|
|
sha256sum "$file" | awk '{print $1}' >"$work/$name.sha256"
|
|
done
|
|
|
|
context="$(kubectl config current-context)"
|
|
api="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')"
|
|
ready="$(kubectl get node "$TARGET_NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')"
|
|
[[ "$ready" == True ]] || fail "대상 노드가 Ready가 아닙니다"
|
|
printf 'Current context: %s\n' "$context"
|
|
printf 'API server: %s\n' "$api"
|
|
for name in $NAMES; do
|
|
printf 'SHA-256 %-20s %s\n' "$name" "$(cat "$work/$name.sha256")"
|
|
done
|
|
|
|
if [[ "$execute" == false ]]; then
|
|
printf 'DRY RUN PASS: --execute를 지정하지 않아 클러스터를 변경하지 않았습니다.\n'
|
|
exit 0
|
|
fi
|
|
|
|
[[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다"
|
|
[[ "$password_file" == /* && -f "$password_file" && ! -L "$password_file" ]] || \
|
|
fail "pgAdmin 비밀번호 파일은 일반 파일인 절대 경로여야 합니다"
|
|
[[ "$(stat -c '%a' "$password_file")" == 600 ]] || fail "pgAdmin 비밀번호 파일 권한은 0600이어야 합니다"
|
|
password_bytes="$(wc -c <"$password_file" | tr -d '[:space:]')"
|
|
(( password_bytes >= 16 && password_bytes <= 256 )) || fail "pgAdmin 비밀번호는 16~256 bytes여야 합니다"
|
|
last_byte="$(tail -c 1 "$password_file" | od -An -t x1 | tr -d '[:space:]')"
|
|
[[ "$last_byte" != 0a && "$last_byte" != 0d ]] || \
|
|
fail "pgAdmin 비밀번호 파일 끝에 개행이 없어야 합니다"
|
|
|
|
for contract in "object-storage $AI_OIDC_SECRET client-id client-secret" \
|
|
"platform-admin $PG_OIDC_SECRET client-id client-secret"; do
|
|
set -- $contract
|
|
namespace="$1"
|
|
secret="$2"
|
|
key_a="$3"
|
|
key_b="$4"
|
|
kubectl -n "$namespace" get "secret/$secret" >/dev/null 2>&1 || \
|
|
fail "$namespace/$secret Secret이 없습니다. Keycloak OIDC 구성을 먼저 실행하세요"
|
|
keys="$(kubectl -n "$namespace" get "secret/$secret" \
|
|
-o go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' | LC_ALL=C sort)"
|
|
[[ "$keys" == "$key_a"$'\n'"$key_b" ]] || fail "$namespace/$secret key 계약이 다릅니다"
|
|
done
|
|
|
|
core_data="$(kubectl -n kube-system get configmap coredns-custom \
|
|
-o jsonpath='{.data.learn-hyeonworks\.server}' 2>/dev/null)" || \
|
|
fail "coredns-custom이 없습니다. apply-private-dns.sh를 먼저 실행하세요"
|
|
for host in git.learn.hyeonworks.com id.learn.hyeonworks.com \
|
|
storage-admin.learn.hyeonworks.com db-admin.learn.hyeonworks.com; do
|
|
printf '%s\n' "$core_data" | rg -q -F "$host" || fail "CoreDNS에 $host가 없습니다"
|
|
done
|
|
|
|
printf 'Type APPLY %s to deploy private admin services: ' "$context"
|
|
read -r answer
|
|
[[ "$answer" == "APPLY $context" ]] || fail "취소했습니다"
|
|
[[ "$(kubectl config current-context)" == "$context" ]] || fail "context가 바뀌었습니다"
|
|
[[ "$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" == "$api" ]] || \
|
|
fail "API server가 바뀌었습니다"
|
|
for name in $NAMES; do
|
|
current_sha="$(sha256sum "$work/$name.yaml" | awk '{print $1}')"
|
|
[[ "$current_sha" == "$(cat "$work/$name.sha256")" ]] || fail "$name handoff가 확인 뒤 바뀌었습니다"
|
|
done
|
|
|
|
printf '[1/6] sudo 인증 확인\n'
|
|
sudo -v
|
|
printf '[2/6] pgAdmin SSD filesystem 경계와 Local PV 경로 확인\n'
|
|
root_source="$(sudo findmnt --kernel --first-only --noheadings --output SOURCE --target /)"
|
|
srv_source="$(sudo findmnt --kernel --first-only --noheadings --output SOURCE --target /srv)"
|
|
srv_target="$(sudo findmnt --kernel --first-only --noheadings --output TARGET --target /srv)"
|
|
[[ "$srv_source" == "$root_source" && "$srv_target" == / ]] || \
|
|
fail "/srv가 root SSD 파일시스템 경계가 아닙니다"
|
|
if sudo test -L "$PG_PATH"; then
|
|
fail "$PG_PATH가 심볼릭 링크입니다"
|
|
fi
|
|
if sudo test -e "$PG_PATH"; then
|
|
sudo test -d "$PG_PATH" || fail "$PG_PATH가 디렉터리가 아닙니다"
|
|
else
|
|
sudo install -d -o root -g root -m 0750 "$PG_PATH"
|
|
fi
|
|
[[ "$(sudo findmnt --kernel --first-only --noheadings --output SOURCE --target "$PG_PATH")" == "$root_source" ]] || \
|
|
fail "$PG_PATH가 root SSD에 있지 않습니다"
|
|
[[ "$(sudo findmnt --kernel --first-only --noheadings --output TARGET --target "$PG_PATH")" == / ]] || \
|
|
fail "$PG_PATH 아래에 다른 mount가 있습니다"
|
|
|
|
printf '[3/6] rollback용 AIStor spec과 기존 pgAdmin Secret 상태 저장\n'
|
|
kubectl -n object-storage get objectstore minio-aistor -o json | \
|
|
jq '{apiVersion,kind,metadata:{name:.metadata.name,namespace:.metadata.namespace},spec}' \
|
|
>"$work/objectstore-before.json"
|
|
if kubectl -n platform-admin get secret "$PG_SECRET" -o yaml >"$work/pgadmin-bootstrap-before.yaml" 2>/dev/null; then
|
|
bootstrap_existed=true
|
|
fi
|
|
|
|
rollback_armed=true
|
|
printf '[4/6] namespace, Local PV, Secret, AIStor OIDC와 pgAdmin 적용\n'
|
|
kubectl apply -f "$work/admin-namespace.yaml"
|
|
kubectl apply -f "$work/pgadmin-local-pv.yaml"
|
|
|
|
kubectl -n platform-admin create secret generic "$PG_SECRET" \
|
|
--from-file="password=$password_file" \
|
|
--dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
|
kubectl -n platform-admin label secret "$PG_SECRET" \
|
|
app.kubernetes.io/name=pgadmin4 \
|
|
app.kubernetes.io/component=bootstrap-credential \
|
|
app.kubernetes.io/part-of=platform \
|
|
app.kubernetes.io/managed-by=bootstrap-script \
|
|
--overwrite >/dev/null
|
|
|
|
kubectl apply -f "$work/aistor-admin-oidc.yaml"
|
|
kubectl apply -f "$work/pgadmin.yaml"
|
|
|
|
printf '[5/6] pgAdmin rollout, EndpointSlice와 AIStor health 대기\n'
|
|
kubectl -n platform-admin rollout status deployment/pgadmin --timeout=300s
|
|
kubectl -n platform-admin wait --for=jsonpath='{.endpoints[0].conditions.ready}'=true \
|
|
endpointslice --selector=kubernetes.io/service-name=pgadmin --timeout=120s
|
|
|
|
healthy=false
|
|
for attempt in $(seq 1 60); do
|
|
health="$(kubectl -n object-storage get objectstore minio-aistor -o jsonpath='{.status.healthStatus}' 2>/dev/null)"
|
|
if [[ "$health" == green ]]; then
|
|
healthy=true
|
|
break
|
|
fi
|
|
if (( attempt == 1 || attempt % 3 == 0 )); then
|
|
printf 'AIStor health 대기: status=%s elapsed=%ss/300s\n' \
|
|
"${health:-unknown}" "$((attempt * 5))"
|
|
fi
|
|
sleep 5
|
|
done
|
|
[[ "$healthy" == true ]] || fail "AIStor ObjectStore가 green으로 복귀하지 않았습니다"
|
|
|
|
printf '[6/6] Traefik Host routing 확인\n'
|
|
pg_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
|
--noproxy '*' -H 'Host: db-admin.learn.hyeonworks.com' http://127.0.0.1:30080/)"
|
|
ai_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
|
--noproxy '*' -H 'Host: storage-admin.learn.hyeonworks.com' http://127.0.0.1:30080/)"
|
|
[[ "$pg_code" == 200 || "$pg_code" == 302 || "$pg_code" == 303 ]] || fail "pgAdmin Traefik 응답 실패: $pg_code"
|
|
[[ "$ai_code" == 200 || "$ai_code" == 302 || "$ai_code" == 303 ]] || fail "AIStor Console Traefik 응답 실패: $ai_code"
|
|
|
|
rollback_armed=false
|
|
printf 'ADMIN SERVICES APPLY SUCCESS\n'
|
|
printf 'pgAdmin PVC/PV: Retain, AIStor S3 API: cluster-internal only\n'
|