first commit
This commit is contained in:
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Airflow Entrypoint Wrapper
|
||||
# =============================================================================
|
||||
# 1. Install sling CLI (binary, not available via pip)
|
||||
# 2. Delegate ke Airflow entrypoint asli untuk service start
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Percayai sertifikat TLS Trino (dev, self-signed) ───────────────────────
|
||||
# Sebagian klien MEMVERIFIKASI TLS dan tidak punya opsi mematikannya — contohnya
|
||||
# Great Expectations / trino client (`verify=True`) dan DQ native OpenMetadata.
|
||||
# Alih-alih `append` ke certifi (dgn guard marker) — yang MEMBUAT certifi tetap
|
||||
# memegang cert LAMA setelah cert Trino di-regenerasi — kita bangun ULANG bundle
|
||||
# CA (certifi + cert Trino) setiap start, lalu arahkan REQUESTS_CA_BUNDLE /
|
||||
# SSL_CERT_FILE ke sana. Verifikasi TLS TETAP aktif.
|
||||
CA_SRC="/etc/ssl/trino/trino.crt"
|
||||
SICABE_CA="/tmp/sicabe-ca.pem"
|
||||
if [[ -f "${CA_SRC}" ]]; then
|
||||
CERTIFI_BUNDLE="$(python -c 'import certifi;print(certifi.where())' 2>/dev/null || true)"
|
||||
if [[ -n "${CERTIFI_BUNDLE}" && -f "${CERTIFI_BUNDLE}" ]]; then
|
||||
if cat "${CERTIFI_BUNDLE}" "${CA_SRC}" > "${SICABE_CA}" 2>/dev/null; then
|
||||
export REQUESTS_CA_BUNDLE="${SICABE_CA}"
|
||||
export SSL_CERT_FILE="${SICABE_CA}"
|
||||
echo "[SiCABE] CA bundle (certifi + Trino) → ${SICABE_CA} (selalu dibangun ulang)."
|
||||
else
|
||||
echo "[SiCABE] WARNING: gagal membangun CA bundle — lanjut."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─── Install sling CLI jika belum ada (NON-BLOCKING, fail-fast) ─────────────
|
||||
# Install ke ~/.local/bin (writable oleh user airflow, sudah di PATH)
|
||||
if ! command -v sling &>/dev/null; then
|
||||
echo "[SiCABE] Installing sling CLI (timeout 15s)..."
|
||||
SLING_VER="1.5.23"
|
||||
mkdir -p "${HOME}/.local/bin"
|
||||
if curl -fL --max-time 15 --connect-timeout 5 \
|
||||
"https://github.com/slingdata-io/sling-cli/releases/download/v${SLING_VER}/sling_linux_amd64.tar.gz" \
|
||||
-o /tmp/sling.tar.gz 2>/dev/null && tar -xzf /tmp/sling.tar.gz -C /tmp/ 2>/dev/null && [[ -f /tmp/sling ]]; then
|
||||
chmod +x /tmp/sling
|
||||
mv /tmp/sling "${HOME}/.local/bin/sling"
|
||||
echo "[SiCABE] Sling CLI v${SLING_VER} installed: $(sling --version 2>&1)"
|
||||
else
|
||||
echo "[SiCABE] WARNING: sling CLI download gagal — lanjut tanpa sling (DAG akan warning)."
|
||||
fi
|
||||
rm -rf /tmp/sling.tar.gz /tmp/sling 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ─── Copy dbt project ke home airflow (writable) ────────────────────────────
|
||||
# Mount /opt/airflow/dbt adalah read-only (host bind). dbt perlu menulis
|
||||
# target/, logs/, dbt_packages/. Copy ke ~/dbt yang writable oleh airflow.
|
||||
if [[ -d /opt/airflow/dbt/sicabe && ! -d "${HOME}/dbt/sicabe" ]]; then
|
||||
echo "[SiCABE] Copying dbt project ke ${HOME}/dbt..."
|
||||
mkdir -p "${HOME}/dbt"
|
||||
cp -r /opt/airflow/dbt/sicabe "${HOME}/dbt/sicabe"
|
||||
cp /opt/airflow/dbt/profiles.yml "${HOME}/dbt/profiles.yml"
|
||||
echo "[SiCABE] dbt project siap di ${HOME}/dbt/sicabe"
|
||||
fi
|
||||
|
||||
# ─── Copy Great Expectations ke home airflow (writable) ─────────────────────
|
||||
if [[ -d /opt/airflow/great_expectations && ! -d "${HOME}/great_expectations" ]]; then
|
||||
echo "[SiCABE] Copying Great Expectations ke ${HOME}/great_expectations..."
|
||||
cp -r /opt/airflow/great_expectations "${HOME}/great_expectations"
|
||||
echo "[SiCABE] Great Expectations siap di ${HOME}/great_expectations"
|
||||
fi
|
||||
|
||||
# ─── LINK sling env.yaml ke ~/.sling (SELALU ikut file yang di-mount) ───────
|
||||
# WAJIB symlink, bukan copy: bootstrap men-regenerasi /opt/airflow/sling/env.yaml
|
||||
# SETELAH container start (host-aware), sehingga salinan hasil `cp` menjadi STALE
|
||||
# (gejala: Sling "lookup postgres ... server misbehaving"). Symlink → perubahan
|
||||
# langsung terbaca tanpa restart. ~/.sling/ tetap writable untuk .sling.db.
|
||||
# NON-FATAL: jangan sampai crash-loop container (set -e).
|
||||
# Dibuat TANPA syarat berkas ada: bila env.yaml belum ada saat start (bootstrap jalan
|
||||
# SETELAH container), symlink "menggantung" lalu VALID begitu berkas muncul. Dulu ada
|
||||
# guard `[[ -f … ]]` → symlink tak dibuat → Sling "could not find connection POSTGRES_DB".
|
||||
mkdir -p "${HOME}/.sling"
|
||||
rm -f "${HOME}/.sling/env.yaml" 2>/dev/null || true
|
||||
ln -s /opt/airflow/sling/env.yaml "${HOME}/.sling/env.yaml" \
|
||||
&& echo "[SiCABE] Sling env.yaml -> symlink ${HOME}/.sling/env.yaml" \
|
||||
|| echo "[SiCABE] WARNING: gagal symlink sling env.yaml — lanjut."
|
||||
|
||||
# ─── dbt-trino http_scheme ──────────────────────────────────────────────────
|
||||
# Sejak image di-build dari paket resmi (tanpa patch), http_scheme sudah HTTPS
|
||||
# lewat config/airflow/dbt/profiles.yml — tidak ada patch yang perlu dibalik.
|
||||
|
||||
# ─── Delegasikan ke entrypoint asli Airflow ─────────────────────────────────
|
||||
# Airflow official entrypoint ada di /entrypoint
|
||||
if [[ -x /entrypoint ]]; then
|
||||
exec /entrypoint "$@"
|
||||
elif [[ -x /usr/bin/docker-entrypoint.sh ]]; then
|
||||
exec /usr/bin/docker-entrypoint.sh "$@"
|
||||
else
|
||||
echo "[SiCABE] WARNING: Airflow entrypoint tidak ditemukan, menjalankan langsung..."
|
||||
exec "$@"
|
||||
fi
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — JALUR B: Data Quality NATIVE OpenMetadata (OM yang menjalankan)
|
||||
# =============================================================================
|
||||
# Bedanya dengan Jalur A (Great Expectations):
|
||||
# A: GE yang MENJALANKAN ekspektasi → hasilnya DIKIRIM ke OM
|
||||
# (test case ber-platform "GreatExpectations").
|
||||
# B: OPENMETADATA yang MENJALANKAN test (definisi bawaan OM:
|
||||
# tableRowCountToBeBetween, columnValuesToBeNotNull, columnValuesToBeBetween)
|
||||
# → hasilnya langsung tersimpan di OM (platform "OpenMetadata").
|
||||
#
|
||||
# Catatan implementasi (dua hal yang harus begini, sudah diverifikasi):
|
||||
# 1. HARUS lewat `TestRunner.for_table`/`from_yaml` — `TestRunner(...)` mentah
|
||||
# tidak memuat entity tabel sehingga run() gagal "Table entity not provided".
|
||||
# 2. Koneksi data HARUS dibawa eksplisit lewat workflow (serviceConnection +
|
||||
# verify=<sertifikat Trino>). Bila mengandalkan koneksi yang TERSIMPAN di OM,
|
||||
# eksekusi gagal 401 'Invalid credentials' pada OM 1.13.1. `metadata ingest`
|
||||
# CLI juga bukan jalur eksekusi TestSuite (registry source-nya terpisah).
|
||||
#
|
||||
# Jalankan DI DALAM container Airflow:
|
||||
# docker exec -e OPENMETADATA_ADMIN_PASSWORD=... \
|
||||
# sicabe-airflow-webserver python /opt/airflow/scripts/run-om-native-dq.py
|
||||
#
|
||||
# Exit code: 0 = semua test lulus; 1 = ada test gagal/aborted.
|
||||
# =============================================================================
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
from metadata.generated.schema.entity.services.connections.metadata.openMetadataConnection import (
|
||||
OpenMetadataConnection,
|
||||
)
|
||||
from metadata.generated.schema.security.client.openMetadataJWTClientConfig import (
|
||||
OpenMetadataJWTClientConfig,
|
||||
)
|
||||
from metadata.ingestion.ometa.ometa_api import OpenMetadata
|
||||
from metadata.sdk.data_quality.runner import TestRunner
|
||||
from metadata.sdk.data_quality.tests import (
|
||||
ColumnValuesToBeBetween,
|
||||
ColumnValuesToBeNotNull,
|
||||
TableRowCountToBeBetween,
|
||||
)
|
||||
|
||||
OM_API_URL = os.getenv("OM_API_URL", "http://openmetadata-server:8585/api")
|
||||
SERVICE = os.getenv("OM_DATABASE_SERVICE", "trino_sicabe")
|
||||
CATALOG = os.getenv("OM_DATABASE_NAME", "iceberg")
|
||||
TRINO_HOSTPORT = os.getenv("OM_TRINO_HOSTPORT", "trino-coordinator:8443")
|
||||
TRINO_CERT = os.getenv("TRINO_CA_BUNDLE", "/etc/ssl/trino/trino.crt")
|
||||
|
||||
# Workflow TestSuite: membawa koneksi Trino eksplisit (username + password +
|
||||
# verify=sertifikat) supaya eksekusi terautentikasi dan TLS tetap DIVERIFIKASI.
|
||||
WORKFLOW_TEMPLATE = """
|
||||
source:
|
||||
type: TestSuite
|
||||
serviceName: {service}
|
||||
sourceConfig:
|
||||
config:
|
||||
type: TestSuite
|
||||
entityFullyQualifiedName: {table}
|
||||
# WAJIB 'serviceConnections' (plural, list) — DQ source membaca field ini via
|
||||
# _load_yaml_service_connections(). Memakai 'serviceConnection' (tunggal)
|
||||
# diabaikan, sehingga eksekusi jatuh ke koneksi TERSIMPAN di OM yang
|
||||
# kehilangan secret → HTTP 401 'Invalid credentials' (lihat TODO upstream
|
||||
# open-metadata#21259). Dengan bentuk ini koneksi dari YAML yang dipakai.
|
||||
serviceConnections:
|
||||
- serviceName: {service}
|
||||
serviceConnection:
|
||||
config:
|
||||
type: Trino
|
||||
scheme: trino
|
||||
hostPort: {hostport}
|
||||
username: svc_om
|
||||
authType:
|
||||
password: "{password}"
|
||||
catalog: {catalog}
|
||||
verify: {cert}
|
||||
sink:
|
||||
type: metadata-rest
|
||||
config: {{}}
|
||||
workflowConfig:
|
||||
loggerLevel: INFO
|
||||
openMetadataServerConfig:
|
||||
hostPort: {om_api}
|
||||
authProvider: openmetadata
|
||||
securityConfig:
|
||||
jwtToken: "{token}"
|
||||
"""
|
||||
|
||||
# Test NATIVE (dijalankan OM) — definisi bawaan OM, bukan ekspektasi GE.
|
||||
TABLES = {
|
||||
f"{SERVICE}.{CATALOG}.bronze.orders": [
|
||||
TableRowCountToBeBetween(min_count=1, max_count=1000000),
|
||||
ColumnValuesToBeNotNull(column="id"),
|
||||
ColumnValuesToBeNotNull(column="komoditas"),
|
||||
ColumnValuesToBeBetween(column="harga_per_kg", min_value=0, max_value=1000000),
|
||||
],
|
||||
f"{SERVICE}.{CATALOG}.silver.orders_silver": [
|
||||
TableRowCountToBeBetween(min_count=1, max_count=1000000),
|
||||
ColumnValuesToBeNotNull(column="id"),
|
||||
ColumnValuesToBeBetween(column="total_value", min_value=0, max_value=1000000000000),
|
||||
],
|
||||
f"{SERVICE}.{CATALOG}.gold.orders_gold": [
|
||||
TableRowCountToBeBetween(min_count=1, max_count=1000000),
|
||||
ColumnValuesToBeNotNull(column="komoditas"),
|
||||
ColumnValuesToBeBetween(column="avg_harga_per_kg", min_value=0, max_value=1000000),
|
||||
ColumnValuesToBeBetween(column="total_nilai", min_value=0, max_value=1000000000000),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _token():
|
||||
"""Token OM: dari OM_JWT_TOKEN, atau login pakai password admin.
|
||||
|
||||
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 diulang dalam loop: akun admin OM bisa terkunci.
|
||||
|
||||
Catatan: 401 pada kandidat pertama adalah hal NORMAL (mis. step 7 belum jalan),
|
||||
jadi urlopen-nya dibungkus try/except — tanpa itu HTTPError 401 menggagalkan
|
||||
seluruh skrip.
|
||||
"""
|
||||
if os.getenv("OM_JWT_TOKEN"):
|
||||
return os.environ["OM_JWT_TOKEN"]
|
||||
email = os.getenv("OPENMETADATA_ADMIN_EMAIL", "admin@open-metadata.org")
|
||||
for password in (os.getenv("OPENMETADATA_ADMIN_PASSWORD"), "admin"):
|
||||
if not password:
|
||||
continue
|
||||
body = json.dumps({
|
||||
"email": email,
|
||||
"password": base64.b64encode(password.encode()).decode(),
|
||||
}).encode()
|
||||
req = urllib.request.Request(f"{OM_API_URL}/v1/users/login", data=body,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
tok = json.load(urllib.request.urlopen(req, timeout=20)).get("accessToken")
|
||||
except Exception: # noqa: BLE001 — kandidat pertama gagal = hal biasa
|
||||
tok = None
|
||||
if tok:
|
||||
return tok
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
token = _token()
|
||||
trino_password = os.getenv("TRINO_SVC_OM_PASSWORD")
|
||||
if not token:
|
||||
print("ERROR: token OpenMetadata tidak tersedia (OM_JWT_TOKEN / "
|
||||
"OPENMETADATA_ADMIN_PASSWORD)")
|
||||
return 1
|
||||
if not trino_password:
|
||||
print("ERROR: TRINO_SVC_OM_PASSWORD tidak diset — koneksi Trino tidak bisa dibuat")
|
||||
return 1
|
||||
|
||||
client = OpenMetadata(
|
||||
OpenMetadataConnection(
|
||||
hostPort=OM_API_URL,
|
||||
authProvider="openmetadata",
|
||||
securityConfig=OpenMetadataJWTClientConfig(jwtToken=token),
|
||||
)
|
||||
)
|
||||
|
||||
failed = 0
|
||||
total = 0
|
||||
for table_fqn, tests in TABLES.items():
|
||||
print(f"[DQ-native] tabel {table_fqn} — {len(tests)} test")
|
||||
workflow = WORKFLOW_TEMPLATE.format(
|
||||
service=SERVICE, hostport=TRINO_HOSTPORT, password=trino_password,
|
||||
catalog=CATALOG, cert=TRINO_CERT, table=table_fqn, om_api=OM_API_URL,
|
||||
token=token,
|
||||
)
|
||||
# use_connection_from_yaml=True → pakai koneksi dari workflow (bukan yang
|
||||
# tersimpan di OM), sehingga autentikasi Trino bekerja.
|
||||
runner = TestRunner.from_yaml(
|
||||
yaml_string=workflow, use_connection_from_yaml=True, client=client
|
||||
)
|
||||
for test in tests:
|
||||
runner.add_test(test)
|
||||
|
||||
for result in runner.run():
|
||||
total += 1
|
||||
status = str(getattr(result.testCaseResult, "testCaseStatus", None))
|
||||
name = getattr(result, "name", None) or getattr(result, "fqn", "test")
|
||||
print(f" - {name}: {status.split('.')[-1]}")
|
||||
if status.split(".")[-1] != "Success":
|
||||
failed += 1
|
||||
|
||||
print(f"[DQ-native] ringkasan: {total - failed}/{total} test lulus")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,210 @@
|
||||
#!/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)
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Dremio — Register Nessie catalog source via REST API
|
||||
# =============================================================================
|
||||
# Membuat source "Nessie" di Dremio OSS lewat API (bukan UI).
|
||||
#
|
||||
# ⚠️ Catatan: dokumentasi Dremio menyatakan Catalog API "unable to retrieve or
|
||||
# manage Nessie sources" (DX-64994). Namun TERBUKTI (Dremio OSS 25.2.0, diuji
|
||||
# 2026-09-10) bahwa POST /api/v3/catalog dengan type=NESSIE BERHASIL, dan
|
||||
# GET /api/v3/catalog/by-path/<name> juga bekerja. Kunci config:
|
||||
# - JANGAN pakai "storageType" (ditolak: "Invalid value found at: storageType")
|
||||
# - awsRootPath format "/bucket/path" (TANPA prefix s3://)
|
||||
#
|
||||
# Pemakaian:
|
||||
# export DREMIO_USER=admin DREMIO_PASSWORD='<password>'
|
||||
# ./scripts/dremio/register-nessie-source.sh
|
||||
#
|
||||
# Env (opsional, ada default):
|
||||
# DREMIO_URL default http://localhost:12947
|
||||
# DREMIO_SOURCE_NAME default nessie
|
||||
# NESSIE_ENDPOINT default ${NESSIE_URL}/api/v2 (http://nessie:19120/api/v2)
|
||||
# MINIO_ENDPOINT default host dari S3_ENDPOINT (tanpa skema)
|
||||
# AWS_ROOT_PATH default /<bucket>/warehouse
|
||||
# MINIO_ROOT_USER / MINIO_ROOT_PASSWORD (fallback: S3_ACCESS_KEY / S3_SECRET_KEY)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
DREMIO_URL="${DREMIO_URL:-http://localhost:12947}"
|
||||
DREMIO_USER="${DREMIO_USER:-admin}"
|
||||
DREMIO_PASSWORD="${DREMIO_PASSWORD:?set DREMIO_PASSWORD}"
|
||||
DREMIO_SOURCE_NAME="${DREMIO_SOURCE_NAME:-nessie}"
|
||||
NESSIE_ENDPOINT="${NESSIE_ENDPOINT:-${NESSIE_URL:-http://nessie:19120}/api/v2}"
|
||||
# MINIO_ENDPOINT dipakai tanpa skema (Dremio memisahkan host:port) → buang skema.
|
||||
_minio_host="${MINIO_ENDPOINT:-${S3_ENDPOINT:-http://minio:9000}}"
|
||||
MINIO_ENDPOINT="${_minio_host#*://}"
|
||||
AWS_ROOT_PATH="${AWS_ROOT_PATH:-/${MINIO_BUCKET_ICEBERG_DATA:-iceberg-data}/warehouse}"
|
||||
MINIO_ROOT_USER="${MINIO_ROOT_USER:-${S3_ACCESS_KEY:-minioadmin}}"
|
||||
MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-${S3_SECRET_KEY:?set MINIO_ROOT_PASSWORD atau S3_SECRET_KEY}}"
|
||||
|
||||
echo "==> Login Dremio (${DREMIO_URL}) sebagai '${DREMIO_USER}'..."
|
||||
LOGIN_RESP=$(curl -s -X POST "${DREMIO_URL}/apiv2/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"userName\":\"${DREMIO_USER}\",\"password\":\"${DREMIO_PASSWORD}\"}")
|
||||
TOKEN=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json;print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
|
||||
if [[ -z "${TOKEN}" ]]; then
|
||||
echo "ERR: login gagal. Respons: ${LOGIN_RESP}"
|
||||
exit 1
|
||||
fi
|
||||
AUTH="Authorization: _dremio${TOKEN}"
|
||||
echo " OK: token diperoleh."
|
||||
|
||||
# Cek apakah source sudah ada (best-effort; GET Nessie mungkin tidak didukung)
|
||||
EXIST_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "${AUTH}" \
|
||||
"${DREMIO_URL}/api/v3/catalog/by-path/${DREMIO_SOURCE_NAME}" || echo "000")
|
||||
if [[ "${EXIST_CODE}" == "200" ]]; then
|
||||
echo "OK: source '${DREMIO_SOURCE_NAME}' sudah ada."
|
||||
exit 0
|
||||
fi
|
||||
echo " Source belum ada (GET by-path -> HTTP ${EXIST_CODE}). Membuat..."
|
||||
|
||||
BODY=$(cat <<JSON
|
||||
{
|
||||
"entityType": "source",
|
||||
"name": "${DREMIO_SOURCE_NAME}",
|
||||
"type": "NESSIE",
|
||||
"config": {
|
||||
"nessieEndpoint": "${NESSIE_ENDPOINT}",
|
||||
"nessieAuthType": "NONE",
|
||||
"awsRootPath": "${AWS_ROOT_PATH}",
|
||||
"credentialType": "ACCESS_KEY",
|
||||
"awsAccessKey": "${MINIO_ROOT_USER}",
|
||||
"awsAccessSecret": "${MINIO_ROOT_PASSWORD}",
|
||||
"secure": false,
|
||||
"asyncEnabled": true,
|
||||
"isCachingEnabled": false,
|
||||
"propertyList": [
|
||||
{ "name": "fs.s3a.path.style.access", "value": "true" },
|
||||
{ "name": "fs.s3a.endpoint", "value": "${MINIO_ENDPOINT}" },
|
||||
{ "name": "dremio.s3.compat", "value": "true" }
|
||||
]
|
||||
}
|
||||
}
|
||||
JSON
|
||||
)
|
||||
|
||||
echo "==> POST /api/v3/catalog (type=NESSIE)..."
|
||||
CREATE_CODE=$(curl -s -o /tmp/dremio_create_resp.json -w "%{http_code}" \
|
||||
-X POST "${DREMIO_URL}/api/v3/catalog" \
|
||||
-H "${AUTH}" -H "Content-Type: application/json" -d "${BODY}")
|
||||
echo " HTTP ${CREATE_CODE}"
|
||||
cat /tmp/dremio_create_resp.json 2>/dev/null; echo
|
||||
|
||||
case "${CREATE_CODE}" in
|
||||
200|201)
|
||||
echo "✅ Source '${DREMIO_SOURCE_NAME}' dibuat via API."
|
||||
;;
|
||||
409)
|
||||
echo "✅ Source '${DREMIO_SOURCE_NAME}' sudah ada (409 Conflict)."
|
||||
;;
|
||||
*)
|
||||
echo "⚠️ API menolak pembuatan source Nessie (HTTP ${CREATE_CODE})."
|
||||
echo " Sesuai docs (DX-64994), Catalog API tidak mengelola Nessie source."
|
||||
echo " Buat manual via UI: http://localhost:12947 → Add Source → Nessie"
|
||||
echo " endpoint: ${NESSIE_ENDPOINT}"
|
||||
echo " storage : AWS S3 / MinIO (path-style), endpoint ${MINIO_ENDPOINT}"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Generate keystore TLS Trino (+ ekspor sertifikat untuk klien)
|
||||
# =============================================================================
|
||||
# Mengapa perlu SAN (Subject Alternative Name)?
|
||||
# Keystore lama hanya berisi CN=localhost TANPA SAN. Klien yang memverifikasi
|
||||
# TLS (bukan mematikannya) tidak dapat memakai sertifikat itu untuk hostname
|
||||
# `trino` (nama service di jaringan compose) → verifikasi gagal. Sertifikat di
|
||||
# sini menerbitkan SAN: trino, localhost, sicabe-trino, 127.0.0.1 sehingga
|
||||
# verifikasi BERJALAN (bukan dimatikan).
|
||||
#
|
||||
# Diperlukan oleh:
|
||||
# - Trino: /etc/trino/keystore.p12 (password di config.properties)
|
||||
# - Klien yang memverifikasi (mis. ingestion OpenMetadata): sertifikat PEM hasil
|
||||
# ekspor di config/trino/certs/trino.crt
|
||||
#
|
||||
# ⚠️ Ini keystore DEVELOPMENT (di-commit). Untuk produksi: generate ulang, jangan
|
||||
# commit private key-nya, dan ganti `http-server.https.keystore.key`.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/generate-trino-keystore.sh
|
||||
# TRINO_KEYSTORE_PASSWORD=rahasia ./scripts/generate-trino-keystore.sh
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
# shellcheck source=../lib/common.sh
|
||||
source "${SCRIPT_DIR}/lib/common.sh"
|
||||
|
||||
# Opsi:
|
||||
# --out <dir> tulis <dir>/keystore.p12 + <dir>/certs/trino.crt (default config/trino)
|
||||
# --san-extra <csv> tambahkan entri SAN, mis. "IP:10.0.0.11,IP:10.0.0.12" — dipakai
|
||||
# multi-host supaya klien yang terhubung via ALAMAT HOST tetap
|
||||
# memverifikasi TLS (cert tak hanya valid utk nama container).
|
||||
OUT_DIR="${SCRIPT_DIR}/config/trino"
|
||||
SAN_EXTRA=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--out) OUT_DIR="${2:-}"; shift 2 ;;
|
||||
--san-extra) SAN_EXTRA="${2:-}"; shift 2 ;;
|
||||
-h|--help) sed -n '20,23p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo -e "${ERR} Argumen tidak dikenal: $1"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
KEYSTORE="${OUT_DIR}/keystore.p12"
|
||||
CERT_DIR="${OUT_DIR}/certs"
|
||||
CERT="${CERT_DIR}/trino.crt"
|
||||
PROPS="${SCRIPT_DIR}/config/trino/config-coordinator.properties"
|
||||
|
||||
# Password keystore: env menang, kalau tidak ambil dari config.properties
|
||||
# (Trino memakai SATU password untuk keystore & key).
|
||||
KS_PASSWORD="${TRINO_KEYSTORE_PASSWORD:-}"
|
||||
if [[ -z "${KS_PASSWORD}" ]]; then
|
||||
KS_PASSWORD="$(sed -n 's/^http-server.https.keystore.key=//p' "${PROPS}" 2>/dev/null | head -1)"
|
||||
fi
|
||||
if [[ -z "${KS_PASSWORD}" ]]; then
|
||||
echo -e "${ERR} Password keystore tidak diketahui (isi TRINO_KEYSTORE_PASSWORD)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SAN="DNS:trino,DNS:localhost,DNS:sicabe-trino,DNS:trino-coordinator,DNS:trino-worker,IP:127.0.0.1"
|
||||
[[ -n "${SAN_EXTRA}" ]] && SAN="${SAN},${SAN_EXTRA}"
|
||||
mkdir -p "${CERT_DIR}"
|
||||
TMP_KEY="$(mktemp)"
|
||||
trap 'rm -f "${TMP_KEY}"' EXIT
|
||||
|
||||
echo -e "${INFO} Menerbitkan sertifikat self-signed (SAN: ${SAN})..."
|
||||
if ! openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
|
||||
-keyout "${TMP_KEY}" -out "${CERT}" \
|
||||
-subj "/CN=trino" -addext "subjectAltName=${SAN}" >/dev/null 2>&1; then
|
||||
echo -e "${ERR} Gagal membuat sertifikat (openssl tidak tersedia?)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${INFO} Membungkus ke PKCS12 (${KEYSTORE##*/})..."
|
||||
if ! openssl pkcs12 -export -in "${CERT}" -inkey "${TMP_KEY}" \
|
||||
-name trino -out "${KEYSTORE}" -passout "pass:${KS_PASSWORD}" >/dev/null 2>&1; then
|
||||
echo -e "${ERR} Gagal membuat keystore PKCS12."
|
||||
exit 1
|
||||
fi
|
||||
chmod 644 "${KEYSTORE}" "${CERT}"
|
||||
|
||||
echo -e "${OK} Keystore dibuat: ${KEYSTORE}"
|
||||
echo -e "${OK} Sertifikat diekspor: ${CERT}"
|
||||
openssl x509 -in "${CERT}" -noout -subject -dates 2>/dev/null | sed 's/^/ /'
|
||||
openssl x509 -in "${CERT}" -noout -ext subjectAltName 2>/dev/null | sed 's/^/ /'
|
||||
echo -e "${INFO} Restart Trino coordinator agar sertifikat baru dipakai: ./install.sh --restart trino-coordinator"
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Jalankan agent ingestion OpenMetadata
|
||||
# =============================================================================
|
||||
# Mengapa script ini ada:
|
||||
# Membuat SERVICE di OpenMetadata hanya menyimpan konfigurasi koneksi. Metadata
|
||||
# (database → schema → tabel → kolom) baru terisi setelah AGENT dijalankan, dan
|
||||
# halaman Explore membaca hasilnya dari search index. Tanpa agent: Explore kosong.
|
||||
#
|
||||
# LINEAGE (bronze→silver→gold) TIDAK dihasilkan oleh ingestion metadata: lineage
|
||||
# berasal dari definisi transformasi, jadi diambil dari ARTEFAK dbt lewat workflow
|
||||
# terpisah (`ingest-dbt.yaml`). Keduanya dijalankan oleh `--all`.
|
||||
#
|
||||
# Script memakai Ingestion Framework yang sudah ada di container Airflow
|
||||
# (`metadata` CLI) — tidak perlu memasang apa pun.
|
||||
#
|
||||
# ⚠️ Cakupan metadata mengikuti ACL identitas agent (workflow memakai `svc_om`,
|
||||
# role `data-catalog` = baca-saja seluruh tabel).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/ingest-openmetadata.sh # metadata (Trino) saja
|
||||
# ./scripts/ingest-openmetadata.sh --all # metadata + lineage dbt
|
||||
# ./scripts/ingest-openmetadata.sh --dbt-only # lineage dbt saja
|
||||
# ./scripts/ingest-openmetadata.sh --workflow config/openmetadata/ingest-dbt.yaml
|
||||
# =============================================================================
|
||||
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}" "$@"
|
||||
|
||||
OM_URL="${OPENMETADATA_URL:-http://localhost:${OPENMETADATA_PORT:-12085}}"
|
||||
OM_ADMIN_EMAIL="${OPENMETADATA_ADMIN_EMAIL:-admin@open-metadata.org}"
|
||||
OM_ADMIN_PASS="${OPENMETADATA_ADMIN_PASSWORD:-openmetadata123}"
|
||||
SERVICE_NAME="${OPENMETADATA_TRINO_SERVICE:-trino_sicabe}"
|
||||
AIRFLOW_CONTAINER="${AIRFLOW_INGEST_CONTAINER:-sicabe-airflow-webserver}"
|
||||
REMOTE_DIR="/tmp/om-ingest"
|
||||
DBT_PROJECT_DIR="${DBT_PROJECT_DIR:-/opt/airflow/dbt/sicabe}"
|
||||
|
||||
# Di multi-host, pakai workflow TER-RENDER dari bundle host (hostname → alamat host);
|
||||
# sumber `config/**` masih memakai nama container (single-host). `SICABE_BUNDLE_DIR`
|
||||
# diset sicabe_load_env(): single-host = deploy/ (config sumber), multi-host =
|
||||
# generated/host-<x>/ (config ter-render).
|
||||
CONFIG_DIR="${SICABE_BUNDLE_DIR:-${SCRIPT_DIR}}/config"
|
||||
WF_METADATA="${CONFIG_DIR}/openmetadata/ingest-trino.yaml"
|
||||
WF_DBT="${CONFIG_DIR}/openmetadata/ingest-dbt.yaml"
|
||||
MODE="metadata"
|
||||
CUSTOM_WORKFLOW=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--env-file) shift 2 ;; # sudah dipakai sicabe_load_env() di atas
|
||||
--all) MODE="all"; shift ;;
|
||||
--dbt-only) MODE="dbt"; shift ;;
|
||||
--workflow) CUSTOM_WORKFLOW="$2"; shift 2 ;;
|
||||
*) echo "Argumen tidak dikenal: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Helper ──────────────────────────────────────────────────────────────────
|
||||
om_ws() { # om_ws <method> <path> [body]
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
if [[ -n "${body}" ]]; then
|
||||
curl -s --max-time 30 -X "${method}" "${OM_URL}${path}" \
|
||||
-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}" -H "Authorization: Bearer ${TOKEN}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
run_workflow() { # run_workflow <path-workflow> <label>
|
||||
local wf="$1" label="$2" remote
|
||||
remote="${REMOTE_DIR}/$(basename "${wf}")"
|
||||
if [[ ! -f "${wf}" ]]; then
|
||||
echo -e "${ERR} Workflow tidak ditemukan: ${wf}"
|
||||
return 1
|
||||
fi
|
||||
echo -e "${INFO} [${label}] menyalin ${wf##*/} ke container..."
|
||||
docker exec "${AIRFLOW_CONTAINER}" mkdir -p "${REMOTE_DIR}"
|
||||
docker cp "${wf}" "${AIRFLOW_CONTAINER}:${remote}" >/dev/null
|
||||
|
||||
echo -e "${INFO} [${label}] menjalankan ingestion..."
|
||||
local out
|
||||
out="$(docker exec -e TRINO_SVC_OM_PASSWORD="${TRINO_SVC_OM_PASSWORD:-}" -e OM_JWT_TOKEN="${TOKEN}" \
|
||||
"${AIRFLOW_CONTAINER}" metadata ingest -c "${remote}" 2>&1)"
|
||||
echo "${out}" | sed 's/\x1b\[[0-9;]*m//g' |
|
||||
grep -iE "Processed records|Errors:|Success %|Workflow Success|Error running node|ERROR" | tail -8
|
||||
if ! echo "${out}" | grep -q "Workflow Success %: 100.0"; then
|
||||
echo -e "${ERR} [${label}] ingestion tidak 100% sukses — periksa keluaran di atas."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Prasyarat ───────────────────────────────────────────────────────────────
|
||||
for f in "${WF_METADATA}" "${WF_DBT}"; do
|
||||
[[ -f "${f}" ]] || { echo -e "${ERR} Workflow tidak ditemukan: ${f}"; exit 1; }
|
||||
done
|
||||
if ! docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${AIRFLOW_CONTAINER}$"; then
|
||||
echo -e "${ERR} Container '${AIRFLOW_CONTAINER}' tidak berjalan (butuh Ingestion Framework)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${INFO} Login ke OpenMetadata (${OM_ADMIN_EMAIL})..."
|
||||
# Dua kandidat, MAKSIMAL DUA percobaan — jangan diulang dalam loop (akun admin bisa
|
||||
# terkunci): password .env, lalu default 'admin' yang dipakai OM 2.0.x sampai
|
||||
# bootstrap step 7 menyetel hash-nya. `curl -s` TANPA -f, agar kegagalan HTTP tidak
|
||||
# mematikan skrip di bawah `set -e` — penanganannya ada di blok if berikutnya.
|
||||
TOKEN=""
|
||||
for TRY_PASS in "${OM_ADMIN_PASS}" "admin"; do
|
||||
[[ -n "${TRY_PASS}" ]] || continue
|
||||
PW64="$(printf '%s' "${TRY_PASS}" | base64)"
|
||||
TOKEN="$(curl -s -X POST "${OM_URL}/api/v1/users/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"${OM_ADMIN_EMAIL}\",\"password\":\"${PW64}\"}" 2>/dev/null |
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin).get('accessToken',''))" 2>/dev/null || true)"
|
||||
[[ -n "${TOKEN}" ]] && break
|
||||
done
|
||||
if [[ -z "${TOKEN}" ]]; then
|
||||
echo -e "${ERR} Gagal login ke OpenMetadata di ${OM_URL} (dicoba: password .env + 'admin')"
|
||||
echo " pastikan admin sudah diset: ./bootstrap.sh --step 7"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${OK} Token diperoleh."
|
||||
|
||||
RC=0
|
||||
if [[ -n "${CUSTOM_WORKFLOW}" ]]; then
|
||||
run_workflow "${CUSTOM_WORKFLOW}" "custom" || RC=1
|
||||
else
|
||||
[[ "${MODE}" == "metadata" || "${MODE}" == "all" ]] && { run_workflow "${WF_METADATA}" "metadata" || RC=1; }
|
||||
if [[ "${MODE}" == "dbt" || "${MODE}" == "all" ]]; then
|
||||
# Artefak dbt wajib ada di container (hilang bila container di-recreate,
|
||||
# karena berada di layer container, bukan bind-mount).
|
||||
# Artefak dbt ditulis ke DBT_TARGET_PATH (default /tmp/dbt/target — lihat
|
||||
# docker-compose.yml). DULU diperiksa di ${DBT_PROJECT_DIR}/target, yaitu langsung
|
||||
# di dalam bind-mount repo yang TIDAK bisa ditulis container (uid 50000 vs uid host),
|
||||
# sehingga pemeriksaan ini selalu melaporkan "belum ada" walaupun artefaknya ada.
|
||||
DBT_TARGET_PATH="${DBT_TARGET_PATH:-/tmp/dbt/target}"
|
||||
if ! docker exec "${AIRFLOW_CONTAINER}" test -f "${DBT_TARGET_PATH}/manifest.json" 2>/dev/null; then
|
||||
echo -e "${WARN} Artefak dbt belum ada di container (${DBT_TARGET_PATH})."
|
||||
echo -e "${INFO} Buat dulu (butuh kredensial dbt). Perhatikan DBT_TARGET_PATH/DBT_LOG_PATH:"
|
||||
echo " docker exec -e DBT_TRINO_USER=svc_dbt -e DBT_TRINO_PASSWORD=\$TRINO_SVC_DBT_PASSWORD \\"
|
||||
echo " -e DBT_PROFILES_DIR=/opt/airflow/dbt -e DBT_TARGET_PATH=${DBT_TARGET_PATH} \\"
|
||||
echo " -e DBT_LOG_PATH=/tmp/dbt/logs -w ${DBT_PROJECT_DIR} ${AIRFLOW_CONTAINER} \\"
|
||||
echo " bash -lc 'dbt docs generate --profile trino --target prod'"
|
||||
RC=1
|
||||
else
|
||||
run_workflow "${WF_DBT}" "lineage dbt" || RC=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Bukti hasil ─────────────────────────────────────────────────────────────
|
||||
echo -e "${INFO} Memverifikasi hasil di OpenMetadata..."
|
||||
SUMMARY="$(om_ws GET "/api/v1/tables?service=${SERVICE_NAME}&fields=columns&limit=50" |
|
||||
python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin).get('data',[])
|
||||
print('{0}|{1}'.format(len(d), sum(len(t.get('columns') or []) for t in d)))
|
||||
except Exception: print('0|0')" 2>/dev/null)"
|
||||
TABLES="${SUMMARY%%|*}"; COLUMNS="${SUMMARY##*|}"
|
||||
if [[ "${TABLES}" =~ ^[0-9]+$ ]] && [[ ${TABLES} -gt 0 ]]; then
|
||||
echo -e "${OK} ${TABLES} tabel ter-indeks (${COLUMNS} kolom) — muncul di Explore."
|
||||
else
|
||||
echo -e "${WARN} Belum ada tabel ter-indeks untuk '${SERVICE_NAME}'."
|
||||
RC=1
|
||||
fi
|
||||
|
||||
# Lineage (ditampilkan bila workflow dbt dijalankan)
|
||||
if [[ "${MODE}" == "dbt" || "${MODE}" == "all" ]]; then
|
||||
EDGES="$(om_ws GET "/api/v1/lineage/getLineage?fqn=${SERVICE_NAME}.iceberg.bronze.orders&upstreamDepth=0&downstreamDepth=3" |
|
||||
python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
print(len(d.get('downstreamEdges') or []))
|
||||
except Exception: print('0')" 2>/dev/null)"
|
||||
if [[ "${EDGES}" =~ ^[0-9]+$ ]] && [[ ${EDGES} -gt 0 ]]; then
|
||||
echo -e "${OK} Lineage terbentuk: ${EDGES} relasi downstream dari bronze.orders."
|
||||
else
|
||||
echo -e "${WARN} Lineage belum terbentuk (0 relasi) — periksa keluaran ingestion dbt."
|
||||
RC=1
|
||||
fi
|
||||
fi
|
||||
|
||||
exit ${RC}
|
||||
Executable
+336
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# prefetch-images.sh — tarik SEMUA image Docker lebih dulu (agar install offline-ish)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Daftar image TIDAK di-hardcode: diambil dari `docker compose config --images`,
|
||||
# sehingga selalu sinkron dengan docker-compose.yml (termasuk image yang berada di
|
||||
# balik profil, mis. dremio).
|
||||
#
|
||||
# CATATAN PENTING — satu image dibangun LOKAL:
|
||||
# sicabe/airflow:3.3.2-om203-ol dibangun dari docker/airflow/Dockerfile.332
|
||||
# (base apache/airflow:slim-3.3.2-python3.11). Ia TIDAK ada di registry publik,
|
||||
# sehingga `pull` dari Docker Hub tidak mungkin.
|
||||
# ✅ SEJAK 2026-10-02 image ini TERSEDIA di REGISTRY INTERNAL:
|
||||
# registry.sicabe.com/airflow:3.3.2-om203-ol
|
||||
# Script ini OTOMATIS ikut menariknya bila `AIRFLOW_IMAGE` menunjuk REGISTRY
|
||||
# (host memuat titik, mis. `registry.sicabe.com/airflow:…`) — cukup set
|
||||
# AIRFLOW_IMAGE=registry.sicabe.com/airflow:3.3.2-om203-ol
|
||||
# di .env. Bila `AIRFLOW_IMAGE` dibiarkan nama LOKAL polos (`sicabe/airflow:…`),
|
||||
# image DIKECUALIKAN dari pull dan wajib dibangun/di-retag manual (alur di bawah).
|
||||
# Untuk host TANPA akses registry, tetap ada alur build-sekali-salin:
|
||||
# 1) di SATU host: ./scripts/prefetch-images.sh
|
||||
# docker build -t sicabe/airflow:3.3.2-om203-ol \
|
||||
# -f docker/airflow/Dockerfile.332 docker/airflow
|
||||
# ./scripts/prefetch-images.sh --save-airflow /tmp/airflow.tar
|
||||
# 2) salin: scp /tmp/airflow.tar host2:/tmp/ ; scp … host3:/tmp/
|
||||
# 3) di host lain: ./scripts/prefetch-images.sh --load-airflow /tmp/airflow.tar
|
||||
#
|
||||
# Pemakaian:
|
||||
# ./scripts/prefetch-images.sh [--all | --host <nama>] [--list] [--no-base]
|
||||
# ./scripts/prefetch-images.sh --save-airflow /tmp/airflow.tar
|
||||
# ./scripts/prefetch-images.sh --load-airflow /tmp/airflow.tar
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
RED=$'\e[31m'; GRN=$'\e[32m'; YLW=$'\e[33m'; BLD=$'\e[1m'; NC=$'\e[0m'
|
||||
# PENTING: semua diagnostik ke STDERR. stdout hanya untuk DATA (daftar image),
|
||||
# karena collect_images() ditangkap dengan $( ) — pesan info yang ikut tertangkap
|
||||
# akan dianggap nama image (bug yang ditemukan saat menguji --list).
|
||||
ok() { echo -e " ${GRN}[OK]${NC} $*" >&2; }
|
||||
inf() { echo -e " ${YLW}[..]${NC} $*" >&2; }
|
||||
warn() { echo -e " ${YLW}[WARN]${NC} $*" >&2; }
|
||||
err() { echo -e " ${RED}[!!]${NC} $*" >&2; }
|
||||
|
||||
MODE="all" # all | host
|
||||
HOST_NAME=""
|
||||
LIST_ONLY=0
|
||||
# Dremio (opt-in di compose), image build lokal, dan base build Airflow DEFAULT
|
||||
# DIKELUARKAN dari daftar prefetch. Ketiganya diaktifkan lewat flag bila memang
|
||||
# diperlukan (mis. deployment yang menjalankan dremio / akan build Airflow).
|
||||
WITH_BASE=0
|
||||
WITH_DREMIO=0
|
||||
SAVE_AIRFLOW=""
|
||||
LOAD_AIRFLOW=""
|
||||
|
||||
usage() { sed -n '2,28p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0; }
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--all) MODE="all" ;;
|
||||
--host) MODE="host"; HOST_NAME="${2:-}"; shift ;;
|
||||
--list) LIST_ONLY=1 ;;
|
||||
--no-base) WITH_BASE=0 ;; # dipertahankan: kini defaultnya sudah tanpa base
|
||||
--with-base) WITH_BASE=1 ;;
|
||||
--with-dremio) WITH_DREMIO=1 ;;
|
||||
--save-airflow) SAVE_AIRFLOW="${2:-}"; shift ;;
|
||||
--load-airflow) LOAD_AIRFLOW="${2:-}"; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) err "Argumen tidak dikenal: $1"; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
require_docker() {
|
||||
if ! command -v docker &>/dev/null; then
|
||||
err "docker tidak ditemukan — jalankan ./scripts/prepare-host.sh lebih dulu."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Daftar image (tidak di-hardcode) ───────────────────────────────────────
|
||||
compose_images() { # <file> [--env-file <f>] [--profile …]…
|
||||
local file="$1"; shift
|
||||
local out err_file
|
||||
# stderr JANGAN ditelan: kalau kosong, tampilkan penyebabnya (pelajaran dari gate
|
||||
# Fase 5 yang sempat menyembunyikan error compose sehingga gagal sulit dilacak).
|
||||
#
|
||||
# Catatan bug: versi pertama memakai `local err` + `trap … RETURN`. Trap RETURN
|
||||
# berjalan SETELAH variabel local dibersihkan sehingga ${err} menjadi unbound, dan
|
||||
# nama `err` juga menutupi FUNGSI err(). Kini tanpa trap + bernama err_file.
|
||||
err_file="$(mktemp)"
|
||||
out="$(docker compose -f "${file}" "$@" config --images 2>"${err_file}" | sort -u)"
|
||||
if [[ -z "${out}" && -s "${err_file}" ]]; then
|
||||
warn "docker compose gagal mendaftar image:"
|
||||
sed 's/^/ /' "${err_file}" >&2
|
||||
fi
|
||||
rm -f "${err_file}"
|
||||
printf '%s\n' "${out}"
|
||||
}
|
||||
|
||||
# Isi ENV_ARGS (variabel global) dengan argumen env untuk compose.
|
||||
# Bila .env BELUM ADA (server masih bersih — .env dibuat install.sh saat instalasi),
|
||||
# pakai default dari config/environment.defaults.sh: ke-16 variabel yang diwajibkan
|
||||
# compose (:?err) semuanya ada di sana, dan nilainya TIDAK memengaruhi daftar image
|
||||
# (image di-pin di docker-compose.yml).
|
||||
#
|
||||
# PENTING: fungsi ini HARUS dipanggil di shell saat ini — bukan di dalam $( ) — supaya
|
||||
# `set -a; source defaults` benar-benar berlaku untuk proses compose kita. Versi pertama
|
||||
# saya memanggilnya dalam command substitution (subshell), jadi sourcing-nya sia-sia.
|
||||
ENV_ARGS=()
|
||||
resolve_env() { # <envfile>
|
||||
local envfile="$1"
|
||||
if [[ -f "${envfile}" ]]; then
|
||||
ENV_ARGS=(--env-file "${envfile}")
|
||||
return 0
|
||||
fi
|
||||
warn "berkas ${envfile} belum ada — memakai default config/environment.defaults.sh"
|
||||
echo " (.env dibuat oleh install.sh saat instalasi; tidak memengaruhi daftar image)" >&2
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
. "${DEPLOY_DIR}/config/environment.defaults.sh"
|
||||
set +a
|
||||
ENV_ARGS=()
|
||||
}
|
||||
|
||||
collect_images() {
|
||||
local file envfile
|
||||
if [[ "${MODE}" == "host" ]]; then
|
||||
[[ -z "${HOST_NAME}" ]] && { err "--host memerlukan nama host."; exit 2; }
|
||||
file="${DEPLOY_DIR}/generated/host-${HOST_NAME}/docker-compose.yml"
|
||||
if [[ ! -f "${file}" ]]; then
|
||||
err "Bundle host belum ada: ${file}"
|
||||
echo " Jalankan dulu: ./install.sh --generate (atau --generate --inventory cluster.yml)"
|
||||
exit 1
|
||||
fi
|
||||
envfile="${DEPLOY_DIR}/generated/host-${HOST_NAME}/.env"
|
||||
[[ -f "${envfile}" ]] || envfile="${DEPLOY_DIR}/.env"
|
||||
inf "sumber daftar image: bundle host '${HOST_NAME}'"
|
||||
resolve_env "${envfile}"
|
||||
compose_images "${file}" ${ENV_ARGS[@]+"${ENV_ARGS[@]}"} --profile all
|
||||
else
|
||||
local extra=()
|
||||
if [[ ${WITH_DREMIO} -eq 1 ]]; then
|
||||
extra=(--profile dremio)
|
||||
inf "sumber daftar image: docker-compose.yml (profil all + dremio)"
|
||||
else
|
||||
inf "sumber daftar image: docker-compose.yml (profil all; dremio dikeluarkan)"
|
||||
fi
|
||||
resolve_env "${DEPLOY_DIR}/.env"
|
||||
compose_images "${DEPLOY_DIR}/docker-compose.yml" ${ENV_ARGS[@]+"${ENV_ARGS[@]}"} \
|
||||
--profile all ${extra[@]+"${extra[@]}"}
|
||||
fi
|
||||
}
|
||||
|
||||
# Base image Airflow diambil dari Dockerfile agar tidak ter-hardcode di sini.
|
||||
airflow_base_image() {
|
||||
local df="${DEPLOY_DIR}/docker/airflow/Dockerfile.slim"
|
||||
[[ -f "${df}" ]] || return 1
|
||||
grep -m1 '^FROM ' "${df}" | awk '{print $2}'
|
||||
}
|
||||
|
||||
airflow_built_image() {
|
||||
# shellcheck disable=SC1091
|
||||
( set -a; [[ -f "${DEPLOY_DIR}/config/environment.defaults.sh" ]] && . "${DEPLOY_DIR}/config/environment.defaults.sh"
|
||||
[[ -f "${DEPLOY_DIR}/.env" ]] && . "${DEPLOY_DIR}/.env"
|
||||
echo "${AIRFLOW_IMAGE:-sicabe/airflow:3.3.2-om203-ol}" ) 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
# Host registry dari nama image — untuk petunjuk diagnosa saat pull gagal.
|
||||
# Image tanpa '/' atau dengan komponen pertama tanpa titik berasal dari Docker Hub
|
||||
# (endpoint API-nya registry-1.docker.io).
|
||||
registry_host() {
|
||||
# Dipecah DUA baris: `local x="$1" y="${x%%/*}"` mengevaluasi RHS `y` SEBELUM `x`
|
||||
# di-assign → "unbound variable" di bawah `set -u` (bug laten yang baru muncul saat
|
||||
# fungsi ini dipanggil di jalur normal).
|
||||
local img="$1"
|
||||
local first="${img%%/*}"
|
||||
# WAJIB ada '/' dulu: tanpa itu, "postgres:15" membuat ${img%%/*} = seluruh string
|
||||
# dan tanda ':' adalah TAG, bukan port registry (bug yang ditemukan saat menguji).
|
||||
if [[ "${img}" == */* && ( "${first}" == *.* || "${first}" == *:* || "${first}" == "localhost" ) ]]; then
|
||||
echo "${first}"
|
||||
else
|
||||
echo "registry-1.docker.io"
|
||||
fi
|
||||
}
|
||||
|
||||
image_size_mb() {
|
||||
docker image inspect -f '{{.Size}}' "$1" 2>/dev/null | awk '{printf "%d", $1/1024/1024}'
|
||||
}
|
||||
|
||||
# ─── Aksi: simpan / muat image Airflow yang dibangun lokal ──────────────────
|
||||
do_save_airflow() {
|
||||
local built; built="$(airflow_built_image)"
|
||||
if ! docker image inspect "${built}" &>/dev/null; then
|
||||
err "Image '${built}' belum ada — bangun dulu:"
|
||||
echo " docker build -t ${built} -f docker/airflow/Dockerfile.332 docker/airflow"
|
||||
exit 1
|
||||
fi
|
||||
inf "menyimpan '${built}' ke ${SAVE_AIRFLOW} (berisi seluruh layer, termasuk base)"
|
||||
docker save -o "${SAVE_AIRFLOW}" "${built}" && \
|
||||
ok "tersimpan ($(du -h "${SAVE_AIRFLOW}" | cut -f1)) — salin ke host lain lalu pakai --load-airflow"
|
||||
}
|
||||
|
||||
do_load_airflow() {
|
||||
[[ -f "${LOAD_AIRFLOW}" ]] || { err "berkas tidak ditemukan: ${LOAD_AIRFLOW}"; exit 1; }
|
||||
inf "memuat image dari ${LOAD_AIRFLOW}"
|
||||
docker load -i "${LOAD_AIRFLOW}" && ok "image dimuat"
|
||||
local built; built="$(airflow_built_image)"
|
||||
docker image inspect "${built}" &>/dev/null && ok "'${built}' tersedia" || warn "image '${built}' belum terlihat"
|
||||
}
|
||||
|
||||
# ─── Aksi: tarik image ──────────────────────────────────────────────────────
|
||||
do_pull() {
|
||||
local images base built built_local
|
||||
images="$(collect_images)"
|
||||
if [[ -z "${images}" ]]; then
|
||||
err "daftar image kosong — periksa docker-compose.yml / bundle host."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base="$(airflow_base_image || true)"
|
||||
built="$(airflow_built_image)"
|
||||
|
||||
# Image Airflow: dikecualikan HANYA bila namanya menunjuk LOKAL polos
|
||||
# (`sicabe/airflow:…`, tidak ada host registry). Bila `AIRFLOW_IMAGE` menunjuk
|
||||
# REGISTRY (mis. `registry.sicabe.com/airflow:…`, host memuat titik), image
|
||||
# IKUT di-pull seperti image lain — sejak image dipublikasikan ke registry internal.
|
||||
# Deteksi memakai registry_host(): nama Docker Hub polos -> "registry-1.docker.io".
|
||||
built_local=0
|
||||
if [[ -n "${built}" && "$(registry_host "${built}")" == "registry-1.docker.io" ]]; then
|
||||
built_local=1
|
||||
# Keluarkan dari daftar (bukan sekadar dilewati) supaya daftar = yang benar-benar diunduh.
|
||||
images="$(printf '%s\n' "${images}" | grep -vxF "${built}" || true)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${BLD}Daftar image (${MODE})${NC}"
|
||||
printf '%s\n' "${images}" | sed 's/^/ - /'
|
||||
[[ -n "${base}" && ${WITH_BASE} -eq 1 ]] && echo " - ${base} (base build Airflow)"
|
||||
if [[ -n "${built}" && ${built_local} -eq 1 ]]; then
|
||||
echo " (dikecualikan: ${built} — dibangun lokal, tidak ada di Docker Hub; tersedia di registry internal)"
|
||||
fi
|
||||
[[ ${WITH_DREMIO} -eq 0 ]] && echo " (dikecualikan: dremio/dremio-oss — opt-in, aktifkan dengan --with-dremio)"
|
||||
|
||||
if [[ ${LIST_ONLY} -eq 1 ]]; then
|
||||
echo
|
||||
inf "--list: tidak ada yang ditarik."
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${BLD}Menarik image${NC}"
|
||||
local failed=() okc=0
|
||||
while IFS= read -r img; do
|
||||
[[ -z "${img}" ]] && continue
|
||||
# Image yang dibangun lokal tidak ada di registry — jangan dicoba di-pull.
|
||||
if [[ ${built_local} -eq 1 && "${img}" == "${built}" ]]; then
|
||||
inf "${img} — dibangun lokal, dilewati (lihat alur build-sekali-salin di bawah)"
|
||||
continue
|
||||
fi
|
||||
if docker image inspect "${img}" &>/dev/null && [[ "${img}" != *":latest" ]]; then
|
||||
ok "${img} (sudah ada)"
|
||||
okc=$((okc + 1))
|
||||
continue
|
||||
fi
|
||||
inf "pull ${img} …"
|
||||
# Output pull ditangkap ke berkas, BUKAN dibuang: tanpa ini kegagalan hanya
|
||||
# berbunyi "GAGAL" tanpa sebab (dialami saat minio/quay.io gagal di server).
|
||||
local perr; perr="$(mktemp)"
|
||||
if docker pull "${img}" >"${perr}" 2>&1; then
|
||||
ok "${img}"
|
||||
okc=$((okc + 1))
|
||||
else
|
||||
warn "GAGAL: ${img}"
|
||||
tail -3 "${perr}" | sed 's/^/ /' >&2
|
||||
local reg; reg="$(registry_host "${img}")"
|
||||
echo " hint: periksa akses ke registry '${reg}' → curl -sI https://${reg}/v2/" >&2
|
||||
failed+=("${img}")
|
||||
fi
|
||||
rm -f "${perr}"
|
||||
done <<< "${images}"
|
||||
|
||||
if [[ -n "${base}" && ${WITH_BASE} -eq 1 ]]; then
|
||||
if docker image inspect "${base}" &>/dev/null; then
|
||||
ok "${base} (sudah ada)"
|
||||
elif docker pull -q "${base}" >/dev/null 2>&1; then
|
||||
ok "${base} (base build Airflow)"
|
||||
else
|
||||
warn "GAGAL: ${base} — build Airflow lokal akan mengunduh ulang"
|
||||
failed+=("${base}")
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo -e "${BLD}Ringkasan${NC}"
|
||||
echo " berhasil/sudah ada : ${okc}"
|
||||
if [[ ${#failed[@]} -gt 0 ]]; then
|
||||
echo " gagal : ${#failed[@]}"
|
||||
printf ' - %s\n' "${failed[@]}"
|
||||
fi
|
||||
|
||||
# Ukuran total image yang terdaftar (perkiraan kebutuhan disk)
|
||||
local total=0 size
|
||||
while IFS= read -r img; do
|
||||
size="$(image_size_mb "${img}")"
|
||||
[[ -n "${size}" ]] && total=$((total + size))
|
||||
done <<< "${images}"
|
||||
echo " ukuran total : ~${total} MB (image yang terdaftar)"
|
||||
|
||||
if [[ ${built_local} -eq 1 ]] && ! docker image inspect "${built}" &>/dev/null; then
|
||||
echo
|
||||
warn "'${built}' BELUM ada dan ia TIDAK dapat di-pull (dibangun lokal)."
|
||||
echo " Alur build-sekali-salin (opsi b):"
|
||||
echo " di SATU host : docker build -t ${built} -f docker/airflow/Dockerfile.332 docker/airflow"
|
||||
echo " ./scripts/prefetch-images.sh --save-airflow /tmp/airflow.tar"
|
||||
echo " host lain : scp /tmp/airflow.tar <host>:/tmp/"
|
||||
echo " ./scripts/prefetch-images.sh --load-airflow /tmp/airflow.tar"
|
||||
fi
|
||||
|
||||
[[ ${#failed[@]} -eq 0 ]]
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
require_docker
|
||||
if [[ -n "${SAVE_AIRFLOW}" ]]; then do_save_airflow; exit $?; fi
|
||||
if [[ -n "${LOAD_AIRFLOW}" ]]; then do_load_airflow; exit $?; fi
|
||||
echo -e "${BLD}Prefetch image SiCABE${NC}"
|
||||
do_pull
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+316
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# prepare-host.sh — siapkan server Ubuntu 24.04 (noble) untuk instalasi SiCABE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Menyiapkan satu host sebelum `install.sh` dijalankan:
|
||||
# 1. paket apt yang benar-benar dipakai script deploy (bukan container)
|
||||
# 2. Docker Engine + plugin compose v2 (opsional --no-docker)
|
||||
# 3. sysctl vm.max_map_count=262144 → SYARAT KERAS OpenSearch
|
||||
# 4. (opsional --swap-off) matikan swap → rekomendasi OpenSearch
|
||||
# 5. prometheus-node-exporter (:9100) → PRA-SYARAT dashboard Host
|
||||
# (opsional --no-node-exporter)
|
||||
# 6. precheck: AVX2, jam, RAM, disk, port yang akan dipakai
|
||||
#
|
||||
# Pemakaian:
|
||||
# sudo ./scripts/prepare-host.sh --dry-run # lihat rencana, TIDAK mengubah apa pun
|
||||
# sudo ./scripts/prepare-host.sh # jalankan
|
||||
# sudo ./scripts/prepare-host.sh --no-docker # bila Docker sudah dipasang manual
|
||||
# sudo ./scripts/prepare-host.sh --swap-off # ikut mematikan swap
|
||||
# sudo ./scripts/prepare-host.sh --no-node-exporter # jangan pasang node_exporter
|
||||
#
|
||||
# Sifat: IDEMPOTEN — aman dijalankan berulang.
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
DRY_RUN=0
|
||||
WITH_DOCKER=1
|
||||
SWAP_OFF=0
|
||||
WITH_NODE_EXPORTER=1
|
||||
|
||||
RED=$'\e[31m'; GRN=$'\e[32m'; YLW=$'\e[33m'; BLD=$'\e[1m'; NC=$'\e[0m'
|
||||
ok() { echo -e " ${GRN}[OK]${NC} $*"; }
|
||||
inf() { echo -e " ${YLW}[..]${NC} $*"; }
|
||||
warn() { echo -e " ${YLW}[WARN]${NC} $*"; }
|
||||
err() { echo -e " ${RED}[!!]${NC} $*" >&2; }
|
||||
|
||||
# Jalankan perintah; pada --dry-run cukup dicetak.
|
||||
run() {
|
||||
if [[ ${DRY_RUN} -eq 1 ]]; then
|
||||
echo " [dry-run] $*"
|
||||
return 0
|
||||
fi
|
||||
"$@"
|
||||
}
|
||||
|
||||
usage() {
|
||||
sed -n '2,22p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--no-docker) WITH_DOCKER=0 ;;
|
||||
--swap-off) SWAP_OFF=1 ;;
|
||||
--no-node-exporter) WITH_NODE_EXPORTER=0 ;;
|
||||
-h|--help) usage ;;
|
||||
*) err "Argumen tidak dikenal: $1"; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
}
|
||||
|
||||
# ─── 0. OS & hak akses ──────────────────────────────────────────────────────
|
||||
check_os() {
|
||||
echo -e "${BLD}A. Sistem${NC}"
|
||||
if [[ ! -r /etc/os-release ]]; then
|
||||
err "/etc/os-release tidak terbaca — script ini untuk Ubuntu."; exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
echo " OS: ${PRETTY_NAME:-?} (codename: ${VERSION_CODENAME:-?})"
|
||||
if [[ "${ID:-}" != "ubuntu" ]]; then
|
||||
warn "Bukan Ubuntu (ID=${ID:-?}) — script ini diuji pada Ubuntu 24.04; lanjutkan hati-hati."
|
||||
elif [[ "${VERSION_CODENAME:-}" != "noble" ]]; then
|
||||
warn "Bukan 24.04/noble (codename=${VERSION_CODENAME:-?}) — nama paket bisa berbeda."
|
||||
else
|
||||
ok "Ubuntu 24.04 (noble) — sesuai target"
|
||||
fi
|
||||
if [[ ${EUID} -ne 0 && ${DRY_RUN} -eq 0 ]]; then
|
||||
err "Perlu root: jalankan dengan sudo."
|
||||
exit 1
|
||||
fi
|
||||
[[ ${EUID} -ne 0 ]] && inf "berjalan tanpa root (dry-run) — hanya memeriksa"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── 1. Paket apt ───────────────────────────────────────────────────────────
|
||||
# Daftar ini berasal dari pemakaian nyata di deploy/, bukan tebakan:
|
||||
# htpasswd (apache2-utils) → password.db Trino (bootstrap/install)
|
||||
# ldapsearch (ldap-utils) → bootstrap step 3 + verify-phase1
|
||||
# mysql (client) → seed/reapply StarRocks + verify-phase4
|
||||
# openssl → keystore Trino
|
||||
# curl → hampir semua script
|
||||
# python3 + PyYAML → lib/cluster/*.py + bootstrap
|
||||
# ca-certificates/gnupg → repo & TLS
|
||||
PKGS=(ca-certificates curl gnupg openssl apache2-utils ldap-utils python3 python3-yaml git unzip)
|
||||
|
||||
install_packages() {
|
||||
echo
|
||||
echo -e "${BLD}B. Paket apt${NC}"
|
||||
# Klien MySQL: nama paket berbeda antar rilis → coba berurutan, laporkan yang dipakai.
|
||||
local mysql_candidates=(default-mysql-client mysql-client mariadb-client) picked=""
|
||||
run apt-get update -qq
|
||||
run apt-get install -y --no-install-recommends "${PKGS[@]}"
|
||||
for cand in "${mysql_candidates[@]}"; do
|
||||
if apt-cache show "${cand}" >/dev/null 2>&1; then
|
||||
run apt-get install -y --no-install-recommends "${cand}"
|
||||
picked="${cand}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "${picked}" ]]; then
|
||||
ok "paket terpasang (klien MySQL: ${picked})"
|
||||
else
|
||||
warn "klien MySQL tidak ditemukan di repo — seed/reapply StarRocks butuh binary 'mysql'"
|
||||
fi
|
||||
|
||||
# MinIO client (mc) dipakai verify-phase2 & bootstrap, tapi tidak selalu ada di repo.
|
||||
if command -v mc &>/dev/null; then
|
||||
ok "mc tersedia"
|
||||
else
|
||||
warn "mc (MinIO client) belum ada — sebagian cek Fase 2 & bootstrap akan dilewati."
|
||||
echo " Pasang manual bila diinginkan, mis.:"
|
||||
echo " curl -sSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc && chmod +x /usr/local/bin/mc"
|
||||
fi
|
||||
|
||||
# node_exporter (:9100) — PRA-SYARAT dashboard Host (job `node` di Prometheus).
|
||||
# Best-effort: paket ada di repo Ubuntu (universe). Bila tak tersedia, dashboard
|
||||
# Host hanya menampilkan target DOWN — tidak fatal.
|
||||
if [[ ${WITH_NODE_EXPORTER} -eq 0 ]]; then
|
||||
inf "node_exporter dilewati (--no-node-exporter)"
|
||||
elif command -v prometheus-node-exporter &>/dev/null; then
|
||||
ok "node_exporter sudah tersedia (:9100)"
|
||||
elif apt-cache show prometheus-node-exporter >/dev/null 2>&1; then
|
||||
run apt-get install -y --no-install-recommends prometheus-node-exporter
|
||||
ok "node_exporter terpasang & listen :9100 (di-scrape Prometheus job 'node')"
|
||||
else
|
||||
warn "paket prometheus-node-exporter tidak ada di repo — dashboard Host akan kosong"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 2. Docker ──────────────────────────────────────────────────────────────
|
||||
install_docker() {
|
||||
echo
|
||||
echo -e "${BLD}C. Docker${NC}"
|
||||
if [[ ${WITH_DOCKER} -eq 0 ]]; then
|
||||
inf "--no-docker: dilewati"
|
||||
elif command -v docker &>/dev/null && docker compose version &>/dev/null; then
|
||||
ok "Docker + compose v2 sudah tersedia ($(docker --version))"
|
||||
else
|
||||
inf "memasang Docker Engine + plugin compose dari repo resmi Docker"
|
||||
run install -m 0755 -d /etc/apt/keyrings
|
||||
# kunci GPG repo Docker
|
||||
if [[ ${DRY_RUN} -eq 0 ]]; then
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/ubuntu ${VERSION_CODENAME} stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
apt-get update -qq
|
||||
apt-get install -y --no-install-recommends \
|
||||
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
else
|
||||
echo " [dry-run] tambah repo Docker + pasang docker-ce, docker-compose-plugin, …"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Pengguna non-root: supaya `docker` bisa dipakai tanpa sudo setelah re-login.
|
||||
local target_user="${SUDO_USER:-}"
|
||||
if [[ -n "${target_user}" && "${target_user}" != "root" && ${DRY_RUN} -eq 0 ]]; then
|
||||
if id -nG "${target_user}" | grep -qw docker; then
|
||||
ok "pengguna ${target_user} sudah di grup docker"
|
||||
else
|
||||
run usermod -aG docker "${target_user}"
|
||||
warn "pengguna ${target_user} ditambahkan ke grup docker — LOGOUT/LOGIN agar berlaku"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${DRY_RUN} -eq 0 ]] && command -v docker &>/dev/null; then
|
||||
if systemctl is-active --quiet docker; then
|
||||
ok "layanan docker aktif"
|
||||
else
|
||||
run systemctl enable --now docker
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 3. sysctl (SYARAT KERAS OpenSearch) ────────────────────────────────────
|
||||
configure_sysctl() {
|
||||
echo
|
||||
echo -e "${BLD}D. sysctl — vm.max_map_count${NC}"
|
||||
local want=262144 have
|
||||
have="$(sysctl -n vm.max_map_count 2>/dev/null || echo 0)"
|
||||
if [[ "${have}" -ge "${want}" ]]; then
|
||||
ok "vm.max_map_count=${have} (cukup)"
|
||||
return 0
|
||||
fi
|
||||
warn "vm.max_map_count=${have} < ${want} — OpenSearch GAGAL start bila kurang (syarat keras)"
|
||||
local f=/etc/sysctl.d/99-sicabe.conf
|
||||
if [[ ${DRY_RUN} -eq 1 ]]; then
|
||||
echo " [dry-run] tulis 'vm.max_map_count=${want}' ke ${f} lalu sysctl --system"
|
||||
return 0
|
||||
fi
|
||||
echo "vm.max_map_count=${want}" > "${f}"
|
||||
sysctl --system >/dev/null 2>&1 || true
|
||||
ok "ditetapkan vm.max_map_count=$(sysctl -n vm.max_map_count 2>/dev/null) (persist: ${f})"
|
||||
}
|
||||
|
||||
# ─── 4. Swap (opsional, rekomendasi OpenSearch) ─────────────────────────────
|
||||
configure_swap() {
|
||||
echo
|
||||
echo -e "${BLD}E. Swap${NC}"
|
||||
if [[ ${SWAP_OFF} -eq 0 ]]; then
|
||||
inf "--swap-off tidak diberikan: swap dibiarkan (OpenSearch hanya merekomendasikan)"
|
||||
return 0
|
||||
fi
|
||||
if [[ -z "$(swapon --show 2>/dev/null)" ]]; then
|
||||
ok "swap sudah mati"
|
||||
return 0
|
||||
fi
|
||||
if [[ ${DRY_RUN} -eq 1 ]]; then
|
||||
echo " [dry-run] swapoff -a + nonaktifkan entri swap di /etc/fstab (dengan backup)"
|
||||
return 0
|
||||
fi
|
||||
cp -a /etc/fstab "/etc/fstab.sicabe-bak-$(date +%Y%m%d%H%M%S)"
|
||||
swapoff -a
|
||||
sed -i 's|^\([^#].*[[:space:]]swap[[:space:]].*\)$|# \1|' /etc/fstab
|
||||
ok "swap dimatikan (fstab dibackup; entri swap dikomentari)"
|
||||
warn "pastikan RAM cukup — tanpa swap, OOM langsung membunuh proses"
|
||||
}
|
||||
|
||||
# ─── 5. Precheck ────────────────────────────────────────────────────────────
|
||||
precheck() {
|
||||
echo
|
||||
echo -e "${BLD}F. Precheck${NC}"
|
||||
|
||||
# AVX2: syarat keras starrocks-cn (tanpa ini container gagal SIGILL)
|
||||
if grep -qo -m1 avx2 /proc/cpuinfo 2>/dev/null; then
|
||||
ok "CPU mendukung AVX2 (syarat starrocks-cn)"
|
||||
else
|
||||
warn "AVX2 TIDAK terdeteksi — container starrocks-cn akan gagal (SIGILL)."
|
||||
echo " Jalankan install tanpa Fase 4, atau pindahkan starrocks-cn ke host ber-AVX2."
|
||||
fi
|
||||
|
||||
# Jam: OIDC/JWT sensitif terhadap skew (preflight juga memeriksanya)
|
||||
if command -v timedatectl &>/dev/null; then
|
||||
local ntp
|
||||
ntp="$(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo "")"
|
||||
case "${ntp}" in
|
||||
yes) ok "jam tersinkronisasi (NTP aktif)" ;;
|
||||
no) warn "jam BELUM tersinkron — aktifkan NTP (timedatectl set-ntp true) sebelum install" ;;
|
||||
*) inf "status NTP tidak dapat dibaca di lingkungan ini — verifikasi manual di server nyata" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# RAM & disk
|
||||
local ram_gb disk_gb
|
||||
ram_gb="$(awk '/MemTotal/{printf "%d", $2/1024/1024}' /proc/meminfo 2>/dev/null || echo 0)"
|
||||
disk_gb="$(df -BG --output=avail /var/lib/docker 2>/dev/null | tail -1 | tr -dc '0-9')"
|
||||
[[ -z "${disk_gb}" ]] && disk_gb="$(df -BG --output=avail / | tail -1 | tr -dc '0-9')"
|
||||
echo " RAM: ${ram_gb} GB · disk tersedia: ${disk_gb:-?} GB"
|
||||
[[ "${ram_gb}" -lt 8 ]] 2>/dev/null && warn "RAM < 8 GB: sesuaikan penempatan komponen (lihat reviews/sicabe-09…)"
|
||||
[[ "${disk_gb:-0}" -lt 20 ]] 2>/dev/null && warn "disk < 20 GB: image + volume bisa tidak cukup (siapkan ±20 GB untuk image)"
|
||||
|
||||
# Port yang akan dipakai: dibaca dari config/environment.defaults.sh agar sinkron
|
||||
local ports=""
|
||||
if [[ -f "${DEPLOY_DIR}/config/environment.defaults.sh" ]]; then
|
||||
ports="$(grep -oE ':-(12[0-9]{3})\}' "${DEPLOY_DIR}/config/environment.defaults.sh" \
|
||||
| tr -dc '0-9\n' | sort -un | paste -sd' ' -)"
|
||||
fi
|
||||
if [[ -n "${ports}" ]]; then
|
||||
local busy="" p
|
||||
for p in ${ports}; do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/${p}") 2>/dev/null; then busy+=" ${p}"; fi
|
||||
exec 3<&- 2>/dev/null
|
||||
done
|
||||
if [[ -z "${busy}" ]]; then
|
||||
ok "semua port 12xxx yang dibutuhkan masih bebas"
|
||||
else
|
||||
warn "port sudah terpakai:${busy}"
|
||||
echo " Bila itu stack SiCABE yang lama: matikan dulu (RAM tidak cukup untuk dua stack)."
|
||||
fi
|
||||
else
|
||||
inf "daftar port tidak terbaca dari environment.defaults.sh — lewati pemeriksaan port"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
echo -e "${BLD}Persiapan host untuk SiCABE${NC}"
|
||||
echo " deploy dir : ${DEPLOY_DIR}"
|
||||
[[ ${DRY_RUN} -eq 1 ]] && echo -e " ${YLW}MODE DRY-RUN — tidak ada perubahan sistem${NC}"
|
||||
echo
|
||||
check_os
|
||||
install_packages
|
||||
install_docker
|
||||
configure_sysctl
|
||||
configure_swap
|
||||
precheck
|
||||
echo
|
||||
echo -e "${BLD}Ringkasan${NC}"
|
||||
echo " Langkah berikutnya di host ini:"
|
||||
echo " 1. salin direktori deploy/ ke host ini"
|
||||
echo " 2. ./scripts/prefetch-images.sh # tarik semua image lebih dulu"
|
||||
echo " 3. ./install.sh # atau ./install.sh --host <nama>"
|
||||
echo " 4. ./install.sh --preflight (cek keterjangkauan lintas host)"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — print-keycloak-requirements.sh
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mencetak KEBUTUHAN Keycloak (nilai konkret dari .env) untuk diserahkan ke
|
||||
# administrator Keycloak — pasangan dari docs/keycloak-existing-requirements.md.
|
||||
#
|
||||
# Read-only: tidak mengubah apa pun; hanya menampilkan.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/print-keycloak-requirements.sh
|
||||
# ./scripts/print-keycloak-requirements.sh --env-file generated/host-control/.env
|
||||
# ./scripts/print-keycloak-requirements.sh --mask # sembunyikan client secret
|
||||
#
|
||||
# Keluaran bisa disalin ke tiket/email ke pengelola Keycloak.
|
||||
# =============================================================================
|
||||
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"
|
||||
|
||||
MASK=0
|
||||
ARGS=()
|
||||
for _a in "$@"; do
|
||||
case "${_a}" in
|
||||
--mask) MASK=1 ;;
|
||||
*) ARGS+=("${_a}") ;;
|
||||
esac
|
||||
done
|
||||
sicabe_load_env "${SCRIPT_DIR}" "${ARGS[@]}"
|
||||
|
||||
REALM="${SICABE_REALM:-sicabe}"
|
||||
KC_PUB="${KEYCLOAK_PUBLIC_URL:-http://localhost:${KEYCLOAK_PORT:-12080}}"
|
||||
SSL_REQ="${KEYCLOAK_SSL_REQUIRED:-external}"
|
||||
SR_HTTP_PORT="${STARROCKS_HTTP_PORT:-12030}"
|
||||
|
||||
# Tampilkan secret: nilai asli, atau '********' bila --mask.
|
||||
show_secret() { # <value> <dev-default>
|
||||
local v="${1:-}" d="${2:-}"
|
||||
if [[ -z "${v}" ]]; then echo "(KOSONG!)"; return; fi
|
||||
if [[ "${MASK}" == "1" ]]; then echo "********"; return; fi
|
||||
if [[ "${v}" == "${d}" ]]; then echo "${v} ← DEFAULT DEV, ganti di produksi"; else echo "${v}"; fi
|
||||
}
|
||||
|
||||
printf '%s\n' "================================================================================"
|
||||
printf '%s\n' " KEBUTUHAN KEYCLOAK (existing) — dari: ${SICABE_ENV_FILE:-${SCRIPT_DIR}/.env}"
|
||||
printf '%s\n' "================================================================================"
|
||||
printf ' Realm : %s\n' "${REALM}"
|
||||
printf ' Issuer : %s/realms/%s\n' "${KC_PUB}" "${REALM}"
|
||||
printf ' sslRequired : %s (external bila ada TLS; none utk http lokal)\n' "${SSL_REQ}"
|
||||
printf '\n'
|
||||
printf '%s\n' " Realm roles : admin engineer analyst viewer"
|
||||
printf '%s\n' " Users (opsional) : admin engineer analyst viewer"
|
||||
printf '%s\n' " (atau petakan user organisasi ke 4 role di atas)"
|
||||
printf '\n'
|
||||
printf '%s\n' " OIDC clients — SEMUA: type=OpenID Connect, client authentication ON,"
|
||||
printf '%s\n' " Standard flow ON, Service accounts ON (publicClient=false)"
|
||||
printf '\n'
|
||||
printf ' %-13s %-34s %s\n' "clientId" "client secret (harus sama)" "redirect URI"
|
||||
printf ' %-13s %-34s %s\n' "------------" "---------------------------" "--------------------------"
|
||||
printf ' %-13s %-34s %s/*\n' "trino" "$(show_secret "${TRINO_CLIENT_SECRET:-}" "trino-secret")" "${TRINO_PUBLIC_URL:-}"
|
||||
printf ' %-13s %-34s %s\n' "starrocks" "$(show_secret "${STARROCKS_CLIENT_SECRET:-}" "starrocks-secret")" "http://<host-starrocks>:${SR_HTTP_PORT}/api/oauth2"
|
||||
printf ' %-13s %-34s %s/*\n' "airflow" "$(show_secret "${AIRFLOW_CLIENT_SECRET:-}" "airflow-secret")" "${AIRFLOW_PUBLIC_URL:-}"
|
||||
printf ' %-13s %-34s %s/*\n' "openmetadata" "$(show_secret "${OPENMETADATA_CLIENT_SECRET:-}" "om-secret")" "${OPENMETADATA_PUBLIC_URL:-}"
|
||||
printf ' %-13s %-34s %s/*\n' "grafana" "$(show_secret "${GRAFANA_CLIENT_SECRET:-}" "grafana-secret")" "${GRAFANA_PUBLIC_URL:-}"
|
||||
printf '\n'
|
||||
printf '%s\n' " Semua client juga sertakan: http://localhost:* dan https://localhost:*"
|
||||
printf '\n'
|
||||
printf '%s\n' " Protocol mapper (WAJIB tiap client): Audience 'account' (oidc-audience-mapper)"
|
||||
printf '%s\n' " → Tanpa ini Trino MENOLAK token (Trino memverifikasi aud=account)."
|
||||
printf '%s\n' " StarRocks: tambahan audience 'starrocks' (required_audience=starrocks)."
|
||||
printf '\n'
|
||||
printf '%s\n' " Kembalikan ke SiCABE: nama realm, issuer URL, dan 5 client secret di atas."
|
||||
if [[ "${MASK}" == "0" ]]; then
|
||||
printf '\n'
|
||||
printf '%s\n' " ⚠ KELUARAN MEMUAT SECRET — kirim lewat kanal aman; jangan tempel di tiket publik."
|
||||
fi
|
||||
printf '%s\n' "================================================================================"
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — print-openldap-requirements.sh
|
||||
# -----------------------------------------------------------------------------
|
||||
# Mencetak KEBUTUHAN OpenLDAP (nilai konkret dari .env) untuk diserahkan ke
|
||||
# administrator LDAP — pasangan dari docs/openldap-existing-requirements.md.
|
||||
#
|
||||
# Read-only: hanya menampilkan.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/print-openldap-requirements.sh
|
||||
# ./scripts/print-openldap-requirements.sh --env-file generated/host-control/.env
|
||||
# ./scripts/print-openldap-requirements.sh --mask # sembunyikan bind password
|
||||
# =============================================================================
|
||||
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"
|
||||
|
||||
MASK=0
|
||||
ARGS=()
|
||||
for _a in "$@"; do
|
||||
case "${_a}" in
|
||||
--mask) MASK=1 ;;
|
||||
*) ARGS+=("${_a}") ;;
|
||||
esac
|
||||
done
|
||||
sicabe_load_env "${SCRIPT_DIR}" "${ARGS[@]}"
|
||||
|
||||
BASE_DN="${LDAP_BASE_DN:-dc=sicabe,dc=local}"
|
||||
USERS_DN="${LDAP_USERS_DN:-ou=users,${BASE_DN}}"
|
||||
GROUPS_DN="${LDAP_GROUPS_DN:-ou=groups,${BASE_DN}}"
|
||||
BIND_DN="${LDAP_ADMIN_DN:-cn=admin,${BASE_DN}}"
|
||||
LDAP_URL="${LDAP_URL:-ldap://openldap:389}"
|
||||
LDAP_HOST="${LDAP_HOST:-localhost}"
|
||||
LDAP_PORT="${LDAP_PORT:-389}"
|
||||
|
||||
show_secret() { # <value> <dev-default>
|
||||
local v="${1:-}" d="${2:-}"
|
||||
if [[ -z "${v}" ]]; then echo "(KOSONG!)"; return; fi
|
||||
if [[ "${MASK}" == "1" ]]; then echo "********"; return; fi
|
||||
if [[ "${v}" == "${d}" ]]; then echo "${v} ← DEFAULT DEV, ganti di produksi"; else echo "${v}"; fi
|
||||
}
|
||||
|
||||
printf '%s\n' "================================================================================"
|
||||
printf '%s\n' " KEBUTUHAN OPENLDAP (existing) — dari: ${SICABE_ENV_FILE:-${SCRIPT_DIR}/.env}"
|
||||
printf '%s\n' "================================================================================"
|
||||
printf '%s\n' " Peran LDAP di SiCABE = SUMBER GRUP (bukan autentikasi; auth = Keycloak OIDC)."
|
||||
printf '\n'
|
||||
printf ' URL (dari container) : %s\n' "${LDAP_URL}"
|
||||
printf ' host:port (dari host): %s:%s\n' "${LDAP_HOST}" "${LDAP_PORT}"
|
||||
printf '\n'
|
||||
printf ' Base DN : %s\n' "${BASE_DN}"
|
||||
printf ' OU users : %s\n' "${USERS_DN}"
|
||||
printf ' OU groups : %s\n' "${GROUPS_DN}"
|
||||
printf '\n'
|
||||
printf ' Akun BIND (buat baca) : %s\n' "${BIND_DN}"
|
||||
printf ' → boleh akun READ-ONLY, bukan harus admin.\n'
|
||||
printf ' password : %s\n' "$(show_secret "${LDAP_ADMIN_PASSWORD:-}" "ldap_admin_DevOnly")"
|
||||
printf '\n'
|
||||
printf '%s\n' " Grup (WAJIB, objectClass=groupOfNames, atribut cn + member=DN user):"
|
||||
printf '%s\n' " data-admin data-engineer data-analyst data-viewer"
|
||||
printf '%s\n' " → nama ini HARUS sama dengan kunci roles di config/opa/data.json"
|
||||
printf '%s\n' " DAN permitted_groups StarRocks."
|
||||
printf '\n'
|
||||
printf '%s\n' " User (objectClass inetOrgPerson + posixAccount):"
|
||||
printf '%s\n' " uid HARUS = username Keycloak (preferred_username) ← krusial"
|
||||
printf '%s\n' " atribut minimal: uid, cn, sn, mail, uidNumber, gidNumber, homeDirectory"
|
||||
printf '\n'
|
||||
printf '%s\n' " Kembalikan ke SiCABE: LDAP_URL, host:port, bind DN + password, base DN/OU,"
|
||||
printf '%s\n' " dan konfirmasi bahwa uid user = username Keycloak & keanggotaan grup benar."
|
||||
if [[ "${MASK}" == "0" ]]; then
|
||||
printf '\n'
|
||||
printf '%s\n' " ⚠ KELUARAN MEMUAT PASSWORD — kirim lewat kanal aman; jangan tempel di tiket publik."
|
||||
fi
|
||||
printf '%s\n' "================================================================================"
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# render-config.sh — render config/** untuk deploy multi-host (Fase 0/1, R10 §4.9)
|
||||
#
|
||||
# MASALAH: file config/** yang dibaca CONTAINER (.properties/.yaml/.cfg/.json/rego)
|
||||
# TIDAK dapat melakukan ekspansi env saat runtime, berbeda dari docker-compose.yml.
|
||||
# Pada deploy multi-host, hostname nama container (mis. `nessie:19120`) harus
|
||||
# menjadi alamat host sebenarnya (mis. `10.20.0.10:19120`).
|
||||
#
|
||||
# SOLUSI (Opsi (i) R10 §4.9): `config/` dibiarkan LITERAL (single-host tidak
|
||||
# tersentuh); script ini menyalinnya ke direktori output sambil mengganti
|
||||
# hostname TERDAFTAR, menghasilkan `generated/host-<x>/config/` (gitignored).
|
||||
#
|
||||
# PRINSIP KEAMANAN
|
||||
# 1. Hanya hostname yang di-`--map` yang diganti → tidak ada kejutan.
|
||||
# 2. Penggantian ANCHORED: `host` diganti hanya bila diikuti `:` atau `/`
|
||||
# (posisi target jaringan). Karena itu `KC_DB: postgres` (dialek DB),
|
||||
# `DB_SCHEME: postgresql`, dan `postgres:15` (tag image di compose) TIDAK
|
||||
# ikut berubah.
|
||||
# 3. `config/` sumber tidak pernah ditulis — hanya dibaca.
|
||||
# 4. Baris KOMENTAR (`#`) tidak ditulis ulang: komentar sering menyebut nilai
|
||||
# default lokal, dan menimpanya akan membuat komentar itu justru salah.
|
||||
#
|
||||
# PEMAKAIAN
|
||||
# # Uji tanpa menulis (perlihatkan rencana):
|
||||
# ./scripts/render-config.sh --out /tmp/render --dry-run \
|
||||
# --map postgres=10.20.0.12 --map nessie=10.20.0.10
|
||||
#
|
||||
# # Render sungguhan:
|
||||
# ./scripts/render-config.sh --out generated/host-compute/config \
|
||||
# --map postgres=10.20.0.12 --map minio=10.20.0.12
|
||||
#
|
||||
# # Fase 1 akan menambah: --inventory cluster.yml --host <nama>
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
SRC_CONFIG="${DEPLOY_DIR}/config"
|
||||
OUT_DIR=""
|
||||
DRY_RUN=0
|
||||
declare -A MAP=()
|
||||
|
||||
# Hostname yang SAH dipetakan ke alamat host. Daftar sengaja tetap (bukan bebas)
|
||||
# supaya salah ketik --map tidak diam-diam menulis ulang file config.
|
||||
KNOWN_HOSTS=(
|
||||
postgres minio nessie keycloak openldap opa
|
||||
trino trino-coordinator trino-worker
|
||||
starrocks-fe starrocks-cn airflow-webserver
|
||||
openmetadata-server openmetadata-opensearch dremio grafana prometheus
|
||||
# Sidecar observabilitas (M2/M3): target di config/prometheus dipetakan ke alamat
|
||||
# HOST-nya saat multi-host. WAJIB ada di sini — generate.py meng-`--map` setiap
|
||||
# RENDER_HOSTS, dan hostname yang tak terdaftar membuat render-config menolak,
|
||||
# sehingga `install.sh --generate` GAGAL TOTAL (bug M2/M3 yang diperbaiki di sini).
|
||||
statsd-exporter trino-jmx-exporter
|
||||
)
|
||||
|
||||
# Tidak di-render: skrip yang sanggup ekspansi env sendiri + dokumentasi.
|
||||
# environment.defaults.sh — di-source shell, memakai ${VAR:-...}
|
||||
# buckets.sh — skrip mc, memakai ${MINIO_ENDPOINT:-...}
|
||||
SKIP_FILES=(environment.defaults.sh buckets.sh)
|
||||
SKIP_PATTERNS=("*.md")
|
||||
|
||||
RED=$'\e[31m'; GRN=$'\e[32m'; YLW=$'\e[33m'; BLD=$'\e[1m'; NC=$'\e[0m'
|
||||
ok() { echo -e " ${GRN}[OK]${NC} $*"; }
|
||||
inf() { echo -e " ${YLW}[..]${NC} $*"; }
|
||||
err() { echo -e " ${RED}[!!]${NC} $*" >&2; }
|
||||
|
||||
usage() {
|
||||
sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||
echo
|
||||
echo "Hostname yang dikenal: ${KNOWN_HOSTS[*]}"
|
||||
}
|
||||
|
||||
is_known_host() {
|
||||
local h="$1" k
|
||||
for k in "${KNOWN_HOSTS[@]}"; do [[ "${k}" == "${h}" ]] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
add_map() {
|
||||
local pair="$1" host addr
|
||||
if [[ "${pair}" != *"="* ]]; then
|
||||
err "--map harus berformat host=alamat (diberikan: '${pair}')"; exit 2
|
||||
fi
|
||||
host="${pair%%=*}"; addr="${pair#*=}"
|
||||
[[ -n "${host}" && -n "${addr}" ]] || { err "--map kosong: '${pair}'"; exit 2; }
|
||||
is_known_host "${host}" || { err "hostname '${host}' tidak dikenal (lihat --help)"; exit 2; }
|
||||
MAP["${host}"]="${addr}"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--out) OUT_DIR="${2:-}"; shift 2 ;;
|
||||
--map) add_map "${2:-}"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) err "Argumen tidak dikenal: $1"; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "${OUT_DIR}" ]] || { err "--out wajib diisi"; exit 2; }
|
||||
[[ ${#MAP[@]} -gt 0 ]] || { err "minimal satu --map host=alamat wajib diisi"; exit 2; }
|
||||
}
|
||||
|
||||
# Satu pass perl untuk semua host. Anchor diperketat ke TIGA konteks jaringan —
|
||||
# tanpa ini, path filesystem seperti `/etc/trino/` atau `/data/trino/` ikut
|
||||
# terkorupsi (hostname `trino` diikuti `/`):
|
||||
# 1. setelah skema URL `//` → http://nessie:19120, jdbc:postgresql://postgres:5432
|
||||
# 2. setelah `@` (JDBC) → user:pw@postgres:5432
|
||||
# 3. bare `host:port` → nessie:19120, starrocks-fe:8030 (bukan bagian token lain)
|
||||
# Baris komentar (`#`) tidak ditulis ulang.
|
||||
render_file() {
|
||||
local src="$1" dst="$2"
|
||||
local -a pairs=()
|
||||
local k
|
||||
for k in "${!MAP[@]}"; do pairs+=("${k}=${MAP[$k]}"); done
|
||||
perl -e '
|
||||
my ($src, $dst, @pairs) = @ARGV;
|
||||
my %map = map { split(/=/, $_, 2) } @pairs;
|
||||
my $alt = join "|", sort { length($b) <=> length($a) } map { quotemeta } keys %map;
|
||||
open(my $in, "<", $src) or die "buka gagal: $src\n";
|
||||
open(my $out, ">", $dst) or die "tulis gagal: $dst\n";
|
||||
while (my $l = <$in>) {
|
||||
unless ($l =~ /^\s*#/) {
|
||||
$l =~ s{(?<=//)(${alt})(?=[:/])}{$map{$1}}g; # 1. setelah skema //
|
||||
$l =~ s{(?<=@)(${alt})(?=[:/])}{$map{$1}}g; # 2. setelah @ (JDBC)
|
||||
$l =~ s{(?<![\w/.:-])(${alt})(?=:\d)}{$map{$1}}g; # 3. bare host:port
|
||||
# 4. nilai BARE untuk key yang jelas host (host, hostname, *_HOST).
|
||||
# `host: postgres` (Sling) tidak punya ':' atau '/', sehingga aturan 1-3
|
||||
# tidak mengenainya -> di host lain Sling mencari nama container yang tidak
|
||||
# ada (kelas bug yang sama dengan DB_HOST OpenMetadata). Hanya key ber-'host'
|
||||
# yang disentuh, supaya `type: postgres` / `KC_DB: postgres` (dialek) aman.
|
||||
$l =~ s{^(\s*(?:-\s*)?[A-Za-z_.\-]*(?:host|hostname)[A-Za-z_.\-]*\s*[:=]\s*)(${alt})(\s*)$}{$1 . $map{$2} . $3}gie;
|
||||
}
|
||||
print $out $l;
|
||||
}
|
||||
close $in; close $out;
|
||||
' "${src}" "${dst}" "${pairs[@]}"
|
||||
}
|
||||
|
||||
# Jumlah baris sumber yang berubah di file tujuan.
|
||||
count_subs() {
|
||||
local src="$1" dst="$2"
|
||||
diff "${src}" "${dst}" 2>/dev/null | grep -c '^<' || true
|
||||
}
|
||||
|
||||
should_skip() {
|
||||
local name="$1" pat skip
|
||||
for skip in "${SKIP_FILES[@]}"; do [[ "${name}" == "${skip}" ]] && return 0; done
|
||||
for pat in "${SKIP_PATTERNS[@]}"; do
|
||||
# shellcheck disable=SC2053
|
||||
[[ "${name}" == ${pat} ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Render satu file; cetak jumlah baris berubah bila ada.
|
||||
process_file() {
|
||||
local src="$1" rel="${1#${SRC_CONFIG}/}" dst="$2" n tmp
|
||||
if [[ ${DRY_RUN} -eq 1 ]]; then
|
||||
tmp="$(mktemp)"; render_file "${src}" "${tmp}"; n="$(count_subs "${src}" "${tmp}")"; rm -f "${tmp}"
|
||||
else
|
||||
dst="${OUT_DIR}/${rel}"
|
||||
mkdir -p "$(dirname "${dst}")"
|
||||
render_file "${src}" "${dst}"
|
||||
n="$(count_subs "${src}" "${dst}")"
|
||||
fi
|
||||
CHANGED_TOTAL=$((CHANGED_TOTAL + n))
|
||||
if [[ "${n}" -gt 0 ]]; then ok "${rel} — ${n} baris"; FILES_CHANGED=$((FILES_CHANGED + 1)); fi
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
echo -e "${BLD}Render config/** untuk multi-host${NC}"
|
||||
echo " sumber : ${SRC_CONFIG}"
|
||||
echo " tujuan : ${OUT_DIR}$([[ ${DRY_RUN} -eq 1 ]] && echo ' (DRY-RUN)')"
|
||||
echo -n " peta : "; for k in "${!MAP[@]}"; do printf '%s→%s ' "${k}" "${MAP[$k]}"; done; echo; echo
|
||||
[[ ${DRY_RUN} -eq 0 ]] && mkdir -p "${OUT_DIR}"
|
||||
|
||||
FILES_CHANGED=0; CHANGED_TOTAL=0
|
||||
local src scanned=0 skipped=0
|
||||
while IFS= read -r -d '' src; do
|
||||
scanned=$((scanned + 1))
|
||||
if should_skip "$(basename "${src}")"; then skipped=$((skipped + 1)); continue; fi
|
||||
process_file "${src}" ""
|
||||
done < <(find "${SRC_CONFIG}" -type f -print0 | sort -z)
|
||||
|
||||
echo
|
||||
inf "dipindai ${scanned} file · ${FILES_CHANGED} berubah (${CHANGED_TOTAL} baris) · ${skipped} dilewati"
|
||||
echo -e " ${GRN}${BLD}Selesai.${NC} config/ sumber tidak tersentuh."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — seed contoh Data Quality NATIVE OpenMetadata (Jalur B)
|
||||
# =============================================================================
|
||||
# Membuat contoh test case native + pipeline TestSuite yang membawa koneksi Trino
|
||||
# eksplisit, lalu (opsional) deploy + trigger supaya hasilnya masuk ke OM.
|
||||
#
|
||||
# Mengapa lewat pipeline, bukan TestRunner SDK: lihat header
|
||||
# scripts/airflow/seed-om-native-dq.py dan docs/sicabe-install-phases-detail.md
|
||||
# §8.10 — jalur SDK memakai koneksi tersimpan yang kehilangan secret (bug
|
||||
# upstream) sehingga selalu `Aborted`.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/seed-native-dq.sh # buat definisi + pipeline saja
|
||||
# ./scripts/seed-native-dq.sh --trigger # sekaligus deploy + trigger
|
||||
#
|
||||
# Idempoten: test case & pipeline yang sudah ada dilewati.
|
||||
# Dijalankan dari host; pekerjaannya dieksekusi DI DALAM container Airflow.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ⚠️ JALUR B TERBLOKIR UPSTREAM — banner ini disengaja.
|
||||
# DQ native OpenMetadata (Jalur B) memakai koneksi tersimpan yang kehilangan secret saat
|
||||
# dibangun runner, sehingga test SELALU berakhir `Aborted` (docs/sicabe-install-phases-detail.md
|
||||
# §8.10). Karena itu skrip ini TIDAK dipanggil bootstrap.sh / seed-sample-data.sh /
|
||||
# ingest-openmetadata.sh: ia opt-in untuk eksperimen. Gate fase 6 memperlakukannya sebagai
|
||||
# SKIP, bukan FAIL. Untuk DQ yang benar-benar bekerja, pakai Jalur A (Great Expectations)
|
||||
# pada task validate_gold / cdc_validate di DAG sicabe_batch_daily.
|
||||
echo "⚠️ seed-native-dq.sh = Jalur B (DQ native OM) yang TERBLOKIR bug upstream (§8.10)."
|
||||
echo " Hasil test akan 'Aborted'. Untuk DQ yang bekerja: task validate_gold/cdc_validate (Jalur A)."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(dirname "${SCRIPT_DIR}")"
|
||||
|
||||
# shellcheck source=../lib/common.sh
|
||||
. "${DEPLOY_DIR}/lib/common.sh" 2>/dev/null || true
|
||||
# shellcheck source=../config/environment.defaults.sh
|
||||
. "${DEPLOY_DIR}/config/environment.defaults.sh" 2>/dev/null || true
|
||||
|
||||
# .env memuat password admin OM + password service account Trino.
|
||||
# Multi-host: dukung --env-file / SICABE_ENV_FILE seperti skrip lain.
|
||||
sicabe_load_env "${DEPLOY_DIR}" "$@" || exit 1
|
||||
|
||||
AIRFLOW_CONTAINER="${AIRFLOW_CONTAINER:-sicabe-airflow-webserver}"
|
||||
WORKER="/opt/airflow/scripts/seed-om-native-dq.py"
|
||||
|
||||
# Multi-host: Airflow ada di host `control`; `docker exec` bersifat lokal.
|
||||
sicabe_require_local_container "${AIRFLOW_CONTAINER}" "Airflow webserver" "control" || exit $?
|
||||
|
||||
for var in OPENMETADATA_ADMIN_PASSWORD TRINO_SVC_OM_PASSWORD; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
echo "GAGAL: ${var} kosong — periksa ${DEPLOY_DIR}/.env" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "== seed contoh DQ native =="
|
||||
# Buang `--env-file <berkas>` dari argumen yang diteruskan ke worker Python
|
||||
# (sudah dipakai sicabe_load_env; worker tak mengenalnya).
|
||||
WORKER_ARGS=(); _snq_prev=""
|
||||
for _snq_a in "$@"; do
|
||||
if [[ "${_snq_prev}" == "--env-file" ]]; then _snq_prev=""; continue; fi
|
||||
if [[ "${_snq_a}" == "--env-file" ]]; then _snq_prev="--env-file"; continue; fi
|
||||
WORKER_ARGS+=("${_snq_a}")
|
||||
done
|
||||
docker exec \
|
||||
-e OPENMETADATA_ADMIN_PASSWORD="${OPENMETADATA_ADMIN_PASSWORD}" \
|
||||
-e TRINO_SVC_OM_PASSWORD="${TRINO_SVC_OM_PASSWORD}" \
|
||||
"${AIRFLOW_CONTAINER}" python "${WORKER}" "${WORKER_ARGS[@]+"${WORKER_ARGS[@]}"}"
|
||||
Executable
+169
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Seed Sample Data: Iceberg bronze.orders
|
||||
# =============================================================================
|
||||
# Membuat namespace + tabel contoh `iceberg.bronze.orders` (8 kolom, sesuai
|
||||
# dbt sources.yml) lalu mengisinya dengan 6 baris contoh. Idempoten: aman
|
||||
# dijalankan ulang (schema/table IF NOT EXISTS, INSERT hanya bila kosong).
|
||||
#
|
||||
# Autentikasi: OIDC password-grant (client 'trino') ke Keycloak → Bearer JWT.
|
||||
# ⚠️ Password-grant bersifat dev-only (bukan untuk produksi).
|
||||
#
|
||||
# Pemakaian:
|
||||
# cd deploy
|
||||
# set -a; source config/environment.defaults.sh; source .env; set +a
|
||||
# ./scripts/seed-sample-data.sh
|
||||
#
|
||||
# Env (opsional, ada default):
|
||||
# TRINO_HTTPS_PORT (12443), KEYCLOAK_PORT (12080), SICABE_REALM (sicabe),
|
||||
# TRINO_CLIENT_SECRET, KEYCLOAK_ADMIN_PASSWORD, SEED_USER (admin),
|
||||
# SEED_CATALOG (iceberg), SEED_SCHEMA (bronze), SEED_TABLE (orders)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
# ─── Shared Library & Environment ───────────────────────────────────────────
|
||||
source "${SCRIPT_DIR}/lib/common.sh"
|
||||
source "${SCRIPT_DIR}/config/environment.defaults.sh"
|
||||
sicabe_load_env "${SCRIPT_DIR}" "$@"
|
||||
|
||||
# Service account (HTTP Basic) — langsung ke Trino HTTPS (self-signed).
|
||||
# Multi-host: Trino ada di host `compute` → endpoint host-aware (single-host tetap localhost).
|
||||
TRINO_URL="$(sicabe_local_url https TRINO_HOST "${TRINO_HTTPS_PORT:-12443}" TRINO_URL)"
|
||||
SEED_USER="${SEED_USER:-svc_seed}"
|
||||
SEED_PASSWORD="${SEED_PASSWORD:-${TRINO_SVC_SEED_PASSWORD:-}}"
|
||||
SEED_CATALOG="${SEED_CATALOG:-iceberg}"
|
||||
SEED_SCHEMA="${SEED_SCHEMA:-bronze}"
|
||||
SEED_TABLE="${SEED_TABLE:-orders}"
|
||||
FQN="${SEED_CATALOG}.${SEED_SCHEMA}.${SEED_TABLE}"
|
||||
|
||||
usage_seed() {
|
||||
echo "Usage: $0 [--user <trino-user>] [--help]"
|
||||
echo " Seed tabel contoh '${FQN}' (idempoten)."
|
||||
}
|
||||
|
||||
# ─── Auth: service account (HTTP Basic) ─────────────────────────────────────
|
||||
check_auth() {
|
||||
[[ -n "${SEED_USER}" && -n "${SEED_PASSWORD}" ]]
|
||||
}
|
||||
|
||||
# ─── Submit SQL ke Trino + polling nextUri (Trino 483) ──────────────────────
|
||||
# Cetak baris data (jika ada); gagal (return 1) bila Trino melaporkan error.
|
||||
trino_exec() {
|
||||
local sql="$1" resp next err i=0
|
||||
resp=$(curl -skf -X POST "${TRINO_URL}/v1/statement" \
|
||||
-u "${SEED_USER}:${SEED_PASSWORD}" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "${sql}" 2>/dev/null) || { echo -e "${ERR} submit ke Trino gagal."; return 1; }
|
||||
|
||||
while :; do
|
||||
err=$(echo "${resp}" | python3 -c "import sys,json;d=json.load(sys.stdin);e=d.get('error');print(e.get('message','') if e else '')" 2>/dev/null || true)
|
||||
if [[ -n "${err}" ]]; then
|
||||
echo -e "${ERR} ${err}"
|
||||
return 1
|
||||
fi
|
||||
echo "${resp}" | python3 -c "import sys,json;d=json.load(sys.stdin);[print('\t',r) for r in (d.get('data') or [])]" 2>/dev/null || true
|
||||
next=$(echo "${resp}" | python3 -c "import sys,json;print(json.load(sys.stdin).get('nextUri',''))" 2>/dev/null || true)
|
||||
[[ -z "${next}" ]] && break
|
||||
i=$((i + 1))
|
||||
if [[ ${i} -gt 60 ]]; then
|
||||
echo -e "${WARN} Polling Trino melebihi batas waktu."
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
resp=$(curl -skf -u "${SEED_USER}:${SEED_PASSWORD}" "${next}" 2>/dev/null) || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Ambil satu nilai skalar dari Trino ─────────────────────────────────────
|
||||
trino_value() {
|
||||
local sql="$1" resp next val i=0
|
||||
resp=$(curl -skf -X POST "${TRINO_URL}/v1/statement" \
|
||||
-u "${SEED_USER}:${SEED_PASSWORD}" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "${sql}" 2>/dev/null) || return 1
|
||||
|
||||
while :; do
|
||||
val=$(echo "${resp}" | python3 -c "import sys,json;d=json.load(sys.stdin);r=d.get('data') or [];print(r[0][0] if r else '')" 2>/dev/null || true)
|
||||
[[ -n "${val}" ]] && { echo "${val}"; return 0; }
|
||||
next=$(echo "${resp}" | python3 -c "import sys,json;print(json.load(sys.stdin).get('nextUri',''))" 2>/dev/null || true)
|
||||
[[ -z "${next}" ]] && { echo "${val}"; return 0; }
|
||||
i=$((i + 1)); [[ ${i} -gt 60 ]] && return 1
|
||||
sleep 1
|
||||
resp=$(curl -skf -u "${SEED_USER}:${SEED_PASSWORD}" "${next}" 2>/dev/null) || return 1
|
||||
done
|
||||
}
|
||||
|
||||
# ─── DDL + seed (idempoten) ─────────────────────────────────────────────────
|
||||
seed_table() {
|
||||
echo -e "${INFO} [1/3] Buat namespace '${SEED_CATALOG}.${SEED_SCHEMA}'..."
|
||||
trino_exec "CREATE SCHEMA IF NOT EXISTS ${SEED_CATALOG}.${SEED_SCHEMA}" \
|
||||
&& echo -e "${OK} Namespace '${SEED_SCHEMA}' siap."
|
||||
|
||||
echo -e "${INFO} [2/3] Buat tabel '${FQN}'..."
|
||||
trino_exec "CREATE TABLE IF NOT EXISTS ${FQN} (
|
||||
id bigint,
|
||||
order_date date,
|
||||
komoditas varchar,
|
||||
harga_per_kg double,
|
||||
stok_kg bigint,
|
||||
region varchar,
|
||||
created_at timestamp(6),
|
||||
updated_at timestamp(6)
|
||||
) WITH (format = 'PARQUET')" \
|
||||
&& echo -e "${OK} Tabel '${FQN}' siap."
|
||||
|
||||
echo -e "${INFO} [3/3] Isi data contoh (bila kosong)..."
|
||||
local count
|
||||
count=$(trino_value "SELECT count(*) FROM ${FQN}") || count="0"
|
||||
if [[ "${count}" -gt 0 ]]; then
|
||||
echo -e "${OK} Tabel sudah berisi ${count} baris (skip seed)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
trino_exec "INSERT INTO ${FQN} VALUES
|
||||
(1, DATE '2026-09-01', 'beras', 12000.0, 500, 'jakarta', TIMESTAMP '2026-09-01 08:00:00', TIMESTAMP '2026-09-01 08:00:00'),
|
||||
(2, DATE '2026-09-01', 'gula', 15000.0, 300, 'bandung', TIMESTAMP '2026-09-01 09:00:00', TIMESTAMP '2026-09-01 09:00:00'),
|
||||
(3, DATE '2026-09-02', 'minyak', 18000.0, 200, 'surabaya', TIMESTAMP '2026-09-02 08:00:00', TIMESTAMP '2026-09-02 08:00:00'),
|
||||
(4, DATE '2026-09-02', 'beras', 12500.0, 450, 'jakarta', TIMESTAMP '2026-09-02 10:00:00', TIMESTAMP '2026-09-02 10:00:00'),
|
||||
(5, DATE '2026-09-03', 'tepung', 11000.0, 350, 'medan', TIMESTAMP '2026-09-03 08:00:00', TIMESTAMP '2026-09-03 08:00:00'),
|
||||
(6, DATE '2026-09-03', 'gula', 15500.0, 250, 'bandung', TIMESTAMP '2026-09-03 11:00:00', TIMESTAMP '2026-09-03 11:00:00')" \
|
||||
&& echo -e "${OK} 6 baris contoh dimasukkan."
|
||||
}
|
||||
|
||||
# ─── Verifikasi akhir ───────────────────────────────────────────────────────
|
||||
verify_seed() {
|
||||
echo -e "${INFO} Verifikasi:"
|
||||
trino_exec "SELECT id, komoditas, harga_per_kg, stok_kg, region FROM ${FQN} ORDER BY id"
|
||||
}
|
||||
|
||||
# ─── MAIN ───────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--env-file) shift 2 ;; # sudah dipakai sicabe_load_env() di atas
|
||||
--user) SEED_USER="$2"; shift 2 ;;
|
||||
--help|-h) usage_seed; exit 0 ;;
|
||||
*) echo -e "${ERR} Argumen tidak dikenal: $1"; usage_seed; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo -e "${BOLD}${BLUE}═══ Seed Sample Data — Iceberg '${FQN}' ═══${NC}"
|
||||
|
||||
echo -e "${INFO} Autentikasi Trino (service account '${SEED_USER}')..."
|
||||
if ! check_auth; then
|
||||
echo -e "${ERR} SEED_PASSWORD / TRINO_SVC_SEED_PASSWORD belum diset."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${OK} Kredensial service account siap."
|
||||
|
||||
seed_table
|
||||
verify_seed
|
||||
|
||||
echo -e "${OK} ${GREEN}Seed selesai — '${FQN}' siap dipakai.${NC}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Seed Sumber PostgreSQL: sicabe_data.public.orders
|
||||
# =============================================================================
|
||||
# Membuat database `sicabe_data` + tabel `public.orders` (8 kolom, konsisten
|
||||
# dengan dbt sources.yml dan tabel Iceberg `bronze.orders`) lalu mengisinya
|
||||
# dengan 6 baris contoh.
|
||||
#
|
||||
# Tabel ini adalah SUMBER untuk tugas DAG `sicabe_batch_daily.ingest_to_bronze`
|
||||
# (Sling: POSTGRES_DB.public.orders → ICEBERG bronze.orders).
|
||||
#
|
||||
# CATATAN: `scripts/seed-sample-data.sh` mengisi sisi Iceberg (bronze.orders)
|
||||
# secara langsung — script INI mengisi sisi PostgreSQL (sumbernya), sehingga
|
||||
# jalur Sling → Iceberg → dbt → GE dapat berjalan end-to-end.
|
||||
#
|
||||
# Idempoten: aman dijalankan berulang (CREATE ... IF NOT EXISTS; INSERT hanya
|
||||
# bila tabel kosong).
|
||||
#
|
||||
# Pemakaian:
|
||||
# cd deploy
|
||||
# ./scripts/seed-source-postgres.sh
|
||||
#
|
||||
# Env opsional (ada default):
|
||||
# PG_CONTAINER (sicabe-postgres), SEED_SOURCE_DB (sicabe_data),
|
||||
# SEED_SOURCE_TABLE (orders), POSTGRES_USER (sicabe_admin)
|
||||
# =============================================================================
|
||||
|
||||
set -euo 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}" "$@"
|
||||
|
||||
PG_CONTAINER="${PG_CONTAINER:-sicabe-postgres}"
|
||||
SEED_DB="${SEED_SOURCE_DB:-sicabe_data}"
|
||||
SEED_TABLE="${SEED_SOURCE_TABLE:-orders}"
|
||||
PG_USER="${POSTGRES_USER:-sicabe_admin}"
|
||||
|
||||
# Multi-host: PostgreSQL ada di host `state` dan `docker exec` bersifat lokal →
|
||||
# bila bukan di host ini, beri instruksi & keluar rapi (kode 3), bukan gagal.
|
||||
sicabe_require_local_container "${PG_CONTAINER}" "PostgreSQL" "state" || exit $?
|
||||
|
||||
on_err() { echo -e "${ERR} Gagal pada baris $1."; exit 1; }
|
||||
trap 'on_err $LINENO' ERR
|
||||
|
||||
# psql di dalam container (tanpa password: koneksi lokal container).
|
||||
pg() { docker exec -i "${PG_CONTAINER}" psql -v ON_ERROR_STOP=1 -qtA -U "${PG_USER}" "$@"; }
|
||||
|
||||
echo -e "${BOLD}${BLUE}=== Seed Sumber PostgreSQL: ${SEED_DB}.public.${SEED_TABLE} ===${NC}"
|
||||
|
||||
# ─── [1/3] Database ─────────────────────────────────────────────────────────
|
||||
echo -e "${INFO} [1/3] Database '${SEED_DB}'..."
|
||||
if [[ "$(pg -d postgres -c "SELECT 1 FROM pg_database WHERE datname='${SEED_DB}'")" == "1" ]]; then
|
||||
echo -e "${OK} Database '${SEED_DB}' sudah ada."
|
||||
else
|
||||
pg -d postgres -c "CREATE DATABASE ${SEED_DB} OWNER ${PG_USER}" >/dev/null
|
||||
echo -e "${OK} Database '${SEED_DB}' dibuat."
|
||||
fi
|
||||
|
||||
# ─── [2/3] Tabel sumber ─────────────────────────────────────────────────────
|
||||
echo -e "${INFO} [2/3] Tabel 'public.${SEED_TABLE}'..."
|
||||
pg -d "${SEED_DB}" -c "CREATE TABLE IF NOT EXISTS public.${SEED_TABLE} (
|
||||
id bigint PRIMARY KEY,
|
||||
order_date date,
|
||||
komoditas varchar(64),
|
||||
harga_per_kg double precision,
|
||||
stok_kg bigint,
|
||||
region varchar(64),
|
||||
created_at timestamp,
|
||||
updated_at timestamp
|
||||
)" >/dev/null
|
||||
echo -e "${OK} Tabel 'public.${SEED_TABLE}' siap."
|
||||
|
||||
# ─── [3/3] Data contoh ──────────────────────────────────────────────────────
|
||||
echo -e "${INFO} [3/3] Data contoh (bila kosong)..."
|
||||
COUNT="$(pg -d "${SEED_DB}" -c "SELECT count(*) FROM public.${SEED_TABLE}")"
|
||||
if [[ "${COUNT}" -gt 0 ]]; then
|
||||
echo -e "${OK} Sudah berisi ${COUNT} baris (skip seed)."
|
||||
else
|
||||
pg -d "${SEED_DB}" -c "INSERT INTO public.${SEED_TABLE} VALUES
|
||||
(1, DATE '2026-09-01', 'beras', 12000.0, 500, 'jakarta', TIMESTAMP '2026-09-01 08:00:00', TIMESTAMP '2026-09-01 08:00:00'),
|
||||
(2, DATE '2026-09-01', 'gula', 15000.0, 300, 'bandung', TIMESTAMP '2026-09-01 09:00:00', TIMESTAMP '2026-09-01 09:00:00'),
|
||||
(3, DATE '2026-09-02', 'minyak', 18000.0, 200, 'surabaya', TIMESTAMP '2026-09-02 08:00:00', TIMESTAMP '2026-09-02 08:00:00'),
|
||||
(4, DATE '2026-09-02', 'beras', 12500.0, 450, 'jakarta', TIMESTAMP '2026-09-02 10:00:00', TIMESTAMP '2026-09-02 10:00:00'),
|
||||
(5, DATE '2026-09-03', 'tepung', 11000.0, 350, 'medan', TIMESTAMP '2026-09-03 08:00:00', TIMESTAMP '2026-09-03 08:00:00'),
|
||||
(6, DATE '2026-09-03', 'gula', 15500.0, 250, 'bandung', TIMESTAMP '2026-09-03 11:00:00', TIMESTAMP '2026-09-03 11:00:00')" >/dev/null
|
||||
echo -e "${OK} 6 baris contoh dimasukkan."
|
||||
fi
|
||||
|
||||
# ─── Verifikasi ─────────────────────────────────────────────────────────────
|
||||
echo -e "${INFO} Verifikasi (sumber PostgreSQL):"
|
||||
pg -d "${SEED_DB}" -c "SELECT id, order_date, komoditas, harga_per_kg, stok_kg, region
|
||||
FROM public.${SEED_TABLE} ORDER BY id" \
|
||||
| while IFS='|' read -r a b c d e f; do printf ' %-3s %-12s %-8s %-10s %-6s %s\n' "$a" "$b" "$c" "$d" "$e" "$f"; done
|
||||
|
||||
echo -e "${OK} Selesai. Lanjutkan dengan DAG 'sicabe_batch_daily' (task ingest_to_bronze)."
|
||||
Executable
+175
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Seed StarRocks: External Catalog (Nessie) + Native Shared Tables
|
||||
# =============================================================================
|
||||
# Idempoten:
|
||||
# 1. Pastikan external catalog 'iceberg_catalog' → Nessie (query Iceberg).
|
||||
# 2. Buat database native 'sicabe' di default_catalog.
|
||||
# 3. Buat tabel native shared-data 'sicabe.orders' + 'sicabe.products'
|
||||
# (otomatis CLOUD_NATIVE via default storage volume 's3_volume' → MinIO).
|
||||
# 4. Seed data contoh (hanya bila kosong).
|
||||
# 5. GRANT object privileges ke role (RBAC internal — tanpa ini user OIDC
|
||||
# bisa login tapi tabel tidak terlihat).
|
||||
#
|
||||
# Pemakaian:
|
||||
# cd deploy
|
||||
# set -a; source config/environment.defaults.sh; source .env; set +a
|
||||
# ./scripts/seed-starrocks.sh
|
||||
#
|
||||
# Env (opsional):
|
||||
# STARROCKS_FE_CONTAINER (sicabe-starrocks-fe), STARROCKS_ROOT_PASSWORD,
|
||||
# SEED_DB (sicabe)
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
source "${SCRIPT_DIR}/lib/common.sh"
|
||||
source "${SCRIPT_DIR}/config/environment.defaults.sh"
|
||||
sicabe_load_env "${SCRIPT_DIR}" "$@"
|
||||
|
||||
SR_CONTAINER="${STARROCKS_FE_CONTAINER:-sicabe-starrocks-fe}"
|
||||
SR_PASSWORD="${STARROCKS_ROOT_PASSWORD:?set STARROCKS_ROOT_PASSWORD}"
|
||||
SEED_DB="${SEED_DB:-sicabe}"
|
||||
|
||||
# Multi-host: StarRocks FE ada di host `compute`; `docker exec` bersifat lokal.
|
||||
sicabe_require_local_container "${SR_CONTAINER}" "StarRocks FE" "compute" || exit $?
|
||||
|
||||
# Jalankan SQL via mysql client di dalam container FE (selalu tersedia di image).
|
||||
sr_sql() {
|
||||
docker exec "${SR_CONTAINER}" mysql -h 127.0.0.1 -P 9030 -u root -p"${SR_PASSWORD}" -N -e "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# ─── 1. External catalog Nessie (idempoten) ─────────────────────────────────
|
||||
ensure_external_catalog() {
|
||||
echo -e "${INFO} [1/5] Pastikan external catalog 'iceberg_catalog' → Nessie..."
|
||||
if sr_sql "SHOW CATALOGS;" | grep -q "iceberg_catalog"; then
|
||||
echo -e "${OK} Catalog 'iceberg_catalog' sudah ada."
|
||||
return 0
|
||||
fi
|
||||
sr_sql "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}'
|
||||
);" && echo -e "${OK} Catalog 'iceberg_catalog' dibuat."
|
||||
}
|
||||
|
||||
# ─── 2. Database native ─────────────────────────────────────────────────────
|
||||
ensure_native_db() {
|
||||
echo -e "${INFO} [2/5] Database native '${SEED_DB}'..."
|
||||
sr_sql "CREATE DATABASE IF NOT EXISTS ${SEED_DB};" \
|
||||
&& echo -e "${OK} Database '${SEED_DB}' siap."
|
||||
}
|
||||
|
||||
# ─── 3. Tabel native shared-data (→ MinIO via default storage volume) ───────
|
||||
ensure_native_tables() {
|
||||
echo -e "${INFO} [3/5] Tabel native shared-data (→ MinIO via 's3_volume')..."
|
||||
sr_sql "CREATE TABLE IF NOT EXISTS ${SEED_DB}.orders (
|
||||
id INT NOT NULL,
|
||||
item VARCHAR(50),
|
||||
customer VARCHAR(50),
|
||||
qty INT,
|
||||
price DECIMAL(10,2),
|
||||
ts DATETIME
|
||||
) DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 3
|
||||
PROPERTIES (\"replication_num\" = \"1\");" \
|
||||
&& echo -e "${OK} Tabel '${SEED_DB}.orders' siap."
|
||||
sr_sql "CREATE TABLE IF NOT EXISTS ${SEED_DB}.products (
|
||||
id INT NOT NULL,
|
||||
name VARCHAR(50),
|
||||
category VARCHAR(50),
|
||||
price DECIMAL(10,2)
|
||||
) DUPLICATE KEY(id) DISTRIBUTED BY HASH(id) BUCKETS 3
|
||||
PROPERTIES (\"replication_num\" = \"1\");" \
|
||||
&& echo -e "${OK} Tabel '${SEED_DB}.products' siap."
|
||||
}
|
||||
|
||||
# ─── 4. Seed data (hanya bila kosong) ───────────────────────────────────────
|
||||
seed_rows() {
|
||||
echo -e "${INFO} [4/5] Seed data contoh (bila kosong)..."
|
||||
|
||||
local cnt
|
||||
cnt=$(sr_sql "SELECT count(*) FROM ${SEED_DB}.orders;" || true); cnt="${cnt:-0}"
|
||||
if [[ "${cnt}" -gt 0 ]]; then
|
||||
echo -e "${OK} '${SEED_DB}.orders' sudah berisi ${cnt} baris (skip)."
|
||||
else
|
||||
sr_sql "INSERT INTO ${SEED_DB}.orders VALUES
|
||||
(1,'laptop','alice',2,1500.00,'2026-09-01 09:00:00'),
|
||||
(2,'monitor','budi',1,300.50,'2026-09-02 10:30:00'),
|
||||
(3,'keyboard','citra',3,75.25,'2026-09-03 11:00:00');" \
|
||||
&& echo -e "${OK} 3 baris → '${SEED_DB}.orders'."
|
||||
fi
|
||||
|
||||
local pcnt
|
||||
pcnt=$(sr_sql "SELECT count(*) FROM ${SEED_DB}.products;" || true); pcnt="${pcnt:-0}"
|
||||
if [[ "${pcnt}" -gt 0 ]]; then
|
||||
echo -e "${OK} '${SEED_DB}.products' sudah berisi ${pcnt} baris (skip)."
|
||||
else
|
||||
sr_sql "INSERT INTO ${SEED_DB}.products VALUES
|
||||
(1,'laptop','elektronik',1500.00),
|
||||
(2,'monitor','elektronik',300.50),
|
||||
(3,'keyboard','aksesoris',75.25),
|
||||
(4,'kursi','furnitur',450.00);" \
|
||||
&& echo -e "${OK} 4 baris → '${SEED_DB}.products'."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── 5. GRANT object privileges ke role (RBAC internal) ─────────────────────
|
||||
# Role di-map dari grup eksternal (bootstrap Step 5: GRANT role TO EXTERNAL
|
||||
# GROUP). Tanpa object privilege, user OIDC bisa login tapi tidak melihat tabel.
|
||||
grant_roles() {
|
||||
echo -e "${INFO} [5/5] GRANT object privileges ke role (RBAC internal)..."
|
||||
local role
|
||||
for role in sr_admin sr_engineer sr_analyst sr_viewer; do
|
||||
sr_sql "CREATE ROLE ${role};" >/dev/null 2>&1 || true
|
||||
done
|
||||
sr_sql "GRANT SELECT ON ALL TABLES IN DATABASE ${SEED_DB} TO ROLE sr_analyst;" >/dev/null 2>&1 \
|
||||
&& echo -e "${OK} sr_analyst: SELECT ${SEED_DB}.*"
|
||||
sr_sql "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN DATABASE ${SEED_DB} TO ROLE sr_engineer;" >/dev/null 2>&1 \
|
||||
&& echo -e "${OK} sr_engineer: RW ${SEED_DB}.*"
|
||||
sr_sql "GRANT SELECT,INSERT,UPDATE,DELETE,ALTER,DROP ON ALL TABLES IN ALL DATABASES TO ROLE sr_admin;" >/dev/null 2>&1 \
|
||||
&& echo -e "${OK} sr_admin: ALL (semua database)"
|
||||
echo -e "${INFO} sr_viewer: tanpa grant (fail-closed)"
|
||||
}
|
||||
|
||||
# ─── Verifikasi ─────────────────────────────────────────────────────────────
|
||||
verify() {
|
||||
echo -e "${INFO} Verifikasi:"
|
||||
echo " Catalogs:"
|
||||
sr_sql "SHOW CATALOGS;" | sed 's/^/ /'
|
||||
echo " Native tables (default_catalog.${SEED_DB}):"
|
||||
sr_sql "SHOW TABLES FROM ${SEED_DB};" | sed 's/^/ /'
|
||||
echo " ${SEED_DB}.orders:"
|
||||
sr_sql "SELECT id,item,customer,qty,price FROM ${SEED_DB}.orders ORDER BY id;" | sed 's/^/ /'
|
||||
echo " External (Nessie) iceberg_catalog.bronze.orders:"
|
||||
sr_sql "SELECT id,komoditas,harga_per_kg FROM iceberg_catalog.bronze.orders ORDER BY id;" | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# ─── MAIN ───────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
echo -e "${BOLD}${BLUE}═══ Seed StarRocks — catalog Nessie + tabel native shared (${SEED_DB}) ═══${NC}"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${SR_CONTAINER}$"; then
|
||||
echo -e "${ERR} Container '${SR_CONTAINER}' tidak running."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_external_catalog
|
||||
ensure_native_db
|
||||
ensure_native_tables
|
||||
seed_rows
|
||||
grant_roles
|
||||
verify
|
||||
|
||||
echo -e "${OK} ${GREEN}Seed StarRocks selesai.${NC}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — StarRocks: terapkan DYNAMIC CONFIG (hilang saat FE restart)
|
||||
# =============================================================================
|
||||
# Mengapa terpisah dan BUKAN di fe.conf?
|
||||
# `authentication_chain` harus memuat `sr_oidc`, tetapi integrasi `sr_oidc`
|
||||
# baru dibuat lewat SQL (bootstrap step 5). Pada boot pertama FE, integrasi itu
|
||||
# BELUM ada sehingga menaruhnya di fe.conf akan gagal. Nilainya karena itu
|
||||
# bersifat runtime: harus diterapkan ULANG setiap kali FE restart.
|
||||
#
|
||||
# Yang diterapkan:
|
||||
# 1. authentication_chain = native,<sr_oidc> → tanpa ini login OIDC mati
|
||||
# (integrasi tetap "ada" di SHOW SECURITY INTEGRATIONS, jadi sulit terlihat).
|
||||
# 2. activate_all_roles_on_login = TRUE → role otomatis aktif saat login.
|
||||
#
|
||||
# Skrip ini:
|
||||
# - idempoten (aman dipanggil berkali-kali),
|
||||
# - memverifikasi lebih dulu bahwa security integration ada (kalau tidak,
|
||||
# berhenti dengan pesan agar menjalankan bootstrap step 5),
|
||||
# - memverifikasi hasilnya setelah diterapkan (exit != 0 bila gagal).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/starrocks-reapply-dynamic-config.sh
|
||||
# # StarRocks existing:
|
||||
# STARROCKS_HOST=starrocks.corp STARROCKS_PORT=9030 \
|
||||
# STARROCKS_ROOT_PASSWORD=... ./scripts/starrocks-reapply-dynamic-config.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}" "$@"
|
||||
|
||||
STARROCKS_HOST="${STARROCKS_HOST:-localhost}"
|
||||
STARROCKS_PORT="${STARROCKS_PORT:-${STARROCKS_FE_PORT:-12930}}"
|
||||
# Multi-host: arahkan ke host pemilik StarRocks FE (single-host tetap localhost).
|
||||
STARROCKS_HOST="$(sicabe_local_host STARROCKS_FE_HOST STARROCKS_HOST)"
|
||||
SR_USER="${STARROCKS_USER:-root}"
|
||||
SR_PW="${STARROCKS_ROOT_PASSWORD:-}"
|
||||
OIDC_NAME="${STARROCKS_OIDC_INTEGRATION:-sr_oidc}"
|
||||
SR_MYSQL_IMAGE="${SR_MYSQL_IMAGE:-mysql:8}"
|
||||
|
||||
if command -v mysql >/dev/null 2>&1; then
|
||||
SR_MODE="host"
|
||||
elif docker image inspect "${SR_MYSQL_IMAGE}" >/dev/null 2>&1; then
|
||||
SR_MODE="container"
|
||||
else
|
||||
SR_MODE="none"
|
||||
fi
|
||||
|
||||
# sr_sql <sql> → hasil; kode keluar bukan-0 bila koneksi/kliens gagal.
|
||||
sr_sql() {
|
||||
case "${SR_MODE}" in
|
||||
host)
|
||||
MYSQL_PWD="${SR_PW}" mysql --protocol=TCP -h "${STARROCKS_HOST}" -P "${STARROCKS_PORT}" \
|
||||
-u "${SR_USER}" -N -B -e "$1" 2>/dev/null
|
||||
;;
|
||||
container)
|
||||
docker run --rm -i --network "${SICABE_NETWORK:-sicabe-network}" \
|
||||
-e MYSQL_PWD="${SR_PW}" "${SR_MYSQL_IMAGE}" \
|
||||
mysql --protocol=TCP -h "${STARROCKS_HOST}" -P "${STARROCKS_PORT}" -u "${SR_USER}" \
|
||||
-N -B -e "$1" 2>/dev/null
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
apply_dynamic_config() {
|
||||
echo -e "${INFO} [1/2] authentication_chain = native,${OIDC_NAME}"
|
||||
sr_sql "ADMIN SET FRONTEND CONFIG (\"authentication_chain\" = \"native,${OIDC_NAME}\");" >/dev/null
|
||||
|
||||
echo -e "${INFO} [2/2] activate_all_roles_on_login = TRUE"
|
||||
sr_sql "SET GLOBAL activate_all_roles_on_login = TRUE;" >/dev/null
|
||||
}
|
||||
|
||||
main() {
|
||||
print_section "StarRocks — terapkan dynamic config (auth chain OIDC)"
|
||||
|
||||
if [[ "${SR_MODE}" == "none" ]]; then
|
||||
echo -e "${ERR} Klien MySQL tidak tersedia di host dan image ${SR_MYSQL_IMAGE} belum ada."
|
||||
echo " Install klien: sudo apt-get install -y mysql-client (atau tarik image mysql)"
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${SR_PW}" ]]; then
|
||||
echo -e "${ERR} STARROCKS_ROOT_PASSWORD kosong (isi di .env)."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(sr_sql 'SELECT 1')" != "1" ]]; then
|
||||
echo -e "${ERR} Tidak bisa terhubung ke StarRocks di ${STARROCKS_HOST}:${STARROCKS_PORT}."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${OK} Terhubung ke ${STARROCKS_HOST}:${STARROCKS_PORT}"
|
||||
|
||||
# Prasyarat: integrasi harus sudah ada — kalau tidak, menyetel chain akan error.
|
||||
if ! sr_sql "SHOW SECURITY INTEGRATIONS;" | grep -q "${OIDC_NAME}"; then
|
||||
echo -e "${ERR} Security integration '${OIDC_NAME}' belum ada."
|
||||
echo " Jalankan dulu: ./bootstrap.sh --step 5"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${OK} Security integration '${OIDC_NAME}' ada (prasyarat terpenuhi)"
|
||||
|
||||
apply_dynamic_config
|
||||
|
||||
# Verifikasi hasil — jangan lapor sukses tanpa bukti.
|
||||
local chain roles
|
||||
chain="$(sr_sql "ADMIN SHOW FRONTEND CONFIG LIKE 'authentication_chain';" | awk -F'\t' '{print $3}')"
|
||||
roles="$(sr_sql "SHOW VARIABLES LIKE 'activate_all_roles_on_login';" | awk -F'\t' '{print $2}')"
|
||||
|
||||
local rc=0
|
||||
if [[ "${chain}" == *"${OIDC_NAME}"* ]]; then
|
||||
echo -e "${OK} authentication_chain = ${chain}"
|
||||
else
|
||||
echo -e "${ERR} authentication_chain = ${chain:-tak terbaca} — gagal memuat ${OIDC_NAME}"
|
||||
rc=1
|
||||
fi
|
||||
if [[ "${roles}" == "true" ]]; then
|
||||
echo -e "${OK} activate_all_roles_on_login = ${roles}"
|
||||
else
|
||||
echo -e "${ERR} activate_all_roles_on_login = ${roles:-tak terbaca} (diharapkan true)"
|
||||
rc=1
|
||||
fi
|
||||
|
||||
if [[ ${rc} -eq 0 ]]; then
|
||||
echo -e "${OK} ${GREEN}Dynamic config diterapkan.${NC}"
|
||||
echo -e "${INFO} Verifikasi menyeluruh: ./scripts/verify-phase4-serving.sh"
|
||||
else
|
||||
echo -e "${ERR} Sebagian dynamic config gagal diterapkan."
|
||||
fi
|
||||
exit ${rc}
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# test-bootstrap-host.sh — penjagaan untuk `bootstrap.sh --host` (Fase 2b/2c)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Latar: `generate_runtime_credentials()` menulis TIGA berkas ke `config/` BERSAMA.
|
||||
# Dalam mode `--host`, endpoint-nya milik host itu, sehingga berkas bersama tertimpa
|
||||
# alamat host lain — dan itu TIDAK terlihat di `git status` karena
|
||||
# `config/airflow/sling/env.yaml` memang gitignored. Dampaknya: deploy single-host
|
||||
# yang sedang berjalan menunjuk alamat yang tidak ada.
|
||||
#
|
||||
# Uji ini punya dua lapis:
|
||||
# A. STATIS — membaca bootstrap.sh dan memastikan ketiga penulisan memakai
|
||||
# `${cfg_base}` (bukan `${SCRIPT_DIR}/config` langsung). Tidak butuh
|
||||
# apa pun, selalu jalan.
|
||||
# B. DINAMIS — bila stack SiCABE sedang berjalan: hash ketiga berkas bersama,
|
||||
# jalankan `bootstrap.sh --host state` (hanya step 2 = pastikan bucket,
|
||||
# idempoten), lalu pastikan hash TIDAK berubah.
|
||||
#
|
||||
# Jalankan: ./scripts/test-bootstrap-host.sh
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
GRN=$'\e[32m'; RED=$'\e[31m'; YLW=$'\e[33m'; BLD=$'\e[1m'; NC=$'\e[0m'
|
||||
PASS_N=0; FAIL_N=0
|
||||
ok() { printf " ${GRN}✅${NC} %s\n" "$1"; PASS_N=$((PASS_N + 1)); }
|
||||
bad() { printf " ${RED}❌${NC} %s\n" "$1"; FAIL_N=$((FAIL_N + 1)); }
|
||||
inf() { printf " ${YLW}[..]${NC} %s\n" "$1"; }
|
||||
|
||||
SHARED_FILES=(
|
||||
"config/airflow/sling/env.yaml"
|
||||
"config/airflow/simple_auth_manager_passwords.json"
|
||||
"config/trino/password.db"
|
||||
)
|
||||
|
||||
echo -e "${BLD}Uji bootstrap.sh --host${NC}"
|
||||
echo
|
||||
echo "── A. penjagaan statis (tanpa dependensi) ──"
|
||||
|
||||
# Ekstrak badan fungsi generate_runtime_credentials.
|
||||
body="$(awk '/^generate_runtime_credentials\(\) \{/{f=1} f{print} f&&/^\}/{exit}' "${DEPLOY_DIR}/bootstrap.sh")"
|
||||
if [[ -z "${body}" ]]; then
|
||||
bad "fungsi generate_runtime_credentials() tidak ditemukan"
|
||||
else
|
||||
ok "fungsi generate_runtime_credentials() ditemukan"
|
||||
|
||||
for target in 'airflow/sling' 'airflow/simple_auth_manager_passwords.json' 'trino/password.db'; do
|
||||
if grep -q 'local .*="\${cfg_base}/'"${target}"'"' <<<"${body}"; then
|
||||
ok "penulisan ${target} memakai \${cfg_base}"
|
||||
else
|
||||
bad "penulisan ${target} TIDAK memakai \${cfg_base} (rawan menimpa config bersama)"
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -q 'HOST_NAME' <<<"${body}" && grep -q 'generated/host-' <<<"${body}"; then
|
||||
ok "cfg_base dialihkan ke bundle host saat HOST_NAME diisi"
|
||||
else
|
||||
bad "cfg_base tidak dialihkan berdasarkan HOST_NAME"
|
||||
fi
|
||||
|
||||
# Tidak boleh ada penulisan langsung ke ${SCRIPT_DIR}/config (selain default cfg_base).
|
||||
if grep -nE '(cat >|: >|>>) *"\$\{SCRIPT_DIR\}/config' <<<"${body}"; then
|
||||
bad "masih ada penulisan langsung ke \${SCRIPT_DIR}/config"
|
||||
else
|
||||
ok "tidak ada penulisan langsung ke \${SCRIPT_DIR}/config"
|
||||
fi
|
||||
fi
|
||||
|
||||
# step_tiers harus memetakan seluruh step 1..9. Bentuk pattern bisa berkelompok
|
||||
# (mis. `1|3)`, `4|5)`, `7|8)`), jadi diambil hanya bagian sebelum ')' lalu dipecah
|
||||
# pada '|' — regex "baris diawali angka" akan salah untuk bentuk berkelompok.
|
||||
patterns="$(awk '/^step_tiers\(\) \{/{f=1} f{print} f&&/^\}/{exit}' "${DEPLOY_DIR}/bootstrap.sh" \
|
||||
| grep -E '\)' | sed 's/).*//' | tr '|' '\n' | tr -d ' ')"
|
||||
missing=""
|
||||
for s in 1 2 3 4 5 6 7 8 9; do
|
||||
grep -qx "${s}" <<<"${patterns}" || missing+=" ${s}"
|
||||
done
|
||||
if [[ -z "${missing}" ]]; then
|
||||
ok "step_tiers() memetakan step 1..9"
|
||||
else
|
||||
bad "step_tiers() belum memetakan step:${missing}"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── B. penjagaan dinamis (butuh stack SiCABE berjalan) ──"
|
||||
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^sicabe-'; then
|
||||
inf "tidak ada container sicabe- yang berjalan — lapis dinamis DILEWATI"
|
||||
echo
|
||||
printf " ${YLW}Ringkasan:${NC} %s PASS · %s FAIL (lapis dinamis dilewati)\n" "${PASS_N}" "${FAIL_N}"
|
||||
[[ ${FAIL_N} -eq 0 ]] && exit 0 || exit 1
|
||||
fi
|
||||
|
||||
GEN_DIR="${DEPLOY_DIR}/generated"
|
||||
created_gen=0
|
||||
if [[ ! -f "${GEN_DIR}/host-state/.env" ]]; then
|
||||
inf "bundle host belum ada — generate dulu (sementara, akan dihapus)"
|
||||
if python3 "${DEPLOY_DIR}/lib/cluster/generate.py" render \
|
||||
"${DEPLOY_DIR}/cluster.example.yml" "${DEPLOY_DIR}/docker-compose.yml" \
|
||||
"${DEPLOY_DIR}/.env" "${GEN_DIR}" "${DEPLOY_DIR}/scripts/render-config.sh" \
|
||||
>/dev/null 2>&1; then
|
||||
created_gen=1
|
||||
else
|
||||
bad "gagal menyiapkan bundle host untuk uji dinamis"
|
||||
GEN_DIR=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${GEN_DIR}" ]]; then
|
||||
declare -A before=()
|
||||
for f in "${SHARED_FILES[@]}"; do
|
||||
[[ -f "${DEPLOY_DIR}/${f}" ]] && before["${f}"]="$(sha256sum "${DEPLOY_DIR}/${f}" | cut -c1-16)"
|
||||
done
|
||||
inf "menjalankan: bootstrap.sh --host state (hanya step 2 — pastikan bucket)"
|
||||
"${DEPLOY_DIR}/bootstrap.sh" --host state >/tmp/test-bootstrap-host.log 2>&1 || true
|
||||
|
||||
touched=0
|
||||
for f in "${SHARED_FILES[@]}"; do
|
||||
[[ -z "${before[${f}]:-}" ]] && continue
|
||||
now="$(sha256sum "${DEPLOY_DIR}/${f}" | cut -c1-16)"
|
||||
if [[ "${before[${f}]}" != "${now}" ]]; then
|
||||
bad "${f} BERUBAH setelah --host state (bug config bersama kembali)"
|
||||
touched=1
|
||||
fi
|
||||
done
|
||||
[[ ${touched} -eq 0 ]] && ok "ketiga berkas config bersama tidak berubah (sha256 identik)"
|
||||
|
||||
# Kredensial host harus mendarat di bundle host.
|
||||
if [[ -f "${GEN_DIR}/host-state/config/airflow/sling/env.yaml" ]]; then
|
||||
ok "kredensial runtime ditulis ke bundle host (generated/host-state/config/...)"
|
||||
else
|
||||
bad "kredensial runtime TIDAK ditemukan di bundle host"
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ ${created_gen} -eq 1 ]] && rm -rf "${GEN_DIR}" && inf "artefak uji generated/ dihapus"
|
||||
rm -f /tmp/test-bootstrap-host.log
|
||||
|
||||
# ── install.sh --host: profil `edge` ikut saat bundle memuat traefik (T-20) ─────
|
||||
# Traefik berada di profile `edge`. Bila run_host hanya memakai `--profile all`,
|
||||
# Traefik tidak pernah start sehingga SELURUH rute publik tidak ada — padahal label
|
||||
# sudah terpasang di setiap layanan dan sertifikat sudah bisa diterbitkan.
|
||||
run_host_body="$(sed -n '/^run_host()/,/^}/p' "${DEPLOY_DIR}/install.sh")"
|
||||
if grep -q 'traefik:' <<<"${run_host_body}" && grep -q 'profile edge' <<<"${run_host_body}"; then
|
||||
ok "run_host menyertakan profile edge bila bundle memuat traefik"
|
||||
else
|
||||
bad "run_host tidak mendeteksi profile edge (Traefik tidak akan pernah start)"
|
||||
fi
|
||||
|
||||
# ── install.sh: nama LOGIS harus diuraikan di EXTERNAL_SERVICES/LOCAL_SERVICES ──
|
||||
# Tanpa ini, `starrocks` (dan dulu hanya `trino` yang istimewa) diam-diam tidak
|
||||
# berefek: filter mencocokkan nama SERVICE (`starrocks-fe`, `starrocks-cn`).
|
||||
if grep -q 'expand_service_names()' "${DEPLOY_DIR}/install.sh" \
|
||||
&& grep -q 'starrocks-fe starrocks-cn' "${DEPLOY_DIR}/install.sh" \
|
||||
&& grep -q 'expand_service_names ${LOCAL_SERVICES' "${DEPLOY_DIR}/install.sh" \
|
||||
&& grep -q 'expand_service_names ${EXTERNAL_SERVICES' "${DEPLOY_DIR}/install.sh"; then
|
||||
ok "nama logis diuraikan di EXTERNAL_SERVICES & LOCAL_SERVICES (starrocks/trino/…)"
|
||||
else
|
||||
bad "pemetaan nama logis hilang — setelan seperti LOCAL_SERVICES=starrocks akan diabaikan"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [[ ${FAIL_N} -eq 0 ]]; then
|
||||
echo -e " ${GRN}${BLD}SEMUA LULUS${NC} (${PASS_N} cek)"
|
||||
exit 0
|
||||
fi
|
||||
echo -e " ${RED}${BLD}GAGAL${NC}: ${FAIL_N} masalah · ${PASS_N} lulus"
|
||||
exit 1
|
||||
Executable
+814
@@ -0,0 +1,814 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# test-generate.sh — verifikasi generator multi-host (Fase 1 R10 §4.5)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Yang dibuktikan (bukan sekadar "berkasnya ada"):
|
||||
# 1. `docker compose config` VALID untuk setiap host hasil generate.
|
||||
# 2. Service yang ikut **identik secara semantik** dengan single-host pada semua
|
||||
# field (image, command, ports, healthcheck, profiles, volumes, ...) — ini yang
|
||||
# menangkap kerusakan akibat round-trip YAML (mis. `"true"` jadi boolean).
|
||||
# 3. Perbedaan `environment` HANYA pada variabel topologi yang memang diarahkan
|
||||
# generator (alamat host) — tidak ada kebocoran lain.
|
||||
# 4. `depends_on` lintas host benar-benar HABIS; intra-host tetap.
|
||||
# 5. Tidak ada variabel topologi yang DUPLIKAT di `.env` (dotenv: yang terakhir
|
||||
# menang → duplikat = nilai inventory bisa hilang diam-diam).
|
||||
#
|
||||
# Jalankan: ./scripts/test-generate.sh [cluster.yml]
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
INVENTORY="${1:-${DEPLOY_DIR}/cluster.example.yml}"
|
||||
OUT="$(mktemp -d)"
|
||||
trap 'rm -rf "${OUT}"' EXIT
|
||||
|
||||
GRN=$'\e[32m'; RED=$'\e[31m'; BLD=$'\e[1m'; NC=$'\e[0m'
|
||||
PASS_N=0; FAIL_N=0
|
||||
ok() { printf " ${GRN}✅${NC} %s\n" "$1"; PASS_N=$((PASS_N + 1)); }
|
||||
bad() { printf " ${RED}❌${NC} %s\n" "$1"; FAIL_N=$((FAIL_N + 1)); }
|
||||
|
||||
echo -e "${BLD}Uji generator multi-host (lib/cluster/generate.py)${NC}"
|
||||
echo " inventory: ${INVENTORY}"
|
||||
echo
|
||||
|
||||
# ─── 0. konsistensi RENDER_HOSTS ⊆ KNOWN_HOSTS ──────────────────────────────
|
||||
# generate.py meng-`--map` SETIAP entri RENDER_HOSTS, sedangkan render-config.sh
|
||||
# hanya menerima hostname di KNOWN_HOSTS. Bila berbeda, `install.sh --generate`
|
||||
# gagal total (bug M2/M3: statsd-exporter/trino-jmx-exporter). Dijaga di sini agar
|
||||
# tidak terulang diam-diam.
|
||||
map_gap="$(python3 - "${DEPLOY_DIR}" <<'PY'
|
||||
import re, sys
|
||||
d = sys.argv[1]
|
||||
gen = open(f"{d}/lib/cluster/generate.py", encoding="utf-8").read()
|
||||
rc = open(f"{d}/scripts/render-config.sh", encoding="utf-8").read()
|
||||
render_hosts = set(re.findall(r'"([^"]+)"', re.search(r"RENDER_HOSTS = \((.*?)\)", gen, re.S).group(1)))
|
||||
# Blok KNOWN_HOSTS berakhir pada baris ")" tersendiri; komentar dibuang sebelum split
|
||||
# (komentar boleh memuat tanda kurung, mis. "(M2/M3)", yang merusak regex sederhana).
|
||||
block = re.search(r"KNOWN_HOSTS=\((.*?)\n\)", rc, re.S).group(1)
|
||||
known = set()
|
||||
for line in block.splitlines():
|
||||
known.update(line.split("#", 1)[0].split())
|
||||
print(" ".join(sorted(render_hosts - known)))
|
||||
PY
|
||||
)"
|
||||
if [[ -z "${map_gap}" ]]; then
|
||||
ok "RENDER_HOSTS ⊆ KNOWN_HOSTS (render-config menerima semua --map)"
|
||||
else
|
||||
bad "RENDER_HOSTS memuat hostname yang TIDAK ada di KNOWN_HOSTS: ${map_gap}"
|
||||
fi
|
||||
|
||||
# ─── 0b. Trino group-provider memakai LDAP_ADMIN_DN (bukan DN literal) ───────
|
||||
# LDAP existing bisa punya bind DN ≠ cn=admin,dc=sicabe,dc=local. Bila
|
||||
# group-provider.properties menulis DN literal, Trino mem-bind dengan DN SALAH →
|
||||
# user terautentikasi tetapi tanpa grup. Pastikan ia memakai ${ENV:LDAP_ADMIN_DN}
|
||||
# DAN compose meneruskan env itu ke trino-coordinator (Trino gagal start bila tak ada).
|
||||
gp="${DEPLOY_DIR}/config/trino/group-provider.properties"
|
||||
if grep -q 'ldap.admin-user=${ENV:LDAP_ADMIN_DN}' "${gp}" 2>/dev/null; then
|
||||
ok "group-provider.properties: ldap.admin-user dari \${ENV:LDAP_ADMIN_DN}"
|
||||
else
|
||||
bad "group-provider.properties: ldap.admin-user BUKAN \${ENV:LDAP_ADMIN_DN} (bind DN existing tak akan jalan)"
|
||||
fi
|
||||
if awk '/^ trino-coordinator:/{f=1;next} f && /^ [a-zA-Z0-9_-]+:/{exit} f' \
|
||||
"${DEPLOY_DIR}/docker-compose.yml" | grep -q 'LDAP_ADMIN_DN:'; then
|
||||
ok "docker-compose: trino-coordinator menerima LDAP_ADMIN_DN"
|
||||
else
|
||||
bad "docker-compose: trino-coordinator TIDAK menerima LDAP_ADMIN_DN (Trino gagal start)"
|
||||
fi
|
||||
|
||||
# ─── 0c. Port internal terparameter (bukan literal) ──────────────────────────
|
||||
# Port container harus bisa di-override via .env (mis. layanan existon berport
|
||||
# non-standar) tanpa menyunting docker-compose.yml.
|
||||
dc="${DEPLOY_DIR}/docker-compose.yml"
|
||||
miss=""
|
||||
grep -q '${POSTGRES_INTERNAL_PORT:-5432}' "${dc}" || miss="${miss} POSTGRES"
|
||||
grep -q '${KEYCLOAK_INTERNAL_PORT:-8080}' "${dc}" || miss="${miss} KEYCLOAK"
|
||||
grep -q '${MINIO_INTERNAL_PORT:-9000}' "${dc}" || miss="${miss} MINIO"
|
||||
[[ -z "${miss}" ]] && ok "docker-compose: port internal terparameter (POSTGRES/KEYCLOAK/MINIO)" \
|
||||
|| bad "docker-compose: port internal BUKAN parameter:${miss}"
|
||||
hard="$(grep -nE '\$\{(POSTGRES_HOST|KEYCLOAK_HOST|MINIO_HOST)[^}]*\}:(5432|8080|9000)' "${dc}" || true)"
|
||||
[[ -z "${hard}" ]] && ok "docker-compose: tak ada port internal literal di var *_HOST" \
|
||||
|| bad "docker-compose: masih ada port literal: ${hard}"
|
||||
grep -q '${ENV:KEYCLOAK_INTERNAL_PORT}' "${DEPLOY_DIR}/config/trino/config-coordinator.properties" \
|
||||
&& ok "Trino config: token/jwks-url pakai \${ENV:KEYCLOAK_INTERNAL_PORT}" \
|
||||
|| bad "Trino config: port Keycloak masih literal 8080"
|
||||
grep -q ':{port}' "${DEPLOY_DIR}/lib/cluster/generate.py" \
|
||||
&& ok "generate.py: URL_ENDPOINT_VARS memakai {port} (dari .env)" \
|
||||
|| bad "generate.py: URL_ENDPOINT_VARS masih port literal"
|
||||
|
||||
# ─── 0d. Image ref terparameter (bisa diarahkan ke mirror/registry internal) ─
|
||||
# Repositori asal bisa mengubah kebijakan (mis. MinIO community butuh auth →
|
||||
# pull anonim GAGAL). Var *_IMAGE (full ref) harus ada agar bisa di-mirror tanpa
|
||||
# menyunting docker-compose.yml.
|
||||
for pair in "MINIO_IMAGE:quay.io/minio/minio" "MINIO_MC_IMAGE:quay.io/minio/mc" "NESSIE_IMAGE:ghcr.io/projectnessie/nessie"; do
|
||||
var="${pair%%:*}"; repo="${pair#*:}"
|
||||
if grep -q "\${${var}:-${repo}" "${DEPLOY_DIR}/docker-compose.yml"; then
|
||||
ok "docker-compose: image ${repo} lewat \${${var}}"
|
||||
else
|
||||
bad "docker-compose: image ${repo} masih hardcoded (tidak bisa di-mirror)"
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── 1. generate ────────────────────────────────────────────────────────────
|
||||
if ! python3 "${DEPLOY_DIR}/lib/cluster/generate.py" render \
|
||||
"${INVENTORY}" "${DEPLOY_DIR}/docker-compose.yml" "${DEPLOY_DIR}/.env" \
|
||||
"${OUT}" "${DEPLOY_DIR}/scripts/render-config.sh" > "${OUT}/generate.log" 2>&1; then
|
||||
bad "generate gagal:"; sed 's/^/ /' "${OUT}/generate.log" | head -10; exit 1
|
||||
fi
|
||||
ok "generate berhasil ($(grep -c '^host-' "${OUT}/generate.log") host)"
|
||||
HOSTS="$(grep '^host-' "${OUT}/generate.log" | awk '{print $1}' | sed 's/^host-//')"
|
||||
|
||||
# ─── 1b. target node_exporter (job `node`, file_sd) di bundle Prometheus ─────
|
||||
# node_exporter = PRA-SYARAT HOST (di luar installer), jadi generate.py menulis
|
||||
# daftar target SELURUH host dari cluster.yml ke bundle host pemilik Prometheus.
|
||||
nodeyml="${OUT}/host-control/config/prometheus/targets/node.yml"
|
||||
if [[ -f "${nodeyml}" ]]; then
|
||||
nsum="$(python3 - "${nodeyml}" <<'PY'
|
||||
import sys, yaml
|
||||
data = yaml.safe_load(open(sys.argv[1], encoding="utf-8")) or []
|
||||
hosts = sorted((e.get("labels") or {}).get("host", "") for e in data)
|
||||
addrs = sorted(t for e in data for t in (e.get("targets") or []))
|
||||
print(f"{len(hosts)}|{','.join(hosts)}|{','.join(addrs)}")
|
||||
PY
|
||||
)"
|
||||
ncount="${nsum%%|*}"; rest="${nsum#*|}"; nhosts="${rest%%|*}"; naddrs="${rest##*|}"
|
||||
exp_hosts="$(echo ${HOSTS} | tr ' ' '\n' | sort | paste -sd, -)"
|
||||
nhost_total="$(echo ${HOSTS} | wc -w | tr -d ' ')"
|
||||
if [[ "${ncount}" == "${nhost_total}" && "${nhosts}" == "${exp_hosts}" ]]; then
|
||||
ok "node targets: ${ncount} host digenerate di bundle Prometheus (${nhosts})"
|
||||
else
|
||||
bad "node targets: ${ncount} host (harus ${nhost_total}); host=${nhosts} (harus ${exp_hosts})"
|
||||
fi
|
||||
if echo "${naddrs}" | tr ',' '\n' | grep -qx '10.20.0.12:9100'; then
|
||||
ok 'node targets: format "<alamat>:9100" per host (contoh 10.20.0.12:9100)'
|
||||
else
|
||||
bad "node targets: format alamat:9100 salah (${naddrs})"
|
||||
fi
|
||||
else
|
||||
bad "node targets: ${nodeyml} tidak digenerate"
|
||||
fi
|
||||
|
||||
# ─── 2. compose valid per host + berkas runtime bind-mount berupa FILE ──────
|
||||
for h in ${HOSTS}; do
|
||||
d="${OUT}/host-${h}"
|
||||
if docker compose -f "${d}/docker-compose.yml" --env-file "${d}/.env" --profile all config -q 2>"${OUT}/err-${h}"; then
|
||||
ok "host-${h}: docker compose config valid"
|
||||
else
|
||||
bad "host-${h}: compose TIDAK valid"; head -3 "${OUT}/err-${h}" | sed 's/^/ /'
|
||||
fi
|
||||
# WAJIB FILE (bukan direktori): Docker membuat DIRECTORY bila sumber bind-mount
|
||||
# hilang → Airflow SimpleAuthManager IsADirectoryError / Trino gagal auth.
|
||||
for rf in config/airflow/simple_auth_manager_passwords.json config/trino/password.db; do
|
||||
[[ -f "${d}/${rf}" ]] && ok "host-${h}: ${rf} berupa file" \
|
||||
|| bad "host-${h}: ${rf} BUKAN file (bind-mount akan jadi DIRECTORY)"
|
||||
done
|
||||
done
|
||||
|
||||
# ─── 3. .env: tidak ada variabel topologi ganda ─────────────────────────────
|
||||
TOPO='EXTERNAL_SERVICES|LOCAL_SERVICES|POSTGRES_HOST|MINIO_HOST|KEYCLOAK_HOST|OPENMETADATA_HOST|AIRFLOW_WEBSERVER_HOST|STARROCKS_FE_HOST|STARROCKS_CN_HOST|OPENSEARCH_HOST|S3_ENDPOINT|NESSIE_URL|LDAP_URL|OPA_URL'
|
||||
for h in ${HOSTS}; do
|
||||
dup="$(grep -E "^(${TOPO})=" "${OUT}/host-${h}/.env" | cut -d= -f1 | sort | uniq -d | paste -sd' ' -)"
|
||||
[[ -z "${dup}" ]] && ok "host-${h}: .env tanpa variabel topologi ganda" \
|
||||
|| bad "host-${h}: .env punya variabel ganda: ${dup}"
|
||||
done
|
||||
|
||||
# ─── 4 & 5. perbandingan semantik dengan render single-host ─────────────────
|
||||
docker compose -f "${DEPLOY_DIR}/docker-compose.yml" --env-file "${DEPLOY_DIR}/.env" \
|
||||
--profile all config --format json > "${OUT}/base.json" 2>/dev/null
|
||||
if [[ ! -s "${OUT}/base.json" ]]; then
|
||||
bad "tidak bisa render single-host (base) sebagai pembanding"; exit 1
|
||||
fi
|
||||
|
||||
for h in ${HOSTS}; do
|
||||
d="${OUT}/host-${h}"
|
||||
docker compose -f "${d}/docker-compose.yml" --env-file "${d}/.env" \
|
||||
--profile all config --format json > "${OUT}/host-${h}.json" 2>/dev/null
|
||||
done
|
||||
|
||||
PLAN_JSON="$(python3 "${DEPLOY_DIR}/lib/cluster/inventory.py" plan "${INVENTORY}")"
|
||||
COMP_JSON="$(python3 "${DEPLOY_DIR}/lib/cluster/inventory.py" components "${INVENTORY}" | python3 -c "
|
||||
import sys, json
|
||||
d = {}
|
||||
for line in sys.stdin:
|
||||
name, _, hosts = line.strip().partition(' -> ')
|
||||
name = name.strip() # keluaran CLI mem-pad nama (kolom 24)
|
||||
if name: d[name] = hosts.strip().split(',')
|
||||
print(json.dumps(d))")"
|
||||
|
||||
# ─── 3b. target Prometheus lintas-host → portnya HARUS diterbitkan ───────────
|
||||
# Regresi nyata: prometheus.yml memakai `trino-jmx-exporter:9404` (→ IP host coord
|
||||
# saat generate) tetapi 9404 TIDAK diterbitkan → job `trino` DOWN di multi-host.
|
||||
# Guard: tiap target `static_configs` yang menunjuk ALAMAT HOST harus punya binding
|
||||
# `<addr>:<port>:<port>` di bundle host pemilik. Target berupa NAMA container
|
||||
# (mis. `statsd-exporter:9102`) harus SE-HOST dengan Prometheus (host-control).
|
||||
pym="${OUT}/host-control/config/prometheus/prometheus.yml"
|
||||
if [[ -f "${pym}" ]]; then
|
||||
prom_issues="$(python3 - "${OUT}" "${PLAN_JSON}" <<'PY'
|
||||
import json, sys, yaml
|
||||
from pathlib import Path
|
||||
out = Path(sys.argv[1]); plan = json.loads(sys.argv[2])
|
||||
addr2host = {v.get("address"): h for h, v in plan.items()}
|
||||
doc = yaml.safe_load((out / "host-control" / "config" / "prometheus" / "prometheus.yml").read_text()) or {}
|
||||
|
||||
def ports_of(hostdir):
|
||||
comp = yaml.safe_load((out / hostdir / "docker-compose.yml").read_text()) or {}
|
||||
found = set()
|
||||
for svc in (comp.get("services") or {}).values():
|
||||
for e in svc.get("ports") or []:
|
||||
if isinstance(e, dict):
|
||||
found.add(f"{e.get('host_ip','')}:{e.get('published')}:{e.get('target')}")
|
||||
else:
|
||||
found.add(str(e))
|
||||
return found
|
||||
|
||||
issues = []
|
||||
for job in doc.get("scrape_configs", []):
|
||||
if "file_sd_configs" in job:
|
||||
continue # node_exporter = proses di HOST (:9100), bukan container
|
||||
for sc in job.get("static_configs", []) or []:
|
||||
for t in sc.get("targets", []) or []:
|
||||
if ":" not in t:
|
||||
continue
|
||||
a, p = t.rsplit(":", 1)
|
||||
if a in ("localhost", "127.0.0.1"):
|
||||
continue
|
||||
if a in addr2host:
|
||||
owner = addr2host[a]
|
||||
if f"{a}:{p}:{p}" not in ports_of(f"host-{owner}"):
|
||||
issues.append(f"job {job.get('job_name')}: {t} lintas-host tetapi {a}:{p} TIDAK diterbitkan di host-{owner}")
|
||||
else:
|
||||
ctrl = yaml.safe_load((out / "host-control" / "docker-compose.yml").read_text()) or {}
|
||||
if a not in (ctrl.get("services") or {}):
|
||||
issues.append(f"job {job.get('job_name')}: {t} bukan alamat host & bukan container di host-control")
|
||||
print("\n".join(issues))
|
||||
PY
|
||||
)"
|
||||
if [[ -z "${prom_issues}" ]]; then
|
||||
ok "prometheus.yml: target lintas-host menunjuk port yang diterbitkan"
|
||||
else
|
||||
bad "prometheus.yml target bermasalah:"
|
||||
echo "${prom_issues}" | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
bad "prometheus.yml tidak ada di bundle control: ${pym}"
|
||||
fi
|
||||
|
||||
# ─── 3c. config TIDAK boleh menunjuk ALAMAT HOST SENDIRI pada port tak-terbit ─
|
||||
# Kelas bug: komponen SE-HOST ditulis ke alamat host (mis. grafana→prometheus jadi
|
||||
# `http://<ip>:9090`) padahal port container itu TIDAK diterbitkan di host → connection
|
||||
# refused. Komponen se-host seharusnya tetap memakai NAMA container.
|
||||
coloc_issues="$(python3 - "${OUT}" "${PLAN_JSON}" <<'PY'
|
||||
import json, re, sys, yaml
|
||||
from pathlib import Path
|
||||
out = Path(sys.argv[1]); plan = json.loads(sys.argv[2])
|
||||
issues = []
|
||||
for host, spec in plan.items():
|
||||
addr = spec.get("address")
|
||||
hd = out / f"host-{host}"
|
||||
compf = hd / "docker-compose.yml"
|
||||
if not addr or not compf.is_file():
|
||||
continue
|
||||
comp = yaml.safe_load(compf.read_text()) or {}
|
||||
published = set()
|
||||
for svc in (comp.get("services") or {}).values():
|
||||
for e in svc.get("ports") or []:
|
||||
if isinstance(e, dict):
|
||||
published.add(str(e.get("published")))
|
||||
elif isinstance(e, str):
|
||||
parts = e.split(":")
|
||||
if len(parts) == 2:
|
||||
published.add(parts[0])
|
||||
elif len(parts) == 3:
|
||||
published.add(parts[1])
|
||||
cfg = hd / "config"
|
||||
if not cfg.is_dir():
|
||||
continue
|
||||
for f in cfg.rglob("*"):
|
||||
if not f.is_file():
|
||||
continue
|
||||
try:
|
||||
text = f.read_text(encoding="utf-8", errors="ignore")
|
||||
except OSError:
|
||||
continue
|
||||
# 9100 = node_exporter: proses di HOST (pra-syarat), BUKAN container → wajar tak
|
||||
# ada di `ports:` compose. Port host-level lain bisa ditambah ke sini.
|
||||
host_level_ports = {"9100"}
|
||||
for m in re.finditer(re.escape(addr) + r":(\d+)", text):
|
||||
port = m.group(1)
|
||||
if port in host_level_ports:
|
||||
continue
|
||||
if port not in published:
|
||||
issues.append(f"host-{host}: {f.relative_to(hd)} menunjuk alamat HOST SENDIRI {addr}:{port} (port tak diterbitkan)")
|
||||
print("\n".join(sorted(set(issues))))
|
||||
PY
|
||||
)"
|
||||
if [[ -z "${coloc_issues}" ]]; then
|
||||
ok "config: tidak ada rujukan ke alamat host sendiri pada port tak-terbit"
|
||||
else
|
||||
bad "config menunjuk alamat host sendiri pada port tak-terbit:"
|
||||
echo "${coloc_issues}" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
sem_out="$(python3 - "${OUT}" "${HOSTS}" "${DEPLOY_DIR}" "${PLAN_JSON}" "${COMP_JSON}" <<'PY'
|
||||
import json, re, sys
|
||||
from pathlib import Path
|
||||
|
||||
out = Path(sys.argv[1]); hosts = sys.argv[2].split(); deploy = Path(sys.argv[3])
|
||||
plan = json.loads(sys.argv[4]); comps = json.loads(sys.argv[5])
|
||||
base = json.loads((out / "base.json").read_text())["services"]
|
||||
|
||||
COMPARE = ("image", "entrypoint", "user", "restart", "profiles",
|
||||
"deploy", "networks", "ulimits", "cap_add")
|
||||
|
||||
def split_ports(ports, addr):
|
||||
"""Pisahkan binding port internal multi-host (host_ip == alamat host) dari dasar.
|
||||
|
||||
Fase 2 menambahkan `alamat:cport:cport` HANYA di artefak multi-host (port container
|
||||
yang harus terjangkau lintas host, mis. postgres 5432). Binding itu tidak ada di
|
||||
compose dasar, jadi harus dipisahkan sebelum membandingkan — sekaligus diperiksa
|
||||
bentuknya (host port == container port, dikunci ke alamat host).
|
||||
"""
|
||||
base, internal = [], []
|
||||
for p in ports or []:
|
||||
if isinstance(p, dict):
|
||||
if p.get("host_ip") == addr:
|
||||
internal.append(p)
|
||||
else:
|
||||
base.append(p)
|
||||
elif isinstance(p, str) and p.startswith(addr + ":"):
|
||||
internal.append(p)
|
||||
else:
|
||||
base.append(p)
|
||||
return base, internal
|
||||
|
||||
def addr_of(host: str) -> str:
|
||||
return plan.get(host, {}).get("address", "")
|
||||
|
||||
def name_addr(host: str) -> dict:
|
||||
"""hostname container → alamat, dengan aturan yang sama seperti generate.py."""
|
||||
out_map = {}
|
||||
for name, owners in comps.items():
|
||||
if not owners:
|
||||
continue
|
||||
owner = host if host in owners else owners[0]
|
||||
if owner in plan: # `plan` di-key oleh NAMA HOST
|
||||
out_map[name] = plan[owner]["address"]
|
||||
return out_map
|
||||
|
||||
def normalize(value, mapping: dict):
|
||||
"""Samakan nama container DAN alamat host menjadi satu placeholder.
|
||||
|
||||
Yang diuji: satu-satunya perbedaan nilai dasar vs per-host adalah **identitas
|
||||
host** yang dipakai (nama container ↔ alamat host). Pendekatan "rekonstruksi
|
||||
nilai yang diharapkan" dicoba lebih dulu dan rapuh — gagal pada bentuk
|
||||
berkutip (`"starrocks-cn"`) dan salah menandai nilai `postgres` yang memang
|
||||
dialek DB, bukan endpoint. Normalisasi dua arah menghindari keduanya.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
s = value
|
||||
for _name, addr in mapping.items():
|
||||
s = s.replace(addr, "<HOST>")
|
||||
for name in mapping:
|
||||
s = re.sub(r"(?<![\w.-])" + re.escape(name) + r"(?![\w-])", "<HOST>", s)
|
||||
return s
|
||||
if isinstance(value, list):
|
||||
return [normalize(v, mapping) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: normalize(v, mapping) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
# Kunci yang nilainya URL BROWSER-FACING. Nilainya memang berbeda antara single-host
|
||||
# (localhost) dan multi-host (alamat host yang menjalankan komponen) — itu DISENGAJA,
|
||||
# justru itu inti perbaikannya. Untuk kunci ini `localhost` diperlakukan sama seperti
|
||||
# alamat host, sehingga yang masih terdeteksi sebagai masalah hanyalah perbedaan
|
||||
# sebenarnya (skema, port, atau path berubah).
|
||||
PUBLIC_URL_KEYS = (
|
||||
"KC_HOSTNAME", "KC_HOSTNAME_URL",
|
||||
"GF_SERVER_ROOT_URL", "AIRFLOW__WEBSERVER__BASE_URL",
|
||||
"KEYCLOAK_PUBLIC_URL", "GRAFANA_PUBLIC_URL", "OPENMETADATA_PUBLIC_URL",
|
||||
"AIRFLOW_PUBLIC_URL", "TRINO_PUBLIC_URL", "MINIO_CONSOLE_PUBLIC_URL",
|
||||
# Konsumen browser-facing turunan URL publik (T-21). Disertakan supaya beda
|
||||
# localhost (single-host) vs alamat host (multi-host) tidak dianggap regresi,
|
||||
# sementara beda port/skema tetap tertangkap.
|
||||
"OPENMETADATA_SERVER_URL", "MINIO_BROWSER_REDIRECT_URL", "MINIO_SERVER_URL",
|
||||
"OIDC_SERVER_URL", "OIDC_CALLBACK", "AUTHENTICATION_CALLBACK_URL",
|
||||
)
|
||||
|
||||
def normalize_env_key(key: str, value, mapping: dict):
|
||||
s = normalize(value, mapping)
|
||||
if isinstance(s, str) and key in PUBLIC_URL_KEYS:
|
||||
s = re.sub(r"(?<![\w.-])localhost(?![\w-])", "<HOST>", s)
|
||||
return s
|
||||
|
||||
def norm_volumes(vols, root: Path):
|
||||
result = []
|
||||
for v in vols or []:
|
||||
item = dict(v)
|
||||
src = item.get("source", "") or ""
|
||||
prefix = str(root) + "/"
|
||||
if src.startswith(prefix): src = src[len(prefix):]
|
||||
item["source"] = src
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
issues, checked, changed_env, internal_count = [], 0, 0, 0
|
||||
warn_skip = 0
|
||||
for h in hosts:
|
||||
host_root = out / f"host-{h}"
|
||||
doc = json.loads((out / f"host-{h}.json").read_text())
|
||||
svcs = doc["services"]; names = set(svcs); mapping = name_addr(h)
|
||||
for name, spec in svcs.items():
|
||||
ref = base.get(name)
|
||||
if ref is None:
|
||||
issues.append(f"host-{h}: service '{name}' TIDAK ada di compose dasar"); continue
|
||||
for key in COMPARE:
|
||||
if spec.get(key) != ref.get(key):
|
||||
issues.append(f"host-{h}/{name}: field '{key}' berbeda dari single-host")
|
||||
# ports: binding internal multi-host dipisahkan dulu, lalu bentuknya diperiksa
|
||||
addr = addr_of(h)
|
||||
hb, hi = split_ports(spec.get("ports"), addr)
|
||||
rb, ri = split_ports(ref.get("ports"), addr)
|
||||
if hb != rb:
|
||||
issues.append(f"host-{h}/{name}: ports dasar berbeda dari single-host")
|
||||
if ri:
|
||||
issues.append(f"host-{h}/{name}: compose DASAR sudah punya binding internal? {ri}")
|
||||
for entry in hi:
|
||||
if isinstance(entry, str):
|
||||
parts = entry.split(":")
|
||||
ok = len(parts) == 3 and parts[0] == addr and parts[1] == parts[2]
|
||||
if not ok:
|
||||
issues.append(f"host-{h}/{name}: binding internal salah bentuk: {entry}")
|
||||
elif str(entry.get("target")) != str(entry.get("published")) or entry.get("host_ip") != addr:
|
||||
issues.append(f"host-{h}/{name}: binding internal salah bentuk: {entry}")
|
||||
internal_count += len(hi)
|
||||
if norm_volumes(spec.get("volumes"), host_root) != norm_volumes(ref.get("volumes"), deploy):
|
||||
issues.append(f"host-{h}/{name}: 'volumes' berbeda (di luar prefix direktori)")
|
||||
for key in ("command", "healthcheck", "entrypoint"):
|
||||
if normalize(spec.get(key), mapping) != normalize(ref.get(key), mapping):
|
||||
issues.append(f"host-{h}/{name}: '{key}' berbeda di luar identitas host")
|
||||
dd = spec.get("depends_on", {}) or {}; db = ref.get("depends_on", {}) or {}
|
||||
if set(dd) - set(db):
|
||||
issues.append(f"host-{h}/{name}: depends_on menambah {sorted(set(dd)-set(db))}")
|
||||
if set(dd) - names:
|
||||
issues.append(f"host-{h}/{name}: depends_on masih lintas host {sorted(set(dd)-names)}")
|
||||
ea = spec.get("environment", {}) or {}; eb = ref.get("environment", {}) or {}
|
||||
if set(ea) != set(eb):
|
||||
issues.append(f"host-{h}/{name}: kunci environment berbeda "
|
||||
f"(hilang={sorted(set(eb)-set(ea))}, tambahan={sorted(set(ea)-set(eb))})")
|
||||
for k in set(ea) & set(eb):
|
||||
if normalize_env_key(k, ea[k], mapping) != normalize_env_key(k, eb[k], mapping):
|
||||
issues.append(f"host-{h}/{name}: env '{k}' berbeda di luar identitas host\n"
|
||||
f" dasar={eb[k]!r}\n host ={ea[k]!r}")
|
||||
elif eb[k] != ea[k]:
|
||||
changed_env += 1
|
||||
checked += 1
|
||||
print(f"checked={checked}")
|
||||
print(f"env_diarahkan={changed_env}")
|
||||
print(f"binding_internal={internal_count}")
|
||||
for i in issues:
|
||||
print(f"issue={i}")
|
||||
PY
|
||||
)"
|
||||
checked="$(echo "${sem_out}" | sed -n 's/^checked=//p')"
|
||||
internal="$(echo "${sem_out}" | sed -n 's/^binding_internal=//p')"
|
||||
issues="$(echo "${sem_out}" | grep -c '^issue=' || true)"
|
||||
if [[ "${issues}" -eq 0 ]]; then
|
||||
ok "semantik: ${checked} service identik dengan single-host (kecuali variabel topologi)"
|
||||
ok "port internal multi-host: ${internal} binding (dikunci ke alamat host, host port == container port)"
|
||||
else
|
||||
bad "semantik: ${issues} masalah"
|
||||
echo "${sem_out}" | grep '^issue=' | sed 's/^issue=/ /' | head -12
|
||||
fi
|
||||
|
||||
# ─── 6. URL browser-facing menurut tls.mode ────────────────────────────────
|
||||
# Mode `none` (contoh) tidak boleh menghasilkan URL publik sama sekali; mode `edge`
|
||||
# harus menghasilkan https://<label>.<domain> untuk enam UI, TANPA membocorkan nama
|
||||
# domain ke variabel internal (container tetap memakai alamat).
|
||||
echo
|
||||
echo "── URL browser-facing (tls.mode) ──"
|
||||
# Jebakan bash: `grep -c` mencetak "0" SEKALIGUS keluar status 1, sehingga
|
||||
# `|| echo 0` menambah baris kedua ("0\n0") dan [[ ]] jadi error sintaks.
|
||||
# Pakai `|| true` lalu default-nya.
|
||||
none_urls="$(grep -c '_PUBLIC_URL=' "${OUT}/host-control/.env" 2>/dev/null || true)"
|
||||
none_urls="${none_urls:-0}"
|
||||
if [[ "${none_urls}" -eq 6 ]]; then
|
||||
ok "mode none: 6 URL publik di .env host"
|
||||
else
|
||||
bad "mode none: ${none_urls} URL publik (harus 6)"
|
||||
fi
|
||||
|
||||
# Mode none = tanpa TLS/proxy → browser memakai alamat HOST, jadi URL publik:
|
||||
# (a) harus menunjuk alamat host, BUKAN localhost atau nama service internal
|
||||
# (dulu defaultnya localhost / airflow-webserver:8080 → browser host lain
|
||||
# dibuang ke localhost, dan issuer OIDC ikut salah);
|
||||
# (b) harus SAMA di setiap bundle — issuer OIDC hanya boleh satu nilai.
|
||||
none_bad=""
|
||||
while IFS= read -r line; do
|
||||
[[ -z "${line}" ]] && continue
|
||||
val="${line#*=}"
|
||||
[[ "${val}" =~ ^https?://([0-9]{1,3}\.){3}[0-9]{1,3}:[0-9]+$ ]] || none_bad+=" ${line}"
|
||||
done < <(grep '_PUBLIC_URL=' "${OUT}/host-control/.env" 2>/dev/null || true)
|
||||
if [[ -z "${none_bad}" ]]; then
|
||||
ok "mode none: URL publik memakai alamat host (bukan localhost/nama internal)"
|
||||
else
|
||||
bad "mode none: URL publik bukan alamat host:${none_bad}"
|
||||
fi
|
||||
|
||||
for f in "${OUT}"/host-*/.env; do
|
||||
grep '_PUBLIC_URL=' "${f}" 2>/dev/null | sort > "${f}.urls" || true
|
||||
done
|
||||
if diff -q "${OUT}/host-control/.env.urls" "${OUT}/host-state/.env.urls" >/dev/null 2>&1 \
|
||||
&& diff -q "${OUT}/host-control/.env.urls" "${OUT}/host-compute/.env.urls" >/dev/null 2>&1; then
|
||||
ok "mode none: URL publik identik di semua bundle (issuer satu nilai)"
|
||||
else
|
||||
bad "mode none: URL publik BEDA antar bundle"
|
||||
fi
|
||||
|
||||
# KC_HOSTNAME harus berisi URL publik itu — opsi inilah yang menentukan issuer.
|
||||
kc_pub="$(grep '^KEYCLOAK_PUBLIC_URL=' "${OUT}/host-control/.env" 2>/dev/null | cut -d= -f2- || true)"
|
||||
kc_resolved="$(docker compose -f "${OUT}/host-control/docker-compose.yml" \
|
||||
--env-file "${OUT}/host-control/.env" --profile all config 2>/dev/null \
|
||||
| python3 -c "
|
||||
import sys, yaml
|
||||
d = yaml.safe_load(sys.stdin) or {}
|
||||
print(((d.get('services') or {}).get('keycloak') or {}).get('environment', {}).get('KC_HOSTNAME', ''))" 2>/dev/null || true)"
|
||||
if [[ -n "${kc_pub}" && "${kc_resolved}" == "${kc_pub}" ]]; then
|
||||
ok "mode none: KC_HOSTNAME = URL publik Keycloak (${kc_pub})"
|
||||
else
|
||||
bad "mode none: KC_HOSTNAME='${kc_resolved}' != KEYCLOAK_PUBLIC_URL='${kc_pub}'"
|
||||
fi
|
||||
# T-21 di mode none: console MinIO memakai URL host pemilik (state), bukan localhost.
|
||||
rmc="$(docker compose -f "${OUT}/host-state/docker-compose.yml" --env-file "${OUT}/host-state/.env" \
|
||||
--profile all config 2>/dev/null | python3 -c "
|
||||
import sys, yaml
|
||||
d = yaml.safe_load(sys.stdin) or {}
|
||||
e = ((d.get('services') or {}).get('minio') or {}).get('environment', {}) or {}
|
||||
print(e.get('MINIO_BROWSER_REDIRECT_URL', ''))" 2>/dev/null || true)"
|
||||
if [[ "${rmc}" == "http://10.20.0.12:12901" ]]; then
|
||||
ok "mode none: MINIO_BROWSER_REDIRECT_URL = alamat host state"
|
||||
else
|
||||
bad "mode none: MINIO_BROWSER_REDIRECT_URL='${rmc}' (harus http://10.20.0.12:12901)"
|
||||
fi
|
||||
# T-20: tanpa TLS/ingress, service traefik TIDAK ikut (single-host tetap utuh)
|
||||
if grep -q '^ traefik:' "${OUT}/host-control/docker-compose.yml" 2>/dev/null; then
|
||||
bad "mode none: service traefik ikut padahal tidak ada ingress"
|
||||
else
|
||||
ok "mode none: service traefik tidak ikut (single-host tidak berubah)"
|
||||
fi
|
||||
if grep -qx 'TRINO_PROCESS_FORWARDED=false' "${OUT}/host-control/.env" 2>/dev/null; then
|
||||
ok "mode none: TRINO_PROCESS_FORWARDED=false (menolak header X-Forwarded-* dari klien)"
|
||||
else
|
||||
bad "mode none: TRINO_PROCESS_FORWARDED bukan false"
|
||||
fi
|
||||
# sslRequired: mode none = HTTP polos -> realm Keycloak tidak boleh menuntut HTTPS.
|
||||
# Alamat Tailscale 100.64.0.0/10 dianggap NON-lokal oleh Keycloak, sehingga default
|
||||
# `external` membuat admin console gagal dengan "HTTPS required".
|
||||
if grep -qx 'KEYCLOAK_SSL_REQUIRED=none' "${OUT}/host-control/.env" 2>/dev/null; then
|
||||
ok "mode none: KEYCLOAK_SSL_REQUIRED=none (bukan external)"
|
||||
else
|
||||
bad "mode none: KEYCLOAK_SSL_REQUIRED bukan none"
|
||||
fi
|
||||
if grep -q 'KC_HOSTNAME_URL' "${OUT}/host-control/docker-compose.yml" 2>/dev/null; then
|
||||
bad "compose masih memuat KC_HOSTNAME_URL (opsi dihapus di Keycloak 26, tidak berefek)"
|
||||
else
|
||||
ok "compose tidak memuat KC_HOSTNAME_URL (benar: dihapus di KC 26)"
|
||||
fi
|
||||
|
||||
EDGE_INV="${OUT}/edge.yml"
|
||||
cat > "${EDGE_INV}" <<'YAML'
|
||||
version: 1
|
||||
cluster:
|
||||
name: sicabe-prod
|
||||
env: production
|
||||
domain: sicabe.com
|
||||
preset: a3
|
||||
tls:
|
||||
mode: edge
|
||||
hosts:
|
||||
control: { address: 10.20.0.10, tiers: [identity, policy, pipeline, governance] }
|
||||
compute: { address: 10.20.0.11, tiers: [coord, engine] }
|
||||
state: { address: 10.20.0.12, tiers: [state] }
|
||||
components:
|
||||
dremio: { enabled: false }
|
||||
YAML
|
||||
EDGE_OUT="${OUT}/edge"
|
||||
if python3 "${DEPLOY_DIR}/lib/cluster/generate.py" render "${EDGE_INV}" \
|
||||
"${DEPLOY_DIR}/docker-compose.yml" "${DEPLOY_DIR}/.env" "${EDGE_OUT}" \
|
||||
"${DEPLOY_DIR}/scripts/render-config.sh" >/dev/null 2>&1; then
|
||||
edge_env="${EDGE_OUT}/host-control/.env"
|
||||
edge_n="$(grep -c '_PUBLIC_URL=' "${edge_env}" 2>/dev/null || true)"
|
||||
edge_n="${edge_n:-0}"
|
||||
[[ "${edge_n}" -eq 6 ]] && ok "mode edge: 6 URL publik di .env" \
|
||||
|| bad "mode edge: ${edge_n} URL publik (harus 6)"
|
||||
for pair in "KEYCLOAK_PUBLIC_URL=https://kc.sicabe.com" \
|
||||
"GRAFANA_PUBLIC_URL=https://grafana.sicabe.com" \
|
||||
"OPENMETADATA_PUBLIC_URL=https://om.sicabe.com" \
|
||||
"AIRFLOW_PUBLIC_URL=https://airflow.sicabe.com" \
|
||||
"TRINO_PUBLIC_URL=https://trino.sicabe.com" \
|
||||
"MINIO_CONSOLE_PUBLIC_URL=https://minio.sicabe.com"; do
|
||||
grep -qx "${pair}" "${edge_env}" && ok "mode edge: ${pair%%=*}" \
|
||||
|| bad "mode edge: ${pair} tidak ada"
|
||||
done
|
||||
# ── T-20 (ingress/TLS): service Traefik + rute + kebijakan proxy ────────────
|
||||
svc_labels() { # <host> <service>
|
||||
docker compose -f "${EDGE_OUT}/host-$1/docker-compose.yml" --env-file "${EDGE_OUT}/host-$1/.env" \
|
||||
--profile all --profile edge config 2>/dev/null | python3 -c "
|
||||
import sys, yaml
|
||||
d = yaml.safe_load(sys.stdin) or {}
|
||||
lab = (((d.get('services') or {}).get('$2') or {}).get('labels')) or []
|
||||
if isinstance(lab, dict):
|
||||
for k, v in lab.items():
|
||||
print(k + '=' + str(v))
|
||||
else:
|
||||
for x in lab:
|
||||
print(x)" 2>/dev/null || true
|
||||
}
|
||||
host_port_of() { # <host> <service> <container_port>
|
||||
docker compose -f "${EDGE_OUT}/host-$1/docker-compose.yml" --env-file "${EDGE_OUT}/host-$1/.env" \
|
||||
--profile all --profile edge config 2>/dev/null | python3 -c "
|
||||
import sys, yaml
|
||||
d = yaml.safe_load(sys.stdin) or {}
|
||||
for e in (((d.get('services') or {}).get('$2') or {}).get('ports') or []):
|
||||
if isinstance(e, dict) and str(e.get('target')) == '$3':
|
||||
print(e.get('host_ip')); break" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Ingress SATU host (default: pemilik keycloak = control). Ini yang menghilangkan
|
||||
# perebutan record TXT DNS-01 (dulu tiap host menerbitkan wildcard yang sama) dan
|
||||
# menekan pemakaian batas sertifikat Let's Encrypt.
|
||||
grep -q '^ traefik:' "${EDGE_OUT}/host-control/docker-compose.yml" 2>/dev/null \
|
||||
&& ok "mode edge: service traefik ada di host ingress (control)" \
|
||||
|| bad "mode edge: service traefik TIDAK ada di host ingress"
|
||||
for h in compute state; do
|
||||
grep -q '^ traefik:' "${EDGE_OUT}/host-${h}/docker-compose.yml" 2>/dev/null \
|
||||
&& bad "mode edge: service traefik ikut di host ${h} (harus hanya ingress)" \
|
||||
|| ok "mode edge: host ${h} tanpa Traefik (rute jarak jauh ditangani ingress)"
|
||||
done
|
||||
for pt in 80 443; do
|
||||
got="$(host_port_of control traefik "${pt}")"
|
||||
[[ "${got}" == "10.20.0.10" ]] && ok "mode edge: traefik :${pt} terikat ke 10.20.0.10" \
|
||||
|| bad "mode edge: traefik :${pt} terikat ke '${got}' (harus 10.20.0.10)"
|
||||
done
|
||||
# CLUSTER_DOMAIN hanya perlu di host ingress (label rute)
|
||||
grep -qx 'CLUSTER_DOMAIN=sicabe.com' "${EDGE_OUT}/host-control/.env" 2>/dev/null \
|
||||
&& ok "mode edge: CLUSTER_DOMAIN ada di host ingress" \
|
||||
|| bad "mode edge: CLUSTER_DOMAIN tidak ada di host ingress"
|
||||
grep -q '^CLUSTER_DOMAIN=' "${EDGE_OUT}/host-compute/.env" 2>/dev/null \
|
||||
&& bad "mode edge: CLUSTER_DOMAIN ikut di host non-ingress" \
|
||||
|| ok "mode edge: host non-ingress tanpa CLUSTER_DOMAIN"
|
||||
|
||||
# Rute untuk komponen di HOST LAIN ditulis di berkas dinamis (file provider),
|
||||
# karena Traefik hanya melihat container di host-nya sendiri.
|
||||
dyn="${EDGE_OUT}/host-control/config/traefik/dynamic.yml"
|
||||
if [[ -f "${dyn}" ]]; then
|
||||
ok "mode edge: berkas dinamis ingress dibuat"
|
||||
grep -q 'https://10.20.0.11:12443' "${dyn}" \
|
||||
&& ok "mode edge: rute trino -> compute:12443 (https/backend)" \
|
||||
|| bad "mode edge: rute trino tidak menunjuk compute:12443"
|
||||
grep -q 'http://10.20.0.12:12901' "${dyn}" \
|
||||
&& ok "mode edge: rute minio -> state:12901 (console)" \
|
||||
|| bad "mode edge: rute minio tidak menunjuk state:12901"
|
||||
grep -q 'serversTransport: trino-insecure' "${dyn}" \
|
||||
&& ok "mode edge: rute trino memakai serversTransport insecure (sertifikat internal)" \
|
||||
|| bad "mode edge: rute trino tanpa serversTransport"
|
||||
grep -qE 'kc\.sicabe\.com|grafana\.sicabe\.com' "${dyn}" \
|
||||
&& bad "mode edge: komponen lokal ikut ditulis di berkas dinamis (harus lewat label)" \
|
||||
|| ok "mode edge: berkas dinamis hanya memuat rute jarak jauh"
|
||||
else
|
||||
bad "mode edge: berkas dinamis ingress tidak dibuat"
|
||||
fi
|
||||
|
||||
# Rute Host() per layanan memakai domain dari bundle (bukan jatuh ke `localhost`).
|
||||
for spec in "control:keycloak:kc" "control:grafana:grafana" "control:openmetadata-server:om" \
|
||||
"control:airflow-webserver:airflow"; do
|
||||
h="${spec%%:*}"; rest="${spec#*:}"; svc="${rest%%:*}"; label="${rest##*:}"
|
||||
svc_labels "${h}" "${svc}" | grep -qF "Host(\`${label}.sicabe.com\`)" \
|
||||
&& ok "mode edge: rute $svc = Host(\`${label}.sicabe.com\`)" \
|
||||
|| bad "mode edge: rute $svc tidak memakai domain bundle"
|
||||
done
|
||||
|
||||
# Trino internal HTTPS -> backend https + serversTransport dari provider file
|
||||
# (transport TIDAK bisa lewat label; lewat label muncul error runtime).
|
||||
svc_labels compute trino-coordinator | grep -qF "loadbalancer.server.scheme=https" \
|
||||
&& ok "mode edge: backend Trino memakai https" \
|
||||
|| bad "mode edge: backend Trino tidak diset https"
|
||||
svc_labels compute trino-coordinator | grep -qF "serverstransport=trino-insecure@file" \
|
||||
&& ok "mode edge: Trino memakai serversTransport dari provider file" \
|
||||
|| bad "mode edge: serversTransport Trino salah"
|
||||
# Jeda propagasi DNS-01: apex + wildcard menulis nama TXT yang SAMA
|
||||
# (_acme-challenge.<domain>) -> tanpa jeda, LE bisa membaca record lama.
|
||||
if docker compose -f "${EDGE_OUT}/host-control/docker-compose.yml" \
|
||||
--env-file "${EDGE_OUT}/host-control/.env" --profile all --profile edge config 2>/dev/null \
|
||||
| grep -q 'dnschallenge.propagation.delayBeforeChecks='; then
|
||||
ok "mode edge: ACME memakai propagation.delayBeforeChecks (jeda propagasi DNS)"
|
||||
else
|
||||
bad "mode edge: ACME tanpa jeda propagasi DNS"
|
||||
fi
|
||||
|
||||
grep -q 'insecureSkipVerify: true' "${DEPLOY_DIR}/config/traefik/dynamic.yml" 2>/dev/null \
|
||||
&& ok "mode edge: berkas dinamis mendefinisikan insecureSkipVerify untuk Trino" \
|
||||
|| bad "mode edge: config/traefik/dynamic.yml tidak berisi insecureSkipVerify"
|
||||
|
||||
if grep -qx 'KEYCLOAK_SSL_REQUIRED=external' "${edge_env}" 2>/dev/null; then
|
||||
ok "mode edge: KEYCLOAK_SSL_REQUIRED=external (ada TLS/proxy di depan)"
|
||||
else
|
||||
bad "mode edge: KEYCLOAK_SSL_REQUIRED bukan external"
|
||||
fi
|
||||
# Kelas bug "hostname bare": config yang memakai nilai bare untuk key host
|
||||
# (mis. Sling `host: postgres`) tidak punya ':' atau '/', sehingga sempat TIDAK
|
||||
# tersubstitusi -> di host lain mencari nama container yang tidak ada. Sekaligus
|
||||
# pastikan nilai non-host yang kebetulan sama (dialek DB) TIDAK ikut berubah.
|
||||
sling="${EDGE_OUT}/host-control/config/airflow/sling/env.yaml"
|
||||
if [[ -f "${sling}" ]]; then
|
||||
grep -q "host: 10.20.0.12" "${sling}" \
|
||||
&& ok "config: nilai bare key 'host' tersubstitusi (sling 5432 -> alamat state)" \
|
||||
|| bad "config: 'host: postgres' di sling TIDAK tersubstitusi"
|
||||
grep -q "type: postgres" "${sling}" \
|
||||
&& ok "config: 'type: postgres' (dialek) TIDAK ikut berubah" \
|
||||
|| bad "config: nilai dialek 'type: postgres' ikut berubah (berbahaya)"
|
||||
else
|
||||
warn_skip=$((warn_skip + 1))
|
||||
fi
|
||||
# DB_HOST OpenMetadata: sempat literal "postgres" dan menyebabkan
|
||||
# java.net.UnknownHostException di container openmetadata-migrate.
|
||||
# CATATAN: compose hasil generate SENGAJA tetap memuat ${POSTGRES_HOST:-postgres}
|
||||
# (resolusi terjadi saat `up` lewat .env host) — berbeda dari config/** yang
|
||||
# di-render menjadi nilai literal. Jadi yang diuji ada DUA:
|
||||
# (a) nilainya sudah terparameterisasi (bukan literal "postgres"), dan
|
||||
# (b) hasil RESOLUSI-nya benar-benar alamat host state.
|
||||
om_cfg="${EDGE_OUT}/host-control/docker-compose.yml"
|
||||
grep -q 'DB_HOST: ${POSTGRES_HOST:-postgres}' "${om_cfg}" \
|
||||
&& ok "compose: DB_HOST OpenMetadata terparameterisasi (bukan literal)" \
|
||||
|| bad "compose: DB_HOST OpenMetadata masih literal 'postgres'"
|
||||
resolved_om="$(docker compose -f "${om_cfg}" --env-file "${EDGE_OUT}/host-control/.env" \
|
||||
--profile all config 2>/dev/null | grep -m1 'DB_HOST:' | awk '{print $2}')"
|
||||
[[ "${resolved_om}" == "10.20.0.12" ]] \
|
||||
&& ok "compose: DB_HOST ter-resolve ke alamat state (${resolved_om})" \
|
||||
|| bad "compose: DB_HOST ter-resolve '${resolved_om}' (harusnya alamat state 10.20.0.12)"
|
||||
|
||||
# Precedence nilai: Compose memprioritaskan variabel SHELL di atas --env-file,
|
||||
# sedangkan install.sh mengekspor default (POSTGRES_HOST=postgres) sebelum memanggil
|
||||
# compose. Bundle host HARUS tetap menang setelah .env-nya di-source -- inilah yang
|
||||
# run_host lakukan. Tanpa itu container mencari hostname `postgres` yang hanya ada di
|
||||
# host lain: java.net.UnknownHostException (bug yang dilaporkan dari server).
|
||||
resolved_prec="$(set -a; . "${DEPLOY_DIR}/config/environment.defaults.sh"; set +a
|
||||
set -a; . "${EDGE_OUT}/host-control/.env"; set +a
|
||||
docker compose -f "${EDGE_OUT}/host-control/docker-compose.yml" \
|
||||
--env-file "${EDGE_OUT}/host-control/.env" --profile all config 2>/dev/null \
|
||||
| grep -m1 'DB_HOST:' | awk '{print $2}')"
|
||||
if [[ "${resolved_prec}" == "10.20.0.12" ]]; then
|
||||
ok "precedence: nilai bundle host menang atas default yang diekspor (DB_HOST=${resolved_prec})"
|
||||
else
|
||||
bad "precedence: DB_HOST ter-resolve '${resolved_prec}' — default menimpa bundle host"
|
||||
fi
|
||||
|
||||
# variabel internal TIDAK boleh memakai nama domain (container pakai alamat)
|
||||
# Helper: nilai env satu service pada compose host ter-resolve.
|
||||
pub_env() { # <host> <service> <key>
|
||||
docker compose -f "${EDGE_OUT}/host-$1/docker-compose.yml" --env-file "${EDGE_OUT}/host-$1/.env" \
|
||||
--profile all config 2>/dev/null | python3 -c "
|
||||
import sys, yaml
|
||||
d = yaml.safe_load(sys.stdin) or {}
|
||||
e = ((d.get('services') or {}).get('$2') or {}).get('environment', {}) or {}
|
||||
print(e.get('$3', ''))" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# T-21: URL publik tersambung ke konsumen browser-facing-nya (bukan hanya ditulis di .env).
|
||||
om_srv="$(pub_env control openmetadata-server OPENMETADATA_SERVER_URL)"
|
||||
if [[ "${om_srv}" == "https://om.sicabe.com" ]]; then
|
||||
ok "mode edge: OPENMETADATA_SERVER_URL = URL publik (link email/notifikasi)"
|
||||
else
|
||||
bad "mode edge: OPENMETADATA_SERVER_URL='${om_srv}' (harus https://om.sicabe.com)"
|
||||
fi
|
||||
mc_redir="$(pub_env state minio MINIO_BROWSER_REDIRECT_URL)"
|
||||
if [[ "${mc_redir}" == "https://minio.sicabe.com" ]]; then
|
||||
ok "mode edge: MINIO_BROWSER_REDIRECT_URL = URL publik (console)"
|
||||
else
|
||||
bad "mode edge: MINIO_BROWSER_REDIRECT_URL='${mc_redir}' (harus https://minio.sicabe.com)"
|
||||
fi
|
||||
|
||||
# INVRASIAN: SERVER_HOST_API_URL dipakai sisi SERVER (Airflow/ingestion memanggil balik
|
||||
# OM) -> wajib tetap alamat internal; kalau ikut URL publik, ingestion rusak.
|
||||
om_api="$(pub_env control openmetadata-server SERVER_HOST_API_URL)"
|
||||
if [[ -n "${om_api}" && "${om_api}" != *"sicabe.com"* ]]; then
|
||||
ok "mode edge: SERVER_HOST_API_URL tetap internal (${om_api})"
|
||||
else
|
||||
bad "mode edge: SERVER_HOST_API_URL='${om_api}' — harus internal, bukan URL publik"
|
||||
fi
|
||||
|
||||
if grep -E '^(KEYCLOAK_HOST|NESSIE_URL|S3_ENDPOINT|OPA_URL|LDAP_URL|POSTGRES_HOST)=' "${edge_env}" | grep -q 'sicabe\.com'; then
|
||||
bad "mode edge: nama domain bocor ke variabel internal"
|
||||
else
|
||||
ok "mode edge: variabel internal tetap pakai alamat (bukan domain)"
|
||||
fi
|
||||
else
|
||||
bad "mode edge: generate gagal"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [[ ${FAIL_N} -eq 0 ]]; then
|
||||
echo -e " ${GRN}${BLD}SEMUA LULUS${NC} (${PASS_N} grup cek)"
|
||||
exit 0
|
||||
fi
|
||||
echo -e " ${RED}${BLD}GAGAL${NC}: ${FAIL_N} masalah · ${PASS_N} lulus"
|
||||
exit 1
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# test-inventory.sh — uji validator inventory multi-host (Fase 1)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Menguji `lib/cluster/inventory.py` secara negatif (harus DITOLAK) dan positif
|
||||
# (harus DITERIMA). Sengaja banyak kasus negatif: validator yang selalu setuju
|
||||
# tidak berguna — setiap aturan penempatan R10 §4.3 / R11 §3.2 diuji terpisah.
|
||||
#
|
||||
# Jalankan: ./scripts/test-inventory.sh
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
INV="${DEPLOY_DIR}/lib/cluster/inventory.py"
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "${TMP}"' EXIT
|
||||
|
||||
GRN=$'\e[32m'; RED=$'\e[31m'; BLD=$'\e[1m'; NC=$'\e[0m'
|
||||
PASS_N=0; FAIL_N=0
|
||||
|
||||
check() { # <harapan: reject|accept> <nama> <yaml>
|
||||
local expect="$1" name="$2" yaml="$3" rc out
|
||||
printf '%b' "$yaml" > "${TMP}/t.yml"
|
||||
out="$(python3 "${INV}" validate "${TMP}/t.yml" 2>&1)"; rc=$?
|
||||
if [[ "${expect}" == "reject" ]]; then
|
||||
if [[ ${rc} -ne 0 && "${out}" != *"Traceback"* ]]; then
|
||||
printf " ${GRN}✅ ditolak${NC} %-42s → %s\n" "${name}" "$(echo "${out}" | sed -n '2p' | cut -c1-66)"
|
||||
PASS_N=$((PASS_N + 1))
|
||||
else
|
||||
printf " ${RED}❌ MASALAH${NC} %-42s rc=%s (harus ditolak tanpa traceback)\n" "${name}" "${rc}"
|
||||
echo "${out}" | head -4 | sed 's/^/ /'
|
||||
FAIL_N=$((FAIL_N + 1))
|
||||
fi
|
||||
else
|
||||
if [[ ${rc} -eq 0 ]]; then
|
||||
printf " ${GRN}✅ diterima${NC} %-41s\n" "${name}"
|
||||
PASS_N=$((PASS_N + 1))
|
||||
else
|
||||
printf " ${RED}❌ DITOLAK (harusnya valid)${NC} %s\n" "${name}"
|
||||
echo "${out}" | head -4 | sed 's/^/ /'
|
||||
FAIL_N=$((FAIL_N + 1))
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
echo -e "${BLD}Uji validator inventory (lib/cluster/inventory.py)${NC}"
|
||||
echo
|
||||
echo "── harus DITOLAK ──"
|
||||
check reject "state dipecah ke 2 host" 'hosts:\n a:\n address: 1.1.1.1\n tiers: [state]\n b:\n address: 2.2.2.2\n tiers: [state, engine]\n'
|
||||
check reject "coord di 2 host" 'hosts:\n a:\n address: 1.1.1.1\n tiers: [coord, engine]\n b:\n address: 2.2.2.2\n tiers: [coord, state]\n'
|
||||
check reject "state + engine satu host (anti-affinity)" 'hosts:\n a:\n address: 1.1.1.1\n tiers: [state, engine, coord]\n'
|
||||
check reject "preset tak dikenal" 'preset: a9\nhosts:\n a:\n address: 1.1.1.1\n tiers: [state]\n b:\n address: 2.2.2.2\n tiers: [engine, coord]\n'
|
||||
check reject "tier tak dikenal" 'hosts:\n a:\n address: 1.1.1.1\n tiers: [state, engine, coord, banjar]\n'
|
||||
check reject "address kosong" 'hosts:\n a:\n tiers: [state, engine, coord]\n'
|
||||
check reject "components.host tidak ada di hosts" 'hosts:\n a:\n address: 1.1.1.1\n tiers: [state]\n b:\n address: 2.2.2.2\n tiers: [engine, coord]\ncomponents:\n grafana: { host: tidak-ada }\n'
|
||||
check reject "tanpa host bertier engine" 'hosts:\n a:\n address: 1.1.1.1\n tiers: [state, coord]\n'
|
||||
check reject "hosts kosong" 'preset: a3\nhosts: {}\n'
|
||||
check reject "YAML rusak" 'hosts:\n a: [ini: bukan: yaml\n'
|
||||
|
||||
echo
|
||||
echo "── harus DITOLAK (TLS & domain) ──"
|
||||
check reject "tls.mode tak dikenal" 'tls:
|
||||
mode: tls
|
||||
cluster:
|
||||
domain: sicabe.com
|
||||
preset: a3
|
||||
hosts:
|
||||
control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }
|
||||
compute: { address: 1.1.1.2, tiers: [coord, engine] }
|
||||
state: { address: 1.1.1.3, tiers: [state] }
|
||||
'
|
||||
check reject "edge tanpa domain" 'tls:
|
||||
mode: edge
|
||||
preset: a3
|
||||
hosts:
|
||||
control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }
|
||||
compute: { address: 1.1.1.2, tiers: [coord, engine] }
|
||||
state: { address: 1.1.1.3, tiers: [state] }
|
||||
'
|
||||
check reject "domain memuat skema" 'tls:
|
||||
mode: edge
|
||||
cluster:
|
||||
domain: https://sicabe.com
|
||||
preset: a3
|
||||
hosts:
|
||||
control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }
|
||||
compute: { address: 1.1.1.2, tiers: [coord, engine] }
|
||||
state: { address: 1.1.1.3, tiers: [state] }
|
||||
'
|
||||
check reject "domain tanpa titik" 'tls:
|
||||
mode: full
|
||||
cluster:
|
||||
domain: sicabe
|
||||
preset: a3
|
||||
hosts:
|
||||
control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }
|
||||
compute: { address: 1.1.1.2, tiers: [coord, engine] }
|
||||
state: { address: 1.1.1.3, tiers: [state] }
|
||||
'
|
||||
|
||||
echo
|
||||
echo "── harus DITERIMA ──"
|
||||
check accept "cluster.example.yml (preset a3)" "$(cat "${DEPLOY_DIR}/cluster.example.yml")"
|
||||
check accept "edge + domain sah" 'tls:
|
||||
mode: edge
|
||||
cluster:
|
||||
domain: sicabe.com
|
||||
preset: a3
|
||||
hosts:
|
||||
control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }
|
||||
compute: { address: 1.1.1.2, tiers: [coord, engine] }
|
||||
state: { address: 1.1.1.3, tiers: [state] }
|
||||
'
|
||||
check accept "preset b5 (5 host, tanpa tiers eksplisit)" 'preset: b5\nhosts:\n control: { address: 10.0.0.10 }\n coord: { address: 10.0.0.11 }\n engine-1: { address: 10.0.0.12 }\n engine-2: { address: 10.0.0.13 }\n state: { address: 10.0.0.14 }\n'
|
||||
|
||||
echo
|
||||
echo "── distribusi penempatan (plan) ──"
|
||||
plan_out="$(python3 "${INV}" plan "${DEPLOY_DIR}/cluster.example.yml" 2>&1)"
|
||||
plan_check() { # <nama> <host> <service-yang-harus-ada>
|
||||
local name="$1" host="$2" svc="$3"
|
||||
if echo "${plan_out}" | python3 -c "
|
||||
import json,sys
|
||||
p=json.load(sys.stdin)
|
||||
sys.exit(0 if '$svc' in p.get('$host',{}).get('services',[]) else 1)"; then
|
||||
printf " ${GRN}✅${NC} %-46s %s ∈ %s\n" "$name" "$svc" "$host"; PASS_N=$((PASS_N + 1))
|
||||
else
|
||||
printf " ${RED}❌${NC} %-46s %s TIDAK di %s\n" "$name" "$svc" "$host"; FAIL_N=$((FAIL_N + 1))
|
||||
fi
|
||||
}
|
||||
plan_check "a3: coordinator di compute" compute trino-coordinator
|
||||
plan_check "a3: worker di compute" compute trino-worker
|
||||
plan_check "a3: postgres di state" state postgres
|
||||
plan_check "a3: minio di state" state minio
|
||||
plan_check "a3: keycloak di control" control keycloak
|
||||
plan_check "a3: grafana di control" control grafana
|
||||
plan_check "a3: airflow-webserver di control" control airflow-webserver
|
||||
plan_not_in() { # <nama> <host> <service-yang-harus-TIDAK-ada>
|
||||
local name="$1" host="$2" svc="$3"
|
||||
if echo "${plan_out}" | python3 -c "
|
||||
import json,sys
|
||||
p=json.load(sys.stdin)
|
||||
sys.exit(0 if '$svc' not in p.get('$host',{}).get('services',[]) else 1)"; then
|
||||
printf " ${GRN}✅${NC} %-46s %s ∉ %s\n" "${name}" "${svc}" "${host}"; PASS_N=$((PASS_N + 1))
|
||||
else
|
||||
printf " ${RED}❌${NC} %-46s %s ADA di %s\n" "${name}" "${svc}" "${host}"; FAIL_N=$((FAIL_N + 1))
|
||||
fi
|
||||
}
|
||||
plan_not_in "a3: postgres tidak di control" control postgres
|
||||
plan_not_in "a3: trino-worker tidak di control" control trino-worker
|
||||
plan_not_in "a3: dremio off (default) tidak ditempatkan" compute dremio
|
||||
|
||||
# b5: tier engine >1 host → worker & CN harus muncul di KEDUA host engine
|
||||
printf 'preset: b5\nhosts:\n control: { address: 10.0.0.10 }\n coord: { address: 10.0.0.11 }\n engine-1: { address: 10.0.0.12 }\n engine-2: { address: 10.0.0.13 }\n state: { address: 10.0.0.14 }\n' > "${TMP}/b5.yml"
|
||||
b5_out="$(python3 "${INV}" plan "${TMP}/b5.yml" 2>&1)"
|
||||
b5_check() { # <host> <service>
|
||||
if echo "${b5_out}" | python3 -c "
|
||||
import json,sys
|
||||
p=json.load(sys.stdin)
|
||||
sys.exit(0 if '$2' in p.get('$1',{}).get('services',[]) else 1)"; then
|
||||
printf " ${GRN}✅${NC} b5: %-14s %s\n" "$2 ∈ $1" ""; PASS_N=$((PASS_N + 1))
|
||||
else
|
||||
printf " ${RED}❌${NC} b5: %s TIDAK di %s\n" "$2" "$1"; FAIL_N=$((FAIL_N + 1))
|
||||
fi
|
||||
}
|
||||
b5_check engine-1 trino-worker
|
||||
b5_check engine-2 trino-worker
|
||||
b5_check engine-1 starrocks-cn
|
||||
b5_check engine-2 starrocks-cn
|
||||
b5_check coord trino-coordinator
|
||||
if echo "${b5_out}" | python3 -c "
|
||||
import json,sys
|
||||
p=json.load(sys.stdin)
|
||||
sys.exit(0 if 'trino-worker' not in p.get('coord',{}).get('services',[]) else 1)"; then
|
||||
printf " ${GRN}✅${NC} b5: trino-worker tidak di coord (coord ⊥ engine)\n"; PASS_N=$((PASS_N + 1))
|
||||
else
|
||||
printf " ${RED}❌${NC} b5: trino-worker ada di coord\n"; FAIL_N=$((FAIL_N + 1))
|
||||
fi
|
||||
|
||||
echo "── URL browser-facing turunan (tls.mode) ──"
|
||||
url_case() { # <nama> <yaml> <harapan: kosong|ada> <var>
|
||||
local name="$1" yaml="$2" expect="$3" var="$4" out
|
||||
printf '%b' "$yaml" > "${TMP}/tls.yml"
|
||||
out="$(python3 -c "
|
||||
import sys; sys.path.insert(0,'${DEPLOY_DIR}/lib/cluster')
|
||||
import inventory
|
||||
doc = inventory.apply_preset(inventory.load('${TMP}/tls.yml'))
|
||||
print('|'.join(f'{k}={v}' for k,v in sorted(inventory.public_urls(doc).items())))" 2>/dev/null)"
|
||||
if [[ "${expect}" == "kosong" ]]; then
|
||||
if [[ -z "${out}" ]]; then printf " ${GRN}✅${NC} %s\n" "${name}"; PASS_N=$((PASS_N+1))
|
||||
else printf " ${RED}❌${NC} %s — seharusnya kosong, dapat: %s\n" "${name}" "${out}"; FAIL_N=$((FAIL_N+1)); fi
|
||||
else
|
||||
if [[ "${out}" == *"${var}=${expect}"* ]]; then printf " ${GRN}✅${NC} %s\n" "${name}"; PASS_N=$((PASS_N+1))
|
||||
else printf " ${RED}❌${NC} %s — tidak menemukan %s=%s di: %s\n" "${name}" "${var}" "${expect}" "${out}"; FAIL_N=$((FAIL_N+1)); fi
|
||||
fi
|
||||
}
|
||||
A3='tls:\n mode: none\ncluster:\n domain: sicabe.com\npreset: a3\nhosts:\n control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }\n compute: { address: 1.1.1.2, tiers: [coord, engine] }\n state: { address: 1.1.1.3, tiers: [state] }\n'
|
||||
EDGE='tls:\n mode: edge\ncluster:\n domain: sicabe.com\npreset: a3\nhosts:\n control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }\n compute: { address: 1.1.1.2, tiers: [coord, engine] }\n state: { address: 1.1.1.3, tiers: [state] }\n'
|
||||
FULL='tls:\n mode: full\ncluster:\n domain: contoh.org\npreset: a3\nhosts:\n control: { address: 1.1.1.1, tiers: [identity, policy, pipeline, governance] }\n compute: { address: 1.1.1.2, tiers: [coord, engine] }\n state: { address: 1.1.1.3, tiers: [state] }\n'
|
||||
url_case "mode none -> TIDAK ada URL publik" "${A3}" kosong ""
|
||||
url_case "mode edge -> Keycloak https" "${EDGE}" "https://kc.sicabe.com" "KEYCLOAK_PUBLIC_URL"
|
||||
url_case "mode edge -> Grafana https" "${EDGE}" "https://grafana.sicabe.com" "GRAFANA_PUBLIC_URL"
|
||||
url_case "mode edge -> MinIO console https" "${EDGE}" "https://minio.sicabe.com" "MINIO_CONSOLE_PUBLIC_URL"
|
||||
url_case "mode full -> memakai domain sendiri" "${FULL}" "https://om.contoh.org" "OPENMETADATA_PUBLIC_URL"
|
||||
|
||||
echo
|
||||
if [[ ${FAIL_N} -eq 0 ]]; then
|
||||
echo -e " ${GRN}${BLD}SEMUA LULUS${NC} (${PASS_N} cek)"
|
||||
exit 0
|
||||
fi
|
||||
echo -e " ${RED}${BLD}GAGAL${NC}: ${FAIL_N} bermasalah · ${PASS_N} lulus"
|
||||
exit 1
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# test-shell-safety.sh — jaga "jebakan set -e + grep" di skrip deploy
|
||||
# -----------------------------------------------------------------------------
|
||||
# JEBAKAN: pipeline yang memuat `grep` mengembalikan 1 saat TIDAK ada baris cocok.
|
||||
# Di skrip ber-`set -euo pipefail`, hasil pipeline itu MEMATIKAN skrip DIAM-DIAM.
|
||||
#
|
||||
# Regresi nyata: e099b95 membuat `external_services()` di install.sh berakhir
|
||||
# ... | grep -v '^$' | sort -u | paste ...
|
||||
# sehingga `./install.sh` (single-host tanpa service eksternal) keluar exit 1
|
||||
# TEPAT setelah banner. Perbaikan: `awk 'NF'` (buang baris kosong, selalu exit 0).
|
||||
#
|
||||
# YANG DIPERIKSA (hanya skrip ber-`set -e`):
|
||||
# 1. `| grep -v` / `| grep -c` — mode FILTER data yang lazim exit 1 saat kosong,
|
||||
# dan jarang dipakai sebagai uji boolean. Lain halnya `grep -q`/`-E` yang
|
||||
# memang sering dipakai di `if`, sehingga TIDAK ditandai.
|
||||
# 2. Idiom `grep -v '^$'` — pemicu langsung regresi di atas.
|
||||
#
|
||||
# Baik (1) maupun (2) DILEWATI bila barisnya jelas kondisional — `if/elif/while/
|
||||
# until/!` atau memuat `&&`/`||` — karena status exit-nya memang dikonsumsi.
|
||||
#
|
||||
# Jalankan: ./scripts/test-shell-safety.sh
|
||||
# =============================================================================
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
GRN=$'\e[32m'; RED=$'\e[31m'; BLD=$'\e[1m'; NC=$'\e[0m'
|
||||
PASS_N=0; FAIL_N=0
|
||||
ok() { printf " ${GRN}✅${NC} %s\n" "$1"; PASS_N=$((PASS_N+1)); }
|
||||
bad() { printf " ${RED}❌${NC} %s\n" "$1"; FAIL_N=$((FAIL_N+1)); }
|
||||
|
||||
# Baris ini "aman" dari set -e karena status exit-nya dikonsumsi (kondisi operator)?
|
||||
is_conditional() {
|
||||
local s="$1"
|
||||
case "${s}" in
|
||||
if\ *|elif\ *|while\ *|until\ *|'!'*) return 0 ;;
|
||||
esac
|
||||
[[ "${s}" == *"&&"* || "${s}" == *"||"* || "${s}" == *"|| true"* ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
echo -e "${BLD}Uji keamanan shell (jebakan set -e + grep)${NC}"
|
||||
echo
|
||||
|
||||
checked=0
|
||||
while IFS= read -r f; do
|
||||
# Hanya skrip ber-`set -e` (opsi memuat huruf 'e'); `set -uo pipefail` tanpa -e
|
||||
# tidak berisiko karena tak ada yang mematikan skrip.
|
||||
grep -qE '^[[:space:]]*set -[a-zA-Z]*e' "$f" 2>/dev/null || continue
|
||||
checked=$((checked+1))
|
||||
rel="${f#"${DEPLOY_DIR}/"}"
|
||||
file_bad=0
|
||||
|
||||
# (1) pipe ke grep -v / grep -c
|
||||
while IFS=: read -r ln text; do
|
||||
[[ -z "${ln}" ]] && continue
|
||||
stripped="$(printf '%s' "${text}" | sed 's/^[[:space:]]*//')"
|
||||
is_conditional "${stripped}" && continue
|
||||
bad "${rel}:${ln} — pipe ke 'grep -v/-c' (exit 1 saat kosong → set -e mematikan)"
|
||||
printf ' %s\n' "${stripped}"
|
||||
file_bad=1
|
||||
done < <(grep -nE '\|[[:space:]]*grep[[:space:]]+-[a-zA-Z]*[vc]' "$f" 2>/dev/null || true)
|
||||
|
||||
# (2) idiom terlarang grep -v '^$' (baris komentar & kondisional dikecualikan)
|
||||
while IFS=: read -r ln text; do
|
||||
[[ -z "${ln}" ]] && continue
|
||||
stripped="$(printf '%s' "${text}" | sed 's/^[[:space:]]*//')"
|
||||
[[ "${stripped}" == '#'* ]] && continue
|
||||
is_conditional "${stripped}" && continue
|
||||
bad "${rel}:${ln} — idiom terlarang grep -v '^\$' (pakai awk 'NF')"
|
||||
file_bad=1
|
||||
done < <(grep -nF "grep -v '^$'" "$f" 2>/dev/null || true)
|
||||
|
||||
[[ ${file_bad} -eq 0 ]] && ok "${rel}: bersih"
|
||||
done < <(find "${DEPLOY_DIR}" -name '*.sh' -not -path '*/generated/*' | sort)
|
||||
|
||||
echo
|
||||
echo " skrip ber-set -e diperiksa: ${checked}"
|
||||
if [[ ${FAIL_N} -eq 0 ]]; then
|
||||
echo -e " ${GRN}${BLD}SEMUA LULUS${NC} (${PASS_N} cek)"
|
||||
exit 0
|
||||
fi
|
||||
echo -e " ${RED}${BLD}GAGAL${NC}: ${FAIL_N} masalah · ${PASS_N} lulus"
|
||||
exit 1
|
||||
Executable
+478
@@ -0,0 +1,478 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Verifikasi Phase 1 (IAM): Keycloak + OpenLDAP
|
||||
#
|
||||
# Gate "kontrak IAM": memastikan konfigurasi Keycloak & LDAP benar-benar SIAP
|
||||
# dipakai fase berikutnya (Trino+OPA, StarRocks, Airflow, OpenMetadata, Grafana)
|
||||
# — termasuk saat memakai Keycloak/LDAP EXISTING (bukan container deploy ini).
|
||||
#
|
||||
# Yang diperiksa:
|
||||
# A. Keycloak statis : realm, roles, 5 OIDC client, audience mapper, redirect URI
|
||||
# B. Keycloak fungsional : login tiap user → preferred_username & roles
|
||||
# C. LDAP : ou=users/ou=groups + pemetaan user→grup
|
||||
# (filter PERSIS seperti config/trino/group-provider.properties)
|
||||
# D. Cross-check : user Keycloak == uid LDAP ;
|
||||
# grup LDAP == roles di config/opa/data.json == permitted_groups StarRocks
|
||||
# E. Client secret : nilai tersimpan di Keycloak == nilai env deploy
|
||||
#
|
||||
# Exit code: 0 = semua gate lolos (WARN boleh ada), 1 = ada FAIL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/verify-phase1-iam.sh
|
||||
# # menunjuk IAM existing:
|
||||
# KEYCLOAK_URL=http://kc.corp:8080 LDAP_HOST=ldap.corp LDAP_PORT=389 \
|
||||
# ./scripts/verify-phase1-iam.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
|
||||
|
||||
KC="$(sicabe_local_url http KEYCLOAK_HOST "${KEYCLOAK_PORT:-12080}" KEYCLOAK_URL)"
|
||||
REALM="${SICABE_REALM:-sicabe}"
|
||||
LDAP_HOST="${LDAP_HOST:-localhost}"
|
||||
LDAP_PORT="${LDAP_PORT:-389}"
|
||||
LDAP_ADMIN_DN="${LDAP_ADMIN_DN:-cn=admin,dc=sicabe,dc=local}"
|
||||
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=sicabe,dc=local}"
|
||||
USERS_DN="${LDAP_USERS_DN:-ou=users,${LDAP_BASE_DN}}"
|
||||
GROUPS_DN="${LDAP_GROUPS_DN:-ou=groups,${LDAP_BASE_DN}}"
|
||||
|
||||
REQUIRED_CLIENTS=("trino" "starrocks" "airflow" "openmetadata" "grafana")
|
||||
REQUIRED_ROLES=("admin" "engineer" "analyst" "viewer")
|
||||
REQUIRED_USERS=("admin" "engineer" "analyst" "viewer")
|
||||
AUDIENCE_MAPPER="audience-account"
|
||||
|
||||
PASS_N=0
|
||||
FAIL_N=0
|
||||
WARN_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)); }
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Keycloak — helper
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
kc_token() {
|
||||
curl -sf -X POST "${KC}/realms/master/protocol/openid-connect/token" \
|
||||
-d client_id=admin-cli \
|
||||
-d "username=${KEYCLOAK_ADMIN:-admin}" \
|
||||
-d "password=${KEYCLOAK_ADMIN_PASSWORD}" \
|
||||
-d grant_type=password 2>/dev/null |
|
||||
python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
kc_get() {
|
||||
curl -sf -H "Authorization: Bearer ${KC_TOKEN}" \
|
||||
"${KC}/admin/realms/${REALM}$1" 2>/dev/null
|
||||
}
|
||||
|
||||
kc_user_login_probe() {
|
||||
# → "preferred_username|roles". Membaca **id_token** (scope=openid): sejak
|
||||
# Keycloak 26 access token bersifat "lightweight" sehingga tidak lagi memuat
|
||||
# preferred_username / realm_access.
|
||||
local user="$1" pw="$2"
|
||||
curl -s -X POST "${KC}/realms/${REALM}/protocol/openid-connect/token" \
|
||||
-d client_id=admin-cli -d "username=${user}" -d "password=${pw}" \
|
||||
-d grant_type=password -d scope=openid 2>/dev/null |
|
||||
python3 -c "
|
||||
import sys,json,base64
|
||||
try:
|
||||
r=json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(''); raise SystemExit
|
||||
tok=r.get('id_token') or r.get('access_token') or ''
|
||||
if not tok:
|
||||
print(''); raise SystemExit
|
||||
p=tok.split('.')[1]; p+='='*(-len(p)%4)
|
||||
try:
|
||||
c=json.loads(base64.urlsafe_b64decode(p))
|
||||
except Exception:
|
||||
print(''); raise SystemExit
|
||||
roles=c.get('realm_access',{}).get('roles') or []
|
||||
print(c.get('preferred_username',''), '|', ','.join(roles))
|
||||
" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
# Deteksi "lightweight access token" (Keycloak 26+): access token tanpa
|
||||
# preferred_username. Tidak fatal, tetapi perlu diketahui karena komponen yang
|
||||
# membaca klaim dari ACCESS token (bukan id_token) harus menyesuaikan.
|
||||
kc_access_token_is_lightweight() {
|
||||
local user="$1" pw="$2"
|
||||
curl -s -X POST "${KC}/realms/${REALM}/protocol/openid-connect/token" \
|
||||
-d client_id=admin-cli -d "username=${user}" -d "password=${pw}" \
|
||||
-d grant_type=password 2>/dev/null |
|
||||
python3 -c "
|
||||
import sys,json,base64
|
||||
try:
|
||||
t=json.load(sys.stdin).get('access_token','')
|
||||
p=t.split('.')[1]; p+='='*(-len(p)%4)
|
||||
c=json.loads(base64.urlsafe_b64decode(p))
|
||||
except Exception:
|
||||
print('unknown'); raise SystemExit
|
||||
print('lightweight' if 'preferred_username' not in c else 'full')
|
||||
" 2>/dev/null || echo "unknown"
|
||||
}
|
||||
|
||||
check_keycloak_static() {
|
||||
echo -e "${BOLD}A. Keycloak — statis (realm, roles, client, mapper)${NC}"
|
||||
|
||||
local discovery
|
||||
discovery=$(curl -sf "${KC}/realms/${REALM}/.well-known/openid-configuration" 2>/dev/null || echo "")
|
||||
if [[ -n "${discovery}" ]]; then
|
||||
pass "OIDC discovery realm '${REALM}' OK ($(echo "${discovery}" | python3 -c "import sys,json;print(json.load(sys.stdin).get('issuer',''))" 2>/dev/null))"
|
||||
else
|
||||
fail "OIDC discovery realm '${REALM}' tidak merespon — cek ${KC}/realms/${REALM}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "${HAVE_ADMIN:-0}" -eq 0 ]]; then
|
||||
warn "Tanpa admin Keycloak — cek realm/roles/client/mapper DILEWATI (didelegasikan ke admin IdP)."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local roles
|
||||
roles=$(kc_get "/roles" | python3 -c "import sys,json;print(' '.join(r['name'] for r in json.load(sys.stdin)))" 2>/dev/null || echo "")
|
||||
local r missing_roles=""
|
||||
for r in "${REQUIRED_ROLES[@]}"; do
|
||||
[[ " ${roles} " == *" ${r} "* ]] || missing_roles+=" ${r}"
|
||||
done
|
||||
if [[ -z "${missing_roles}" ]]; then
|
||||
pass "Roles lengkap: ${REQUIRED_ROLES[*]}"
|
||||
else
|
||||
fail "Role belum ada:${missing_roles}"
|
||||
fi
|
||||
|
||||
local c cid mappers
|
||||
for c in "${REQUIRED_CLIENTS[@]}"; do
|
||||
cid=$(kc_get "/clients?clientId=${c}" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d[0]['id'] if d else '')" 2>/dev/null || echo "")
|
||||
if [[ -z "${cid}" ]]; then
|
||||
fail "Client OIDC '${c}' tidak ada"
|
||||
continue
|
||||
fi
|
||||
mappers=$(kc_get "/clients/${cid}/protocol-mappers/models" | python3 -c "import sys,json;print(' '.join(m['name'] for m in json.load(sys.stdin)))" 2>/dev/null || echo "")
|
||||
if [[ " ${mappers} " == *" ${AUDIENCE_MAPPER} "* ]]; then
|
||||
pass "Client '${c}' ada + audience mapper '${AUDIENCE_MAPPER}'"
|
||||
else
|
||||
fail "Client '${c}' TANPA audience mapper '${AUDIENCE_MAPPER}' (Trino OIDC akan menolak token)"
|
||||
fi
|
||||
done
|
||||
|
||||
local sr_redirect
|
||||
sr_redirect=$(kc_get "/clients?clientId=starrocks" | python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
print(' '.join(d[0].get('redirectUris',[])) if d else '')" 2>/dev/null || echo "")
|
||||
if [[ " ${sr_redirect} " == *"starrocks-fe:8030"* ]]; then
|
||||
pass "Client 'starrocks' punya redirect URI internal FE"
|
||||
else
|
||||
warn "Client 'starrocks' belum memuat redirect internal (http://starrocks-fe:8030/api/oauth2)"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
check_client_secrets() {
|
||||
echo -e "${BOLD}E. Client secret — Keycloak vs env deploy${NC}"
|
||||
if [[ "${HAVE_ADMIN:-0}" -eq 0 ]]; then
|
||||
warn "Tanpa admin Keycloak — cek secret via Admin API DILEWATI (diganti cek client_credentials §F)."
|
||||
return 0
|
||||
fi
|
||||
local pairs=("trino:${TRINO_CLIENT_SECRET}" "starrocks:${STARROCKS_CLIENT_SECRET}"
|
||||
"airflow:${AIRFLOW_CLIENT_SECRET}" "openmetadata:${OPENMETADATA_CLIENT_SECRET}"
|
||||
"grafana:${GRAFANA_CLIENT_SECRET}")
|
||||
local pair cid expected actual bad=0
|
||||
for pair in "${pairs[@]}"; do
|
||||
cid="${pair%%:*}"
|
||||
expected="${pair#*:}"
|
||||
actual=$(kc_get "/clients?clientId=${cid}" | python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
print(d[0].get('secret','') if d else '')" 2>/dev/null || echo "")
|
||||
if [[ -z "${actual}" ]]; then
|
||||
warn "Client '${cid}' tidak ditemukan — lewati cek secret"
|
||||
elif [[ "${actual}" == "${expected}" ]]; then
|
||||
pass "Secret '${cid}' sinkron dengan env deploy"
|
||||
else
|
||||
fail "Secret '${cid}' BEDA dari env deploy (komponen akan gagal login)"
|
||||
bad=$((bad + 1))
|
||||
fi
|
||||
done
|
||||
[[ ${bad} -eq 0 ]] || warn "Samakan secret di .env dengan nilai di Keycloak."
|
||||
}
|
||||
|
||||
check_keycloak_users_login() {
|
||||
echo -e "${BOLD}B. Keycloak — fungsional (login tiap user)${NC}"
|
||||
local user pw claim preferred
|
||||
for user in "${REQUIRED_USERS[@]}"; do
|
||||
case "${user}" in
|
||||
admin) pw="${KEYCLOAK_ADMIN_PASSWORD}" ;;
|
||||
*) pw="${user}123" ;;
|
||||
esac
|
||||
claim=$(kc_user_login_probe "${user}" "${pw}")
|
||||
preferred="${claim%% |*}"
|
||||
if [[ "${preferred}" == "${user}" ]]; then
|
||||
pass "Login '${user}' OK (preferred_username=${preferred})"
|
||||
elif [[ -n "${preferred}" ]]; then
|
||||
fail "Login '${user}' OK tapi preferred_username='${preferred}' — harus sama dengan uid LDAP"
|
||||
elif [[ "${HAVE_ADMIN:-0}" -eq 0 ]]; then
|
||||
warn "Login '${user}' tak bisa diverifikasi (password user dikelola admin IdP) — dilewati"
|
||||
else
|
||||
fail "Login '${user}' gagal (password realm di luar default → isi manual)"
|
||||
fi
|
||||
done
|
||||
|
||||
local lt
|
||||
lt=$(kc_access_token_is_lightweight "engineer" "engineer123")
|
||||
if [[ "${lt}" == "lightweight" ]]; then
|
||||
warn "Access token 'lightweight' (Keycloak 26): tanpa preferred_username/realm_access di access token."
|
||||
warn " Komponen yang membaca klaim dari access token harus memakai id_token / userinfo."
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# LDAP — helper (container lokal atau LDAP existing)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
ldap_is_local_container() {
|
||||
docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${LDAP_CONTAINER_NAME:-sicabe-openldap}$"
|
||||
}
|
||||
|
||||
ldap_endpoint() {
|
||||
if ldap_is_local_container; then
|
||||
echo "ldap://localhost"
|
||||
else
|
||||
echo "ldap://${LDAP_HOST}:${LDAP_PORT}"
|
||||
fi
|
||||
}
|
||||
|
||||
ldap_run() {
|
||||
if ldap_is_local_container; then
|
||||
docker exec -i "${LDAP_CONTAINER_NAME:-sicabe-openldap}" "$@"
|
||||
else
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ldap_search() {
|
||||
ldap_run ldapsearch -LLL -x -H "$(ldap_endpoint)" \
|
||||
-D "${LDAP_ADMIN_DN}" -w "${LDAP_ADMIN_PASSWORD}" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
# Catatan: JANGAN menyalurkan keluaran ldap_search ke `grep -q`. Dengan
|
||||
# `set -o pipefail`, `grep -q` yang berhenti lebih awal membuat `docker exec`
|
||||
# kena SIGPIPE (exit 141) sehingga pipa dilaporkan gagal padahal cocok.
|
||||
# Karena itu hasil ditampung dulu ke variabel.
|
||||
ldap_entry_exists() {
|
||||
local base="$1" filter="$2" attr="${3:-cn}" out
|
||||
out=$(ldap_search -b "${base}" -s one "${filter}" "${attr}")
|
||||
[[ -n "${out}" ]]
|
||||
}
|
||||
|
||||
ldap_dn_exists() {
|
||||
local out
|
||||
out=$(ldap_search -b "$1" -s base "(objectClass=*)" "*")
|
||||
[[ -n "${out}" ]]
|
||||
}
|
||||
|
||||
check_ldap_structure() {
|
||||
echo -e "${BOLD}C. LDAP — struktur & pemetaan user→grup${NC}"
|
||||
if ! ldap_is_local_container && ! command -v ldapsearch >/dev/null 2>&1; then
|
||||
fail "ldapsearch tidak ada di host (mode LDAP existing). Install ldap-utils."
|
||||
return 1
|
||||
fi
|
||||
|
||||
local result
|
||||
result=$(ldap_search -b "${LDAP_BASE_DN}" -s one "(objectClass=*)" dn)
|
||||
if [[ -n "${result}" ]]; then
|
||||
pass "LDAP terjangkau di $(ldap_endpoint) — base '${LDAP_BASE_DN}'"
|
||||
else
|
||||
fail "LDAP tidak terjangkau / bind gagal di $(ldap_endpoint)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local u g dn
|
||||
for u in "${REQUIRED_USERS[@]}"; do
|
||||
dn="uid=${u},${USERS_DN}"
|
||||
if ldap_dn_exists "${dn}"; then
|
||||
pass "User LDAP '${u}' ada"
|
||||
else
|
||||
fail "User LDAP '${u}' tidak ada (${dn}) — Trino tidak akan menemukan grupnya"
|
||||
fi
|
||||
done
|
||||
|
||||
local user_groups
|
||||
for u in "${REQUIRED_USERS[@]}"; do
|
||||
user_groups=$(ldap_search -b "${GROUPS_DN}" "(member=uid=${u},${USERS_DN})" cn |
|
||||
sed -n 's/^cn: //p' | tr '\n' ',' | sed 's/,$//')
|
||||
if [[ -n "${user_groups}" ]]; then
|
||||
pass " ${u} → ${user_groups}"
|
||||
else
|
||||
fail " ${u} tidak berada di grup mana pun (otorisasi akan selalu ditolak)"
|
||||
fi
|
||||
done
|
||||
for g in data-admin data-engineer data-analyst data-viewer; do
|
||||
ldap_entry_exists "${GROUPS_DN}" "(cn=${g})" cn ||
|
||||
warn "Grup LDAP '${g}' tidak ada"
|
||||
done
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# D. Cross-check antar-sistem
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_cross_reference() {
|
||||
echo -e "${BOLD}D. Cross-check antar-sistem${NC}"
|
||||
|
||||
if [[ "${HAVE_ADMIN:-0}" -eq 1 ]]; then
|
||||
local kc_users
|
||||
kc_users=$(kc_get "/users?max=200" |
|
||||
python3 -c "import sys,json;print(' '.join(sorted(u['username'] for u in json.load(sys.stdin))))" 2>/dev/null || echo "")
|
||||
local u ldap_missing=""
|
||||
for u in "${REQUIRED_USERS[@]}"; do
|
||||
[[ " ${kc_users} " == *" ${u} "* ]] || ldap_missing+=" ${u}"
|
||||
done
|
||||
if [[ -z "${ldap_missing}" ]]; then
|
||||
pass "User Keycloak ⊇ user wajib (${REQUIRED_USERS[*]})"
|
||||
else
|
||||
fail "User Keycloak tidak punya:${ldap_missing} — tidak akan bisa login OIDC"
|
||||
fi
|
||||
else
|
||||
warn "Tanpa admin Keycloak — cek daftar user DILEWATI (verifikasi user lewat login §B)."
|
||||
fi
|
||||
|
||||
local opa_roles
|
||||
opa_roles=$(python3 -c "
|
||||
import json
|
||||
print(' '.join(sorted(json.load(open('${CONFIG_DIR}/opa/data.json'))['roles'])))" 2>/dev/null || echo "")
|
||||
if [[ -n "${opa_roles}" ]]; then
|
||||
pass "Roles di OPA (data.json): ${opa_roles}"
|
||||
else
|
||||
fail "Tidak bisa membaca roles dari config/opa/data.json"
|
||||
fi
|
||||
|
||||
# Role dapat diberikan lewat dua jalur: (a) grup LDAP dengan cn sama, atau
|
||||
# (b) pemetaan service account di data.users (untuk akun yang tidak ada di LDAP).
|
||||
local svc_roles
|
||||
svc_roles=$(python3 -c "
|
||||
import json
|
||||
print(' '.join(sorted(set(json.load(open('${CONFIG_DIR}/opa/data.json')).get('users', {}).values()))))" 2>/dev/null || echo "")
|
||||
|
||||
local g no_ldap="" svc_only=""
|
||||
for g in ${opa_roles}; do
|
||||
if ldap_entry_exists "${GROUPS_DN}" "(cn=${g})" cn; then
|
||||
continue
|
||||
fi
|
||||
if [[ " ${svc_roles} " == *" ${g} "* ]]; then
|
||||
svc_only+=" ${g}"
|
||||
else
|
||||
no_ldap+=" ${g}"
|
||||
fi
|
||||
done
|
||||
if [[ -z "${no_ldap}" ]]; then
|
||||
pass "Semua role OPA dapat diberikan (via grup LDAP atau pemetaan service account)"
|
||||
[[ -n "${svc_only}" ]] && pass " role khusus service account:${svc_only}"
|
||||
else
|
||||
fail "Role OPA tanpa grup LDAP & tanpa pemetaan service:${no_ldap}"
|
||||
fi
|
||||
|
||||
local sr_groups
|
||||
sr_groups=$(grep -oE "'permitted_groups' = '[^']+'" "${SCRIPT_DIR}/bootstrap.sh" 2>/dev/null |
|
||||
head -1 | sed "s/.*= '//; s/'//" | tr ',' ' ')
|
||||
if [[ -n "${sr_groups}" ]]; then
|
||||
local grp mismatch=""
|
||||
for grp in ${sr_groups}; do
|
||||
[[ " ${opa_roles} " == *" ${grp} "* ]] || mismatch+=" ${grp}"
|
||||
done
|
||||
if [[ -z "${mismatch}" ]]; then
|
||||
pass "permitted_groups StarRocks ⊆ roles OPA"
|
||||
else
|
||||
warn "permitted_groups StarRocks memuat grup yang tidak ada di OPA:${mismatch}"
|
||||
fi
|
||||
else
|
||||
warn "Tidak bisa membaca permitted_groups StarRocks dari bootstrap.sh"
|
||||
fi
|
||||
}
|
||||
|
||||
# F. client_credentials — TANPA admin: membuktikan klien ADA + secret SINKRON +
|
||||
# audience mapper jalan (aud=account). Ini pengganti cek statis §A/§E saat IAM
|
||||
# dikelola pihak lain (tidak ada akses admin). Butuh serviceAccountsEnabled=true.
|
||||
check_client_credentials() {
|
||||
echo -e "${BOLD}F. Keycloak — client_credentials (klien + secret + audience; tanpa admin)${NC}"
|
||||
local pairs=("trino:${TRINO_CLIENT_SECRET}" "starrocks:${STARROCKS_CLIENT_SECRET}"
|
||||
"airflow:${AIRFLOW_CLIENT_SECRET}" "openmetadata:${OPENMETADATA_CLIENT_SECRET}"
|
||||
"grafana:${GRAFANA_CLIENT_SECRET}")
|
||||
local pair cid secret tok aud
|
||||
for pair in "${pairs[@]}"; do
|
||||
cid="${pair%%:*}"; secret="${pair#*:}"
|
||||
tok=$(curl -s -X POST "${KC}/realms/${REALM}/protocol/openid-connect/token" \
|
||||
-d grant_type=client_credentials -d "client_id=${cid}" -d "client_secret=${secret}" 2>/dev/null |
|
||||
python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || echo "")
|
||||
if [[ -z "${tok}" ]]; then
|
||||
fail "client_credentials '${cid}' GAGAL (klien tak ada / secret beda / service account OFF)"
|
||||
continue
|
||||
fi
|
||||
aud=$(printf '%s' "${tok}" | python3 -c "
|
||||
import sys,json,base64
|
||||
t=sys.stdin.read().strip(); p=t.split('.')[1]; p+='='*(-len(p)%4)
|
||||
d=json.loads(base64.urlsafe_b64decode(p)); a=d.get('aud') or []
|
||||
print(','.join(a if isinstance(a,list) else [a]))" 2>/dev/null || echo "")
|
||||
if [[ " ${aud} " == *" account "* ]]; then
|
||||
pass "client_credentials '${cid}' OK + aud=account (mapper audience ada, secret sinkron)"
|
||||
else
|
||||
warn "client_credentials '${cid}' OK tapi aud tanpa 'account' (aud='${aud}') — cek audience mapper"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
print_section "Verifikasi Phase 1 — Kontrak IAM (Keycloak + LDAP)"
|
||||
echo -e " Keycloak : ${CYAN}${KC}${NC} (realm ${REALM})"
|
||||
echo -e " LDAP : ${CYAN}$(ldap_endpoint)${NC}"
|
||||
echo ""
|
||||
|
||||
KC_TOKEN="$(kc_token)"
|
||||
HAVE_ADMIN=1
|
||||
if [[ -z "${KC_TOKEN}" ]]; then
|
||||
HAVE_ADMIN=0
|
||||
echo -e "${WARN} Tanpa akses admin Keycloak (${KC}) — lanjut mode READ-ONLY."
|
||||
echo -e " ${INFO} Cek statis realm/role/user/client di Keycloak DILEWATI (didelegasikan ke admin IdP)."
|
||||
echo -e " ${INFO} Tetap diperiksa: OIDC discovery · client_credentials (klien ada + secret sinkron +"
|
||||
echo -e " ${INFO} aud=account) · LDAP · cross-check lokal (OPA/StarRocks)."
|
||||
fi
|
||||
|
||||
check_keycloak_static
|
||||
check_keycloak_users_login
|
||||
check_client_credentials
|
||||
check_client_secrets
|
||||
check_ldap_structure
|
||||
check_cross_reference
|
||||
|
||||
echo ""
|
||||
echo -e "${BOLD}Ringkasan:${NC} ${GREEN}${PASS_N} PASS${NC} · ${RED}${FAIL_N} FAIL${NC} · ${YELLOW}${WARN_N} WARN${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 IAM Phase 1 terpenuhi — aman lanjut ke fase berikutnya.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${ERR} Ada ${FAIL_N} gate yang gagal — perbaiki sebelum melanjutkan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+411
@@ -0,0 +1,411 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Verifikasi Phase 2 (Storage & Catalog): MinIO/S3 + Nessie
|
||||
#
|
||||
# Gate "kontrak storage": memastikan object storage & Nessie benar-benar SIAP
|
||||
# dipakai fase berikutnya (Trino, StarRocks, Airflow/Sling, Dremio) — termasuk
|
||||
# saat memakai object storage / Nessie EXISTING (S3_ENDPOINT, NESSIE_URL).
|
||||
#
|
||||
# Yang diperiksa:
|
||||
# A. Object storage : endpoint terjangkau, bucket ada, tulis+baca berhasil
|
||||
# B. Nessie : /api/v2/config, default branch, endpoint Iceberg REST /iceberg/v1/config
|
||||
# C. Version store : database `nessie` + tabel Nessie di PostgreSQL (JDBC2)
|
||||
# D. Kontrak & antarmuka:
|
||||
# D1 (milik Phase 2) : warehouse & endpoint S3 pada Nessie (compose) → FAIL bila salah
|
||||
# D2 (milik fase 3/4/5) : config Trino / Sling / StarRocks selaras → WARN/SKIP saja
|
||||
#
|
||||
# Mode --deep (opsional — menguji KONSUMEN, jadi komponennya harus sudah ada):
|
||||
# E. Trino : baca nyata dari warehouse (SELECT count(*) FROM iceberg.bronze.orders)
|
||||
#
|
||||
# Prinsip gate: hanya memverifikasi kontrak yang DIMILIKI fase ini. Pemeriksaan yang
|
||||
# bergantung pada runtime fase lain (mis. Trino di fase 3) tidak dijalankan kecuali --deep,
|
||||
# supaya gate tidak pernah "hijau karena di-SKIP" atau salah atribusi kegagalan.
|
||||
#
|
||||
# Exit code: 0 = semua gate lolos (WARN/SKIP boleh), 1 = ada FAIL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/verify-phase2-storage.sh
|
||||
# ./scripts/verify-phase2-storage.sh --deep # + uji baca via Trino (fase 3)
|
||||
# S3_ENDPOINT=https://s3.corp.internal NESSIE_URL=https://nessie.corp.internal \
|
||||
# ./scripts/verify-phase2-storage.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
|
||||
|
||||
S3_ENDPOINT="${S3_ENDPOINT:-http://minio:9000}"
|
||||
S3_REGION="${S3_REGION:-us-east-1}"
|
||||
S3_ACCESS_KEY="${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}"
|
||||
S3_SECRET_KEY="${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-}}"
|
||||
BUCKET="${MINIO_BUCKET_ICEBERG_DATA:-iceberg-data}"
|
||||
NESSIE_URL="${NESSIE_URL:-http://nessie:19120}"
|
||||
NESSIE_HOST="${NESSIE_HOST:-localhost}"
|
||||
NESSIE_PORT="${NESSIE_PORT:-12120}"
|
||||
NESSIE_HOST_API="http://${NESSIE_HOST}:${NESSIE_PORT}"
|
||||
NESSIE_DB="${NESSIE_DB:-nessie}"
|
||||
|
||||
# Mode --deep: ikut menguji KONSUMEN (Trino) — komponennya harus sudah ada (fase 3+).
|
||||
DEEP=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--env-file) shift 2 ;; # sudah dipakai sicabe_load_env() di atas
|
||||
--deep) DEEP=1; shift ;;
|
||||
"") break ;;
|
||||
*) echo "Argumen tidak dikenal: $1 (gunakan --deep)"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
MC_IMAGE="quay.io/minio/mc:${MINIO_MC_VERSION:-RELEASE.2025-08-13T08-35-41Z}"
|
||||
TRINO_URL="$(sicabe_local_url https TRINO_HOST "${TRINO_HTTPS_PORT:-12443}" TRINO_URL)"
|
||||
|
||||
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$"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# A. Object storage
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
s3_mc() {
|
||||
# Menjalankan perintah mc di container (image ter-pin) dengan kredensial S3
|
||||
# lewat environment, agar tidak muncul di daftar proses.
|
||||
docker run --rm -i \
|
||||
--network "${SICABE_NETWORK:-sicabe-network}" \
|
||||
-e S3E="${S3_ENDPOINT}" -e S3U="${S3_ACCESS_KEY}" -e S3P="${S3_SECRET_KEY}" \
|
||||
-e BUCKET="${BUCKET}" \
|
||||
--entrypoint sh "${MC_IMAGE}" -c "$1" 2>&1
|
||||
}
|
||||
|
||||
check_object_storage() {
|
||||
echo -e "${BOLD}A. Object storage — ${S3_ENDPOINT} (bucket '${BUCKET}')${NC}"
|
||||
|
||||
if [[ -z "${S3_SECRET_KEY}" ]]; then
|
||||
fail "S3_SECRET_KEY / MINIO_ROOT_PASSWORD kosong — kredensial storage tidak lengkap"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# S3_ENDPOINT umumnya hanya terjangkau dari jaringan Docker (mis.
|
||||
# http://minio:9000) → uji keterjangkauan dilakukan dari container (uji mc).
|
||||
# Di host kita cek port publik MinIO saja (informatif, bukan gate).
|
||||
local host_ep="$(sicabe_local_url http MINIO_HOST "${MINIO_PORT:-12900}")"
|
||||
local host_code
|
||||
host_code=$(curl -sk -o /dev/null -w "%{http_code}" --max-time 8 \
|
||||
"${host_ep}/minio/health/live" 2>/dev/null || true)
|
||||
if [[ "${host_code}" == "200" ]]; then
|
||||
pass "Storage terjangkau dari host: ${host_ep}"
|
||||
else
|
||||
warn "Storage tidak terjangkau dari host (${host_ep}) — normal bila memakai S3 eksternal"
|
||||
fi
|
||||
|
||||
if ! docker image inspect "${MC_IMAGE}" >/dev/null 2>&1; then
|
||||
skip "Image mc '${MC_IMAGE}' belum ada lokal — lewati uji bucket tulis/baca"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local out
|
||||
out=$(s3_mc 'if ! mc alias set probe "$S3E" "$S3U" "$S3P" >/dev/null 2>&1; then
|
||||
echo "mc: $(mc alias set probe "$S3E" "$S3U" "$S3P" 2>&1 | tail -2)"
|
||||
echo ALIAS_FAIL
|
||||
exit 0
|
||||
fi
|
||||
mc ls "probe/$BUCKET" >/dev/null 2>&1 && echo BUCKET_OK || echo BUCKET_MISSING')
|
||||
|
||||
if [[ "${out}" == *ALIAS_FAIL* ]]; then
|
||||
fail "Gagal autentikasi/terjangkau dari container (cek S3_ENDPOINT & kredensial)"
|
||||
warn " keluaran mc: $(echo "${out}" | tr '\n' ' ' | cut -c1-160)"
|
||||
return 1
|
||||
elif [[ "${out}" == *BUCKET_OK* ]]; then
|
||||
pass "Bucket '${BUCKET}' ada dan bisa diakses dari container"
|
||||
else
|
||||
fail "Bucket '${BUCKET}' tidak ada / tidak bisa diakses"
|
||||
return 1
|
||||
fi
|
||||
|
||||
out=$(s3_mc 'mc alias set probe "$S3E" "$S3U" "$S3P" >/dev/null 2>&1
|
||||
echo "verify-$RANDOM" > /tmp/probe.txt
|
||||
if mc cp /tmp/probe.txt "probe/$BUCKET/.verify/probe.txt" >/dev/null 2>&1; then
|
||||
mc cat "probe/$BUCKET/.verify/probe.txt" >/dev/null 2>&1 && echo RW_OK || echo READ_FAIL
|
||||
mc rm "probe/$BUCKET/.verify/probe.txt" >/dev/null 2>&1
|
||||
else
|
||||
echo WRITE_FAIL
|
||||
fi')
|
||||
case "${out}" in
|
||||
*RW_OK*) pass "Uji tulis+baca objek di bucket '${BUCKET}' berhasil" ;;
|
||||
*WRITE_FAIL*) fail "Gagal menulis objek ke bucket '${BUCKET}'" ;;
|
||||
*READ_FAIL*) fail "Gagal membaca kembali objek dari bucket '${BUCKET}'" ;;
|
||||
*) warn "Hasil uji tulis/baca tidak terbaca: ${out}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# B. Nessie
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_nessie() {
|
||||
echo -e "${BOLD}B. Nessie — ${NESSIE_URL} (API host: ${NESSIE_HOST_API})${NC}"
|
||||
|
||||
local cfg
|
||||
cfg=$(curl -sf --max-time 10 "${NESSIE_HOST_API}/api/v2/config" 2>/dev/null || echo "")
|
||||
if [[ -z "${cfg}" ]]; then
|
||||
fail "Nessie REST /api/v2/config tidak merespon di ${NESSIE_HOST_API}"
|
||||
return 1
|
||||
fi
|
||||
pass "Nessie REST v2 aktif"
|
||||
echo "${cfg}" | python3 -c "
|
||||
import sys,json
|
||||
d=json.load(sys.stdin)
|
||||
print(' defaultBranch =', d.get('defaultBranch'))
|
||||
print(' specVersion =', d.get('specVersion'))" 2>/dev/null || true
|
||||
|
||||
local iceberg_cfg
|
||||
iceberg_cfg=$(curl -sf --max-time 10 "${NESSIE_HOST_API}/iceberg/v1/config" 2>/dev/null || echo "")
|
||||
if [[ -n "${iceberg_cfg}" ]]; then
|
||||
pass "Endpoint Iceberg REST (/iceberg) aktif (dipakai Sling & StarRocks)"
|
||||
else
|
||||
fail "Endpoint Iceberg REST ${NESSIE_HOST_API}/iceberg tidak merespon"
|
||||
fi
|
||||
|
||||
local branch
|
||||
branch=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${NESSIE_HOST_API}/api/v2/trees/main" 2>/dev/null || echo "000")
|
||||
if [[ "${branch}" == "200" ]]; then
|
||||
pass "Branch 'main' tersedia"
|
||||
else
|
||||
warn "Branch 'main' tidak merespon 200 (HTTP ${branch}) — katalog mungkin masih kosong"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# C. Version store (PostgreSQL)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_version_store() {
|
||||
echo -e "${BOLD}C. Version store Nessie di PostgreSQL (JDBC2)${NC}"
|
||||
if ! docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^sicabe-postgres$"; then
|
||||
skip "Container sicabe-postgres tidak berjalan — lewati cek version store"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local dbs
|
||||
dbs=$(docker exec sicabe-postgres psql -U "${POSTGRES_USER:-sicabe_admin}" -d postgres -tAc \
|
||||
"SELECT datname FROM pg_database;" 2>/dev/null || echo "")
|
||||
# NB: keluaran psql dipisah baris baru → gunakan grep -x (bukan "* x *").
|
||||
if grep -qx "${NESSIE_DB}" <<< "${dbs}"; then
|
||||
pass "Database '${NESSIE_DB}' ada"
|
||||
else
|
||||
fail "Database '${NESSIE_DB}' tidak ada (dibuat oleh config/postgres/init/01-create-dbs.sh)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tables
|
||||
tables=$(docker exec sicabe-postgres psql -U "${NESSIE_DB_USER:-nessie}" -d "${NESSIE_DB}" -tAc \
|
||||
"SELECT count(*) FROM information_schema.tables WHERE table_schema='nessie';" 2>/dev/null || echo "")
|
||||
if [[ "${tables}" =~ ^[0-9]+$ ]] && [[ "${tables}" -gt 0 ]]; then
|
||||
pass "Schema 'nessie' berisi ${tables} tabel (version store aktif)"
|
||||
else
|
||||
warn "Schema 'nessie' belum berisi tabel — Nessie mungkin belum pernah start"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# D1. Kontrak Phase 2 — warehouse & endpoint S3 pada Nessie (compose)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_storage_contract() {
|
||||
echo -e "${BOLD}D1. Kontrak Phase 2 — warehouse & endpoint S3 Nessie${NC}"
|
||||
|
||||
local cfg wh ep
|
||||
cfg=$(docker compose -f "${SCRIPT_DIR}/docker-compose.yml" --profile all config 2>/dev/null || echo "")
|
||||
if [[ -z "${cfg}" ]]; then
|
||||
fail "Tidak bisa membaca konfigurasi compose"
|
||||
return 1
|
||||
fi
|
||||
|
||||
wh=$(echo "${cfg}" | sed -n 's/.*WAREHOUSE_LOCATION: *//p' | head -1 | tr -d '"')
|
||||
ep=$(echo "${cfg}" | sed -n 's/.*DEFAULT_OPTIONS_ENDPOINT: *//p' | head -1 | tr -d '"')
|
||||
|
||||
if [[ "${wh}" == "s3://${BUCKET}/warehouse" ]]; then
|
||||
pass "Warehouse Nessie → ${wh} (sesuai bucket '${BUCKET}')"
|
||||
elif [[ -n "${wh}" ]]; then
|
||||
fail "Warehouse Nessie '${wh}' ≠ 's3://${BUCKET}/warehouse' — bucket tidak sinkron"
|
||||
else
|
||||
fail "WAREHOUSE_LOCATION Nessie tidak ditemukan di compose"
|
||||
fi
|
||||
|
||||
if [[ "${ep}" == "${S3_ENDPOINT}" ]]; then
|
||||
pass "Endpoint S3 Nessie → ${ep}"
|
||||
elif [[ -n "${ep}" ]]; then
|
||||
fail "Endpoint S3 Nessie '${ep}' ≠ S3_ENDPOINT '${S3_ENDPOINT}'"
|
||||
else
|
||||
warn "DEFAULT_OPTIONS_ENDPOINT Nessie tidak ditemukan di compose"
|
||||
fi
|
||||
|
||||
pass "Region S3: ${S3_REGION}"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# D2. Antarmuka konsumen (Trino/Sling/StarRocks) — milik fase 3/4/5 → WARN/SKIP
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_consumer_interfaces() {
|
||||
echo -e "${BOLD}D2. Antarmuka konsumen — Trino / Sling / StarRocks (informatif, bukan gate)${NC}"
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-trino-coordinator; then
|
||||
skip "Container sicabe-trino-coordinator di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-trino-coordinator; then
|
||||
local props="${CONFIG_DIR}/trino/catalog/iceberg.properties"
|
||||
local prop_bucket prop_endpoint
|
||||
prop_bucket=$(sed -n "s|.*default-warehouse-dir=s3://\([^/]*\)/.*|\1|p" "${props}" 2>/dev/null)
|
||||
prop_endpoint=$(sed -n "s|^s3.endpoint=||p" "${props}" 2>/dev/null)
|
||||
if [[ "${prop_bucket}" == '${ENV:MINIO_BUCKET_ICEBERG_DATA}' || "${prop_bucket}" == "${BUCKET}" ]]; then
|
||||
pass "Trino iceberg.properties → bucket dari env (uji penuh di gate Phase 3)"
|
||||
else
|
||||
warn "Trino iceberg.properties bucket '${prop_bucket:-?}' ≠ '${BUCKET}'"
|
||||
fi
|
||||
if [[ "${prop_endpoint}" == '${ENV:S3_ENDPOINT}' || "${prop_endpoint}" == "${S3_ENDPOINT}" ]]; then
|
||||
pass "Trino iceberg.properties → endpoint dari env"
|
||||
else
|
||||
warn "Trino iceberg.properties masih hardcode endpoint: ${prop_endpoint:-?}"
|
||||
fi
|
||||
else
|
||||
skip "Trino belum berjalan (fase 3) — lewati pemeriksaan antarmukanya"
|
||||
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
|
||||
local sling_file="${CONFIG_DIR}/airflow/sling/env.yaml"
|
||||
if [[ -f "${sling_file}" ]]; then
|
||||
local sling_uri sling_ep
|
||||
sling_uri=$(sed -n 's/^ *rest_uri: *//p' "${sling_file}" | head -1)
|
||||
sling_ep=$(sed -n 's/^ *s3_endpoint: *//p' "${sling_file}" | head -1)
|
||||
if [[ "${sling_uri}" == *nessie* ]]; then
|
||||
pass "Sling env.yaml → rest_uri ${sling_uri}"
|
||||
else
|
||||
warn "Sling env.yaml rest_uri tidak menunjuk Nessie: ${sling_uri:-?}"
|
||||
fi
|
||||
[[ -n "${sling_ep}" ]] && pass "Sling env.yaml → s3_endpoint ${sling_ep}"
|
||||
else
|
||||
skip "config/airflow/sling/env.yaml belum ada (dibuat bootstrap step 6 — fase 5)"
|
||||
fi
|
||||
else
|
||||
skip "Airflow belum berjalan (fase 5) — lewati pemeriksaan antarmuka Sling"
|
||||
fi
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-starrocks-fe; then
|
||||
skip "Container sicabe-starrocks-fe di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-starrocks-fe; then
|
||||
local sr_ep
|
||||
sr_ep=$(grep -m1 -oE "'aws\.s3\.endpoint' = '[^']*'" "${SCRIPT_DIR}/bootstrap.sh" 2>/dev/null |
|
||||
sed "s/.*= '//; s/'//")
|
||||
if [[ -n "${sr_ep}" ]]; then
|
||||
pass "StarRocks s3_volume endpoint → ${sr_ep} (uji penuh di gate Phase 4)"
|
||||
else
|
||||
warn "Tidak bisa membaca endpoint s3_volume StarRocks dari bootstrap.sh"
|
||||
fi
|
||||
else
|
||||
skip "StarRocks belum berjalan (fase 4) — lewati pemeriksaan antarmukanya"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# E. Trino — bukti warehouse bisa dibaca
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_trino_read() {
|
||||
echo -e "${BOLD}E. Trino — baca nyata dari warehouse${NC}"
|
||||
if ! curl -skf --max-time 10 "${TRINO_URL}/v1/info" >/dev/null 2>&1; then
|
||||
skip "Trino belum siap — lewati uji baca"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local result
|
||||
result=$(curl -sk --max-time 20 -X POST "${TRINO_URL}/v1/statement" \
|
||||
-u "svc_seed:${TRINO_SVC_SEED_PASSWORD}" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d "SELECT count(*) FROM iceberg.bronze.orders" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -z "${result}" ]]; then
|
||||
skip "Query Trino tidak menghasilkan respon (service account/permission)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Trino mengembalikan status QUEUED + nextUri → perlu polling sampai FINISHED.
|
||||
local state next tries=0
|
||||
while [[ ${tries} -lt 15 ]]; do
|
||||
state=$(echo "${result}" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('stats',{}).get('state',''))
|
||||
except Exception: print('')" 2>/dev/null)
|
||||
[[ "${state}" == "FINISHED" || "${state}" == "FAILED" ]] && break
|
||||
next=$(echo "${result}" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('nextUri',''))
|
||||
except Exception: print('')" 2>/dev/null)
|
||||
[[ -z "${next}" ]] && break
|
||||
sleep 1
|
||||
result=$(curl -sk --max-time 20 "${next}" 2>/dev/null || echo "")
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
|
||||
if [[ "${state}" == "FINISHED" ]]; then
|
||||
pass "Query 'SELECT count(*) FROM iceberg.bronze.orders' FINISHED (warehouse terbaca)"
|
||||
elif [[ "${state}" == "FAILED" ]]; then
|
||||
warn "Query gagal — kemungkinan tabel bronze.orders belum di-seed (scripts/seed-sample-data.sh)"
|
||||
else
|
||||
skip "Status query belum selesai (${state:-tidak diketahui})"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
print_section "Verifikasi Phase 2 — Kontrak Storage & Catalog (MinIO/S3 + Nessie)"
|
||||
echo -e " Object storage : ${CYAN}${S3_ENDPOINT}${NC} (bucket ${BUCKET}, region ${S3_REGION})"
|
||||
echo -e " Nessie : ${CYAN}${NESSIE_URL}${NC} (API host ${NESSIE_HOST_API})"
|
||||
echo ""
|
||||
|
||||
check_object_storage
|
||||
check_nessie
|
||||
check_version_store
|
||||
check_storage_contract
|
||||
check_consumer_interfaces
|
||||
|
||||
if [[ ${DEEP} -eq 1 ]]; then
|
||||
check_trino_read
|
||||
else
|
||||
echo -e "${INFO} Mode gate fase 2: uji baca via Trino DILEWATI (runtime fase 3) — pakai --deep untuk menjalankannya."
|
||||
fi
|
||||
|
||||
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 storage Phase 2 terpenuhi — aman lanjut ke fase berikutnya.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${ERR} Ada ${FAIL_N} gate yang gagal — perbaiki sebelum melanjutkan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+581
@@ -0,0 +1,581 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Verifikasi Phase 3 (Processing): Trino + OPA
|
||||
#
|
||||
# Gate "kontrak processing": memastikan query engine (Trino) DAN titik keputusan
|
||||
# otorisasi (OPA) benar-benar siap dipakai fase berikutnya (StarRocks, Airflow/dbt,
|
||||
# OpenMetadata) — termasuk saat memakai Trino/OPA EXISTING (bukan container deploy ini).
|
||||
#
|
||||
# Yang diperiksa:
|
||||
# A. Trino — kontrak service : container, node ACTIVE, HTTPS saja, katalog
|
||||
# `iceberg` + `system`, berkas .properties kunci
|
||||
# B. OPA — kebijakan : deny-by-default + uji keputusan LANGSUNG ke policy
|
||||
# (tanpa Trino) untuk 4 peran & batas per-kolom
|
||||
# C. Penegakan Trino↔OPA : bukti izin/penolakan NYATA saat query dijalankan
|
||||
# (SKIP bila tabel probe belum di-seed — fase 5)
|
||||
# D. Authenticator : password.db 4 service account, bcrypt, HTTP 200/401
|
||||
# E. Antarmuka konsumen : dbt / StarRocks / Airflow / OpenMetadata (WARN/SKIP —
|
||||
# artefaknya milik fase 4/5/6, lihat §2.10 dokumen fase)
|
||||
#
|
||||
# Catatan desain:
|
||||
# - OPA menyimpan data ACL di memori (bind-mount read-only) → tidak ada state disk.
|
||||
# - Gate ini TIDAK mengubah apa pun; murni verifikasi.
|
||||
#
|
||||
# Mode --deep: disiapkan untuk penambahan uji lintas-komponen di masa depan
|
||||
# (mis. query nyata dari dbt / Airflow). Saat ini tidak ada pemeriksaan tambahan.
|
||||
#
|
||||
# Exit code: 0 = semua gate lolos (WARN/SKIP boleh), 1 = ada FAIL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/verify-phase3-processing.sh
|
||||
# # menunjuk Trino/OPA existing:
|
||||
# TRINO_URL=https://trino.corp:8443 OPA_URL=http://opa.corp:8181 TRINO_TLS_VERIFY=1 \
|
||||
# ./scripts/verify-phase3-processing.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 yang RELEVAN: di mode host (--env-file) = BUNDLE host (yang di-mount container,
|
||||
# sudah ter-render dgn endpoint host); bukan deploy/config/ sumber. Sejalan dgn
|
||||
# generate_runtime_credentials() yang menulis ke generated/host-<x>/config saat --host.
|
||||
if [[ -n "${SICABE_BUNDLE_DIR:-}" ]]; then
|
||||
CONFIG_DIR="${SICABE_BUNDLE_DIR}/config"
|
||||
else
|
||||
CONFIG_DIR="${SCRIPT_DIR}/config"
|
||||
fi
|
||||
|
||||
TRINO_URL="$(sicabe_local_url https TRINO_HOST "${TRINO_HTTPS_PORT:-12443}" TRINO_URL)"
|
||||
OPA_URL="${OPA_URL:-http://localhost:${OPA_PORT:-12181}}"
|
||||
OPA_POLICY_PATH="${OPA_POLICY_PATH:-/v1/data/trino/allow}"
|
||||
LDAP_URL="${LDAP_URL:-ldap://openldap:389}"
|
||||
|
||||
# User manusia untuk uji ACL lewat jalur OIDC (Keycloak → grup LDAP → OPA).
|
||||
# Jalur ini menguji rantai penuh TANPA memerlukan akun Trino ber-role terbatas.
|
||||
OIDC_ANALYST_USER="${OIDC_ANALYST_USER:-analyst}"
|
||||
OIDC_ANALYST_PASSWORD="${OIDC_ANALYST_PASSWORD:-analyst123}"
|
||||
|
||||
# TLS: dev memakai sertifikat self-signed → -k. Untuk Trino existing dengan
|
||||
# sertifikat valid, set TRINO_TLS_VERIFY=1 agar verifikasi TLS tetap aktif.
|
||||
TRINO_CURL_OPTS=()
|
||||
if [[ "${TRINO_URL}" == https://* && "${TRINO_TLS_VERIFY:-0}" != "1" ]]; then
|
||||
TRINO_CURL_OPTS+=("-k")
|
||||
fi
|
||||
|
||||
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$"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: Trino REST + OPA decision
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# _trino_stmt <sql> [arg curl auth...] → "<state>|<errorName>|<data-json>"
|
||||
# Mengakumulasi `data` lintas-halaman: Trino mengirim hasil bertahap dan halaman
|
||||
# terakhir (FINISHED) sering kosong.
|
||||
_trino_stmt() {
|
||||
local sql="$1"; shift
|
||||
local resp next state tries=0 rows="" err=""
|
||||
resp=$(curl -s "${TRINO_CURL_OPTS[@]}" --max-time 25 -X POST "${TRINO_URL}/v1/statement" \
|
||||
"$@" -H "Content-Type: text/plain" -d "${sql}" 2>/dev/null || echo "")
|
||||
[[ -z "${resp}" ]] && { echo "NO_RESPONSE||"; return 0; }
|
||||
|
||||
while [[ ${tries} -lt 20 ]]; do
|
||||
rows+=$(echo "${resp}" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.dumps(json.load(sys.stdin).get('data') or []))
|
||||
except Exception: pass" 2>/dev/null)
|
||||
state=$(echo "${resp}" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('stats',{}).get('state',''))
|
||||
except Exception: print('')" 2>/dev/null)
|
||||
err=$(echo "${resp}" | python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
print(d['error'].get('errorName','') if d.get('error') else '')
|
||||
except Exception: print('')" 2>/dev/null)
|
||||
[[ "${state}" == "FINISHED" || "${state}" == "FAILED" ]] && break
|
||||
next=$(echo "${resp}" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('nextUri',''))
|
||||
except Exception: print('')" 2>/dev/null)
|
||||
[[ -z "${next}" ]] && break
|
||||
sleep 1
|
||||
resp=$(curl -s "${TRINO_CURL_OPTS[@]}" --max-time 25 "${next}" 2>/dev/null || echo "")
|
||||
tries=$((tries + 1))
|
||||
done
|
||||
echo "${state}|${err}|${rows}"
|
||||
}
|
||||
|
||||
# trino_exec <user> <password> <sql> — HTTP Basic (service account)
|
||||
trino_exec() { _trino_stmt "$3" -u "$1:$2"; }
|
||||
|
||||
# trino_exec_oidc <bearer-token> <sql> — jalur user manusia (OIDC Keycloak)
|
||||
trino_exec_oidc() { _trino_stmt "$2" -H "Authorization: Bearer $1"; }
|
||||
|
||||
# oidc_token <username> <password> → access token (client 'trino', scope openid)
|
||||
oidc_token() {
|
||||
curl -s --max-time 20 -X POST \
|
||||
"$(sicabe_local_url http KEYCLOAK_HOST "${KEYCLOAK_PORT:-12080}" KEYCLOAK_URL)/realms/${SICABE_REALM:-sicabe}/protocol/openid-connect/token" \
|
||||
-d grant_type=password -d client_id=trino -d "client_secret=${TRINO_CLIENT_SECRET}" \
|
||||
-d "username=$1" -d "password=$2" -d scope=openid 2>/dev/null |
|
||||
python3 -c "import sys,json;print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null
|
||||
}
|
||||
|
||||
# trino_http <user> <password> → kode HTTP untuk SELECT 1 (uji authenticator saja)
|
||||
trino_http() {
|
||||
curl -s -o /dev/null -w "%{http_code}" "${TRINO_CURL_OPTS[@]}" --max-time 15 \
|
||||
-X POST "${TRINO_URL}/v1/statement" -u "$1:$2" \
|
||||
-H "Content-Type: text/plain" -d "SELECT 1" 2>/dev/null
|
||||
}
|
||||
|
||||
# opa_decide <input-json> → "true" | "false" | "" (tak terjawab)
|
||||
opa_decide() {
|
||||
curl -sf --max-time 10 -X POST "${OPA_URL}${OPA_POLICY_PATH}" \
|
||||
-H "Content-Type: application/json" -d "$1" 2>/dev/null |
|
||||
python3 -c "import sys,json
|
||||
try:
|
||||
r=json.load(sys.stdin).get('result')
|
||||
print('true' if r is True else 'false' if r is False else '')
|
||||
except Exception: print('')" 2>/dev/null || echo ""
|
||||
}
|
||||
|
||||
# opa_input <op> <catalog> <schema> <table> <columns-json> <user> <groups-json>
|
||||
opa_input() {
|
||||
printf '{"input":{"action":{"operation":"%s","resource":{"table":{"catalogName":"%s","schemaName":"%s","tableName":"%s","columns":%s}}},"context":{"identity":{"user":"%s","groups":%s}}}}' \
|
||||
"$1" "$2" "$3" "$4" "$5" "$6" "$7"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# A. Trino — kontrak service
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_trino_service() {
|
||||
echo -e "${BOLD}A. Trino — kontrak service${NC}"
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-trino-coordinator; then
|
||||
skip "Container sicabe-trino-coordinator di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-trino-coordinator; then
|
||||
pass "Container sicabe-trino-coordinator berjalan"
|
||||
elif [[ "${TRINO_URL}" == *localhost* ]]; then
|
||||
fail "Container sicabe-trino-coordinator tidak berjalan"
|
||||
else
|
||||
skip "Trino dikelola di luar compose ini (${TRINO_URL}) — lewati cek container"
|
||||
fi
|
||||
|
||||
# Worker berdiri sendiri sebagai service (tier `engine`): tanpa worker,
|
||||
# coordinator tidak punya tempat eksekusi task.
|
||||
if ! sicabe_host_expects_container sicabe-trino-worker; then
|
||||
skip "Container sicabe-trino-worker di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-trino-worker; then
|
||||
pass "Container sicabe-trino-worker berjalan"
|
||||
elif [[ "${TRINO_URL}" == *localhost* ]]; then
|
||||
fail "Container sicabe-trino-worker tidak berjalan — task tidak akan dieksekusi"
|
||||
else
|
||||
skip "Trino worker dikelola eksternal — lewati cek container"
|
||||
fi
|
||||
|
||||
# Semantik split: coordinator TIDAK boleh mengeksekusi task.
|
||||
if grep -q "^node-scheduler.include-coordinator=false" \
|
||||
"${CONFIG_DIR}/trino/config-coordinator.properties" 2>/dev/null; then
|
||||
pass "Coordinator tidak mengeksekusi task (node-scheduler.include-coordinator=false)"
|
||||
else
|
||||
fail "config-coordinator.properties: include-coordinator bukan false"
|
||||
fi
|
||||
|
||||
# Bukti fungsional worker terpakai. Dua batasan yang harus dihormati SEKALIGUS:
|
||||
# (a) tabel system.runtime.nodes TIDAK di-whitelist OPA (least privilege), jadi
|
||||
# tidak bisa dipakai untuk melihat daftar node;
|
||||
# (b) gate ini TIDAK boleh bergantung pada data pipeline.
|
||||
# Agregasi membutuhkan stage task, dan karena include-coordinator=false stage itu
|
||||
# hanya bisa dijalankan worker — jadi agregasi atas nilai literal sudah cukup.
|
||||
# Dulu kueri memakai iceberg.gold.orders_gold (dibuat oleh DAG, BUKAN oleh seed),
|
||||
# sehingga instalasi baru gagal dengan pesan menyesatkan "worker tidak mengeksekusi
|
||||
# task" padahal penyebabnya schema `gold` belum ada.
|
||||
local dist st err
|
||||
dist=$(trino_exec "svc_seed" "${TRINO_SVC_SEED_PASSWORD}" \
|
||||
"SELECT count(*) FROM (VALUES 1,2,3) t(x)")
|
||||
IFS='|' read -r st err _ <<< "${dist}"
|
||||
if [[ "${st}" == "FINISHED" ]]; then
|
||||
pass "Query agregasi selesai — worker mengeksekusi task"
|
||||
else
|
||||
fail "Query agregasi '${st}' — worker tidak mengeksekusi task${err:+ (error Trino: ${err})}"
|
||||
fi
|
||||
|
||||
local info
|
||||
info=$(curl -s "${TRINO_CURL_OPTS[@]}" --max-time 15 "${TRINO_URL}/v1/info" 2>/dev/null || echo "")
|
||||
if [[ -z "${info}" ]]; then
|
||||
fail "Trino tidak menjawab di ${TRINO_URL}/v1/info"
|
||||
return 1
|
||||
fi
|
||||
if echo "${info}" | grep -qE '"state":\s*"ACTIVE"'; then
|
||||
pass "Node Trino ACTIVE"
|
||||
else
|
||||
fail "Node Trino tidak ACTIVE: $(echo "${info}" | head -c 120)"
|
||||
fi
|
||||
|
||||
# Trino hanya melayani HTTPS: HTTP polos harus MATI (allow-insecure-over-http=false)
|
||||
local http_code
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 \
|
||||
"http://localhost:${TRINO_PORT:-12081}/v1/info" 2>/dev/null)
|
||||
if [[ "${http_code}" == "000" ]]; then
|
||||
pass "HTTP polos (${TRINO_PORT:-12081}) tidak dilayani — sesuai allow-insecure-over-http=false"
|
||||
else
|
||||
warn "Port HTTP ${TRINO_PORT:-12081} menjawab (HTTP ${http_code}) — pastikan ini memang diinginkan"
|
||||
fi
|
||||
|
||||
# Katalog via service account (HTTP Basic) — bukti authenticator + katalog hidup
|
||||
local res state data
|
||||
res=$(trino_exec "svc_seed" "${TRINO_SVC_SEED_PASSWORD:?}" "SHOW CATALOGS")
|
||||
state="${res%%|*}"; data="${res##*|}"
|
||||
if [[ "${state}" == "FINISHED" && "${data}" == *iceberg* ]]; then
|
||||
pass "Catalog 'iceberg' terdeteksi via service account svc_seed"
|
||||
else
|
||||
fail "Catalog 'iceberg' tidak terverifikasi (state=${state})"
|
||||
fi
|
||||
if [[ "${state}" == "FINISHED" && "${data}" == *system* ]]; then
|
||||
pass "Catalog 'system' terdeteksi"
|
||||
else
|
||||
fail "Catalog 'system' tidak terverifikasi (state=${state})"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# B. OPA — titik keputusan & kebijakan (deny-by-default)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
opa_expect() { # opa_expect <deskripsi> <harapan true|false> <input-json>
|
||||
local got
|
||||
got=$(opa_decide "$3")
|
||||
if [[ "${got}" == "$2" ]]; then
|
||||
pass "$1 → allow=${got}"
|
||||
else
|
||||
fail "$1 → allow=${got:-tak terjawab} (diharapkan $2)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_opa_policy() {
|
||||
echo -e "${BOLD}B. OPA — kebijakan (deny-by-default)${NC}"
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-opa; then
|
||||
skip "Container sicabe-opa di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-opa; then
|
||||
pass "Container sicabe-opa berjalan"
|
||||
elif [[ "${OPA_URL}" == *localhost* ]]; then
|
||||
fail "Container sicabe-opa tidak berjalan"
|
||||
else
|
||||
skip "OPA dikelola di luar compose ini (${OPA_URL}) — lewati cek container"
|
||||
fi
|
||||
|
||||
if [[ -z "$(opa_decide '{"input":{}}')" ]]; then
|
||||
fail "Policy OPA tidak menjawab di ${OPA_URL}${OPA_POLICY_PATH}"
|
||||
return 1
|
||||
fi
|
||||
opa_expect "Deny-by-default (input kosong)" "false" '{"input":{}}'
|
||||
|
||||
local eng='["data-engineer"]' ana='["data-analyst"]'
|
||||
opa_expect "data-admin (superuser) DROPTABLE gold" \
|
||||
"true" "$(opa_input 'DROPTABLE' iceberg gold orders_gold '[]' admin '["data-admin"]')"
|
||||
opa_expect "data-engineer SELECT bronze.orders" \
|
||||
"true" "$(opa_input 'SELECTFROMCOLUMNS' iceberg bronze orders '["id"]' u "$eng")"
|
||||
opa_expect "data-engineer DROPTABLE gold.orders_gold" \
|
||||
"true" "$(opa_input 'DROPTABLE' iceberg gold orders_gold '[]' u "$eng")"
|
||||
opa_expect "data-engineer CREATE SCHEMA" \
|
||||
"true" "$(printf '{"input":{"action":{"operation":"CREATESCHEMA","resource":{}},"context":{"identity":{"user":"u","groups":%s}}}}' "$eng")"
|
||||
opa_expect "data-analyst SELECT gold.orders_gold" \
|
||||
"true" "$(opa_input 'SELECTFROMCOLUMNS' iceberg gold orders_gold '["id"]' u "$ana")"
|
||||
opa_expect "data-analyst SELECT bronze.orders (kolom tak diberi)" \
|
||||
"false" "$(opa_input 'SELECTFROMCOLUMNS' iceberg bronze orders '["id"]' u "$ana")"
|
||||
opa_expect "data-analyst SELECT silver.orders_silver (kolom diizinkan)" \
|
||||
"true" "$(opa_input 'SELECTFROMCOLUMNS' iceberg silver orders_silver '["id","total_value"]' u "$ana")"
|
||||
opa_expect "data-analyst SELECT silver.orders_silver (kolom terlarang)" \
|
||||
"false" "$(opa_input 'SELECTFROMCOLUMNS' iceberg silver orders_silver '["id","order_date"]' u "$ana")"
|
||||
opa_expect "data-analyst SELECT agregat tanpa kolom (count(*))" \
|
||||
"true" "$(opa_input 'SELECTFROMCOLUMNS' iceberg silver orders_silver '[]' u "$ana")"
|
||||
opa_expect "data-analyst DROP TABLE (tanpa hak ddl)" \
|
||||
"false" "$(opa_input 'DROPTABLE' iceberg gold orders_gold '[]' u "$ana")"
|
||||
opa_expect "data-viewer SELECT gold.orders_gold (tanpa grant)" \
|
||||
"false" "$(opa_input 'SELECTFROMCOLUMNS' iceberg gold orders_gold '["id"]' u '["data-viewer"]')"
|
||||
opa_expect "Service account svc_om via data.users (tanpa grup LDAP)" \
|
||||
"true" "$(opa_input 'SELECTFROMCOLUMNS' iceberg gold orders_gold '["id"]' svc_om '[]')"
|
||||
opa_expect "User tak dikenal" \
|
||||
"false" "$(opa_input 'SELECTFROMCOLUMNS' iceberg gold orders_gold '["id"]' ghost '[]')"
|
||||
opa_expect "Metadata information_schema" \
|
||||
"true" "$(opa_input 'SELECTFROMCOLUMNS' system information_schema tables '["table_name"]' ghost '[]')"
|
||||
|
||||
# FilterColumns — operasi yang dipakai Trino untuk memfilter daftar kolom di
|
||||
# information_schema / SHOW COLUMNS. Bila policy tidak memuatnya (deny-by-default),
|
||||
# SEMUA kolom tersembunyi walau SELECT-nya diizinkan → katalog (mis. ingestion
|
||||
# OpenMetadata) melihat tabel TANPA kolom. Batas per-kolom harus tetap dihormati.
|
||||
opa_expect "FilterColumns: data-engineer gold.orders_gold" \
|
||||
"true" "$(opa_input 'FilterColumns' iceberg gold orders_gold '["komoditas"]' u "$eng")"
|
||||
opa_expect "FilterColumns: data-analyst silver kolom diizinkan (id)" \
|
||||
"true" "$(opa_input 'FilterColumns' iceberg silver orders_silver '["id"]' u "$ana")"
|
||||
opa_expect "FilterColumns: data-analyst silver kolom terlarang (order_date)" \
|
||||
"false" "$(opa_input 'FilterColumns' iceberg silver orders_silver '["order_date"]' u "$ana")"
|
||||
|
||||
# Cakupan data ACL (statis — artefak Phase 3)
|
||||
local data_file="${CONFIG_DIR}/opa/data.json"
|
||||
local missing="" r
|
||||
for r in data-admin data-engineer data-analyst data-viewer; do
|
||||
grep -q "\"${r}\"" "${data_file}" 2>/dev/null || missing+=" ${r}"
|
||||
done
|
||||
if [[ -z "${missing}" ]]; then
|
||||
pass "data.json memuat 4 peran (data-admin/engineer/analyst/viewer)"
|
||||
else
|
||||
fail "data.json kehilangan peran:${missing}"
|
||||
fi
|
||||
local svc missing_svc=""
|
||||
for svc in svc_seed svc_dbt svc_airflow svc_om; do
|
||||
grep -q "\"${svc}\"" "${data_file}" 2>/dev/null || missing_svc+=" ${svc}"
|
||||
done
|
||||
if [[ -z "${missing_svc}" ]]; then
|
||||
pass "data.json memetakan 4 service account ke peran"
|
||||
else
|
||||
warn "data.json belum memetakan:${missing_svc} (hanya perlu bila service account tidak ada di LDAP)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# C. Penegakan Trino ↔ OPA (butuh tabel probe → fase 5)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_trino_assert() { # <desc> <auth-label> <state-harapan> <substring-error> <hasil>
|
||||
local desc="$1" auth="$2" want="$3" suberr="$4" res="$5"
|
||||
local state err
|
||||
state="${res%%|*}"; err=$(echo "${res}" | cut -d'|' -f2)
|
||||
if [[ "${state}" == "${want}" ]] && { [[ -z "${suberr}" ]] || [[ "${err}" == *"${suberr}"* ]]; }; then
|
||||
pass "${desc} (state=${state}${err:+, ${err}})"
|
||||
else
|
||||
fail "${desc} → state=${state}${err:+, ${err}} (diharapkan ${want}${suberr:+, ${suberr}}) [${auth}]"
|
||||
fi
|
||||
}
|
||||
|
||||
trino_expect() { # <desc> <user> <pass> <sql> <state> [substring-error]
|
||||
_trino_assert "$1" "basic:$2" "$5" "${6:-}" "$(trino_exec "$2" "$3" "$4")"
|
||||
}
|
||||
|
||||
trino_expect_oidc() { # <desc> <token> <sql> <state> [substring-error]
|
||||
_trino_assert "$1" "oidc" "$4" "${5:-}" "$(trino_exec_oidc "$2" "$3")"
|
||||
}
|
||||
|
||||
check_enforcement() {
|
||||
echo -e "${BOLD}C. Penegakan Trino ↔ OPA (query nyata)${NC}"
|
||||
|
||||
if [[ -z "${TRINO_SVC_OM_PASSWORD:-}" ]]; then
|
||||
skip "TRINO_SVC_OM_PASSWORD tidak di-set — lewati uji penegakan"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tables
|
||||
tables=$(trino_exec "svc_seed" "${TRINO_SVC_SEED_PASSWORD:?}" "SHOW TABLES FROM iceberg.bronze" | tail -1)
|
||||
if [[ "${tables}" != *orders* ]]; then
|
||||
skip "Tabel probe iceberg.bronze.orders belum ada (di-seed fase 5) — lewati uji penegakan"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Positif: pemilik data boleh baca
|
||||
trino_expect "svc_seed boleh baca bronze.orders" svc_seed "${TRINO_SVC_SEED_PASSWORD:?}" \
|
||||
"SELECT count(*) FROM iceberg.bronze.orders" FINISHED
|
||||
|
||||
# Metadata kolom HARUS terlihat: regresi yang pernah terjadi adalah policy tanpa
|
||||
# rule FilterColumns → information_schema/SHOW COLUMNS kosong walau SELECT boleh.
|
||||
local meta_res ncols
|
||||
meta_res=$(trino_exec "svc_seed" "${TRINO_SVC_SEED_PASSWORD:?}" \
|
||||
"SELECT count(*) FROM iceberg.information_schema.columns WHERE table_schema='bronze' AND table_name='orders'")
|
||||
ncols=$(echo "${meta_res##*|}" | grep -oE '[0-9]+' | head -1)
|
||||
if [[ "${ncols}" =~ ^[0-9]+$ ]] && [[ ${ncols} -gt 0 ]]; then
|
||||
pass "Metadata kolom terlihat via Trino (${ncols} kolom di bronze.orders)"
|
||||
else
|
||||
fail "Metadata kolom TIDAK terlihat via Trino (information_schema → '${ncols:-kosong}')"
|
||||
warn " cek rule FilterColumns di config/opa/policies/trino.rego (lihat catatan §5.6)"
|
||||
fi
|
||||
trino_expect "svc_dbt boleh baca bronze.orders" svc_dbt "${TRINO_SVC_DBT_PASSWORD:?}" \
|
||||
"SELECT count(*) FROM iceberg.bronze.orders" FINISHED
|
||||
trino_expect "svc_airflow boleh baca bronze.orders" svc_airflow "${TRINO_SVC_AIRFLOW_PASSWORD:?}" \
|
||||
"SELECT count(*) FROM iceberg.bronze.orders" FINISHED
|
||||
|
||||
# svc_om = identitas AGENT katalog (role data-catalog: baca-saja seluruh tabel).
|
||||
# Positif: boleh baca semua, tetapi TIDAK boleh menulis (least privilege).
|
||||
trino_expect "svc_om (agent katalog) boleh baca bronze.orders" svc_om "${TRINO_SVC_OM_PASSWORD:-}" \
|
||||
"SELECT count(*) FROM iceberg.bronze.orders" FINISHED
|
||||
trino_expect "svc_om DITOLAK menulis (INSERT) di bronze.orders" svc_om "${TRINO_SVC_OM_PASSWORD:-}" \
|
||||
"INSERT INTO iceberg.bronze.orders (id) VALUES (999)" FAILED PERMISSION_DENIED
|
||||
|
||||
# ── Negatif end-to-end lewat jalur USER MANUSIA (OIDC) ────────────────────
|
||||
# Menguji OIDC Keycloak + group provider LDAP + OPA sekaligus; tidak perlu akun
|
||||
# Trino ber-role terbatas hanya untuk pengujian.
|
||||
local oidc_tok
|
||||
oidc_tok="$(oidc_token "${OIDC_ANALYST_USER}" "${OIDC_ANALYST_PASSWORD}")"
|
||||
if [[ -z "${oidc_tok}" ]]; then
|
||||
skip "Tidak dapat token OIDC untuk '${OIDC_ANALYST_USER}' — uji ACL jalur OIDC dilewati"
|
||||
else
|
||||
pass "Token OIDC '${OIDC_ANALYST_USER}' diperoleh (jalur OIDC aktif)"
|
||||
trino_expect_oidc "'${OIDC_ANALYST_USER}' (data-analyst) DITOLAK baca bronze.orders" "${oidc_tok}" \
|
||||
"SELECT count(*) FROM iceberg.bronze.orders" FAILED PERMISSION_DENIED
|
||||
trino_expect_oidc "'${OIDC_ANALYST_USER}' boleh baca kolom yang diizinkan di silver" "${oidc_tok}" \
|
||||
"SELECT id, total_value FROM iceberg.silver.orders_silver" FINISHED
|
||||
trino_expect_oidc "'${OIDC_ANALYST_USER}' DITOLAK SELECT * di silver (batas kolom)" "${oidc_tok}" \
|
||||
"SELECT * FROM iceberg.silver.orders_silver" FAILED PERMISSION_DENIED
|
||||
trino_expect_oidc "'${OIDC_ANALYST_USER}' boleh baca gold.orders_gold" "${oidc_tok}" \
|
||||
"SELECT count(*) FROM iceberg.gold.orders_gold" FINISHED
|
||||
trino_expect_oidc "'${OIDC_ANALYST_USER}' DITOLAK DROP TABLE gold" "${oidc_tok}" \
|
||||
"DROP TABLE iceberg.gold.orders_gold" FAILED PERMISSION_DENIED
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# D. Authenticator service account (password.db)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_authenticator() {
|
||||
echo -e "${BOLD}D. Authenticator service account (password.db)${NC}"
|
||||
|
||||
local pw_file="${CONFIG_DIR}/trino/password.db"
|
||||
if [[ -d "${pw_file}" ]]; then
|
||||
fail "config/trino/password.db berupa DIREKTORI (Docker membuatnya saat bind-mount gagal) — jalankan bootstrap.sh step 1"
|
||||
elif [[ ! -f "${pw_file}" ]]; then
|
||||
fail "config/trino/password.db tidak ada — di-generate bootstrap.sh (butuh htpasswd)"
|
||||
else
|
||||
pass "config/trino/password.db ada (berkas biasa)"
|
||||
local svc missing=""
|
||||
for svc in svc_seed svc_dbt svc_airflow svc_om; do
|
||||
grep -q "^${svc}:" "${pw_file}" || missing+=" ${svc}"
|
||||
done
|
||||
if [[ -z "${missing}" ]]; then
|
||||
pass "4 service account terdaftar: svc_seed, svc_dbt, svc_airflow, svc_om"
|
||||
else
|
||||
fail "password.db kehilangan:${missing}"
|
||||
fi
|
||||
if grep -qE '^\w+:\$2[aby]\$' "${pw_file}"; then
|
||||
pass "Hash bcrypt (\$2a/\$2b/\$2y) terdeteksi"
|
||||
else
|
||||
warn "Format hash tidak dikenali sebagai bcrypt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Uji login nyata (hanya kode HTTP — kata sandi tidak pernah dicetak)
|
||||
local pair u p
|
||||
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
|
||||
u="${pair%%:*}"; p="${pair#*:}"
|
||||
[[ -z "${p}" ]] && continue
|
||||
if [[ "$(trino_http "${u}" "${p}")" == "200" ]]; then
|
||||
pass "Login HTTP Basic ${u} → 200"
|
||||
else
|
||||
fail "Login HTTP Basic ${u} gagal (bukan 200)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$(trino_http "svc_seed" "salah-sekali")" == "401" ]]; then
|
||||
pass "Kata sandi salah → 401"
|
||||
else
|
||||
fail "Kata sandi salah tidak ditolak dengan 401"
|
||||
fi
|
||||
if [[ "$(trino_http "tidak-ada-user" "apa-saja")" == "401" ]]; then
|
||||
pass "User tak dikenal → 401"
|
||||
else
|
||||
fail "User tak dikenal tidak ditolak dengan 401"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# E. Antarmuka konsumen — milik fase 4/5/6 (WARN/SKIP)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_consumer_interfaces() {
|
||||
echo -e "${BOLD}E. Antarmuka konsumen — dbt / StarRocks / Airflow / OpenMetadata (informatif)${NC}"
|
||||
|
||||
local prof="${CONFIG_DIR}/airflow/dbt/profiles.yml"
|
||||
if [[ -f "${prof}" ]]; then
|
||||
local port scheme
|
||||
port=$(sed -n 's/^ *port: *//p' "${prof}" | head -1)
|
||||
scheme=$(sed -n 's/^ *http_scheme: *//p' "${prof}" | head -1)
|
||||
if [[ "${port}" == "8443" && "${scheme}" == "https" ]]; then
|
||||
pass "dbt profiles.yml → https://…:8443 (sesuai HTTPS-only Trino)"
|
||||
else
|
||||
warn "dbt profiles.yml port=${port:-?} scheme=${scheme:-?} — pastikan cocok dengan endpoint Trino"
|
||||
fi
|
||||
else
|
||||
skip "config/airflow/dbt/profiles.yml belum ada"
|
||||
fi
|
||||
|
||||
local props="${CONFIG_DIR}/trino/config-coordinator.properties"
|
||||
if grep -q "internal-communication.shared-secret=trino-internal-secret-dev-only" "${props}" 2>/dev/null; then
|
||||
warn "internal-communication.shared-secret masih nilai DEV — ganti untuk instalasi nyata"
|
||||
else
|
||||
pass "internal-communication.shared-secret tidak memakai nilai dev default"
|
||||
fi
|
||||
if grep -qE '^http-server.https.keystore.path=' "${props}" 2>/dev/null; then
|
||||
warn "keystore.p12 ikut ter-commit (rahasia DEV 'trino_ssl_dev') — regenerate untuk instalasi nyata"
|
||||
fi
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-starrocks-fe; then
|
||||
skip "Container sicabe-starrocks-fe di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-starrocks-fe; then
|
||||
pass "StarRocks berjalan — external catalog Trino diuji di gate Phase 4"
|
||||
else
|
||||
skip "StarRocks belum berjalan (fase 4) — lewati pemeriksaan antarmukanya"
|
||||
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 — koneksi Trino/dbt diuji di gate Phase 5"
|
||||
else
|
||||
skip "Airflow belum berjalan (fase 5) — lewati pemeriksaan antarmukanya"
|
||||
fi
|
||||
if ! sicabe_host_expects_container sicabe-openmetadata; then
|
||||
skip "Container sicabe-openmetadata di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-openmetadata; then
|
||||
pass "OpenMetadata berjalan — konektor Trino diuji di gate Phase 6"
|
||||
else
|
||||
skip "OpenMetadata belum berjalan (fase 6) — lewati pemeriksaan antarmukanya"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
print_section "Verifikasi Phase 3 — Kontrak Processing (Trino + OPA)"
|
||||
echo -e " Trino : ${CYAN}${TRINO_URL}${NC} (HTTPS) · OPA: ${CYAN}${OPA_URL}${NC}"
|
||||
echo -e " Authz : ${CYAN}${OPA_URL}${OPA_POLICY_PATH}${NC} (deny-by-default, ACL di memori)"
|
||||
echo ""
|
||||
|
||||
check_trino_service
|
||||
check_opa_policy
|
||||
check_enforcement
|
||||
check_authenticator
|
||||
check_consumer_interfaces
|
||||
|
||||
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 processing Phase 3 terpenuhi — aman lanjut ke fase berikutnya.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${ERR} Ada ${FAIL_N} gate yang gagal — perbaiki sebelum melanjutkan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+467
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Verifikasi Phase 4 (Serving): StarRocks Shared-Data
|
||||
#
|
||||
# Gate "kontrak serving": memastikan StarRocks (FE + Compute Node) benar-benar
|
||||
# siap dipakai — termasuk saat FE/CN EXISTING (dikelola sistem lain).
|
||||
#
|
||||
# Yang diperiksa:
|
||||
# A. Kontrak service : FE LEADER + Alive, CN Alive, protokol MySQL terjangkau
|
||||
# B. Storage volume : s3_volume — ada, DEFAULT, enabled, location & endpoint S3
|
||||
# sesuai konfigurasi deploy (DESC STORAGE VOLUME)
|
||||
# C. Keamanan : sr_oidc (permitted_groups, redirect_url, issuer, audience,
|
||||
# principal_field) + sr_ldap (ldap_conn_url == LDAP_URL),
|
||||
# role sr_*, activate_all_roles_on_login, dan
|
||||
# authentication_chain yang MEMUAT sr_oidc
|
||||
# D. External catalog : iceberg_catalog (Iceberg via Nessie) + database terbaca
|
||||
# E. Antarmuka konsumen: OpenMetadata/Grafana + konsistensi antar-fase → WARN/SKIP
|
||||
#
|
||||
# Catatan desain (mengikuti §2.10 dokumen fase): hanya memverifikasi kontrak yang
|
||||
# dimiliki fase ini. Bagian E bersifat informatif karena artefaknya milik fase 6.
|
||||
#
|
||||
# ⚠️ `authentication_chain` adalah dynamic config yang HILANG saat FE restart;
|
||||
# gate ini sengaja memeriksanya sebagai FAIL karena tanpa itu login OIDC mati.
|
||||
#
|
||||
# Exit code: 0 = semua gate lolos (WARN/SKIP boleh), 1 = ada FAIL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/verify-phase4-serving.sh
|
||||
# # menunjuk StarRocks existing:
|
||||
# STARROCKS_HOST=starrocks.corp STARROCKS_PORT=9030 \
|
||||
# STARROCKS_ROOT_PASSWORD=... ./scripts/verify-phase4-serving.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
|
||||
|
||||
# STARROCKS_HOST tetap = penanda EXTERNAL (nilai operator; default localhost).
|
||||
STARROCKS_HOST="${STARROCKS_HOST:-localhost}"
|
||||
STARROCKS_PORT="${STARROCKS_PORT:-${STARROCKS_FE_PORT:-12930}}"
|
||||
# Host EFEKTIF untuk koneksi: alamat host pemilik (multi-host) atau override operator.
|
||||
SR_CONNECT="$(sicabe_local_host STARROCKS_FE_HOST STARROCKS_HOST)"
|
||||
SR_USER="${STARROCKS_USER:-root}"
|
||||
SR_PW="${STARROCKS_ROOT_PASSWORD:-}"
|
||||
BUCKET="${MINIO_BUCKET_ICEBERG_DATA:-iceberg-data}"
|
||||
S3_ENDPOINT="${S3_ENDPOINT:-http://minio:9000}"
|
||||
S3_REGION="${S3_REGION:-us-east-1}"
|
||||
LDAP_URL="${LDAP_URL:-ldap://openldap:389}"
|
||||
KC_BASE="$(sicabe_local_url http KEYCLOAK_HOST "${KEYCLOAK_PORT:-12080}" KEYCLOAK_URL)"
|
||||
REALM="${SICABE_REALM:-sicabe}"
|
||||
GROUP_PROVIDER_NAME="${STARROCKS_GROUP_PROVIDER:-sr_ldap}"
|
||||
OIDC_NAME="${STARROCKS_OIDC_INTEGRATION:-sr_oidc}"
|
||||
CATALOG="${STARROCKS_ICEBERG_CATALOG:-iceberg_catalog}"
|
||||
EXPECTED_GROUPS="data-admin,data-engineer,data-analyst,data-viewer"
|
||||
|
||||
# Klien MySQL: pakai host bila ada, jika tidak fallback ke container (tanpa pull
|
||||
# mengejutkan — hanya bila image-nya sudah ada).
|
||||
SR_MYSQL_IMAGE="${SR_MYSQL_IMAGE:-mysql:8}"
|
||||
if command -v mysql >/dev/null 2>&1; then
|
||||
SR_MODE="host"
|
||||
elif docker image inspect "${SR_MYSQL_IMAGE}" >/dev/null 2>&1; then
|
||||
SR_MODE="container"
|
||||
else
|
||||
SR_MODE="none"
|
||||
fi
|
||||
|
||||
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$"
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helper: query StarRocks
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# sr_sql <sql> → tab-separated, tanpa header (untuk parsing)
|
||||
# sr_sql_vertical → format \G (label: value) untuk FE/CN
|
||||
sr_sql() {
|
||||
case "${SR_MODE}" in
|
||||
host)
|
||||
# --protocol=TCP: tanpa ini, -h localhost akan memakai unix socket, bukan port.
|
||||
MYSQL_PWD="${SR_PW}" mysql --protocol=TCP -h "${SR_CONNECT}" -P "${STARROCKS_PORT}" \
|
||||
-u "${SR_USER}" -N -B -e "$1" 2>/dev/null
|
||||
;;
|
||||
container)
|
||||
docker run --rm -i --network "${SICABE_NETWORK:-sicabe-network}" \
|
||||
-e MYSQL_PWD="${SR_PW}" "${SR_MYSQL_IMAGE}" \
|
||||
mysql --protocol=TCP -h "${SR_CONNECT}" -P "${STARROCKS_PORT}" -u "${SR_USER}" \
|
||||
-N -B -e "$1" 2>/dev/null
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
sr_sql_vertical() {
|
||||
case "${SR_MODE}" in
|
||||
host)
|
||||
MYSQL_PWD="${SR_PW}" mysql --protocol=TCP -h "${SR_CONNECT}" -P "${STARROCKS_PORT}" \
|
||||
-u "${SR_USER}" -e "$1\G" 2>/dev/null
|
||||
;;
|
||||
container)
|
||||
docker run --rm -i --network "${SICABE_NETWORK:-sicabe-network}" \
|
||||
-e MYSQL_PWD="${SR_PW}" "${SR_MYSQL_IMAGE}" \
|
||||
mysql --protocol=TCP -h "${SR_CONNECT}" -P "${STARROCKS_PORT}" -u "${SR_USER}" \
|
||||
-e "$1\G" 2>/dev/null
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# A. Kontrak service
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_service() {
|
||||
echo -e "${BOLD}A. Kontrak service StarRocks${NC}"
|
||||
|
||||
local external_expected=0
|
||||
[[ "${STARROCKS_HOST}" != "localhost" && "${STARROCKS_HOST}" != "127.0.0.1" ]] && external_expected=1
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-starrocks-fe; then
|
||||
skip "Container sicabe-starrocks-fe di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-starrocks-fe; then
|
||||
pass "Container sicabe-starrocks-fe berjalan"
|
||||
elif [[ ${external_expected} -eq 1 ]]; then
|
||||
skip "FE dikelola eksternal (${STARROCKS_HOST}:${STARROCKS_PORT}) — lewati cek container"
|
||||
else
|
||||
fail "Container sicabe-starrocks-fe tidak berjalan"
|
||||
fi
|
||||
if ! sicabe_host_expects_container sicabe-starrocks-cn; then
|
||||
skip "Container sicabe-starrocks-cn di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-starrocks-cn; then
|
||||
pass "Container sicabe-starrocks-cn berjalan"
|
||||
elif [[ ${external_expected} -eq 1 ]]; then
|
||||
skip "Compute Node dikelola eksternal — lewati cek container"
|
||||
else
|
||||
fail "Container sicabe-starrocks-cn tidak berjalan"
|
||||
fi
|
||||
|
||||
if [[ "${SR_MODE}" == "none" ]]; then
|
||||
fail "Klien MySQL tidak tersedia (host) & image ${SR_MYSQL_IMAGE} belum ada lokal — semua cek SQL dilewati"
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${SR_PW}" ]]; then
|
||||
fail "STARROCKS_ROOT_PASSWORD kosong — tidak bisa terhubung"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$(sr_sql 'SELECT 1')" != "1" ]]; then
|
||||
fail "Tidak bisa terhubung ke StarRocks di ${STARROCKS_HOST}:${STARROCKS_PORT}"
|
||||
return 1
|
||||
fi
|
||||
pass "Protokol MySQL terjangkau (${STARROCKS_HOST}:${STARROCKS_PORT})"
|
||||
|
||||
local fe
|
||||
fe="$(sr_sql_vertical 'SHOW FRONTENDS')"
|
||||
if [[ "${fe}" == *"Alive: true"* ]]; then
|
||||
pass "FE Alive"
|
||||
else
|
||||
fail "FE tidak Alive"
|
||||
fi
|
||||
if [[ "${fe}" == *"Role: LEADER"* ]]; then
|
||||
pass "FE berperan LEADER"
|
||||
else
|
||||
warn "FE bukan LEADER (cluster multi-FE?)"
|
||||
fi
|
||||
|
||||
local cn
|
||||
cn="$(sr_sql_vertical 'SHOW COMPUTE NODES')"
|
||||
if [[ "${cn}" == *"Alive: true"* ]]; then
|
||||
pass "Compute Node Alive (shared-data)"
|
||||
else
|
||||
fail "Tidak ada Compute Node yang Alive"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# B. Storage volume (shared-data) — inti fase ini
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_storage_volume() {
|
||||
echo -e "${BOLD}B. Storage volume S3 (shared-data)${NC}"
|
||||
|
||||
if ! sr_sql "SHOW STORAGE VOLUMES;" | grep -q "s3_volume"; then
|
||||
fail "Storage volume 's3_volume' tidak ada (jalankan bootstrap.sh --step 5)"
|
||||
return 1
|
||||
fi
|
||||
pass "Storage volume 's3_volume' terdaftar"
|
||||
|
||||
local desc
|
||||
desc="$(sr_sql "DESC STORAGE VOLUME s3_volume;")"
|
||||
# Kolom: Name · Type · IsDefault · Location · Params · Enabled · Comment
|
||||
local is_default location params enabled
|
||||
is_default="$(echo "${desc}" | awk -F'\t' '{print $3}')"
|
||||
location="$(echo "${desc}" | awk -F'\t' '{print $4}')"
|
||||
params="$(echo "${desc}" | awk -F'\t' '{print $5}')"
|
||||
enabled="$(echo "${desc}" | awk -F'\t' '{print $6}')"
|
||||
|
||||
if [[ "${enabled}" == "true" ]]; then
|
||||
pass "s3_volume enabled"
|
||||
else
|
||||
fail "s3_volume tidak enabled ('${enabled}')"
|
||||
fi
|
||||
if [[ "${is_default}" == "true" ]]; then
|
||||
pass "s3_volume = DEFAULT STORAGE VOLUME"
|
||||
else
|
||||
fail "s3_volume bukan default storage volume ('${is_default}')"
|
||||
fi
|
||||
if [[ "${location}" == "s3://${BUCKET}/starrocks/" ]]; then
|
||||
pass "Location → ${location}"
|
||||
else
|
||||
fail "Location '${location}' ≠ 's3://${BUCKET}/starrocks/'"
|
||||
fi
|
||||
|
||||
local ep region path_style
|
||||
ep="$(echo "${params}" | grep -oE '"aws\.s3\.endpoint":"[^"]*"' | sed 's/.*:"//; s/"//')"
|
||||
region="$(echo "${params}" | grep -oE '"aws\.s3\.region":"[^"]*"' | sed 's/.*:"//; s/"//')"
|
||||
path_style="$(echo "${params}" | grep -oE '"aws\.s3\.enable_path_style_access":"[^"]*"' | sed 's/.*:"//; s/"//')"
|
||||
if [[ "${ep}" == "${S3_ENDPOINT}" ]]; then
|
||||
pass "Params aws.s3.endpoint → ${ep}"
|
||||
else
|
||||
fail "Params aws.s3.endpoint '${ep:-tak terbaca}' ≠ S3_ENDPOINT '${S3_ENDPOINT}'"
|
||||
fi
|
||||
if [[ "${region}" == "${S3_REGION}" ]]; then
|
||||
pass "Params aws.s3.region → ${region}"
|
||||
else
|
||||
fail "Params aws.s3.region '${region:-tak terbaca}' ≠ S3_REGION '${S3_REGION}'"
|
||||
fi
|
||||
if [[ "${path_style}" == "true" ]]; then
|
||||
pass "Path-style access aktif (wajib untuk MinIO/S3 non-AWS)"
|
||||
else
|
||||
warn "Path-style access '${path_style:-tak terbaca}' — bisa gagal di MinIO"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# C. Keamanan: OIDC + LDAP group provider + role
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_security() {
|
||||
echo -e "${BOLD}C. Keamanan — OIDC Keycloak + group provider LDAP${NC}"
|
||||
|
||||
local oidc
|
||||
oidc="$(sr_sql "SHOW CREATE SECURITY INTEGRATION ${OIDC_NAME};")"
|
||||
if [[ -z "${oidc}" ]]; then
|
||||
fail "Security integration '${OIDC_NAME}' tidak ada"
|
||||
else
|
||||
pass "Security integration '${OIDC_NAME}' ada"
|
||||
local pat
|
||||
for pat in "client_id\" = \"starrocks" "principal_field\" = \"preferred_username" \
|
||||
"group_provider\" = \"${GROUP_PROVIDER_NAME}\"" "required_audience\" = \"starrocks" \
|
||||
"required_issuer\" = \"${KC_BASE}/realms/${REALM}\""; do
|
||||
if [[ "${oidc}" == *"${pat}"* ]]; then
|
||||
pass " ${OIDC_NAME}: $(echo "${pat}" | sed 's/\\"//g')"
|
||||
else
|
||||
fail " ${OIDC_NAME}: tidak memuat ${pat} (cek bootstrap.sh --step 5)"
|
||||
fi
|
||||
done
|
||||
if [[ "${oidc}" == *"redirect_url\" = \"http://localhost:${STARROCKS_HTTP_PORT:-12030}/api/oauth2\""* ]]; then
|
||||
pass " ${OIDC_NAME}: redirect_url → http://localhost:${STARROCKS_HTTP_PORT:-12030}/api/oauth2"
|
||||
else
|
||||
warn " ${OIDC_NAME}: redirect_url tidak sesuai STARROCKS_HTTP_PORT (${STARROCKS_HTTP_PORT:-12030})"
|
||||
fi
|
||||
local g miss="" groups
|
||||
IFS=',' read -ra groups <<< "${EXPECTED_GROUPS}"
|
||||
for g in "${groups[@]}"; do
|
||||
[[ "${oidc}" == *"${g}"* ]] || miss+=" ${g}"
|
||||
done
|
||||
if [[ -z "${miss}" ]]; then
|
||||
pass " permitted_groups memuat 4 grup (${EXPECTED_GROUPS})"
|
||||
else
|
||||
fail " permitted_groups kehilangan:${miss}"
|
||||
fi
|
||||
fi
|
||||
|
||||
local ldap
|
||||
ldap="$(sr_sql "SHOW CREATE GROUP PROVIDER ${GROUP_PROVIDER_NAME};")"
|
||||
if [[ -z "${ldap}" ]]; then
|
||||
fail "Group provider '${GROUP_PROVIDER_NAME}' tidak ada"
|
||||
else
|
||||
pass "Group provider '${GROUP_PROVIDER_NAME}' ada"
|
||||
if [[ "${ldap}" == *"\"ldap_conn_url\" = \"${LDAP_URL}\""* ]]; then
|
||||
pass " ldap_conn_url → ${LDAP_URL}"
|
||||
else
|
||||
fail " ldap_conn_url tidak menunjuk LDAP_URL (${LDAP_URL}) — cek bootstrap.sh --step 5"
|
||||
fi
|
||||
if [[ "${ldap}" == *"\"ldap_group_identifier_attr\" = \"cn\""* ]]; then
|
||||
pass " group identifier = cn (cocok dengan grup LDAP data-*)"
|
||||
else
|
||||
fail " ldap_group_identifier_attr bukan 'cn'"
|
||||
fi
|
||||
if [[ "${ldap}" == *"***"* ]]; then
|
||||
pass " bind password tersimpan (disamarkan oleh StarRocks)"
|
||||
fi
|
||||
fi
|
||||
|
||||
local roles missing=""
|
||||
roles="$(sr_sql "SHOW ROLES;")"
|
||||
local r
|
||||
for r in sr_admin sr_engineer sr_analyst sr_viewer; do
|
||||
grep -q "^${r} " <<< "${roles}" || missing+=" ${r}"
|
||||
done
|
||||
if [[ -z "${missing}" ]]; then
|
||||
pass "Role sr_admin/sr_engineer/sr_analyst/sr_viewer ada"
|
||||
else
|
||||
fail "Role hilang:${missing}"
|
||||
fi
|
||||
|
||||
if [[ "$(sr_sql "SHOW VARIABLES LIKE 'activate_all_roles_on_login';" | awk -F'\t' '{print $2}')" == "true" ]]; then
|
||||
pass "activate_all_roles_on_login = true"
|
||||
else
|
||||
fail "activate_all_roles_on_login bukan true"
|
||||
fi
|
||||
|
||||
# ⚠️ Dynamic config — hilang saat FE restart. Tanpa ini login OIDC mati meski
|
||||
# integration-nya ada.
|
||||
local chain
|
||||
chain="$(sr_sql "ADMIN SHOW FRONTEND CONFIG LIKE 'authentication_chain';" | awk -F'\t' '{print $3}')"
|
||||
if [[ "${chain}" == *"${OIDC_NAME}"* ]]; then
|
||||
pass "authentication_chain memuat ${OIDC_NAME} (${chain})"
|
||||
else
|
||||
fail "authentication_chain = ${chain:-tak terbaca} TIDAK memuat ${OIDC_NAME} → login OIDC mati"
|
||||
warn " perbaiki: ./bootstrap.sh --step 5 (dynamic config hilang setelah FE restart)"
|
||||
fi
|
||||
|
||||
# Pemetaan role → EXTERNAL GROUP: tidak dapat diintrospeksi tanpa login OIDC.
|
||||
local app
|
||||
app="$(sr_sql "SELECT GRANTEE FROM information_schema.applicable_roles WHERE GRANTEE LIKE 'data-%';")"
|
||||
if [[ -n "${app}" ]]; then
|
||||
pass "GRANT role → EXTERNAL GROUP terlihat untuk grup: $(echo "${app}" | tr '\n' ' ')"
|
||||
else
|
||||
skip "Pemetaan role→EXTERNAL GROUP tidak terlihat (baru tampil setelah login OIDC) — diterapkan bootstrap step 5"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# D. External catalog Iceberg via Nessie
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_catalog() {
|
||||
echo -e "${BOLD}D. External catalog Iceberg → Nessie${NC}"
|
||||
|
||||
if sr_sql "SHOW CATALOGS;" | grep -q "^${CATALOG}"; then
|
||||
pass "Catalog '${CATALOG}' terdaftar"
|
||||
else
|
||||
fail "Catalog '${CATALOG}' tidak ada (jalankan bootstrap.sh --step 5)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local dbs
|
||||
dbs="$(sr_sql "SHOW DATABASES FROM ${CATALOG};")"
|
||||
if [[ -z "${dbs}" ]]; then
|
||||
fail "Tidak bisa membaca database dari ${CATALOG} (Nessie tidak terjangkau?)"
|
||||
return 1
|
||||
fi
|
||||
pass "Database terbaca dari ${CATALOG}: $(echo "${dbs}" | tr '\n' ' ')"
|
||||
|
||||
# Query nyata — tabel berasal dari seed fase 5, jadi SKIP (bukan FAIL) bila belum ada.
|
||||
local t state n
|
||||
for t in bronze.orders silver.orders_silver gold.orders_gold; do
|
||||
n="$(sr_sql "SELECT count(*) FROM ${CATALOG}.${t};" 2>/dev/null | head -1)"
|
||||
if [[ "${n}" =~ ^[0-9]+$ ]]; then
|
||||
pass "SELECT count(*) FROM ${CATALOG}.${t} → ${n} baris"
|
||||
elif [[ -z "${n}" ]]; then
|
||||
skip "Tabel ${CATALOG}.${t} belum ada (di-seed fase 5)"
|
||||
else
|
||||
warn "Query ${CATALOG}.${t} → '$(echo "${n}" | cut -c1-70)'"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# E. Antarmuka konsumen — milik fase 6 (WARN/SKIP)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_consumer_interfaces() {
|
||||
echo -e "${BOLD}E. Antarmuka konsumen & konsistensi antar-fase (informatif)${NC}"
|
||||
|
||||
# Konsistensi statis: SQL s3_volume di bootstrap harus memakai S3_* params.
|
||||
local bs="${SCRIPT_DIR}/bootstrap.sh"
|
||||
if grep -q "aws.s3.endpoint' = '\${S3_ENDPOINT" "${bs}" 2>/dev/null; then
|
||||
pass "bootstrap.sh: s3_volume memakai \${S3_ENDPOINT} (siap S3 existing)"
|
||||
else
|
||||
warn "bootstrap.sh: 'aws.s3.endpoint' tidak memakai \${S3_ENDPOINT} — sulit diarahkan ke S3 existing"
|
||||
fi
|
||||
|
||||
# permitted_groups (fase 4) ⊆ roles di ACL OPA (fase 3) — artefak lintas fase.
|
||||
local opa_roles="" g
|
||||
if [[ -f "${CONFIG_DIR}/opa/data.json" ]]; then
|
||||
opa_roles="$(grep -oE '"(data-[a-z]+)"' "${CONFIG_DIR}/opa/data.json" | tr -d '"' | sort -u | tr '\n' ' ')"
|
||||
local miss="" groups
|
||||
IFS=',' read -ra groups <<< "${EXPECTED_GROUPS}"
|
||||
for g in "${groups[@]}"; do
|
||||
[[ " ${opa_roles} " == *" ${g} "* ]] || miss+=" ${g}"
|
||||
done
|
||||
if [[ -z "${miss}" ]]; then
|
||||
pass "permitted_groups StarRocks ⊇ kunci roles di config/opa/data.json"
|
||||
else
|
||||
warn "Grup StarRocks tanpa padanan di data.json:${miss}"
|
||||
fi
|
||||
else
|
||||
skip "config/opa/data.json tidak ada — lewati konsistensi antar-fase"
|
||||
fi
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-openmetadata; then
|
||||
skip "Container sicabe-openmetadata di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-openmetadata; then
|
||||
pass "OpenMetadata berjalan — konektor StarRocks diuji di gate Phase 6"
|
||||
else
|
||||
skip "OpenMetadata belum berjalan (fase 6) — lewati pemeriksaan antarmukanya"
|
||||
fi
|
||||
if ! sicabe_host_expects_container sicabe-grafana; then
|
||||
skip "Container sicabe-grafana di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-grafana; then
|
||||
pass "Grafana berjalan — datasource StarRocks diuji di gate Phase 6"
|
||||
else
|
||||
skip "Grafana belum berjalan (fase 6) — lewati pemeriksaan antarmukanya"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
print_section "Verifikasi Phase 4 — Kontrak Serving (StarRocks shared-data)"
|
||||
echo -e " FE : ${CYAN}${STARROCKS_HOST}:${STARROCKS_PORT}${NC} (root) · klien MySQL: ${SR_MODE}"
|
||||
echo -e " Volume : ${CYAN}s3_volume${NC} → s3://${BUCKET}/starrocks/ @ ${S3_ENDPOINT} (${S3_REGION})"
|
||||
echo -e " Catalog: ${CYAN}${CATALOG}${NC} (Iceberg via Nessie) · OIDC + ${GROUP_PROVIDER_NAME}"
|
||||
echo ""
|
||||
|
||||
check_service
|
||||
check_storage_volume
|
||||
check_security
|
||||
check_catalog
|
||||
check_consumer_interfaces
|
||||
|
||||
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 serving Phase 4 terpenuhi — aman lanjut ke fase berikutnya.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${ERR} Ada ${FAIL_N} gate yang gagal — perbaiki sebelum melanjutkan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+398
@@ -0,0 +1,398 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# eBdesk SiCABE — Verifikasi Phase 5 (Ingestion): Airflow
|
||||
#
|
||||
# Gate "kontrak ingestion": memastikan Airflow siap menjalankan pipeline —
|
||||
# termasuk saat Airflow EXISTING (dikelola sistem lain).
|
||||
#
|
||||
# Yang diperiksa:
|
||||
# A. Kontrak service : webserver + scheduler + dag-processor, health API
|
||||
# (metadatabase & scheduler), airflow-init pernah jalan
|
||||
# B. DAG : tanpa import error, sicabe_batch_daily ada & aktif,
|
||||
# 4 task sesuai kontrak pipeline
|
||||
# C. Connections : minio_s3 (endpoint/kredensial) DAN trino_default yang
|
||||
# BENAR-BENAR DAPAT DIPAKAI (diuji konek nyata) — utang
|
||||
# dari review Fase 3 (§7.6)
|
||||
# D. Sumber & aset : sicabe_data.public.orders (sumber DAG), sling/env.yaml,
|
||||
# DBT_TRINO_* untuk service Airflow
|
||||
# E. Temuan/antarmuka: berkas yatim connections.sh, docstring DAG basi, dan
|
||||
# validasi GE yang menunjuk tabel sumber → WARN; consumer
|
||||
# fase 6 → SKIP
|
||||
#
|
||||
# Catatan: setiap koneksi diuji dengan PARAMETER TERSIMPAN-nya (bukan asumsi),
|
||||
# dan kata sandi tidak pernah dicetak / tidak lewat argumen proses (via stdin).
|
||||
#
|
||||
# Exit code: 0 = semua gate lolos (WARN/SKIP boleh), 1 = ada FAIL.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/verify-phase5-ingestion.sh
|
||||
# AIRFLOW_HOST=airflow.corp AIRFLOW_PORT=8080 ./scripts/verify-phase5-ingestion.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
|
||||
|
||||
AIRFLOW_HOST="${AIRFLOW_HOST:-localhost}"
|
||||
AIRFLOW_PORT="${AIRFLOW_PORT:-12082}"
|
||||
AIRFLOW_URL="http://$(sicabe_local_host AIRFLOW_WEBSERVER_HOST AIRFLOW_HOST):${AIRFLOW_PORT}"
|
||||
BATCH_DAG="${AIRFLOW_BATCH_DAG:-sicabe_batch_daily}"
|
||||
CDC_DAG="${AIRFLOW_CDC_DAG:-sicabe_cdc_realtime}"
|
||||
S3_ENDPOINT="${S3_ENDPOINT:-http://minio:9000}"
|
||||
BUCKET="${MINIO_BUCKET_ICEBERG_DATA:-iceberg-data}"
|
||||
EXPECTED_TASKS="bronze_to_silver ingest_to_bronze silver_to_gold validate_gold"
|
||||
WEB_CONTAINER="sicabe-airflow-webserver"
|
||||
AIRFLOW_EXTERNAL=0
|
||||
if [[ "${AIRFLOW_HOST}" != "localhost" && "${AIRFLOW_HOST}" != "127.0.0.1" ]]; then
|
||||
AIRFLOW_EXTERNAL=1
|
||||
fi
|
||||
|
||||
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$"
|
||||
}
|
||||
|
||||
af_cli() { docker exec "${WEB_CONTAINER}" airflow "$@" 2>/dev/null; }
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# A. Kontrak service
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_service() {
|
||||
echo -e "${BOLD}A. Kontrak service Airflow${NC}"
|
||||
|
||||
local c runtime=""
|
||||
for c in "${WEB_CONTAINER}" sicabe-airflow-scheduler sicabe-airflow-dag-processor; do
|
||||
if ! sicabe_host_expects_container "${c}"; then
|
||||
skip "Container ${c} di host lain (mode host) - dilewati"
|
||||
elif container_running "${c}"; then
|
||||
runtime+=" ${c}"
|
||||
fi
|
||||
done
|
||||
if [[ -n "${runtime}" ]]; then
|
||||
pass "Container berjalan:${runtime}"
|
||||
fi
|
||||
if [[ ${AIRFLOW_EXTERNAL} -eq 1 ]]; then
|
||||
skip "Airflow dikelola eksternal (${AIRFLOW_HOST}:${AIRFLOW_PORT}) — uji container dilewati"
|
||||
else
|
||||
for c in "${WEB_CONTAINER}" sicabe-airflow-scheduler sicabe-airflow-dag-processor; do
|
||||
if ! sicabe_host_expects_container "${c}"; then
|
||||
skip "Container ${c} di host lain (mode host) - dilewati"
|
||||
else
|
||||
container_running "${c}" || fail "Container ${c} tidak berjalan"
|
||||
fi
|
||||
done
|
||||
if docker ps -a --format "{{.Names}}" 2>/dev/null | grep -q "^sicabe-airflow-init$"; then
|
||||
pass "Container one-shot sicabe-airflow-init ada (db migrate + users create)"
|
||||
else
|
||||
warn "Container sicabe-airflow-init tidak ditemukan — metadata DB mungkin belum di-migrate"
|
||||
fi
|
||||
fi
|
||||
|
||||
local health
|
||||
health="$(curl -sf --max-time 15 "${AIRFLOW_URL}/api/v2/monitor/health" 2>/dev/null)"
|
||||
if [[ -z "${health}" ]]; then
|
||||
fail "Health API tidak menjawab di ${AIRFLOW_URL}/api/v2/monitor/health"
|
||||
return 1
|
||||
fi
|
||||
local meta sched
|
||||
meta="$(echo "${health}" | python3 -c "import json,sys;print(json.load(sys.stdin).get('metadatabase',{}).get('status',''))" 2>/dev/null)"
|
||||
sched="$(echo "${health}" | python3 -c "import json,sys;print(json.load(sys.stdin).get('scheduler',{}).get('status',''))" 2>/dev/null)"
|
||||
if [[ "${meta}" == "healthy" ]]; then
|
||||
pass "metadatabase.status = healthy"
|
||||
else
|
||||
fail "metadatabase.status = '${meta:-tak terbaca}' (harus healthy)"
|
||||
fi
|
||||
if [[ "${sched}" == "healthy" ]]; then
|
||||
pass "scheduler.status = healthy"
|
||||
else
|
||||
warn "scheduler.status = '${sched:-tak terbaca}'"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# B. DAG
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_dags() {
|
||||
echo -e "${BOLD}B. DAG pipeline${NC}"
|
||||
|
||||
if [[ ${AIRFLOW_EXTERNAL} -eq 1 ]]; then
|
||||
skip "Airflow eksternal — pemeriksaan DAG via CLI dilewati (gunakan UI/API di sana)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local errs
|
||||
errs="$(af_cli dags list-import-errors)"
|
||||
if [[ "${errs}" == *"No data found"* || -z "${errs}" ]]; then
|
||||
pass "Tidak ada DAG import error"
|
||||
else
|
||||
fail "Ada DAG import error: $(echo "${errs}" | tr '\n' ' ' | cut -c1-120)"
|
||||
fi
|
||||
|
||||
local list
|
||||
list="$(af_cli dags list)"
|
||||
if [[ "${list}" == *"${BATCH_DAG}"* ]]; then
|
||||
pass "DAG '${BATCH_DAG}' ada"
|
||||
else
|
||||
fail "DAG '${BATCH_DAG}' tidak ditemukan — sudah disalin? (bootstrap.sh --step 6)"
|
||||
fi
|
||||
if [[ "${list}" == *"${CDC_DAG}"* ]]; then
|
||||
pass "DAG '${CDC_DAG}' ada (opsional, default paused)"
|
||||
else
|
||||
warn "DAG '${CDC_DAG}' tidak ditemukan"
|
||||
fi
|
||||
# Baris DAG pada `dags list`: "<dag_id> | <fileloc> | <owner> | <is_paused> | ..."
|
||||
local row
|
||||
row="$(echo "${list}" | grep -m1 "^${BATCH_DAG} " || true)"
|
||||
if [[ "${row}" == *"| False"* ]]; then
|
||||
pass "DAG '${BATCH_DAG}' aktif (is_paused=False)"
|
||||
else
|
||||
# Gate ini SENGAJA read-only: ia melaporkan, tidak memperbaiki. Pengaktifan DAG
|
||||
# dilakukan di bootstrap.sh step 6 (skrip yang memang memiliki DAG-nya), supaya
|
||||
# hasil verifikasi tidak bergantung pada apakah gate pernah dijalankan.
|
||||
warn "DAG '${BATCH_DAG}' paused — pipeline harian tidak akan jalan. Aktifkan: docker exec sicabe-airflow-webserver airflow dags unpause ${BATCH_DAG}"
|
||||
fi
|
||||
|
||||
local tasks
|
||||
tasks="$(af_cli tasks list "${BATCH_DAG}" | tr '\n' ' ' | tr -s ' ' | sed 's/ $//')"
|
||||
local t missing=""
|
||||
for t in ${EXPECTED_TASKS}; do
|
||||
[[ " ${tasks} " == *" ${t} "* ]] || missing+=" ${t}"
|
||||
done
|
||||
if [[ -z "${missing}" ]]; then
|
||||
pass "4 task sesuai kontrak: ${EXPECTED_TASKS}"
|
||||
else
|
||||
fail "Task hilang di '${BATCH_DAG}':${missing}"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# C. Connections — inti utang review Fase 3
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_connections() {
|
||||
echo -e "${BOLD}C. Connections (dipakai DAG user/operator)${NC}"
|
||||
|
||||
if [[ ${AIRFLOW_EXTERNAL} -eq 1 ]]; then
|
||||
skip "Airflow eksternal — pemeriksaan connection via CLI dilewati"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local json
|
||||
json="$(af_cli connections export -)"
|
||||
if [[ -z "${json}" ]]; then
|
||||
fail "Tidak bisa membaca connections (airflow connections export)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── minio_s3 ──────────────────────────────────────────────────────────────
|
||||
local m_ep m_login expected_login
|
||||
m_ep="$(echo "${json}" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
c=json.load(sys.stdin)['minio_s3']; print(json.loads(c.get('extra') or '{}').get('endpoint_url',''))
|
||||
except Exception: print('')" 2>/dev/null)"
|
||||
m_login="$(echo "${json}" | python3 -c "
|
||||
import json,sys
|
||||
try: print(json.load(sys.stdin)['minio_s3'].get('login') or '')
|
||||
except Exception: print('')" 2>/dev/null)"
|
||||
if [[ "${json}" != *'"minio_s3"'* ]]; then
|
||||
fail "Connection 'minio_s3' tidak ada (bootstrap.sh --step 6)"
|
||||
elif [[ "${m_ep}" == "${S3_ENDPOINT}" ]]; then
|
||||
pass "minio_s3: endpoint_url = ${m_ep}"
|
||||
else
|
||||
fail "minio_s3: endpoint_url '${m_ep:-kosong}' ≠ S3_ENDPOINT '${S3_ENDPOINT}'"
|
||||
fi
|
||||
# Kredensial harus mengikuti S3_ACCESS_KEY (fallback saat-pakai), bukan MINIO_ROOT_USER
|
||||
# — kalau tidak, salah saat memakai S3 existing (§2.9).
|
||||
expected_login="${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}"
|
||||
if [[ -z "${m_login}" ]]; then
|
||||
fail "minio_s3: login/kredensial kosong"
|
||||
elif [[ "${m_login}" == "${expected_login}" ]]; then
|
||||
pass "minio_s3: kredensial sesuai S3 aktif (login '${m_login}', password disamarkan)"
|
||||
else
|
||||
fail "minio_s3: login '${m_login}' ≠ kredensial S3 aktif '${expected_login}' — salah saat S3 existing"
|
||||
warn " perbaiki: bootstrap.sh --step 6 (memakai \${S3_ACCESS_KEY} dengan fallback \${MINIO_ROOT_USER})"
|
||||
fi
|
||||
|
||||
# ── trino_default — SENGAJA tidak disediakan (keputusan review Fase 5) ────
|
||||
# Tidak ada DAG yang memakainya (task memakai dbt HTTPS 8443 + Sling), dan versi
|
||||
# lamanya menunjuk http://trino:8080 tanpa kredensial → selalu 403.
|
||||
if [[ "${json}" == *'"trino_default"'* ]]; then
|
||||
local t_parsed
|
||||
t_parsed="$(echo "${json}" | python3 -c "
|
||||
import json,sys
|
||||
try:
|
||||
c=json.load(sys.stdin)['trino_default']
|
||||
print('{0}:{1}'.format(c.get('host') or '', c.get('port') or ''))
|
||||
except Exception: print('')" 2>/dev/null)"
|
||||
fail "Connection 'trino_default' masih ada (${t_parsed}) — sudah diputuskan TIDAK disediakan"
|
||||
warn " hapus: docker exec sicabe-airflow-webserver airflow connections delete trino_default"
|
||||
else
|
||||
pass "tidak ada connection 'trino_default' (sesuai keputusan — DAG memakai dbt + Sling)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# D. Sumber data & aset pipeline
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_source_and_assets() {
|
||||
echo -e "${BOLD}D. Sumber data & aset pipeline${NC}"
|
||||
|
||||
# Sumber DAG: sicabe_data.public.orders (di-seed bootstrap step 6)
|
||||
if ! sicabe_host_expects_container sicabe-postgres; then
|
||||
skip "Container sicabe-postgres di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-postgres; then
|
||||
local n
|
||||
n="$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD:-}" sicabe-postgres \
|
||||
psql -U "${POSTGRES_USER:-sicabe_admin}" -d sicabe_data -tAc \
|
||||
"SELECT count(*) FROM public.orders" 2>/dev/null || echo "")"
|
||||
if [[ "${n}" =~ ^[0-9]+$ ]] && [[ ${n} -ge 1 ]]; then
|
||||
pass "Sumber sicabe_data.public.orders → ${n} baris"
|
||||
elif [[ -z "${n}" ]]; then
|
||||
fail "sicabe_data.public.orders tidak ada / tidak terbaca (jalankan scripts/seed-source-postgres.sh)"
|
||||
else
|
||||
fail "sicabe_data.public.orders kosong (0 baris) — task ingest_to_bronze tidak akan menghasilkan data"
|
||||
fi
|
||||
else
|
||||
skip "Container sicabe-postgres tidak berjalan — lewati cek sumber contoh"
|
||||
fi
|
||||
|
||||
# Aset kredensial runtime Sling
|
||||
local sling="${CONFIG_DIR}/airflow/sling/env.yaml"
|
||||
if [[ -f "${sling}" ]]; then
|
||||
pass "config/airflow/sling/env.yaml ada"
|
||||
local key missing=""
|
||||
for key in POSTGRES_DB ICEBERG rest_uri s3_endpoint; do
|
||||
grep -q "${key}" "${sling}" || missing+=" ${key}"
|
||||
done
|
||||
if [[ -z "${missing}" ]]; then
|
||||
pass " memuat koneksi POSTGRES_DB + ICEBERG (rest_uri & s3_endpoint)"
|
||||
else
|
||||
fail " env.yaml kehilangan:${missing} — generate ulang (bootstrap.sh / generate_runtime_credentials)"
|
||||
fi
|
||||
else
|
||||
fail "config/airflow/sling/env.yaml tidak ada — task ingest_to_bronze akan gagal"
|
||||
fi
|
||||
|
||||
# dbt → Trino: env DBT_TRINO_* harus SAMPAI ke container Airflow yang mengeksekusi
|
||||
# task. Dicek dari ENV CONTAINER — bukan hasil render `docker compose config`.
|
||||
# Alasan: (a) env container adalah yang benar-benar dipakai task dbt; (b) cek lama
|
||||
# pernah salah lapor (19 PASS/1 FAIL, lalu 20/0 pada 3 pengulangan) sementara
|
||||
# rendernya sendiri selalu memuat key itu → mekanisme render tidak dapat dipercaya
|
||||
# sebagai bukti. `docker compose ... 2>/dev/null` juga menelan pesan error compose.
|
||||
if [[ ${AIRFLOW_EXTERNAL} -eq 1 ]]; then
|
||||
skip "Airflow eksternal (${AIRFLOW_HOST}:${AIRFLOW_PORT}) — cek env DBT_TRINO_* dilewati"
|
||||
elif ! container_running sicabe-airflow-scheduler; then
|
||||
fail "Container sicabe-airflow-scheduler tidak berjalan — env DBT_TRINO_* tidak dapat diverifikasi"
|
||||
else
|
||||
local dbt_user dbt_pw
|
||||
dbt_user="$(docker exec sicabe-airflow-scheduler printenv DBT_TRINO_USER 2>/dev/null || true)"
|
||||
dbt_pw="$(docker exec sicabe-airflow-scheduler printenv DBT_TRINO_PASSWORD 2>/dev/null || true)"
|
||||
if [[ -n "${dbt_user}" ]]; then
|
||||
pass "Aset dbt: DBT_TRINO_USER='${dbt_user}' ada di env container Airflow"
|
||||
else
|
||||
fail "DBT_TRINO_USER tidak ada di env container Airflow — task dbt tidak bisa autentikasi"
|
||||
fi
|
||||
if [[ -n "${dbt_pw}" ]]; then
|
||||
pass "Aset dbt: DBT_TRINO_PASSWORD ada di env container Airflow (nilai disamarkan)"
|
||||
else
|
||||
fail "DBT_TRINO_PASSWORD tidak ada di env container Airflow"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# E. Temuan statis & antarmuka konsumen (WARN/SKIP)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
check_findings_and_consumers() {
|
||||
echo -e "${BOLD}E. Temuan statis & antarmuka konsumen (informatif)${NC}"
|
||||
|
||||
local orphan="${CONFIG_DIR}/airflow/connections.sh"
|
||||
if [[ -f "${orphan}" ]]; then
|
||||
warn "config/airflow/connections.sh ADA tetapi tidak dirujuk siapa pun dan isinya menyimpang"
|
||||
warn " (menyebut trino http:8080, postgres_default & openmetadata_default yang tidak pernah dibuat)"
|
||||
else
|
||||
pass "Tidak ada berkas connection yatim"
|
||||
fi
|
||||
|
||||
local dag_file="${CONFIG_DIR}/airflow/dags/example_sicabe_pipeline.py"
|
||||
if [[ -f "${dag_file}" ]]; then
|
||||
if grep -q "postgres_default" "${dag_file}" 2>/dev/null; then
|
||||
warn "Docstring DAG menyebut connection 'postgres_default' yang tidak dibuat bootstrap (DAG memakai env var)"
|
||||
fi
|
||||
if grep -q "trino-proxy" "${dag_file}" 2>/dev/null; then
|
||||
warn "Komentar DAG masih menyebut 'trino-proxy' (service yang tidak ada; dbt konek langsung HTTPS 8443)"
|
||||
fi
|
||||
if grep -q "_postgres_conn_string" "${dag_file}" 2>/dev/null; then
|
||||
warn "validate_gold/validate_bronze memvalidasi tabel di PostgreSQL (sicabe_data.orders = SUMBER),"
|
||||
warn " bukan tabel gold/bronze di Iceberg — hasil 'LULUS' tidak membuktikan output pipeline"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! sicabe_host_expects_container sicabe-openmetadata; then
|
||||
skip "Container sicabe-openmetadata di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-openmetadata; then
|
||||
pass "OpenMetadata berjalan — konektor Airflow diuji di gate Phase 6"
|
||||
else
|
||||
skip "OpenMetadata belum berjalan (fase 6) — lewati pemeriksaan antarmukanya"
|
||||
fi
|
||||
if ! sicabe_host_expects_container sicabe-grafana; then
|
||||
skip "Container sicabe-grafana di host lain (mode host) - dilewati"
|
||||
elif container_running sicabe-grafana; then
|
||||
pass "Grafana berjalan — dashboard Airflow diuji di gate Phase 6"
|
||||
else
|
||||
skip "Grafana belum berjalan (fase 6) — lewati pemeriksaan antarmukanya"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
print_section "Verifikasi Phase 5 — Kontrak Ingestion (Airflow)"
|
||||
echo -e " Airflow: ${CYAN}${AIRFLOW_URL}${NC} · DAG utama: ${CYAN}${BATCH_DAG}${NC}"
|
||||
echo -e " Sumber : sicabe_data.public.orders (PostgreSQL) → Sling → dbt/Trino"
|
||||
echo ""
|
||||
|
||||
check_service
|
||||
check_dags
|
||||
check_connections
|
||||
check_source_and_assets
|
||||
check_findings_and_consumers
|
||||
|
||||
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 ingestion Phase 5 terpenuhi — aman lanjut ke fase berikutnya.${NC}"
|
||||
exit 0
|
||||
fi
|
||||
echo -e "${ERR} Ada ${FAIL_N} gate yang gagal — perbaiki sebelum melanjutkan."
|
||||
exit 1
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+701
@@ -0,0 +1,701 @@
|
||||
#!/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 "$@"
|
||||
Reference in New Issue
Block a user