#!/usr/bin/env bash # ============================================================================= # eBdesk SiCABE — Verifikasi Phase 6 (Governance Lanjutan): OpenMetadata + Grafana # # Gate "kontrak governance": memastikan katalog metadata (OpenMetadata) dan # observability (Grafana) benar-benar SIAP dipakai — termasuk saat keduanya # EXISTING (dikelola sistem lain). # # Yang diperiksa: # A. Kontrak service : OM + OpenSearch + Grafana hidup & sehat, openmetadata-migrate # selesai (exit 0) # B. OpenMetadata : login (password base64) + konektor Trino & Airflow beserta # konfigurasi koneksinya (termasuk kredensial service account) # C. Grafana : Basic Auth, ketiga datasource ADA, dan — yang penting — # tiap datasource benar-benar SEHAT (bukan sekadar terdaftar) # D. Dashboard : dashboard hasil provisioning benar-benar ter-load # E. Temuan/mode : catatan OIDC mati, OpenSearch password inert, dll → WARN # # Catatan desain (mengikuti §2.10): datasource yang "terdaftar tapi mati" adalah # kegagalan kontrak fase ini, bukan hiburan — karena itu diperiksa sampai health API. # # ⚠️ Login OM yang gagal berulang dapat mengunci akun admin → gate hanya mencoba SEKALI. # # Exit code: 0 = semua gate lolos (WARN/SKIP boleh), 1 = ada FAIL. # # Usage: # ./scripts/verify-phase6-governance.sh # OPENMETADATA_URL=http://om.corp:8585 GRAFANA_URL=http://grafana.corp:3000 \ # ./scripts/verify-phase6-governance.sh # ============================================================================= set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=../lib/common.sh source "${SCRIPT_DIR}/lib/common.sh" # shellcheck source=../config/environment.defaults.sh source "${SCRIPT_DIR}/config/environment.defaults.sh" sicabe_load_env "${SCRIPT_DIR}" "$@" # Config RELEVAN: mode host (--env-file) = BUNDLE host (ter-render), bukan deploy/config/ sumber. if [[ -n "${SICABE_BUNDLE_DIR:-}" ]]; then CONFIG_DIR="${SICABE_BUNDLE_DIR}/config"; else CONFIG_DIR="${SCRIPT_DIR}/config"; fi # Penanda EXTERNAL dihitung dari nilai ENV (operator) SEBELUM endpoint diarahkan ke # alamat host pemilik (multi-host) — jangan sampai alamat host pemilik dianggap "external". OM_EXTERNAL=0 [[ -n "${OPENMETADATA_URL:-}" && "${OPENMETADATA_URL}" != *localhost* && "${OPENMETADATA_URL}" != *127.0.0.1* ]] && OM_EXTERNAL=1 GF_EXTERNAL=0 [[ -n "${GRAFANA_URL:-}" && "${GRAFANA_URL}" != *localhost* && "${GRAFANA_URL}" != *127.0.0.1* ]] && GF_EXTERNAL=1 OM_URL="$(sicabe_local_url http OPENMETADATA_HOST "${OPENMETADATA_PORT:-12085}" OPENMETADATA_URL)" GRAFANA_URL="$(sicabe_local_url http GRAFANA_HOST "${GRAFANA_PORT:-12300}" GRAFANA_URL)" GF_AUTH="admin:${GRAFANA_ADMIN_PASSWORD:-grafana123}" OM_ADMIN_EMAIL="${OPENMETADATA_ADMIN_EMAIL:-admin@open-metadata.org}" OM_ADMIN_PASS="${OPENMETADATA_ADMIN_PASSWORD:-openmetadata123}" OM_TRINO_SERVICE="${OPENMETADATA_TRINO_SERVICE:-trino_sicabe}" OM_AIRFLOW_SERVICE="${OPENMETADATA_AIRFLOW_SERVICE:-airflow_sicabe}" GF_DASHBOARD="${GRAFANA_DASHBOARD:-sicabe_overview}" # Array (bukan string ber-spasi): "MinIO Metrics" mengandung spasi. # 'OpenMetadata' TIDAK termasuk: pluginnya tidak ada di katalog Grafana. EXPECTED_DATASOURCES=("Prometheus" "MinIO Metrics") # (OM_EXTERNAL / GF_EXTERNAL sudah dihitung di ATAS, dari nilai ENV operator.) PASS_N=0 FAIL_N=0 WARN_N=0 SKIP_N=0 pass() { echo -e " ${PASS} $1"; PASS_N=$((PASS_N + 1)); } FAIL_MSGS=() fail() { echo -e " ${FAIL} $1"; FAIL_N=$((FAIL_N + 1)); FAIL_MSGS+=("$1"); } warn() { echo -e " ${WARN} $1"; WARN_N=$((WARN_N + 1)); } skip() { echo -e " ${YELLOW}[SKIP]${NC} $1"; SKIP_N=$((SKIP_N + 1)); } container_running() { docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^$1$" } om_api() { # om_api [token] [body] local method="$1" path="$2" token="${3:-}" body="${4:-}" if [[ -n "${body}" ]]; then curl -s --max-time 60 -X "${method}" "${OM_URL}${path}" \ ${token:+-H "Authorization: Bearer ${token}"} \ -H "Content-Type: application/json" -d "${body}" 2>/dev/null else curl -s --max-time 30 -X "${method}" "${OM_URL}${path}" \ ${token:+-H "Authorization: Bearer ${token}"} 2>/dev/null fi } # ───────────────────────────────────────────────────────────────────────────── # A. Kontrak service # ───────────────────────────────────────────────────────────────────────────── check_service() { echo -e "${BOLD}A. Kontrak service${NC}" local c for c in sicabe-openmetadata sicabe-grafana; do if ! sicabe_host_expects_container "${c}"; then skip "Container ${c} di host lain (mode host) - dilewati" elif container_running "${c}"; then pass "Container ${c} berjalan" elif [[ "${c}" == "sicabe-openmetadata" && ${OM_EXTERNAL} -eq 1 ]] || [[ "${c}" == "sicabe-grafana" && ${GF_EXTERNAL} -eq 1 ]]; then skip "${c} dikelola eksternal — lewati cek container" else fail "Container ${c} tidak berjalan" fi done if ! sicabe_host_expects_container sicabe-opensearch; then skip "Container sicabe-opensearch di host lain (mode host) - dilewati" elif container_running sicabe-opensearch; then pass "Container sicabe-opensearch berjalan" elif [[ ${OM_EXTERNAL} -eq 1 ]]; then skip "OpenSearch dikelola eksternal — lewati cek container" else fail "Container sicabe-opensearch tidak berjalan" fi # one-shot migrate: harus ADA dan sukses (exit 0) local mig_status mig_status="$(docker inspect -f '{{.State.Status}} {{.State.ExitCode}}' sicabe-openmetadata-migrate 2>/dev/null || echo "")" if [[ -z "${mig_status}" ]]; then warn "Container one-shot sicabe-openmetadata-migrate tidak ditemukan (schema OM mungkin belum di-migrate)" elif [[ "${mig_status}" == "exited 0" ]]; then pass "sicabe-openmetadata-migrate selesai dengan exit 0 (schema termigrasi)" else fail "sicabe-openmetadata-migrate status '${mig_status}' (harus 'exited 0') — OM bisa crash-loop" fi # OpenMetadata HTTP local code code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 20 "${OM_URL}/" 2>/dev/null)" if [[ "${code}" == "200" ]]; then pass "OpenMetadata menjawab HTTP 200 di ${OM_URL}" else fail "OpenMetadata tidak menjawab (HTTP ${code:-000}) di ${OM_URL}" fi local ver ver="$(om_api GET /api/v1/system/version | python3 -c " import sys,json try: print(json.load(sys.stdin).get('version','')) except Exception: print('')" 2>/dev/null)" if [[ -n "${ver}" ]]; then pass "OpenMetadata version ${ver}" else warn "Tidak bisa membaca versi OpenMetadata" fi # OpenSearch (single node → 'yellow' normal) local os_url="$(sicabe_local_url http OPENSEARCH_HOST "${OPENSEARCH_PORT:-12920}")" local os_json os_json="$(curl -s --max-time 15 "${os_url}/_cluster/health" 2>/dev/null)" if [[ -z "${os_json}" ]]; then if [[ ${OM_EXTERNAL} -eq 1 ]]; then skip "OpenSearch tidak di lokal (OM eksternal) — lewati cek cluster" else fail "OpenSearch tidak menjawab di ${os_url}/_cluster/health" fi else local os_status os_status="$(echo "${os_json}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('status','')) except Exception: print('')" 2>/dev/null)" if [[ "${os_status}" == "green" || "${os_status}" == "yellow" ]]; then pass "OpenSearch cluster status '${os_status}' (yellow normal untuk single-node)" else fail "OpenSearch cluster status '${os_status}'" fi fi # Grafana health local gf_json gf_db gf_json="$(curl -s --max-time 15 "${GRAFANA_URL}/api/health" 2>/dev/null)" gf_db="$(echo "${gf_json}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('database','')) except Exception: print('')" 2>/dev/null)" if [[ "${gf_db}" == "ok" ]]; then pass "Grafana health: database ok" else fail "Grafana tidak sehat di ${GRAFANA_URL}/api/health" fi } # ───────────────────────────────────────────────────────────────────────────── # B. OpenMetadata — login + konektor # ───────────────────────────────────────────────────────────────────────────── check_openmetadata() { echo -e "${BOLD}B. OpenMetadata — login & konektor${NC}" # Dua kandidat, MAKSIMAL DUA percobaan — jangan pernah diulang dalam loop: akun admin # OM bisa terkunci. Alasannya sama dengan pola di bootstrap step 7: OpenMetadata 2.0.x # mengabaikan OPENMETADATA_ADMIN_PASSWORD saat bootstrap dan memakai 'admin' sampai # hash-nya di-set (bootstrap step 7). Gate yang hanya mencoba password .env akan GAGAL # pada instalasi yang step 7-nya belum jalan/gagal, padahal OM sehat. local token="" try_pass pw64 for try_pass in "${OM_ADMIN_PASS}" "admin"; do [[ -n "${try_pass}" ]] || continue pw64="$(printf '%s' "${try_pass}" | base64 2>/dev/null)" token="$(om_api POST /api/v1/users/login "" \ "{\"email\":\"${OM_ADMIN_EMAIL}\",\"password\":\"${pw64}\"}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('accessToken','')) except Exception: print('')" 2>/dev/null)" [[ -n "${token}" ]] && break done if [[ -n "${token}" ]]; then if [[ "${try_pass}" == "${OM_ADMIN_PASS}" ]]; then pass "Login OpenMetadata berhasil (${OM_ADMIN_EMAIL}, password .env)" else pass "Login OpenMetadata berhasil dengan password default 'admin' — jalankan ./bootstrap.sh --step 7 agar password .env yang dipakai" fi else fail "Login OpenMetadata GAGAL (dicoba: password .env + 'admin') — jangan ulangi berkali-kali, akun admin bisa terkunci" warn " perbaiki: ./bootstrap.sh --step 7 (menyetel hash password admin di DB)" return 1 fi # ── Konektor Trino ──────────────────────────────────────────────────────── local list names list="$(om_api GET "/api/v1/services/databaseServices?limit=50" "${token}")" names="$(echo "${list}" | python3 -c " import sys,json try: print(' '.join(x['name'] for x in json.load(sys.stdin).get('data',[]))) except Exception: print('')" 2>/dev/null)" local svc svc_name svc="$(om_api GET "/api/v1/services/databaseServices/name/${OM_TRINO_SERVICE}" "${token}")" # Catatan: OM mengembalikan JSON error yang memuat nama yang diminta → cek # keberadaan HARUS dari field hasil parse, bukan substring nama. svc_name="$(echo "${svc}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('name','')) except Exception: print('')" 2>/dev/null)" if [[ "${svc_name}" == "${OM_TRINO_SERVICE}" ]]; then pass "databaseService '${OM_TRINO_SERVICE}' ada" local ok_cfg ok_cfg="$(echo "${svc}" | python3 -c " import sys,json try: c=json.load(sys.stdin).get('connection',{}).get('config',{}) # Catatan: 'verifySSL'/'sslConfig' tidak ada di skema koneksi Trino OM 2.0.2 # (mengirimnya membuat pembuatan service gagal) → tidak ikut diperiksa. checks = { 'hostPort': c.get('hostPort') == 'trino-coordinator:8443', 'username': c.get('username') == 'svc_om', 'catalog': c.get('catalog') == 'iceberg', 'scheme': c.get('scheme') == 'trino', } print(' '.join(k for k,v in checks.items() if not v)) except Exception: print('parse-error')" 2>/dev/null)" if [[ -z "${ok_cfg}" ]]; then pass " konfigurasi koneksi: trino-coordinator:8443 · svc_om · catalog iceberg · scheme trino" elif [[ "${ok_cfg}" == "parse-error" ]]; then warn " tidak bisa mem-parse konfigurasi koneksi konektor" else fail " konfigurasi koneksi menyimpang pada: ${ok_cfg}" fi # Kredensial harus sama dengan service account di .env local stored_pw stored_pw="$(echo "${svc}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('connection',{}).get('config',{}).get('authType',{}).get('password','')) except Exception: print('')" 2>/dev/null)" if [[ -z "${stored_pw}" ]]; then warn " kredensial konektor tidak terbaca (mungkin disamarkan OM)" elif [[ "${stored_pw}" == *"*"* ]]; then skip " kredensial konektor disamarkan OM → tidak dapat dibandingkan dengan .env" elif [[ "${stored_pw}" == "${TRINO_SVC_OM_PASSWORD}" ]]; then pass " kredensial konektor = TRINO_SVC_OM_PASSWORD" else fail " kredensial konektor ≠ TRINO_SVC_OM_PASSWORD (basi setelah ganti .env)" fi else fail "databaseService '${OM_TRINO_SERVICE}' tidak ditemukan" if [[ -n "${names}" ]]; then warn " service yang ada sekarang: ${names}" warn " ⚠️ bootstrap.sh --step 7 membuat '${OM_TRINO_SERVICE}' → menjalankannya akan MENDUPLIKASI" warn " samakan nama (rename service lama) atau sesuaikan bootstrap ke nama yang dipakai" fi fi # ── Konektor Airflow ────────────────────────────────────────────────────── local psvc psvc="$(om_api GET "/api/v1/services/pipelineServices/name/${OM_AIRFLOW_SERVICE}" "${token}")" if [[ -n "${psvc}" && "${psvc}" == *"${OM_AIRFLOW_SERVICE}"* ]]; then pass "pipelineService '${OM_AIRFLOW_SERVICE}' ada" if [[ "${psvc}" == *"airflow-webserver:8080"* ]]; then pass " hostPort → http://airflow-webserver:8080" else warn " hostPort konektor Airflow tidak sesuai (cek bootstrap.sh --step 7)" fi else fail "pipelineService '${OM_AIRFLOW_SERVICE}' tidak ditemukan" fi # ── Entitas hasil ingestion ─────────────────────────────────────────────── # Service ADA ≠ metadata ADA. Tanpa agent (ingestion pipeline) yang dijalankan, # service hanya menyimpan konfigurasi koneksi dan halaman Explore tetap kosong. local db_total tbl_total db_total="$(om_api GET "/api/v1/databases?service=${OM_TRINO_SERVICE}&limit=1" "${token}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('paging',{}).get('total',0)) except Exception: print('')" 2>/dev/null)" tbl_total="$(om_api GET "/api/v1/tables?service=${OM_TRINO_SERVICE}&limit=1" "${token}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('paging',{}).get('total',0)) except Exception: print('')" 2>/dev/null)" if [[ "${tbl_total}" =~ ^[0-9]+$ ]] && [[ ${tbl_total} -gt 0 ]]; then pass "Hasil ingestion ada: ${db_total:-?} database · ${tbl_total} tabel (terlihat di Explore)" local col_total col_total="$(om_api GET "/api/v1/tables?service=${OM_TRINO_SERVICE}&fields=columns&limit=20" "${token}" | python3 -c " import sys,json try: print(sum(len(t.get('columns') or []) for t in json.load(sys.stdin).get('data',[]))) except Exception: print('')" 2>/dev/null)" if [[ "${col_total}" =~ ^[0-9]+$ ]] && [[ ${col_total} -gt 0 ]]; then pass "Metadata kolom ikut ter-ingest (${col_total} kolom pada tabel yang dibaca agent)" else warn "Tabel ter-ingest TANPA kolom — cek rule FilterColumns di policy OPA (§5.6)" fi else fail "databaseService '${OM_TRINO_SERVICE}' ada tetapi TANPA entitas → Explore kosong" warn " service hanya menyimpan konfigurasi koneksi; metadata diambil oleh AGENT." warn " jalankan: ./scripts/ingest-openmetadata.sh (lihat §8.7)" fi # Agent terdaftar (untuk refresh berkala)? local pipes pipes="$(om_api GET "/api/v1/services/ingestionPipelines?limit=50&service=${OM_TRINO_SERVICE}" "${token}" | python3 -c " import sys,json try: print(len(json.load(sys.stdin).get('data',[]))) except Exception: print('')" 2>/dev/null)" if [[ "${pipes}" == "0" ]]; then warn "Tidak ada ingestion pipeline terdaftar → katalog tidak ter-refresh otomatis" elif [[ -n "${pipes}" ]]; then pass "${pipes} ingestion pipeline terdaftar untuk '${OM_TRINO_SERVICE}'" fi # ── Lineage antar entitas ──────────────────────────────────────────────── # Ingestion metadata TIDAK menghasilkan lineage; lineage datang dari artefak dbt # (workflow terpisah). Jadi "tabel ada" belum berarti "relasi ada". local first_fqn edges first_fqn="$(om_api GET "/api/v1/tables?service=${OM_TRINO_SERVICE}&limit=1" "${token}" | python3 -c " import sys,json try: print(json.load(sys.stdin)['data'][0]['fullyQualifiedName']) except Exception: print('')" 2>/dev/null)" if [[ -z "${first_fqn}" ]]; then skip "Belum ada tabel untuk diuji lineage-nya" else edges="$(om_api GET "/api/v1/lineage/getLineage?fqn=${first_fqn}&upstreamDepth=3&downstreamDepth=3" "${token}" | python3 -c " import sys,json try: d=json.load(sys.stdin) print(len(d.get('upstreamEdges') or []) + len(d.get('downstreamEdges') or [])) except Exception: print('')" 2>/dev/null)" if [[ "${edges}" =~ ^[0-9]+$ ]] && [[ ${edges} -gt 0 ]]; then pass "Lineage antar entitas ada (${edges} relasi, diuji dari '${first_fqn##*.}')" else fail "Tidak ada lineage antar entitas (0 relasi) — tabel ada, relasinya belum" warn " jalankan: ./scripts/ingest-openmetadata.sh --all (lineage dari artefak dbt)" warn " prasyarat: 'dbt docs generate' di container + paket collate-dbt-artifacts-parser (§8.8)" fi fi # ── Data Quality: hasil Great Expectations harus sampai ke OpenMetadata ──── local tc_total ts_total ts_total="$(om_api GET "/api/v1/dataQuality/testSuites?limit=1" "${token}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('paging',{}).get('total',0)) except Exception: print('')" 2>/dev/null)" tc_total="$(om_api GET "/api/v1/dataQuality/testCases?limit=1" "${token}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('paging',{}).get('total',0)) except Exception: print('')" 2>/dev/null)" if [[ "${tc_total}" =~ ^[0-9]+$ ]] && [[ ${tc_total} -gt 0 ]]; then pass "Data Quality terisi: ${ts_total:-?} test suite · ${tc_total} test case" # "Ada test case" belum berarti "eksekusinya jalan": tangkap yang ABORTED. local dq_aborted dq_aborted="$(om_api GET "/api/v1/dataQuality/testCases?limit=100&fields=testCaseResult" "${token}" | python3 -c " import sys,json try: cases=json.load(sys.stdin).get('data',[]) print(sum(1 for c in cases if str((c.get('testCaseResult') or {}).get('testCaseStatus') or '').endswith('Aborted'))) except Exception: print('')" 2>/dev/null)" if [[ "${dq_aborted}" =~ ^[0-9]+$ ]] && [[ ${dq_aborted} -gt 0 ]]; then # DULU ini FAIL. Sekarang SKIP: temuan ini berasal dari Jalur B (DQ native # OpenMetadata lewat pipeline), yang (a) OPT-IN — hanya dibuat oleh # scripts/seed-native-dq.sh, tidak dipanggil bootstrap/seed-sample-data/ingest — # dan (b) terblokir bug upstream: koneksi tersimpan kehilangan secret sehingga test # selalu 'Aborted'. Kegagalan eksekusi di sini bukan regresi stack, jadi tidak # pantas memblokir fase. Jalur A (Great Expectations) tetap diverifikasi di atas. skip "${dq_aborted} test case DQ native ABORTED — keterbatasan upstream Jalur B (§8.10), bukan regresi" echo " Jalur B opt-in: ./scripts/seed-native-dq.sh (tidak dipanggil bootstrap.sh)" echo " DQ yang bekerja: Jalur A (Great Expectations) pada task validate_gold/cdc_validate" fi else fail "Belum ada Data Quality test case — hasil Great Expectations tidak terkirim ke OpenMetadata" warn " via DAG: airflow dags test sicabe_batch_daily (task validate_gold)" warn " manual : python /opt/airflow/great_expectations/validate_to_openmetadata.py (di container Airflow)" fi # ── Pipeline service client (Airflow) — menentukan tombol "Test Connection" UI ── local pipeline_status pipeline_code pipeline_status="$(om_api GET "/api/v1/services/ingestionPipelines/status" "${token}")" pipeline_code="$(echo "${pipeline_status}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('code','')) except Exception: print('')" 2>/dev/null)" if [[ "${pipeline_code}" == "200" ]]; then pass "Pipeline service client (Airflow) aktif — tombol 'Test Connection' di UI tersedia" else warn "Pipeline service client (Airflow) belum valid → tombol 'Test Connection' di UI DISABLED" warn " pesan UI 'Platform Service Client Unavailable' adalah tooltip, BUKAN plugin Trino (§8.11)" warn " periksa: PIPELINE_SERVICE_CLIENT_ENDPOINT + AIRFLOW_USERNAME/AIRFLOW_PASSWORD," warn " dan pastikan plugin openmetadata-managed-apis terpasang di image Airflow (§8.11)" fi # ── Versi server OpenMetadata ─────────────────────────────────────────────── # Plugin Airflow (openmetadata-managed-apis) dan action GE Jalur A mengharapkan # server 2.0.x; di 1.13.x keduanya tidak tersedia/ditolak (§8.11). local om_version om_version="$(om_api GET "/api/v1/system/version" "${token}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('version','')) except Exception: print('')" 2>/dev/null)" if [[ "${om_version}" == 2.* ]]; then pass "Versi OpenMetadata ${om_version} — sesuai kebutuhan plugin Airflow + Jalur A" elif [[ -n "${om_version}" ]]; then fail "Versi OpenMetadata ${om_version} — jalur ini mengharapkan 2.0.x (plugin + action GE Jalur A)" fi } # ───────────────────────────────────────────────────────────────────────────── # C. Grafana — datasource & KESEHATANNYA # ───────────────────────────────────────────────────────────────────────────── check_grafana() { echo -e "${BOLD}C. Grafana — datasource${NC}" local user_json user_json="$(curl -s --max-time 15 -u "${GF_AUTH}" "${GRAFANA_URL}/api/user" 2>/dev/null)" if [[ -n "${user_json}" && "${user_json}" == *"login"* ]]; then pass "Grafana API bisa diakses (Basic Auth admin)" else fail "Grafana API tidak bisa diakses dengan Basic Auth" return 1 fi local ds_json ds_names ds_json="$(curl -s --max-time 20 -u "${GF_AUTH}" "${GRAFANA_URL}/api/datasources" 2>/dev/null)" ds_names="$(echo "${ds_json}" | python3 -c " import sys,json try: print('\n'.join(x['name'] for x in json.load(sys.stdin))) except Exception: pass" 2>/dev/null)" local nm missing="" for nm in "${EXPECTED_DATASOURCES[@]}"; do grep -qxF "${nm}" <<< "${ds_names}" || missing+=" [${nm}]" done if [[ -z "${missing}" ]]; then pass "${#EXPECTED_DATASOURCES[@]} datasource terdaftar (${EXPECTED_DATASOURCES[*]})" else fail "Datasource hilang:${missing}" fi # Health tiap datasource — "terdaftar" belum berarti "berfungsi". # Iterasi via while-read (newline) supaya nama ber-spasi tidak terpecah. local uid name status msg while IFS='|' read -r uid name; do [[ -z "${uid}" ]] && continue local h h="$(curl -s --max-time 25 -u "${GF_AUTH}" "${GRAFANA_URL}/api/datasources/uid/${uid}/health" 2>/dev/null)" status="$(echo "${h}" | python3 -c " import sys,json try: print(json.load(sys.stdin).get('status') or '') except Exception: print('')" 2>/dev/null)" msg="$(echo "${h}" | python3 -c " import sys,json try: print(str(json.load(sys.stdin).get('message'))[:100]) except Exception: print('tidak ada respon')" 2>/dev/null)" if [[ "${status}" == "OK" ]]; then pass "datasource '${name}': sehat" else fail "datasource '${name}': ${msg:-status '${status:-tidak terjawab}'} (terdaftar ≠ berfungsi)" fi done < <(echo "${ds_json}" | python3 -c " import sys,json try: for x in json.load(sys.stdin): print('{0}|{1}'.format(x['uid'], x['name'])) except Exception: pass" 2>/dev/null) } # ───────────────────────────────────────────────────────────────────────────── # D. Dashboard hasil provisioning # ───────────────────────────────────────────────────────────────────────────── check_dashboards() { echo -e "${BOLD}D. Dashboard provisioning${NC}" local conf="${CONFIG_DIR}/grafana/dashboards/dashboards.yaml" if [[ -f "${conf}" ]]; then pass "Berkas provisioning dashboard ada" else fail "config/grafana/dashboards/dashboards.yaml tidak ada" fi local search search="$(curl -s --max-time 20 -u "${GF_AUTH}" "${GRAFANA_URL}/api/search?type=dash-db" 2>/dev/null)" if [[ -z "${search}" ]]; then fail "Tidak bisa membaca daftar dashboard dari Grafana" return 1 fi local count count="$(echo "${search}" | python3 -c " import sys,json try: print(len(json.load(sys.stdin))) except Exception: print('0')" 2>/dev/null)" if [[ "${search}" == *"SiCABE"* ]]; then pass "Dashboard SiCABE ter-load (${count} dashboard terdaftar)" else fail "Dashboard SiCABE tidak ter-load (${count} dashboard) — cek provisioning/JSON" fi # Bandingkan dengan judul nyata di berkas provisioning. Perbandingan dilakukan # di sisi JSON (bukan grep pada raw response) karena judul memuat karakter # non-ASCII (em dash) yang di-escape menjadi \u2014 pada respons HTTP. local dash_file="${CONFIG_DIR}/grafana/dashboards/sicabe/${GF_DASHBOARD}.json" local cmp cmp="$(python3 -c " import json try: want = json.load(open('${dash_file}')).get('title','') except Exception: print('NO_FILE'); raise SystemExit import urllib.request, base64 req = urllib.request.Request('${GRAFANA_URL}/api/search?type=dash-db') req.add_header('Authorization', 'Basic ' + base64.b64encode( ('admin:${GRAFANA_ADMIN_PASSWORD:-grafana123}').encode()).decode()) try: titles = [d.get('title','') for d in json.load(urllib.request.urlopen(req, timeout=20))] except Exception: print('NO_SEARCH'); raise SystemExit print('FOUND' if want in titles else 'MISSING:' + want) " 2>/dev/null)" case "${cmp}" in FOUND) pass "Dashboard berjudul sama dengan berkas provisioning ada di Grafana" ;; NO_FILE) skip "Berkas dashboard ${GF_DASHBOARD}.json tidak terbaca — lewati cek judul" ;; NO_SEARCH) warn "Tidak bisa membandingkan judul dashboard (Grafana API)" ;; *) warn "Judul dashboard tidak ditemukan di Grafana (${cmp:-tidak diketahui})" ;; esac } # ───────────────────────────────────────────────────────────────────────────── # D2. Data nyata tiap panel — datasource SEHAT belum berarti panel BERISI # ───────────────────────────────────────────────────────────────────────────── # Kelas bug yang pernah lolos: target Prometheus 'up' dan datasource sehat, tetapi # panel tetap "No data" karena metrik yang diminta memang tidak diekspor pada # endpoint yang di-scrape (kasus MinIO: metrik bucket ada di /bucket, bukan /cluster). check_panel_data() { echo -e "${BOLD}D2. Data nyata tiap panel (query lewat datasource Grafana)${NC}" local dash_file="${CONFIG_DIR}/grafana/dashboards/sicabe/${GF_DASHBOARD}.json" if [[ ! -f "${dash_file}" ]]; then skip "Berkas dashboard tidak ada — lewati cek data panel" return 0 fi local ds_json ds_json="$(curl -s --max-time 20 -u "${GF_AUTH}" "${GRAFANA_URL}/api/datasources" 2>/dev/null)" local dsname expr uid n while IFS='|' read -r dsname expr; do [[ -z "${expr}" ]] && continue uid="$(echo "${ds_json}" | python3 -c " import sys,json try: for x in json.load(sys.stdin): if x['name'] == sys.argv[1]: print(x['uid']); break except Exception: pass" "${dsname}" 2>/dev/null)" if [[ -z "${uid}" ]]; then fail "panel '${expr:0:44}': datasource '${dsname}' tidak ada di Grafana" continue fi n="$(EXPR="${expr}" DS_UID="${uid}" GF_AUTH="${GF_AUTH}" GURL="${GRAFANA_URL}" python3 - <<'PY' 2>/dev/null import base64, json, os, urllib.request body = json.dumps({ "queries": [{"refId": "A", "datasource": {"uid": os.environ["DS_UID"]}, "expr": os.environ["EXPR"], "instant": True}], "from": "now-15m", "to": "now"}).encode() req = urllib.request.Request(os.environ["GURL"] + "/api/ds/query", data=body) req.add_header("Content-Type", "application/json") req.add_header("Authorization", "Basic " + base64.b64encode(os.environ["GF_AUTH"].encode()).decode()) try: res = json.load(urllib.request.urlopen(req, timeout=25))["results"]["A"] if res.get("error"): print("ERR") else: total = 0 for f in res.get("frames", []): vals = f.get("data", {}).get("values") or [] if vals: total += len(vals[0]) print(total) except Exception: print("ERR") PY )" if [[ "${n}" =~ ^[0-9]+$ ]] && [[ ${n} -gt 0 ]]; then pass "panel (${dsname}) '${expr:0:40}' → ${n} data point" elif [[ "${n}" == "ERR" ]]; then warn "panel '${expr:0:40}': query gagal dijalankan lewat Grafana" else warn "panel '${expr:0:40}': TANPA data — metrik tidak diproduksi stack (lihat §8.6)" fi done < <(python3 -c " import json try: d = json.load(open('${dash_file}')) for p in d.get('panels', []): ds = p.get('datasource') name = ds.get('type') if isinstance(ds, dict) else ds for t in p.get('targets', []): e = t.get('expr') or t.get('query') or '' if e: print('{0}|{1}'.format(name, e)) except Exception: pass" 2>/dev/null) } # ───────────────────────────────────────────────────────────────────────────── # E. Temuan statis & mode existing (WARN/SKIP) # ───────────────────────────────────────────────────────────────────────────── check_findings() { echo -e "${BOLD}E. Temuan statis (informatif)${NC}" local compose="${SCRIPT_DIR}/docker-compose.yml" # OpenSearch 3.x: security plugin dimatikan lewat DISABLE_SECURITY_PLUGIN, dan # tidak boleh ada kredensial admin yang ditulis di compose. # Catatan: pola di-anchor ke awal baris agar KOMENTAR yang menyebut nama # variabel (mis. penjelasan why di compose) tidak memicu peringatan palsu. if grep -qE '^[[:space:]]*OPENSEARCH_INITIAL_ADMIN_PASSWORD[[:space:]]*:' "${compose}" 2>/dev/null; then warn "OpenSearch: kredensial admin hardcode di compose — hapus; security dimatikan lewat DISABLE_SECURITY_PLUGIN" elif grep -q 'DISABLE_SECURITY_PLUGIN: "true"' "${compose}" 2>/dev/null; then pass "OpenSearch: security plugin dimatikan lewat DISABLE_SECURITY_PLUGIN (gaya 3.x), tanpa kredensial di compose" else warn "OpenSearch: DISABLE_SECURITY_PLUGIN tidak diset — periksa konfigurasi security" fi # OpenMetadata: OIDC masih dikomentari → SSO Keycloak belum aktif if grep -qE "^\s*#\s*OIDC_CLIENT_ID" "${compose}" 2>/dev/null; then warn "OpenMetadata: AUTHENTICATION_PROVIDER=basic — blok OIDC Keycloak masih dikomentari (SSO belum aktif)" fi # Grafana: OIDC aktif? role mapping mengandalkan klaim 'roles' if grep -q "GF_AUTH_GENERIC_OAUTH_ENABLED" "${compose}" 2>/dev/null; then pass "Grafana: OIDC Keycloak aktif (perlu klaim 'roles' pada token)" else warn "Grafana: OIDC Keycloak tidak aktif" fi if ! sicabe_host_expects_container sicabe-airflow-webserver; then skip "Container sicabe-airflow-webserver di host lain (mode host) - dilewati" elif container_running sicabe-airflow-webserver; then pass "Airflow berjalan — konektor already terdaftar di OpenMetadata (Phase 5)" fi } # ───────────────────────────────────────────────────────────────────────────── # Main # ───────────────────────────────────────────────────────────────────────────── main() { print_section "Verifikasi Phase 6 — Kontrak Governance (OpenMetadata + Grafana)" echo -e " OpenMetadata: ${CYAN}${OM_URL}${NC} (+ OpenSearch :${OPENSEARCH_PORT:-12920})" echo -e " Grafana : ${CYAN}${GRAFANA_URL}${NC} (admin) · dashboard: ${GF_DASHBOARD}" echo "" check_service check_openmetadata check_grafana check_dashboards check_panel_data check_findings echo "" echo -e "${BOLD}Ringkasan:${NC} ${GREEN}${PASS_N} PASS${NC} · ${RED}${FAIL_N} FAIL${NC} · ${YELLOW}${WARN_N} WARN${NC} · ${YELLOW}${SKIP_N} SKIP${NC}" if [[ ${FAIL_N} -gt 0 ]]; then echo "" echo -e "${BOLD}Yang gagal (${FAIL_N}):${NC}" local _i=1 _m for _m in "${FAIL_MSGS[@]}"; do echo -e " ${RED}${_i}.${NC} ${_m}" _i=$((_i + 1)) done echo -e "${INFO} Perbaiki item di atas, lalu jalankan ulang: ./scripts/${0##*/}" fi if [[ ${FAIL_N} -eq 0 ]]; then echo -e "${OK} ${GREEN}Kontrak governance Phase 6 terpenuhi — seluruh 6 fase lengkap.${NC}" exit 0 fi echo -e "${ERR} Ada ${FAIL_N} gate yang gagal — perbaiki sebelum melanjutkan." exit 1 } main "$@"