#!/usr/bin/env bash # ============================================================================= # eBdesk SiCABE — Bootstrap: Inisialisasi & Integrasi Lintas Lapisan # ============================================================================= # Script ini menjalankan inisialisasi dan integrasi antar komponen setelah # semua container berhasil dideploy oleh install.sh. # # Sesuai urutan di dokumen (Bab 5 — Integrasi & Konfigurasi Lintas Lapisan): # 5.1 Alur Otentikasi Terpusat (Keycloak + OpenLDAP) # 5.2 Storage Layer: Koneksi Komponen ke MinIO + Nessie # 5.3 Storage → Processing: Koneksi Query Engine ke Nessie (+ OPA authz) # 5.3b Storage → Processing: Dremio (query engine kedua) + source Nessie # 5.4 Ingestion → Processing: Pipeline Integration # 5.5 Governance: Observability Integration # # Usage: # ./bootstrap.sh # Jalankan semua langkah # ./bootstrap.sh --step 1 # Jalankan step tertentu # ./bootstrap.sh --status # Cek status bootstrap # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${SCRIPT_DIR}" # ─── Shared Library ────────────────────────────────────────────────────────── source "${SCRIPT_DIR}/lib/common.sh" # ─── Load Environment ─────────────────────────────────────────────────────── source "${SCRIPT_DIR}/config/environment.defaults.sh" if [[ -f "${SCRIPT_DIR}/.env" ]]; then set -a; source "${SCRIPT_DIR}/.env"; set +a fi # ─── Fungsi Bantuan ────────────────────────────────────────────────────────── usage_bootstrap() { echo -e "${BOLD}Usage:${NC}" echo " $0 # Bootstrap semua langkah" echo " $0 --step N # Jalankan step tertentu" echo " $0 --host [--dry-run] # Hanya step milik host itu (endpoint dari" echo " # generated/host-/.env)" echo " $0 --status # Cek status bootstrap" echo " $0 --help # Bantuan" echo "" echo "Steps:" echo " 1 = Keycloak: Realm, Client, User, Role" echo " 2 = MinIO: Bucket" echo " 3 = OpenLDAP: Seed user + grup (group provider)" echo " 4 = Trino: Verifikasi koneksi Iceberg + Nessie + OPA" echo " 5 = StarRocks: Storage volume + OIDC + LDAP group provider + External Catalog Nessie" echo " 6 = Airflow: Connections + Example DAG" echo " 7 = OpenMetadata: Konektor (Trino, Airflow)" echo " 8 = Grafana: Datasource + Dashboard" echo " 9 = Dremio: First-run admin + Source Nessie" exit 0 } # ─── Fungsi HTTP Helper ────────────────────────────────────────────────────── kc_admin() { curl -sf -u "${KEYCLOAK_ADMIN:-admin}:${KEYCLOAK_ADMIN_PASSWORD}" "$@" } kc_post() { curl -sf -X POST -u "${KEYCLOAK_ADMIN:-admin}:${KEYCLOAK_ADMIN_PASSWORD}" \ -H "Content-Type: application/json" "$@" } kc_get() { curl -sf -u "${KEYCLOAK_ADMIN:-admin}:${KEYCLOAK_ADMIN_PASSWORD}" "$@" } # ─── Helper LDAP: container lokal (docker exec) vs LDAP EXISTING (dari host) ── # Dipakai step 3. Bila container `sicabe-openldap` tidak berjalan, perintah LDAP # dijalankan langsung dari host ke ${LDAP_HOST}:${LDAP_PORT} — sehingga bootstrap # tetap bisa memakai/seed LDAP yang sudah ada (mode IAM existing). ldap_is_local_container() { docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${LDAP_CONTAINER_NAME:-sicabe-openldap}$" } ldap_run() { if ldap_is_local_container; then docker exec -i "${LDAP_CONTAINER_NAME:-sicabe-openldap}" "$@" else "$@" fi } ldap_endpoint() { if ldap_is_local_container; then echo "ldap://localhost" else echo "ldap://${LDAP_HOST:-localhost}:${LDAP_PORT:-389}" fi } # Alamat BROWSER-reachable komponen (tempat user memakai UI di browser): nilai # bila berupa ALAMAT (mengandung titik); kalau tidak → SICABE_HOST_ADDRESS (multi-host); # kalau tak ada → localhost (single-host). Dipakai redirect_url OIDC StarRocks & origin # redirectUris Keycloak (JANGAN nama container/hostname internal — browser tak bisa resolve). bootstrap_browser_addr() { # local v="${!1:-}" if [[ "${v}" == *.* && "${v}" != "127.0.0.1" ]]; then echo "${v}"; return; fi echo "${SICABE_HOST_ADDRESS:-localhost}" } # ============================================================================= # STEP 1: Keycloak — Realm, Client, User, Role (bab 5.1) # ============================================================================= bootstrap_keycloak() { echo -e "${BOLD}${BLUE}═══ STEP 1/9: Keycloak — Realm, Client, User, Role ═══${NC}" echo -e "${INFO} Referensi: bab 4.1.1.5-4.1.1.8" local kc_base="${KEYCLOAK_URL:-http://localhost:${KEYCLOAK_PORT:-12080}}" # Tunggu Keycloak siap echo -e "${INFO} Menunggu Keycloak siap..." for i in $(seq 1 30); do if curl -sf "${kc_base}/health/ready" >/dev/null 2>&1; then echo -e "${OK} Keycloak siap." break fi if [[ $i -eq 30 ]]; then echo -e "${ERR} Keycloak tidak siap dalam 60 detik." return 1 fi sleep 2 done # Dapatkan token admin local token token=$(curl -sf -X POST "${kc_base}/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=admin-cli" \ -d "username=${KEYCLOAK_ADMIN:-admin}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))") || true echo -e "${INFO} Token admin diperoleh." # ── Buat Realm (jika belum ada) ────────────────────────────────────────────── local realm="${SICABE_REALM:-sicabe}" local realm_exists realm_exists=$(curl -sf -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}" 2>/dev/null || echo "404") if [[ "${realm_exists}" == "200" ]]; then echo -e "${OK} Realm '${realm}' sudah ada." else echo -e "${INFO} Membuat realm '${realm}'..." curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"realm\": \"${realm}\", \"enabled\": true, \"displayName\": \"eBdesk SiCABE\", \"sslRequired\": \"none\", \"registrationAllowed\": false, \"loginWithEmailAllowed\": true, \"resetPasswordAllowed\": true, \"accessTokenLifespan\": 3600 }" "${kc_base}/admin/realms" >/dev/null echo -e "${OK} Realm '${realm}' dibuat." fi # ── sslRequired: samakan dengan kebijakan deployment ───────────────────────── # Keycloak default `external` menuntut HTTPS untuk permintaan dari alamat NON-lokal. # Alamat Tailscale 100.64.0.0/10 (CGNAT) TERMASUK non-lokal (isLocal = loopback + # RFC1918 + link-local + fc00::/7), jadi admin console di http://:12080 # gagal "HTTPS required". Keycloak 26 tidak punya opsi server-level untuk ini. # Dijalankan untuk realm `master` juga — admin console ada di sana, dan dulu realm ini # tidak pernah disentuh sama sekali (sisa default `external`). local ssl_required="${KEYCLOAK_SSL_REQUIRED:-none}" for r in master "${realm}"; do if curl -sf -X PUT -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{\"sslRequired\": \"${ssl_required}\"}" \ "${kc_base}/admin/realms/${r}" >/dev/null 2>&1; then echo -e "${OK} Realm '${r}' sslRequired=${ssl_required}." else echo -e "${WARN} Gagal menyetel sslRequired realm '${r}' (lanjut)." fi done # ── Buat Roles ────────────────────────────────────────────────────────────── echo -e "${INFO} Membuat roles..." for role in admin engineer analyst viewer; do local role_exists role_exists=$(curl -sf -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/roles/${role}" 2>/dev/null || echo "404") if [[ "${role_exists}" != "200" ]]; then curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{\"name\": \"${role}\", \"description\": \"Role: ${role}\"}" \ "${kc_base}/admin/realms/${realm}/roles" >/dev/null echo -e "${OK} Role '${role}' dibuat." else echo -e "${OK} Role '${role}' sudah ada." fi done # ── Buat Clients (setiap komponen di setiap lapisan) ───────────────────────── echo -e "${INFO} Membuat OIDC clients untuk setiap komponen..." # ⚠️ Client secrets di sini WAJIB sinkron dengan file konfigurasi # komponen terkait (trino/catalog/iceberg.properties, starrocks, dll). # Di development, nilai default sudah cocok. # Di production, override lewat .env dan update file config komponen. declare -A CLIENTS CLIENTS=( ["trino"]="Trino — Processing:Query Engine|${TRINO_CLIENT_SECRET}" ["starrocks"]="StarRocks — Serving:OLAP Engine|${STARROCKS_CLIENT_SECRET}" ["airflow"]="Airflow — Ingestion:Orchestrator|${AIRFLOW_CLIENT_SECRET}" ["openmetadata"]="OpenMetadata — Governance:Metadata Platform|${OPENMETADATA_CLIENT_SECRET}" ["grafana"]="Grafana — Governance:Observability|${GRAFANA_CLIENT_SECRET}" ) # URL PUBLIK tiap aplikasi (dari .env — diisi generator; mode none = alamat host # pemilik). Dipakai untuk redirectUris/webOrigins, karena browser yang dipakai # operator TIDAK selalu localhost. declare -A CLIENT_PUBLIC_URL CLIENT_PUBLIC_URL=( ["trino"]="${TRINO_PUBLIC_URL:-}" ["starrocks"]="" ["airflow"]="${AIRFLOW_PUBLIC_URL:-}" ["openmetadata"]="${OPENMETADATA_PUBLIC_URL:-}" ["grafana"]="${GRAFANA_PUBLIC_URL:-}" ) # Origin HOST (skema://alamat:port) — tempat browser MEMBUKA komponen. WAJIB ada di # redirectUris, kalau tidak login via ALAMAT (mis. https://100.123.123.71:12443) ditolak # "Invalid parameter: redirect_uri" (redirectUris hanya localhost). Sinkron dgn # scripts/verify-keycloak.sh. # =" " declare -A CLIENT_ORIGIN=( ["trino"]="https TRINO_HOST TRINO_HTTPS_PORT 12443" ["grafana"]="http GRAFANA_HOST GRAFANA_PORT 12300" ["openmetadata"]="http OPENMETADATA_HOST OPENMETADATA_PORT 12085" ["airflow"]="http AIRFLOW_HOST AIRFLOW_PORT 12082" ["starrocks"]="http STARROCKS_FE_HOST STARROCKS_HTTP_PORT 12030" ) _comp_addr() { # → alamat efektif (IP/alamat, atau host ini bila co-located) local v="${!1:-}" if [[ "${v}" == *.* && "${v}" != "127.0.0.1" ]]; then echo "${v}"; else echo "${SICABE_HOST_ADDRESS:-localhost}"; fi } _client_origin() { # → skema://alamat:port (atau kosong) local spec="${CLIENT_ORIGIN[$1]:-}"; [[ -n "${spec}" ]] || { echo ""; return; } local s hv pv dp; read -r s hv pv dp <<< "${spec}" printf '%s://%s:%s' "${s}" "$(_comp_addr "${hv}")" "${!pv:-${dp}}" } for client_id in "${!CLIENTS[@]}"; do local entry="${CLIENTS[$client_id]}" IFS="|" read -r desc secret <<< "${entry}" # Cek apakah client sudah ada local client_exists client_exists=$(curl -sf \ -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/clients?clientId=${client_id}" | \ python3 -c "import sys,json; data=json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0") if [[ "${client_exists}" -gt 0 ]]; then echo -e "${OK} Client '${client_id}' sudah ada." else curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"clientId\": \"${client_id}\", \"name\": \"${desc}\", \"enabled\": true, \"protocol\": \"openid-connect\", \"clientAuthenticatorType\": \"client-secret\", \"secret\": \"${secret}\", \"standardFlowEnabled\": true, \"serviceAccountsEnabled\": true, \"publicClient\": false, \"redirectUris\": [\"http://localhost:*\", \"https://localhost:*\"], \"webOrigins\": [\"http://localhost:*\", \"https://localhost:*\"] }" "${kc_base}/admin/realms/${realm}/clients" >/dev/null echo -e "${OK} Client '${client_id}' (${desc}) dibuat." fi # Tambah audience mapper 'account' untuk semua client (dibutuhkan Trino OIDC) local client_uuid client_uuid=$(curl -sf -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/clients?clientId=${client_id}" | \ python3 -c "import sys,json; print(json.load(sys.stdin)[0]['id'])" 2>/dev/null || echo "") # Sinkronkan redirectUris/webOrigins: DIGABUNG dengan yang sudah ada (tidak menimpa), # jadi aman dijalankan berulang. Ini juga memperbaiki client LAMA yang dibuat saat # URL publik belum dikonfigurasi (dulu hanya "localhost" dan tidak pernah di-update), # yang membuat login browser lewat alamat publik ditolak: # Invalid parameter: redirect_uri if [[ -n "${client_uuid}" ]]; then local _payload _host_origin _host_origin="$(_client_origin "${client_id}")" _payload=$(curl -sf -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/clients/${client_uuid}" \ | PUBLIC_ORIGIN="${CLIENT_PUBLIC_URL[$client_id]:-}" HOST_ORIGIN="${_host_origin}" \ EXTRA_ORIGINS="${SICABE_CLIENT_REDIRECT_ORIGINS:-}" python3 -c ' import json, os, sys c = json.load(sys.stdin) pub = (os.environ.get("PUBLIC_ORIGIN") or "").rstrip("/") host = (os.environ.get("HOST_ORIGIN") or "").rstrip("/") redirects = set(c.get("redirectUris") or []) | {"http://localhost:*", "https://localhost:*"} origins = set(c.get("webOrigins") or []) | {"http://localhost:*", "https://localhost:*"} if host: redirects.add(host + "/*") origins.add(host) if pub: redirects.add(pub + "/*") origins.add(pub) # Origin client dari CLUSTER (host existing tak tahu alamat host cluster) — daftar # spasi-dipisah, mis. SICABE_CLIENT_REDIRECT_ORIGINS="https://100.123.123.71:12443 ...". for o in (os.environ.get("EXTRA_ORIGINS") or "").split(): o = o.rstrip("/") if o: redirects.add(o + "/*") origins.add(o) print(json.dumps({"redirectUris": sorted(redirects), "webOrigins": sorted(origins)})) ' 2>/dev/null || echo "") if [[ -n "${_payload}" ]]; then curl -sf -X PUT -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" -d "${_payload}" \ "${kc_base}/admin/realms/${realm}/clients/${client_uuid}" >/dev/null 2>&1 || true echo -e "${OK} Client '${client_id}' redirectUris disinkronkan (origin host ${_host_origin:-—}; publik ${CLIENT_PUBLIC_URL[$client_id]:-—})." fi fi if [[ -n "${client_uuid}" ]]; then local mapper_name="audience-account" local mapper_exists mapper_exists=$(curl -sf -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/clients/${client_uuid}/protocol-mappers/models" | \ python3 -c "import sys,json; mappers=json.load(sys.stdin); print(any(m['name']=='${mapper_name}' for m in mappers))" 2>/dev/null || echo "False") if [[ "${mapper_exists}" != "True" ]]; then curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"name\": \"${mapper_name}\", \"protocol\": \"openid-connect\", \"protocolMapper\": \"oidc-audience-mapper\", \"config\": { \"included.client.audience\": \"account\", \"id.token.claim\": \"false\", \"access.token.claim\": \"true\" } }" "${kc_base}/admin/realms/${realm}/clients/${client_uuid}/protocol-mappers/models" >/dev/null echo -e "${OK} Audience mapper '${mapper_name}' untuk '${client_id}' ditambahkan." fi fi unset IFS done # ── StarRocks OIDC: sinkronkan redirectUris (idempoten) ──────────────────── # StarRocks FE mengirim redirect internal (http://starrocks-fe:8030/api/oauth2) # saat membangun URL OAuth2 → WAJIB terdaftar, jika tidak Keycloak menolak # dengan "Invalid parameter: redirect_uri". Pola host dipakai untuk callback browser. local sr_client_id sr_client_json sr_fe_browser_addr sr_fe_browser_addr="$(bootstrap_browser_addr STARROCKS_FE_HOST)" sr_client_id=$(curl -sf -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/clients?clientId=starrocks" | \ python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['id'] if d else '')" 2>/dev/null || echo "") if [[ -n "${sr_client_id}" ]]; then sr_client_json=$(curl -sf -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/clients/${sr_client_id}" | \ python3 -c " import sys,json c=json.load(sys.stdin) extra=['http://starrocks-fe:8030/api/oauth2','http://localhost:${STARROCKS_HTTP_PORT:-12030}/*','http://${sr_fe_browser_addr}:${STARROCKS_HTTP_PORT:-12030}/*'] c['redirectUris']=sorted(set((c.get('redirectUris') or [])+extra)) print(json.dumps(c)) " 2>/dev/null || true) if [[ -n "${sr_client_json}" ]]; then curl -sf -X PUT -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "${sr_client_json}" \ "${kc_base}/admin/realms/${realm}/clients/${sr_client_id}" >/dev/null \ && echo -e "${OK} Client 'starrocks' redirectUris disinkronkan (redirect internal FE)." fi fi # ── Buat Users ────────────────────────────────────────────────────────────── echo -e "${INFO} Membuat users..." # Admin user local admin_exists admin_exists=$(curl -sf \ -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/users?username=admin" | \ python3 -c "import sys,json; data=json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0") if [[ "${admin_exists}" -eq 0 ]]; then curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"username\": \"admin\", \"enabled\": true, \"email\": \"admin@sicabe.local\", \"firstName\": \"Administrator\", \"lastName\": \"SiCABE\", \"credentials\": [{\"type\": \"password\", \"value\": \"${KEYCLOAK_ADMIN_PASSWORD}\", \"temporary\": false}], \"realmRoles\": [\"admin\"] }" "${kc_base}/admin/realms/${realm}/users" >/dev/null echo -e "${OK} User 'admin' dibuat." else echo -e "${OK} User 'admin' sudah ada." fi # Data Engineer user local engineer_exists engineer_exists=$(curl -sf \ -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/users?username=engineer" | \ python3 -c "import sys,json; data=json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0") if [[ "${engineer_exists}" -eq 0 ]]; then curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"username\": \"engineer\", \"enabled\": true, \"email\": \"engineer@sicabe.local\", \"firstName\": \"Data\", \"lastName\": \"Engineer\", \"credentials\": [{\"type\": \"password\", \"value\": \"engineer123\", \"temporary\": false}], \"realmRoles\": [\"engineer\"] }" "${kc_base}/admin/realms/${realm}/users" >/dev/null echo -e "${OK} User 'engineer' dibuat." else echo -e "${OK} User 'engineer' sudah ada." fi # Data Analyst user local analyst_exists analyst_exists=$(curl -sf \ -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/users?username=analyst" | \ python3 -c "import sys,json; data=json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0") if [[ "${analyst_exists}" -eq 0 ]]; then curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"username\": \"analyst\", \"enabled\": true, \"email\": \"analyst@sicabe.local\", \"firstName\": \"Data\", \"lastName\": \"Analyst\", \"credentials\": [{\"type\": \"password\", \"value\": \"analyst123\", \"temporary\": false}], \"realmRoles\": [\"analyst\"] }" "${kc_base}/admin/realms/${realm}/users" >/dev/null echo -e "${OK} User 'analyst' dibuat." else echo -e "${OK} User 'analyst' sudah ada." fi # Viewer user local viewer_exists viewer_exists=$(curl -sf \ -H "Authorization: Bearer ${token}" \ "${kc_base}/admin/realms/${realm}/users?username=viewer" | \ python3 -c "import sys,json; data=json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0") if [[ "${viewer_exists}" -eq 0 ]]; then curl -sf -X POST -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "{ \"username\": \"viewer\", \"enabled\": true, \"email\": \"viewer@sicabe.local\", \"firstName\": \"Viewer\", \"lastName\": \"SiCABE\", \"credentials\": [{\"type\": \"password\", \"value\": \"viewer123\", \"temporary\": false}], \"realmRoles\": [\"viewer\"] }" "${kc_base}/admin/realms/${realm}/users" >/dev/null echo -e "${OK} User 'viewer' dibuat." else echo -e "${OK} User 'viewer' sudah ada." fi echo -e "${OK} ${GREEN}Step 1 selesai — Keycloak terkonfigurasi.${NC}" echo -e " URL Admin Console: ${CYAN}${KEYCLOAK_URL:-http://localhost:${KEYCLOAK_PORT:-12080}}/admin/${realm}/console${NC}" echo -e " Login: admin / ${KEYCLOAK_ADMIN_PASSWORD}" echo "" } # ============================================================================= # STEP 2: MinIO — Bucket, Policy, Service Account (bab 5.2) # ============================================================================= bootstrap_minio() { echo -e "${BOLD}${BLUE}═══ STEP 2/9: Object storage — Bucket ═══${NC}" echo -e "${INFO} Referensi: bab 4.2.1.7-4.2.1.8" # Berfungsi untuk MinIO lokal MAUPUN object storage existing: endpoint & # kredensial diambil dari S3_* (lihat config/environment.defaults.sh §2.9). local mc_endpoint="${S3_ENDPOINT:-http://minio:9000}" local mc_user="${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}" local mc_pass="${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-}}" # Nama bucket = config (MINIO_BUCKET_ICEBERG_DATA), bukan hardcoded. local bucket="${MINIO_BUCKET_ICEBERG_DATA:-iceberg-data}" local mc_image="quay.io/minio/mc:${MINIO_MC_VERSION:-RELEASE.2025-08-13T08-35-41Z}" # Pemilik storage ⇔ container MinIO ada di bundle host INI (single-host → selalu # lokal, krena SICABE_HOST_NAME kosong). Bila storage dikelola SISTEM LAIN # (mis. sisi existing P1/P3), host ini HANYA MEMVERIFIKASI bucket — pembuatan # bucket adalah tugas sisi PEMILIK storage. Ini menghindari cluster "menciptakan" # bucket di storage pihak lain tanpa otoritas. local mc_mode="create" sicabe_host_expects_container sicabe-minio || mc_mode="verify" # Penantian via host hanya relevan untuk MinIO lokal milik host ini. if [[ "${mc_mode}" == "create" ]]; then echo -e "${INFO} Menunggu MinIO siap..." for i in $(seq 1 15); do if curl -sf "http://localhost:${MINIO_PORT:-12900}/minio/health/live" >/dev/null 2>&1; then echo -e "${OK} MinIO siap." break fi [[ $i -eq 15 ]] && echo -e "${WARN} MinIO lokal belum siap — dicoba lewat container." sleep 2 done else echo -e "${INFO} Object storage dikelola sistem lain (${mc_endpoint}) — mode VERIFIKASI bucket." fi if [[ -z "${mc_pass}" ]]; then echo -e "${WARN} Kredensial storage kosong (S3_SECRET_KEY / MINIO_ROOT_PASSWORD) — lewati cek bucket." return 0 fi # Bucket diuji/dibuat dari container pada jaringan compose: S3_ENDPOINT umumnya # hanya resolve di jaringan itu (berlaku sama untuk MinIO lokal & S3 existing). if ! docker image inspect "${mc_image}" >/dev/null 2>&1; then echo -e "${INFO} Menarik image mc ${mc_image}..." if ! docker pull "${mc_image}" >/dev/null 2>&1; then echo -e "${WARN} Gagal menarik image mc — buat bucket '${bucket}' manual lalu jalankan gate fase 2." return 0 fi fi local out out=$(docker run --rm -i \ --network "${SICABE_NETWORK:-sicabe-network}" \ -e S3E="${mc_endpoint}" -e S3U="${mc_user}" -e S3P="${mc_pass}" \ -e BUCKET="${bucket}" -e MODE="${mc_mode}" \ --entrypoint sh "${mc_image}" -c ' if ! mc alias set probe "$S3E" "$S3U" "$S3P" >/dev/null 2>&1; then echo ALIAS_FAIL; exit 0; fi if mc ls "probe/$BUCKET" >/dev/null 2>&1; then echo BUCKET_OK elif [ "$MODE" = "create" ] && mc mb "probe/$BUCKET" >/dev/null 2>&1; then echo BUCKET_CREATED else echo BUCKET_MISSING; fi' 2>&1) case "${out}" in *ALIAS_FAIL*) echo -e "${WARN} Tidak bisa autentikasi ke ${mc_endpoint} dari container." echo " Cek S3_ENDPOINT + S3_ACCESS_KEY/S3_SECRET_KEY; untuk S3 existing buat bucket '${bucket}' manual." return 0 ;; *BUCKET_OK*) echo -e "${OK} Bucket '${bucket}' sudah ada." ;; *BUCKET_CREATED*) echo -e "${OK} Bucket '${bucket}' dibuat." ;; *BUCKET_MISSING*) if [[ "${mc_mode}" == "verify" ]]; then echo -e "${WARN} Bucket '${bucket}' belum ada — storage dikelola sistem lain; buat di sisi PEMILIK storage." echo " Bucket = MINIO_BUCKET_ICEBERG_DATA (config); konfirmasi ke admin storage existing." else echo -e "${WARN} Bucket '${bucket}' belum bisa dipastikan — buat manual, lalu:" echo " ./scripts/verify-phase2-storage.sh" fi return 0 ;; esac echo -e "${OK} ${GREEN}Step 2 selesai — object storage terkonfigurasi.${NC}" echo " Endpoint: ${mc_endpoint} | Bucket: ${bucket}" echo "" } # ============================================================================= # STEP 3: OpenLDAP — Seed user + grup (sumber grup Trino & StarRocks) # ============================================================================= bootstrap_openldap() { echo -e "${BOLD}${BLUE}═══ STEP 3/9: OpenLDAP — Seed user + grup ═══${NC}" echo -e "${INFO} LDAP = source of truth grup untuk Trino & StarRocks group provider" local ldap_container="${LDAP_CONTAINER_NAME:-sicabe-openldap}" local admin_dn="${LDAP_ADMIN_DN:-cn=admin,dc=sicabe,dc=local}" local base_dn="${LDAP_BASE_DN:-dc=sicabe,dc=local}" local ldap_url ldap_url="$(ldap_endpoint)" if ldap_is_local_container; then echo -e "${INFO} Mode LDAP lokal (container ${ldap_container})." else echo -e "${INFO} Mode LDAP EXISTING — target ${ldap_url}" if ! command -v ldapsearch >/dev/null 2>&1; then echo -e "${WARN} ldapsearch tidak ada di host. Install ldap-utils lalu ulangi step 3." echo -e "${WARN} Melewati seed LDAP." return 0 fi fi echo -e "${INFO} Menunggu LDAP siap..." for i in $(seq 1 15); do if ldap_run ldapsearch -x -H "${ldap_url}" \ -b "${base_dn}" -D "${admin_dn}" -w "${LDAP_ADMIN_PASSWORD}" \ "(objectClass=*)" >/dev/null 2>&1; then echo -e "${OK} LDAP siap." break fi if [[ $i -eq 15 ]]; then echo -e "${WARN} LDAP belum siap. Lanjutkan..." return 0 fi sleep 2 done # Seed idempotent: cek apakah OU users sudah ada (pakai -LLL agar filter tidak # ikut tercetak — jika tidak, grep "ou=users" selalu cocok = false positive). local has_users has_users=$(ldap_run ldapsearch -LLL -x -H "${ldap_url}" \ -b "${base_dn}" -D "${admin_dn}" -w "${LDAP_ADMIN_PASSWORD}" \ "(ou=users)" dn 2>/dev/null | grep -c "^dn: ou=users" || true) if [[ "${has_users}" -eq 0 ]]; then ldap_run ldapadd -x -H "${ldap_url}" \ -D "${admin_dn}" -w "${LDAP_ADMIN_PASSWORD}" \ < "${SCRIPT_DIR}/config/openldap/seed.ldif" >/dev/null 2>&1 \ && echo -e "${OK} Seed LDAP (user + grup) dibuat." \ || echo -e "${WARN} Seed LDAP gagal (mungkin sudah ada)." else echo -e "${OK} Seed LDAP sudah ada." fi echo -e "${OK} ${GREEN}Step 3 selesai — LDAP terkonfigurasi.${NC}" echo " Endpoint: ${ldap_url}" echo " Base DN: ${base_dn}" echo " Groups: data-admin, data-engineer, data-analyst, data-viewer" echo "" } # ============================================================================= # STEP 4: Trino — Verifikasi Koneksi Iceberg + Nessie + OPA (bab 5.3) # ============================================================================= bootstrap_trino() { echo -e "${BOLD}${BLUE}═══ STEP 4/9: Trino — Verifikasi Iceberg + Nessie + OPA ═══${NC}" echo -e "${INFO} Referensi: bab 4.3.1.3 & 4.3.1.8" # Endpoint dari sisi HOST — dapat diarahkan ke layanan EXISTING lewat .env: # TRINO_URL / OPA_URL / NESSIE_HOST+NESSIE_PORT (lihat config/environment.defaults.sh) local trino_url="${TRINO_URL:-https://localhost:${TRINO_HTTPS_PORT:-12443}}" local nessie_url="http://${NESSIE_HOST:-localhost}:${NESSIE_PORT:-12120}" local opa_url="${OPA_URL:-http://localhost:${OPA_PORT:-12181}}" # Tunggu Trino siap echo -e "${INFO} Menunggu Trino siap..." for i in $(seq 1 30); do if curl -skf "${trino_url}/v1/info" >/dev/null 2>&1; then echo -e "${OK} Trino siap." break fi if [[ $i -eq 30 ]]; then echo -e "${WARN} Trino belum siap." return 0 fi sleep 2 done # Verifikasi Nessie (catalog) reachable if curl -sf "${nessie_url}/api/v2/config" >/dev/null 2>&1; then echo -e "${OK} Nessie (catalog) reachable di ${nessie_url}." else echo -e "${WARN} Nessie tidak reachable di ${nessie_url}." fi # Verifikasi OPA (decision point authz) reachable if curl -sf -X POST "${opa_url}/v1/data/trino/allow" \ -H "Content-Type: application/json" -d '{"input":{}}' >/dev/null 2>&1; then echo -e "${OK} OPA (authz) reachable di ${opa_url}." else echo -e "${WARN} OPA tidak reachable di ${opa_url}." fi # Verifikasi catalog 'iceberg' via service account (HTTP Basic) langsung ke HTTPS local result result=$(curl -skf -X POST "${trino_url}/v1/statement" \ -u "svc_seed:${TRINO_SVC_SEED_PASSWORD}" \ -H "Content-Type: text/plain" \ -d "SHOW CATALOGS" 2>/dev/null || true) if echo "${result}" | grep -q "iceberg"; then echo -e "${OK} Catalog 'iceberg' terdeteksi di Trino (service account)." else echo -e "${WARN} Catalog belum terverifikasi via service account (cek auth/endpoint)." fi echo -e "${OK} ${GREEN}Step 4 selesai — Trino terverifikasi.${NC}" echo " URL: https://localhost:${TRINO_HTTPS_PORT:-12443}" echo " Catalog: iceberg (via Nessie) | Authz: OPA" echo "" } # ============================================================================= # STEP 5: StarRocks — External Catalog + OIDC (bab 5.3 lanjutan) # ============================================================================= bootstrap_starrocks() { echo -e "${BOLD}${BLUE}═══ STEP 5/9: StarRocks — shared-data + OIDC + LDAP + External Catalog ═══${NC}" echo -e "${INFO} Referensi: bab 4.4.1.3-4.4.1.4" local sr_port="${STARROCKS_FE_PORT:-12930}" # Base Keycloak yang dijangkau HOST ini — owner + port TERPUBLIKASI (samakan dgn # verify-phase4 KC_BASE). single-host → localhost:12080; cluster → :12080. # Sebelumnya pakai KEYCLOAK_URL (default localhost) → issuer salah saat keycloak existing. local kc_base kc_base="$(sicabe_local_url http KEYCLOAK_HOST "${KEYCLOAK_PORT:-12080}" KEYCLOAK_URL)" # Alamat Keycloak server→server (dari container StarRocks): service/owner + port container. local kc_internal="http://${KEYCLOAK_HOST:-keycloak}:${KEYCLOAK_INTERNAL_PORT:-8080}" sr_mysql() { mysql -h 127.0.0.1 -P "${sr_port}" -u root -p"${STARROCKS_ROOT_PASSWORD}" -e "$1" 2>/dev/null } # Tunggu StarRocks FE siap echo -e "${INFO} Menunggu StarRocks FE siap..." for i in $(seq 1 30); do if sr_mysql "SELECT 1;" >/dev/null 2>&1; then echo -e "${OK} StarRocks FE siap." break fi if [[ $i -eq 30 ]]; then echo -e "${WARN} StarRocks FE belum siap atau MySQL client tidak tersedia." print_starrocks_sql return 0 fi sleep 5 done if ! command -v mysql &>/dev/null; then echo -e "${WARN} MySQL client tidak ditemukan." echo " docker run --rm --network=sicabe-network mysql:8 mysql -hstarrocks-fe -P9030 -uroot -p..." print_starrocks_sql return 0 fi # ── 1. Storage volume S3 (shared-data) ────────────────────────────────── echo -e "${INFO} [1/6] Storage volume S3 (MinIO)..." if ! sr_mysql "SHOW STORAGE VOLUMES;" | grep -qi "s3_volume"; then sr_mysql "CREATE STORAGE VOLUME s3_volume TYPE = S3 LOCATIONS = ('s3://${MINIO_BUCKET_ICEBERG_DATA:-iceberg-data}/starrocks/') PROPERTIES ( 'enabled' = 'true', 'aws.s3.endpoint' = '${S3_ENDPOINT:-http://minio:9000}', 'aws.s3.region' = '${S3_REGION:-us-east-1}', 'aws.s3.access_key' = '${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}', 'aws.s3.secret_key' = '${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD}}', 'aws.s3.use_instance_profile' = 'false', 'aws.s3.use_aws_sdk_default_behavior' = 'false', 'aws.s3.enable_path_style_access' = 'true' );" && echo -e "${OK} Storage volume 's3_volume' dibuat." || echo -e "${WARN} CREATE STORAGE VOLUME gagal." else # Volume sudah ADA → SINKRONKAN properti: S3_ENDPOINT/kredensial bisa berubah # (mis. diarahkan ke MinIO existing / port non-standar). Tanpa ini endpoint LAMA # tertinggal → verify-phase4 FAIL "aws.s3.endpoint … ≠ S3_ENDPOINT". if sr_mysql "ALTER STORAGE VOLUME s3_volume SET ( 'aws.s3.endpoint' = '${S3_ENDPOINT:-http://minio:9000}', 'aws.s3.region' = '${S3_REGION:-us-east-1}', 'aws.s3.access_key' = '${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}', 'aws.s3.secret_key' = '${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD}}' );" >/dev/null 2>&1; then echo -e "${OK} Storage volume 's3_volume' ada — properti disinkronkan (endpoint/kredensial)." else echo -e "${WARN} Storage volume 's3_volume' ada tetapi ALTER gagal — endpoint mungkin masih lama." echo -e "${WARN} Perbaiki manual: DROP STORAGE VOLUME s3_volume; lalu jalankan ulang step 5." fi fi sr_mysql "SET s3_volume AS DEFAULT STORAGE VOLUME;" >/dev/null 2>&1 && echo -e "${OK} s3_volume = DEFAULT STORAGE VOLUME" # ── 2. Security integration OIDC (Keycloak) ───────────────────────────── echo -e "${INFO} [2/6] Security integration OIDC..." # redirect_url = URL yang dibuka BROWSER (host FE + port HTTP publik) — bukan localhost/ # nama container. Keycloak memakai nilai ini LITERAL sebagai redirect_uri. local sr_redirect_addr sr_redirect_url sr_redirect_addr="$(bootstrap_browser_addr STARROCKS_FE_HOST)" sr_redirect_url="http://${sr_redirect_addr}:${STARROCKS_HTTP_PORT:-12030}/api/oauth2" echo -e "${INFO} redirect_url OIDC StarRocks: ${sr_redirect_url}" if ! sr_mysql "SHOW SECURITY INTEGRATIONS;" | grep -qi "sr_oidc"; then # redirect_url dipakai LITERAL sebagai redirect_uri & harus reachable dari # browser (DBeaver membuka browser ke sini). JANGAN pakai hostname internal. sr_mysql "CREATE SECURITY INTEGRATION sr_oidc PROPERTIES ( 'type' = 'authentication_oauth2', 'auth_server_url' = '${kc_base}/realms/${SICABE_REALM:-sicabe}/protocol/openid-connect/auth', 'token_server_url' = '${kc_internal}/realms/${SICABE_REALM:-sicabe}/protocol/openid-connect/token', 'client_id' = 'starrocks', 'client_secret' = '${STARROCKS_CLIENT_SECRET}', 'redirect_url' = '${sr_redirect_url}', 'jwks_url' = '${kc_internal}/realms/${SICABE_REALM:-sicabe}/protocol/openid-connect/certs', 'principal_field' = 'preferred_username', 'required_issuer' = '${kc_base}/realms/${SICABE_REALM:-sicabe}', 'required_audience' = 'starrocks' );" && echo -e "${OK} Security integration 'sr_oidc' dibuat." || echo -e "${WARN} CREATE SECURITY INTEGRATION gagal." else echo -e "${OK} Security integration 'sr_oidc' sudah ada." fi # ── 3. Group provider LDAP (OpenLDAP) ─────────────────────────────────── echo -e "${INFO} [3/6] Group provider LDAP..." # ALTER GROUP PROVIDER TIDAK didukung (uji: syntax error) → DROP+CREATE agar # ldap_conn_url/dn selalu mengikuti env saat re-run (mis. LDAP existing). sr_mysql "DROP GROUP PROVIDER sr_ldap;" >/dev/null 2>&1 || true sr_mysql "CREATE GROUP PROVIDER sr_ldap PROPERTIES ( 'type' = 'ldap', 'ldap_conn_url' = '${LDAP_URL:-ldap://openldap:389}', 'ldap_bind_root_dn' = '${LDAP_ADMIN_DN:-cn=admin,dc=sicabe,dc=local}', 'ldap_bind_root_pwd' = '${LDAP_ADMIN_PASSWORD}', 'ldap_bind_base_dn' = '${LDAP_BASE_DN:-dc=sicabe,dc=local}', 'ldap_group_filter' = '(objectClass=groupOfNames)', 'ldap_group_identifier_attr' = 'cn', 'ldap_group_member_attr' = 'member', 'ldap_user_search_attr' = 'uid', 'ldap_cache_refresh_interval' = '900' );" && echo -e "${OK} Group provider 'sr_ldap' dibuat/disinkronkan." || echo -e "${WARN} CREATE GROUP PROVIDER gagal." sr_mysql "ALTER SECURITY INTEGRATION sr_oidc SET ( 'redirect_url' = '${sr_redirect_url}', 'group_provider' = 'sr_ldap', 'permitted_groups' = 'data-admin,data-engineer,data-analyst,data-viewer', 'auth_server_url' = '${kc_base}/realms/${SICABE_REALM:-sicabe}/protocol/openid-connect/auth', 'token_server_url' = '${kc_internal}/realms/${SICABE_REALM:-sicabe}/protocol/openid-connect/token', 'jwks_url' = '${kc_internal}/realms/${SICABE_REALM:-sicabe}/protocol/openid-connect/certs', 'required_issuer' = '${kc_base}/realms/${SICABE_REALM:-sicabe}', 'client_secret' = '${STARROCKS_CLIENT_SECRET}' );" >/dev/null 2>&1 && echo -e "${OK} sr_oidc disinkronkan (issuer/secret/endpoint + group provider)." # ── 4. Roles + GRANT ke EXTERNAL GROUP ────────────────────────────────── echo -e "${INFO} [4/6] Roles + GRANT ke EXTERNAL GROUP..." for role in sr_admin sr_engineer sr_analyst sr_viewer; do sr_mysql "CREATE ROLE ${role};" >/dev/null 2>&1 || true done for pair in "sr_admin:data-admin" "sr_engineer:data-engineer" "sr_analyst:data-analyst" "sr_viewer:data-viewer"; do local role="${pair%%:*}" grp="${pair##*:}" sr_mysql "GRANT ${role} TO EXTERNAL GROUP \`${grp}\`;" >/dev/null 2>&1 \ && echo -e "${OK} GRANT ${role} → EXTERNAL GROUP ${grp}" \ || echo -e "${WARN} GRANT ${role} → ${grp} (mungkin sudah ada)" done # Catatan: activate_all_roles_on_login & authentication_chain diterapkan di # langkah [5] oleh scripts/starrocks-reapply-dynamic-config.sh (satu tempat). # ── 5. Dynamic config (auth chain + role on login) ────────────────────── # TIDAK bisa ditaruh di fe.conf: `sr_oidc` baru ada setelah SQL di atas, # sedangkan fe.conf dibaca saat FE boot (saat integrasi belum ada → error). # Karena itu diterapkan lewat skrip terpisah yang JUGA bisa dijalankan ulang # setiap kali FE restart: scripts/starrocks-reapply-dynamic-config.sh echo -e "${INFO} [5/6] Dynamic config (auth chain OIDC)..." local dc_out if dc_out=$("${SCRIPT_DIR}/scripts/starrocks-reapply-dynamic-config.sh" 2>&1); then echo -e "${OK} authentication_chain memuat ${STARROCKS_OIDC_INTEGRATION:-sr_oidc} + activate_all_roles_on_login" else echo -e "${WARN} Dynamic config belum diterapkan — jalankan manual:" echo "${dc_out}" | sed 's/^/ /' fi # ── 6. External Catalog Iceberg via Nessie ────────────────────────────── echo -e "${INFO} [6/6] External Catalog Iceberg via Nessie..." sr_mysql "DROP CATALOG IF EXISTS iceberg_catalog;" >/dev/null 2>&1 || true sr_mysql "CREATE EXTERNAL CATALOG iceberg_catalog PROPERTIES ( 'type' = 'iceberg', 'iceberg.catalog.type' = 'rest', 'iceberg.catalog.uri' = '${NESSIE_URL:-http://nessie:19120}/iceberg/', 'iceberg.catalog.warehouse' = 'warehouse', 'iceberg.catalog.rest.nested-namespace-enabled' = 'true', 'aws.s3.endpoint' = '${S3_ENDPOINT:-http://minio:9000}', 'aws.s3.access_key' = '${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}', 'aws.s3.secret_key' = '${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD}}', 'aws.s3.enable_path_style_access' = 'true', 'aws.s3.region' = '${S3_REGION:-us-east-1}' );" 2>/dev/null && echo -e "${OK} External Catalog 'iceberg_catalog' dibuat." || \ echo -e "${WARN} External Catalog gagal/mungkin sudah ada." sr_mysql "SHOW CATALOGS;" | grep -q "iceberg_catalog" && echo -e "${OK} Catalog 'iceberg_catalog' terverifikasi." echo -e "${OK} ${GREEN}Step 5 selesai — StarRocks terkonfigurasi.${NC}" echo "" } print_starrocks_sql() { cat </dev/null | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('metadatabase',{}).get('status',''))" 2>/dev/null) if [[ "${health}" == "healthy" ]]; then echo -e "${OK} Airflow siap." break fi if [[ $i -eq 30 ]]; then echo -e "${WARN} Airflow belum siap." return 0 fi sleep 3 done # Airflow 3.x menghapus /api/v1/security/login. # Setup connections via Airflow CLI dalam container. echo -e "${INFO} Setup connections via Airflow CLI..." if docker exec sicabe-airflow-webserver airflow connections list >/dev/null 2>&1; then docker exec sicabe-airflow-webserver airflow connections delete 'minio_s3' 2>/dev/null || true docker exec sicabe-airflow-webserver airflow connections add 'minio_s3' \ --conn-type 'aws' \ --conn-login "${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}" \ --conn-password "${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD}}" \ --conn-extra "{\"endpoint_url\": \"${S3_ENDPOINT:-http://minio:9000}\", \"region_name\": \"${S3_REGION:-us-east-1}\"}" echo -e "${OK} Connection 'minio_s3' dibuat." # 'trino_default' SENGAJA TIDAK dibuat (keputusan review Fase 5): tidak ada DAG # yang memakainya (task memakai dbt HTTPS 8443 + Sling), sedangkan versi lama # menunjuk http://trino:8080 tanpa kredensial → selalu 403 (lihat §7.6). # Baris berikut menyapu sisa connection lama agar instalasi konvergen. docker exec sicabe-airflow-webserver airflow connections delete 'trino_default' 2>/dev/null || true # ── OpenLineage → OpenMetadata (eksperimen experiments/lineage-airflow-om) ─ # Konfigurasi transport disimpan di KONEKSI Airflow (bukan .env) supaya token # lineage TIDAK ikut tersebar di berkas/plaintext env. Dua koneksi: # openlineage_config : extra = {"transport": {...}} — endpoint OM + # shim `eventTime` (ol_time_shim.ZuluHttpTransport) # openmetadata_lineage_api : password = token bot `lineage-bot` (UNLIMITED) # `auth.type = airflow_connection_api_key` akan diganti provider menjadi # {"type":"api_key","apiKey":}. # Provider tetap INERT sampai AIRFLOW_OPENLINEAGE_CONFIG_CONN_ID diisi — # lihat catatan di config/environment.defaults.sh. OM_BASE="${OPENMETADATA_URL:-http://localhost:${OPENMETADATA_PORT:-12085}}" OM_LOGIN_EMAIL="${OPENMETADATA_ADMIN_EMAIL:-admin@open-metadata.org}" OM_LOGIN_PASS="${OPENMETADATA_ADMIN_PASSWORD:-openmetadata123}" OM_PASS64="$(printf '%s' "${OM_LOGIN_PASS}" | base64)" # JANGAN pakai `curl -sf` di dalam assignment ini. Dengan `set -euo pipefail`, curl # yang gagal (mis. login ditolak karena password admin OpenMetadata berbeda dari # .env — OM 2.0.x sering memakai 'admin') membuat ASSIGNMENT-nya gagal, dan itu # MEMATIKAN seluruh step 6 diam-diam. Akibatnya blok penyalinan DAG di bawah tidak # pernah jalan dan gate fase 5 melaporkan "DAG 'sicabe_batch_daily' tidak ditemukan" # tanpa sebab yang jelas. `curl -s` (tanpa -f) + `|| true` menyerahkan kasus itu ke # jalur WARN yang memang sudah disediakan di bawah. OM_ADMIN_TOKEN="" local _om_pw _om_b64 for _om_pw in "${OM_LOGIN_PASS}" "admin"; do [[ -n "${_om_pw}" ]] || continue _om_b64="$(printf '%s' "${_om_pw}" | base64)" OM_ADMIN_TOKEN="$(curl -s -X POST "${OM_BASE}/api/v1/users/login" \ -H 'Content-Type: application/json' \ -d "{\"email\":\"${OM_LOGIN_EMAIL}\",\"password\":\"${_om_b64}\"}" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('accessToken',''))" 2>/dev/null || true)" [[ -n "${OM_ADMIN_TOKEN}" ]] && break done if [[ -n "${OM_ADMIN_TOKEN}" ]]; then BOT_ID="$(curl -s "${OM_BASE}/api/v1/users?limit=100" -H "Authorization: Bearer ${OM_ADMIN_TOKEN}" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(next((u['id'] for u in d.get('data',[]) if u.get('isBot') or u.get('name')=='lineage-bot'), ''))" 2>/dev/null)" BOT_TOKEN="" if [[ -n "${BOT_ID}" ]]; then BOT_TOKEN="$(curl -s -X POST "${OM_BASE}/api/v1/users/generateToken" \ -H "Authorization: Bearer ${OM_ADMIN_TOKEN}" -H 'Content-Type: application/json' \ -d "{\"JWTTokenExpiry\":\"Unlimited\",\"id\":\"${BOT_ID}\"}" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('JWTToken',''))" 2>/dev/null)" fi if [[ -n "${BOT_TOKEN}" ]]; then docker exec sicabe-airflow-webserver airflow connections delete 'openmetadata_lineage_api' 2>/dev/null || true docker exec sicabe-airflow-webserver airflow connections add 'openmetadata_lineage_api' \ --conn-type 'generic' --conn-password "${BOT_TOKEN}" >/dev/null OL_TRANSPORT="{\"transport\":{\"type\":\"ol_time_shim.ZuluHttpTransport\",\"url\":\"http://${OPENMETADATA_HOST:-openmetadata-server}:8585/api/v1/openlineage/\",\"endpoint\":\"lineage\",\"auth\":{\"type\":\"airflow_connection_api_key\",\"conn_id\":\"openmetadata_lineage_api\"}}}" docker exec sicabe-airflow-webserver airflow connections delete 'openlineage_config' 2>/dev/null || true docker exec sicabe-airflow-webserver airflow connections add 'openlineage_config' \ --conn-type 'generic' --conn-extra "${OL_TRANSPORT}" >/dev/null echo -e "${OK} Connections OpenLineage dibuat (openlineage_config + openmetadata_lineage_api)." echo -e "${INFO} Aktifkan: set AIRFLOW_OPENLINEAGE_CONFIG_CONN_ID=openlineage_config lalu recreat webserver+scheduler." else echo -e "${WARN} Token bot 'lineage-bot' tidak diperoleh — OpenLineage dibiarkan inert." fi else echo -e "${WARN} Login OpenMetadata gagal — OpenLineage dibiarkan inert." fi else echo -e "${WARN} Gagal akses Airflow CLI." fi # Copy contoh DAG echo -e "${INFO} Menyalin contoh DAG..." if docker exec sicabe-airflow-webserver ls /opt/airflow/dags/ >/dev/null 2>&1; then docker cp "${SCRIPT_DIR}/config/airflow/dags/example_sicabe_pipeline.py" \ sicabe-airflow-webserver:/opt/airflow/dags/ 2>/dev/null && \ echo -e "${OK} Contoh DAG disalin." || \ echo -e "${WARN} Gagal menyalin DAG. Salin manual: docker cp config/airflow/dags/ sicabe-airflow-webserver:/opt/airflow/dags/" fi # Aktifkan DAG contoh. Default Airflow adalah PAUSED, sehingga pipeline harian tidak # akan jalan sampai diaktifkan. Dilakukan di sini (skrip provisioning, yang memang # memiliki DAG-nya) supaya instalasi baru langsung siap. Ada retry singkat karena # dagbag bisa belum memuat berkas yang baru disalin. local _i _up=0 for _i in 1 2 3 4 5 6; do if docker exec sicabe-airflow-webserver airflow dags unpause sicabe_batch_daily >/dev/null 2>&1; then _up=1; break fi sleep 5 done if [[ ${_up} -eq 1 ]]; then echo -e "${OK} DAG 'sicabe_batch_daily' diaktifkan (unpause)." else echo -e "${WARN} Gagal mengaktifkan DAG 'sicabe_batch_daily' — jalankan manual:" echo -e "${WARN} docker exec sicabe-airflow-webserver airflow dags unpause sicabe_batch_daily" fi # Seed SUMBER contoh pipeline (PostgreSQL sicabe_data.public.orders). # Idempoten. Tanpa ini, DAG sicabe_batch_daily gagal di task ingest_to_bronze # dengan pesan database sicabe_data tidak ditemukan. echo -e "${INFO} Menyiapkan sumber contoh pipeline (sicabe_data.public.orders)..." if [[ -x "${SCRIPT_DIR}/scripts/seed-source-postgres.sh" ]]; then local seed_rc=0 "${SCRIPT_DIR}/scripts/seed-source-postgres.sh" >/dev/null || seed_rc=$? if [[ ${seed_rc} -eq 0 ]]; then echo -e "${OK} Sumber contoh siap (sicabe_data.public.orders)." elif [[ ${seed_rc} -eq 3 ]]; then echo -e "${WARN} Sumber contoh ada DI HOST LAIN (PostgreSQL) — jalankan DI host itu:" echo -e "${WARN} ./scripts/seed-source-postgres.sh --env-file generated/host-state/.env" else echo -e "${WARN} Seeder sumber gagal (kode ${seed_rc}). Jalankan manual: ./scripts/seed-source-postgres.sh" fi else echo -e "${WARN} scripts/seed-source-postgres.sh tidak ada / tidak executable." fi echo -e "${OK} ${GREEN}Step 6 selesai — Airflow terkonfigurasi.${NC}" echo " URL: http://localhost:${AIRFLOW_PORT:-12082}" echo " Login: admin / ${AIRFLOW_ADMIN_PASSWORD}" echo "" } # ───────────────────────────────────────────────────────────────────────────── # Generate file kredensial runtime (JANGAN di-commit ke git): # - config/airflow/sling/env.yaml (kredensial Sling: PostgreSQL, Nessie, MinIO) # - config/airflow/simple_auth_manager_passwords.json (password admin Airflow) # ───────────────────────────────────────────────────────────────────────────── generate_runtime_credentials() { echo -e "${INFO} Generate file kredensial runtime (sling + airflow passwords)..." # Multi-host: tulis ke BUNDLE HOST, jangan ke config/ bersama. Endpoint di file # ini khusus host tersebut; menulisnya ke config/ bersama membuat seluruh deploy # (termasuk single-host) menunjuk alamat host itu. Bug ini NYATA: saat menguji # `bootstrap.sh --host state`, deploy/config/airflow/sling/env.yaml tertimpa # alamat contoh 10.20.0.x (berkas itu gitignored, jadi tidak terlihat git status). local cfg_base="${SCRIPT_DIR}/config" if [[ -n "${HOST_NAME:-}" ]]; then cfg_base="${SCRIPT_DIR}/generated/host-${HOST_NAME}/config" echo -e "${INFO} Mode host '${HOST_NAME}': kredensial runtime → ${cfg_base}" fi # ── 1. Generate sling env.yaml (target: Iceberg REST via Nessie) ─────────── # Sling TIDAK mendukung catalog_type=nessie → pakai Iceberg REST Nessie # (http://nessie:19120/iceberg/), tanpa auth (Nessie terbuka di network internal). local sling_dir="${cfg_base}/airflow/sling" mkdir -p "${sling_dir}" # Guard: Docker membuat DIRECTORY bila sumber bind-mount absen saat `up` → buang & buat file. sicabe_ensure_file_mount "${sling_dir}/env.yaml" 644 cat > "${sling_dir}/env.yaml" < "${pw_file}" </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 local trino_pw_file="${cfg_base}/trino/password.db" # Guard: Docker membuat DIRECTORY bila sumber bind-mount absen → buang dir sisa & buat file. sicabe_ensure_file_mount "${trino_pw_file}" 644 : > "${trino_pw_file}" # kosongkan (idempoten: jangan menumpuk baris saat re-run) local pair tuser tpass 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 tuser="${pair%%:*}"; tpass="${pair#*:}" htpasswd -nbB -C 10 "${tuser}" "${tpass}" | sed '/^$/d' >> "${trino_pw_file}" done chmod 644 "${trino_pw_file}" echo -e "${OK} Trino password.db di-generate: $(cut -d: -f1 "${trino_pw_file}" | paste -sd, -)" } # ============================================================================= # STEP 7: OpenMetadata — Konektor (Trino, Airflow) (bab 5.5) # ============================================================================= # Daftarkan service/konektor ke OpenMetadata — kegagalan TIDAK disembunyikan. # Sebelumnya `curl -sf … || echo "mungkin sudah ada"` menutupi error nyata # (mis. field 'verifySSL' yang tidak dikenal skema koneksi OM 2.0.2) sehingga konektor tidak # pernah terbuat padahal laporannya tampak sukses. om_register_service() { #