#!/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 /v1/config # (prefix dari NESSIE_ICEBERG_PREFIX, default /iceberg) # 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_SCHEME="${S3_ENDPOINT%%://*}" # http | https — dipakai uji kesehatan host 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}" WAREHOUSE="${ICEBERG_WAREHOUSE:-warehouse}" NESSIE_URL="${NESSIE_URL:-http://nessie:19120}" NESSIE_HOST="${NESSIE_HOST:-localhost}" NESSIE_PORT="${NESSIE_PORT:-12120}" # API host-facing. Bila NESSIE_URL menunjuk ALAMAT (IP/FQDN — Nessie EKSTERNAL/lintas-host), # pakai apa adanya (sudah memuat port benar: generator memakai NESSIE_INTERNAL_PORT, mis. # 19120). Selebihnya (nama service `nessie:19120` / single-host) → host:port terbit (NESSIE_PORT). NESSIE_HOST_API="$(sicabe_host_url NESSIE_URL NESSIE_HOST "${NESSIE_PORT:-12120}")" NESSIE_DB="${NESSIE_DB:-nessie}" # Prefix REST Iceberg Nessie (mis. `/iceberg`); ubah bila layanan existing memakai prefix lain. ICEBERG_PREFIX="${NESSIE_ICEBERG_PREFIX:-/iceberg}" # 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)); } # Kode HTTP untuk (000 = koneksi gagal/timeout). Dipakai agar pesan FAIL # menampilkan PENYEBAB (404/401/500/000), bukan hanya "tidak merespon". http_code() { local c; c="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$1" 2>/dev/null)"; printf '%s' "${c:-000}"; } code_hint() { case "$1" in 000) echo "tidak terjangkau (koneksi/DNS/timeout) — cek port, alamat, jaringan";; 401|403) echo "butuh autentikasi — Nessie existing mungkin memakai bearer token";; 404) echo "path tidak ada — versi Nessie lama / Iceberg REST belum aktif (butuh warehouse+object store) / proxy tak merutekan path ini";; 429) echo "rate-limited oleh proxy/edge";; 500|502|503|504) echo "error di server/proxy — cek konfigurasi Nessie (warehouse/object store) atau proxy";; *) echo "HTTP $1";; esac } 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 "${S3_SCHEME:-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 [HTTP ${host_code:-000} — $(code_hint "${host_code:-000}")]" 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 code cfg=$(curl -sf --max-time 10 "${NESSIE_HOST_API}/api/v2/config" 2>/dev/null || echo "") if [[ -z "${cfg}" ]]; then code="$(http_code "${NESSIE_HOST_API}/api/v2/config")" fail "Nessie REST /api/v2/config tidak merespon di ${NESSIE_HOST_API} [HTTP ${code} — $(code_hint "${code}")]" 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_PREFIX}/v1/config" 2>/dev/null || echo "") if [[ -n "${iceberg_cfg}" ]]; then pass "Endpoint Iceberg REST (${ICEBERG_PREFIX}) aktif (dipakai Sling & StarRocks)" else code="$(http_code "${NESSIE_HOST_API}${ICEBERG_PREFIX}/v1/config")" fail "Endpoint Iceberg REST ${NESSIE_HOST_API}${ICEBERG_PREFIX} tidak merespon [HTTP ${code} — $(code_hint "${code}")]" 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} — $(code_hint "${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} (bucket '${BUCKET}' + warehouse '${WAREHOUSE}')" elif [[ -n "${wh}" ]]; then fail "Warehouse Nessie '${wh}' ≠ 's3://${BUCKET}/${WAREHOUSE}' — bucket/warehouse 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}, warehouse ${WAREHOUSE}, 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 "$@"