Files

196 lines
7.4 KiB
Bash
Executable File

#!/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'