Files

211 lines
8.9 KiB
Python
Raw Permalink Normal View History

2026-10-09 02:35:14 +07:00
#!/usr/bin/env python
# =============================================================================
# eBdesk SiCABE — seed contoh Data Quality NATIVE OpenMetadata (Jalur B)
# =============================================================================
# Mengapa skrip ini ada:
# Test case native TIDAK bisa diandalkan lewat TestRunner SDK yang berjalan di
# klien: jalur itu memakai koneksi TERSIMPAN yang kehilangan secret sehingga
# selalu berakhir `Aborted` dengan `401 Access Denied` (bug upstream, masih ada
# di OM 2.0.2 — kredensial sudah dipastikan valid).
# Jalur yang bekerja: definisi test case dibuat lewat REST API, lalu eksekusi
# DIPICU OpenMetadata lewat pipeline service client (plugin → Airflow). Koneksi
# Trino dibawa EKSPLISIT di `sourceConfig.serviceConnections`, sehingga tidak
# bergantung pada koneksi tersimpan.
#
# Idempoten: test case diperiksa berdasarkan nama; pipeline diperiksa berdasarkan
# nama. Menjalankan ulang tidak menggandakan apa pun.
#
# Dipanggil oleh: scripts/seed-native-dq.sh (dari host) — atau manual:
# docker exec -e OM_SERVICE=trino_sicabe ... sicabe-airflow-webserver \
# python /opt/airflow/scripts/seed-om-native-dq.py [--trigger]
#
# Exit code: 0 = semua langkah yang diminta berhasil.
# =============================================================================
import base64
import json
import os
import sys
import urllib.error
import urllib.request
OM_API_URL = os.getenv("OM_API_URL", "http://openmetadata-server:8585/api")
ADMIN_EMAIL = os.getenv("OPENMETADATA_ADMIN_EMAIL", "admin@open-metadata.org")
SERVICE = os.getenv("OM_DATABASE_SERVICE", "trino_sicabe")
CATALOG = os.getenv("OM_DATABASE_NAME", "iceberg")
TABLE_FQN = os.getenv("OM_DQ_SEED_TABLE", f"{SERVICE}.{CATALOG}.gold.orders_gold")
PIPELINE_NAME = os.getenv("OM_DQ_SEED_PIPELINE", "sicabe_dq_gold_native")
SCHEDULE = os.getenv("OM_DQ_SEED_SCHEDULE", "0 3 * * *")
TRINO_HOSTPORT = os.getenv("OM_TRINO_HOSTPORT", "trino-coordinator:8443")
TRINO_USER = os.getenv("DBT_TRINO_USER", "svc_om")
TRINO_CERT = os.getenv("TRINO_CA_BUNDLE", "/etc/ssl/trino/trino.crt")
# Contoh test native. `parameterValues` HARUS memakai nama parameter definisi
# (tableRowCountToBeBetween: minValue/maxValue) dan columnValuesToBeNotNull
# tidak punya parameter sama sekali — kolomnya ditentukan lewat entityLink.
TEST_CASES = [
{
"name": "native_row_count_between",
"testDefinition": "tableRowCountToBeBetween",
"entityLink": f"<#E::table::{TABLE_FQN}>",
"parameterValues": [
{"name": "minValue", "value": "1"},
{"name": "maxValue", "value": "1000000"},
],
},
{
"name": "native_komoditas_not_null",
"testDefinition": "columnValuesToBeNotNull",
"entityLink": f"<#E::table::{TABLE_FQN}::columns::komoditas>",
},
]
def _request(method, path, payload=None, token=None):
"""Panggil REST API OpenMetadata; kembalikan (status, body terurai)."""
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(f"{OM_API_URL}{path}", data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as exc:
raw = exc.read()[:300]
try:
return exc.code, json.loads(raw)
except Exception: # noqa: BLE001
return exc.code, raw.decode(errors="replace")
def login():
"""Token admin: password dikirim base64 (wajib sejak OM 2.0.x).
Dua kandidat: OPENMETADATA_ADMIN_PASSWORD, lalu 'admin' — default yang dipakai
OM 2.0.x sampai bootstrap step 7 menyetel hash-nya dari .env. MAKSIMAL DUA
percobaan dan tidak pernah diulang dalam loop: akun admin OM bisa terkunci.
"""
last = "tidak ada kandidat password"
for password in (os.getenv("OPENMETADATA_ADMIN_PASSWORD"), "admin"):
if not password:
continue
body = {"email": ADMIN_EMAIL,
"password": base64.b64encode(password.encode()).decode()}
status, res = _request("POST", "/v1/users/login", body)
if status == 200 and res.get("accessToken"):
return res["accessToken"]
last = f"HTTP {status}: {str(res)[:120]}"
raise RuntimeError(f"login gagal (password .env dan 'admin'): {last}")
def ensure_test_cases(token):
"""Buat test case native yang belum ada. Kembalikan jumlah yang dibuat."""
status, page = _request("GET", "/v1/dataQuality/testCases?limit=200", token=token)
if status != 200:
raise RuntimeError(f"daftar test case tidak terbaca (HTTP {status})")
existing = {tc.get("name") for tc in page.get("data", [])}
created = 0
for spec in TEST_CASES:
if spec["name"] in existing:
print(f" [SKIP] test case sudah ada: {spec['name']}")
continue
status, res = _request("POST", "/v1/dataQuality/testCases", spec, token=token)
if status in (200, 201):
created += 1
print(f" [OK] test case dibuat: {spec['name']}")
else:
raise RuntimeError(f"gagal membuat {spec['name']} (HTTP {status}): {str(res)[:200]}")
return created
def _pipeline_payload(suite_id):
"""Payload pipeline TestSuite dengan koneksi Trino EKSPLISIT (anti-401)."""
return {
"name": PIPELINE_NAME,
"pipelineType": "TestSuite",
"service": {"id": suite_id, "type": "testSuite"},
"sourceConfig": {"config": {
"type": "TestSuite",
"entityFullyQualifiedName": TABLE_FQN,
"serviceConnections": [{
"serviceName": SERVICE,
"serviceConnection": {"config": {
"type": "Trino", "scheme": "trino", "hostPort": TRINO_HOSTPORT,
"username": TRINO_USER,
"authType": {"password": os.getenv("TRINO_SVC_OM_PASSWORD", "")},
"catalog": CATALOG, "verify": TRINO_CERT,
}},
}],
}},
"airflowConfig": {"scheduleInterval": SCHEDULE},
}
def ensure_pipeline(token):
"""Buat pipeline TestSuite bila belum ada. Kembalikan (id, dibuat_baru)."""
status, suites = _request("GET", "/v1/dataQuality/testSuites?limit=50", token=token)
if status != 200:
raise RuntimeError(f"daftar suite tidak terbaca (HTTP {status})")
suite = next((s for s in suites.get("data", []) if TABLE_FQN.rsplit(".", 1)[0] in s.get("name", "")), None)
if suite is None:
raise RuntimeError(f"test suite untuk '{TABLE_FQN}' belum ada — jalankan ingestion dulu")
status, page = _request("GET", "/v1/services/ingestionPipelines?limit=50", token=token)
for pipe in (page.get("data") or []) if status == 200 else []:
if pipe.get("name") == PIPELINE_NAME:
print(f" [SKIP] pipeline sudah ada: {PIPELINE_NAME} (id {pipe['id']})")
return pipe["id"], False
status, res = _request("POST", "/v1/services/ingestionPipelines",
_pipeline_payload(suite["id"]), token=token)
if status not in (200, 201):
raise RuntimeError(f"gagal membuat pipeline (HTTP {status}): {str(res)[:200]}")
print(f" [OK] pipeline dibuat: {PIPELINE_NAME} (id {res['id']})")
return res["id"], True
def deploy_and_trigger(token, pipeline_id):
"""Deploy lalu trigger pipeline; trigger diulang sekali bila DAG belum siap."""
import time
status, res = _request("POST", f"/v1/services/ingestionPipelines/deploy/{pipeline_id}", token=token)
print(f" [{'OK' if status == 200 else 'GAGAL'}] deploy -> HTTP {status}")
if status != 200:
raise RuntimeError(f"deploy gagal: {str(res)[:200]}")
for attempt in (1, 2):
status, res = _request("POST", f"/v1/services/ingestionPipelines/trigger/{pipeline_id}", token=token)
print(f" [{'OK' if status == 200 else 'GAGAL'}] trigger -> HTTP {status} (percobaan {attempt})")
if status == 200:
return
# DAG baru dibuat: dag-processor bisa belum memparsingnya.
time.sleep(20)
raise RuntimeError("trigger gagal setelah 2 percobaan")
def main():
trigger = "--trigger" in sys.argv
print(f"[seed-native-dq] tabel={TABLE_FQN} pipeline={PIPELINE_NAME}")
token = login()
print(f" [OK] login {ADMIN_EMAIL}")
created = ensure_test_cases(token)
pipeline_id, new_pipeline = ensure_pipeline(token)
if trigger:
deploy_and_trigger(token, pipeline_id)
print(f"[seed-native-dq] selesai: {created} test case baru, pipeline "
f"{'baru' if new_pipeline else 'sudah ada'}"
f"{', deploy+trigger dijalankan' if trigger else ''}")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(f"ERROR: {exc}")
sys.exit(1)