702 lines
34 KiB
Bash
Executable File
702 lines
34 KiB
Bash
Executable File
#!/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 <method> <path> [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 "$@"
|