#!/usr/bin/env bash # ============================================================================= # eBdesk SiCABE — Installer # ============================================================================= # Sesuai urutan deployment berdasarkan Dependency Graph (bab 3.1 & 12.2): # Phase 1: Governance (IAM) → Keycloak # Phase 2: Storage → MinIO → Nessie # Phase 3: Processing → Trino # Phase 4: Serving → StarRocks # Phase 5: Ingestion → Airflow # Phase 6: Governance (Lan) → OpenMetadata → Grafana # # Usage: # ./install.sh # Deploy semua phase # ./install.sh --phase 1 # Deploy hanya phase tertentu # ./install.sh --phase 1-3 # Deploy phase 1 sampai 3 # ./install.sh --status # Cek status semua komponen # ./install.sh --destroy # Hentikan dan hapus semua container # ./install.sh --help # Tampilkan bantuan # ============================================================================= set -euo pipefail # ─── Base Directory ───────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${SCRIPT_DIR}" # ─── Shared Library ────────────────────────────────────────────────────────── source "${SCRIPT_DIR}/lib/common.sh" # ─── Color & Styling (overrides untuk install.sh) ──────────────────────────── STEP="${BOLD}[${NC}" # ─── Generate .env jika belum ada ────────────────────────────────────────── # KECUALI mode multi-host (--host): rahasia untuk host itu sudah ada di # generated/host-/.env. Membuat .env acak di server ini BERBAHAYA karena rahasianya # akan BEDA dari host lain (client secret OIDC, password DB, service account), dan # berkas itu akan dipakai diam-diam oleh pemanggilan TANPA --host (bootstrap.sh, # test-verification.sh, gate fase) sehingga muncul kegagalan login yang sulit dilacak. HOST_MODE_ARG=0 ENV_WAS_CREATED=0 for _arg in "$@"; do [[ "${_arg}" == "--host" ]] && HOST_MODE_ARG=1 done if [[ ${HOST_MODE_ARG} -eq 1 && ! -f "${SCRIPT_DIR}/.env" ]]; then echo -e "${INFO} Mode --host: .env lokal TIDAK dibuat (memakai generated/host-/.env)" fi if [[ ${HOST_MODE_ARG} -eq 0 && ! -f "${SCRIPT_DIR}/.env" ]]; then echo -e "${INFO} Membuat .env dengan password acak..." echo -e "${WARN} Password tersimpan di .env — JANGAN commit file ini ke git!" # PERHATIAN: Password PostgreSQL hanya diproses SAAT PERTAMA container dibuat. # Jika sudah pernah deploy lalu mengubah password di .env, jalankan: # docker compose down -v && docker volume rm sicabe_postgres-data # untuk menghapus volume lama. cat > "${SCRIPT_DIR}/.env" <] # Cek status container (--host: pakai bundle host)" echo " $0 --logs [service] # Lihat logs service (atau semua)" echo " $0 --destroy # Hentikan dan hapus semua container" echo " $0 --reset-ldap # Reset volume OpenLDAP (seed ulang user/grup)" echo " $0 --restart [service] # Restart service tertentu" echo "" echo -e "${BOLD}Multi-host (Fase 1):${NC}" echo " $0 --generate [--inventory F] [--blueprint a3|b5]" echo " # Hasilkan generated/host-/ dari cluster.yml" echo " $0 --host [--dry-run] # Jalankan compose untuk satu host (di host itu!)" echo " $0 --preflight [--inventory F]" echo " # Cek keterjangkauan lintas host (tanpa deploy)" echo " $0 --sync [--host ] [--dest DIR] [--user U] [--delete] [--dry-run]" echo " # Salin deploy/ (termasuk .env) ke host lain (dari control)" echo " $0 --help # Tampilkan bantuan ini" echo "" echo -e "${BOLD}Phase:${NC}" echo " 1 = Keycloak (Governance: IAM)" echo " 2 = MinIO + Nessie (Storage)" echo " 3 = Trino (Processing)" echo " 4 = StarRocks (Serving)" echo " 5 = Airflow (Ingestion)" echo " 6 = OpenMetadata + Grafana (Governance: Metadata & Observability)" echo "" echo -e "${BOLD}Komponen existing (dikelola sistem lain):${NC}" echo " Arahkan endpoint di .env → container komponen itu TIDAK dinyalakan:" echo " KEYCLOAK_URL · LDAP_URL · S3_ENDPOINT · NESSIE_URL · TRINO_URL · OPA_URL" echo " Override eksplisit (mis. Postgres milik DBA):" echo " EXTERNAL_SERVICES=\"keycloak postgres\" LOCAL_SERVICES=\"minio\"" exit 0 } # ─── Fungsi Prasyarat ───────────────────────────────────────────────────────── check_prerequisites() { echo -e "${INFO} Memeriksa prasyarat..." # Docker if ! command -v docker &>/dev/null; then echo -e "${ERR} Docker tidak ditemukan. Install Docker terlebih dahulu." echo " https://docs.docker.com/engine/install/" exit 1 fi echo -e "${OK} Docker: $(docker --version)" # Docker Compose if command -v docker-compose &>/dev/null; then DOCKER_COMPOSE="docker-compose" elif docker compose version &>/dev/null; then DOCKER_COMPOSE="docker compose" else echo -e "${ERR} Docker Compose tidak ditemukan." exit 1 fi echo -e "${OK} Docker Compose: $(${DOCKER_COMPOSE} version 2>/dev/null || docker compose version)" # mc (MinIO client) — opsional untuk bootstrap if command -v mc &>/dev/null; then echo -e "${OK} MinIO Client (mc): tersedia" else echo -e "${WARN} MinIO Client (mc) tidak ditemukan. Beberapa fitur bootstrap mungkin terbatas." fi # OpenSSL if ! command -v openssl &>/dev/null; then echo -e "${ERR} OpenSSL tidak ditemukan. Install openssl." exit 1 fi # curl if ! command -v curl &>/dev/null; then echo -e "${ERR} curl tidak ditemukan." exit 1 fi echo -e "${OK} curl tersedia" # htpasswd (httpd-tools) — generate password.db service account Trino if ! command -v htpasswd &>/dev/null; then echo -e "${ERR} htpasswd tidak ditemukan (dibutuhkan untuk password service account Trino)." echo " Install: Debian/Ubuntu → sudo apt-get install -y apache2-utils" echo " RHEL/Fedora → sudo dnf install -y httpd-tools" echo " macOS → brew install httpd" exit 1 fi echo -e "${OK} htpasswd: tersedia" # python3 (untuk JSON parsing di bootstrap.sh & test-verification.sh) if ! command -v python3 &>/dev/null; then echo -e "${WARN} python3 tidak ditemukan. Beberapa fungsi bootstrap dan test mungkin terbatas." echo " Install python3 atau pastikan tersedia di PATH." else echo -e "${OK} python3: tersedia" fi # Pastikan direktori config ada for dir in config/keycloak config/trino/catalog config/starrocks config/grafana/datasources config/grafana/dashboards config/airflow config/nessie config/opa config/openldap; do if [[ ! -d "${SCRIPT_DIR}/${dir}" ]]; then echo -e "${WARN} Direktori ${dir} tidak ditemukan, membuat..." mkdir -p "${SCRIPT_DIR}/${dir}" fi done echo -e "${OK} Semua prasyarat terpenuhi." echo "" } # ─── Generate password.db service account Trino (bcrypt) ───────────────────── # Pra-syarat: htpasswd. Harus dijalankan SEBELUM Trino start (bind-mount file # harus ada; jika tidak, Docker membuat DIRECTORY di path tsb). generate_trino_password_db() { local pw_file="${SCRIPT_DIR}/config/trino/password.db" # Wajib FILE (bukan direktori): Docker membuat DIRECTORY bila sumber bind-mount # tidak ada saat `up`. `sicabe_ensure_file_mount` membuang sisa direktori & membuat file. sicabe_ensure_file_mount "${pw_file}" 644 local pair user pass for pair in \ "svc_seed:${TRINO_SVC_SEED_PASSWORD:?}" \ "svc_dbt:${TRINO_SVC_DBT_PASSWORD:?}" \ "svc_airflow:${TRINO_SVC_AIRFLOW_PASSWORD:?}" \ "svc_om:${TRINO_SVC_OM_PASSWORD:?}"; do user="${pair%%:*}"; pass="${pair#*:}" htpasswd -nbB -C 10 "${user}" "${pass}" | sed '/^$/d' >> "${pw_file}" done chmod 644 "${pw_file}" echo -e "${OK} password.db di-generate: $(cut -d: -f1 "${pw_file}" | paste -sd, -)" } # ─── Deteksi komponen EXISTING (dikelola sistem lain) ──────────────────────── # Prinsip: docker-compose.yml hanya untuk komponen yang MEMANG dijalankan di sini. # Komponen yang endpoint-nya sudah diarahkan ke luar tidak dinyalakan, sehingga # tidak ada container kembar / bentrok port dengan sistem yang sudah ada. # Lihat config/environment.defaults.sh untuk tabel variabelnya. # Nilai "lokal" (sesuai docker-compose.yml) untuk tiap komponen. local_endpoint_of() { case "$1" in keycloak) echo "http://localhost:${KEYCLOAK_PORT:-12080}" ;; openldap) echo "ldap://openldap:389" ;; minio) echo "http://minio:9000" ;; nessie) echo "http://nessie:19120" ;; trino) echo "https://localhost:${TRINO_HTTPS_PORT:-12443}" ;; opa) echo "http://localhost:${OPA_PORT:-12181}" ;; *) echo "" ;; esac } # Endpoint yang sedang dikonfigurasi (.env / defaults). configured_endpoint_of() { case "$1" in keycloak) echo "${KEYCLOAK_URL:-}" ;; openldap) echo "${LDAP_URL:-}" ;; minio) echo "${S3_ENDPOINT:-}" ;; nessie) echo "${NESSIE_URL:-}" ;; trino) echo "${TRINO_URL:-}" ;; opa) echo "${OPA_URL:-}" ;; *) echo "" ;; esac } # Daftar service yang dikelola EKSTERNAL (spasi-separated, unik). # Nama LOGIS -> nama service sebenarnya. # # Filter komponen eksternal mencocokkan NAMA SERVICE (`starrocks-fe`, `trino-worker`, # …). Tanpa pemetaan ini, menulis nama logis di EXTERNAL_SERVICES/LOCAL_SERVICES # diam-diam TIDAK berefek: `EXTERNAL_SERVICES=starrocks` tidak melewati apa pun, dan # `LOCAL_SERVICES=starrocks` tidak memaksa apa pun. Dulu hanya `trino` yang istimewa, # sehingga orang yang menulis `starrocks` merasa setelannya diabaikan. expand_service_names() { # ... local n out="" for n in "$@"; do case "${n}" in trino) out+=" trino-coordinator trino-worker " ;; starrocks) out+=" starrocks-fe starrocks-cn " ;; airflow) out+=" airflow-init airflow-webserver airflow-scheduler airflow-dag-processor " ;; openmetadata) out+=" openmetadata-server openmetadata-opensearch " ;; *) out+=" ${n} " ;; esac done printf '%s' "${out# }" } external_services() { local out=" $(expand_service_names ${EXTERNAL_SERVICES:-}) " svc cfg local_val for svc in keycloak openldap minio nessie trino opa; do cfg="$(configured_endpoint_of "${svc}")" local_val="$(local_endpoint_of "${svc}")" if [[ -n "${cfg}" && -n "${local_val}" && "${cfg}" != "${local_val}" ]]; then out+=" ${svc} " fi done # S3 eksternal ⇒ minio-init (pembuat bucket lokal) tidak berlaku. if [[ "${out}" == *" minio "* ]]; then out+=" minio-init " fi # Trino kini DUA service (coordinator + worker): nama logis `trino` dipetakan ke # keduanya agar deteksi eksternal benar-benar melewati container-nya. if [[ "${out}" == *" trino "* ]]; then out=" $(echo "${out}" | sed 's/ trino / /') " out+=" trino-coordinator trino-worker " fi # StarRocks: FE & CN dikelola sistem lain bila hostnya bukan localhost. if [[ -n "${STARROCKS_HOST:-}" && "${STARROCKS_HOST}" != "localhost" && "${STARROCKS_HOST}" != "127.0.0.1" ]]; then out+=" starrocks-fe starrocks-cn " fi # Airflow: seluruh service fase 5 dikelola sistem lain bila hostnya bukan localhost. if [[ -n "${AIRFLOW_HOST:-}" && "${AIRFLOW_HOST}" != "localhost" && "${AIRFLOW_HOST}" != "127.0.0.1" ]]; then out+=" airflow-init airflow-webserver airflow-dag-processor airflow-scheduler " fi # OpenMetadata / Grafana: dikelola sistem lain bila URL host-nya bukan localhost. if [[ -n "${OPENMETADATA_URL:-}" && "${OPENMETADATA_URL}" != "http://localhost:${OPENMETADATA_PORT:-12085}" ]]; then out+=" openmetadata-opensearch openmetadata-migrate openmetadata-server " fi if [[ -n "${GRAFANA_URL:-}" && "${GRAFANA_URL}" != "http://localhost:${GRAFANA_PORT:-12300}" ]]; then out+=" grafana " fi # LOCAL_SERVICES: paksa komponen tertentu tetap dijalankan lokal. # Nama logis (`trino`, `starrocks`, `airflow`, `openmetadata`) diuraikan oleh # expand_service_names() supaya cocok dengan nama service yang sebenarnya. local s for s in $(expand_service_names ${LOCAL_SERVICES:-}); do out=" $(echo "${out}" | sed "s/ ${s} / /g") " done # PENTING (regresi e099b95): JANGAN pakai `grep -v '^$'` di sini. Saat TIDAK ada # service eksternal, input hanya baris kosong → grep tidak menemukan baris cocok → # exit 1 → dengan `set -euo pipefail` assignment `EXT_SVCS="$(external_services)"` # MEMATIKAN install.sh tepat setelah banner (tanpa pesan error). `awk 'NF'` setara # (buang baris kosong) tetapi SELALU exit 0. echo "${out}" | tr ' ' '\n' | awk 'NF' | sort -u | paste -sd' ' - } is_external() { [[ " $(external_services) " == *" $1 "* ]] } # Service yang harus dinyalakan untuk sebuah fase: # kandidat dari profil compose DIKURANGI komponen eksternal. phase_services() { local phase="$1" svc out="" local ext=" $(external_services) " while read -r svc; do [[ -z "${svc}" ]] && continue if [[ "${ext}" == *" ${svc} "* ]]; then continue fi out+=" ${svc}" done < <(${DOCKER_COMPOSE} -f "${SCRIPT_DIR}/docker-compose.yml" --profile "phase${phase}" config --services 2>/dev/null) echo "${out# }" } # Naikkan container satu fase. # - Tanpa komponen eksternal: perilaku lama (`--profile phaseN up -d`) supaya # depends_on tetap mengatur urutan + menunggu healthcheck. # - Dengan komponen eksternal: hanya service lokal, `--no-deps` agar depends_on # tidak menyeret service yang dikelola sistem lain. Postgres (bila lokal) # dinaikkan lebih dulu supaya dependennya tidak menabrak DB yang belum siap. deploy_phase_compose() { local phase="$1" svcs svcs="$(phase_services "${phase}")" # Dir bind-mount yang absen di git (config/airflow/sling hanya env.yaml gitignored): # bila tidak ada, Docker (root) membuatnya sebagai ROOT → operator non-root gagal # menulis env.yaml lewat bootstrap. Pastikan ada & user-owned sebelum `up`. mkdir -p "${SCRIPT_DIR}/config/airflow/sling" if [[ -z "$(external_services)" ]]; then ${DOCKER_COMPOSE} -f "${SCRIPT_DIR}/docker-compose.yml" --profile "phase${phase}" up -d return 0 fi if [[ -z "${svcs}" ]]; then echo -e "${INFO} Semua komponen fase ${phase} dikelola eksternal — tidak ada container yang dinyalakan." return 0 fi echo -e "${INFO} Mode existing — container fase ${phase} yang dinyalakan: ${BOLD}${svcs}${NC}" if [[ " ${svcs} " == *" postgres "* ]]; then ${DOCKER_COMPOSE} -f "${SCRIPT_DIR}/docker-compose.yml" --profile all up -d --no-deps postgres wait_for_container "sicabe-postgres" 60 fi # shellcheck disable=SC2086 ${DOCKER_COMPOSE} -f "${SCRIPT_DIR}/docker-compose.yml" --profile all up -d --no-deps ${svcs} } # Tunggu komponen: bila eksternal, tunggu endpoint eksternalnya (bukan localhost). wait_component() { local svc="$1" local_url="$2" timeout="$3" ext_url="${4:-}" if is_external "${svc}"; then if [[ "${ext_url}" == http* ]]; then echo -e "${INFO} ${svc} dikelola eksternal — menunggu ${ext_url}" wait_for_service "${svc} (eksternal)" "${ext_url}" "${timeout}" else echo -e "${INFO} ${svc} dikelola eksternal — lewati penantian (diverifikasi gate fase)" fi return 0 fi wait_for_service "${svc}" "${local_url}" "${timeout}" } # ─── Fungsi Deploy per Phase ───────────────────────────────────────────────── deploy_phase() { local phase="$1" local compose_file="${SCRIPT_DIR}/docker-compose.yml" case "${phase}" in 1) echo -e "${STEP} Phase 1${NC} — Governance (IAM): Keycloak" deploy_phase_compose 1 wait_component "keycloak" "http://localhost:${KEYCLOAK_PORT:-12080}/health/ready" 120 \ "${KEYCLOAK_URL:-}/health/ready" if is_external openldap; then echo -e "${INFO} OpenLDAP dikelola eksternal (LDAP_URL=${LDAP_URL}) — lewati penantian container" else wait_for_container "sicabe-openldap" 60 fi echo -e "${OK} Phase 1 selesai — Keycloak ${KEYCLOAK_URL}, OpenLDAP ${LDAP_URL}" ;; 2) echo -e "${STEP} Phase 2${NC} — Storage: MinIO + Nessie" deploy_phase_compose 2 wait_component "minio" "http://localhost:${MINIO_PORT:-12900}/minio/health/live" 60 wait_component "nessie" "http://localhost:${NESSIE_PORT:-12120}/api/v2/config" 90 \ "http://${NESSIE_HOST:-localhost}:${NESSIE_PORT:-12120}/api/v2/config" echo -e "${OK} Phase 2 selesai — object storage ${S3_ENDPOINT}, Nessie ${NESSIE_URL}" ;; 3) echo -e "${STEP} Phase 3${NC} — Processing: Trino + OPA" if is_external trino; then echo -e "${INFO} Trino dikelola eksternal (${TRINO_URL}) — password.db lokal tidak dibuat/dipakai" else generate_trino_password_db fi deploy_phase_compose 3 wait_component "trino" "https://localhost:${TRINO_HTTPS_PORT:-12443}/v1/info" 90 \ "${TRINO_URL:-}/v1/info" echo -e "${OK} Phase 3 selesai — Trino ${TRINO_URL} (OAuth2 + Basic) · OPA ${OPA_URL}" ;; 4) echo -e "${STEP} Phase 4${NC} — Serving: StarRocks ${BOLD}${NC}" deploy_phase_compose 4 if is_external starrocks-fe; then echo -e "${INFO} StarRocks dikelola eksternal — lewati penantian start" else echo -e "${INFO} StarRocks FE membutuhkan waktu ~2 menit untuk start..." sleep 30 fi echo -e "${OK} Phase 4 selesai — StarRocks FE :${STARROCKS_FE_PORT:-12930}, CN (shared-data)" ;; 5) echo -e "${STEP} Phase 5${NC} — Ingestion: Airflow ${BOLD}${NC}" # SimpleAuthManager membuka file ini dengan mode a+ → wajib ADA & writable (666). # File ini TIDAK di-track git (lihat .gitignore) → generate dari .env di sini # agar Docker tidak membuat bind-mount file ini sebagai DIRECTORY (fresh clone). if is_external airflow-webserver; then echo -e "${INFO} Airflow dikelola eksternal — lewati pembuatan file auth lokal" else local pw_file="${SCRIPT_DIR}/config/airflow/simple_auth_manager_passwords.json" sicabe_ensure_file_mount "${pw_file}" 666 printf '{"admin": "%s"}\n' "${AIRFLOW_ADMIN_PASSWORD:?AIRFLOW_ADMIN_PASSWORD belum diset di .env}" > "${pw_file}" chmod 666 "${pw_file}" fi deploy_phase_compose 5 wait_component "airflow-webserver" "http://localhost:${AIRFLOW_PORT:-12082}/api/v2/monitor/health" 120 echo -e "${OK} Phase 5 selesai — Airflow siap di http://localhost:${AIRFLOW_PORT:-12082}" ;; 6) echo -e "${STEP} Phase 6${NC} — Governance (Lanjutan): OpenMetadata + Grafana ${BOLD}${NC}" deploy_phase_compose 6 wait_component "openmetadata-server" "http://localhost:${OPENMETADATA_PORT:-12085}/health" 180 wait_component "grafana" "http://localhost:${GRAFANA_PORT:-12300}/api/health" 60 echo -e "${OK} Phase 6 selesai — OpenMetadata :${OPENMETADATA_PORT:-12085}, Grafana :${GRAFANA_PORT:-12300}" ;; *) echo -e "${ERR} Phase tidak valid: ${phase}. Gunakan 1-6." exit 1 ;; esac } # ─── Status ────────────────────────────────────────────────────────────────── show_status() { # `--status [--host ]`: dengan --host memakai bundle host itu # (compose + .env hasil generate) sehingga endpoint menunjuk ALAMAT HOST, # bukan localhost (perbaikan bug multi-host). local st_host="${1:-}" local compose_file="${SCRIPT_DIR}/docker-compose.yml" if [[ -n "${st_host}" ]]; then compose_file="${SCRIPT_DIR}/generated/host-${st_host}/docker-compose.yml" local henv="${SCRIPT_DIR}/generated/host-${st_host}/.env" if [[ ! -f "${compose_file}" || ! -f "${henv}" ]]; then echo -e "${ERR} Host '${st_host}' belum digenerate (${compose_file})." echo " Jalankan dulu: ./install.sh --generate" return 1 fi set -a; source "${henv}"; set +a fi echo -e "${BOLD}${BLUE}═══════════════════════════════════════════════════════${NC}" echo -e "${BOLD} STATUS KOMPONEN eBdesk SiCABE${NC}" [[ -n "${st_host}" ]] && echo -e "${BOLD} (host: ${st_host})${NC}" echo -e "${BOLD}${BLUE}═══════════════════════════════════════════════════════${NC}" echo "" if ! ${DOCKER_COMPOSE} -f "${compose_file}" ps &>/dev/null; then echo -e "${INFO} Tidak ada container SiCABE yang berjalan." return fi echo -e "${BOLD}Container Status:${NC}" ${DOCKER_COMPOSE} -f "${compose_file}" ps --all 2>/dev/null || \ docker ps --filter "name=sicabe-" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" echo "" echo -e "${BOLD}Endpoint Access:${NC}" # Alamat dasar: alamat host (mode --host) atau localhost (single-host). local ea="localhost" [[ -n "${st_host}" ]] && ea="${SICABE_HOST_ADDRESS:-localhost}" # `*_PUBLIC_URL` (diisi generator) MENANG bila ada. Untuk layanan tanpa URL publik, # ambil HOST PEMILIK dari variabel endpoint bundle (mode --host), jatuh ke ea. local h_kc="$ea" h_minio="$ea" h_sr="$ea" h_afs="$ea" h_om="$ea" h_ldap="$ea" h_opa="$ea" h_nessie="$ea" if [[ -n "${st_host}" ]]; then h_kc="${KEYCLOAK_HOST:-$ea}" h_minio="${MINIO_HOST:-$ea}" h_sr="${STARROCKS_FE_HOST:-$ea}" h_afs="${AIRFLOW_WEBSERVER_HOST:-$ea}" h_om="${OPENMETADATA_HOST:-$ea}" if [[ -n "${NESSIE_URL:-}" ]]; then h_nessie="${NESSIE_URL#*://}"; h_nessie="${h_nessie%%[:/]*}"; fi if [[ -n "${OPA_URL:-}" ]]; then h_opa="${OPA_URL#*://}"; h_opa="${h_opa%%[:/]*}"; fi if [[ -n "${LDAP_URL:-}" ]]; then h_ldap="${LDAP_URL#*://}"; h_ldap="${h_ldap%%[:/]*}"; fi fi echo -e " Keycloak ${CYAN}${KEYCLOAK_PUBLIC_URL:-http://${h_kc}:${KEYCLOAK_PORT:-12080}}${NC}" echo -e " OpenLDAP ${CYAN}ldap://${h_ldap}:${OPENLDAP_PORT:-12389}${NC}" echo -e " MinIO API ${CYAN}http://${h_minio}:${MINIO_PORT:-12900}${NC}" echo -e " MinIO Console ${CYAN}${MINIO_CONSOLE_PUBLIC_URL:-http://${h_minio}:${MINIO_CONSOLE_PORT:-12901}}${NC}" echo -e " Nessie ${CYAN}http://${h_nessie}:${NESSIE_PORT:-12120}${NC}" echo -e " Trino ${CYAN}${TRINO_PUBLIC_URL:-https://${ea}:${TRINO_HTTPS_PORT:-12443}}${NC} (OAuth2 + Basic, HTTPS-only)" echo -e " OPA ${CYAN}http://${h_opa}:${OPA_PORT:-12181}${NC}" echo -e " StarRocks FE ${CYAN}${h_sr}:${STARROCKS_FE_PORT:-12930}${NC} (MySQL protocol)" echo -e " Dremio ${CYAN}http://${ea}:${DREMIO_UI_PORT:-12947}${NC}" echo -e " Airflow ${CYAN}${AIRFLOW_PUBLIC_URL:-http://${h_afs}:${AIRFLOW_PORT:-12082}}${NC}" echo -e " OpenMetadata ${CYAN}${OPENMETADATA_PUBLIC_URL:-http://${h_om}:${OPENMETADATA_PORT:-12085}}${NC}" echo -e " Grafana ${CYAN}${GRAFANA_PUBLIC_URL:-http://${ea}:${GRAFANA_PORT:-12300}}${NC}" } # ─── Logs ───────────────────────────────────────────────────────────────────── show_logs() { local compose_file="${SCRIPT_DIR}/docker-compose.yml" if [[ $# -eq 0 ]]; then ${DOCKER_COMPOSE} -f "${compose_file}" logs --tail=50 -f else ${DOCKER_COMPOSE} -f "${compose_file}" logs --tail=100 -f "$@" fi } # ─── Destroy ───────────────────────────────────────────────────────────────── destroy() { echo -e "${YELLOW}${BOLD}⚠️ PERINGATAN: Ini akan menghapus semua container dan volume!${NC}" echo -e "${YELLOW}Data di volume akan dihapus jika menggunakan --volumes.${NC}" echo "" echo -n "Lanjutkan? (y/N): " read -r confirm if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then echo -e "${INFO} Dibatalkan." exit 0 fi local compose_file="${SCRIPT_DIR}/docker-compose.yml" # destroy() tidak memanggil check_prerequisites → DOCKER_COMPOSE bisa belum # di-set. Deteksi di sini agar tidak "unbound variable" (set -u). if [[ -z "${DOCKER_COMPOSE:-}" ]]; then if docker compose version &>/dev/null; then DOCKER_COMPOSE="docker compose" elif docker-compose version &>/dev/null; then DOCKER_COMPOSE="docker-compose" else echo -e "${ERR} Docker Compose tidak ditemukan." exit 1 fi fi echo -e "${INFO} Menghentikan container..." # Semua service di balik `profiles:` → WAJIB `--profile all`, jika tidak # `down` tidak menyentuh container apa pun (no-op). ${DOCKER_COMPOSE} -f "${compose_file}" --profile all down --remove-orphans 2>/dev/null || true echo "" echo -n "Hapus semua volume data? (y/N): " read -r confirm_vol if [[ "${confirm_vol}" == "y" || "${confirm_vol}" == "Y" ]]; then echo -e "${INFO} Menghapus volume..." ${DOCKER_COMPOSE} -f "${compose_file}" --profile all down -v 2>/dev/null || true docker volume prune -f --filter "label=com.docker.compose.project=sicabe" 2>/dev/null || true fi echo -e "${OK} Destroy selesai." } # ─── Reset LDAP ────────────────────────────────────────────────────────────── # Menghapus volume OpenLDAP agar config/openldap/seed.ldif di-apply ulang oleh # bootstrap.sh. Diperlukan saat nama user/grup berubah, karena seeding di-skip # bila OU users sudah ada (idempotent). reset_ldap() { local compose_file="${SCRIPT_DIR}/docker-compose.yml" local project="${COMPOSE_PROJECT_NAME:-sicabe}" if [[ -z "${DOCKER_COMPOSE:-}" ]]; then if docker compose version &>/dev/null; then DOCKER_COMPOSE="docker compose" elif docker-compose version &>/dev/null; then DOCKER_COMPOSE="docker-compose" else echo -e "${ERR} Docker Compose tidak ditemukan."; exit 1; fi fi echo -n "Reset OpenLDAP (hapus volume user+group, seed ulang via bootstrap)? (y/N): " read -r confirm if [[ "${confirm}" != "y" && "${confirm}" != "Y" ]]; then echo -e "${INFO} Dibatalkan." exit 0 fi echo -e "${INFO} Menghentikan OpenLDAP..." ${DOCKER_COMPOSE} -f "${compose_file}" --profile all stop openldap 2>/dev/null || true echo -e "${INFO} Menghapus volume ${project}_openldap-data & ${project}_openldap-config..." docker volume rm "${project}_openldap-data" "${project}_openldap-config" 2>/dev/null || true echo -e "${INFO} Menjalankan ulang OpenLDAP..." ${DOCKER_COMPOSE} -f "${compose_file}" --profile all up -d openldap 2>/dev/null || true echo -e "${OK} OpenLDAP di-reset. Jalankan './bootstrap.sh' (Step 3) untuk seed ulang." } # ─── Main ──────────────────────────────────────────────────────────────────── main() { local MODE="all" local PHASE_START=1 local PHASE_END=6 local EXTRA_ARGS=() if [[ $# -gt 0 ]]; then # ── Perintah multi-host (Fase 1) — ditangani lebih dulu ────────────────── # Catatan: `DOCKER_COMPOSE` baru di-set oleh check_prerequisites(), jadi perintah # yang butuh Docker memanggilnya lebih dulu; `--generate` justru TIDAK butuh Docker # (hanya python3+PyYAML) sehingga tidak dipaksa. case "${1:-}" in --generate) local inv="${SCRIPT_DIR}/cluster.yml" bp="" shift while [[ $# -gt 0 ]]; do case "$1" in --inventory) inv="${2:-}"; shift 2 ;; --blueprint) bp="${2:-}"; shift 2 ;; *) echo -e "${ERR} Argumen tidak dikenal untuk --generate: $1"; exit 1 ;; esac done if ! command -v python3 &>/dev/null; then echo -e "${ERR} python3 wajib untuk --generate." exit 1 fi if ! python3 -c 'import yaml' &>/dev/null; then echo -e "${ERR} PyYAML wajib untuk --generate (pip install pyyaml)." exit 1 fi generate_hosts "${inv}" "${bp}" && exit 0 exit 1 ;; --preflight) local pinv="${SCRIPT_DIR}/cluster.yml" shift while [[ $# -gt 0 ]]; do case "$1" in --inventory) pinv="${2:-}"; shift 2 ;; *) echo -e "${ERR} Argumen tidak dikenal untuk --preflight: $1"; exit 1 ;; esac done if [[ ! -f "${pinv}" ]]; then echo -e "${ERR} Inventory tidak ditemukan: ${pinv}" echo " Mulai dari contoh: cp cluster.example.yml cluster.yml" exit 1 fi python3 "${SCRIPT_DIR}/lib/cluster/preflight.py" "${pinv}" \ --compose "${SCRIPT_DIR}/docker-compose.yml" --deploy "${SCRIPT_DIR}" exit $? ;; --sync) sync_hosts "$@" exit $? ;; --host) local hname="${2:-}" hdry=0 if [[ -z "${hname}" ]]; then echo -e "${ERR} Sebutkan nama host (lihat: --generate)." exit 1 fi shift 2 [[ "${1:-}" == "--dry-run" ]] && hdry=1 check_prerequisites # menyetel DOCKER_COMPOSE + memvalidasi Docker run_host "${hname}" "${hdry}" && exit 0 exit 1 ;; esac case "$1" in --help|-h) usage ;; --status) check_prerequisites local st_host="" [[ "${2:-}" == "--host" ]] && st_host="${3:-}" show_status "${st_host}"; exit 0 ;; --logs) check_prerequisites; shift; show_logs "$@"; exit 0 ;; --destroy) destroy; exit 0 ;; --reset-ldap) reset_ldap; exit 0 ;; --restart) check_prerequisites local compose_file="${SCRIPT_DIR}/docker-compose.yml" if [[ $# -lt 2 ]]; then echo -e "${ERR} Sebutkan service yang akan direstart." echo " $0 --restart keycloak" exit 1 fi ${DOCKER_COMPOSE} -f "${compose_file}" restart "${@:2}" exit $? ;; --phase) if [[ $# -lt 2 ]]; then echo -e "${ERR} Sebutkan phase (1-6 atau range 1-3)." exit 1 fi MODE="phase" if [[ "$2" == *-* ]]; then PHASE_START="${2%-*}" PHASE_END="${2#*-}" else PHASE_START="$2" PHASE_END="$2" fi shift 2 EXTRA_ARGS=("$@") ;; *) echo -e "${ERR} Argumen tidak dikenal: $1" usage ;; esac fi # ─── Mulai Deployment ────────────────────────────────────────────────────── echo -e "${BOLD}${BLUE}" echo "╔═══════════════════════════════════════════════════════╗" echo "║ eBdesk SiCABE — Deployment Script ║" echo "║ Arsitektur 5 Lapisan — Urutan Berdasar Dependensi ║" echo "╚═══════════════════════════════════════════════════════╝" echo -e "${NC}" # Komponen yang dikelola sistem lain → container-nya TIDAK dinyalakan. local EXT_SVCS EXT_SVCS="$(external_services)" if [[ -n "${EXT_SVCS}" ]]; then echo -e "${INFO} Komponen dikelola EKSTERNAL: ${BOLD}${EXT_SVCS}${NC}" echo " Container komponen tsb tidak dinyalakan; endpoint diambil dari .env." echo " Verifikasi setelah bootstrap: ./scripts/verify-phase1-iam.sh dst." echo "" fi check_prerequisites if [[ "${MODE}" == "all" ]]; then echo -e "${BOLD}Mode: Full Deployment (Semua Phase 1-6)${NC}" echo "" # Phase 1: Governance (IAM) echo -e "${STEP}╔═══ Phase 1/6: Governance (IAM) — Keycloak ═══╗${NC}" deploy_phase 1 # Phase 2: Storage echo -e "${STEP}╔═══ Phase 2/6: Storage — MinIO → Nessie ═══╗${NC}" deploy_phase 2 # Phase 3: Processing echo -e "${STEP}╔═══ Phase 3/6: Processing — Trino ═══╗${NC}" deploy_phase 3 # Phase 4: Serving echo -e "${STEP}╔═══ Phase 4/6: Serving — StarRocks ═══╗${NC}" deploy_phase 4 # Phase 5: Ingestion echo -e "${STEP}╔═══ Phase 5/6: Ingestion — Airflow ═══╗${NC}" deploy_phase 5 # Phase 6: Governance (Lanjutan) echo -e "${STEP}╔═══ Phase 6/6: Governance (Lan) — OpenMetadata → Grafana ═══╗${NC}" deploy_phase 6 echo "" echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════${NC}" echo -e "${BOLD}${GREEN} ✅ SEMUA PHASE SELESAI${NC}" echo -e "${BOLD}${GREEN} Jalankan ./bootstrap.sh untuk inisialisasi & integrasi${NC}" echo -e "${BOLD}${GREEN} Jalankan ./test-verification.sh untuk verifikasi${NC}" echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════${NC}" echo "" elif [[ "${MODE}" == "phase" ]]; then echo -e "${BOLD}Mode: Phase ${PHASE_START} sampai ${PHASE_END}${NC}" for p in $(seq "${PHASE_START}" "${PHASE_END}"); do deploy_phase "${p}" done echo -e "${OK} Phase ${PHASE_START}-${PHASE_END} selesai!" fi show_status } # ─── Multi-host (Fase 1, R10 §4.5) ─────────────────────────────────────────── # Membangkitkan artefak deploy per host ke `generated/host-/`. generate_hosts() { local inv="$1" blueprint="$2" args=() if [[ ! -f "${inv}" ]]; then echo -e "${ERR} Inventory tidak ditemukan: ${inv}" echo " Mulai dari contoh: cp cluster.example.yml cluster.yml (lalu sesuaikan address)" return 1 fi # Rahasia untuk SEMUA host diambil dari .env mesin ini (keputusan S1: satu .env # bersama). Bila .env baru saja dibuat otomatis, rahasianya BARU dan harus menjadi # satu-satunya sumber: distribusikan generated/ ke semua host, dan JANGAN membuat # .env terpisah di tiap host (client secret OIDC akan tidak cocok -> login gagal). if [[ "${ENV_WAS_CREATED:-0}" -eq 1 ]]; then echo -e "${WARN} .env BARU SAJA dibuat di mesin ini (password acak)." echo " .env ini adalah sumber rahasia untuk SEMUA host." echo " → distribusikan hasil generated/ (di dalamnya rahasia sudah tertanam) ke tiap host;" echo " → JANGAN membuat .env terpisah di host lain (OIDC client secret harus identik)." fi [[ -n "${blueprint}" ]] && args+=("${blueprint}") echo -e "${INFO} Membangkitkan artefak per host dari ${inv}..." python3 "${SCRIPT_DIR}/lib/cluster/generate.py" render \ "${inv}" "${SCRIPT_DIR}/docker-compose.yml" "${SCRIPT_DIR}/.env" \ "${SCRIPT_DIR}/generated" "${SCRIPT_DIR}/scripts/render-config.sh" "${args[@]}" } # Menjalankan compose untuk SATU host dari hasil generate. run_host() { # Catatan bash: JANGAN menggabung `local name="$1" ... dir="...${name}"` dalam satu # baris — dengan `set -u`, bash meng-expand seluruh kata sebelum assignment sehingga # `${name}` masih unbound ("name: unbound variable"). Pisahkan per baris. local name="$1" local dry="$2" local dir="${SCRIPT_DIR}/generated/host-${name}" if [[ ! -d "${dir}" ]]; then echo -e "${ERR} Host '${name}' belum digenerate (tidak ada ${dir})." echo " Jalankan dulu: $0 --generate [--inventory cluster.yml]" return 1 fi # Guard: berkas runtime bind-mount WAJIB FILE (Docker membuat DIRECTORY bila sumber # hilang → mis. Airflow IsADirectoryError). Isi ditulis setelah .env host di-source. sicabe_ensure_file_mount "${dir}/config/trino/password.db" 644 echo -e "${WARN} Jalankan ini HANYA di host '${name}'." echo -e "${WARN} Pilot berdampingan di host yang SAMA tidak cukup dipisah network/port:" echo -e "${WARN} RAM tidak cukup untuk dua stack — matikan stack lama lebih dulu (README)." # ── PRECEDENCE (bug nyata yang dilaporkan dari server) ────────────────────── # Compose memberi prioritas pada variabel **SHELL** di atas `--env-file`. Skrip ini # sudah men-source config/environment.defaults.sh, yang mengekspor # POSTGRES_HOST=postgres (default single-host) dan kawan-kawan. Akibatnya nilai dari # bundle host KALAH, container mencari hostname `postgres` yang hanya ada di host # lain -> java.net.UnknownHostException: postgres, walaupun --env-file sudah benar. # Karena itu .env host di-source lebih dulu (set -a) supaya nilai host yang menang. set -a # shellcheck disable=SC1091 . "${dir}/.env" set +a # Berkas runtime bind-mount WAJIB FILE (Docker membuat DIRECTORY bila sumber hilang → # mis. Airflow SimpleAuthManager IsADirectoryError). Bundle normal sudah memuatnya; # jaring pengaman ini menulis ulang isi dari .env host. local _pwp="${dir}/config/airflow/simple_auth_manager_passwords.json" sicabe_ensure_file_mount "${_pwp}" 666 printf '{"admin": "%s"}\n' "${AIRFLOW_ADMIN_PASSWORD:?AIRFLOW_ADMIN_PASSWORD tidak ada di ${dir}/.env}" > "${_pwp}" chmod 666 "${_pwp}" # ── INGRESS (T-20) ───────────────────────────────────────────────────────── # Service `traefik` berada di profile `edge`. Bila bundle host ini memuatnya # (artinya tls.mode != none), profile itu WAJIB ikut: tanpa itu Traefik tidak pernah # start sehingga semua rute publik tidak ada — walaupun label sudah terpasang di # tiap layanan dan sertifikat sudah bisa diterbitkan. Dideteksi dari BERKAS bundle, # bukan dari variabel, supaya tidak bisa meleset antara mode none dan edge. local profiles="--profile all" if grep -qE '^ traefik:' "${dir}/docker-compose.yml" 2>/dev/null; then profiles="--profile all --profile edge" fi if [[ "${dry}" == "1" ]]; then echo -e "${INFO} (dry-run) perintah yang akan dijalankan:" echo " ${DOCKER_COMPOSE} -f ${dir}/docker-compose.yml --env-file ${dir}/.env ${profiles} up -d" return 0 fi ${DOCKER_COMPOSE} -f "${dir}/docker-compose.yml" --env-file "${dir}/.env" ${profiles} up -d } # ─── Multi-host: distribusi bundle ke host lain (Fase 1) ───────────────────── # Menyalin direktori deploy/ (TERMASUK .env + generated/) ke host lain via rsync # over SSH. Dijalankan di host CONTROL (master installer). Target = semua host di # cluster.yml KECUALI host ini (dideteksi dari kecocokan alamat lokal) — atau satu # host saja via --host. Aman diulang (rsync idempoten). sync_hosts() { local inv="${SCRIPT_DIR}/cluster.yml" dest="~/sicabe-doc/deploy" user="${USER:-root}" \ only_host="" do_delete=0 dry=0 shift while [[ $# -gt 0 ]]; do case "$1" in --inventory) inv="${2:-}"; shift 2 ;; --dest) dest="${2:-}"; shift 2 ;; --user) user="${2:-}"; shift 2 ;; --host) only_host="${2:-}"; shift 2 ;; --delete) do_delete=1; shift ;; --dry-run) dry=1; shift ;; *) echo -e "${ERR} Argumen tidak dikenal untuk --sync: $1"; exit 1 ;; esac done if [[ ! -f "${inv}" ]]; then echo -e "${ERR} Inventory tidak ditemukan: ${inv}" echo " Mulai dari contoh: cp cluster.example.yml cluster.yml" exit 1 fi if ! command -v rsync &>/dev/null; then echo -e "${ERR} rsync wajib terpasang (mis. sudo apt-get install -y rsync)." exit 1 fi local pairs pairs="$(python3 "${SCRIPT_DIR}/lib/cluster/inventory.py" hosts "${inv}" 2>/dev/null)" || { echo -e "${ERR} Gagal membaca inventory (${inv})."; exit 1; } if [[ -z "${pairs}" ]]; then echo -e "${ERR} Inventory tidak memuat host."; exit 1 fi # Tentukan host INI dari kecocokan alamat lokal; bila tak cocok → sinkron ke SEMUA. local self="" lip n a lip="$(hostname -I 2>/dev/null || true)" while read -r n a; do [[ -z "${n}" ]] && continue [[ " ${lip} " == *" ${a} "* ]] && self="${n}" done <<< "${pairs}" # Kumpulkan target: kecuali host ini; hormati --host. local targets=() while read -r n a; do [[ -z "${n}" ]] && continue [[ -n "${self}" && "${n}" == "${self}" ]] && continue [[ -n "${only_host}" && "${n}" != "${only_host}" ]] && continue targets+=("${n}|${a}") done <<< "${pairs}" echo -e "${BOLD}${BLUE}Distribusi deploy/ ke host lain${NC}" echo " sumber : ${SCRIPT_DIR}/" echo " tujuan : ${user}@:${dest}" echo " host ini : ${self:-}" [[ ${do_delete} -eq 1 ]] && echo " mode : --delete (hapus berkas tujuan yang tak ada di sumber)" echo "" if [[ ${#targets[@]} -eq 0 ]]; then echo -e "${WARN} Tidak ada host tujuan (host ini=${self:-?}; --host=${only_host:-—})." return 0 fi local t name addr okc=0 failc=0 for t in "${targets[@]}"; do name="${t%%|*}"; addr="${t##*|}" echo -e "${STEP} → ${name} (${user}@${addr}:${dest})${NC}" local rargs=(-az --human-readable -e "ssh -o ConnectTimeout=8" --exclude '.git' --exclude 'output') [[ ${do_delete} -eq 1 ]] && rargs+=(--delete) [[ ${dry} -eq 1 ]] && rargs+=(--dry-run) # Buat direktori tujuan bila belum ada (`~` diekspansi oleh shell REMOTE). rargs+=(--rsync-path "mkdir -p ${dest} && rsync") if rsync "${rargs[@]}" "${SCRIPT_DIR}/" "${user}@${addr}:${dest}/"; then echo -e " ${OK} ${name}: tersinkron" okc=$((okc + 1)) else echo -e " ${ERR} ${name}: GAGAL — periksa SSH/rsync ke ${addr}" >&2 failc=$((failc + 1)) fi done echo "" echo -e "${BOLD}Ringkasan${NC}" echo " berhasil: ${okc} · gagal: ${failc}" echo " Langkah berikutnya DI tiap host tujuan:" echo " ./scripts/prefetch-images.sh --host && ./install.sh --host " [[ ${failc} -eq 0 ]] } # ─── Jalankan ───────────────────────────────────────────────────────────────── main "$@"