Add platform infrastructure configuration
This commit is contained in:
Executable
+266
@@ -0,0 +1,266 @@
|
||||
#!/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'
|
||||
Executable
+470
@@ -0,0 +1,470 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# 호출자가 bash -x로 실행해도 Secret 경로와 향후 입력이 추적되지 않도록 한다.
|
||||
set +x
|
||||
|
||||
readonly EXPECTED_HELM_VERSION="v3.19.4"
|
||||
readonly EXPECTED_KUSTOMIZE_VERSION="v5.8.1"
|
||||
readonly TARGET_NODE="donghyeon-system-product-name"
|
||||
readonly EXPECTED_API_SERVICE_IP="10.43.0.1"
|
||||
readonly EXPECTED_API_ENDPOINT_IP="192.168.0.107"
|
||||
readonly EXPECTED_AISTOR_DEVICE="/dev/sdb3"
|
||||
readonly EXPECTED_AISTOR_MOUNT="/srv/k3s/aistor"
|
||||
readonly EXPECTED_STORAGE_CLASS="aistor-local-xfs-retain"
|
||||
readonly EXPECTED_PV="aistor-data-local-pv"
|
||||
readonly EXPECTED_OBJECTSTORE="minio-aistor"
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly -a VERIFIED_MANIFEST_NAMES=(
|
||||
phase2-namespaces
|
||||
aistor-local-pv
|
||||
keycloak-operator
|
||||
platform-postgres-keycloak
|
||||
keycloak
|
||||
aistor-operator
|
||||
minio-aistor
|
||||
aistor-network-policies
|
||||
)
|
||||
readonly -a AISTOR_CRDS=(
|
||||
customresourcedefinition/adminjobs.aistor.min.io
|
||||
customresourcedefinition/objectstores.aistor.min.io
|
||||
customresourcedefinition/policybindings.sts.min.io
|
||||
)
|
||||
|
||||
license_file=""
|
||||
root_config_file=""
|
||||
generate_root_config=false
|
||||
execute_requested=false
|
||||
mutation_started=false
|
||||
current_step="preflight"
|
||||
|
||||
report_retained_state() {
|
||||
if [[ "$mutation_started" == true ]]; then
|
||||
printf '%s\n' \
|
||||
"SAFE STOP during ${current_step}." \
|
||||
'No Namespace, Secret, PV, PVC, Operator, ObjectStore, or XFS data was deleted.' \
|
||||
'The PV reclaim policy remains Retain and PVC protection remains enabled.' \
|
||||
'Diagnose the failed wait or policy, then rerun this script.' >&2
|
||||
fi
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
report_retained_state
|
||||
exit 1
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local status="$1"
|
||||
local line="$2"
|
||||
|
||||
trap - ERR
|
||||
set +e
|
||||
printf 'ERROR: command failed at line %s (exit %s).\n' "$line" "$status" >&2
|
||||
report_retained_state
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
on_signal() {
|
||||
local status="$1"
|
||||
|
||||
trap - INT TERM
|
||||
set +e
|
||||
printf 'INTERRUPTED: stopping without deleting cluster or XFS state.\n' >&2
|
||||
report_retained_state
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
PLATFORM_HELM_BIN=/home/donghyeon/.local/bin/helm \
|
||||
bash scripts/bootstrap/apply-aistor.sh \
|
||||
--license-file /home/donghyeon/.secrets/aistor/minio.license \
|
||||
--root-config-file /home/donghyeon/.secrets/aistor/root.env \
|
||||
--generate-root-config \
|
||||
--execute
|
||||
|
||||
Renders, verifies, and applies the internal-only MinIO AIStor path:
|
||||
aistor and object-storage namespaces
|
||||
two out-of-Git Secret contracts
|
||||
one 900Gi Retain Local PV on /srv/k3s/aistor
|
||||
AIStor Operator 5.10.0 and CRDs
|
||||
one-server, one-drive ObjectStore 1.0.16
|
||||
default-deny NetworkPolicies with only required internal paths
|
||||
|
||||
It does not configure Host Nginx, Traefik Ingress, NodePort, LoadBalancer,
|
||||
public DNS, credential rotation, or deletion.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--license-file)
|
||||
(( $# >= 2 )) || fail "--license-file requires a path"
|
||||
license_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--root-config-file)
|
||||
(( $# >= 2 )) || fail "--root-config-file requires a path"
|
||||
root_config_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--generate-root-config)
|
||||
generate_root_config=true
|
||||
shift
|
||||
;;
|
||||
--execute)
|
||||
execute_requested=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "unsupported argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$execute_requested" == true ]] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$license_file" == /* ]] || fail "--license-file must be an absolute path"
|
||||
[[ "$root_config_file" == /* ]] || \
|
||||
fail "--root-config-file must be an absolute path"
|
||||
|
||||
for command_name in cmp curl df find findmnt jq kubectl mktemp mountpoint \
|
||||
rg sed seq sha256sum sleep stat tail tr wc; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
|
||||
if [[ -n "${PLATFORM_HELM_BIN:-}" ]]; then
|
||||
[[ "$PLATFORM_HELM_BIN" == /* ]] || \
|
||||
fail "PLATFORM_HELM_BIN must be an absolute path"
|
||||
[[ -f "$PLATFORM_HELM_BIN" && -x "$PLATFORM_HELM_BIN" ]] || \
|
||||
fail "PLATFORM_HELM_BIN is not an executable file: ${PLATFORM_HELM_BIN}"
|
||||
readonly HELM_BIN="$PLATFORM_HELM_BIN"
|
||||
else
|
||||
HELM_BIN="$(command -v helm 2>/dev/null)" || \
|
||||
fail "Helm ${EXPECTED_HELM_VERSION} is required"
|
||||
readonly HELM_BIN
|
||||
fi
|
||||
|
||||
[[ "$("$HELM_BIN" version --template '{{.Version}}')" == "$EXPECTED_HELM_VERSION" ]] || \
|
||||
fail "Helm must be exactly ${EXPECTED_HELM_VERSION}"
|
||||
kustomize_version="$(
|
||||
kubectl version --client --output=yaml |
|
||||
sed -n 's/^kustomizeVersion: //p'
|
||||
)"
|
||||
[[ "$kustomize_version" == "$EXPECTED_KUSTOMIZE_VERSION" ]] || \
|
||||
fail "expected Kustomize ${EXPECTED_KUSTOMIZE_VERSION}, found ${kustomize_version:-unknown}"
|
||||
|
||||
umask 077
|
||||
render_temp_dir="$(mktemp -d /tmp/platform-phase2-apply.XXXXXX)"
|
||||
|
||||
cleanup() {
|
||||
case "$render_temp_dir" in
|
||||
/tmp/platform-phase2-apply.*)
|
||||
rm -rf -- "$render_temp_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected render directory: %s\n' \
|
||||
"$render_temp_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'on_error "$?" "$LINENO"' ERR
|
||||
trap 'on_signal 130' INT
|
||||
trap 'on_signal 143' TERM
|
||||
|
||||
cd -- "$REPOSITORY_ROOT"
|
||||
PLATFORM_HELM_BIN="$HELM_BIN" \
|
||||
bash scripts/validate/render-phase2.sh \
|
||||
--verified-output-dir "$render_temp_dir"
|
||||
|
||||
declare -A verified_manifest_sha256=()
|
||||
for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do
|
||||
manifest_path="${render_temp_dir}/${manifest_name}.yaml"
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified manifest is missing or unsafe: ${manifest_path}"
|
||||
[[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \
|
||||
fail "verified manifest must have mode 0600: ${manifest_path}"
|
||||
checksum_output="$(sha256sum -- "$manifest_path")"
|
||||
verified_manifest_sha256["$manifest_name"]="${checksum_output%% *}"
|
||||
done
|
||||
verified_entry_count="$(
|
||||
find "$render_temp_dir" -mindepth 1 -maxdepth 1 |
|
||||
wc -l | tr -d '[:space:]'
|
||||
)"
|
||||
[[ "$verified_entry_count" == "${#VERIFIED_MANIFEST_NAMES[@]}" ]] || \
|
||||
fail "verified handoff must contain exactly eight manifest files"
|
||||
|
||||
verify_manifest_unchanged() {
|
||||
local manifest_name="$1"
|
||||
local manifest_path="${render_temp_dir}/${manifest_name}.yaml"
|
||||
local checksum_output
|
||||
local actual_sha256
|
||||
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified manifest became missing or unsafe: ${manifest_path}"
|
||||
checksum_output="$(sha256sum -- "$manifest_path")"
|
||||
actual_sha256="${checksum_output%% *}"
|
||||
[[ "$actual_sha256" == "${verified_manifest_sha256[$manifest_name]}" ]] || \
|
||||
fail "verified manifest changed before apply: ${manifest_name}.yaml"
|
||||
}
|
||||
|
||||
current_context="$(kubectl config current-context)"
|
||||
api_server="$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')"
|
||||
node_ready="$(
|
||||
kubectl get node "$TARGET_NODE" \
|
||||
--output='go-template={{range .status.conditions}}{{if and (eq .type "Ready") (eq .status "True")}}true{{end}}{{end}}'
|
||||
)"
|
||||
[[ "$node_ready" == "true" ]] || fail "target node is not Ready: ${TARGET_NODE}"
|
||||
|
||||
cluster_api_ip="$(
|
||||
kubectl --namespace default get service kubernetes \
|
||||
--output=jsonpath='{.spec.clusterIP}'
|
||||
)"
|
||||
[[ "$cluster_api_ip" == "$EXPECTED_API_SERVICE_IP" ]] || \
|
||||
fail "Kubernetes API Service IP changed: expected ${EXPECTED_API_SERVICE_IP}, found ${cluster_api_ip}"
|
||||
cluster_api_endpoint="$(
|
||||
kubectl --namespace default get endpointslice \
|
||||
--selector=kubernetes.io/service-name=kubernetes \
|
||||
--output=jsonpath='{.items[0].endpoints[0].addresses[0]}'
|
||||
)"
|
||||
[[ "$cluster_api_endpoint" == "$EXPECTED_API_ENDPOINT_IP" ]] || \
|
||||
fail "Kubernetes API endpoint changed: expected ${EXPECTED_API_ENDPOINT_IP}, found ${cluster_api_endpoint}"
|
||||
|
||||
mountpoint --quiet "$EXPECTED_AISTOR_MOUNT" || \
|
||||
fail "${EXPECTED_AISTOR_MOUNT} is not a mountpoint"
|
||||
mount_source="$(findmnt --noheadings --output SOURCE --target "$EXPECTED_AISTOR_MOUNT" | tr -d '[:space:]')"
|
||||
mount_fstype="$(findmnt --noheadings --output FSTYPE --target "$EXPECTED_AISTOR_MOUNT" | tr -d '[:space:]')"
|
||||
mount_options="$(findmnt --noheadings --output OPTIONS --target "$EXPECTED_AISTOR_MOUNT")"
|
||||
[[ "$mount_source" == "$EXPECTED_AISTOR_DEVICE" ]] || \
|
||||
fail "AIStor mount source changed: expected ${EXPECTED_AISTOR_DEVICE}, found ${mount_source}"
|
||||
[[ "$mount_fstype" == "xfs" ]] || \
|
||||
fail "AIStor mount must be XFS, found ${mount_fstype}"
|
||||
[[ ",${mount_options}," == *,rw,* ]] || fail "AIStor XFS mount is not writable"
|
||||
[[ -d "$EXPECTED_AISTOR_MOUNT" && ! -L "$EXPECTED_AISTOR_MOUNT" ]] || \
|
||||
fail "AIStor mount path must be a non-symlink directory"
|
||||
|
||||
available_bytes="$(
|
||||
df --block-size=1 --output=avail "$EXPECTED_AISTOR_MOUNT" |
|
||||
tail -n 1 | tr -d '[:space:]'
|
||||
)"
|
||||
minimum_bytes=$((900 * 1024 * 1024 * 1024))
|
||||
(( available_bytes >= minimum_bytes )) || \
|
||||
fail "AIStor XFS has less than 900Gi available"
|
||||
|
||||
if ! kubectl get persistentvolume "$EXPECTED_PV" >/dev/null 2>&1; then
|
||||
[[ -z "$(find "$EXPECTED_AISTOR_MOUNT" -mindepth 1 -maxdepth 1 -print -quit)" ]] || \
|
||||
fail "initial AIStor XFS root is not empty; refusing to bind an unknown data directory"
|
||||
fi
|
||||
|
||||
unexpected_consumers="$(
|
||||
kubectl get persistentvolumeclaim --all-namespaces --output=json |
|
||||
jq --arg storage_class "$EXPECTED_STORAGE_CLASS" \
|
||||
'[.items[] | select(.spec.storageClassName == $storage_class)] | length'
|
||||
)"
|
||||
if (( unexpected_consumers > 0 )); then
|
||||
existing_expected_claim="$(
|
||||
kubectl --namespace object-storage get persistentvolumeclaim \
|
||||
--ignore-not-found --output=json |
|
||||
jq --arg storage_class "$EXPECTED_STORAGE_CLASS" \
|
||||
'[.items[] | select(.spec.storageClassName == $storage_class)] | length'
|
||||
)"
|
||||
[[ "$unexpected_consumers" == "1" && "$existing_expected_claim" == "1" ]] || \
|
||||
fail "the AIStor StorageClass has an unexpected PVC consumer"
|
||||
fi
|
||||
|
||||
if kubectl get customresourcedefinition objectstores.aistor.min.io >/dev/null 2>&1; then
|
||||
unexpected_objectstores="$(
|
||||
kubectl get objectstores.aistor.min.io --all-namespaces --output=json |
|
||||
jq --arg name "$EXPECTED_OBJECTSTORE" \
|
||||
'[.items[] | select(.metadata.namespace != "object-storage" or .metadata.name != $name)] | length'
|
||||
)"
|
||||
[[ "$unexpected_objectstores" == "0" ]] || \
|
||||
fail "an unexpected AIStor ObjectStore already exists"
|
||||
fi
|
||||
|
||||
[[ -f "$license_file" && ! -L "$license_file" && -O "$license_file" && -s "$license_file" ]] || \
|
||||
fail "license file must be a non-empty, current-user-owned regular file"
|
||||
[[ "$(stat --format='%a' -- "$license_file")" == "600" ]] || \
|
||||
fail "license file must have mode 0600"
|
||||
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$current_context" "$api_server" "$TARGET_NODE"
|
||||
printf 'XFS: %s -> %s (%s, at least 900Gi available)\n' \
|
||||
"$mount_source" "$EXPECTED_AISTOR_MOUNT" "$mount_fstype"
|
||||
printf '%s\n' \
|
||||
'Scope: internal-only AIStor Operator, 900Gi Retain Local PV, one ObjectStore, and NetworkPolicies.' \
|
||||
'Excluded: Host Nginx, Traefik, NodePort, LoadBalancer, public DNS, rotation, and deletion.' \
|
||||
'Failure boundary: all applied state and XFS data are retained; rerunning is the recovery path.'
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required"
|
||||
printf 'Type APPLY AISTOR %s to start the cluster mutation: ' "$current_context"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY AISTOR ${current_context}" ]] || fail "cancelled"
|
||||
|
||||
assert_cluster_identity() {
|
||||
[[ "$(kubectl config current-context)" == "$current_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')" == "$api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \
|
||||
fail "target node disappeared after confirmation: ${TARGET_NODE}"
|
||||
mountpoint --quiet "$EXPECTED_AISTOR_MOUNT" || \
|
||||
fail "AIStor XFS mount disappeared after confirmation"
|
||||
}
|
||||
|
||||
assert_cluster_identity
|
||||
for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do
|
||||
verify_manifest_unchanged "$manifest_name"
|
||||
done
|
||||
|
||||
mutation_started=true
|
||||
|
||||
current_step="[1/7] AIStor namespaces"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged phase2-namespaces
|
||||
kubectl apply --dry-run=server \
|
||||
--filename="${render_temp_dir}/phase2-namespaces.yaml" >/dev/null
|
||||
kubectl apply --filename="${render_temp_dir}/phase2-namespaces.yaml"
|
||||
|
||||
current_step="[2/7] AIStor Secret contracts"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
secret_args=(
|
||||
--license-file "$license_file"
|
||||
--root-config-file "$root_config_file"
|
||||
--execute
|
||||
)
|
||||
if [[ "$generate_root_config" == true ]]; then
|
||||
secret_args+=(--generate-root-config)
|
||||
fi
|
||||
bash scripts/bootstrap/create-aistor-secrets.sh "${secret_args[@]}"
|
||||
|
||||
current_step="[3/7] 900Gi Retain Local PV"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged aistor-local-pv
|
||||
kubectl apply --dry-run=server \
|
||||
--filename="${render_temp_dir}/aistor-local-pv.yaml" >/dev/null
|
||||
kubectl apply --filename="${render_temp_dir}/aistor-local-pv.yaml"
|
||||
|
||||
current_step="[4/7] AIStor Operator and CRDs"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged aistor-operator
|
||||
kubectl apply --server-side \
|
||||
--filename="${render_temp_dir}/aistor-operator.yaml"
|
||||
kubectl wait --for=condition=Established "${AISTOR_CRDS[@]}" --timeout=5m
|
||||
kubectl --namespace aistor rollout status \
|
||||
deployment/adminjob-operator --timeout=10m
|
||||
kubectl --namespace aistor rollout status \
|
||||
deployment/object-store-operator --timeout=10m
|
||||
kubectl --namespace aistor rollout status \
|
||||
deployment/object-store-webhook --timeout=10m
|
||||
|
||||
current_step="[5/7] One-node, one-drive AIStor ObjectStore"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged minio-aistor
|
||||
kubectl apply --server-side --dry-run=server \
|
||||
--filename="${render_temp_dir}/minio-aistor.yaml" >/dev/null
|
||||
kubectl apply --server-side \
|
||||
--filename="${render_temp_dir}/minio-aistor.yaml"
|
||||
|
||||
statefulset_name=""
|
||||
for _ in $(seq 1 180); do
|
||||
statefulset_names="$(
|
||||
kubectl --namespace object-storage get statefulset \
|
||||
--selector="aistor.min.io/objectStore=${EXPECTED_OBJECTSTORE}" \
|
||||
--output=name
|
||||
)"
|
||||
statefulset_count="$(printf '%s\n' "$statefulset_names" | sed '/^$/d' | wc -l | tr -d '[:space:]')"
|
||||
if [[ "$statefulset_count" == "1" ]]; then
|
||||
statefulset_name="$statefulset_names"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
[[ -n "$statefulset_name" ]] || \
|
||||
fail "the ObjectStore Operator did not create exactly one StatefulSet"
|
||||
kubectl --namespace object-storage rollout status "$statefulset_name" --timeout=15m
|
||||
kubectl --namespace object-storage wait \
|
||||
--for=condition=Ready pod \
|
||||
--selector="aistor.min.io/objectStore=${EXPECTED_OBJECTSTORE}" \
|
||||
--timeout=10m
|
||||
|
||||
current_step="[6/7] AIStor default-deny NetworkPolicies"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged aistor-network-policies
|
||||
kubectl apply --dry-run=server \
|
||||
--filename="${render_temp_dir}/aistor-network-policies.yaml" >/dev/null
|
||||
kubectl apply \
|
||||
--filename="${render_temp_dir}/aistor-network-policies.yaml"
|
||||
kubectl --namespace object-storage wait \
|
||||
--for=condition=Ready pod \
|
||||
--selector="aistor.min.io/objectStore=${EXPECTED_OBJECTSTORE}" \
|
||||
--timeout=5m
|
||||
|
||||
current_step="[7/7] Storage, service, and exposure acceptance"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
pvc_json="$(
|
||||
kubectl --namespace object-storage get persistentvolumeclaim --output=json |
|
||||
jq --arg storage_class "$EXPECTED_STORAGE_CLASS" \
|
||||
'{apiVersion, kind, items: [.items[] | select(.spec.storageClassName == $storage_class)]}'
|
||||
)"
|
||||
[[ "$(jq '.items | length' <<<"$pvc_json")" == "1" ]] || \
|
||||
fail "expected exactly one AIStor PVC"
|
||||
[[ "$(jq -r '.items[0].status.phase' <<<"$pvc_json")" == "Bound" ]] || \
|
||||
fail "AIStor PVC is not Bound"
|
||||
[[ "$(jq -r '.items[0].spec.volumeName' <<<"$pvc_json")" == "$EXPECTED_PV" ]] || \
|
||||
fail "AIStor PVC did not bind the expected Local PV"
|
||||
[[ "$(jq -r '.items[0].spec.resources.requests.storage' <<<"$pvc_json")" == "900Gi" ]] || \
|
||||
fail "AIStor PVC request is not 900Gi"
|
||||
|
||||
for service_name in minio minio-aistor-console minio-aistor-hl; do
|
||||
service_type="$(
|
||||
kubectl --namespace object-storage get service "$service_name" \
|
||||
--output=jsonpath='{.spec.type}'
|
||||
)"
|
||||
[[ "$service_type" == "ClusterIP" ]] || \
|
||||
fail "${service_name} must remain ClusterIP"
|
||||
done
|
||||
[[ "$(
|
||||
kubectl --namespace object-storage get service minio \
|
||||
--output=jsonpath='{.spec.ports[0].port}:{.spec.ports[0].targetPort}'
|
||||
)" == "80:9000" ]] || fail "S3 Service must map 80/TCP to 9000/TCP"
|
||||
[[ "$(
|
||||
kubectl --namespace object-storage get service minio-aistor-console \
|
||||
--output=jsonpath='{.spec.ports[0].port}:{.spec.ports[0].targetPort}'
|
||||
)" == "9090:9090" ]] || fail "Console Service must map 9090/TCP to 9090/TCP"
|
||||
[[ "$(
|
||||
kubectl --namespace object-storage get service minio-aistor-hl \
|
||||
--output=jsonpath='{.spec.clusterIP}'
|
||||
)" == "None" ]] || fail "AIStor headless Service must remain headless"
|
||||
[[ -z "$(
|
||||
kubectl --namespace object-storage get service --output=json |
|
||||
jq -r '.items[].spec.ports[]? | select(.nodePort != null) | .nodePort'
|
||||
)" ]] || fail "an AIStor service unexpectedly has a NodePort"
|
||||
[[ -z "$(kubectl --namespace object-storage get ingress --output=name)" ]] || \
|
||||
fail "AIStor must not have an Ingress"
|
||||
|
||||
printf '\nAISTOR APPLY SUCCESS\n'
|
||||
printf 'ObjectStore: object-storage/%s\n' "$EXPECTED_OBJECTSTORE"
|
||||
printf 'Storage: %s -> %s (900Gi PVC, Retain)\n' \
|
||||
"$EXPECTED_AISTOR_DEVICE" "$EXPECTED_AISTOR_MOUNT"
|
||||
printf '%s\n' \
|
||||
'Exposure: ClusterIP only; no Host Nginx, Traefik, NodePort, or public DNS.' \
|
||||
"Root credential file: ${root_config_file}" \
|
||||
'Next: run the authenticated S3 write/read smoke test.'
|
||||
Executable
+673
@@ -0,0 +1,673 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# Do not inherit xtrace: redirect headers contain an OIDC state value.
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly EXPECTED_HELM_VERSION="v3.19.4"
|
||||
readonly TARGET_NODE="donghyeon-system-product-name"
|
||||
readonly EXPECTED_NODE_INTERNAL_IP="192.168.0.107"
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly GITEA_NAMESPACE="gitea"
|
||||
readonly GITEA_DEPLOYMENT="gitea"
|
||||
readonly GITEA_SERVICE="gitea-http"
|
||||
readonly GITEA_HOST="git.learn.hyeonworks.com"
|
||||
readonly GITEA_ROOT_URL="https://${GITEA_HOST}"
|
||||
readonly GITEA_HEALTH_URL="${GITEA_ROOT_URL}/api/healthz"
|
||||
readonly GITEA_LOGIN_URL="${GITEA_ROOT_URL}/user/login"
|
||||
readonly GITEA_SIGNUP_URL="${GITEA_ROOT_URL}/user/sign_up"
|
||||
readonly GITEA_OIDC_START_URL="${GITEA_ROOT_URL}/user/oauth2/keycloak"
|
||||
readonly GITEA_OIDC_CALLBACK_ENCODED="https%3A%2F%2Fgit.learn.hyeonworks.com%2Fuser%2Foauth2%2Fkeycloak%2Fcallback"
|
||||
readonly KEYCLOAK_HOST="id.learn.hyeonworks.com"
|
||||
readonly KEYCLOAK_ISSUER="https://${KEYCLOAK_HOST}/realms/hyeonworks"
|
||||
readonly KEYCLOAK_DISCOVERY_URL="${KEYCLOAK_ISSUER}/.well-known/openid-configuration"
|
||||
readonly KEYCLOAK_AUTH_ENDPOINT="${KEYCLOAK_ISSUER}/protocol/openid-connect/auth"
|
||||
readonly OIDC_SECRET_NAME="gitea-keycloak-oidc"
|
||||
readonly -a VERIFIED_MANIFEST_NAMES=(
|
||||
namespaces
|
||||
ssd-local-pv
|
||||
cnpg-operator
|
||||
platform-postgres
|
||||
gitea
|
||||
gitea-oidc
|
||||
)
|
||||
|
||||
render_temp_dir=""
|
||||
runtime_temp_dir=""
|
||||
gitea_manifest_sha256="not-rendered"
|
||||
apply_started=0
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash scripts/bootstrap/apply-gitea-oidc.sh --execute
|
||||
|
||||
Keycloak 공개 discovery, Gitea OIDC Secret 계약, 현재 Gitea 상태를 먼저
|
||||
검사합니다. 고정된 Chart SHA를 검증하는 render-phase1.sh의 0600 handoff에서
|
||||
gitea-oidc.yaml 하나만 적용한 뒤 OIDC, 외부 인증 전용 가입 정책, 브랜딩을 확인합니다.
|
||||
|
||||
실패해도 Kubernetes 리소스를 삭제하거나 이전 버전으로 롤백하지 않습니다.
|
||||
Secret 값, 토큰, OIDC state가 포함된 전체 Location은 출력하지 않습니다.
|
||||
USAGE
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local cleanup_rc=$?
|
||||
|
||||
trap - EXIT
|
||||
set +e
|
||||
if (( cleanup_rc != 0 )); then
|
||||
if (( apply_started == 1 )); then
|
||||
printf '\nRETAINED STATE: Gitea apply가 시작된 뒤 검증에 실패했습니다.\n' >&2
|
||||
printf '자동 삭제와 롤백은 수행하지 않았으며 현재 클러스터 상태를 보존했습니다.\n' >&2
|
||||
printf '적용 대상으로 고정했던 gitea-oidc.yaml SHA-256: %s\n' \
|
||||
"$gitea_manifest_sha256" >&2
|
||||
printf '확인: kubectl --namespace gitea get deployment,pod,service,endpointslice,ingress\n' >&2
|
||||
else
|
||||
printf '\nNO MUTATION: 사전 검사 또는 렌더링 단계에서 중단되어 Gitea를 적용하지 않았습니다.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$render_temp_dir" ]]; then
|
||||
case "$render_temp_dir" in
|
||||
/tmp/platform-phase1-apply.*)
|
||||
rm -rf -- "$render_temp_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected render path: %s\n' \
|
||||
"$render_temp_dir" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [[ -n "$runtime_temp_dir" ]]; then
|
||||
case "$runtime_temp_dir" in
|
||||
/tmp/gitea-oidc-apply.*)
|
||||
rm -rf -- "$runtime_temp_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected runtime path: %s\n' \
|
||||
"$runtime_temp_dir" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit "$cleanup_rc"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required"
|
||||
[[ "$(pwd -P)" == "$REPOSITORY_ROOT" ]] || \
|
||||
fail "run from ${REPOSITORY_ROOT}"
|
||||
|
||||
for command_name in \
|
||||
kubectl curl jq rg sha256sum stat find wc tr sort mktemp chmod mkdir rm awk; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
|
||||
if [[ -n "${PLATFORM_HELM_BIN:-}" ]]; then
|
||||
[[ "$PLATFORM_HELM_BIN" == /* ]] || \
|
||||
fail "PLATFORM_HELM_BIN must be an absolute path"
|
||||
[[ -f "$PLATFORM_HELM_BIN" && -x "$PLATFORM_HELM_BIN" ]] || \
|
||||
fail "PLATFORM_HELM_BIN is not executable: ${PLATFORM_HELM_BIN}"
|
||||
readonly HELM_BIN="$PLATFORM_HELM_BIN"
|
||||
else
|
||||
HELM_BIN="$(command -v helm 2>/dev/null)" || \
|
||||
fail "Helm ${EXPECTED_HELM_VERSION} is required"
|
||||
readonly HELM_BIN
|
||||
fi
|
||||
[[ "$("$HELM_BIN" version --template '{{.Version}}')" == "$EXPECTED_HELM_VERSION" ]] || \
|
||||
fail "Helm must be exactly ${EXPECTED_HELM_VERSION}"
|
||||
|
||||
readonly CURRENT_CONTEXT="$(kubectl config current-context)"
|
||||
readonly API_SERVER="$(
|
||||
kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}'
|
||||
)"
|
||||
[[ -n "$CURRENT_CONTEXT" ]] || fail "kubectl current-context is empty"
|
||||
[[ -n "$API_SERVER" ]] || fail "the selected Kubernetes API server is empty"
|
||||
|
||||
https_get() {
|
||||
local host="$1"
|
||||
local url="$2"
|
||||
local output_file="$3"
|
||||
|
||||
curl \
|
||||
--disable \
|
||||
--silent \
|
||||
--show-error \
|
||||
--fail-with-body \
|
||||
--noproxy '*' \
|
||||
--resolve "${host}:443:127.0.0.1" \
|
||||
--connect-timeout 3 \
|
||||
--max-time 20 \
|
||||
--header 'Cache-Control: no-cache' \
|
||||
--output "$output_file" \
|
||||
"$url"
|
||||
}
|
||||
|
||||
check_target_node() {
|
||||
local internal_ip
|
||||
local ready_status
|
||||
|
||||
kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \
|
||||
fail "target node is missing: ${TARGET_NODE}"
|
||||
ready_status="$(
|
||||
kubectl get node "$TARGET_NODE" \
|
||||
--output=jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
|
||||
)"
|
||||
[[ "$ready_status" == "True" ]] || \
|
||||
fail "target node is not Ready: ${TARGET_NODE}"
|
||||
internal_ip="$(
|
||||
kubectl get node "$TARGET_NODE" \
|
||||
--output=jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}'
|
||||
)"
|
||||
[[ "$internal_ip" == "$EXPECTED_NODE_INTERNAL_IP" ]] || \
|
||||
fail "target node InternalIP is ${internal_ip:-missing}, expected ${EXPECTED_NODE_INTERNAL_IP}"
|
||||
}
|
||||
|
||||
check_oidc_secret_contract() {
|
||||
local secret_type
|
||||
local secret_keys
|
||||
|
||||
# These output expressions inspect only the Secret type and data key names.
|
||||
# They never select, decode, compare, or print either data value.
|
||||
secret_type="$(
|
||||
kubectl --namespace "$GITEA_NAMESPACE" get secret "$OIDC_SECRET_NAME" \
|
||||
--output=jsonpath='{.type}'
|
||||
)"
|
||||
secret_keys="$(
|
||||
kubectl --namespace "$GITEA_NAMESPACE" get secret "$OIDC_SECRET_NAME" \
|
||||
--output=go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' \
|
||||
| LC_ALL=C sort
|
||||
)"
|
||||
|
||||
[[ "$secret_type" == "Opaque" ]] || \
|
||||
fail "${GITEA_NAMESPACE}/${OIDC_SECRET_NAME} type must be Opaque"
|
||||
[[ "$secret_keys" == $'key\nsecret' ]] || \
|
||||
fail "${GITEA_NAMESPACE}/${OIDC_SECRET_NAME} must contain exactly key and secret"
|
||||
}
|
||||
|
||||
check_public_discovery() {
|
||||
local discovery_file="$1"
|
||||
|
||||
https_get "$KEYCLOAK_HOST" "$KEYCLOAK_DISCOVERY_URL" "$discovery_file"
|
||||
jq --exit-status \
|
||||
--arg issuer "$KEYCLOAK_ISSUER" \
|
||||
--arg authorization_endpoint "$KEYCLOAK_AUTH_ENDPOINT" \
|
||||
'type == "object" and
|
||||
.issuer == $issuer and
|
||||
.authorization_endpoint == $authorization_endpoint and
|
||||
(.token_endpoint | type == "string" and startswith($issuer + "/"))' \
|
||||
"$discovery_file" >/dev/null 2>&1 || \
|
||||
fail "local-SNI Keycloak discovery is not the expected JSON issuer"
|
||||
}
|
||||
|
||||
check_gitea_health() {
|
||||
local health_file="$1"
|
||||
|
||||
kubectl --namespace "$GITEA_NAMESPACE" wait \
|
||||
--for=condition=Available "deployment/${GITEA_DEPLOYMENT}" \
|
||||
--timeout=30s >/dev/null
|
||||
kubectl --namespace "$GITEA_NAMESPACE" wait \
|
||||
--for=jsonpath='{.endpoints[0].conditions.ready}'=true \
|
||||
endpointslice \
|
||||
--selector="kubernetes.io/service-name=${GITEA_SERVICE}" \
|
||||
--timeout=30s >/dev/null
|
||||
|
||||
https_get "$GITEA_HOST" "$GITEA_HEALTH_URL" "$health_file"
|
||||
jq --exit-status '.status == "pass"' "$health_file" >/dev/null || \
|
||||
fail "Gitea public health response is not status=pass JSON"
|
||||
}
|
||||
|
||||
verify_rendered_manifest_unchanged() {
|
||||
local manifest_path="${render_temp_dir}/gitea-oidc.yaml"
|
||||
local checksum_output
|
||||
local actual_sha256
|
||||
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified Gitea manifest is missing or unsafe"
|
||||
[[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \
|
||||
fail "verified Gitea manifest must have mode 0600"
|
||||
checksum_output="$(sha256sum -- "$manifest_path")"
|
||||
actual_sha256="${checksum_output%% *}"
|
||||
[[ "$actual_sha256" == "$gitea_manifest_sha256" ]] || \
|
||||
fail "verified gitea-oidc.yaml changed after confirmation"
|
||||
}
|
||||
|
||||
check_auth_source() {
|
||||
local auth_list_file="$1"
|
||||
local auth_error_file="$2"
|
||||
local keycloak_count
|
||||
local active_oauth2_count
|
||||
|
||||
if ! kubectl --namespace "$GITEA_NAMESPACE" exec \
|
||||
"deployment/${GITEA_DEPLOYMENT}" \
|
||||
--container gitea \
|
||||
-- gitea admin auth list \
|
||||
--vertical-bars \
|
||||
--min-width 1 \
|
||||
--tab-width 1 \
|
||||
--padding 0 \
|
||||
--pad-char ' ' \
|
||||
>"$auth_list_file" 2>"$auth_error_file"; then
|
||||
fail "gitea admin auth list failed; its output was retained only in the private temp directory"
|
||||
fi
|
||||
|
||||
read -r keycloak_count active_oauth2_count < <(
|
||||
awk -F '|' '
|
||||
function trim(value) {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
NF == 4 {
|
||||
name = trim($2)
|
||||
type = trim($3)
|
||||
enabled = trim($4)
|
||||
if (name == "keycloak") {
|
||||
keycloak_count++
|
||||
if (type == "OAuth2" && enabled == "true") {
|
||||
active_oauth2_count++
|
||||
}
|
||||
}
|
||||
}
|
||||
END {
|
||||
print keycloak_count + 0, active_oauth2_count + 0
|
||||
}
|
||||
' "$auth_list_file"
|
||||
)
|
||||
|
||||
[[ "$keycloak_count" == "1" && "$active_oauth2_count" == "1" ]] || \
|
||||
fail "exactly one active OAuth2 auth source named keycloak was not found"
|
||||
}
|
||||
|
||||
check_app_ini_policy() {
|
||||
local app_ini_error_file="$1"
|
||||
|
||||
if ! kubectl --namespace "$GITEA_NAMESPACE" exec \
|
||||
"deployment/${GITEA_DEPLOYMENT}" \
|
||||
--container gitea \
|
||||
-- awk '
|
||||
function trim(value) {
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
return value
|
||||
}
|
||||
/^[[:space:]]*\[/ {
|
||||
section = $0
|
||||
gsub(/^[[:space:]]*\[|\][[:space:]]*$/, "", section)
|
||||
section = tolower(section)
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*[#;]/ || /^[[:space:]]*$/ {
|
||||
next
|
||||
}
|
||||
{
|
||||
split($0, pair, "=")
|
||||
key = toupper(trim(pair[1]))
|
||||
value = $0
|
||||
sub(/^[^=]*=/, "", value)
|
||||
value = trim(value)
|
||||
if (section == "service" && key == "DISABLE_REGISTRATION" && tolower(value) == "false") disabled++
|
||||
if (section == "service" && key == "ALLOW_ONLY_EXTERNAL_REGISTRATION" && tolower(value) == "true") external_only++
|
||||
if (section == "service" && key == "SHOW_REGISTRATION_BUTTON" && tolower(value) == "false") button_hidden++
|
||||
if (section == "service" && key == "ENABLE_PASSWORD_SIGNIN_FORM" && tolower(value) == "true") password_signin++
|
||||
if (section == "oauth2_client" && key == "ENABLE_AUTO_REGISTRATION" && tolower(value) == "true") oidc_jit++
|
||||
if (section == "oauth2_client" && key == "USERNAME" && value == "preferred_username") username_claim++
|
||||
if (section == "oauth2_client" && key == "ACCOUNT_LINKING" && value == "login") account_linking++
|
||||
if (section == "oauth2_client" && key == "OPENID_CONNECT_SCOPES" && value == "profile email") oidc_scopes++
|
||||
}
|
||||
END {
|
||||
exit !(disabled == 1 &&
|
||||
external_only == 1 &&
|
||||
button_hidden == 1 &&
|
||||
password_signin == 1 &&
|
||||
oidc_jit == 1 &&
|
||||
username_claim == 1 &&
|
||||
account_linking == 1 &&
|
||||
oidc_scopes == 1)
|
||||
}
|
||||
' /data/gitea/conf/app.ini >/dev/null 2>"$app_ini_error_file"; then
|
||||
fail "live app.ini does not satisfy the external-registration-only OIDC policy"
|
||||
fi
|
||||
}
|
||||
|
||||
check_login_html() {
|
||||
local login_html_file="$1"
|
||||
local signup_html_file="$2"
|
||||
local signup_headers_file="$3"
|
||||
local signup_status
|
||||
local header_line
|
||||
local field_name
|
||||
local signup_location=""
|
||||
local signup_location_count=0
|
||||
|
||||
https_get "$GITEA_HOST" "$GITEA_LOGIN_URL" "$login_html_file"
|
||||
rg --quiet --fixed-strings 'href="/user/oauth2/keycloak"' "$login_html_file" || \
|
||||
fail "Gitea login HTML does not contain the Keycloak OIDC link"
|
||||
rg --quiet --fixed-strings 'href="/assets/css/hyeonworks.css"' "$login_html_file" || \
|
||||
fail "Gitea login HTML does not contain the Hyeonworks stylesheet"
|
||||
rg --quiet --fixed-strings 'name="theme-color" content="#0f172a"' "$login_html_file" || \
|
||||
fail "Gitea login HTML does not contain the Hyeonworks theme marker"
|
||||
rg --quiet --fixed-strings 'hw-brand-link' "$login_html_file" || \
|
||||
fail "Gitea login HTML does not contain the Hyeonworks navigation marker"
|
||||
if rg --quiet --fixed-strings 'href="/user/sign_up"' "$login_html_file"; then
|
||||
fail "Gitea login HTML still exposes a local sign-up link"
|
||||
fi
|
||||
|
||||
if ! signup_status="$(
|
||||
curl \
|
||||
--disable \
|
||||
--silent \
|
||||
--show-error \
|
||||
--noproxy '*' \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
--connect-timeout 3 \
|
||||
--max-time 20 \
|
||||
--header 'Cache-Control: no-cache' \
|
||||
--output "$signup_html_file" \
|
||||
--dump-header "$signup_headers_file" \
|
||||
--write-out '%{http_code}' \
|
||||
"$GITEA_SIGNUP_URL"
|
||||
)"; then
|
||||
fail "Gitea sign-up endpoint transport check failed"
|
||||
fi
|
||||
|
||||
case "$signup_status" in
|
||||
200)
|
||||
for field_name in user_name email password retype; do
|
||||
if rg --quiet --fixed-strings "name=\"${field_name}\"" "$signup_html_file"; then
|
||||
fail "Gitea sign-up HTML still exposes a local registration input"
|
||||
fi
|
||||
done
|
||||
;;
|
||||
404)
|
||||
;;
|
||||
301|302|303|307|308)
|
||||
while IFS= read -r header_line; do
|
||||
header_line="${header_line%$'\r'}"
|
||||
case "$header_line" in
|
||||
[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:*)
|
||||
signup_location="${header_line#*:}"
|
||||
signup_location="${signup_location#"${signup_location%%[![:space:]]*}"}"
|
||||
((signup_location_count += 1))
|
||||
;;
|
||||
esac
|
||||
done <"$signup_headers_file"
|
||||
[[ "$signup_location_count" == "1" ]] || \
|
||||
fail "Gitea sign-up redirect must contain exactly one Location header"
|
||||
case "$signup_location" in
|
||||
/user/login|"${GITEA_ROOT_URL}/user/login")
|
||||
;;
|
||||
*)
|
||||
fail "Gitea sign-up redirect does not target the same-origin login page"
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
fail "Gitea sign-up endpoint returned an unexpected HTTP status: ${signup_status}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_oidc_redirect() {
|
||||
local headers_file="$1"
|
||||
local status
|
||||
local header_line
|
||||
local location=""
|
||||
local location_count=0
|
||||
local query
|
||||
local parameter
|
||||
local parameter_name
|
||||
local parameter_value
|
||||
local -a query_parameters=()
|
||||
local client_id_count=0
|
||||
local response_type_count=0
|
||||
local redirect_uri_count=0
|
||||
local state_count=0
|
||||
|
||||
status="$(
|
||||
curl \
|
||||
--disable \
|
||||
--silent \
|
||||
--show-error \
|
||||
--noproxy '*' \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
--connect-timeout 3 \
|
||||
--max-time 20 \
|
||||
--output /dev/null \
|
||||
--dump-header "$headers_file" \
|
||||
--write-out '%{http_code}' \
|
||||
"$GITEA_OIDC_START_URL"
|
||||
)"
|
||||
case "$status" in
|
||||
302|303|307)
|
||||
;;
|
||||
*)
|
||||
fail "Gitea OIDC start returned HTTP ${status}, expected 302, 303, or 307"
|
||||
;;
|
||||
esac
|
||||
|
||||
while IFS= read -r header_line; do
|
||||
header_line="${header_line%$'\r'}"
|
||||
case "$header_line" in
|
||||
[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:*)
|
||||
location="${header_line#*:}"
|
||||
location="${location#"${location%%[![:space:]]*}"}"
|
||||
((location_count += 1))
|
||||
;;
|
||||
esac
|
||||
done <"$headers_file"
|
||||
|
||||
[[ "$location_count" == "1" && -n "$location" ]] || \
|
||||
fail "Gitea OIDC start did not return exactly one non-empty Location header"
|
||||
[[ "$location" != *'#'* ]] || \
|
||||
fail "Gitea OIDC Location unexpectedly contains a fragment"
|
||||
case "$location" in
|
||||
"${KEYCLOAK_AUTH_ENDPOINT}"\?*)
|
||||
;;
|
||||
*)
|
||||
fail "Gitea OIDC Location does not target the expected Keycloak authorization endpoint"
|
||||
;;
|
||||
esac
|
||||
|
||||
query="${location#*\?}"
|
||||
IFS='&' read -r -a query_parameters <<<"$query"
|
||||
for parameter in "${query_parameters[@]}"; do
|
||||
[[ "$parameter" == *=* ]] || continue
|
||||
parameter_name="${parameter%%=*}"
|
||||
parameter_value="${parameter#*=}"
|
||||
case "$parameter_name" in
|
||||
client_id)
|
||||
((client_id_count += 1))
|
||||
[[ "$parameter_value" == "gitea" ]] || \
|
||||
fail "OIDC Location client_id is not gitea"
|
||||
;;
|
||||
response_type)
|
||||
((response_type_count += 1))
|
||||
[[ "$parameter_value" == "code" ]] || \
|
||||
fail "OIDC Location response_type is not code"
|
||||
;;
|
||||
redirect_uri)
|
||||
((redirect_uri_count += 1))
|
||||
[[ "$parameter_value" == "$GITEA_OIDC_CALLBACK_ENCODED" ]] || \
|
||||
fail "OIDC Location callback is not the exact public Gitea callback"
|
||||
;;
|
||||
state)
|
||||
((state_count += 1))
|
||||
[[ -n "$parameter_value" ]] || fail "OIDC Location state is empty"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$client_id_count" == "1" ]] || fail "OIDC Location must contain one client_id"
|
||||
[[ "$response_type_count" == "1" ]] || fail "OIDC Location must contain one response_type"
|
||||
[[ "$redirect_uri_count" == "1" ]] || fail "OIDC Location must contain one redirect_uri"
|
||||
[[ "$state_count" == "1" ]] || fail "OIDC Location must contain one non-empty state"
|
||||
# Deliberately do not print $location or any parsed state value.
|
||||
}
|
||||
|
||||
check_branding_hashes() {
|
||||
local remote_dir="$1"
|
||||
local index
|
||||
local source_path
|
||||
local remote_path
|
||||
local checksum_output
|
||||
local local_sha256
|
||||
local remote_sha256
|
||||
local -a source_paths=(
|
||||
"${REPOSITORY_ROOT}/services/gitea/branding/public/assets/css/hyeonworks.css"
|
||||
"${REPOSITORY_ROOT}/services/gitea/branding/public/assets/img/logo.svg"
|
||||
"${REPOSITORY_ROOT}/services/gitea/branding/public/assets/img/favicon.svg"
|
||||
)
|
||||
local -a public_paths=(
|
||||
"/assets/css/hyeonworks.css"
|
||||
"/assets/img/logo.svg"
|
||||
"/assets/img/favicon.svg"
|
||||
)
|
||||
local -a labels=(
|
||||
"hyeonworks.css"
|
||||
"logo.svg"
|
||||
"favicon.svg"
|
||||
)
|
||||
|
||||
for index in "${!source_paths[@]}"; do
|
||||
source_path="${source_paths[$index]}"
|
||||
remote_path="${remote_dir}/${labels[$index]}"
|
||||
[[ -f "$source_path" && ! -L "$source_path" ]] || \
|
||||
fail "branding source is missing or symlinked: ${source_path}"
|
||||
|
||||
https_get \
|
||||
"$GITEA_HOST" \
|
||||
"${GITEA_ROOT_URL}${public_paths[$index]}" \
|
||||
"$remote_path"
|
||||
|
||||
checksum_output="$(sha256sum -- "$source_path")"
|
||||
local_sha256="${checksum_output%% *}"
|
||||
checksum_output="$(sha256sum -- "$remote_path")"
|
||||
remote_sha256="${checksum_output%% *}"
|
||||
[[ "$remote_sha256" == "$local_sha256" ]] || \
|
||||
fail "public branding hash differs from local source: ${labels[$index]}"
|
||||
printf '브랜딩 해시 일치: %-16s %s\n' \
|
||||
"${labels[$index]}" "$local_sha256"
|
||||
done
|
||||
}
|
||||
|
||||
runtime_temp_dir="$(mktemp -d /tmp/gitea-oidc-apply.XXXXXX)"
|
||||
chmod 0700 "$runtime_temp_dir"
|
||||
render_temp_dir="$(mktemp -d /tmp/platform-phase1-apply.XXXXXX)"
|
||||
chmod 0700 "$render_temp_dir"
|
||||
readonly DISCOVERY_FILE="${runtime_temp_dir}/keycloak-discovery.json"
|
||||
readonly HEALTH_FILE="${runtime_temp_dir}/gitea-health.json"
|
||||
readonly AUTH_LIST_FILE="${runtime_temp_dir}/gitea-auth-list.txt"
|
||||
readonly AUTH_ERROR_FILE="${runtime_temp_dir}/gitea-auth-list.err"
|
||||
readonly APP_INI_ERROR_FILE="${runtime_temp_dir}/gitea-app-ini.err"
|
||||
readonly LOGIN_HTML_FILE="${runtime_temp_dir}/gitea-login.html"
|
||||
readonly SIGNUP_HTML_FILE="${runtime_temp_dir}/gitea-signup.html"
|
||||
readonly SIGNUP_HEADERS_FILE="${runtime_temp_dir}/gitea-signup-headers"
|
||||
readonly OIDC_HEADERS_FILE="${runtime_temp_dir}/gitea-oidc-headers"
|
||||
readonly BRANDING_REMOTE_DIR="${runtime_temp_dir}/branding-remote"
|
||||
mkdir -m 0700 -- "$BRANDING_REMOTE_DIR"
|
||||
|
||||
printf '[1/8] 현재 context, 노드, Gitea 상태 확인\n'
|
||||
check_target_node
|
||||
kubectl --namespace "$GITEA_NAMESPACE" get "deployment/${GITEA_DEPLOYMENT}" >/dev/null
|
||||
check_gitea_health "$HEALTH_FILE"
|
||||
|
||||
printf '[2/8] Host Nginx 로컬 SNI 경로의 Keycloak discovery JSON 확인\n'
|
||||
check_public_discovery "$DISCOVERY_FILE"
|
||||
|
||||
printf '[3/8] Gitea OIDC Secret의 type과 key 이름만 확인\n'
|
||||
check_oidc_secret_contract
|
||||
|
||||
printf '[4/8] 고정 Chart SHA 검증 후 Phase 1 manifest 렌더링\n'
|
||||
cd -- "$REPOSITORY_ROOT"
|
||||
PLATFORM_HELM_BIN="$HELM_BIN" \
|
||||
bash scripts/validate/render-phase1.sh \
|
||||
--verified-output-dir "$render_temp_dir"
|
||||
|
||||
verified_entry_count="$(
|
||||
find "$render_temp_dir" -mindepth 1 -maxdepth 1 -type f \
|
||||
-name '*.yaml' | wc -l | tr -d '[:space:]'
|
||||
)"
|
||||
[[ "$verified_entry_count" == "${#VERIFIED_MANIFEST_NAMES[@]}" ]] || \
|
||||
fail "verified handoff must contain exactly ${#VERIFIED_MANIFEST_NAMES[@]} YAML manifests"
|
||||
for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do
|
||||
manifest_path="${render_temp_dir}/${manifest_name}.yaml"
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified manifest is missing or unsafe: ${manifest_name}.yaml"
|
||||
[[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \
|
||||
fail "verified manifest must have mode 0600: ${manifest_name}.yaml"
|
||||
done
|
||||
checksum_output="$(sha256sum -- "${render_temp_dir}/gitea-oidc.yaml")"
|
||||
gitea_manifest_sha256="${checksum_output%% *}"
|
||||
readonly gitea_manifest_sha256
|
||||
|
||||
rg --quiet --fixed-strings 'gitea-keycloak-oidc' "${render_temp_dir}/gitea-oidc.yaml" || \
|
||||
fail "rendered Gitea manifest does not reference the OIDC Secret"
|
||||
rg --quiet --fixed-strings "$KEYCLOAK_DISCOVERY_URL" "${render_temp_dir}/gitea-oidc.yaml" || \
|
||||
fail "rendered Gitea manifest does not contain the exact discovery URL"
|
||||
|
||||
printf '\nKubernetes context: %s\n' "$CURRENT_CONTEXT"
|
||||
printf 'API server: %s\n' "$API_SERVER"
|
||||
printf 'Target node: %s\n' "$TARGET_NODE"
|
||||
printf 'gitea-oidc.yaml SHA-256: %s\n' "$gitea_manifest_sha256"
|
||||
printf '적용 범위: 검증된 gitea-oidc.yaml 하나\n'
|
||||
printf 'Type APPLY %s GITEA-OIDC %s to continue: ' \
|
||||
"$CURRENT_CONTEXT" "$gitea_manifest_sha256"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY ${CURRENT_CONTEXT} GITEA-OIDC ${gitea_manifest_sha256}" ]] || \
|
||||
fail "cancelled"
|
||||
|
||||
[[ "$(kubectl config current-context)" == "$CURRENT_CONTEXT" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(
|
||||
kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}'
|
||||
)" == "$API_SERVER" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
check_target_node
|
||||
check_public_discovery "$DISCOVERY_FILE"
|
||||
check_oidc_secret_contract
|
||||
check_gitea_health "$HEALTH_FILE"
|
||||
verify_rendered_manifest_unchanged
|
||||
|
||||
printf '\n[5/8] 검증된 gitea-oidc.yaml 하나만 적용\n'
|
||||
apply_started=1
|
||||
kubectl apply --filename="${render_temp_dir}/gitea-oidc.yaml"
|
||||
verify_rendered_manifest_unchanged
|
||||
|
||||
printf '[6/8] Deployment rollout과 ready EndpointSlice 대기\n'
|
||||
kubectl --namespace "$GITEA_NAMESPACE" rollout status \
|
||||
"deployment/${GITEA_DEPLOYMENT}" --timeout=10m
|
||||
kubectl --namespace "$GITEA_NAMESPACE" wait \
|
||||
--for=jsonpath='{.endpoints[0].conditions.ready}'=true \
|
||||
endpointslice \
|
||||
--selector="kubernetes.io/service-name=${GITEA_SERVICE}" \
|
||||
--timeout=2m
|
||||
check_gitea_health "$HEALTH_FILE"
|
||||
|
||||
printf '[7/8] 활성 OAuth2 source, app.ini 정책, 로그인/OIDC 흐름 확인\n'
|
||||
check_auth_source "$AUTH_LIST_FILE" "$AUTH_ERROR_FILE"
|
||||
check_app_ini_policy "$APP_INI_ERROR_FILE"
|
||||
check_login_html "$LOGIN_HTML_FILE" "$SIGNUP_HTML_FILE" "$SIGNUP_HEADERS_FILE"
|
||||
check_oidc_redirect "$OIDC_HEADERS_FILE"
|
||||
check_public_discovery "$DISCOVERY_FILE"
|
||||
|
||||
printf '[8/8] 공개 브랜딩 자산과 로컬 소스 SHA-256 비교\n'
|
||||
check_branding_hashes "$BRANDING_REMOTE_DIR"
|
||||
|
||||
printf '\nGITEA OIDC APPLY SUCCESS\n'
|
||||
printf '적용 manifest SHA-256: %s\n' "$gitea_manifest_sha256"
|
||||
printf 'Keycloak discovery, 활성 OAuth2 source, 외부 인증 전용 가입 정책, OIDC redirect를 확인했습니다.\n'
|
||||
printf 'OIDC Secret 값, 토큰, 전체 Location/state는 출력하지 않았습니다.\n'
|
||||
printf '실제 realm 사용자 login/callback/logout은 별도 수동 수용 시험으로 남습니다.\n'
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
||||
readonly CANDIDATE="$ROOT/infrastructure/networking/host-nginx/learn-services-admin.conf"
|
||||
readonly ACTIVE="/etc/nginx/sites-available/learn-services"
|
||||
readonly CERT_NAME="storage-admin.learn.hyeonworks.com"
|
||||
readonly CERT_DIR="/etc/letsencrypt/live/$CERT_NAME"
|
||||
readonly CREDENTIALS="/home/donghyeon/.secrets/certbot/cloudflare.ini"
|
||||
readonly STORAGE_HOST="storage-admin.learn.hyeonworks.com"
|
||||
readonly DB_HOST="db-admin.learn.hyeonworks.com"
|
||||
readonly LAN_IP="192.168.0.107"
|
||||
readonly TAIL_IP="100.92.240.34"
|
||||
execute=false
|
||||
certificate_only=false
|
||||
certbot_email=""
|
||||
rollback_armed=false
|
||||
backup=""
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
사용법:
|
||||
bash scripts/bootstrap/apply-host-nginx-admin.sh
|
||||
bash scripts/bootstrap/apply-host-nginx-admin.sh \
|
||||
--execute --certificate-only --certbot-email you@example.com
|
||||
bash scripts/bootstrap/apply-host-nginx-admin.sh --execute
|
||||
|
||||
dry-run은 공개 DNS, 후보 hash, Ingress endpoint, Cloudflare credential 상태를
|
||||
검사합니다. --execute는 필요하면 공식 Cloudflare snap plugin과 SAN 인증서를
|
||||
발급하고 Nginx를 timestamp backup 뒤 교체합니다.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--certificate-only)
|
||||
certificate_only=true
|
||||
shift
|
||||
;;
|
||||
--execute)
|
||||
execute=true
|
||||
shift
|
||||
;;
|
||||
--certbot-email)
|
||||
(( $# >= 2 )) || fail "--certbot-email 값이 필요합니다"
|
||||
certbot_email="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "지원하지 않는 인자: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
for cmd in awk curl dig install jq kubectl openssl rg sha256sum sleep stat sudo; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
|
||||
done
|
||||
[[ -f "$CANDIDATE" && ! -L "$CANDIDATE" ]] || fail "Nginx 후보가 없습니다"
|
||||
[[ -f "$ACTIVE" && ! -L "$ACTIVE" ]] || fail "활성 Nginx site가 없습니다"
|
||||
[[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요"
|
||||
|
||||
for name in "$STORAGE_HOST" "$DB_HOST"; do
|
||||
[[ -z "$(dig +short @1.1.1.1 A "$name" | tr -d '[:space:]')" ]] || fail "$name 공개 A가 있습니다"
|
||||
[[ -z "$(dig +short @1.1.1.1 AAAA "$name" | tr -d '[:space:]')" ]] || fail "$name 공개 AAAA가 있습니다"
|
||||
done
|
||||
|
||||
kubectl -n object-storage get ingress minio-aistor-console >/dev/null 2>&1 || \
|
||||
printf 'WARNING: AIStor Console Ingress가 아직 적용되지 않았습니다.\n' >&2
|
||||
kubectl -n platform-admin get ingress pgadmin >/dev/null 2>&1 || \
|
||||
printf 'WARNING: pgAdmin Ingress가 아직 적용되지 않았습니다.\n' >&2
|
||||
|
||||
candidate_sha="$(sha256sum "$CANDIDATE" | awk '{print $1}')"
|
||||
active_sha="$(sha256sum "$ACTIVE" | awk '{print $1}')"
|
||||
printf 'Active SHA-256: %s\n' "$active_sha"
|
||||
printf 'Candidate SHA-256: %s\n' "$candidate_sha"
|
||||
printf 'Certificate SAN: %s, %s\n' "$STORAGE_HOST" "$DB_HOST"
|
||||
|
||||
# Ubuntu의 sites-enabled/default는 미등록 HTTP Host에 welcome page(200)를
|
||||
# 반환할 수 있다. HTTP status만으로 관리 도메인의 부분 적용을 판정하지 않고,
|
||||
# 실제 활성 site에 관리 server_name이 들어갔는지를 먼저 확인한다.
|
||||
if [[ "$active_sha" != "$candidate_sha" ]] && \
|
||||
{ rg -q -F "$STORAGE_HOST" "$ACTIVE" || rg -q -F "$DB_HOST" "$ACTIVE"; }; then
|
||||
fail "활성 Nginx site에 후보와 다른 admin 도메인 설정이 부분 적용돼 있습니다"
|
||||
fi
|
||||
|
||||
check_cloudflare_credentials() {
|
||||
sudo test -f "$CREDENTIALS" || \
|
||||
fail "Cloudflare 제한 토큰 파일이 없습니다: $CREDENTIALS"
|
||||
|
||||
owner="$(sudo stat -c '%U:%G' "$CREDENTIALS")"
|
||||
mode="$(sudo stat -c '%a' "$CREDENTIALS")"
|
||||
size="$(sudo stat -c '%s' "$CREDENTIALS")"
|
||||
[[ "$owner" == root:root && "$mode" == 600 ]] || \
|
||||
fail "$CREDENTIALS 소유권/권한은 root:root 0600이어야 합니다"
|
||||
(( size > 0 )) || fail "$CREDENTIALS 파일이 비어 있습니다"
|
||||
|
||||
contract="$(
|
||||
sudo awk -F= '
|
||||
BEGIN { count=0; nonempty=0; unexpected=0 }
|
||||
/^[[:space:]]*($|#)/ { next }
|
||||
/^[[:space:]]*dns_cloudflare_api_token[[:space:]]*=/ {
|
||||
count++
|
||||
value=$0
|
||||
sub(/^[^=]*=[[:space:]]*/, "", value)
|
||||
sub(/[[:space:]]+$/, "", value)
|
||||
if (length(value) >= 20) nonempty++
|
||||
next
|
||||
}
|
||||
{ unexpected++ }
|
||||
END { printf "%d:%d:%d", count, nonempty, unexpected }
|
||||
' "$CREDENTIALS"
|
||||
)"
|
||||
[[ "$contract" == "1:1:0" ]] || \
|
||||
fail "$CREDENTIALS에는 비어 있지 않은 dns_cloudflare_api_token 한 개만 있어야 합니다"
|
||||
|
||||
printf 'Cloudflare credential: root:root 0600 및 key 형식 확인\n'
|
||||
}
|
||||
|
||||
if [[ "$execute" == false ]]; then
|
||||
if sudo -n test -f "$CREDENTIALS" 2>/dev/null; then
|
||||
check_cloudflare_credentials
|
||||
else
|
||||
printf 'Cloudflare credential: root 전용이므로 --execute에서 sudo로 확인\n'
|
||||
fi
|
||||
printf 'DRY RUN PASS: --execute를 지정하지 않아 인증서와 Nginx를 변경하지 않았습니다.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다"
|
||||
sudo -v
|
||||
check_cloudflare_credentials
|
||||
context="$(kubectl config current-context)"
|
||||
printf 'Type APPLY to issue/renew the admin certificate and replace Host Nginx: '
|
||||
read -r answer
|
||||
[[ "$answer" == APPLY ]] || fail "취소했습니다"
|
||||
|
||||
if [[ ! -x /snap/bin/certbot ]]; then
|
||||
fail "지원 대상으로 고정한 snap certbot이 없습니다"
|
||||
fi
|
||||
if ! /snap/bin/certbot plugins 2>/dev/null | rg -q 'dns-cloudflare'; then
|
||||
sudo snap set certbot trust-plugin-with-root=ok
|
||||
sudo snap install certbot-dns-cloudflare
|
||||
fi
|
||||
|
||||
if ! sudo test -s "$CERT_DIR/fullchain.pem" || ! sudo test -s "$CERT_DIR/privkey.pem"; then
|
||||
[[ "$certbot_email" == *@* ]] || fail "최초 인증서 발급에는 --certbot-email이 필요합니다"
|
||||
sudo /snap/bin/certbot certonly \
|
||||
--dns-cloudflare \
|
||||
--dns-cloudflare-credentials "$CREDENTIALS" \
|
||||
--dns-cloudflare-propagation-seconds 60 \
|
||||
--cert-name "$CERT_NAME" \
|
||||
--domains "$STORAGE_HOST" \
|
||||
--domains "$DB_HOST" \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
--email "$certbot_email"
|
||||
fi
|
||||
|
||||
cert_text="$(sudo openssl x509 -in "$CERT_DIR/fullchain.pem" -noout -text)"
|
||||
printf '%s\n' "$cert_text" | rg -q "DNS:$STORAGE_HOST" || fail "인증서에 storage-admin SAN이 없습니다"
|
||||
printf '%s\n' "$cert_text" | rg -q "DNS:$DB_HOST" || fail "인증서에 db-admin SAN이 없습니다"
|
||||
san_count="$(printf '%s\n' "$cert_text" | rg -o 'DNS:[^,[:space:]]+' | sort -u | wc -l | tr -d '[:space:]')"
|
||||
[[ "$san_count" == 2 ]] || fail "admin 인증서 SAN은 정확히 두 개여야 합니다"
|
||||
|
||||
sudo install -d -o root -g root -m 0755 /etc/letsencrypt/renewal-hooks/deploy
|
||||
sudo install -o root -g root -m 0755 \
|
||||
"$ROOT/infrastructure/networking/host-nginx/reload-nginx.sh" \
|
||||
/etc/letsencrypt/renewal-hooks/deploy/reload-nginx
|
||||
|
||||
if [[ "$certificate_only" == true ]]; then
|
||||
printf 'ADMIN CERTIFICATE READY\n'
|
||||
printf 'Nginx cutover는 서비스 적용 뒤 --certificate-only 없이 다시 실행하세요.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for host in "$STORAGE_HOST" "$DB_HOST"; do
|
||||
code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
--noproxy '*' --resolve "$host:80:127.0.0.1" -H "Host: $host" "http://$host/")"
|
||||
[[ "$code" == 200 || "$code" == 404 || "$code" == 301 ]] || \
|
||||
fail "기존 HTTP 경계가 예상과 다릅니다: $host=$code"
|
||||
printf '기존 HTTP fallback: %s=%s\n' "$host" "$code"
|
||||
route_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \
|
||||
--noproxy '*' -H "Host: $host" "http://127.0.0.1:30080/")"
|
||||
[[ "$route_code" == 200 || "$route_code" == 302 || "$route_code" == 303 ]] || \
|
||||
fail "Traefik route가 준비되지 않았습니다: $host=$route_code"
|
||||
done
|
||||
|
||||
probe_https_code() {
|
||||
host="$1"
|
||||
target_ip="$2"
|
||||
source_ip="${3:-}"
|
||||
|
||||
interface_args=()
|
||||
if [[ -n "$source_ip" ]]; then
|
||||
interface_args=(--interface "$source_ip")
|
||||
fi
|
||||
|
||||
curl --disable --silent --output /dev/null --write-out '%{http_code}' \
|
||||
--connect-timeout 1 --max-time 3 --noproxy '*' \
|
||||
"${interface_args[@]}" --resolve "$host:443:$target_ip" "https://$host/" \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
wait_for_admin_proxy_state() {
|
||||
consecutive_passes=0
|
||||
storage_lan=000
|
||||
db_lan=000
|
||||
storage_denied=000
|
||||
db_denied=000
|
||||
|
||||
for ((attempt = 1; attempt <= 15; attempt++)); do
|
||||
storage_lan="$(probe_https_code "$STORAGE_HOST" "$LAN_IP" "$LAN_IP")"
|
||||
db_lan="$(probe_https_code "$DB_HOST" "$LAN_IP" "$LAN_IP")"
|
||||
storage_denied="$(probe_https_code "$STORAGE_HOST" 127.0.0.1)"
|
||||
db_denied="$(probe_https_code "$DB_HOST" 127.0.0.1)"
|
||||
|
||||
if [[ "$storage_lan" == 200 || "$storage_lan" == 302 || "$storage_lan" == 303 ]] &&
|
||||
[[ "$db_lan" == 200 || "$db_lan" == 302 || "$db_lan" == 303 ]] &&
|
||||
[[ "$storage_denied" == 403 && "$db_denied" == 403 ]]; then
|
||||
consecutive_passes=$((consecutive_passes + 1))
|
||||
if (( consecutive_passes >= 2 )); then
|
||||
printf 'Nginx admin proxy state stabilized after %d probes.\n' "$attempt"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
consecutive_passes=0
|
||||
fi
|
||||
|
||||
if (( attempt == 1 || attempt % 5 == 0 )); then
|
||||
printf 'Nginx admin proxy 대기: probe=%d LAN=%s/%s denied=%s/%s\n' \
|
||||
"$attempt" "$storage_lan" "$db_lan" "$storage_denied" "$db_denied"
|
||||
fi
|
||||
(( attempt < 15 )) && sleep 1
|
||||
done
|
||||
|
||||
printf 'ERROR: Nginx admin proxy가 안정화되지 않았습니다: LAN=%s/%s denied=%s/%s\n' \
|
||||
"$storage_lan" "$db_lan" "$storage_denied" "$db_denied" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_admin_tls_rejection() {
|
||||
consecutive_passes=0
|
||||
|
||||
for ((attempt = 1; attempt <= 15; attempt++)); do
|
||||
if ! curl --disable --insecure --silent --output /dev/null \
|
||||
--connect-timeout 1 --max-time 3 --noproxy '*' \
|
||||
--resolve "$STORAGE_HOST:443:127.0.0.1" "https://$STORAGE_HOST/" 2>/dev/null &&
|
||||
! curl --disable --insecure --silent --output /dev/null \
|
||||
--connect-timeout 1 --max-time 3 --noproxy '*' \
|
||||
--resolve "$DB_HOST:443:127.0.0.1" "https://$DB_HOST/" 2>/dev/null; then
|
||||
consecutive_passes=$((consecutive_passes + 1))
|
||||
if (( consecutive_passes >= 2 )); then
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
consecutive_passes=0
|
||||
fi
|
||||
(( attempt < 15 )) && sleep 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
backup="$ACTIVE.before-admin-$(date +%Y%m%d%H%M%S)"
|
||||
printf 'Planned backup: %s\n' "$backup"
|
||||
sudo install -o root -g root -m 0644 "$ACTIVE" "$backup"
|
||||
|
||||
rollback() {
|
||||
set +e
|
||||
printf '\nROLLBACK: %s 복원\n' "$backup" >&2
|
||||
sudo install -o root -g root -m 0644 "$backup" "$ACTIVE"
|
||||
sudo /usr/sbin/nginx -t
|
||||
sudo systemctl reload nginx
|
||||
if ! wait_for_admin_tls_rejection; then
|
||||
printf 'WARNING: rollback 뒤 admin SNI 거부 안정화를 확인하지 못했습니다.\n' >&2
|
||||
fi
|
||||
rollback_armed=false
|
||||
printf 'ROLLBACK complete.\n' >&2
|
||||
}
|
||||
|
||||
finish() {
|
||||
rc=$?
|
||||
trap - EXIT INT TERM
|
||||
if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then
|
||||
rollback
|
||||
fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
rollback_armed=true
|
||||
sudo install -o root -g root -m 0644 "$CANDIDATE" "$ACTIVE"
|
||||
sudo systemctl daemon-reload
|
||||
sudo /usr/sbin/nginx -t
|
||||
sudo systemctl reload nginx
|
||||
sudo systemctl is-active --quiet nginx
|
||||
|
||||
wait_for_admin_proxy_state || fail "LAN HTTPS와 allowlist 상태가 수렴하지 않았습니다"
|
||||
|
||||
for host in "$STORAGE_HOST" "$DB_HOST"; do
|
||||
redirect_result="$(
|
||||
curl --disable --silent --show-error --output /dev/null \
|
||||
--write-out $'%{http_code}\n%{redirect_url}' --max-time 5 --noproxy '*' \
|
||||
--resolve "$host:80:$LAN_IP" "http://$host/"
|
||||
)"
|
||||
[[ "$redirect_result" == $'301\nhttps://'"$host/" ]] || \
|
||||
fail "HTTP redirect 검증 실패: $host=$redirect_result"
|
||||
done
|
||||
|
||||
if curl --insecure --silent --output /dev/null --noproxy '*' \
|
||||
--resolve unconfigured.invalid:443:127.0.0.1 https://unconfigured.invalid/ 2>/dev/null; then
|
||||
fail "unknown SNI가 거부되지 않았습니다"
|
||||
fi
|
||||
|
||||
sudo /snap/bin/certbot renew --dry-run
|
||||
rollback_armed=false
|
||||
printf 'ADMIN NGINX CUTOVER SUCCESS\n'
|
||||
printf 'Backup: %s\n' "$backup"
|
||||
printf 'Candidate SHA-256: %s\n' "$candidate_sha"
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# Do not expose headers, cookies, or future sensitive values through caller xtrace.
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly EXPECTED_CANDIDATE_SHA256="de7ebd4f69cd7d2204ee633e074bf6a4370a6f5e3f3fac9067b099d5d75269b5"
|
||||
readonly EXPECTED_PRE_CUTOVER_SHA256="5b5941519ab677f751568827aa4f315193dfbd9eeac5e8a4ad85d724aaefe16f"
|
||||
readonly EXPECTED_PRE_CUTOVER_HEALTH_SHA256="6b683cb16987ff1f5ded22e9847ac0a45995947927c459b21f427523a41c7484"
|
||||
readonly REPOSITORY_ROOT="/home/donghyeon/workspace/platform"
|
||||
readonly CANDIDATE="${REPOSITORY_ROOT}/infrastructure/networking/host-nginx/learn-services.conf"
|
||||
readonly ACTIVE="/etc/nginx/sites-available/learn-services"
|
||||
readonly ENABLED="/etc/nginx/sites-enabled/learn-services"
|
||||
readonly CURL_BIN="/usr/bin/curl"
|
||||
readonly JQ_BIN="/usr/bin/jq"
|
||||
readonly NGINX_BIN="/usr/sbin/nginx"
|
||||
readonly SYSTEMCTL_BIN="/usr/bin/systemctl"
|
||||
readonly INSTALL_BIN="/usr/bin/install"
|
||||
readonly SHA256SUM_BIN="/usr/bin/sha256sum"
|
||||
readonly STAT_BIN="/usr/bin/stat"
|
||||
readonly READLINK_BIN="/usr/bin/readlink"
|
||||
readonly MKTEMP_BIN="/usr/bin/mktemp"
|
||||
readonly RM_BIN="/usr/bin/rm"
|
||||
readonly DATE_BIN="/usr/bin/date"
|
||||
readonly AWK_BIN="/usr/bin/awk"
|
||||
readonly SLEEP_BIN="/usr/bin/sleep"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'Usage: sudo bash scripts/bootstrap/apply-host-nginx-gitea.sh --execute' \
|
||||
'' \
|
||||
'Backs up the active learn-services site, installs the reviewed Gitea proxy,' \
|
||||
'tests and reloads Nginx, then runs local acceptance checks. Any failure after' \
|
||||
'the active file changes triggers an automatic restore and reload.'
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
[[ "$EUID" -eq 0 ]] || fail "run this script through sudo"
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required"
|
||||
|
||||
for required_binary in \
|
||||
"$CURL_BIN" "$JQ_BIN" "$NGINX_BIN" "$SYSTEMCTL_BIN" "$INSTALL_BIN" \
|
||||
"$SHA256SUM_BIN" "$STAT_BIN" "$READLINK_BIN" "$MKTEMP_BIN" \
|
||||
"$RM_BIN" "$DATE_BIN" "$AWK_BIN" "$SLEEP_BIN"; do
|
||||
[[ -x "$required_binary" ]] || fail "required executable is missing: ${required_binary}"
|
||||
done
|
||||
|
||||
[[ -f "$CANDIDATE" && ! -L "$CANDIDATE" ]] || fail "unsafe candidate: ${CANDIDATE}"
|
||||
[[ -f "$ACTIVE" && ! -L "$ACTIVE" ]] || fail "unsafe active file: ${ACTIVE}"
|
||||
[[ -L "$ENABLED" ]] || fail "enabled path is not a symlink: ${ENABLED}"
|
||||
[[ "$("$READLINK_BIN" -f "$ENABLED")" == "$ACTIVE" ]] || fail "enabled symlink target changed"
|
||||
[[ "$("$STAT_BIN" --format='%U:%G %a' "$ACTIVE")" == "root:root 644" ]] || \
|
||||
fail "active file owner or mode changed"
|
||||
"$SYSTEMCTL_BIN" is-active --quiet nginx || fail "nginx is not active"
|
||||
|
||||
readonly TEMP_DIR="$("$MKTEMP_BIN" -d /tmp/nginx-gitea-cutover.XXXXXX)"
|
||||
readonly CANDIDATE_SNAPSHOT="${TEMP_DIR}/learn-services.candidate"
|
||||
readonly DIRECT_HEALTH="${TEMP_DIR}/direct-health.json"
|
||||
readonly NGINX_HEALTH="${TEMP_DIR}/nginx-health.json"
|
||||
readonly LOGIN_HEADERS="${TEMP_DIR}/login-headers"
|
||||
rollback_armed=0
|
||||
backup=""
|
||||
|
||||
cleanup() {
|
||||
case "$TEMP_DIR" in
|
||||
/tmp/nginx-gitea-cutover.*)
|
||||
"$RM_BIN" -rf -- "$TEMP_DIR"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected temp path: %s\n' "$TEMP_DIR" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
rollback() {
|
||||
local restore_install_rc
|
||||
local restore_test_rc
|
||||
local restore_reload_rc
|
||||
|
||||
set +e
|
||||
printf '\nROLLBACK: restoring %s\n' "$backup" >&2
|
||||
if "$INSTALL_BIN" -o root -g root -m 0644 "$backup" "$ACTIVE" &&
|
||||
[[ "$("$SHA256SUM_BIN" "$ACTIVE" | "$AWK_BIN" '{print $1}')" == "$("$SHA256SUM_BIN" "$backup" | "$AWK_BIN" '{print $1}')" ]]; then
|
||||
restore_install_rc=0
|
||||
else
|
||||
restore_install_rc=1
|
||||
fi
|
||||
"$NGINX_BIN" -t
|
||||
restore_test_rc=$?
|
||||
if (( restore_install_rc == 0 && restore_test_rc == 0 )); then
|
||||
if "$SYSTEMCTL_BIN" reload nginx &&
|
||||
"$SYSTEMCTL_BIN" is-active --quiet nginx &&
|
||||
wait_for_rollback_state; then
|
||||
restore_reload_rc=0
|
||||
else
|
||||
restore_reload_rc=1
|
||||
fi
|
||||
else
|
||||
restore_reload_rc=1
|
||||
fi
|
||||
|
||||
if (( restore_install_rc == 0 && restore_test_rc == 0 && restore_reload_rc == 0 )); then
|
||||
rollback_armed=0
|
||||
printf 'ROLLBACK complete. Active config and response were restored.\n' >&2
|
||||
else
|
||||
printf 'CRITICAL: automatic rollback failed; backup remains at %s\n' "$backup" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
local rc=$?
|
||||
|
||||
trap - EXIT INT TERM
|
||||
if (( rc != 0 && rollback_armed == 1 )); then
|
||||
rollback
|
||||
fi
|
||||
cleanup
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
"$INSTALL_BIN" -o root -g root -m 0600 "$CANDIDATE" "$CANDIDATE_SNAPSHOT"
|
||||
readonly CANDIDATE_SHA256="$("$SHA256SUM_BIN" "$CANDIDATE_SNAPSHOT" | "$AWK_BIN" '{print $1}')"
|
||||
readonly ACTIVE_SHA256="$("$SHA256SUM_BIN" "$ACTIVE" | "$AWK_BIN" '{print $1}')"
|
||||
[[ "$CANDIDATE_SHA256" == "$EXPECTED_CANDIDATE_SHA256" ]] || \
|
||||
fail "candidate digest changed: ${CANDIDATE_SHA256}"
|
||||
if [[ "$ACTIVE_SHA256" != "$EXPECTED_PRE_CUTOVER_SHA256" && "$ACTIVE_SHA256" != "$CANDIDATE_SHA256" ]]; then
|
||||
fail "active config has an unexpected digest: ${ACTIVE_SHA256}"
|
||||
fi
|
||||
|
||||
check_health_json() {
|
||||
local path="$1"
|
||||
|
||||
"$JQ_BIN" -e '
|
||||
.status == "pass" and
|
||||
([.checks["database:ping"][], .checks["cache:ping"][]] |
|
||||
all(.status == "pass"))
|
||||
' "$path" >/dev/null
|
||||
}
|
||||
|
||||
pre_cutover_check() {
|
||||
"$CURL_BIN" --noproxy '*' --fail-with-body --silent --show-error --max-time 10 \
|
||||
--header 'Host: git.learn.hyeonworks.com' \
|
||||
http://127.0.0.1:30080/api/healthz >"$DIRECT_HEALTH"
|
||||
check_health_json "$DIRECT_HEALTH"
|
||||
}
|
||||
|
||||
wait_for_nginx_health() {
|
||||
local attempt
|
||||
local probe_metadata=""
|
||||
local http_code="curl-error"
|
||||
local content_type="unavailable"
|
||||
local size_download="0"
|
||||
local body_sha256=""
|
||||
local classification="transport-error"
|
||||
local consecutive_passes=0
|
||||
|
||||
for ((attempt = 1; attempt <= 10; attempt++)); do
|
||||
if probe_metadata="$(
|
||||
"$CURL_BIN" --noproxy '*' --silent --connect-timeout 1 --max-time 2 \
|
||||
--resolve git.learn.hyeonworks.com:443:127.0.0.1 \
|
||||
--output "$NGINX_HEALTH" \
|
||||
--write-out $'%{http_code}\t%{content_type}\t%{size_download}' \
|
||||
https://git.learn.hyeonworks.com/api/healthz
|
||||
)"; then
|
||||
IFS=$'\t' read -r http_code content_type size_download <<<"$probe_metadata"
|
||||
body_sha256="$(
|
||||
"$SHA256SUM_BIN" "$NGINX_HEALTH" | "$AWK_BIN" '{print $1}'
|
||||
)"
|
||||
|
||||
if [[ "$http_code" == "200" ]] &&
|
||||
check_health_json "$NGINX_HEALTH" 2>/dev/null; then
|
||||
classification="healthy-json"
|
||||
consecutive_passes=$((consecutive_passes + 1))
|
||||
if (( consecutive_passes >= 2 )); then
|
||||
printf 'Nginx proxy health stabilized after %d probes.\n' "$attempt"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
consecutive_passes=0
|
||||
if [[ "$body_sha256" == "$EXPECTED_PRE_CUTOVER_HEALTH_SHA256" ]]; then
|
||||
classification="stale-old-generation"
|
||||
elif [[ "$http_code" =~ ^(502|503|504)$ ]]; then
|
||||
classification="transient-upstream"
|
||||
elif "$JQ_BIN" -e '
|
||||
type == "object" and
|
||||
has("status") and
|
||||
(.checks | type == "object")
|
||||
' "$NGINX_HEALTH" >/dev/null 2>&1; then
|
||||
classification="unhealthy-health-json"
|
||||
else
|
||||
printf 'ERROR: unexpected Nginx health response (status=%s, content-type=%s, bytes=%s, sha256=%s)\n' \
|
||||
"$http_code" "${content_type:-none}" "$size_download" "$body_sha256" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
http_code="curl-error"
|
||||
content_type="unavailable"
|
||||
size_download="0"
|
||||
body_sha256=""
|
||||
classification="transport-error"
|
||||
consecutive_passes=0
|
||||
fi
|
||||
|
||||
if (( attempt < 10 )); then
|
||||
"$SLEEP_BIN" 1
|
||||
fi
|
||||
done
|
||||
|
||||
fail "Nginx health did not converge during the bounded retry window (classification=${classification}, status=${http_code}, content-type=${content_type:-none}, bytes=${size_download})"
|
||||
}
|
||||
|
||||
wait_for_rollback_state() {
|
||||
local attempt
|
||||
local probe_metadata=""
|
||||
local http_code="curl-error"
|
||||
local content_type="unavailable"
|
||||
local size_download="0"
|
||||
local body_sha256=""
|
||||
|
||||
for ((attempt = 1; attempt <= 10; attempt++)); do
|
||||
if probe_metadata="$(
|
||||
"$CURL_BIN" --noproxy '*' --silent --connect-timeout 1 --max-time 2 \
|
||||
--resolve git.learn.hyeonworks.com:443:127.0.0.1 \
|
||||
--output "$NGINX_HEALTH" \
|
||||
--write-out $'%{http_code}\t%{content_type}\t%{size_download}' \
|
||||
https://git.learn.hyeonworks.com/api/healthz
|
||||
)"; then
|
||||
IFS=$'\t' read -r http_code content_type size_download <<<"$probe_metadata"
|
||||
body_sha256="$(
|
||||
"$SHA256SUM_BIN" "$NGINX_HEALTH" | "$AWK_BIN" '{print $1}'
|
||||
)"
|
||||
if [[ "$http_code" == "200" &&
|
||||
"$body_sha256" == "$EXPECTED_PRE_CUTOVER_HEALTH_SHA256" ]]; then
|
||||
if (( attempt > 1 )); then
|
||||
printf 'Rollback proxy state stabilized after %d probes.\n' "$attempt" >&2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
http_code="curl-error"
|
||||
content_type="unavailable"
|
||||
size_download="0"
|
||||
body_sha256=""
|
||||
fi
|
||||
|
||||
if (( attempt < 10 )); then
|
||||
"$SLEEP_BIN" 1
|
||||
fi
|
||||
done
|
||||
|
||||
printf 'CRITICAL: rollback response did not converge (status=%s, content-type=%s, bytes=%s, sha256=%s)\n' \
|
||||
"$http_code" "${content_type:-none}" "$size_download" "${body_sha256:-none}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
post_cutover_checks() {
|
||||
local id_body
|
||||
local redirect_result
|
||||
|
||||
wait_for_nginx_health
|
||||
|
||||
id_body="$(
|
||||
"$CURL_BIN" --noproxy '*' --fail-with-body --silent --show-error --max-time 10 \
|
||||
--resolve id.learn.hyeonworks.com:443:127.0.0.1 \
|
||||
https://id.learn.hyeonworks.com/
|
||||
)"
|
||||
[[ "$id_body" == "Keycloak domain reached Nginx successfully" ]] || \
|
||||
fail "Keycloak hold response changed"
|
||||
|
||||
redirect_result="$(
|
||||
"$CURL_BIN" --noproxy '*' --silent --show-error --max-time 10 \
|
||||
--resolve git.learn.hyeonworks.com:80:127.0.0.1 \
|
||||
--output /dev/null --write-out $'%{http_code}\n%{redirect_url}' \
|
||||
http://git.learn.hyeonworks.com/api/healthz
|
||||
)"
|
||||
[[ "$redirect_result" == $'301\nhttps://git.learn.hyeonworks.com/api/healthz' ]] || \
|
||||
fail "HTTP redirect check failed: ${redirect_result}"
|
||||
|
||||
if "$CURL_BIN" --noproxy '*' --insecure --silent --output /dev/null --max-time 5 \
|
||||
--resolve unconfigured.invalid:443:127.0.0.1 \
|
||||
https://unconfigured.invalid/ 2>/dev/null; then
|
||||
fail "unknown TLS hostname was not rejected"
|
||||
fi
|
||||
|
||||
"$CURL_BIN" --noproxy '*' --fail-with-body --silent --show-error --max-time 10 \
|
||||
--resolve git.learn.hyeonworks.com:443:127.0.0.1 \
|
||||
--dump-header "$LOGIN_HEADERS" --output /dev/null \
|
||||
https://git.learn.hyeonworks.com/user/login
|
||||
"$AWK_BIN" '
|
||||
BEGIN { found = 0; insecure = 0 }
|
||||
tolower($0) ~ /^set-cookie:/ {
|
||||
found++
|
||||
if (tolower($0) !~ /; secure([;[:space:]]|$)/) insecure = 1
|
||||
}
|
||||
END { exit(found == 0 || insecure) }
|
||||
' "$LOGIN_HEADERS" || fail "login cookie Secure check failed"
|
||||
}
|
||||
|
||||
pre_cutover_check
|
||||
"$NGINX_BIN" -t
|
||||
|
||||
if [[ "$ACTIVE_SHA256" == "$CANDIDATE_SHA256" ]]; then
|
||||
post_cutover_checks
|
||||
printf 'The reviewed Host Nginx configuration is already active and healthy.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
backup="${ACTIVE}.before-gitea-$("$DATE_BIN" +%Y%m%d%H%M%S)"
|
||||
[[ ! -e "$backup" && ! -L "$backup" ]] || fail "backup path already exists: ${backup}"
|
||||
|
||||
printf '\nActive SHA-256: %s\n' "$ACTIVE_SHA256"
|
||||
printf 'Candidate SHA-256: %s\n' "$CANDIDATE_SHA256"
|
||||
printf 'Planned backup: %s\n' "$backup"
|
||||
printf 'Type APPLY to replace the Host Nginx site: '
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY" ]] || fail "cancelled"
|
||||
|
||||
"$INSTALL_BIN" -o root -g root -m 0644 "$ACTIVE" "$backup"
|
||||
[[ "$("$SHA256SUM_BIN" "$backup" | "$AWK_BIN" '{print $1}')" == "$ACTIVE_SHA256" ]] || \
|
||||
fail "backup digest mismatch"
|
||||
|
||||
rollback_armed=1
|
||||
"$INSTALL_BIN" -o root -g root -m 0644 "$CANDIDATE_SNAPSHOT" "$ACTIVE"
|
||||
[[ "$("$SHA256SUM_BIN" "$ACTIVE" | "$AWK_BIN" '{print $1}')" == "$CANDIDATE_SHA256" ]] || \
|
||||
fail "installed digest mismatch"
|
||||
"$NGINX_BIN" -t
|
||||
"$SYSTEMCTL_BIN" reload nginx
|
||||
"$SYSTEMCTL_BIN" is-active --quiet nginx
|
||||
post_cutover_checks
|
||||
|
||||
rollback_armed=0
|
||||
printf '\nCUTOVER SUCCESS\n'
|
||||
printf 'Backup: %s\n' "$backup"
|
||||
printf 'Candidate SHA-256: %s\n' "$CANDIDATE_SHA256"
|
||||
printf 'Local HTTPS health, redirect, Secure cookie, Keycloak hold, and unknown-host rejection: PASS\n'
|
||||
printf 'Run the public HTTPS and Git clone/push checks from a separate client next.\n'
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly EXPECTED_CANDIDATE_SHA256="5c5cd74b4992f537fd27c50cf2209573a80a9904e0b19b58c3154717be6ff4a5"
|
||||
readonly EXPECTED_PRE_CUTOVER_SHA256="de7ebd4f69cd7d2204ee633e074bf6a4370a6f5e3f3fac9067b099d5d75269b5"
|
||||
readonly REPOSITORY_ROOT="/home/donghyeon/workspace/platform"
|
||||
readonly CANDIDATE="${REPOSITORY_ROOT}/infrastructure/networking/host-nginx/learn-services-keycloak.conf"
|
||||
readonly ACTIVE="/etc/nginx/sites-available/learn-services"
|
||||
readonly ENABLED="/etc/nginx/sites-enabled/learn-services"
|
||||
readonly GITEA_HOST="git.learn.hyeonworks.com"
|
||||
readonly KEYCLOAK_HOST="id.learn.hyeonworks.com"
|
||||
readonly KEYCLOAK_ISSUER="https://${KEYCLOAK_HOST}/realms/hyeonworks"
|
||||
readonly CURL_BIN="/usr/bin/curl"
|
||||
readonly JQ_BIN="/usr/bin/jq"
|
||||
readonly NGINX_BIN="/usr/sbin/nginx"
|
||||
readonly SYSTEMCTL_BIN="/usr/bin/systemctl"
|
||||
readonly INSTALL_BIN="/usr/bin/install"
|
||||
readonly SHA256SUM_BIN="/usr/bin/sha256sum"
|
||||
readonly STAT_BIN="/usr/bin/stat"
|
||||
readonly READLINK_BIN="/usr/bin/readlink"
|
||||
readonly MKTEMP_BIN="/usr/bin/mktemp"
|
||||
readonly RM_BIN="/usr/bin/rm"
|
||||
readonly DATE_BIN="/usr/bin/date"
|
||||
readonly AWK_BIN="/usr/bin/awk"
|
||||
readonly SLEEP_BIN="/usr/bin/sleep"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'Usage: sudo bash scripts/bootstrap/apply-host-nginx-keycloak.sh --execute' \
|
||||
'' \
|
||||
'Replaces the static id.learn response with the reviewed Keycloak proxy.' \
|
||||
'The active site is backed up first; a failed test or health check restores' \
|
||||
'the prior Gitea-only configuration and reloads Nginx automatically.'
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
[[ "$EUID" -eq 0 ]] || fail "run this script through sudo"
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required"
|
||||
|
||||
for required_binary in \
|
||||
"$CURL_BIN" "$JQ_BIN" "$NGINX_BIN" "$SYSTEMCTL_BIN" "$INSTALL_BIN" \
|
||||
"$SHA256SUM_BIN" "$STAT_BIN" "$READLINK_BIN" "$MKTEMP_BIN" \
|
||||
"$RM_BIN" "$DATE_BIN" "$AWK_BIN" "$SLEEP_BIN"; do
|
||||
[[ -x "$required_binary" ]] || fail "required executable is missing: ${required_binary}"
|
||||
done
|
||||
|
||||
[[ -f "$CANDIDATE" && ! -L "$CANDIDATE" ]] || fail "unsafe candidate: ${CANDIDATE}"
|
||||
[[ -f "$ACTIVE" && ! -L "$ACTIVE" ]] || fail "unsafe active file: ${ACTIVE}"
|
||||
[[ -L "$ENABLED" ]] || fail "enabled path is not a symlink: ${ENABLED}"
|
||||
[[ "$("$READLINK_BIN" -f "$ENABLED")" == "$ACTIVE" ]] || \
|
||||
fail "enabled symlink target changed"
|
||||
[[ "$("$STAT_BIN" --format='%U:%G %a' "$ACTIVE")" == "root:root 644" ]] || \
|
||||
fail "active file owner or mode changed"
|
||||
"$SYSTEMCTL_BIN" is-active --quiet nginx || fail "nginx is not active"
|
||||
|
||||
readonly TEMP_DIR="$("$MKTEMP_BIN" -d /tmp/nginx-keycloak-cutover.XXXXXX)"
|
||||
readonly CANDIDATE_SNAPSHOT="${TEMP_DIR}/learn-services.candidate"
|
||||
readonly GITEA_HEALTH="${TEMP_DIR}/gitea-health.json"
|
||||
readonly KEYCLOAK_DISCOVERY="${TEMP_DIR}/keycloak-discovery.json"
|
||||
readonly LOGIN_HEADERS="${TEMP_DIR}/gitea-login-headers"
|
||||
rollback_armed=0
|
||||
backup=""
|
||||
|
||||
cleanup() {
|
||||
case "$TEMP_DIR" in
|
||||
/tmp/nginx-keycloak-cutover.*)
|
||||
"$RM_BIN" -rf -- "$TEMP_DIR"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected temp path: %s\n' \
|
||||
"$TEMP_DIR" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
check_gitea_health() {
|
||||
"$JQ_BIN" -e '
|
||||
.status == "pass" and
|
||||
([.checks["database:ping"][], .checks["cache:ping"][]] |
|
||||
all(.status == "pass"))
|
||||
' "$GITEA_HEALTH" >/dev/null
|
||||
}
|
||||
|
||||
check_keycloak_discovery() {
|
||||
"$JQ_BIN" -e \
|
||||
--arg issuer "$KEYCLOAK_ISSUER" '
|
||||
.issuer == $issuer and
|
||||
(.authorization_endpoint | startswith($issuer)) and
|
||||
(.token_endpoint | startswith($issuer)) and
|
||||
(.userinfo_endpoint | startswith($issuer)) and
|
||||
(.jwks_uri | startswith($issuer))
|
||||
' "$KEYCLOAK_DISCOVERY" >/dev/null
|
||||
}
|
||||
|
||||
pre_cutover_checks() {
|
||||
"$CURL_BIN" --disable --noproxy '*' --fail-with-body --silent --show-error --max-time 10 \
|
||||
--header "Host: ${GITEA_HOST}" \
|
||||
--header 'X-Forwarded-Proto: https' \
|
||||
http://127.0.0.1:30080/api/healthz >"$GITEA_HEALTH"
|
||||
check_gitea_health
|
||||
|
||||
"$CURL_BIN" --disable --noproxy '*' --fail-with-body --silent --show-error --max-time 10 \
|
||||
--header "Host: ${KEYCLOAK_HOST}" \
|
||||
--header 'X-Forwarded-Host: id.learn.hyeonworks.com' \
|
||||
--header 'X-Forwarded-Proto: https' \
|
||||
--header 'X-Forwarded-Port: 443' \
|
||||
"http://127.0.0.1:30080/realms/hyeonworks/.well-known/openid-configuration" \
|
||||
>"$KEYCLOAK_DISCOVERY"
|
||||
check_keycloak_discovery
|
||||
}
|
||||
|
||||
wait_for_proxy_state() {
|
||||
local attempt
|
||||
|
||||
for ((attempt = 1; attempt <= 12; attempt++)); do
|
||||
if "$CURL_BIN" --disable --noproxy '*' --fail --silent --show-error \
|
||||
--connect-timeout 1 --max-time 3 \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"https://${GITEA_HOST}/api/healthz" >"$GITEA_HEALTH" 2>/dev/null &&
|
||||
check_gitea_health 2>/dev/null &&
|
||||
"$CURL_BIN" --disable --noproxy '*' --fail --silent --show-error \
|
||||
--connect-timeout 1 --max-time 3 \
|
||||
--resolve "${KEYCLOAK_HOST}:443:127.0.0.1" \
|
||||
"https://${KEYCLOAK_HOST}/realms/hyeonworks/.well-known/openid-configuration" \
|
||||
>"$KEYCLOAK_DISCOVERY" 2>/dev/null &&
|
||||
check_keycloak_discovery 2>/dev/null; then
|
||||
if (( attempt > 1 )); then
|
||||
printf 'Nginx proxy state stabilized after %d probes.\n' "$attempt"
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
(( attempt < 12 )) && "$SLEEP_BIN" 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_rollback_state() {
|
||||
local attempt
|
||||
local id_body
|
||||
|
||||
for ((attempt = 1; attempt <= 12; attempt++)); do
|
||||
id_body="$(
|
||||
"$CURL_BIN" --disable --noproxy '*' --fail --silent --show-error \
|
||||
--connect-timeout 1 --max-time 3 \
|
||||
--resolve "${KEYCLOAK_HOST}:443:127.0.0.1" \
|
||||
"https://${KEYCLOAK_HOST}/" 2>/dev/null
|
||||
)" || id_body=""
|
||||
if [[ "$id_body" == "Keycloak domain reached Nginx successfully" ]] &&
|
||||
"$CURL_BIN" --disable --noproxy '*' --fail --silent --show-error \
|
||||
--connect-timeout 1 --max-time 3 \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
"https://${GITEA_HOST}/api/healthz" >"$GITEA_HEALTH" 2>/dev/null &&
|
||||
check_gitea_health 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
(( attempt < 12 )) && "$SLEEP_BIN" 1
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
rollback() {
|
||||
local restore_rc=0
|
||||
|
||||
set +e
|
||||
printf '\nROLLBACK: restoring %s\n' "$backup" >&2
|
||||
"$INSTALL_BIN" -o root -g root -m 0644 "$backup" "$ACTIVE" || restore_rc=1
|
||||
"$NGINX_BIN" -t || restore_rc=1
|
||||
if (( restore_rc == 0 )); then
|
||||
"$SYSTEMCTL_BIN" reload nginx || restore_rc=1
|
||||
"$SYSTEMCTL_BIN" is-active --quiet nginx || restore_rc=1
|
||||
wait_for_rollback_state || restore_rc=1
|
||||
fi
|
||||
|
||||
if (( restore_rc == 0 )); then
|
||||
rollback_armed=0
|
||||
printf 'ROLLBACK complete. The Gitea proxy and static Keycloak hold were restored.\n' >&2
|
||||
else
|
||||
printf 'CRITICAL: automatic rollback failed; backup remains at %s\n' \
|
||||
"$backup" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
local rc=$?
|
||||
|
||||
trap - EXIT INT TERM
|
||||
if (( rc != 0 && rollback_armed == 1 )); then
|
||||
rollback
|
||||
fi
|
||||
cleanup
|
||||
exit "$rc"
|
||||
}
|
||||
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
"$INSTALL_BIN" -o root -g root -m 0600 "$CANDIDATE" "$CANDIDATE_SNAPSHOT"
|
||||
readonly CANDIDATE_SHA256="$(
|
||||
"$SHA256SUM_BIN" "$CANDIDATE_SNAPSHOT" | "$AWK_BIN" '{print $1}'
|
||||
)"
|
||||
readonly ACTIVE_SHA256="$(
|
||||
"$SHA256SUM_BIN" "$ACTIVE" | "$AWK_BIN" '{print $1}'
|
||||
)"
|
||||
[[ "$CANDIDATE_SHA256" == "$EXPECTED_CANDIDATE_SHA256" ]] || \
|
||||
fail "candidate digest changed: ${CANDIDATE_SHA256}"
|
||||
if [[ "$ACTIVE_SHA256" != "$EXPECTED_PRE_CUTOVER_SHA256" &&
|
||||
"$ACTIVE_SHA256" != "$CANDIDATE_SHA256" ]]; then
|
||||
fail "active config has an unexpected digest: ${ACTIVE_SHA256}"
|
||||
fi
|
||||
|
||||
pre_cutover_checks
|
||||
"$NGINX_BIN" -t
|
||||
|
||||
post_cutover_checks() {
|
||||
local host
|
||||
local redirect_result
|
||||
|
||||
wait_for_proxy_state || fail "Gitea and Keycloak HTTPS proxies did not converge"
|
||||
|
||||
for host in "$GITEA_HOST" "$KEYCLOAK_HOST"; do
|
||||
redirect_result="$(
|
||||
"$CURL_BIN" --disable --noproxy '*' --silent --show-error --max-time 10 \
|
||||
--resolve "${host}:80:127.0.0.1" \
|
||||
--output /dev/null \
|
||||
--write-out $'%{http_code}\n%{redirect_url}' \
|
||||
"http://${host}/"
|
||||
)"
|
||||
[[ "$redirect_result" == $'301\nhttps://'"${host}/" ]] || \
|
||||
fail "${host} HTTP redirect check failed: ${redirect_result}"
|
||||
done
|
||||
|
||||
"$CURL_BIN" --disable --noproxy '*' --fail --silent --show-error --max-time 10 \
|
||||
--resolve "${GITEA_HOST}:443:127.0.0.1" \
|
||||
--dump-header "$LOGIN_HEADERS" --output /dev/null \
|
||||
"https://${GITEA_HOST}/user/login"
|
||||
"$AWK_BIN" '
|
||||
BEGIN { found = 0; insecure = 0 }
|
||||
tolower($0) ~ /^set-cookie:/ {
|
||||
found++
|
||||
if (tolower($0) !~ /; secure([;[:space:]]|$)/) insecure = 1
|
||||
}
|
||||
END { exit(found == 0 || insecure) }
|
||||
' "$LOGIN_HEADERS" || fail "Gitea login cookie Secure check failed"
|
||||
|
||||
if "$CURL_BIN" --disable --noproxy '*' --insecure --silent --output /dev/null --max-time 5 \
|
||||
--resolve unconfigured.invalid:443:127.0.0.1 \
|
||||
https://unconfigured.invalid/ 2>/dev/null; then
|
||||
fail "unknown TLS hostname was not rejected"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ "$ACTIVE_SHA256" == "$CANDIDATE_SHA256" ]]; then
|
||||
post_cutover_checks
|
||||
printf 'The reviewed Keycloak Host Nginx configuration is already active and healthy.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
backup="${ACTIVE}.before-keycloak-$("$DATE_BIN" +%Y%m%d%H%M%S)"
|
||||
[[ ! -e "$backup" && ! -L "$backup" ]] || fail "backup path already exists: ${backup}"
|
||||
|
||||
printf '\nActive SHA-256: %s\n' "$ACTIVE_SHA256"
|
||||
printf 'Candidate SHA-256: %s\n' "$CANDIDATE_SHA256"
|
||||
printf 'Planned backup: %s\n' "$backup"
|
||||
printf 'Type APPLY to expose Keycloak through Host Nginx: '
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY" ]] || fail "cancelled"
|
||||
|
||||
"$INSTALL_BIN" -o root -g root -m 0644 "$ACTIVE" "$backup"
|
||||
[[ "$("$SHA256SUM_BIN" "$backup" | "$AWK_BIN" '{print $1}')" == \
|
||||
"$ACTIVE_SHA256" ]] || fail "backup digest mismatch"
|
||||
|
||||
rollback_armed=1
|
||||
"$INSTALL_BIN" -o root -g root -m 0644 "$CANDIDATE_SNAPSHOT" "$ACTIVE"
|
||||
[[ "$("$SHA256SUM_BIN" "$ACTIVE" | "$AWK_BIN" '{print $1}')" == \
|
||||
"$CANDIDATE_SHA256" ]] || fail "installed digest mismatch"
|
||||
"$NGINX_BIN" -t
|
||||
"$SYSTEMCTL_BIN" reload nginx
|
||||
"$SYSTEMCTL_BIN" is-active --quiet nginx
|
||||
post_cutover_checks
|
||||
|
||||
rollback_armed=0
|
||||
printf '\nKEYCLOAK CUTOVER SUCCESS\n'
|
||||
printf 'Backup: %s\n' "$backup"
|
||||
printf 'Candidate SHA-256: %s\n' "$CANDIDATE_SHA256"
|
||||
printf 'Gitea health, Keycloak discovery issuer, HTTPS cookies, redirects, and unknown-host rejection: PASS\n'
|
||||
+1132
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Executable
+407
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# 호출자가 bash -x로 실행해도 향후 Secret 입력이 추적 출력에 노출되지 않도록 한다.
|
||||
set +x
|
||||
|
||||
readonly EXPECTED_KUSTOMIZE_VERSION="v5.8.1"
|
||||
readonly TARGET_NODE="donghyeon-system-product-name"
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly -a VERIFIED_MANIFEST_NAMES=(
|
||||
keycloak-namespace
|
||||
platform-postgres-keycloak
|
||||
keycloak-operator
|
||||
keycloak
|
||||
)
|
||||
readonly -a KEYCLOAK_CRDS=(
|
||||
customresourcedefinition/keycloaks.k8s.keycloak.org
|
||||
customresourcedefinition/keycloakrealmimports.k8s.keycloak.org
|
||||
customresourcedefinition/keycloakoidcclients.k8s.keycloak.org
|
||||
customresourcedefinition/keycloaksamlclients.k8s.keycloak.org
|
||||
)
|
||||
|
||||
mutation_started=false
|
||||
current_step="preflight"
|
||||
|
||||
report_retained_state() {
|
||||
if [[ "$mutation_started" == true ]]; then
|
||||
printf '%s\n' \
|
||||
"SAFE STOP during ${current_step}." \
|
||||
'No Namespace, Secret, DatabaseRole, Database, Operator, or Keycloak resource was deleted.' \
|
||||
'Database reclaim policies remain Retain. Diagnose the failed wait or apply, then rerun this script.' >&2
|
||||
fi
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
report_retained_state
|
||||
exit 1
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local status="$1"
|
||||
local line="$2"
|
||||
|
||||
trap - ERR
|
||||
set +e
|
||||
printf 'ERROR: command failed at line %s (exit %s).\n' "$line" "$status" >&2
|
||||
report_retained_state
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
on_signal() {
|
||||
local status="$1"
|
||||
|
||||
trap - INT TERM
|
||||
set +e
|
||||
printf 'INTERRUPTED: stopping without deleting cluster state.\n' >&2
|
||||
report_retained_state
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash scripts/bootstrap/apply-keycloak.sh --execute
|
||||
|
||||
Renders, verifies, and applies only the Keycloak path in this order:
|
||||
Keycloak Namespace
|
||||
two Keycloak DB Secrets
|
||||
CloudNativePG DatabaseRole, Database, and NetworkPolicy
|
||||
Keycloak Operator
|
||||
Keycloak custom resource and HTTP Ingress
|
||||
|
||||
AIStor namespaces, Secrets, storage, Operator, and ObjectStore are not required
|
||||
or applied. Host Nginx and Gitea OIDC configuration are separate cutovers.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
for command_name in kubectl curl find mktemp rg sed sha256sum stat wc; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
|
||||
kustomize_version="$(
|
||||
kubectl version --client --output=yaml |
|
||||
sed -n 's/^kustomizeVersion: //p'
|
||||
)"
|
||||
[[ "$kustomize_version" == "$EXPECTED_KUSTOMIZE_VERSION" ]] || \
|
||||
fail "expected Kustomize ${EXPECTED_KUSTOMIZE_VERSION}, found ${kustomize_version:-unknown}"
|
||||
|
||||
umask 077
|
||||
render_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-keycloak-apply.XXXXXX")"
|
||||
|
||||
cleanup() {
|
||||
case "$render_temp_dir" in
|
||||
/tmp/platform-keycloak-apply.*|"${TMPDIR:-/tmp}"/platform-keycloak-apply.*)
|
||||
rm -rf -- "$render_temp_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected render directory: %s\n' \
|
||||
"$render_temp_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'on_error "$?" "$LINENO"' ERR
|
||||
trap 'on_signal 130' INT
|
||||
trap 'on_signal 143' TERM
|
||||
|
||||
assert_regex_count() {
|
||||
local file="$1"
|
||||
local pattern="$2"
|
||||
local expected="$3"
|
||||
local description="$4"
|
||||
local actual
|
||||
|
||||
actual="$(rg --count --no-filename -- "$pattern" "$file" || true)"
|
||||
actual="${actual:-0}"
|
||||
[[ "$actual" == "$expected" ]] || \
|
||||
fail "${description}: expected ${expected}, found ${actual}"
|
||||
}
|
||||
|
||||
render_kustomization() {
|
||||
local name="$1"
|
||||
local relative_path="$2"
|
||||
local output="${render_temp_dir}/${name}.yaml"
|
||||
|
||||
kubectl kustomize "${REPOSITORY_ROOT}/${relative_path}" >"$output"
|
||||
[[ -s "$output" ]] || fail "${name} rendered an empty manifest"
|
||||
printf 'Rendered %-30s %8s bytes\n' \
|
||||
"$name" "$(wc -c <"$output" | tr -d '[:space:]')"
|
||||
}
|
||||
|
||||
cd -- "$REPOSITORY_ROOT"
|
||||
|
||||
kubectl create \
|
||||
--dry-run=client \
|
||||
--filename=infrastructure/namespaces/phase2/keycloak.yaml \
|
||||
--output=yaml >"${render_temp_dir}/keycloak-namespace.yaml"
|
||||
render_kustomization \
|
||||
platform-postgres-keycloak services/platform-postgres-keycloak
|
||||
render_kustomization \
|
||||
keycloak-operator infrastructure/controllers/keycloak-operator
|
||||
render_kustomization keycloak services/keycloak
|
||||
|
||||
if rg --line-number \
|
||||
'^[[:space:]]*kind:[[:space:]]*Secret[[:space:]]*$' \
|
||||
"${render_temp_dir}"/*.yaml; then
|
||||
fail "a rendered Keycloak manifest unexpectedly contains a Secret"
|
||||
fi
|
||||
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak-namespace.yaml" \
|
||||
'^kind:[[:space:]]Namespace$' 1 \
|
||||
"Keycloak namespace resource count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak-namespace.yaml" \
|
||||
'^[[:space:]]*name:[[:space:]]keycloak$' 1 \
|
||||
"Keycloak namespace name"
|
||||
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/platform-postgres-keycloak.yaml" \
|
||||
'^kind:[[:space:]]DatabaseRole$' 1 \
|
||||
"Keycloak DatabaseRole count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/platform-postgres-keycloak.yaml" \
|
||||
'^kind:[[:space:]]Database$' 1 \
|
||||
"Keycloak Database count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/platform-postgres-keycloak.yaml" \
|
||||
'^kind:[[:space:]]NetworkPolicy$' 1 \
|
||||
"Keycloak PostgreSQL NetworkPolicy count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/platform-postgres-keycloak.yaml" \
|
||||
'^[[:space:]]*namespace:[[:space:]]platform-data$' 3 \
|
||||
"Keycloak PostgreSQL resource namespace count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/platform-postgres-keycloak.yaml" \
|
||||
'^[[:space:]]*(databaseRoleReclaimPolicy|databaseReclaimPolicy):[[:space:]]retain$' 2 \
|
||||
"Keycloak database Retain policy count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/platform-postgres-keycloak.yaml" \
|
||||
'^[[:space:]]*name:[[:space:]]keycloak-db-credentials$' 1 \
|
||||
"Keycloak DatabaseRole Secret reference"
|
||||
|
||||
assert_regex_count \
|
||||
"${REPOSITORY_ROOT}/infrastructure/controllers/keycloak-operator/kustomization.yaml" \
|
||||
'github\.com/keycloak/keycloak-k8s-resources/kubernetes\?ref=26\.7\.0' 1 \
|
||||
"Keycloak Operator 26.7.0 source pin"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak-operator.yaml" \
|
||||
'^kind:[[:space:]]CustomResourceDefinition$' 4 \
|
||||
"Keycloak Operator CRD count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak-operator.yaml" \
|
||||
'^kind:[[:space:]]Deployment$' 1 \
|
||||
"Keycloak Operator Deployment count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak-operator.yaml" \
|
||||
'^[[:space:]]*image:[[:space:]]quay\.io/keycloak/keycloak-operator:26\.7\.0$' 1 \
|
||||
"Keycloak Operator image"
|
||||
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^kind:[[:space:]]Keycloak$' 1 \
|
||||
"Keycloak custom resource count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^kind:[[:space:]]Ingress$' 1 \
|
||||
"Keycloak Ingress count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^[[:space:]]*namespace:[[:space:]]keycloak$' 2 \
|
||||
"Keycloak service resource namespace count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^[[:space:]]*instances:[[:space:]]1$' 1 \
|
||||
"Keycloak instance count"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^[[:space:]]*hostname:[[:space:]]https://id\.learn\.hyeonworks\.com$' 1 \
|
||||
"Keycloak external hostname"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^[[:space:]]*headers:[[:space:]]xforwarded$' 1 \
|
||||
"Keycloak forwarded header mode"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^[[:space:]]*ingressClassName:[[:space:]]traefik$' 1 \
|
||||
"Keycloak Ingress class"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^[[:space:]]*name:[[:space:]]keycloak-service$' 1 \
|
||||
"Keycloak Ingress backend"
|
||||
assert_regex_count \
|
||||
"${render_temp_dir}/keycloak.yaml" \
|
||||
'^[[:space:]]*number:[[:space:]]8080$' 1 \
|
||||
"Keycloak Ingress backend port"
|
||||
|
||||
if rg --quiet \
|
||||
'^[[:space:]]*namespace:[[:space:]](aistor|object-storage)[[:space:]]*$' \
|
||||
"${render_temp_dir}"/*.yaml; then
|
||||
fail "the Keycloak-only render contains an AIStor namespace"
|
||||
fi
|
||||
if rg --quiet \
|
||||
'^[[:space:]]*(serviceType|type):[[:space:]]*(NodePort|LoadBalancer)[[:space:]]*$' \
|
||||
"${render_temp_dir}/keycloak.yaml"; then
|
||||
fail "Keycloak must not render NodePort or LoadBalancer exposure"
|
||||
fi
|
||||
|
||||
declare -A verified_manifest_sha256=()
|
||||
for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do
|
||||
manifest_path="${render_temp_dir}/${manifest_name}.yaml"
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified manifest is missing or unsafe: ${manifest_path}"
|
||||
[[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \
|
||||
fail "verified manifest must have mode 0600: ${manifest_path}"
|
||||
checksum_output="$(sha256sum -- "$manifest_path")"
|
||||
verified_manifest_sha256["$manifest_name"]="${checksum_output%% *}"
|
||||
done
|
||||
|
||||
verified_entry_count="$(
|
||||
find "$render_temp_dir" -mindepth 1 -maxdepth 1 | wc -l | tr -d '[:space:]'
|
||||
)"
|
||||
[[ "$verified_entry_count" == "${#VERIFIED_MANIFEST_NAMES[@]}" ]] || \
|
||||
fail "verified handoff must contain exactly four manifest files"
|
||||
|
||||
verify_manifest_unchanged() {
|
||||
local manifest_name="$1"
|
||||
local manifest_path="${render_temp_dir}/${manifest_name}.yaml"
|
||||
local checksum_output
|
||||
local actual_sha256
|
||||
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified manifest became missing or unsafe: ${manifest_path}"
|
||||
checksum_output="$(sha256sum -- "$manifest_path")"
|
||||
actual_sha256="${checksum_output%% *}"
|
||||
[[ "$actual_sha256" == "${verified_manifest_sha256[$manifest_name]}" ]] || \
|
||||
fail "verified manifest changed before apply: ${manifest_name}.yaml"
|
||||
}
|
||||
|
||||
current_context="$(kubectl config current-context)"
|
||||
api_server="$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')"
|
||||
kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \
|
||||
fail "target node is missing from the selected cluster: ${TARGET_NODE}"
|
||||
kubectl get namespace platform-data >/dev/null 2>&1 || \
|
||||
fail "Phase 1 namespace platform-data is missing"
|
||||
kubectl get customresourcedefinition \
|
||||
clusters.postgresql.cnpg.io \
|
||||
databaseroles.postgresql.cnpg.io \
|
||||
databases.postgresql.cnpg.io >/dev/null
|
||||
postgres_ready="$(
|
||||
kubectl --namespace platform-data get cluster platform-postgres \
|
||||
--output='go-template={{range .status.conditions}}{{if and (eq .type "Ready") (eq .status "True")}}true{{end}}{{end}}'
|
||||
)"
|
||||
[[ "$postgres_ready" == "true" ]] || \
|
||||
fail "platform-data/platform-postgres is not Ready"
|
||||
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$current_context" "$api_server" "$TARGET_NODE"
|
||||
printf '%s\n' \
|
||||
'Scope: Keycloak namespace, two DB Secrets, DB Role/Database/NetworkPolicy, Operator, Keycloak, and Ingress.' \
|
||||
'Excluded: AIStor resources, Host Nginx, Gitea OIDC source, and all data deletion.' \
|
||||
'Rollback boundary: applied state is retained on failure; rerunning is the recovery path.'
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required"
|
||||
printf 'Type APPLY to start the Keycloak cluster mutation: '
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY" ]] || fail "cancelled"
|
||||
|
||||
assert_cluster_identity() {
|
||||
[[ "$(kubectl config current-context)" == "$current_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')" == "$api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \
|
||||
fail "target node disappeared after confirmation: ${TARGET_NODE}"
|
||||
}
|
||||
|
||||
assert_cluster_identity
|
||||
for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do
|
||||
verify_manifest_unchanged "$manifest_name"
|
||||
done
|
||||
|
||||
mutation_started=true
|
||||
|
||||
current_step="[1/6] Keycloak namespace"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged keycloak-namespace
|
||||
kubectl apply --dry-run=server \
|
||||
--filename="${render_temp_dir}/keycloak-namespace.yaml" >/dev/null
|
||||
kubectl apply --filename="${render_temp_dir}/keycloak-namespace.yaml"
|
||||
|
||||
current_step="[2/6] Keycloak database Secret contracts"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
bash scripts/bootstrap/create-keycloak-secrets.sh --execute
|
||||
|
||||
current_step="[3/6] Keycloak DatabaseRole, Database, and NetworkPolicy"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged platform-postgres-keycloak
|
||||
kubectl apply --server-side --dry-run=server \
|
||||
--filename="${render_temp_dir}/platform-postgres-keycloak.yaml" >/dev/null
|
||||
kubectl apply --server-side \
|
||||
--filename="${render_temp_dir}/platform-postgres-keycloak.yaml"
|
||||
kubectl --namespace platform-data wait \
|
||||
--for=jsonpath='{.status.applied}'=true \
|
||||
databaserole/platform-postgres-keycloak \
|
||||
database/platform-postgres-keycloak \
|
||||
--timeout=3m
|
||||
|
||||
current_step="[4/6] Keycloak Operator and CRDs"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged keycloak-operator
|
||||
kubectl apply --server-side --dry-run=server \
|
||||
--filename="${render_temp_dir}/keycloak-operator.yaml" >/dev/null
|
||||
kubectl apply --server-side \
|
||||
--filename="${render_temp_dir}/keycloak-operator.yaml"
|
||||
kubectl wait --for=condition=Established \
|
||||
"${KEYCLOAK_CRDS[@]}" \
|
||||
--timeout=3m
|
||||
kubectl --namespace keycloak rollout status \
|
||||
deployment/keycloak-operator --timeout=5m
|
||||
|
||||
current_step="[5/6] Keycloak instance and HTTP Ingress"
|
||||
printf '\n%s\n' "$current_step"
|
||||
assert_cluster_identity
|
||||
verify_manifest_unchanged keycloak
|
||||
kubectl apply --server-side --dry-run=server \
|
||||
--filename="${render_temp_dir}/keycloak.yaml" >/dev/null
|
||||
kubectl apply --server-side \
|
||||
--filename="${render_temp_dir}/keycloak.yaml"
|
||||
kubectl --namespace keycloak wait \
|
||||
--for=condition=Ready keycloak/keycloak --timeout=15m
|
||||
kubectl --namespace keycloak wait \
|
||||
--for=jsonpath='{.endpoints[0].conditions.ready}'=true \
|
||||
endpointslice \
|
||||
--selector=kubernetes.io/service-name=keycloak-service \
|
||||
--timeout=2m
|
||||
|
||||
current_step="[6/6] OIDC discovery through Traefik HTTP NodePort"
|
||||
printf '\n%s\n' "$current_step"
|
||||
discovery_file="${render_temp_dir}/keycloak-discovery.json"
|
||||
curl --fail --silent --show-error \
|
||||
--retry 24 \
|
||||
--retry-all-errors \
|
||||
--retry-connrefused \
|
||||
--retry-delay 5 \
|
||||
--retry-max-time 120 \
|
||||
--max-time 10 \
|
||||
--header 'Host: id.learn.hyeonworks.com' \
|
||||
--output "$discovery_file" \
|
||||
http://127.0.0.1:30080/realms/master/.well-known/openid-configuration
|
||||
rg --quiet \
|
||||
'"issuer"[[:space:]]*:[[:space:]]*"https://id\.learn\.hyeonworks\.com/realms/master"' \
|
||||
"$discovery_file" || \
|
||||
fail "OIDC discovery issuer does not match the public Keycloak hostname"
|
||||
|
||||
mutation_started=false
|
||||
printf '\nKeycloak cluster resources and internal OIDC discovery are ready.\n'
|
||||
printf 'No AIStor resource, Host Nginx configuration, or Gitea OIDC source was changed.\n'
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# Do not allow a caller's `bash -x` setting to expose prompts or future secrets.
|
||||
set +x
|
||||
|
||||
readonly EXPECTED_HELM_VERSION="v3.19.4"
|
||||
readonly TARGET_NODE="donghyeon-system-product-name"
|
||||
readonly REPOSITORY_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P)"
|
||||
readonly -a VERIFIED_MANIFEST_NAMES=(
|
||||
namespaces
|
||||
ssd-local-pv
|
||||
cnpg-operator
|
||||
platform-postgres
|
||||
gitea
|
||||
gitea-oidc
|
||||
)
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_create_only_state() {
|
||||
if kubectl --namespace gitea get deployment/gitea >/dev/null 2>&1; then
|
||||
fail "Phase 1 is create-only and gitea/gitea already exists; use a dedicated Gitea lifecycle script"
|
||||
fi
|
||||
if kubectl --namespace gitea get secret/gitea-keycloak-oidc >/dev/null 2>&1; then
|
||||
fail "Phase 1 baseline is blocked because gitea/gitea-keycloak-oidc already exists"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash scripts/bootstrap/apply-phase1-gitea.sh --execute
|
||||
|
||||
Applies the create-only Gitea baseline in dependency order. It does not modify
|
||||
Host Nginx, configure Keycloak OIDC, or install Argo CD. If a Gitea Deployment
|
||||
or OIDC Secret already exists, use the dedicated lifecycle scripts instead.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
for command_name in kubectl curl rg sha256sum stat; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || fail "${command_name} is required"
|
||||
done
|
||||
|
||||
if [[ -n "${PLATFORM_HELM_BIN:-}" ]]; then
|
||||
[[ "$PLATFORM_HELM_BIN" == /* ]] || fail "PLATFORM_HELM_BIN must be an absolute path"
|
||||
[[ -f "$PLATFORM_HELM_BIN" && -x "$PLATFORM_HELM_BIN" ]] || \
|
||||
fail "PLATFORM_HELM_BIN is not an executable file: ${PLATFORM_HELM_BIN}"
|
||||
readonly HELM_BIN="$PLATFORM_HELM_BIN"
|
||||
else
|
||||
HELM_BIN="$(command -v helm 2>/dev/null)" || \
|
||||
fail "Helm ${EXPECTED_HELM_VERSION} is required"
|
||||
readonly HELM_BIN
|
||||
fi
|
||||
|
||||
[[ "$("$HELM_BIN" version --template '{{.Version}}')" == "$EXPECTED_HELM_VERSION" ]] || \
|
||||
fail "Helm must be exactly ${EXPECTED_HELM_VERSION}"
|
||||
|
||||
render_temp_dir="$(mktemp -d /tmp/platform-phase1-apply.XXXXXX)"
|
||||
cleanup() {
|
||||
case "$render_temp_dir" in
|
||||
/tmp/platform-phase1-apply.*)
|
||||
rm -rf -- "$render_temp_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected render directory: %s\n' \
|
||||
"$render_temp_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
cd -- "$REPOSITORY_ROOT"
|
||||
PLATFORM_HELM_BIN="$HELM_BIN" bash scripts/validate/render-phase1.sh \
|
||||
--verified-output-dir "$render_temp_dir"
|
||||
|
||||
declare -A verified_manifest_sha256=()
|
||||
for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do
|
||||
manifest_path="${render_temp_dir}/${manifest_name}.yaml"
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified manifest is missing or unsafe: ${manifest_path}"
|
||||
[[ "$(stat --format='%a' -- "$manifest_path")" == "600" ]] || \
|
||||
fail "verified manifest must have mode 0600: ${manifest_path}"
|
||||
checksum_output="$(sha256sum -- "$manifest_path")"
|
||||
verified_manifest_sha256["$manifest_name"]="${checksum_output%% *}"
|
||||
done
|
||||
verified_entry_count="$(find "$render_temp_dir" -mindepth 1 -maxdepth 1 | wc -l | tr -d '[:space:]')"
|
||||
[[ "$verified_entry_count" == "${#VERIFIED_MANIFEST_NAMES[@]}" ]] || \
|
||||
fail "verified handoff must contain exactly ${#VERIFIED_MANIFEST_NAMES[@]} manifest files"
|
||||
|
||||
verify_manifest_unchanged() {
|
||||
local manifest_name="$1"
|
||||
local manifest_path="${render_temp_dir}/${manifest_name}.yaml"
|
||||
local checksum_output
|
||||
local actual_sha256
|
||||
|
||||
[[ -f "$manifest_path" && ! -L "$manifest_path" && -O "$manifest_path" && -s "$manifest_path" ]] || \
|
||||
fail "verified manifest became missing or unsafe: ${manifest_path}"
|
||||
checksum_output="$(sha256sum -- "$manifest_path")"
|
||||
actual_sha256="${checksum_output%% *}"
|
||||
[[ "$actual_sha256" == "${verified_manifest_sha256[$manifest_name]}" ]] || \
|
||||
fail "verified manifest changed before apply: ${manifest_name}.yaml"
|
||||
}
|
||||
|
||||
current_context="$(kubectl config current-context)"
|
||||
api_server="$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')"
|
||||
kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \
|
||||
fail "target node is missing from the selected cluster: ${TARGET_NODE}"
|
||||
check_create_only_state
|
||||
|
||||
printf '\nKubernetes context: %s\nAPI server: %s\nTarget node: %s\n' \
|
||||
"$current_context" "$api_server" "$TARGET_NODE"
|
||||
printf 'Type APPLY %s to start the cluster mutation: ' "$current_context"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY ${current_context}" ]] || fail "cancelled"
|
||||
|
||||
[[ "$(kubectl config current-context)" == "$current_context" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
confirmed_api_server="$(kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}')"
|
||||
[[ "$confirmed_api_server" == "$api_server" ]] || \
|
||||
fail "Kubernetes API server changed after confirmation"
|
||||
kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \
|
||||
fail "target node disappeared after confirmation: ${TARGET_NODE}"
|
||||
check_create_only_state
|
||||
|
||||
for manifest_name in "${VERIFIED_MANIFEST_NAMES[@]}"; do
|
||||
verify_manifest_unchanged "$manifest_name"
|
||||
done
|
||||
|
||||
printf '\n[1/8] Preparing exact SSD Local PV directories\n'
|
||||
bash scripts/bootstrap/prepare-ssd-local-paths.sh
|
||||
|
||||
printf '\n[2/8] Applying Phase 1 namespaces\n'
|
||||
verify_manifest_unchanged namespaces
|
||||
kubectl apply --filename="${render_temp_dir}/namespaces.yaml"
|
||||
|
||||
printf '\n[3/8] Applying static SSD StorageClasses and Local PVs\n'
|
||||
verify_manifest_unchanged ssd-local-pv
|
||||
kubectl apply --filename="${render_temp_dir}/ssd-local-pv.yaml"
|
||||
|
||||
printf '\n[4/8] Installing CloudNativePG CRDs and operator\n'
|
||||
verify_manifest_unchanged cnpg-operator
|
||||
kubectl apply --server-side --filename="${render_temp_dir}/cnpg-operator.yaml"
|
||||
kubectl wait --for=condition=Established \
|
||||
customresourcedefinition/clusters.postgresql.cnpg.io \
|
||||
customresourcedefinition/databaseroles.postgresql.cnpg.io \
|
||||
customresourcedefinition/databases.postgresql.cnpg.io \
|
||||
--timeout=3m
|
||||
kubectl --namespace cnpg-system wait --for=condition=Available deployment \
|
||||
--selector=app.kubernetes.io/name=cloudnative-pg --timeout=5m
|
||||
|
||||
printf '\n[5/8] Ensuring or reusing the three Secret contracts\n'
|
||||
bash scripts/bootstrap/create-phase1-secrets.sh --execute
|
||||
|
||||
printf '\n[6/8] Applying the shared platform PostgreSQL resources\n'
|
||||
verify_manifest_unchanged platform-postgres
|
||||
kubectl apply --server-side --filename="${render_temp_dir}/platform-postgres.yaml"
|
||||
kubectl --namespace platform-data wait --for=condition=Ready \
|
||||
cluster/platform-postgres --timeout=10m
|
||||
kubectl --namespace platform-data wait \
|
||||
--for=jsonpath='{.status.applied}'=true \
|
||||
database/platform-postgres-gitea --timeout=3m
|
||||
|
||||
printf '\n[7/8] Applying the Gitea baseline, PVC, policies, and HTTP Ingress\n'
|
||||
verify_manifest_unchanged gitea
|
||||
kubectl apply --filename="${render_temp_dir}/gitea.yaml"
|
||||
kubectl --namespace gitea rollout status deployment/gitea --timeout=10m
|
||||
kubectl --namespace gitea wait \
|
||||
--for=jsonpath='{.endpoints[0].conditions.ready}'=true \
|
||||
endpointslice \
|
||||
--selector=kubernetes.io/service-name=gitea-http \
|
||||
--timeout=2m
|
||||
|
||||
printf '\n[8/8] Checking Host-based routing through Traefik HTTP NodePort\n'
|
||||
curl --fail-with-body --show-error \
|
||||
--retry 24 \
|
||||
--retry-all-errors \
|
||||
--retry-connrefused \
|
||||
--retry-delay 5 \
|
||||
--retry-max-time 120 \
|
||||
--max-time 10 \
|
||||
--header 'Host: git.learn.hyeonworks.com' \
|
||||
http://127.0.0.1:30080/api/healthz
|
||||
|
||||
printf '\nPhase 1 cluster resources are ready. Host Nginx was not changed.\n'
|
||||
printf 'Review infrastructure/networking/host-nginx/README.md for the final cutover.\n'
|
||||
Executable
+287
@@ -0,0 +1,287 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
||||
readonly HOST_SOURCE="$ROOT/infrastructure/networking/private-dns/host"
|
||||
readonly K8S_SOURCE="$ROOT/infrastructure/networking/private-dns/kubernetes"
|
||||
readonly LAN_IP="192.168.0.107"
|
||||
readonly TAIL_IP="100.92.240.34"
|
||||
readonly TARGET_NODE="donghyeon-system-product-name"
|
||||
readonly BUSYBOX_IMAGE="docker.io/library/busybox:1.37.0@sha256:7a3ebe5bfd1a4a19797d20b0c0bb39d44393e9a03fd852c0865b0f540d868df0"
|
||||
readonly -a PRIVATE_HOSTS=(git.learn.hyeonworks.com id.learn.hyeonworks.com storage-admin.learn.hyeonworks.com db-admin.learn.hyeonworks.com grafana.learn.hyeonworks.com)
|
||||
readonly -a PUBLIC_PRIVATE_HOSTS=(storage-admin.learn.hyeonworks.com db-admin.learn.hyeonworks.com grafana.learn.hyeonworks.com)
|
||||
execute=false
|
||||
mutation=false
|
||||
rollback_armed=false
|
||||
temp=""
|
||||
lan_was_active=false
|
||||
tail_was_active=false
|
||||
lan_was_enabled=false
|
||||
tail_was_enabled=false
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
사용법:
|
||||
bash scripts/bootstrap/apply-private-dns.sh
|
||||
bash scripts/bootstrap/apply-private-dns.sh --execute
|
||||
|
||||
인자 없이 실행하면 설정, 주소, 공개 DNS, manifest hash만 검사합니다.
|
||||
--execute는 두 dnsmasq 인스턴스와 coredns-custom을 적용합니다.
|
||||
공유기 DHCP DNS와 Tailscale 관리 화면은 변경하지 않습니다.
|
||||
USAGE
|
||||
}
|
||||
|
||||
if (( $# == 0 )); then
|
||||
:
|
||||
elif (( $# == 1 )) && [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
elif (( $# == 1 )) && [[ "$1" == "--execute" ]]; then
|
||||
execute=true
|
||||
else
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
for cmd in awk dig find ip install journalctl kubectl rg sha256sum sleep ss stat systemctl systemd-analyze; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
|
||||
done
|
||||
[[ -x /usr/sbin/dnsmasq ]] || fail "/usr/sbin/dnsmasq가 없습니다"
|
||||
[[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요"
|
||||
|
||||
for file in dnsmasq-lan.conf dnsmasq-tailscale.conf \
|
||||
hyeonworks-dnsmasq-lan.service hyeonworks-dnsmasq-tailscale.service; do
|
||||
[[ -f "$HOST_SOURCE/$file" && ! -L "$HOST_SOURCE/$file" ]] || fail "후보 파일이 없습니다: $file"
|
||||
done
|
||||
|
||||
/usr/sbin/dnsmasq --test --conf-file="$HOST_SOURCE/dnsmasq-lan.conf"
|
||||
/usr/sbin/dnsmasq --test --conf-file="$HOST_SOURCE/dnsmasq-tailscale.conf"
|
||||
systemd-analyze verify "$HOST_SOURCE/hyeonworks-dnsmasq-lan.service" \
|
||||
"$HOST_SOURCE/hyeonworks-dnsmasq-tailscale.service"
|
||||
kubectl kustomize "$K8S_SOURCE" >"/tmp/private-dns-render.$$"
|
||||
trap 'rm -f -- "/tmp/private-dns-render.$$"' EXIT
|
||||
manifest_sha="$(sha256sum "/tmp/private-dns-render.$$" | awk '{print $1}')"
|
||||
|
||||
ip -4 address show | rg -q -F "$LAN_IP/" || fail "LAN 주소 $LAN_IP가 호스트에 없습니다"
|
||||
ip -4 address show | rg -q -F "$TAIL_IP/" || fail "Tailscale 주소 $TAIL_IP가 호스트에 없습니다"
|
||||
[[ "$(kubectl get node "$TARGET_NODE" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}')" == True ]] || \
|
||||
fail "대상 노드가 Ready가 아닙니다"
|
||||
|
||||
for name in "${PUBLIC_PRIVATE_HOSTS[@]}"; do
|
||||
public_a="$(dig +short @1.1.1.1 A "$name" | tr -d '[:space:]')"
|
||||
public_aaaa="$(dig +short @1.1.1.1 AAAA "$name" | tr -d '[:space:]')"
|
||||
[[ -z "$public_a" && -z "$public_aaaa" ]] || fail "$name 공개 A/AAAA가 존재합니다"
|
||||
done
|
||||
|
||||
printf 'Current context: %s\n' "$(kubectl config current-context)"
|
||||
printf 'LAN listener: %s:53\n' "$LAN_IP"
|
||||
printf 'Tail listener: %s:53\n' "$TAIL_IP"
|
||||
printf 'CoreDNS SHA-256: %s\n' "$manifest_sha"
|
||||
printf '공개 private-service A/AAAA: 없음\n'
|
||||
|
||||
if [[ "$execute" == false ]]; then
|
||||
printf 'DRY RUN PASS: --execute를 지정하지 않아 변경하지 않았습니다.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다"
|
||||
context="$(kubectl config current-context)"
|
||||
api="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')"
|
||||
printf 'Type APPLY %s to install private DNS: ' "$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가 바뀌었습니다"
|
||||
|
||||
sudo -v
|
||||
|
||||
managed_listeners_are_exact() {
|
||||
local lan_pid tail_pid line protocol local_address
|
||||
local lan_tcp=0 lan_udp=0 tail_tcp=0 tail_udp=0
|
||||
local -a listener_lines=()
|
||||
|
||||
sudo systemctl is-active --quiet hyeonworks-dnsmasq-lan.service || return 1
|
||||
sudo systemctl is-active --quiet hyeonworks-dnsmasq-tailscale.service || return 1
|
||||
lan_pid="$(sudo systemctl show hyeonworks-dnsmasq-lan.service --property MainPID --value)" || return 1
|
||||
tail_pid="$(sudo systemctl show hyeonworks-dnsmasq-tailscale.service --property MainPID --value)" || return 1
|
||||
[[ "$lan_pid" =~ ^[1-9][0-9]*$ && "$tail_pid" =~ ^[1-9][0-9]*$ ]] || return 1
|
||||
|
||||
sudo cmp -s "$HOST_SOURCE/dnsmasq-lan.conf" /etc/dnsmasq-hyeonworks/lan.conf || return 1
|
||||
sudo cmp -s "$HOST_SOURCE/dnsmasq-tailscale.conf" /etc/dnsmasq-hyeonworks/tailscale.conf || return 1
|
||||
sudo cmp -s "$HOST_SOURCE/hyeonworks-dnsmasq-lan.service" \
|
||||
/etc/systemd/system/hyeonworks-dnsmasq-lan.service || return 1
|
||||
sudo cmp -s "$HOST_SOURCE/hyeonworks-dnsmasq-tailscale.service" \
|
||||
/etc/systemd/system/hyeonworks-dnsmasq-tailscale.service || return 1
|
||||
|
||||
mapfile -t listener_lines < <(sudo ss -H -lnupt '( sport = :53 )') || return 1
|
||||
for line in "${listener_lines[@]}"; do
|
||||
read -r protocol _ _ _ local_address _ <<<"$line"
|
||||
case "$local_address" in
|
||||
"$LAN_IP:53")
|
||||
[[ "$line" == *"pid=$lan_pid,"* ]] || return 1
|
||||
[[ "$protocol" == tcp ]] && lan_tcp=$((lan_tcp + 1))
|
||||
[[ "$protocol" == udp ]] && lan_udp=$((lan_udp + 1))
|
||||
;;
|
||||
"$TAIL_IP:53")
|
||||
[[ "$line" == *"pid=$tail_pid,"* ]] || return 1
|
||||
[[ "$protocol" == tcp ]] && tail_tcp=$((tail_tcp + 1))
|
||||
[[ "$protocol" == udp ]] && tail_udp=$((tail_udp + 1))
|
||||
;;
|
||||
esac
|
||||
done
|
||||
(( lan_tcp == 1 && lan_udp == 1 && tail_tcp == 1 && tail_udp == 1 ))
|
||||
}
|
||||
|
||||
if sudo ss -H -lntu '( sport = :53 )' | awk -v lan="$LAN_IP:53" -v tail="$TAIL_IP:53" \
|
||||
'$5 == lan || $5 == tail {found=1} END {exit found ? 0 : 1}'; then
|
||||
if managed_listeners_are_exact; then
|
||||
printf '기존 exact managed DNS listener를 안전한 재적용 대상으로 확인했습니다.\n'
|
||||
else
|
||||
fail "대상 LAN/Tailscale 주소의 53번 포트를 exact managed listener가 아닌 프로세스가 사용 중입니다"
|
||||
fi
|
||||
fi
|
||||
|
||||
umask 077
|
||||
temp="$(mktemp -d /tmp/platform-private-dns.XXXXXX)"
|
||||
cm_existed=false
|
||||
sudo systemctl is-active --quiet hyeonworks-dnsmasq-lan.service && lan_was_active=true
|
||||
sudo systemctl is-active --quiet hyeonworks-dnsmasq-tailscale.service && tail_was_active=true
|
||||
[[ "$(sudo systemctl is-enabled hyeonworks-dnsmasq-lan.service 2>/dev/null || true)" == enabled ]] && lan_was_enabled=true
|
||||
[[ "$(sudo systemctl is-enabled hyeonworks-dnsmasq-tailscale.service 2>/dev/null || true)" == enabled ]] && tail_was_enabled=true
|
||||
[[ -e /etc/dnsmasq-hyeonworks ]] && sudo cp -a /etc/dnsmasq-hyeonworks "$temp/etc-dnsmasq"
|
||||
[[ -e /etc/systemd/system/hyeonworks-dnsmasq-lan.service ]] && \
|
||||
sudo cp -a /etc/systemd/system/hyeonworks-dnsmasq-lan.service "$temp/lan.service"
|
||||
[[ -e /etc/systemd/system/hyeonworks-dnsmasq-tailscale.service ]] && \
|
||||
sudo cp -a /etc/systemd/system/hyeonworks-dnsmasq-tailscale.service "$temp/tailscale.service"
|
||||
if kubectl -n kube-system get configmap coredns-custom -o yaml >"$temp/coredns-custom.yaml" 2>/dev/null; then
|
||||
cm_existed=true
|
||||
fi
|
||||
|
||||
restore_service_state() {
|
||||
local unit=$1 was_active=$2 was_enabled=$3
|
||||
if [[ "$was_enabled" == true ]]; then
|
||||
sudo systemctl enable "$unit" >/dev/null 2>&1
|
||||
else
|
||||
sudo systemctl disable "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ "$was_active" == true ]]; then
|
||||
sudo systemctl start "$unit" >/dev/null 2>&1
|
||||
else
|
||||
sudo systemctl stop "$unit" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
rollback() {
|
||||
set +e
|
||||
printf '\nROLLBACK: private DNS 이전 상태를 복원합니다.\n' >&2
|
||||
sudo systemctl disable --now hyeonworks-dnsmasq-lan.service \
|
||||
hyeonworks-dnsmasq-tailscale.service >/dev/null 2>&1
|
||||
if [[ -d "$temp/etc-dnsmasq" ]]; then
|
||||
sudo rm -rf -- /etc/dnsmasq-hyeonworks
|
||||
sudo cp -a "$temp/etc-dnsmasq" /etc/dnsmasq-hyeonworks
|
||||
else
|
||||
sudo rm -rf -- /etc/dnsmasq-hyeonworks
|
||||
fi
|
||||
for unit in lan tailscale; do
|
||||
target="/etc/systemd/system/hyeonworks-dnsmasq-$unit.service"
|
||||
if [[ -f "$temp/$unit.service" ]]; then
|
||||
sudo install -o root -g root -m 0644 "$temp/$unit.service" "$target"
|
||||
else
|
||||
sudo rm -f -- "$target"
|
||||
fi
|
||||
done
|
||||
sudo systemctl daemon-reload
|
||||
restore_service_state hyeonworks-dnsmasq-lan.service "$lan_was_active" "$lan_was_enabled"
|
||||
restore_service_state hyeonworks-dnsmasq-tailscale.service "$tail_was_active" "$tail_was_enabled"
|
||||
if [[ "$cm_existed" == true ]]; then
|
||||
kubectl apply -f "$temp/coredns-custom.yaml" >/dev/null
|
||||
else
|
||||
kubectl -n kube-system delete configmap coredns-custom --ignore-not-found >/dev/null
|
||||
fi
|
||||
kubectl -n kube-system rollout restart deployment/coredns >/dev/null
|
||||
kubectl -n kube-system rollout status deployment/coredns --timeout=90s >/dev/null
|
||||
rollback_armed=false
|
||||
printf 'ROLLBACK complete.\n' >&2
|
||||
}
|
||||
|
||||
finish() {
|
||||
rc=$?
|
||||
trap - EXIT INT TERM
|
||||
if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then
|
||||
rollback
|
||||
fi
|
||||
if [[ -n "$temp" ]]; then
|
||||
case "$temp" in
|
||||
/tmp/platform-private-dns.*) rm -rf -- "$temp" ;;
|
||||
esac
|
||||
fi
|
||||
rm -f -- "/tmp/private-dns-render.$$"
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
rollback_armed=true
|
||||
mutation=true
|
||||
sudo install -d -o root -g root -m 0755 /etc/dnsmasq-hyeonworks
|
||||
sudo install -o root -g root -m 0644 "$HOST_SOURCE/dnsmasq-lan.conf" /etc/dnsmasq-hyeonworks/lan.conf
|
||||
sudo install -o root -g root -m 0644 "$HOST_SOURCE/dnsmasq-tailscale.conf" /etc/dnsmasq-hyeonworks/tailscale.conf
|
||||
sudo install -o root -g root -m 0644 "$HOST_SOURCE/hyeonworks-dnsmasq-lan.service" \
|
||||
/etc/systemd/system/hyeonworks-dnsmasq-lan.service
|
||||
sudo install -o root -g root -m 0644 "$HOST_SOURCE/hyeonworks-dnsmasq-tailscale.service" \
|
||||
/etc/systemd/system/hyeonworks-dnsmasq-tailscale.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now hyeonworks-dnsmasq-lan.service hyeonworks-dnsmasq-tailscale.service
|
||||
|
||||
stable=0
|
||||
for (( attempt=1; attempt<=20; attempt++ )); do
|
||||
if sudo systemctl is-active --quiet hyeonworks-dnsmasq-lan.service \
|
||||
hyeonworks-dnsmasq-tailscale.service \
|
||||
&& [[ "$(dig +time=1 +tries=1 +short "@$LAN_IP" git.learn.hyeonworks.com A | tail -n1)" == "$LAN_IP" ]] \
|
||||
&& [[ "$(dig +time=1 +tries=1 +short "@$TAIL_IP" git.learn.hyeonworks.com A | tail -n1)" == "$TAIL_IP" ]]; then
|
||||
stable=$((stable + 1))
|
||||
if (( stable >= 3 )); then
|
||||
break
|
||||
fi
|
||||
else
|
||||
stable=0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if (( stable < 3 )); then
|
||||
for unit in hyeonworks-dnsmasq-lan.service hyeonworks-dnsmasq-tailscale.service; do
|
||||
sudo systemctl status "$unit" --no-pager -n 20 >&2 || true
|
||||
sudo journalctl -u "$unit" --no-pager -n 20 >&2 || true
|
||||
done
|
||||
fail "private DNS listener가 안정화되지 않았습니다"
|
||||
fi
|
||||
|
||||
kubectl apply -f "/tmp/private-dns-render.$$"
|
||||
kubectl -n kube-system rollout restart deployment/coredns
|
||||
kubectl -n kube-system rollout status deployment/coredns --timeout=90s
|
||||
|
||||
for resolver in "$LAN_IP" "$TAIL_IP"; do
|
||||
for name in "${PRIVATE_HOSTS[@]}"; do
|
||||
[[ "$(dig +short "@$resolver" A "$name" | tail -n1)" == "$resolver" ]] ||
|
||||
fail "$name private DNS 검증 실패"
|
||||
done
|
||||
done
|
||||
|
||||
pod="private-dns-smoke-$(date +%H%M%S)"
|
||||
kubectl -n default run "$pod" --restart=Never --image="$BUSYBOX_IMAGE" \
|
||||
--labels=platform.hyeonworks.com/transient=true \
|
||||
--command -- sh -c 'nslookup git.learn.hyeonworks.com >/dev/null && nslookup id.learn.hyeonworks.com >/dev/null && nslookup storage-admin.learn.hyeonworks.com >/dev/null && nslookup db-admin.learn.hyeonworks.com >/dev/null && nslookup grafana.learn.hyeonworks.com >/dev/null'
|
||||
kubectl -n default wait --for=jsonpath='{.status.phase}'=Succeeded "pod/$pod" --timeout=60s
|
||||
kubectl -n default delete "pod/$pod" --wait=true >/dev/null
|
||||
|
||||
rollback_armed=false
|
||||
printf 'PRIVATE DNS APPLY SUCCESS\n'
|
||||
printf '공유기 DHCP DNS와 Tailscale split DNS는 문서에 따라 별도로 등록하세요.\n'
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
ulimit -c 0
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == */* ]]; then
|
||||
_swr_wrapper_directory="${BASH_SOURCE[0]%/*}"
|
||||
else
|
||||
_swr_wrapper_directory='.'
|
||||
fi
|
||||
readonly SWR_REPOSITORY_ROOT="$(cd -- "${_swr_wrapper_directory}/../.." && pwd -P)"
|
||||
readonly SWR_RECOVERY_CONTRACT="${SWR_REPOSITORY_ROOT}/infrastructure/security/k3s/local-recovery.env"
|
||||
unset _swr_wrapper_directory
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${SWR_REPOSITORY_ROOT}/scripts/lib/slack-webhook-recovery.sh"
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
_swr_main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PATH='/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
export PATH
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
umask 077
|
||||
|
||||
_k3slr_wrapper_initial_guard() {
|
||||
local effective_uid="${1-}" shell_options="${2-}"
|
||||
[[ "$effective_uid" =~ ^[0-9]+$ && "$effective_uid" != 0 ]] || return 1
|
||||
[[ "$shell_options" != *x* ]]
|
||||
}
|
||||
|
||||
if ! _k3slr_wrapper_initial_guard "${EUID:-}" "$-"; then
|
||||
printf 'ERROR: lifecycle wrapper refuses root or xtrace execution\n' >&2
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == */* ]]; then
|
||||
K3SLR_WRAPPER_DIRECTORY="${BASH_SOURCE[0]%/*}"
|
||||
else
|
||||
K3SLR_WRAPPER_DIRECTORY='.'
|
||||
fi
|
||||
K3SLR_WRAPPER_ROOT="$(cd -- "${K3SLR_WRAPPER_DIRECTORY}/../.." && pwd -P)" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
K3SLR_WRAPPER_CONTRACT="${K3SLR_WRAPPER_ROOT}/infrastructure/security/k3s/local-recovery.env"
|
||||
# shellcheck source=/dev/null
|
||||
source "${K3SLR_WRAPPER_ROOT}/scripts/lib/k3s-local-recovery.sh" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
|
||||
_k3slr_close_usage() {
|
||||
printf 'Usage: bash scripts/bootstrap/close-k3s-local-recovery.sh [--execute]\n' >&2
|
||||
}
|
||||
|
||||
_k3slr_close_main() {
|
||||
local execution_mode
|
||||
if ! _k3slr_parse_lifecycle_cli execution_mode "$@"; then
|
||||
_k3slr_close_usage
|
||||
return 2
|
||||
fi
|
||||
_k3slr_lifecycle_main close "$execution_mode"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
_k3slr_close_main "$@"
|
||||
fi
|
||||
+400
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
|
||||
readonly KC_NS=keycloak
|
||||
readonly KC_NAME=keycloak
|
||||
readonly KC_SERVICE=keycloak-service
|
||||
readonly KC_HOST=id.learn.hyeonworks.com
|
||||
readonly REALM=hyeonworks
|
||||
readonly PORT=18081
|
||||
readonly BASE=http://127.0.0.1:$PORT
|
||||
execute=false
|
||||
object_admin=""
|
||||
db_admin=""
|
||||
temp=""
|
||||
pf=""
|
||||
rollback_armed=false
|
||||
ai_client_created=false
|
||||
pg_client_created=false
|
||||
ai_group_created=false
|
||||
pg_group_created=false
|
||||
ai_member_added=false
|
||||
pg_member_added=false
|
||||
ai_client_uuid=""
|
||||
pg_client_uuid=""
|
||||
ai_group_uuid=""
|
||||
pg_group_uuid=""
|
||||
ai_user_uuid=""
|
||||
pg_user_uuid=""
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
사용법:
|
||||
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh
|
||||
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh --execute \
|
||||
--object-admin donghyeon.kang --db-admin donghyeon.kang
|
||||
|
||||
두 confidential client, 두 관리자 그룹, claim mapper와 다음 Secret을 만듭니다.
|
||||
- object-storage/aistor-keycloak-oidc: client-id, client-secret
|
||||
- platform-admin/pgadmin-keycloak-oidc: client-id, client-secret
|
||||
USAGE
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--execute) execute=true; shift ;;
|
||||
--object-admin)
|
||||
(( $# >= 2 )) || fail "--object-admin 값이 필요합니다"
|
||||
object_admin="$2"; shift 2 ;;
|
||||
--db-admin)
|
||||
(( $# >= 2 )) || fail "--db-admin 값이 필요합니다"
|
||||
db_admin="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; fail "지원하지 않는 인자: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요"
|
||||
for cmd in base64 chmod curl jq kill kubectl mktemp rg rm seq sleep sort tr; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
|
||||
done
|
||||
context="$(kubectl config current-context)"
|
||||
api="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')"
|
||||
kubectl -n "$KC_NS" wait --for=condition=Ready "keycloak.k8s.keycloak.org/$KC_NAME" --timeout=30s >/dev/null
|
||||
kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" >/dev/null
|
||||
|
||||
printf 'Current context: %s\nRealm: %s\n' "$context" "$REALM"
|
||||
printf 'Clients: aistor-console, pgadmin\n'
|
||||
printf 'Groups: /platform-object-admins, /platform-db-admins\n'
|
||||
[[ -n "$object_admin" ]] && printf 'Object admin: %s\n' "$object_admin"
|
||||
[[ -n "$db_admin" ]] && printf 'DB admin: %s\n' "$db_admin"
|
||||
if [[ "$execute" == false ]]; then
|
||||
printf 'DRY RUN PASS: --execute를 지정하지 않아 변경하지 않았습니다.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다"
|
||||
printf 'Type APPLY %s to configure admin OIDC: ' "$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가 바뀌었습니다"
|
||||
|
||||
kubectl apply -k "$ROOT/infrastructure/namespaces/admin-tools" >/dev/null
|
||||
temp="$(mktemp -d /tmp/keycloak-admin-oidc.XXXXXX)"
|
||||
admin_user="$temp/admin-user"
|
||||
admin_password="$temp/admin-password"
|
||||
token_json="$temp/token.json"
|
||||
token="$temp/token"
|
||||
auth="$temp/auth.conf"
|
||||
response="$temp/response.json"
|
||||
early_cleanup() {
|
||||
rc=$?
|
||||
trap - EXIT INT TERM
|
||||
if [[ -n "$pf" ]] && kill -0 "$pf" 2>/dev/null; then
|
||||
kill "$pf" 2>/dev/null || true
|
||||
wait "$pf" 2>/dev/null || true
|
||||
fi
|
||||
case "$temp" in /tmp/keycloak-admin-oidc.*) rm -rf -- "$temp" ;; esac
|
||||
exit "$rc"
|
||||
}
|
||||
trap early_cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" \
|
||||
-o jsonpath='{.data.username}' | base64 -d >"$admin_user"
|
||||
kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" \
|
||||
-o jsonpath='{.data.password}' | base64 -d >"$admin_password"
|
||||
chmod 0600 "$admin_user" "$admin_password"
|
||||
kubectl -n "$KC_NS" port-forward --address=127.0.0.1 "service/$KC_SERVICE" \
|
||||
"$PORT:8080" >"$temp/port-forward.log" 2>&1 &
|
||||
pf=$!
|
||||
ready=false
|
||||
for _ in $(seq 1 30); do
|
||||
kill -0 "$pf" 2>/dev/null || fail "Keycloak port-forward가 종료됐습니다"
|
||||
if rg -q -F "Forwarding from 127.0.0.1:$PORT -> 8080" "$temp/port-forward.log"; then
|
||||
ready=true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$ready" == true ]] || fail "Keycloak port-forward가 준비되지 않았습니다"
|
||||
|
||||
kc_curl() {
|
||||
curl --disable --silent --show-error --noproxy '*' \
|
||||
--connect-timeout 3 --max-time 20 \
|
||||
--header "Host: $KC_HOST" \
|
||||
--header 'X-Forwarded-Proto: https' \
|
||||
--header 'X-Forwarded-Port: 443' "$@"
|
||||
}
|
||||
|
||||
kc_curl --fail-with-body --output "$token_json" \
|
||||
--data-urlencode grant_type=password --data-urlencode client_id=admin-cli \
|
||||
--data-urlencode "username@$admin_user" --data-urlencode "password@$admin_password" \
|
||||
"$BASE/realms/master/protocol/openid-connect/token" >/dev/null
|
||||
jq -er '.access_token | select(type == "string" and length > 0)' "$token_json" >"$token"
|
||||
{
|
||||
printf 'header = "Authorization: Bearer '
|
||||
tr -d '\r\n' <"$token"
|
||||
printf '"\n'
|
||||
} >"$auth"
|
||||
chmod 0600 "$token" "$auth"
|
||||
|
||||
request() {
|
||||
kc_curl --config "$auth" "$@"
|
||||
}
|
||||
|
||||
backup_secret() {
|
||||
if kubectl -n "$1" get "secret/$2" -o yaml >"$3" 2>/dev/null; then
|
||||
printf true
|
||||
else
|
||||
printf false
|
||||
fi
|
||||
}
|
||||
ai_secret_existed="$(backup_secret object-storage aistor-keycloak-oidc "$temp/ai-secret.yaml")"
|
||||
pg_secret_existed="$(backup_secret platform-admin pgadmin-keycloak-oidc "$temp/pg-secret.yaml")"
|
||||
|
||||
upsert_group() {
|
||||
group_name="$1"
|
||||
policy="$2"
|
||||
before="$3"
|
||||
body="$temp/group.json"
|
||||
request --get --data-urlencode "search=$group_name" --data-urlencode exact=true \
|
||||
-o "$response" "$BASE/admin/realms/$REALM/groups"
|
||||
group_id="$(jq -r --arg path "/$group_name" '[.[] | select(.path == $path)][0].id // empty' "$response")"
|
||||
if [[ -n "$policy" ]]; then
|
||||
jq -n --arg name "$group_name" --arg policy "$policy" \
|
||||
'{name:$name,attributes:{policy:[$policy]}}' >"$body"
|
||||
else
|
||||
jq -n --arg name "$group_name" '{name:$name}' >"$body"
|
||||
fi
|
||||
if [[ -z "$group_id" ]]; then
|
||||
code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \
|
||||
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/groups")"
|
||||
[[ "$code" == 201 ]] || fail "그룹 생성 실패: $group_name HTTP $code"
|
||||
request --get --data-urlencode "search=$group_name" --data-urlencode exact=true \
|
||||
-o "$response" "$BASE/admin/realms/$REALM/groups"
|
||||
group_id="$(jq -er --arg path "/$group_name" '[.[] | select(.path == $path)][0].id' "$response")"
|
||||
last_created=true
|
||||
else
|
||||
request -o "$before" "$BASE/admin/realms/$REALM/groups/$group_id"
|
||||
code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \
|
||||
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/groups/$group_id")"
|
||||
[[ "$code" == 204 ]] || fail "그룹 갱신 실패: $group_name HTTP $code"
|
||||
last_created=false
|
||||
fi
|
||||
last_id="$group_id"
|
||||
}
|
||||
|
||||
upsert_client() {
|
||||
client="$1"
|
||||
display="$2"
|
||||
root_url="$3"
|
||||
redirect="$4"
|
||||
before="$5"
|
||||
body="$temp/client.json"
|
||||
jq -n --arg client "$client" --arg display "$display" --arg root "$root_url" \
|
||||
--arg redirect "$redirect" '{
|
||||
clientId:$client,name:$display,enabled:true,protocol:"openid-connect",
|
||||
clientAuthenticatorType:"client-secret",publicClient:false,
|
||||
standardFlowEnabled:true,implicitFlowEnabled:false,directAccessGrantsEnabled:false,
|
||||
serviceAccountsEnabled:false,authorizationServicesEnabled:false,
|
||||
consentRequired:false,fullScopeAllowed:false,rootUrl:$root,baseUrl:($root+"/"),
|
||||
redirectUris:[$redirect],webOrigins:[$root],
|
||||
attributes:{"oauth2.device.authorization.grant.enabled":"false",
|
||||
"oidc.ciba.grant.enabled":"false","post.logout.redirect.uris":($root+"/*")}
|
||||
}' >"$body"
|
||||
request --get --data-urlencode "clientId=$client" --data-urlencode max=2 \
|
||||
-o "$response" "$BASE/admin/realms/$REALM/clients"
|
||||
count="$(jq 'length' "$response")"
|
||||
[[ "$count" == 0 || "$count" == 1 ]] || fail "$client clientId가 중복됐습니다"
|
||||
if [[ "$count" == 0 ]]; then
|
||||
code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \
|
||||
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients")"
|
||||
[[ "$code" == 201 ]] || fail "client 생성 실패: $client HTTP $code"
|
||||
last_created=true
|
||||
else
|
||||
client_id="$(jq -er '.[0].id' "$response")"
|
||||
request -o "$before" "$BASE/admin/realms/$REALM/clients/$client_id"
|
||||
code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \
|
||||
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients/$client_id")"
|
||||
[[ "$code" == 204 ]] || fail "client 갱신 실패: $client HTTP $code"
|
||||
last_created=false
|
||||
fi
|
||||
request --get --data-urlencode "clientId=$client" --data-urlencode max=2 \
|
||||
-o "$response" "$BASE/admin/realms/$REALM/clients"
|
||||
last_id="$(jq -er 'select(length == 1) | .[0].id' "$response")"
|
||||
}
|
||||
|
||||
upsert_mapper() {
|
||||
client_id="$1"
|
||||
mapper_name="$2"
|
||||
mapper_type="$3"
|
||||
body="$temp/mapper.json"
|
||||
if [[ "$mapper_type" == group ]]; then
|
||||
jq -n --arg name "$mapper_name" '{
|
||||
name:$name,protocol:"openid-connect",protocolMapper:"oidc-group-membership-mapper",
|
||||
consentRequired:false,config:{"claim.name":"groups","full.path":"true",
|
||||
"id.token.claim":"true","access.token.claim":"true",
|
||||
"userinfo.token.claim":"true","introspection.token.claim":"true"}
|
||||
}' >"$body"
|
||||
else
|
||||
jq -n --arg name "$mapper_name" '{
|
||||
name:$name,protocol:"openid-connect",protocolMapper:"oidc-usermodel-attribute-mapper",
|
||||
consentRequired:false,config:{"user.attribute":"policy","claim.name":"policy",
|
||||
"jsonType.label":"String","multivalued":"true","aggregate.attrs":"true",
|
||||
"id.token.claim":"true","access.token.claim":"true",
|
||||
"userinfo.token.claim":"true","introspection.token.claim":"true"}
|
||||
}' >"$body"
|
||||
fi
|
||||
request -o "$response" "$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models"
|
||||
mapper_id="$(jq -r --arg name "$mapper_name" '[.[] | select(.name == $name)][0].id // empty' "$response")"
|
||||
if [[ -z "$mapper_id" ]]; then
|
||||
code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \
|
||||
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models")"
|
||||
[[ "$code" == 201 ]] || fail "mapper 생성 실패: $mapper_name HTTP $code"
|
||||
else
|
||||
code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \
|
||||
-o "$response" -w '%{http_code}' \
|
||||
"$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models/$mapper_id")"
|
||||
[[ "$code" == 204 ]] || fail "mapper 갱신 실패: $mapper_name HTTP $code"
|
||||
fi
|
||||
}
|
||||
|
||||
assign_member() {
|
||||
username="$1"
|
||||
group_id="$2"
|
||||
request --get --data-urlencode "username=$username" --data-urlencode exact=true \
|
||||
-o "$response" "$BASE/admin/realms/$REALM/users"
|
||||
[[ "$(jq 'length' "$response")" == 1 ]] || fail "사용자를 정확히 찾지 못했습니다: $username"
|
||||
user_id="$(jq -er '.[0].id' "$response")"
|
||||
request -o "$response" "$BASE/admin/realms/$REALM/users/$user_id/groups"
|
||||
if jq -e --arg id "$group_id" 'any(.[]; .id == $id)' "$response" >/dev/null; then
|
||||
last_member_added=false
|
||||
else
|
||||
code="$(request -X PUT -o "$response" -w '%{http_code}' \
|
||||
"$BASE/admin/realms/$REALM/users/$user_id/groups/$group_id")"
|
||||
[[ "$code" == 204 ]] || fail "그룹 구성원 추가 실패: $username HTTP $code"
|
||||
last_member_added=true
|
||||
fi
|
||||
last_user_id="$user_id"
|
||||
}
|
||||
|
||||
restore_secret() {
|
||||
if [[ "$1" == true ]]; then
|
||||
kubectl apply -f "$2" >/dev/null 2>&1 || true
|
||||
else
|
||||
kubectl -n "$3" delete "secret/$4" --ignore-not-found >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
rollback() {
|
||||
set +e
|
||||
printf '\nROLLBACK: Keycloak 및 OIDC Secret 이전 상태 복원\n' >&2
|
||||
[[ "$ai_member_added" == true ]] && \
|
||||
request -X DELETE "$BASE/admin/realms/$REALM/users/$ai_user_uuid/groups/$ai_group_uuid" >/dev/null
|
||||
[[ "$pg_member_added" == true ]] && \
|
||||
request -X DELETE "$BASE/admin/realms/$REALM/users/$pg_user_uuid/groups/$pg_group_uuid" >/dev/null
|
||||
for item in "ai $ai_client_uuid $ai_client_created" "pg $pg_client_uuid $pg_client_created"; do
|
||||
set -- $item
|
||||
if [[ "$3" == true ]]; then
|
||||
request -X DELETE "$BASE/admin/realms/$REALM/clients/$2" >/dev/null
|
||||
elif [[ -n "$2" ]]; then
|
||||
request -X PUT -H 'Content-Type: application/json' \
|
||||
--data-binary "@$temp/$1-client-before.json" \
|
||||
"$BASE/admin/realms/$REALM/clients/$2" >/dev/null
|
||||
fi
|
||||
done
|
||||
for item in "ai $ai_group_uuid $ai_group_created" "pg $pg_group_uuid $pg_group_created"; do
|
||||
set -- $item
|
||||
if [[ "$3" == true ]]; then
|
||||
request -X DELETE "$BASE/admin/realms/$REALM/groups/$2" >/dev/null
|
||||
elif [[ -n "$2" ]]; then
|
||||
request -X PUT -H 'Content-Type: application/json' \
|
||||
--data-binary "@$temp/$1-group-before.json" \
|
||||
"$BASE/admin/realms/$REALM/groups/$2" >/dev/null
|
||||
fi
|
||||
done
|
||||
restore_secret "$ai_secret_existed" "$temp/ai-secret.yaml" object-storage aistor-keycloak-oidc
|
||||
restore_secret "$pg_secret_existed" "$temp/pg-secret.yaml" platform-admin pgadmin-keycloak-oidc
|
||||
rollback_armed=false
|
||||
printf 'ROLLBACK complete.\n' >&2
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
rc=$?
|
||||
trap - EXIT INT TERM
|
||||
if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then rollback; fi
|
||||
if [[ -n "$pf" ]] && kill -0 "$pf" 2>/dev/null; then
|
||||
kill "$pf" 2>/dev/null || true
|
||||
wait "$pf" 2>/dev/null || true
|
||||
fi
|
||||
case "$temp" in /tmp/keycloak-admin-oidc.*) rm -rf -- "$temp" ;; esac
|
||||
exit "$rc"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
rollback_armed=true
|
||||
upsert_group platform-object-admins consoleAdmin "$temp/ai-group-before.json"
|
||||
ai_group_uuid="$last_id"; ai_group_created="$last_created"
|
||||
upsert_group platform-db-admins "" "$temp/pg-group-before.json"
|
||||
pg_group_uuid="$last_id"; pg_group_created="$last_created"
|
||||
|
||||
upsert_client aistor-console "AIStor Console" https://storage-admin.learn.hyeonworks.com \
|
||||
https://storage-admin.learn.hyeonworks.com/oauth_callback "$temp/ai-client-before.json"
|
||||
ai_client_uuid="$last_id"; ai_client_created="$last_created"
|
||||
upsert_mapper "$ai_client_uuid" aistor-policy-claim policy
|
||||
|
||||
upsert_client pgadmin pgAdmin https://db-admin.learn.hyeonworks.com \
|
||||
https://db-admin.learn.hyeonworks.com/oauth2/authorize "$temp/pg-client-before.json"
|
||||
pg_client_uuid="$last_id"; pg_client_created="$last_created"
|
||||
upsert_mapper "$pg_client_uuid" pgadmin-groups group
|
||||
|
||||
for item in "object-storage aistor-keycloak-oidc aistor-console $ai_client_uuid ai" \
|
||||
"platform-admin pgadmin-keycloak-oidc pgadmin $pg_client_uuid pg"; do
|
||||
set -- $item
|
||||
secret_file="$temp/$5-client-secret"
|
||||
request -o "$response" "$BASE/admin/realms/$REALM/clients/$4/client-secret"
|
||||
jq --exit-status --join-output --raw-output '.value | select(type == "string" and length >= 16)' "$response" >"$secret_file"
|
||||
chmod 0600 "$secret_file"
|
||||
kubectl -n "$1" create secret generic "$2" --from-literal="client-id=$3" \
|
||||
--from-file="client-secret=$secret_file" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
|
||||
kubectl -n "$1" label "secret/$2" app.kubernetes.io/component=oidc-client \
|
||||
app.kubernetes.io/part-of=platform app.kubernetes.io/managed-by=bootstrap-script \
|
||||
--overwrite >/dev/null
|
||||
done
|
||||
|
||||
if [[ -n "$object_admin" ]]; then
|
||||
assign_member "$object_admin" "$ai_group_uuid"
|
||||
ai_member_added="$last_member_added"; ai_user_uuid="$last_user_id"
|
||||
fi
|
||||
if [[ -n "$db_admin" ]]; then
|
||||
assign_member "$db_admin" "$pg_group_uuid"
|
||||
pg_member_added="$last_member_added"; pg_user_uuid="$last_user_id"
|
||||
fi
|
||||
|
||||
for contract in "object-storage aistor-keycloak-oidc" "platform-admin pgadmin-keycloak-oidc"; do
|
||||
set -- $contract
|
||||
keys="$(kubectl -n "$1" get "secret/$2" \
|
||||
-o go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' | LC_ALL=C sort)"
|
||||
[[ "$keys" == $'client-id\nclient-secret' ]] || fail "$1/$2 Secret 계약이 다릅니다"
|
||||
done
|
||||
request -o "$response" "$BASE/realms/$REALM/.well-known/openid-configuration"
|
||||
jq -e --arg issuer "https://$KC_HOST/realms/$REALM" '.issuer == $issuer' "$response" >/dev/null || \
|
||||
fail "OIDC discovery issuer가 다릅니다"
|
||||
|
||||
rollback_armed=false
|
||||
printf 'KEYCLOAK ADMIN OIDC CONFIG SUCCESS\n'
|
||||
printf 'Secret payload, 관리자 암호, token은 출력하지 않았습니다.\n'
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# Never inherit caller xtrace: this script handles bootstrap credentials,
|
||||
# bearer tokens, and the Gitea OIDC client secret.
|
||||
set +x
|
||||
umask 077
|
||||
|
||||
readonly REPOSITORY_ROOT="/home/donghyeon/workspace/platform"
|
||||
readonly KEYCLOAK_NAMESPACE="keycloak"
|
||||
readonly KEYCLOAK_NAME="keycloak"
|
||||
readonly KEYCLOAK_SERVICE="keycloak-service"
|
||||
readonly KEYCLOAK_HOST="id.learn.hyeonworks.com"
|
||||
readonly KEYCLOAK_REALM="hyeonworks"
|
||||
readonly GITEA_NAMESPACE="gitea"
|
||||
readonly GITEA_OIDC_SECRET="gitea-keycloak-oidc"
|
||||
readonly GITEA_CLIENT_ID="gitea"
|
||||
readonly GITEA_ROOT_URL="https://git.learn.hyeonworks.com"
|
||||
readonly GITEA_REDIRECT_URI="${GITEA_ROOT_URL}/user/oauth2/keycloak/callback"
|
||||
readonly LOCAL_PORT="${KEYCLOAK_LOCAL_PORT:-18080}"
|
||||
readonly LOCAL_BASE_URL="http://127.0.0.1:${LOCAL_PORT}"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
'Usage: bash scripts/bootstrap/configure-keycloak-gitea-oidc.sh --execute' \
|
||||
'' \
|
||||
'Uses the Operator-generated temporary Keycloak administrator through a' \
|
||||
'loopback-only kubectl port-forward. It creates or updates the hyeonworks' \
|
||||
'realm and confidential Gitea client, then writes only the generated client' \
|
||||
'credential to gitea/gitea-keycloak-oidc.' \
|
||||
'' \
|
||||
'No credential, token, or Secret payload is printed or written to Git.'
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
[[ "$LOCAL_PORT" =~ ^[0-9]+$ ]] || fail "KEYCLOAK_LOCAL_PORT must be numeric"
|
||||
(( LOCAL_PORT >= 1024 && LOCAL_PORT <= 65535 )) || \
|
||||
fail "KEYCLOAK_LOCAL_PORT must be between 1024 and 65535"
|
||||
[[ "$PWD" == "$REPOSITORY_ROOT" ]] || \
|
||||
fail "run from ${REPOSITORY_ROOT}"
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required"
|
||||
|
||||
for required_binary in \
|
||||
kubectl curl jq base64 mktemp chmod kill sleep seq rg sed tr sort rm; do
|
||||
command -v "$required_binary" >/dev/null 2>&1 || \
|
||||
fail "${required_binary} is required"
|
||||
done
|
||||
|
||||
readonly CURRENT_CONTEXT="$(kubectl config current-context)"
|
||||
readonly API_SERVER="$(
|
||||
kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}'
|
||||
)"
|
||||
readonly TARGET_NODE="$(
|
||||
kubectl get nodes \
|
||||
--selector='node-role.kubernetes.io/control-plane' \
|
||||
--output=jsonpath='{.items[0].metadata.name}'
|
||||
)"
|
||||
[[ -n "$CURRENT_CONTEXT" ]] || fail "kubectl current-context is empty"
|
||||
[[ -n "$API_SERVER" ]] || fail "the selected Kubernetes API server is empty"
|
||||
[[ -n "$TARGET_NODE" ]] || fail "the control-plane node was not found"
|
||||
|
||||
kubectl get namespace "$KEYCLOAK_NAMESPACE" >/dev/null
|
||||
kubectl get namespace "$GITEA_NAMESPACE" >/dev/null
|
||||
kubectl --namespace "$KEYCLOAK_NAMESPACE" get \
|
||||
"keycloak.k8s.keycloak.org/${KEYCLOAK_NAME}" >/dev/null
|
||||
kubectl --namespace "$KEYCLOAK_NAMESPACE" wait \
|
||||
--for=condition=Ready \
|
||||
"keycloak.k8s.keycloak.org/${KEYCLOAK_NAME}" \
|
||||
--timeout=30s >/dev/null
|
||||
kubectl --namespace "$KEYCLOAK_NAMESPACE" get \
|
||||
"service/${KEYCLOAK_SERVICE}" >/dev/null
|
||||
kubectl --namespace "$KEYCLOAK_NAMESPACE" get \
|
||||
"secret/${KEYCLOAK_NAME}-initial-admin" >/dev/null
|
||||
|
||||
printf 'Current context: %s\n' "$CURRENT_CONTEXT"
|
||||
printf 'API server: %s\n' "$API_SERVER"
|
||||
printf 'Target node: %s\n' "$TARGET_NODE"
|
||||
printf 'Realm: %s\n' "$KEYCLOAK_REALM"
|
||||
printf 'OIDC client: %s\n' "$GITEA_CLIENT_ID"
|
||||
printf 'Redirect URI: %s\n' "$GITEA_REDIRECT_URI"
|
||||
printf 'Type APPLY %s to configure Keycloak and create the Gitea OIDC Secret: ' \
|
||||
"$CURRENT_CONTEXT"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "APPLY ${CURRENT_CONTEXT}" ]] || fail "cancelled"
|
||||
[[ "$(kubectl config current-context)" == "$CURRENT_CONTEXT" ]] || \
|
||||
fail "kubectl context changed after confirmation"
|
||||
[[ "$(
|
||||
kubectl config view --minify --output=jsonpath='{.clusters[0].cluster.server}'
|
||||
)" == "$API_SERVER" ]] || fail "Kubernetes API server changed after confirmation"
|
||||
[[ "$(
|
||||
kubectl get nodes \
|
||||
--selector='node-role.kubernetes.io/control-plane' \
|
||||
--output=jsonpath='{.items[0].metadata.name}'
|
||||
)" == "$TARGET_NODE" ]] || fail "target control-plane node changed after confirmation"
|
||||
kubectl get node "$TARGET_NODE" >/dev/null 2>&1 || \
|
||||
fail "target node disappeared after confirmation: ${TARGET_NODE}"
|
||||
|
||||
readonly TEMP_DIR="$(mktemp -d /tmp/keycloak-gitea-oidc.XXXXXX)"
|
||||
readonly PORT_FORWARD_LOG="${TEMP_DIR}/port-forward.log"
|
||||
readonly ADMIN_USERNAME_FILE="${TEMP_DIR}/admin-username"
|
||||
readonly ADMIN_PASSWORD_FILE="${TEMP_DIR}/admin-password"
|
||||
readonly TOKEN_RESPONSE_FILE="${TEMP_DIR}/token-response.json"
|
||||
readonly ACCESS_TOKEN_FILE="${TEMP_DIR}/access-token"
|
||||
readonly AUTH_CONFIG_FILE="${TEMP_DIR}/curl-auth.conf"
|
||||
readonly REALM_FILE="${TEMP_DIR}/realm.json"
|
||||
readonly CLIENT_FILE="${TEMP_DIR}/client.json"
|
||||
readonly RESPONSE_FILE="${TEMP_DIR}/response.json"
|
||||
readonly CLIENT_SECRET_RESPONSE_FILE="${TEMP_DIR}/client-secret.json"
|
||||
readonly CLIENT_SECRET_FILE="${TEMP_DIR}/client-secret"
|
||||
port_forward_pid=""
|
||||
|
||||
cleanup() {
|
||||
local cleanup_rc=$?
|
||||
|
||||
if [[ -n "$port_forward_pid" ]] && kill -0 "$port_forward_pid" 2>/dev/null; then
|
||||
kill "$port_forward_pid" 2>/dev/null || true
|
||||
wait "$port_forward_pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
case "$TEMP_DIR" in
|
||||
/tmp/keycloak-gitea-oidc.*)
|
||||
rm -rf -- "$TEMP_DIR"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected temp path: %s\n' \
|
||||
"$TEMP_DIR" >&2
|
||||
;;
|
||||
esac
|
||||
|
||||
exit "$cleanup_rc"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
kubectl --namespace "$KEYCLOAK_NAMESPACE" get \
|
||||
"secret/${KEYCLOAK_NAME}-initial-admin" \
|
||||
--output=jsonpath='{.data.username}' \
|
||||
| base64 --decode >"$ADMIN_USERNAME_FILE"
|
||||
kubectl --namespace "$KEYCLOAK_NAMESPACE" get \
|
||||
"secret/${KEYCLOAK_NAME}-initial-admin" \
|
||||
--output=jsonpath='{.data.password}' \
|
||||
| base64 --decode >"$ADMIN_PASSWORD_FILE"
|
||||
chmod 0600 "$ADMIN_USERNAME_FILE" "$ADMIN_PASSWORD_FILE"
|
||||
[[ -s "$ADMIN_USERNAME_FILE" ]] || fail "temporary admin username is empty"
|
||||
[[ -s "$ADMIN_PASSWORD_FILE" ]] || fail "temporary admin password is empty"
|
||||
|
||||
kubectl --namespace "$KEYCLOAK_NAMESPACE" port-forward \
|
||||
--address=127.0.0.1 \
|
||||
"service/${KEYCLOAK_SERVICE}" \
|
||||
"${LOCAL_PORT}:8080" >"$PORT_FORWARD_LOG" 2>&1 &
|
||||
port_forward_pid=$!
|
||||
|
||||
port_forward_ready=0
|
||||
for _ in $(seq 1 30); do
|
||||
kill -0 "$port_forward_pid" 2>/dev/null || {
|
||||
printf 'Port-forward failed; non-sensitive log follows:\n' >&2
|
||||
sed -n '1,20p' "$PORT_FORWARD_LOG" >&2
|
||||
fail "Keycloak port-forward exited"
|
||||
}
|
||||
if rg --quiet --fixed-strings \
|
||||
"Forwarding from 127.0.0.1:${LOCAL_PORT} -> 8080" \
|
||||
"$PORT_FORWARD_LOG"; then
|
||||
port_forward_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
(( port_forward_ready == 1 )) || fail "Keycloak port-forward did not become ready"
|
||||
|
||||
curl_common=(
|
||||
--disable
|
||||
--silent
|
||||
--show-error
|
||||
--noproxy '*'
|
||||
--connect-timeout 3
|
||||
--max-time 20
|
||||
--header "Host: ${KEYCLOAK_HOST}"
|
||||
--header 'X-Forwarded-Proto: https'
|
||||
--header 'X-Forwarded-Port: 443'
|
||||
)
|
||||
|
||||
curl "${curl_common[@]}" \
|
||||
--fail-with-body \
|
||||
--output "$TOKEN_RESPONSE_FILE" \
|
||||
--data-urlencode 'grant_type=password' \
|
||||
--data-urlencode 'client_id=admin-cli' \
|
||||
--data-urlencode "username@${ADMIN_USERNAME_FILE}" \
|
||||
--data-urlencode "password@${ADMIN_PASSWORD_FILE}" \
|
||||
"${LOCAL_BASE_URL}/realms/master/protocol/openid-connect/token" \
|
||||
>/dev/null || fail "temporary Keycloak administrator authentication failed"
|
||||
|
||||
jq --exit-status --raw-output \
|
||||
'.access_token | select(type == "string" and length > 0)' \
|
||||
"$TOKEN_RESPONSE_FILE" >"$ACCESS_TOKEN_FILE" || \
|
||||
fail "Keycloak token response did not contain an access token"
|
||||
chmod 0600 "$ACCESS_TOKEN_FILE"
|
||||
|
||||
{
|
||||
printf 'header = "Authorization: Bearer '
|
||||
tr -d '\r\n' <"$ACCESS_TOKEN_FILE"
|
||||
printf '"\n'
|
||||
} >"$AUTH_CONFIG_FILE"
|
||||
chmod 0600 "$AUTH_CONFIG_FILE"
|
||||
|
||||
cat >"$REALM_FILE" <<'JSON'
|
||||
{
|
||||
"realm": "hyeonworks",
|
||||
"displayName": "Hyeonworks",
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"registrationAllowed": false,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
"verifyEmail": false,
|
||||
"loginWithEmailAllowed": true,
|
||||
"duplicateEmailsAllowed": false,
|
||||
"resetPasswordAllowed": true,
|
||||
"editUsernameAllowed": false,
|
||||
"bruteForceProtected": true,
|
||||
"permanentLockout": false,
|
||||
"maxFailureWaitSeconds": 900,
|
||||
"minimumQuickLoginWaitSeconds": 60,
|
||||
"waitIncrementSeconds": 60,
|
||||
"quickLoginCheckMilliSeconds": 1000,
|
||||
"maxDeltaTimeSeconds": 43200,
|
||||
"failureFactor": 5,
|
||||
"internationalizationEnabled": true,
|
||||
"supportedLocales": ["ko", "en"],
|
||||
"defaultLocale": "ko"
|
||||
}
|
||||
JSON
|
||||
|
||||
cat >"$CLIENT_FILE" <<'JSON'
|
||||
{
|
||||
"clientId": "gitea",
|
||||
"name": "Hyeonworks Gitea",
|
||||
"description": "Gitea confidential OIDC client managed by the platform bootstrap",
|
||||
"enabled": true,
|
||||
"protocol": "openid-connect",
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"publicClient": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"authorizationServicesEnabled": false,
|
||||
"consentRequired": false,
|
||||
"fullScopeAllowed": false,
|
||||
"rootUrl": "https://git.learn.hyeonworks.com",
|
||||
"baseUrl": "https://git.learn.hyeonworks.com/",
|
||||
"redirectUris": [
|
||||
"https://git.learn.hyeonworks.com/user/oauth2/keycloak/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
"https://git.learn.hyeonworks.com"
|
||||
],
|
||||
"attributes": {
|
||||
"post.logout.redirect.uris": "https://git.learn.hyeonworks.com/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"oidc.ciba.grant.enabled": "false"
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
admin_request() {
|
||||
curl "${curl_common[@]}" \
|
||||
--config "$AUTH_CONFIG_FILE" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
realm_status="$(
|
||||
admin_request \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}"
|
||||
)"
|
||||
case "$realm_status" in
|
||||
200)
|
||||
update_status="$(
|
||||
admin_request \
|
||||
--request PUT \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@${REALM_FILE}" \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}"
|
||||
)"
|
||||
[[ "$update_status" == "204" ]] || \
|
||||
fail "Keycloak realm update failed with HTTP ${update_status}"
|
||||
printf 'Updated Keycloak realm %s.\n' "$KEYCLOAK_REALM"
|
||||
;;
|
||||
404)
|
||||
create_status="$(
|
||||
admin_request \
|
||||
--request POST \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@${REALM_FILE}" \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms"
|
||||
)"
|
||||
[[ "$create_status" == "201" ]] || \
|
||||
fail "Keycloak realm creation failed with HTTP ${create_status}"
|
||||
printf 'Created Keycloak realm %s.\n' "$KEYCLOAK_REALM"
|
||||
;;
|
||||
*)
|
||||
fail "Keycloak realm lookup failed with HTTP ${realm_status}"
|
||||
;;
|
||||
esac
|
||||
|
||||
client_lookup_status="$(
|
||||
admin_request \
|
||||
--get \
|
||||
--data-urlencode "clientId=${GITEA_CLIENT_ID}" \
|
||||
--data-urlencode 'max=2' \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients"
|
||||
)"
|
||||
[[ "$client_lookup_status" == "200" ]] || \
|
||||
fail "Keycloak client lookup failed with HTTP ${client_lookup_status}"
|
||||
client_count="$(jq 'length' "$RESPONSE_FILE")"
|
||||
[[ "$client_count" == "0" || "$client_count" == "1" ]] || \
|
||||
fail "more than one Keycloak client uses clientId=${GITEA_CLIENT_ID}"
|
||||
|
||||
if [[ "$client_count" == "0" ]]; then
|
||||
client_create_status="$(
|
||||
admin_request \
|
||||
--request POST \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@${CLIENT_FILE}" \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients"
|
||||
)"
|
||||
[[ "$client_create_status" == "201" ]] || \
|
||||
fail "Keycloak Gitea client creation failed with HTTP ${client_create_status}"
|
||||
printf 'Created confidential Keycloak client %s.\n' "$GITEA_CLIENT_ID"
|
||||
else
|
||||
client_uuid="$(jq --exit-status --raw-output '.[0].id' "$RESPONSE_FILE")"
|
||||
[[ -n "$client_uuid" ]] || fail "existing Gitea client has no internal id"
|
||||
client_update_status="$(
|
||||
admin_request \
|
||||
--request PUT \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-binary "@${CLIENT_FILE}" \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_uuid}"
|
||||
)"
|
||||
[[ "$client_update_status" == "204" ]] || \
|
||||
fail "Keycloak Gitea client update failed with HTTP ${client_update_status}"
|
||||
printf 'Updated confidential Keycloak client %s.\n' "$GITEA_CLIENT_ID"
|
||||
fi
|
||||
|
||||
client_lookup_status="$(
|
||||
admin_request \
|
||||
--get \
|
||||
--data-urlencode "clientId=${GITEA_CLIENT_ID}" \
|
||||
--data-urlencode 'max=2' \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients"
|
||||
)"
|
||||
[[ "$client_lookup_status" == "200" ]] || \
|
||||
fail "post-update Keycloak client lookup failed with HTTP ${client_lookup_status}"
|
||||
[[ "$(jq 'length' "$RESPONSE_FILE")" == "1" ]] || \
|
||||
fail "post-update Gitea client lookup did not return exactly one client"
|
||||
client_uuid="$(jq --exit-status --raw-output '.[0].id' "$RESPONSE_FILE")"
|
||||
|
||||
client_secret_status="$(
|
||||
admin_request \
|
||||
--output "$CLIENT_SECRET_RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_uuid}/client-secret"
|
||||
)"
|
||||
[[ "$client_secret_status" == "200" ]] || \
|
||||
fail "Keycloak client-secret retrieval failed with HTTP ${client_secret_status}"
|
||||
jq --exit-status --raw-output --join-output \
|
||||
'.value | select(type == "string" and length >= 16)' \
|
||||
"$CLIENT_SECRET_RESPONSE_FILE" >"$CLIENT_SECRET_FILE" || \
|
||||
fail "Keycloak returned an invalid Gitea client secret"
|
||||
chmod 0600 "$CLIENT_SECRET_FILE"
|
||||
|
||||
kubectl --namespace "$GITEA_NAMESPACE" create secret generic \
|
||||
"$GITEA_OIDC_SECRET" \
|
||||
--from-literal="key=${GITEA_CLIENT_ID}" \
|
||||
--from-file="secret=${CLIENT_SECRET_FILE}" \
|
||||
--dry-run=client \
|
||||
--output=yaml \
|
||||
| kubectl apply --filename=- >/dev/null
|
||||
kubectl --namespace "$GITEA_NAMESPACE" label secret "$GITEA_OIDC_SECRET" \
|
||||
app.kubernetes.io/name=gitea \
|
||||
app.kubernetes.io/instance=gitea \
|
||||
app.kubernetes.io/component=oidc-client \
|
||||
app.kubernetes.io/part-of=platform \
|
||||
app.kubernetes.io/managed-by=bootstrap-script \
|
||||
--overwrite >/dev/null
|
||||
|
||||
secret_type="$(
|
||||
kubectl --namespace "$GITEA_NAMESPACE" get secret "$GITEA_OIDC_SECRET" \
|
||||
--output=jsonpath='{.type}'
|
||||
)"
|
||||
secret_keys="$(
|
||||
kubectl --namespace "$GITEA_NAMESPACE" get secret "$GITEA_OIDC_SECRET" \
|
||||
--output=go-template='{{range $key, $value := .data}}{{$key}}{{"\n"}}{{end}}' \
|
||||
| LC_ALL=C sort
|
||||
)"
|
||||
[[ "$secret_type" == "Opaque" ]] || fail "Gitea OIDC Secret type is not Opaque"
|
||||
[[ "$secret_keys" == $'key\nsecret' ]] || \
|
||||
fail "Gitea OIDC Secret key contract is invalid"
|
||||
|
||||
discovery_status="$(
|
||||
curl "${curl_common[@]}" \
|
||||
--output "$RESPONSE_FILE" \
|
||||
--write-out '%{http_code}' \
|
||||
"${LOCAL_BASE_URL}/realms/${KEYCLOAK_REALM}/.well-known/openid-configuration"
|
||||
)"
|
||||
[[ "$discovery_status" == "200" ]] || \
|
||||
fail "Keycloak OIDC discovery failed with HTTP ${discovery_status}"
|
||||
jq --exit-status \
|
||||
--arg issuer "https://${KEYCLOAK_HOST}/realms/${KEYCLOAK_REALM}" \
|
||||
'.issuer == $issuer and
|
||||
(.authorization_endpoint | startswith($issuer)) and
|
||||
(.token_endpoint | startswith($issuer))' \
|
||||
"$RESPONSE_FILE" >/dev/null || \
|
||||
fail "Keycloak discovery metadata contains an unexpected public issuer"
|
||||
|
||||
printf '\nKEYCLOAK OIDC BOOTSTRAP SUCCESS\n'
|
||||
printf 'Realm: %s\n' "$KEYCLOAK_REALM"
|
||||
printf 'Client: %s (confidential, authorization code flow)\n' "$GITEA_CLIENT_ID"
|
||||
printf 'Gitea Secret: %s/%s\n' "$GITEA_NAMESPACE" "$GITEA_OIDC_SECRET"
|
||||
printf 'Secret and token payloads were not printed and the temporary files were removed.\n'
|
||||
printf 'Next: run scripts/bootstrap/apply-host-nginx-keycloak.sh, then apply-gitea-oidc.sh.\n'
|
||||
printf 'Security follow-up: replace the temporary Keycloak administrator with a named administrator and MFA.\n'
|
||||
+1928
File diff suppressed because it is too large
Load Diff
Executable
+324
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# 비밀값이 명령 추적, argv 또는 표준 출력에 노출되지 않도록 한다.
|
||||
set +x
|
||||
|
||||
readonly REQUIRED_CONFIRMATION="APPLY AISTOR SECRETS"
|
||||
readonly -a SECRET_CONTRACTS=(
|
||||
"aistor/minio-license"
|
||||
"object-storage/aistor-root-configuration"
|
||||
)
|
||||
|
||||
license_file=""
|
||||
root_config_file=""
|
||||
generate_root_config=false
|
||||
execute_requested=false
|
||||
license_created=false
|
||||
root_config_created=false
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash scripts/bootstrap/create-aistor-secrets.sh \
|
||||
--license-file /home/donghyeon/.secrets/aistor/minio.license \
|
||||
--root-config-file /home/donghyeon/.secrets/aistor/root.env \
|
||||
--generate-root-config \
|
||||
--execute
|
||||
|
||||
Creates exactly these two Secrets only when both are absent:
|
||||
aistor/minio-license
|
||||
object-storage/aistor-root-configuration
|
||||
|
||||
When both already exist, validates and reuses them unchanged. A partial state
|
||||
is refused. --generate-root-config creates the local 0600 credential file only
|
||||
when it is absent; it never overwrites or rotates an existing credential.
|
||||
USAGE
|
||||
}
|
||||
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--license-file)
|
||||
(( $# >= 2 )) || fail "--license-file requires a path"
|
||||
license_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--root-config-file)
|
||||
(( $# >= 2 )) || fail "--root-config-file requires a path"
|
||||
root_config_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--generate-root-config)
|
||||
generate_root_config=true
|
||||
shift
|
||||
;;
|
||||
--execute)
|
||||
execute_requested=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "unsupported argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$execute_requested" == true ]] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
[[ "$license_file" == /* ]] || fail "--license-file must be an absolute path"
|
||||
[[ "$root_config_file" == /* ]] || \
|
||||
fail "--root-config-file must be an absolute path"
|
||||
|
||||
for command_name in awk base64 chmod cmp find install jq kubectl mktemp \
|
||||
openssl sort stat wc; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
|
||||
validate_private_file() {
|
||||
local file="$1"
|
||||
local description="$2"
|
||||
|
||||
[[ -f "$file" && ! -L "$file" && -O "$file" && -r "$file" && -s "$file" ]] || \
|
||||
fail "${description} must be a readable, non-empty, current-user-owned regular file"
|
||||
[[ "$(stat --format='%a' -- "$file")" == "600" ]] || \
|
||||
fail "${description} must have mode 0600: ${file}"
|
||||
}
|
||||
|
||||
validate_license_file() {
|
||||
local payload
|
||||
|
||||
validate_private_file "$license_file" "license file"
|
||||
payload="$(<"$license_file")"
|
||||
payload="${payload%$'\r'}"
|
||||
[[ "$payload" =~ ^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]] || \
|
||||
fail "license file must contain one JWT value beginning with eyJ"
|
||||
unset payload
|
||||
}
|
||||
|
||||
validate_root_config_file() {
|
||||
local file="$1"
|
||||
|
||||
validate_private_file "$file" "AIStor root configuration file"
|
||||
awk '
|
||||
BEGIN {
|
||||
user_prefix = "export MINIO_ROOT_USER="
|
||||
password_prefix = "export MINIO_ROOT_PASSWORD="
|
||||
}
|
||||
NR == 1 && index($0, user_prefix) == 1 {
|
||||
value = substr($0, length(user_prefix) + 1)
|
||||
if (length(value) >= 3 && substr(value, 1, 1) == "\042" && substr(value, length(value), 1) == "\042") {
|
||||
value = substr(value, 2, length(value) - 2)
|
||||
users++
|
||||
user = value
|
||||
if (length(value) < 8 || value == "minioadmin") bad = 1
|
||||
next
|
||||
}
|
||||
}
|
||||
NR == 2 && index($0, password_prefix) == 1 {
|
||||
value = substr($0, length(password_prefix) + 1)
|
||||
if (length(value) >= 3 && substr(value, 1, 1) == "\042" && substr(value, length(value), 1) == "\042") {
|
||||
value = substr(value, 2, length(value) - 2)
|
||||
passwords++
|
||||
password = value
|
||||
if (length(value) < 16 || value == "minioadmin") bad = 1
|
||||
next
|
||||
}
|
||||
}
|
||||
{ bad = 1 }
|
||||
END {
|
||||
if (NR != 2 || users != 1 || passwords != 1 ||
|
||||
user == password || bad) exit 1
|
||||
}
|
||||
' "$file" || \
|
||||
fail "root configuration must contain exactly valid MINIO_ROOT_USER and MINIO_ROOT_PASSWORD exports"
|
||||
}
|
||||
|
||||
generate_root_configuration() {
|
||||
local parent_dir="${root_config_file%/*}"
|
||||
local root_user
|
||||
local root_password
|
||||
|
||||
[[ "$parent_dir" != "$root_config_file" ]] || \
|
||||
fail "root configuration path has no parent directory"
|
||||
[[ ! -e "$root_config_file" && ! -L "$root_config_file" ]] || \
|
||||
fail "refusing to overwrite existing root configuration: ${root_config_file}"
|
||||
|
||||
install -d -m 0700 -- "$parent_dir"
|
||||
[[ -d "$parent_dir" && ! -L "$parent_dir" && -O "$parent_dir" ]] || \
|
||||
fail "root configuration parent must be a current-user-owned directory"
|
||||
[[ "$(stat --format='%a' -- "$parent_dir")" == "700" ]] || \
|
||||
fail "root configuration parent must have mode 0700: ${parent_dir}"
|
||||
|
||||
root_user="hyeonworks-aistor-$(openssl rand -hex 4)"
|
||||
root_password="$(openssl rand -hex 24)"
|
||||
umask 077
|
||||
{
|
||||
printf 'export MINIO_ROOT_USER="%s"\n' "$root_user"
|
||||
printf 'export MINIO_ROOT_PASSWORD="%s"\n' "$root_password"
|
||||
} >"$root_config_file"
|
||||
chmod 0600 -- "$root_config_file"
|
||||
unset root_user root_password
|
||||
validate_root_config_file "$root_config_file"
|
||||
printf 'Generated a local AIStor root configuration with mode 0600: %s\n' \
|
||||
"$root_config_file"
|
||||
}
|
||||
|
||||
validate_license_file
|
||||
|
||||
for namespace in aistor object-storage; do
|
||||
kubectl get namespace "$namespace" >/dev/null 2>&1 || \
|
||||
fail "namespace ${namespace} does not exist; apply AIStor namespaces first"
|
||||
done
|
||||
|
||||
existing_secret_count=0
|
||||
for contract in "${SECRET_CONTRACTS[@]}"; do
|
||||
namespace="${contract%%/*}"
|
||||
name="${contract#*/}"
|
||||
if kubectl --namespace "$namespace" get secret "$name" >/dev/null 2>&1; then
|
||||
(( existing_secret_count += 1 ))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( existing_secret_count > 0 && existing_secret_count < ${#SECRET_CONTRACTS[@]} )); then
|
||||
fail "partial AIStor Secret state detected; no Secret was created or rotated"
|
||||
fi
|
||||
|
||||
umask 077
|
||||
secret_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-aistor-secrets.XXXXXX")"
|
||||
existing_license_file="${secret_temp_dir}/existing-minio-license"
|
||||
existing_root_config_file="${secret_temp_dir}/existing-root-config"
|
||||
|
||||
cleanup() {
|
||||
case "$secret_temp_dir" in
|
||||
/tmp/platform-aistor-secrets.*|"${TMPDIR:-/tmp}"/platform-aistor-secrets.*)
|
||||
rm -rf -- "$secret_temp_dir"
|
||||
;;
|
||||
*)
|
||||
printf 'WARNING: refusing to remove unexpected temporary directory: %s\n' \
|
||||
"$secret_temp_dir" >&2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
validate_secret_contract() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local expected_key="$3"
|
||||
local actual_type
|
||||
local actual_keys
|
||||
|
||||
actual_type="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output=jsonpath='{.type}'
|
||||
)"
|
||||
[[ "$actual_type" == "Opaque" ]] || \
|
||||
fail "${namespace}/${name} must have type Opaque"
|
||||
actual_keys="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" --output=json |
|
||||
jq -r '.data | keys[]' | sort
|
||||
)"
|
||||
[[ "$actual_keys" == "$expected_key" ]] || \
|
||||
fail "${namespace}/${name} must contain only the ${expected_key} key"
|
||||
}
|
||||
|
||||
if (( existing_secret_count == ${#SECRET_CONTRACTS[@]} )); then
|
||||
validate_secret_contract aistor minio-license minio.license
|
||||
validate_secret_contract \
|
||||
object-storage aistor-root-configuration config.env
|
||||
|
||||
kubectl --namespace aistor get secret minio-license \
|
||||
--output=jsonpath='{.data.minio\.license}' |
|
||||
base64 --decode >"$existing_license_file"
|
||||
cmp --silent -- "$license_file" "$existing_license_file" || \
|
||||
fail "the supplied license differs from the existing Secret; rotation was not performed"
|
||||
|
||||
kubectl --namespace object-storage get secret aistor-root-configuration \
|
||||
--output=jsonpath='{.data.config\.env}' |
|
||||
base64 --decode >"$existing_root_config_file"
|
||||
chmod 0600 -- "$existing_root_config_file"
|
||||
validate_root_config_file "$existing_root_config_file"
|
||||
|
||||
if [[ -e "$root_config_file" || -L "$root_config_file" ]]; then
|
||||
validate_root_config_file "$root_config_file"
|
||||
cmp --silent -- "$root_config_file" "$existing_root_config_file" || \
|
||||
fail "local root configuration differs from the existing Secret; rotation was not performed"
|
||||
else
|
||||
fail "existing Secret is valid, but the local root configuration file is missing"
|
||||
fi
|
||||
|
||||
printf 'Existing AIStor Secret contracts are valid and were reused unchanged.\n'
|
||||
printf 'No credential or license rotation was performed.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -e "$root_config_file" && ! -L "$root_config_file" ]]; then
|
||||
[[ "$generate_root_config" == true ]] || \
|
||||
fail "root configuration is absent; pass --generate-root-config to create it"
|
||||
generate_root_configuration
|
||||
else
|
||||
validate_root_config_file "$root_config_file"
|
||||
fi
|
||||
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required for initial Secret creation"
|
||||
printf '\nThis will create exactly two AIStor Secrets. Type %s to continue: ' \
|
||||
"$REQUIRED_CONFIRMATION"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "$REQUIRED_CONFIRMATION" ]] || fail "cancelled"
|
||||
|
||||
rollback_new_secrets() {
|
||||
set +e
|
||||
if [[ "$root_config_created" == true ]]; then
|
||||
kubectl --namespace object-storage delete secret \
|
||||
aistor-root-configuration --ignore-not-found >/dev/null
|
||||
fi
|
||||
if [[ "$license_created" == true ]]; then
|
||||
kubectl --namespace aistor delete secret \
|
||||
minio-license --ignore-not-found >/dev/null
|
||||
fi
|
||||
if [[ "$root_config_created" == true || "$license_created" == true ]]; then
|
||||
printf 'ROLLBACK: removed only Secrets created by this failed invocation.\n' >&2
|
||||
fi
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local status="$1"
|
||||
local line="$2"
|
||||
|
||||
trap - ERR
|
||||
rollback_new_secrets
|
||||
printf 'ERROR: Secret creation failed at line %s (exit %s).\n' \
|
||||
"$line" "$status" >&2
|
||||
exit "$status"
|
||||
}
|
||||
trap 'on_error "$?" "$LINENO"' ERR
|
||||
|
||||
kubectl --namespace aistor create secret generic minio-license \
|
||||
--type=Opaque \
|
||||
--from-file="minio.license=${license_file}"
|
||||
license_created=true
|
||||
|
||||
kubectl --namespace object-storage create secret generic \
|
||||
aistor-root-configuration \
|
||||
--type=Opaque \
|
||||
--from-file="config.env=${root_config_file}"
|
||||
root_config_created=true
|
||||
|
||||
validate_secret_contract aistor minio-license minio.license
|
||||
validate_secret_contract object-storage aistor-root-configuration config.env
|
||||
|
||||
printf 'Created both AIStor Secret contracts without printing payloads.\n'
|
||||
printf 'Local root credentials remain only in: %s\n' "$root_config_file"
|
||||
printf 'This script does not perform credential or license rotation.\n'
|
||||
Executable
+298
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# 호출자가 bash -x로 실행해도 비밀번호가 추적 출력에 노출되지 않도록 한다.
|
||||
set +x
|
||||
|
||||
readonly REQUIRED_CONFIRMATION="APPLY KEYCLOAK SECRETS"
|
||||
readonly DB_USERNAME="keycloak"
|
||||
readonly TOTAL_SECRET_CONTRACTS=2
|
||||
readonly -a SECRET_CONTRACTS=(
|
||||
"platform-data/keycloak-db-credentials"
|
||||
"keycloak/keycloak-db-credentials"
|
||||
)
|
||||
|
||||
mutation_started=false
|
||||
secret_temp_dir=""
|
||||
|
||||
report_retained_state() {
|
||||
if [[ "$mutation_started" == true ]]; then
|
||||
printf '%s\n' \
|
||||
'SAFE STOP: no Secret or database data was deleted or rolled back.' \
|
||||
'Any Secret created before the failure remains in the cluster.' \
|
||||
'Inspect Secret names and events without printing Secret data, then rerun after resolving the cause.' >&2
|
||||
fi
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
report_retained_state
|
||||
exit 1
|
||||
}
|
||||
|
||||
on_error() {
|
||||
local status="$1"
|
||||
local line="$2"
|
||||
|
||||
trap - ERR
|
||||
set +e
|
||||
printf 'ERROR: command failed at line %s (exit %s).\n' "$line" "$status" >&2
|
||||
report_retained_state
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
on_signal() {
|
||||
local status="$1"
|
||||
|
||||
trap - INT TERM
|
||||
set +e
|
||||
printf 'INTERRUPTED: stopping without deleting cluster state.\n' >&2
|
||||
report_retained_state
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash scripts/bootstrap/create-keycloak-secrets.sh --execute
|
||||
bash scripts/bootstrap/create-keycloak-secrets.sh --generate --execute
|
||||
|
||||
Creates exactly these two Secrets only when both are absent:
|
||||
platform-data/keycloak-db-credentials
|
||||
keycloak/keycloak-db-credentials
|
||||
|
||||
When both already exist, validates and reuses their data unchanged. A partial
|
||||
state is refused. This script does not require or modify an AIStor license,
|
||||
does not rotate credentials, and never prints Secret payloads.
|
||||
|
||||
--execute prompts twice for a database password and requires the exact
|
||||
confirmation text. --generate --execute is an explicit non-interactive mode:
|
||||
it generates 32 random bytes with OpenSSL in a private 0600 temporary file.
|
||||
USAGE
|
||||
}
|
||||
|
||||
execute_requested=false
|
||||
generate_requested=false
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--execute)
|
||||
execute_requested=true
|
||||
shift
|
||||
;;
|
||||
--generate)
|
||||
generate_requested=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "unsupported argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[[ "$execute_requested" == true ]] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
for command_name in kubectl base64 cmp mktemp sort stat tr wc; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || \
|
||||
fail "${command_name} is required"
|
||||
done
|
||||
if [[ "$generate_requested" == true ]]; then
|
||||
command -v openssl >/dev/null 2>&1 || fail "openssl is required for --generate"
|
||||
fi
|
||||
|
||||
for namespace in platform-data keycloak; do
|
||||
kubectl get namespace "$namespace" >/dev/null 2>&1 || \
|
||||
fail "namespace ${namespace} does not exist"
|
||||
done
|
||||
|
||||
umask 077
|
||||
secret_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-keycloak-secrets.XXXXXX")"
|
||||
db_user_file="${secret_temp_dir}/expected-username"
|
||||
db_password_file="${secret_temp_dir}/new-password"
|
||||
platform_username_file="${secret_temp_dir}/platform-username"
|
||||
keycloak_username_file="${secret_temp_dir}/keycloak-username"
|
||||
platform_password_file="${secret_temp_dir}/platform-password"
|
||||
keycloak_password_file="${secret_temp_dir}/keycloak-password"
|
||||
|
||||
cleanup() {
|
||||
unset db_password
|
||||
if [[ -n "$secret_temp_dir" ]]; then
|
||||
rm -f -- \
|
||||
"$db_user_file" \
|
||||
"$db_password_file" \
|
||||
"$platform_username_file" \
|
||||
"$keycloak_username_file" \
|
||||
"$platform_password_file" \
|
||||
"$keycloak_password_file"
|
||||
rmdir -- "$secret_temp_dir" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'on_error "$?" "$LINENO"' ERR
|
||||
trap 'on_signal 130' INT
|
||||
trap 'on_signal 143' TERM
|
||||
|
||||
printf '%s' "$DB_USERNAME" >"$db_user_file"
|
||||
|
||||
secret_data_b64() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local key="$3"
|
||||
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output="go-template={{ index .data \"${key}\" }}"
|
||||
}
|
||||
|
||||
validate_secret_type_and_keys() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local actual_type
|
||||
local actual_keys
|
||||
local actual_keys_sorted
|
||||
|
||||
actual_type="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output='jsonpath={.type}'
|
||||
)"
|
||||
[[ "$actual_type" == "kubernetes.io/basic-auth" ]] || \
|
||||
fail "${namespace}/${name} must have type kubernetes.io/basic-auth"
|
||||
|
||||
actual_keys="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output='go-template={{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}'
|
||||
)"
|
||||
actual_keys_sorted="$(printf '%s\n' "$actual_keys" | LC_ALL=C sort)"
|
||||
[[ "$actual_keys_sorted" == $'password\nusername' ]] || \
|
||||
fail "${namespace}/${name} has an unexpected data key set"
|
||||
}
|
||||
|
||||
validate_existing_contracts() {
|
||||
validate_secret_type_and_keys platform-data keycloak-db-credentials
|
||||
validate_secret_type_and_keys keycloak keycloak-db-credentials
|
||||
|
||||
secret_data_b64 platform-data keycloak-db-credentials username \
|
||||
| base64 --decode >"$platform_username_file"
|
||||
secret_data_b64 keycloak keycloak-db-credentials username \
|
||||
| base64 --decode >"$keycloak_username_file"
|
||||
cmp --silent -- "$db_user_file" "$platform_username_file" || \
|
||||
fail "platform-data/keycloak-db-credentials username must be keycloak"
|
||||
cmp --silent -- "$db_user_file" "$keycloak_username_file" || \
|
||||
fail "keycloak/keycloak-db-credentials username must be keycloak"
|
||||
|
||||
secret_data_b64 platform-data keycloak-db-credentials password \
|
||||
| base64 --decode >"$platform_password_file"
|
||||
secret_data_b64 keycloak keycloak-db-credentials password \
|
||||
| base64 --decode >"$keycloak_password_file"
|
||||
(( $(wc -c <"$platform_password_file") >= 16 )) || \
|
||||
fail "platform-data/keycloak-db-credentials password must contain at least 16 bytes"
|
||||
(( $(wc -c <"$keycloak_password_file") >= 16 )) || \
|
||||
fail "keycloak/keycloak-db-credentials password must contain at least 16 bytes"
|
||||
cmp --silent -- "$platform_password_file" "$keycloak_password_file" || \
|
||||
fail "the two Keycloak DB Secret passwords do not match"
|
||||
}
|
||||
|
||||
existing_secret_count=0
|
||||
for contract in "${SECRET_CONTRACTS[@]}"; do
|
||||
namespace="${contract%%/*}"
|
||||
name="${contract#*/}"
|
||||
existing_resource="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--ignore-not-found --output=name
|
||||
)"
|
||||
if [[ -n "$existing_resource" ]]; then
|
||||
((existing_secret_count += 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( existing_secret_count > 0 && existing_secret_count < TOTAL_SECRET_CONTRACTS )); then
|
||||
fail "partial Keycloak Secret state detected (${existing_secret_count}/${TOTAL_SECRET_CONTRACTS}); refusing creation or rotation"
|
||||
fi
|
||||
|
||||
if (( existing_secret_count == TOTAL_SECRET_CONTRACTS )); then
|
||||
validate_existing_contracts
|
||||
|
||||
reload_label="$(
|
||||
kubectl --namespace platform-data get secret keycloak-db-credentials \
|
||||
--output='jsonpath={.metadata.labels.cnpg\.io/reload}'
|
||||
)"
|
||||
if [[ "$reload_label" != "true" ]]; then
|
||||
mutation_started=true
|
||||
kubectl --namespace platform-data label secret keycloak-db-credentials \
|
||||
cnpg.io/reload=true --overwrite
|
||||
mutation_started=false
|
||||
printf 'Repaired cnpg.io/reload=true without changing Secret data.\n'
|
||||
fi
|
||||
|
||||
printf 'Existing Keycloak Secret contracts are valid and were reused unchanged.\n'
|
||||
printf 'No credential rotation was performed.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
read_secret_twice() {
|
||||
local prompt="$1"
|
||||
local first
|
||||
local second
|
||||
|
||||
read -r -s -p "${prompt}: " first
|
||||
printf '\n' >&2
|
||||
read -r -s -p "Confirm ${prompt}: " second
|
||||
printf '\n' >&2
|
||||
|
||||
[[ "$first" == "$second" ]] || fail "the two values do not match"
|
||||
(( ${#first} >= 16 )) || fail "${prompt} must contain at least 16 characters"
|
||||
[[ "$first" != *$'\n'* && "$first" != *$'\r'* ]] || \
|
||||
fail "${prompt} contains an unsupported line break"
|
||||
printf '%s' "$first"
|
||||
}
|
||||
|
||||
if [[ "$generate_requested" == true ]]; then
|
||||
openssl rand -hex 32 | tr -d '\n' >"$db_password_file"
|
||||
[[ "$(stat --format='%a' -- "$db_password_file")" == "600" ]] || \
|
||||
fail "generated password file must have mode 0600"
|
||||
[[ "$(wc -c <"$db_password_file" | tr -d '[:space:]')" == "64" ]] || \
|
||||
fail "OpenSSL did not generate the expected 32-byte password"
|
||||
printf '%s\n' \
|
||||
'Authorized by explicit --generate --execute flags.' \
|
||||
'A 32-byte random database password was generated without printing it.'
|
||||
else
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required for initial Secret creation"
|
||||
db_password="$(read_secret_twice 'Keycloak database password')"
|
||||
printf '%s' "$db_password" >"$db_password_file"
|
||||
unset db_password
|
||||
|
||||
printf '\nThe script will create exactly two Keycloak DB Secrets.\n'
|
||||
printf 'It will not read or modify any AIStor Secret.\n'
|
||||
printf 'Type %s to continue: ' "$REQUIRED_CONFIRMATION"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "$REQUIRED_CONFIRMATION" ]] || fail "cancelled"
|
||||
fi
|
||||
|
||||
create_basic_auth_secret() {
|
||||
local namespace="$1"
|
||||
|
||||
kubectl --namespace "$namespace" create secret generic keycloak-db-credentials \
|
||||
--type=kubernetes.io/basic-auth \
|
||||
--from-file="username=${db_user_file}" \
|
||||
--from-file="password=${db_password_file}" \
|
||||
--dry-run=client \
|
||||
--output=yaml \
|
||||
| kubectl create --filename=-
|
||||
}
|
||||
|
||||
mutation_started=true
|
||||
create_basic_auth_secret platform-data
|
||||
create_basic_auth_secret keycloak
|
||||
kubectl --namespace platform-data label secret keycloak-db-credentials \
|
||||
cnpg.io/reload=true --overwrite
|
||||
|
||||
validate_existing_contracts
|
||||
mutation_started=false
|
||||
|
||||
printf 'Initial Keycloak Secret contracts were created without printing payloads.\n'
|
||||
printf 'This script does not perform credential rotation.\n'
|
||||
+1826
File diff suppressed because it is too large
Load Diff
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# 비밀값이 명령 추적 출력에 노출되지 않도록 호출자가 활성화한 xtrace도 끈다.
|
||||
set +x
|
||||
|
||||
readonly REQUIRED_CONFIRMATION="APPLY"
|
||||
readonly DB_USERNAME="gitea"
|
||||
readonly TOTAL_SECRET_CONTRACTS=3
|
||||
readonly -a SECRET_CONTRACTS=(
|
||||
"platform-data/gitea-db-credentials"
|
||||
"gitea/gitea-db-credentials"
|
||||
"gitea/gitea-admin"
|
||||
)
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: bash scripts/bootstrap/create-phase1-secrets.sh --execute
|
||||
|
||||
Creates these Secrets only when all three are absent:
|
||||
platform-data/gitea-db-credentials
|
||||
gitea/gitea-db-credentials
|
||||
gitea/gitea-admin
|
||||
|
||||
When all three already exist, validates and reuses their data unchanged. The
|
||||
script refuses a partial state and does not perform credential rotation. It may
|
||||
repair only the cnpg.io/reload=true label on the platform-data DB Secret.
|
||||
USAGE
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "--execute" && "$#" -eq 1 ]] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
command -v kubectl >/dev/null 2>&1 || fail "kubectl is required"
|
||||
command -v base64 >/dev/null 2>&1 || fail "base64 is required"
|
||||
|
||||
for namespace in platform-data gitea; do
|
||||
kubectl get namespace "$namespace" >/dev/null 2>&1 || \
|
||||
fail "namespace ${namespace} does not exist; apply namespaces first"
|
||||
done
|
||||
|
||||
existing_secret_count=0
|
||||
for contract in "${SECRET_CONTRACTS[@]}"; do
|
||||
namespace="${contract%%/*}"
|
||||
name="${contract#*/}"
|
||||
existing_resource="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--ignore-not-found --output=name
|
||||
)"
|
||||
if [[ -n "$existing_resource" ]]; then
|
||||
((existing_secret_count += 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( existing_secret_count > 0 && existing_secret_count < TOTAL_SECRET_CONTRACTS )); then
|
||||
fail "partial Phase 1 Secret state detected (${existing_secret_count}/${TOTAL_SECRET_CONTRACTS}); refusing creation or rotation"
|
||||
fi
|
||||
|
||||
secret_data_b64() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local key="$3"
|
||||
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output="jsonpath={.data.${key}}"
|
||||
}
|
||||
|
||||
validate_basic_auth_secret() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local secret_type
|
||||
local username_b64
|
||||
local password_b64
|
||||
|
||||
secret_type="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output='jsonpath={.type}'
|
||||
)"
|
||||
[[ "$secret_type" == "kubernetes.io/basic-auth" ]] || \
|
||||
fail "${namespace}/${name} must have type kubernetes.io/basic-auth"
|
||||
|
||||
username_b64="$(secret_data_b64 "$namespace" "$name" username)"
|
||||
password_b64="$(secret_data_b64 "$namespace" "$name" password)"
|
||||
[[ -n "$username_b64" ]] || fail "${namespace}/${name} is missing non-empty data.username"
|
||||
[[ -n "$password_b64" ]] || fail "${namespace}/${name} is missing non-empty data.password"
|
||||
}
|
||||
|
||||
if (( existing_secret_count == TOTAL_SECRET_CONTRACTS )); then
|
||||
validate_basic_auth_secret platform-data gitea-db-credentials
|
||||
validate_basic_auth_secret gitea gitea-db-credentials
|
||||
validate_basic_auth_secret gitea gitea-admin
|
||||
|
||||
platform_db_username_b64="$(
|
||||
secret_data_b64 platform-data gitea-db-credentials username
|
||||
)"
|
||||
platform_db_password_b64="$(
|
||||
secret_data_b64 platform-data gitea-db-credentials password
|
||||
)"
|
||||
gitea_db_username_b64="$(
|
||||
secret_data_b64 gitea gitea-db-credentials username
|
||||
)"
|
||||
gitea_db_password_b64="$(
|
||||
secret_data_b64 gitea gitea-db-credentials password
|
||||
)"
|
||||
expected_db_username_b64="$(printf '%s' "$DB_USERNAME" | base64)"
|
||||
|
||||
[[ "$platform_db_username_b64" == "$expected_db_username_b64" ]] || \
|
||||
fail "platform-data/gitea-db-credentials username must be gitea"
|
||||
[[ "$gitea_db_username_b64" == "$expected_db_username_b64" ]] || \
|
||||
fail "gitea/gitea-db-credentials username must be gitea"
|
||||
[[ "$platform_db_username_b64" == "$gitea_db_username_b64" ]] || \
|
||||
fail "the two DB Secret usernames do not match"
|
||||
[[ "$platform_db_password_b64" == "$gitea_db_password_b64" ]] || \
|
||||
fail "the two DB Secret passwords do not match"
|
||||
|
||||
reload_label="$(
|
||||
kubectl --namespace platform-data get secret gitea-db-credentials \
|
||||
--output='jsonpath={.metadata.labels.cnpg\.io/reload}'
|
||||
)"
|
||||
if [[ "$reload_label" != "true" ]]; then
|
||||
kubectl --namespace platform-data label secret gitea-db-credentials \
|
||||
cnpg.io/reload=true --overwrite
|
||||
printf 'Repaired cnpg.io/reload=true without changing Secret data.\n'
|
||||
fi
|
||||
|
||||
unset platform_db_username_b64 platform_db_password_b64
|
||||
unset gitea_db_username_b64 gitea_db_password_b64 expected_db_username_b64
|
||||
printf 'Existing Phase 1 Secret contracts are valid and were reused unchanged.\n'
|
||||
printf 'No credential rotation was performed.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required for initial Secret creation"
|
||||
|
||||
read_secret_twice() {
|
||||
local prompt="$1"
|
||||
local first
|
||||
local second
|
||||
|
||||
read -r -s -p "${prompt}: " first
|
||||
printf '\n' >&2
|
||||
read -r -s -p "Confirm ${prompt}: " second
|
||||
printf '\n' >&2
|
||||
|
||||
[[ "$first" == "$second" ]] || fail "the two values do not match"
|
||||
(( ${#first} >= 16 )) || fail "use at least 16 characters"
|
||||
printf '%s' "$first"
|
||||
}
|
||||
|
||||
read -r -p 'Gitea administrator username: ' admin_username
|
||||
[[ "$admin_username" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || \
|
||||
fail "administrator username contains unsupported characters"
|
||||
|
||||
db_password="$(read_secret_twice 'Gitea database password')"
|
||||
admin_password="$(read_secret_twice 'Gitea administrator password')"
|
||||
|
||||
secret_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-phase1-secrets.XXXXXX")"
|
||||
db_user_file="${secret_temp_dir}/db-username"
|
||||
db_password_file="${secret_temp_dir}/db-password"
|
||||
admin_user_file="${secret_temp_dir}/admin-username"
|
||||
admin_password_file="${secret_temp_dir}/admin-password"
|
||||
|
||||
cleanup() {
|
||||
unset db_password admin_password
|
||||
rm -f -- "$db_user_file" "$db_password_file" "$admin_user_file" "$admin_password_file"
|
||||
rmdir -- "$secret_temp_dir" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
umask 077
|
||||
printf '%s' "$DB_USERNAME" >"$db_user_file"
|
||||
printf '%s' "$db_password" >"$db_password_file"
|
||||
printf '%s' "$admin_username" >"$admin_user_file"
|
||||
printf '%s' "$admin_password" >"$admin_password_file"
|
||||
unset db_password admin_password
|
||||
|
||||
printf '\nThe script will create exactly three Secrets. Type %s to continue: ' "$REQUIRED_CONFIRMATION"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "$REQUIRED_CONFIRMATION" ]] || fail "cancelled"
|
||||
|
||||
apply_basic_auth_secret() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local username_file="$3"
|
||||
local password_file="$4"
|
||||
|
||||
kubectl --namespace "$namespace" create secret generic "$name" \
|
||||
--type=kubernetes.io/basic-auth \
|
||||
--from-file="username=${username_file}" \
|
||||
--from-file="password=${password_file}" \
|
||||
--dry-run=client \
|
||||
--output=yaml \
|
||||
| kubectl apply --filename=-
|
||||
}
|
||||
|
||||
apply_basic_auth_secret \
|
||||
platform-data gitea-db-credentials "$db_user_file" "$db_password_file"
|
||||
kubectl --namespace platform-data label secret gitea-db-credentials \
|
||||
cnpg.io/reload=true --overwrite
|
||||
|
||||
apply_basic_auth_secret \
|
||||
gitea gitea-db-credentials "$db_user_file" "$db_password_file"
|
||||
apply_basic_auth_secret \
|
||||
gitea gitea-admin "$admin_user_file" "$admin_password_file"
|
||||
|
||||
printf 'Initial Phase 1 Secret contracts were created. No values were written to the repository.\n'
|
||||
printf 'This script does not perform credential rotation.\n'
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
# 비밀값이 명령 추적, argv 또는 표준 출력에 노출되지 않도록 한다.
|
||||
set +x
|
||||
|
||||
readonly REQUIRED_CONFIRMATION="APPLY PHASE 2 SECRETS"
|
||||
readonly DB_USERNAME="keycloak"
|
||||
readonly TOTAL_SECRET_CONTRACTS=4
|
||||
readonly -a SECRET_CONTRACTS=(
|
||||
"platform-data/keycloak-db-credentials"
|
||||
"keycloak/keycloak-db-credentials"
|
||||
"aistor/minio-license"
|
||||
"object-storage/aistor-root-configuration"
|
||||
)
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage:
|
||||
bash scripts/bootstrap/create-phase2-secrets.sh \
|
||||
--license-file /absolute/path/to/minio.license \
|
||||
--execute
|
||||
|
||||
Creates all four Phase 2 Secrets only when all four are absent. When all four
|
||||
already exist, validates and reuses them unchanged. A partial state is refused.
|
||||
This script never rotates credentials or the AIStor license.
|
||||
USAGE
|
||||
}
|
||||
|
||||
license_file=""
|
||||
execute_requested=false
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--license-file)
|
||||
(( $# >= 2 )) || fail "--license-file requires a path"
|
||||
license_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
--execute)
|
||||
execute_requested=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
fail "unsupported argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$execute_requested" == true ]] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
[[ -n "$license_file" ]] || fail "--license-file is required"
|
||||
[[ "$license_file" == /* ]] || fail "--license-file must be an absolute path"
|
||||
[[ -f "$license_file" && -r "$license_file" && -s "$license_file" ]] || \
|
||||
fail "license file must be a readable, non-empty regular file"
|
||||
|
||||
license_payload="$(<"$license_file")"
|
||||
license_payload="${license_payload%$'\r'}"
|
||||
[[ "$license_payload" =~ ^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$ ]] || \
|
||||
fail "license file must contain one decoded JWT value beginning with eyJ"
|
||||
unset license_payload
|
||||
|
||||
command -v kubectl >/dev/null 2>&1 || fail "kubectl is required"
|
||||
command -v base64 >/dev/null 2>&1 || fail "base64 is required"
|
||||
command -v cmp >/dev/null 2>&1 || fail "cmp is required"
|
||||
command -v wc >/dev/null 2>&1 || fail "wc is required"
|
||||
|
||||
for namespace in platform-data keycloak aistor object-storage; do
|
||||
kubectl get namespace "$namespace" >/dev/null 2>&1 || \
|
||||
fail "namespace ${namespace} does not exist; apply Phase 2 namespaces first"
|
||||
done
|
||||
|
||||
existing_secret_count=0
|
||||
for contract in "${SECRET_CONTRACTS[@]}"; do
|
||||
namespace="${contract%%/*}"
|
||||
name="${contract#*/}"
|
||||
existing_resource="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--ignore-not-found --output=name
|
||||
)"
|
||||
if [[ -n "$existing_resource" ]]; then
|
||||
((existing_secret_count += 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( existing_secret_count > 0 && existing_secret_count < TOTAL_SECRET_CONTRACTS )); then
|
||||
fail "partial Phase 2 Secret state detected (${existing_secret_count}/${TOTAL_SECRET_CONTRACTS}); refusing creation or rotation"
|
||||
fi
|
||||
|
||||
umask 077
|
||||
secret_temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/platform-phase2-secrets.XXXXXX")"
|
||||
db_user_file="${secret_temp_dir}/db-username"
|
||||
db_password_file="${secret_temp_dir}/db-password"
|
||||
root_config_file="${secret_temp_dir}/config.env"
|
||||
existing_license_file="${secret_temp_dir}/existing-minio-license"
|
||||
existing_root_config_file="${secret_temp_dir}/existing-config.env"
|
||||
existing_platform_db_password_file="${secret_temp_dir}/existing-platform-db-password"
|
||||
existing_keycloak_db_password_file="${secret_temp_dir}/existing-keycloak-db-password"
|
||||
|
||||
cleanup() {
|
||||
unset db_password root_user root_password
|
||||
rm -f -- \
|
||||
"$db_user_file" "$db_password_file" "$root_config_file" \
|
||||
"$existing_license_file" "$existing_root_config_file" \
|
||||
"$existing_platform_db_password_file" \
|
||||
"$existing_keycloak_db_password_file"
|
||||
rmdir -- "$secret_temp_dir" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
secret_data_b64() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local key="$3"
|
||||
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output="go-template={{ index .data \"${key}\" }}"
|
||||
}
|
||||
|
||||
validate_secret_type_and_keys() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local expected_type="$3"
|
||||
local expected_keys="$4"
|
||||
local actual_type
|
||||
local actual_keys
|
||||
local actual_keys_sorted
|
||||
local expected_keys_sorted
|
||||
|
||||
actual_type="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output='jsonpath={.type}'
|
||||
)"
|
||||
[[ "$actual_type" == "$expected_type" ]] || \
|
||||
fail "${namespace}/${name} must have type ${expected_type}"
|
||||
|
||||
actual_keys="$(
|
||||
kubectl --namespace "$namespace" get secret "$name" \
|
||||
--output='go-template={{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}'
|
||||
)"
|
||||
actual_keys_sorted="$(printf '%s\n' "$actual_keys" | LC_ALL=C sort)"
|
||||
expected_keys_sorted="$(printf '%s\n' "$expected_keys" | LC_ALL=C sort)"
|
||||
[[ "$actual_keys_sorted" == "$expected_keys_sorted" ]] || \
|
||||
fail "${namespace}/${name} has an unexpected data key set"
|
||||
}
|
||||
|
||||
validate_root_config_file() {
|
||||
local file="$1"
|
||||
|
||||
awk '
|
||||
BEGIN {
|
||||
user_prefix = "export MINIO_ROOT_USER=\042"
|
||||
password_prefix = "export MINIO_ROOT_PASSWORD=\042"
|
||||
}
|
||||
NR == 1 && index($0, user_prefix) == 1 &&
|
||||
substr($0, length($0), 1) == "\042" {
|
||||
value = substr($0, length(user_prefix) + 1,
|
||||
length($0) - length(user_prefix) - 1)
|
||||
users++
|
||||
user = value
|
||||
if (length(value) < 8 || value == "minioadmin" || index(value, "\042") > 0) bad = 1
|
||||
next
|
||||
}
|
||||
NR == 2 && index($0, password_prefix) == 1 &&
|
||||
substr($0, length($0), 1) == "\042" {
|
||||
value = substr($0, length(password_prefix) + 1,
|
||||
length($0) - length(password_prefix) - 1)
|
||||
passwords++
|
||||
password = value
|
||||
if (length(value) < 16 || value == "minioadmin" || index(value, "\042") > 0) bad = 1
|
||||
next
|
||||
}
|
||||
{ bad = 1 }
|
||||
END {
|
||||
if (NR != 2 || users != 1 || passwords != 1 ||
|
||||
user == password || bad) exit 1
|
||||
}
|
||||
' "$file" || fail "object-storage/aistor-root-configuration has an invalid config.env contract"
|
||||
}
|
||||
|
||||
if (( existing_secret_count == TOTAL_SECRET_CONTRACTS )); then
|
||||
validate_secret_type_and_keys \
|
||||
platform-data keycloak-db-credentials kubernetes.io/basic-auth $'password\nusername'
|
||||
validate_secret_type_and_keys \
|
||||
keycloak keycloak-db-credentials kubernetes.io/basic-auth $'password\nusername'
|
||||
validate_secret_type_and_keys aistor minio-license Opaque 'minio.license'
|
||||
validate_secret_type_and_keys \
|
||||
object-storage aistor-root-configuration Opaque 'config.env'
|
||||
|
||||
platform_username_b64="$(secret_data_b64 platform-data keycloak-db-credentials username)"
|
||||
keycloak_username_b64="$(secret_data_b64 keycloak keycloak-db-credentials username)"
|
||||
expected_username_b64="$(printf '%s' "$DB_USERNAME" | base64)"
|
||||
|
||||
[[ "$platform_username_b64" == "$expected_username_b64" ]] || \
|
||||
fail "platform-data/keycloak-db-credentials username must be keycloak"
|
||||
[[ "$keycloak_username_b64" == "$expected_username_b64" ]] || \
|
||||
fail "keycloak/keycloak-db-credentials username must be keycloak"
|
||||
[[ "$platform_username_b64" == "$keycloak_username_b64" ]] || \
|
||||
fail "the two Keycloak DB Secret usernames do not match"
|
||||
secret_data_b64 platform-data keycloak-db-credentials password \
|
||||
| base64 --decode >"$existing_platform_db_password_file"
|
||||
secret_data_b64 keycloak keycloak-db-credentials password \
|
||||
| base64 --decode >"$existing_keycloak_db_password_file"
|
||||
(( $(wc -c <"$existing_platform_db_password_file") >= 16 )) || \
|
||||
fail "platform-data/keycloak-db-credentials password must contain at least 16 bytes"
|
||||
(( $(wc -c <"$existing_keycloak_db_password_file") >= 16 )) || \
|
||||
fail "keycloak/keycloak-db-credentials password must contain at least 16 bytes"
|
||||
cmp --silent -- "$existing_platform_db_password_file" \
|
||||
"$existing_keycloak_db_password_file" || \
|
||||
fail "the two Keycloak DB Secret passwords do not match"
|
||||
|
||||
reload_label="$(
|
||||
kubectl --namespace platform-data get secret keycloak-db-credentials \
|
||||
--output='jsonpath={.metadata.labels.cnpg\.io/reload}'
|
||||
)"
|
||||
[[ "$reload_label" == "true" ]] || \
|
||||
fail "platform-data/keycloak-db-credentials must have cnpg.io/reload=true"
|
||||
|
||||
secret_data_b64 aistor minio-license minio.license \
|
||||
| base64 --decode >"$existing_license_file"
|
||||
[[ -s "$existing_license_file" ]] || \
|
||||
fail "aistor/minio-license has an empty minio.license payload"
|
||||
cmp --silent -- "$license_file" "$existing_license_file" || \
|
||||
fail "the supplied license file differs from the existing Secret; rotation was not performed"
|
||||
|
||||
secret_data_b64 object-storage aistor-root-configuration config.env \
|
||||
| base64 --decode >"$existing_root_config_file"
|
||||
[[ -s "$existing_root_config_file" ]] || \
|
||||
fail "object-storage/aistor-root-configuration has an empty config.env payload"
|
||||
validate_root_config_file "$existing_root_config_file"
|
||||
|
||||
unset platform_username_b64 keycloak_username_b64 expected_username_b64
|
||||
printf 'Existing Phase 2 Secret contracts are valid and were reused unchanged.\n'
|
||||
printf 'No credential or license rotation was performed.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -t 0 ]] || fail "an interactive terminal is required for initial Secret creation"
|
||||
|
||||
read_secret_twice() {
|
||||
local prompt="$1"
|
||||
local minimum_length="$2"
|
||||
local first
|
||||
local second
|
||||
|
||||
read -r -s -p "${prompt}: " first
|
||||
printf '\n' >&2
|
||||
read -r -s -p "Confirm ${prompt}: " second
|
||||
printf '\n' >&2
|
||||
|
||||
[[ "$first" == "$second" ]] || fail "the two values do not match"
|
||||
(( ${#first} >= minimum_length )) || \
|
||||
fail "${prompt} must contain at least ${minimum_length} characters"
|
||||
[[ "$first" != *$'\n'* && "$first" != *$'\r'* && "$first" != *"'"* ]] || \
|
||||
fail "${prompt} contains a character unsupported by config.env"
|
||||
printf '%s' "$first"
|
||||
}
|
||||
|
||||
db_password="$(read_secret_twice 'Keycloak database password' 16)"
|
||||
root_user="$(read_secret_twice 'AIStor root username' 8)"
|
||||
root_password="$(read_secret_twice 'AIStor root password' 16)"
|
||||
|
||||
[[ "$root_user" != "minioadmin" ]] || fail "do not use the default root username"
|
||||
[[ "$root_password" != "minioadmin" ]] || fail "do not use the default root password"
|
||||
[[ "$root_user" != "$root_password" ]] || fail "root username and password must differ"
|
||||
|
||||
printf '%s' "$DB_USERNAME" >"$db_user_file"
|
||||
printf '%s' "$db_password" >"$db_password_file"
|
||||
printf 'export MINIO_ROOT_USER="%s"\n' "$root_user" >"$root_config_file"
|
||||
printf 'export MINIO_ROOT_PASSWORD="%s"\n' "$root_password" >>"$root_config_file"
|
||||
validate_root_config_file "$root_config_file"
|
||||
unset db_password root_user root_password
|
||||
|
||||
printf '\nThis will create exactly four Phase 2 Secrets. Type %s to continue: ' \
|
||||
"$REQUIRED_CONFIRMATION"
|
||||
read -r confirmation
|
||||
[[ "$confirmation" == "$REQUIRED_CONFIRMATION" ]] || fail "cancelled"
|
||||
|
||||
create_secret_from_files() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local type="$3"
|
||||
shift 3
|
||||
|
||||
kubectl --namespace "$namespace" create secret generic "$name" \
|
||||
--type="$type" "$@" \
|
||||
--dry-run=client --output=yaml \
|
||||
| kubectl create --filename=-
|
||||
}
|
||||
|
||||
create_secret_from_files \
|
||||
platform-data keycloak-db-credentials kubernetes.io/basic-auth \
|
||||
--from-file="username=${db_user_file}" \
|
||||
--from-file="password=${db_password_file}"
|
||||
kubectl --namespace platform-data label secret keycloak-db-credentials \
|
||||
cnpg.io/reload=true --overwrite
|
||||
|
||||
create_secret_from_files \
|
||||
keycloak keycloak-db-credentials kubernetes.io/basic-auth \
|
||||
--from-file="username=${db_user_file}" \
|
||||
--from-file="password=${db_password_file}"
|
||||
create_secret_from_files \
|
||||
aistor minio-license Opaque \
|
||||
--from-file="minio.license=${license_file}"
|
||||
create_secret_from_files \
|
||||
object-storage aistor-root-configuration Opaque \
|
||||
--from-file="config.env=${root_config_file}"
|
||||
|
||||
printf 'Initial Phase 2 Secret contracts were created without printing payloads.\n'
|
||||
printf 'This script does not perform credential or license rotation.\n'
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PATH='/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
export PATH
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
umask 077
|
||||
|
||||
_k3slr_wrapper_initial_guard() {
|
||||
local effective_uid="${1-}" shell_options="${2-}"
|
||||
[[ "$effective_uid" =~ ^[0-9]+$ && "$effective_uid" != 0 ]] || return 1
|
||||
[[ "$shell_options" != *x* ]]
|
||||
}
|
||||
|
||||
if ! _k3slr_wrapper_initial_guard "${EUID:-}" "$-"; then
|
||||
printf 'ERROR: lifecycle wrapper refuses root or xtrace execution\n' >&2
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == */* ]]; then
|
||||
K3SLR_WRAPPER_DIRECTORY="${BASH_SOURCE[0]%/*}"
|
||||
else
|
||||
K3SLR_WRAPPER_DIRECTORY='.'
|
||||
fi
|
||||
K3SLR_WRAPPER_ROOT="$(cd -- "${K3SLR_WRAPPER_DIRECTORY}/../.." && pwd -P)" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
K3SLR_WRAPPER_CONTRACT="${K3SLR_WRAPPER_ROOT}/infrastructure/security/k3s/local-recovery.env"
|
||||
# shellcheck source=/dev/null
|
||||
source "${K3SLR_WRAPPER_ROOT}/scripts/lib/k3s-local-recovery.sh" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
|
||||
_k3slr_open_usage() {
|
||||
printf 'Usage: bash scripts/bootstrap/open-k3s-local-recovery.sh [--execute]\n' >&2
|
||||
}
|
||||
|
||||
_k3slr_open_main() {
|
||||
local execution_mode
|
||||
if ! _k3slr_parse_lifecycle_cli execution_mode "$@"; then
|
||||
_k3slr_open_usage
|
||||
return 2
|
||||
fi
|
||||
_k3slr_lifecycle_main open "$execution_mode"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
_k3slr_open_main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PATH='/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
export PATH
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
umask 077
|
||||
|
||||
_k3slr_wrapper_initial_guard() {
|
||||
local effective_uid="${1-}" shell_options="${2-}"
|
||||
[[ "$effective_uid" =~ ^[0-9]+$ && "$effective_uid" != 0 ]] || return 1
|
||||
[[ "$shell_options" != *x* ]]
|
||||
}
|
||||
|
||||
if ! _k3slr_wrapper_initial_guard "${EUID:-}" "$-"; then
|
||||
printf 'ERROR: lifecycle wrapper refuses root or xtrace execution\n' >&2
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
unset -f _k3slr_prove_header_restore 2>/dev/null || :
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == */* ]]; then
|
||||
K3SLR_WRAPPER_DIRECTORY="${BASH_SOURCE[0]%/*}"
|
||||
else
|
||||
K3SLR_WRAPPER_DIRECTORY='.'
|
||||
fi
|
||||
K3SLR_WRAPPER_ROOT="$(cd -- "${K3SLR_WRAPPER_DIRECTORY}/../.." && pwd -P)" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
K3SLR_WRAPPER_CONTRACT="${K3SLR_WRAPPER_ROOT}/infrastructure/security/k3s/local-recovery.env"
|
||||
# shellcheck source=/dev/null
|
||||
source "${K3SLR_WRAPPER_ROOT}/scripts/lib/k3s-local-recovery.sh" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
|
||||
_k3slr_prepare_usage() {
|
||||
printf 'Usage: bash scripts/bootstrap/prepare-k3s-local-recovery.sh [--execute]\n' >&2
|
||||
}
|
||||
|
||||
_k3slr_prepare_main() {
|
||||
local execution_mode
|
||||
if ! _k3slr_parse_lifecycle_cli execution_mode "$@"; then
|
||||
_k3slr_prepare_usage
|
||||
return 2
|
||||
fi
|
||||
_k3slr_lifecycle_main prepare "$execution_mode"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
_k3slr_prepare_main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,564 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Intentionally sourceable. Privileged filesystem work crosses exactly one
|
||||
# helper seam and runs descriptor-relative inside one process.
|
||||
|
||||
_olp_error() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
_olp_target_rows() {
|
||||
local base="$1"
|
||||
printf '%s\t%s\t%s\n' \
|
||||
"$base/observability/prometheus" 1000 2000 \
|
||||
"$base/observability/grafana" 472 472 \
|
||||
"$base/observability/alertmanager" 1000 2000 \
|
||||
"$base/observability/alloy" 473 473 \
|
||||
"$base/observability/loki" 10001 10001 \
|
||||
"$base/observability/tempo" 10001 10001
|
||||
}
|
||||
|
||||
_olp_helper_program() {
|
||||
/usr/bin/cat <<'PY'
|
||||
import errno
|
||||
import os
|
||||
import signal
|
||||
import stat
|
||||
import sys
|
||||
|
||||
TARGETS = (
|
||||
("prometheus", 1000, 2000),
|
||||
("grafana", 472, 472),
|
||||
("alertmanager", 1000, 2000),
|
||||
("alloy", 473, 473),
|
||||
("loki", 10001, 10001),
|
||||
("tempo", 10001, 10001),
|
||||
)
|
||||
MIN_AVAILABLE = 50 * 1024 * 1024 * 1024
|
||||
MAX_USE = 1024 * 1024 * 1024
|
||||
MAX_INTEGER = (1 << 63) - 1
|
||||
OPEN_DIR = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW
|
||||
SIGNALS = (signal.SIGHUP, signal.SIGINT, signal.SIGTERM)
|
||||
|
||||
|
||||
class Rejected(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Interrupted(Exception):
|
||||
def __init__(self, signum):
|
||||
self.signum = signum
|
||||
|
||||
|
||||
def reject(message):
|
||||
raise Rejected(message)
|
||||
|
||||
|
||||
if len(sys.argv) != 5 or sys.argv[1] not in {"preflight", "apply"}:
|
||||
print("REJECT: helper argv differs", file=sys.stderr)
|
||||
raise SystemExit(97)
|
||||
|
||||
action, root_path, ssd_path, aistor_path = sys.argv[1:]
|
||||
|
||||
|
||||
def normalized_absolute(path):
|
||||
return os.path.isabs(path) and os.path.normpath(path) == path and "//" not in path
|
||||
|
||||
|
||||
for candidate in (root_path, ssd_path, aistor_path):
|
||||
if not normalized_absolute(candidate):
|
||||
reject("path is not normalized absolute")
|
||||
|
||||
fixture_root = os.environ.get("OLP_HELPER_FIXTURE_ROOT", "")
|
||||
fixture = bool(fixture_root) and os.geteuid() != 0
|
||||
if fixture:
|
||||
if not normalized_absolute(fixture_root):
|
||||
reject("fixture root is invalid")
|
||||
for candidate in (root_path, ssd_path, aistor_path):
|
||||
if os.path.commonpath((fixture_root, candidate)) != fixture_root:
|
||||
reject("fixture path escaped fixture root")
|
||||
elif (root_path, ssd_path, aistor_path) != ("/", "/srv/k3s/ssd", "/srv/k3s/aistor"):
|
||||
reject("production helper paths differ from exact contract")
|
||||
|
||||
|
||||
def fixture_value(name, default=""):
|
||||
return os.environ.get(name, default) if fixture else default
|
||||
|
||||
|
||||
audit_path = fixture_value("OLP_HELPER_FIXTURE_AUDIT")
|
||||
if audit_path and os.path.commonpath((fixture_root, audit_path)) != fixture_root:
|
||||
reject("fixture audit escaped fixture root")
|
||||
|
||||
|
||||
def audit(message):
|
||||
if audit_path:
|
||||
with open(audit_path, "a", encoding="utf-8") as stream:
|
||||
stream.write(message + "\n")
|
||||
|
||||
|
||||
def open_physical_absolute(path):
|
||||
descriptor = os.open("/", OPEN_DIR)
|
||||
try:
|
||||
for component in path.split("/")[1:]:
|
||||
if not component:
|
||||
continue
|
||||
child = os.open(component, OPEN_DIR, dir_fd=descriptor)
|
||||
os.close(descriptor)
|
||||
descriptor = child
|
||||
return descriptor
|
||||
except BaseException:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
|
||||
def open_child(parent, name, missing_ok=False):
|
||||
try:
|
||||
return os.open(name, OPEN_DIR, dir_fd=parent)
|
||||
except FileNotFoundError:
|
||||
if missing_ok:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def identity(descriptor):
|
||||
metadata = os.fstat(descriptor)
|
||||
return metadata.st_dev, metadata.st_ino
|
||||
|
||||
|
||||
def entry_identity(parent, name):
|
||||
metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
if not stat.S_ISDIR(metadata.st_mode):
|
||||
reject("entry is not a physical directory")
|
||||
return metadata.st_dev, metadata.st_ino
|
||||
|
||||
|
||||
def directory_use(descriptor):
|
||||
total = os.fstat(descriptor).st_blocks * 512
|
||||
for name in os.listdir(descriptor):
|
||||
metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
|
||||
if stat.S_ISLNK(metadata.st_mode):
|
||||
reject("symbolic link below target is forbidden")
|
||||
if stat.S_ISDIR(metadata.st_mode):
|
||||
child = os.open(name, OPEN_DIR, dir_fd=descriptor)
|
||||
try:
|
||||
total += directory_use(child)
|
||||
finally:
|
||||
os.close(child)
|
||||
else:
|
||||
total += metadata.st_blocks * 512
|
||||
if total > MAX_USE:
|
||||
return total
|
||||
return total
|
||||
|
||||
|
||||
def filesystem_snapshot(root_fd, ssd_fd, aistor_fd, obs_fd):
|
||||
root_dev = os.fstat(root_fd).st_dev
|
||||
if os.fstat(ssd_fd).st_dev != root_dev:
|
||||
reject("SSD base is not on root filesystem")
|
||||
aistor_dev = os.fstat(aistor_fd).st_dev
|
||||
if fixture_value("OLP_HELPER_FIXTURE_VIRTUAL_AISTOR") == "1":
|
||||
aistor_dev = root_dev + 1
|
||||
if aistor_dev == root_dev:
|
||||
reject("AIStor must use another filesystem")
|
||||
fs = os.fstatvfs(root_fd)
|
||||
available = fs.f_bavail * fs.f_frsize
|
||||
if available < 0 or available > MAX_INTEGER or available < MIN_AVAILABLE:
|
||||
reject("root available bytes fail boundary")
|
||||
uses = []
|
||||
if obs_fd is None:
|
||||
uses = [0] * len(TARGETS)
|
||||
else:
|
||||
for name, _, _ in TARGETS:
|
||||
target_fd = open_child(obs_fd, name, True)
|
||||
if target_fd is None:
|
||||
uses.append(0)
|
||||
continue
|
||||
try:
|
||||
value = directory_use(target_fd)
|
||||
if value < 0 or value > MAX_USE:
|
||||
reject("individual target use exceeds 1GiB")
|
||||
uses.append(value)
|
||||
if os.listdir(target_fd):
|
||||
reject("existing target is non-empty")
|
||||
finally:
|
||||
os.close(target_fd)
|
||||
total = 0
|
||||
for value in uses:
|
||||
if value < 0 or value > MAX_USE:
|
||||
reject("individual target use exceeds 1GiB")
|
||||
total += value
|
||||
if total > MAX_USE:
|
||||
reject("aggregate target use exceeds 1GiB")
|
||||
return available, uses
|
||||
|
||||
|
||||
root_fd = ssd_fd = aistor_fd = obs_fd = None
|
||||
created = []
|
||||
restored = []
|
||||
active_created = None
|
||||
|
||||
|
||||
def close_all():
|
||||
seen = set()
|
||||
values = [record[2] for record in created]
|
||||
values.extend(record[1] for record in restored)
|
||||
values.extend((obs_fd, aistor_fd, ssd_fd, root_fd))
|
||||
for descriptor in values:
|
||||
if descriptor is None or descriptor in seen:
|
||||
continue
|
||||
seen.add(descriptor)
|
||||
try:
|
||||
os.close(descriptor)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def rollback():
|
||||
global active_created
|
||||
failed = False
|
||||
if active_created is not None:
|
||||
parent_fd, name, expected = active_created
|
||||
try:
|
||||
if entry_identity(parent_fd, name) == expected:
|
||||
descriptor = open_child(parent_fd, name)
|
||||
try:
|
||||
if os.listdir(descriptor):
|
||||
failed = True
|
||||
else:
|
||||
os.rmdir(name, dir_fd=parent_fd)
|
||||
audit(f"remove-active-created {name}")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
else:
|
||||
failed = True
|
||||
except OSError:
|
||||
failed = True
|
||||
active_created = None
|
||||
for parent_fd, name, target_fd, expected in reversed(created):
|
||||
try:
|
||||
if entry_identity(parent_fd, name) != expected:
|
||||
failed = True
|
||||
continue
|
||||
if os.listdir(target_fd):
|
||||
failed = True
|
||||
continue
|
||||
os.rmdir(name, dir_fd=parent_fd)
|
||||
audit(f"remove-created {name}")
|
||||
except OSError:
|
||||
failed = True
|
||||
for name, target_fd, uid, gid, mode, _expected in reversed(restored):
|
||||
try:
|
||||
os.fchown(target_fd, uid, gid)
|
||||
audit(f"restore-chown {name} {uid} {gid}")
|
||||
os.fchmod(target_fd, mode)
|
||||
audit(f"restore-chmod {name} {mode:04o}")
|
||||
except OSError:
|
||||
failed = True
|
||||
return not failed
|
||||
|
||||
|
||||
def signal_handler(signum, _frame):
|
||||
raise Interrupted(signum)
|
||||
|
||||
|
||||
def block_signals():
|
||||
return signal.pthread_sigmask(signal.SIG_BLOCK, SIGNALS)
|
||||
|
||||
|
||||
def restore_signal_mask(previous):
|
||||
signal.pthread_sigmask(signal.SIG_SETMASK, previous)
|
||||
|
||||
|
||||
def requested_owner(uid, gid):
|
||||
if fixture_value("OLP_HELPER_FIXTURE_OWNERS") == "1":
|
||||
return os.getuid(), os.getgid()
|
||||
return uid, gid
|
||||
|
||||
|
||||
def maybe_fixture_race(kind, name=""):
|
||||
race = fixture_value("OLP_HELPER_FIXTURE_RACE")
|
||||
external = fixture_value("OLP_HELPER_FIXTURE_EXTERNAL")
|
||||
if not race:
|
||||
return
|
||||
if not external or os.path.commonpath((fixture_root, external)) != fixture_root:
|
||||
reject("fixture race external path is invalid")
|
||||
if kind == "ancestor" and race == "ancestor":
|
||||
os.rename("observability", "observability.pinned", src_dir_fd=ssd_fd, dst_dir_fd=ssd_fd)
|
||||
os.symlink(external, "observability", dir_fd=ssd_fd)
|
||||
audit("fixture-race ancestor")
|
||||
elif kind == "target" and race == f"target:{name}":
|
||||
os.rename(name, f"{name}.pinned", src_dir_fd=obs_fd, dst_dir_fd=obs_fd)
|
||||
os.symlink(external, name, dir_fd=obs_fd)
|
||||
audit(f"fixture-race target {name}")
|
||||
|
||||
|
||||
def maybe_fixture_signal(name):
|
||||
configured = fixture_value("OLP_HELPER_FIXTURE_SIGNAL")
|
||||
target = fixture_value("OLP_HELPER_FIXTURE_SIGNAL_TARGET")
|
||||
if configured and target == name:
|
||||
signum = {"HUP": signal.SIGHUP, "INT": signal.SIGINT, "TERM": signal.SIGTERM}.get(configured)
|
||||
if signum is None:
|
||||
reject("fixture signal is invalid")
|
||||
os.kill(os.getpid(), signum)
|
||||
|
||||
|
||||
exit_status = 0
|
||||
try:
|
||||
root_fd = open_physical_absolute(root_path)
|
||||
ssd_fd = open_physical_absolute(ssd_path)
|
||||
aistor_fd = open_physical_absolute(aistor_path)
|
||||
obs_fd = open_child(ssd_fd, "observability", True)
|
||||
available, uses = filesystem_snapshot(root_fd, ssd_fd, aistor_fd, obs_fd)
|
||||
if action == "preflight":
|
||||
print("\t".join(["PREFLIGHT", str(available), *(str(value) for value in uses)]))
|
||||
else:
|
||||
for signum in SIGNALS:
|
||||
signal.signal(signum, signal_handler)
|
||||
if obs_fd is None:
|
||||
previous = block_signals()
|
||||
try:
|
||||
os.mkdir("observability", 0o770, dir_fd=ssd_fd)
|
||||
active_created = (ssd_fd, "observability", entry_identity(ssd_fd, "observability"))
|
||||
obs_fd = open_child(ssd_fd, "observability")
|
||||
if identity(obs_fd) != active_created[2]:
|
||||
reject("new observability ancestor identity changed before open")
|
||||
created.append((ssd_fd, "observability", obs_fd, identity(obs_fd)))
|
||||
active_created = None
|
||||
finally:
|
||||
restore_signal_mask(previous)
|
||||
obs_expected = identity(obs_fd)
|
||||
maybe_fixture_race("ancestor")
|
||||
for name, requested_uid, requested_gid in TARGETS:
|
||||
target_fd = open_child(obs_fd, name, True)
|
||||
if target_fd is None:
|
||||
previous = block_signals()
|
||||
try:
|
||||
os.mkdir(name, 0o770, dir_fd=obs_fd)
|
||||
active_created = (obs_fd, name, entry_identity(obs_fd, name))
|
||||
target_fd = open_child(obs_fd, name)
|
||||
if identity(target_fd) != active_created[2]:
|
||||
reject("new target identity changed before open")
|
||||
created.append((obs_fd, name, target_fd, identity(target_fd)))
|
||||
active_created = None
|
||||
actual_uid, actual_gid = requested_owner(requested_uid, requested_gid)
|
||||
os.fchown(target_fd, actual_uid, actual_gid)
|
||||
os.fchmod(target_fd, 0o770)
|
||||
audit(f"set-owner-mode {name} {requested_uid} {requested_gid} 0770")
|
||||
finally:
|
||||
restore_signal_mask(previous)
|
||||
maybe_fixture_signal(name)
|
||||
else:
|
||||
metadata = os.fstat(target_fd)
|
||||
if os.listdir(target_fd):
|
||||
reject("existing target is non-empty")
|
||||
restored.append((
|
||||
name,
|
||||
target_fd,
|
||||
metadata.st_uid,
|
||||
metadata.st_gid,
|
||||
stat.S_IMODE(metadata.st_mode),
|
||||
identity(target_fd),
|
||||
))
|
||||
maybe_fixture_race("target", name)
|
||||
actual_uid, actual_gid = requested_owner(requested_uid, requested_gid)
|
||||
os.fchown(target_fd, actual_uid, actual_gid)
|
||||
os.fchmod(target_fd, 0o770)
|
||||
audit(f"set-owner-mode {name} {requested_uid} {requested_gid} 0770")
|
||||
metadata = os.fstat(target_fd)
|
||||
actual_uid, actual_gid = requested_owner(requested_uid, requested_gid)
|
||||
if (metadata.st_uid, metadata.st_gid, stat.S_IMODE(metadata.st_mode)) != (actual_uid, actual_gid, 0o770):
|
||||
reject("post-create owner or mode differs")
|
||||
for parent_fd, name, target_fd, expected in created:
|
||||
if entry_identity(parent_fd, name) != expected:
|
||||
reject("created entry identity changed")
|
||||
for name, _target_fd, _uid, _gid, _mode, expected in restored:
|
||||
if entry_identity(obs_fd, name) != expected:
|
||||
reject("pre-existing target entry identity changed")
|
||||
if entry_identity(ssd_fd, "observability") != obs_expected:
|
||||
reject("observability ancestor identity changed")
|
||||
print("APPLIED")
|
||||
except Interrupted as error:
|
||||
for signum in SIGNALS:
|
||||
signal.signal(signum, signal.SIG_IGN)
|
||||
rollback()
|
||||
print(f"REJECT: interrupted by signal {error.signum}", file=sys.stderr)
|
||||
exit_status = 128 + error.signum
|
||||
except (Rejected, OSError) as error:
|
||||
for signum in SIGNALS:
|
||||
signal.signal(signum, signal.SIG_IGN)
|
||||
rollback()
|
||||
message = str(error) if str(error) else "privileged helper failed"
|
||||
print(f"REJECT: {message}", file=sys.stderr)
|
||||
exit_status = 23
|
||||
finally:
|
||||
close_all()
|
||||
|
||||
raise SystemExit(exit_status)
|
||||
PY
|
||||
}
|
||||
|
||||
_olp_privileged_helper() {
|
||||
if (( $# != 4 )) || [[ "$1" != preflight && "$1" != apply ]]; then
|
||||
return 97
|
||||
fi
|
||||
[[ "$2" == / && "$3" == /srv/k3s/ssd && "$4" == /srv/k3s/aistor ]] || \
|
||||
return 97
|
||||
local program
|
||||
program="$(_olp_helper_program)" || return
|
||||
/usr/bin/printf '%s\n' "$program" | \
|
||||
/usr/bin/sudo -- /usr/bin/python3 - "$@"
|
||||
}
|
||||
|
||||
_olp_root_facts() {
|
||||
/usr/bin/findmnt --kernel --first-only --noheadings --output SOURCE,FSTYPE \
|
||||
--target "$1" | /usr/bin/awk 'NF == 2 { print $1 "\t" $2 }'
|
||||
}
|
||||
|
||||
_olp_available_bytes() {
|
||||
/usr/bin/df --block-size=1 --output=avail "$1" | /usr/bin/tail -n 1 | \
|
||||
/usr/bin/tr -d '[:space:]'
|
||||
}
|
||||
|
||||
_olp_current_context() {
|
||||
local kubectl_bin
|
||||
kubectl_bin="$(command -v kubectl)" || return 1
|
||||
"$kubectl_bin" config current-context
|
||||
}
|
||||
|
||||
_olp_node_names() {
|
||||
local kubectl_bin
|
||||
kubectl_bin="$(command -v kubectl)" || return 1
|
||||
"$kubectl_bin" get nodes \
|
||||
-o 'jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}'
|
||||
}
|
||||
|
||||
_olp_read_confirmation() {
|
||||
local value
|
||||
printf 'Type APPLY default to prepare the six Local PV paths: ' >&2
|
||||
IFS= read -r value </dev/tty || return 1
|
||||
printf '%s\n' "$value"
|
||||
}
|
||||
|
||||
_olp_is_normalized_absolute() {
|
||||
local path="$1"
|
||||
[[ "$path" == /* && "$path" != *'//' && "$path" != */. && \
|
||||
"$path" != */.. && "$path" != *'/./'* && "$path" != *'/../'* ]]
|
||||
}
|
||||
|
||||
_olp_is_canonical_decimal() {
|
||||
[[ "$1" =~ ^(0|[1-9][0-9]*)$ ]]
|
||||
}
|
||||
|
||||
_olp_decimal_le() {
|
||||
local left="$1" right="$2" LC_ALL=C
|
||||
(( ${#left} < ${#right} )) || \
|
||||
{ (( ${#left} == ${#right} )) && [[ "$left" == "$right" || "$left" < "$right" ]]; }
|
||||
}
|
||||
|
||||
_olp_validate_preflight_reply() {
|
||||
local reply="$1" tag available value total=0
|
||||
local -a fields=()
|
||||
[[ "$reply" != *$'\n'* && "$reply" != *$'\r'* ]] || return 1
|
||||
IFS=$'\t' read -r -a fields <<<"$reply"
|
||||
(( ${#fields[@]} == 8 )) || return 1
|
||||
tag="${fields[0]}"
|
||||
available="${fields[1]}"
|
||||
[[ "$tag" == PREFLIGHT ]] || return 1
|
||||
_olp_is_canonical_decimal "$available" || return 1
|
||||
_olp_decimal_le "$available" 9223372036854775807 || return 1
|
||||
_olp_decimal_le 53687091200 "$available" || return 1
|
||||
for value in "${fields[@]:2}"; do
|
||||
_olp_is_canonical_decimal "$value" || return 1
|
||||
_olp_decimal_le "$value" 9223372036854775807 || return 1
|
||||
_olp_decimal_le "$value" 1073741824 || return 1
|
||||
total=$((total + 10#$value))
|
||||
(( total <= 1073741824 )) || return 1
|
||||
done
|
||||
}
|
||||
|
||||
observability_local_paths_dry_run() {
|
||||
local root_path="$1" ssd_base="$2" aistor_base="$3"
|
||||
local facts device filesystem available target uid gid
|
||||
_olp_is_normalized_absolute "$root_path" || \
|
||||
_olp_error "root path is not normalized" || return
|
||||
_olp_is_normalized_absolute "$ssd_base" || \
|
||||
_olp_error "SSD base path is not normalized" || return
|
||||
_olp_is_normalized_absolute "$aistor_base" || \
|
||||
_olp_error "AIStor path is not normalized" || return
|
||||
facts="$(_olp_root_facts "$root_path")" || \
|
||||
_olp_error "could not read root SSD device/filesystem facts" || return
|
||||
IFS=$'\t' read -r device filesystem <<<"$facts"
|
||||
[[ "$device" =~ ^[A-Za-z0-9._/+:=-]+$ && \
|
||||
"$filesystem" =~ ^[A-Za-z0-9._+-]+$ ]] || \
|
||||
_olp_error "root SSD facts are not safely printable" || return
|
||||
available="$(_olp_available_bytes "$root_path")" || \
|
||||
_olp_error "could not read root SSD available bytes" || return
|
||||
_olp_is_canonical_decimal "$available" || \
|
||||
_olp_error "root SSD available bytes are invalid" || return
|
||||
_olp_decimal_le "$available" 9223372036854775807 || \
|
||||
_olp_error "root SSD available bytes are invalid" || return
|
||||
printf 'Root SSD device: %s\n' "$device"
|
||||
printf 'Root SSD filesystem: %s\n' "$filesystem"
|
||||
printf 'Root SSD available bytes: %s\n' "$available"
|
||||
while IFS=$'\t' read -r target uid gid; do
|
||||
printf 'Planned path: %s\n' "$target"
|
||||
done < <(_olp_target_rows "$ssd_base")
|
||||
}
|
||||
|
||||
_olp_execute_preflight() {
|
||||
local root_path="$1" ssd_base="$2" aistor_base="$3"
|
||||
local reply context nodes
|
||||
reply="$(_olp_privileged_helper preflight "$root_path" "$ssd_base" "$aistor_base" \
|
||||
2>/dev/null)" || _olp_error "privileged filesystem preflight failed" || return
|
||||
_olp_validate_preflight_reply "$reply" || \
|
||||
_olp_error "privileged preflight protocol or capacity boundary failed" || return
|
||||
context="$(_olp_current_context)" || \
|
||||
_olp_error "could not read Kubernetes context" || return
|
||||
[[ "$context" == default ]] || \
|
||||
_olp_error "Kubernetes context must be exactly default" || return
|
||||
nodes="$(_olp_node_names)" || \
|
||||
_olp_error "could not read Kubernetes node names" || return
|
||||
[[ "$nodes" == donghyeon-system-product-name ]] || \
|
||||
_olp_error "Kubernetes node must be exactly donghyeon-system-product-name" || return
|
||||
}
|
||||
|
||||
observability_local_paths_execute() {
|
||||
local root_path="$1" ssd_base="$2" aistor_base="$3" confirmation reply
|
||||
_olp_execute_preflight "$root_path" "$ssd_base" "$aistor_base" || return
|
||||
confirmation="$(_olp_read_confirmation)" || \
|
||||
_olp_error "TTY confirmation was not read" || return
|
||||
[[ "$confirmation" == 'APPLY default' ]] || \
|
||||
_olp_error "confirmation did not match APPLY default" || return
|
||||
_olp_execute_preflight "$root_path" "$ssd_base" "$aistor_base" || return
|
||||
reply="$(_olp_privileged_helper apply "$root_path" "$ssd_base" "$aistor_base")" || \
|
||||
_olp_error "descriptor-relative path preparation failed" || return
|
||||
[[ "$reply" == APPLIED ]] || \
|
||||
_olp_error "privileged apply protocol differed" || return
|
||||
printf 'Prepared six observability Local PV paths on the root filesystem.\n'
|
||||
printf 'No Kubernetes resources were applied.\n'
|
||||
}
|
||||
|
||||
_olp_usage() {
|
||||
printf 'Usage: bash scripts/bootstrap/prepare-observability-local-paths.sh [--execute]\n' >&2
|
||||
}
|
||||
|
||||
_olp_main() {
|
||||
if (( $# == 0 )); then
|
||||
observability_local_paths_dry_run / /srv/k3s/ssd /srv/k3s/aistor
|
||||
elif (( $# == 1 )) && [[ "$1" == --execute ]]; then
|
||||
observability_local_paths_execute / /srv/k3s/ssd /srv/k3s/aistor
|
||||
else
|
||||
_olp_usage
|
||||
return 2
|
||||
fi
|
||||
}
|
||||
|
||||
_olp_entry() (
|
||||
set -Eeuo pipefail
|
||||
_olp_main "$@"
|
||||
)
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
_olp_entry "$@"
|
||||
fi
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly SSD_BASE_PATH="/srv/k3s/ssd"
|
||||
readonly POSTGRES_PATH="${SSD_BASE_PATH}/platform-postgres"
|
||||
readonly GITEA_PATH="${SSD_BASE_PATH}/gitea"
|
||||
readonly DECLARED_CAPACITY_BYTES=$((70 * 1024 * 1024 * 1024))
|
||||
readonly SUDO_BIN="/usr/bin/sudo"
|
||||
readonly FINDMNT_BIN="/usr/bin/findmnt"
|
||||
readonly INSTALL_BIN="/usr/bin/install"
|
||||
readonly TEST_BIN="/usr/bin/test"
|
||||
|
||||
fail() {
|
||||
printf 'ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mount_source_for() {
|
||||
# The Local PV directories are intentionally root:root 0750. An unprivileged
|
||||
# findmnt cannot canonicalize a child below /srv/k3s/ssd after that directory
|
||||
# has been created, so perform the mount lookup with the same privilege that
|
||||
# creates and owns the paths.
|
||||
"$SUDO_BIN" -- "$FINDMNT_BIN" --kernel --first-only \
|
||||
--noheadings --output SOURCE --target "$1"
|
||||
}
|
||||
|
||||
mount_target_for() {
|
||||
"$SUDO_BIN" -- "$FINDMNT_BIN" --kernel --first-only \
|
||||
--noheadings --output TARGET --target "$1"
|
||||
}
|
||||
|
||||
sudo_test() {
|
||||
"$SUDO_BIN" -- "$TEST_BIN" "$@"
|
||||
}
|
||||
|
||||
validate_existing_path_kind() {
|
||||
local target="$1"
|
||||
|
||||
if sudo_test -L "$target"; then
|
||||
fail "refusing symbolic-link target: ${target}"
|
||||
fi
|
||||
|
||||
if sudo_test -e "$target"; then
|
||||
sudo_test -d "$target" || \
|
||||
fail "target exists but is not a directory: ${target}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_plain_directory() {
|
||||
local target="$1"
|
||||
|
||||
if validate_existing_path_kind "$target"; then
|
||||
printf 'Already present; left ownership and mode unchanged: %s\n' "$target"
|
||||
return 0
|
||||
fi
|
||||
|
||||
"$SUDO_BIN" -- "$INSTALL_BIN" -d -o root -g root -m 0750 -- "$target"
|
||||
validate_existing_path_kind "$target" || \
|
||||
fail "post-create target is missing: ${target}"
|
||||
printf 'Created %s\n' "$target"
|
||||
}
|
||||
|
||||
for required_binary in "$SUDO_BIN" "$FINDMNT_BIN" "$INSTALL_BIN" "$TEST_BIN"; do
|
||||
[[ -x "$required_binary" ]] || \
|
||||
fail "required executable is missing: ${required_binary}"
|
||||
done
|
||||
for command_name in df tail tr; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || fail "${command_name} is required"
|
||||
done
|
||||
[[ -d /srv ]] || fail "/srv does not exist"
|
||||
|
||||
# Authenticate before command substitutions call sudo so a failed or cancelled
|
||||
# prompt cannot be confused with an empty mount-source result.
|
||||
printf 'Validating sudo access for SSD path checks...\n'
|
||||
"$SUDO_BIN" -v || fail "sudo authentication failed"
|
||||
|
||||
readonly ROOT_SOURCE="$(mount_source_for /)"
|
||||
readonly SRV_SOURCE="$(mount_source_for /srv)"
|
||||
readonly SRV_MOUNT_TARGET="$(mount_target_for /srv)"
|
||||
|
||||
[[ -n "$ROOT_SOURCE" ]] || fail "could not identify the root filesystem source"
|
||||
[[ "$SRV_SOURCE" == "$ROOT_SOURCE" ]] || fail "/srv is not on the root filesystem (${SRV_SOURCE} != ${ROOT_SOURCE})"
|
||||
[[ "$SRV_MOUNT_TARGET" == "/" ]] || fail "/srv is covered by a separate mount (${SRV_MOUNT_TARGET})"
|
||||
|
||||
for target in "$SSD_BASE_PATH" "$POSTGRES_PATH" "$GITEA_PATH"; do
|
||||
if validate_existing_path_kind "$target"; then
|
||||
[[ "$(mount_source_for "$target")" == "$ROOT_SOURCE" ]] || fail "${target} is not on the root filesystem"
|
||||
[[ "$(mount_target_for "$target")" == "/" ]] || fail "${target} is covered by a separate mount"
|
||||
fi
|
||||
done
|
||||
|
||||
readonly ROOT_AVAILABLE_BYTES="$(df --block-size=1 --output=avail / | tail -n 1 | tr -d '[:space:]')"
|
||||
if [[ "$ROOT_AVAILABLE_BYTES" =~ ^[0-9]+$ ]] && (( ROOT_AVAILABLE_BYTES < DECLARED_CAPACITY_BYTES )); then
|
||||
printf 'WARNING: root filesystem has less than 70 GiB free; Local PV capacity is not a quota.\n' >&2
|
||||
fi
|
||||
|
||||
ensure_plain_directory "$SSD_BASE_PATH"
|
||||
ensure_plain_directory "$POSTGRES_PATH"
|
||||
ensure_plain_directory "$GITEA_PATH"
|
||||
|
||||
for target in "$SSD_BASE_PATH" "$POSTGRES_PATH" "$GITEA_PATH"; do
|
||||
validate_existing_path_kind "$target" || fail "post-create target is missing: ${target}"
|
||||
[[ "$(mount_source_for "$target")" == "$ROOT_SOURCE" ]] || fail "post-create filesystem check failed for ${target}"
|
||||
[[ "$(mount_target_for "$target")" == "/" ]] || fail "post-create mount check failed for ${target}"
|
||||
done
|
||||
|
||||
printf 'SSD Local PV paths are ready on root source %s.\n' "$ROOT_SOURCE"
|
||||
printf 'No Kubernetes resources were applied.\n'
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
PATH='/usr/sbin:/usr/bin:/sbin:/bin'
|
||||
export PATH
|
||||
LC_ALL=C
|
||||
export LC_ALL
|
||||
umask 077
|
||||
|
||||
_k3slrhp_inherited_namespace_is_clean() {
|
||||
if compgen -A function _k3slrh_ >/dev/null; then return 1; fi
|
||||
if compgen -A variable K3SLRH_ >/dev/null; then return 1; fi
|
||||
if compgen -A variable _K3SLRH_ >/dev/null; then return 1; fi
|
||||
if compgen -A variable K3SLR_ >/dev/null; then return 1; fi
|
||||
}
|
||||
|
||||
if ! _k3slrhp_inherited_namespace_is_clean; then
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
_k3slrh_wrapper_initial_guard() {
|
||||
local effective_uid="${1-}" shell_options="${2-}"
|
||||
(( $# == 2 )) || return 1
|
||||
[[ "$effective_uid" =~ ^[0-9]+$ && "$effective_uid" != 0 ]] || return 1
|
||||
[[ "$shell_options" != *x* ]]
|
||||
}
|
||||
|
||||
if ! _k3slrh_wrapper_initial_guard "${EUID:-}" "$-"; then
|
||||
return 1 2>/dev/null || exit 1
|
||||
fi
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == */* ]]; then
|
||||
K3SLRH_WRAPPER_DIRECTORY="${BASH_SOURCE[0]%/*}"
|
||||
else
|
||||
K3SLRH_WRAPPER_DIRECTORY='.'
|
||||
fi
|
||||
K3SLRH_REPOSITORY_ROOT="$(cd -- "${K3SLRH_WRAPPER_DIRECTORY}/../.." && pwd -P)" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
K3SLRH_CONTRACT_PATH="${K3SLRH_REPOSITORY_ROOT}/infrastructure/security/k3s/local-recovery.env"
|
||||
readonly K3SLRH_WRAPPER_DIRECTORY K3SLRH_REPOSITORY_ROOT K3SLRH_CONTRACT_PATH
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
builtin source "${K3SLRH_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery.sh" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
# shellcheck source=/dev/null
|
||||
builtin source "${K3SLRH_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery-a1.sh" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
# shellcheck source=/dev/null
|
||||
builtin source "${K3SLRH_REPOSITORY_ROOT}/scripts/lib/k3s-local-recovery-header-proof.sh" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
|
||||
_k3slr_load_contract "$K3SLRH_CONTRACT_PATH" || {
|
||||
return 1 2>/dev/null || exit 1
|
||||
}
|
||||
readonly K3SLR_SCHEMA_VERSION K3SLR_RECOVERY_DISK_BY_ID K3SLR_RECOVERY_PARTITION_BY_ID
|
||||
readonly K3SLR_RECOVERY_FS_UUID K3SLR_RECOVERY_PARTUUID K3SLR_RECOVERY_MODEL
|
||||
readonly K3SLR_RECOVERY_SERIAL K3SLR_RECOVERY_WWN K3SLR_K3S_DISK_BY_ID
|
||||
readonly K3SLR_K3S_PARTITION_BY_ID K3SLR_K3S_FS_UUID K3SLR_K3S_PARTUUID
|
||||
readonly K3SLR_K3S_MODEL K3SLR_K3S_SERIAL K3SLR_K3S_WWN K3SLR_OWNER_UID
|
||||
readonly K3SLR_OWNER_GID K3SLR_OUTER_MOUNT K3SLR_INNER_MOUNT K3SLR_ROOT_RELATIVE
|
||||
readonly K3SLR_DATABASE_RELATIVE K3SLR_CONTAINER_RELATIVE K3SLR_RUNTIME_METADATA_RELATIVE
|
||||
readonly K3SLR_MAPPING_NAME K3SLR_PROOF_MAPPING_NAME K3SLR_INNER_LABEL
|
||||
readonly K3SLR_CONTAINER_SIZE_BYTES K3SLR_MINIMUM_FREE_BYTES K3SLR_OUTER_MIN_REMAINING_PERCENT
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
_k3slrh_header_proof_main "$@"
|
||||
fi
|
||||
Reference in New Issue
Block a user