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