Add platform infrastructure configuration

This commit is contained in:
donghyeon-ka
2026-08-28 17:35:41 +09:00
parent fa76531e5b
commit 16c337bcc9
302 changed files with 83259 additions and 1 deletions
+266
View File
@@ -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'
+470
View File
@@ -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.'
+673
View File
@@ -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'
+332
View File
@@ -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"
+351
View File
@@ -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
View File
@@ -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'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+407
View File
@@ -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
+195
View File
@@ -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'
+287
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'
File diff suppressed because it is too large Load Diff
+324
View File
@@ -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'
+298
View File
@@ -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'
File diff suppressed because it is too large Load Diff
+216
View File
@@ -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'
+323
View File
@@ -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
+112
View File
@@ -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