first commit

This commit is contained in:
2026-10-09 02:35:14 +07:00
commit 4ca7a9a58e
108 changed files with 23035 additions and 0 deletions
+97
View File
@@ -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
+194
View File
@@ -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())
+210
View File
@@ -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
View File
@@ -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
+88
View File
@@ -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"
+190
View File
@@ -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}
+336
View File
@@ -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 "$@"
+316
View File
@@ -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
View File
@@ -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
View File
@@ -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' "================================================================================"
+195
View File
@@ -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 "$@"
+69
View File
@@ -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[@]}"}"
+169
View File
@@ -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 "$@"
+99
View File
@@ -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)."
+175
View File
@@ -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
View File
@@ -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 "$@"
+168
View File
@@ -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
+814
View File
@@ -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
+212
View File
@@ -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
+86
View File
@@ -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
+478
View File
@@ -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 "$@"
+411
View File
@@ -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 "$@"
+581
View File
@@ -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 "$@"
+467
View File
@@ -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 "$@"
+398
View File
@@ -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 "$@"
+701
View File
@@ -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 "$@"