#!/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 # ─── 0e. Komponen EKSTERNAL ber-`address` (skenario P3) ────────────────────── # `components..address` = komponen dikelola SISTEM LAIN → TIDAK dijalankan cluster # (tak muncul di services host mana pun) + endpoint-nya = alamat itu. Diuji via cluster.p3.yml. p3_problems="$(python3 - "${DEPLOY_DIR}" <<'PY' import sys sys.path.insert(0, sys.argv[1] + "/lib/cluster") import inventory d = inventory.apply_preset(inventory.load(sys.argv[1] + "/cluster.p3.yml")) pl = inventory.plan(d) svc = {s for h in pl.values() for s in h["services"]} p = [] for c in ("keycloak", "minio", "nessie"): if c in svc: p.append(c + " masih dijalankan") if not inventory.external_address(d, c): p.append(c + ": address hilang") if "openldap" not in pl.get("control", {}).get("services", []): p.append("openldap tak di control") print("; ".join(p)) PY )" if [[ -z "${p3_problems}" ]]; then ok "P3: components..address → eksternal tak dijalankan, openldap lokal di control" else bad "P3: perilaku components..address salah — ${p3_problems}" fi # ─── 0f. Deteksi keystore Trino BEDA antar-host ────────────────────────────── # Keystore Trino harus identik di semua host (satu --generate + --sync). Skrip ini # mendeteksi divergensi (mis. --generate dijalankan di >1 host). cks="${DEPLOY_DIR}/scripts/check-trino-keystore.sh" if [[ -x "${cks}" ]] && bash -n "${cks}" 2>/dev/null \ && grep -q -- '--inventory' "${cks}" && grep -q -- '--expect' "${cks}" \ && grep -q 'keystore.p12' "${cks}" && grep -q 'password.db' "${cks}" \ && grep -q 'svc_dbt' "${cks}"; then ok "check-trino-keystore.sh: deteksi keystore + password.db antar-host (--inventory/--expect)" else bad "check-trino-keystore.sh hilang/tidak lengkap (divergensi keystore/password.db tak terdeteksi)" fi # Fungsional (bila bundle host sudah ada): cetak sha256 & hormati --expect. _first_bundle="$(ls -d "${DEPLOY_DIR}"/generated/host-* 2>/dev/null | head -1)" if [[ -n "${_first_bundle}" ]]; then _bh="$(basename "${_first_bundle}" | sed 's/^host-//')" _sha="$("${cks}" --host "${_bh}" 2>/dev/null | sed -n 's/^ keystore : sha256=//p')" if [[ "${_sha}" =~ ^[0-9a-f]{64}$ ]]; then ok "check-trino-keystore.sh: sha256 host '${_bh}' terbaca" "${cks}" --host "${_bh}" --expect "${_sha}" >/dev/null 2>&1 \ && ok "check-trino-keystore.sh: --expect cocok → exit 0" \ || bad "check-trino-keystore.sh: --expect cocok seharusnya exit 0" else bad "check-trino-keystore.sh: sha256 tidak terbaca untuk host '${_bh}'" fi fi # ─── 0g. .env auto-generate memuat SEMUA var WAJIB compose ─────────────────── # Regresi nyata: STARROCKS_ROOT_PASSWORD (dipakai `${VAR:?}` di compose) TIDAK dibuat # install.sh → `docker compose --env-file .env` (tanpa shell env) gagal # "required variable ... is missing a value". _required="$(grep -oE '\$\{[A-Z_][A-Z0-9_]*:\?' "${DEPLOY_DIR}/docker-compose.yml" | sed 's/\${//;s/:?//' | sort -u)" _envvars="$(awk '/^[[:space:]]*cat > .*\/\.env" </dev/null \ && grep -q 'ingest-openmetadata.sh' "${e2e}" \ && grep -q 'airflow dags test' "${e2e}" \ && grep -q 'verify-phase6-governance.sh' "${e2e}"; then ok "run-e2e.sh: orkestrasi alur data E2E (ingest→DAG→ingest→DAG→verify)" else bad "run-e2e.sh hilang/tidak lengkap" fi # ─── 0i. Prometheus: target MinIO EKSTERNAL → alamat:port publik ───────────── # Prometheus tak mendukung substitusi env, dan render-config hanya memetakan komponen # milik host lain — komponen EKSTERNAL (components.minio.address) harus di-rewrite # generate.py, kalau tidak target tetap `minio:9000` → scrape gagal dari host prometheus. _tmp_prom="$(mktemp -d)"; _prom_ok="" if [[ -f "${DEPLOY_DIR}/.env" ]]; then cp "${DEPLOY_DIR}/.env" "${_tmp_prom}/e.env" printf '\nMINIO_INTERNAL_PORT=12900\n' >> "${_tmp_prom}/e.env" if python3 "${DEPLOY_DIR}/lib/cluster/generate.py" render \ "${DEPLOY_DIR}/cluster.p3.yml" "${DEPLOY_DIR}/docker-compose.yml" \ "${_tmp_prom}/e.env" "${_tmp_prom}/out" "${DEPLOY_DIR}/scripts/render-config.sh" \ >/dev/null 2>&1; then _pyml="${_tmp_prom}/out/host-control/config/prometheus/prometheus.yml" if grep -q 'targets: \["100.93.54.108:12900"\]' "${_pyml}" 2>/dev/null \ && ! grep -q 'targets: \["minio:9000"\]' "${_pyml}" 2>/dev/null; then _prom_ok=1 fi fi if [[ -n "${_prom_ok}" ]]; then ok "prometheus: target MinIO eksternal → 100.93.54.108:12900 (bukan minio:9000)" else bad "prometheus: target MinIO eksternal salah (masih minio:9000?)" fi fi # ─── 0j. Verifikasi Keycloak lengkap (verify-keycloak.sh) ──────────────────── vk="${DEPLOY_DIR}/scripts/verify-keycloak.sh" if [[ -x "${vk}" ]] && bash -n "${vk}" 2>/dev/null \ && grep -q 'client_credentials' "${vk}" && grep -q 'redirectUris' "${vk}" \ && grep -q 'publicClient' "${vk}" && grep -q 'origin host' "${vk}" \ && grep -q 'kc_auth_probe' "${vk}" && grep -q 'password masih DEFAULT' "${vk}"; then ok "verify-keycloak.sh: realm/roles/client(redirectUri origin-host,secret,publicClient)/user/password-default + fungsional" else bad "verify-keycloak.sh hilang/tidak lengkap" fi # ─── 0k. Trino OAuth2: token/jwks pakai KEYCLOAK_HOST (bukan literal `keycloak`) ── # Kls bug sama dgn target MinIO Prometheus: Trino server-ke-server ke Keycloak. Bila # Keycloak EKSTERNAL (P3), literal `keycloak:` tak resolve → penukaran token gagal → # callback `/oauth2/callback` balas "Authentication response could not be verified". _tc="${DEPLOY_DIR}/config/trino/config-coordinator.properties" if grep -q 'token-url=http://${ENV:KEYCLOAK_HOST}:${ENV:KEYCLOAK_INTERNAL_PORT}' "${_tc}" 2>/dev/null \ && grep -q 'jwks-url=http://${ENV:KEYCLOAK_HOST}:' "${_tc}" 2>/dev/null \ && grep -q 'oauth2.state-key=${ENV:TRINO_OAUTH_STATE_KEY}' "${_tc}" 2>/dev/null \ && grep -q 'KEYCLOAK_HOST: ${KEYCLOAK_HOST' "${DEPLOY_DIR}/docker-compose.yml" 2>/dev/null \ && grep -q 'TRINO_OAUTH_STATE_KEY: ${TRINO_OAUTH_STATE_KEY' "${DEPLOY_DIR}/docker-compose.yml" 2>/dev/null \ && ! grep -qE 'token-url=http://keycloak:' "${_tc}" 2>/dev/null; then ok "trino oauth2: token-url/jwks-url pakai KEYCLOAK_HOST + state-key tetap" else bad "trino oauth2: token-url/jwks-url literal 'keycloak:' atau state-key belum diset" fi # ─── 0l. StarRocks OIDC: redirect_url = alamat browser (bukan localhost) ────── if ! grep -qE "redirect_url' = 'http://localhost" "${DEPLOY_DIR}/bootstrap.sh" 2>/dev/null \ && grep -q 'sr_redirect_url=' "${DEPLOY_DIR}/bootstrap.sh" 2>/dev/null \ && grep -q 'bootstrap_browser_addr' "${DEPLOY_DIR}/bootstrap.sh" 2>/dev/null; then ok "StarRocks OIDC: redirect_url pakai alamat browser (bukan localhost literal)" else bad "StarRocks OIDC: redirect_url masih localhost literal" fi # ─── 0m. Tujuan `--sync` BEBAS nama repo (cermin $HOME, bukan ~/sicabe-doc) ─── # Regresi: dest default dipatok `~/sicabe-doc/deploy` → repo distribusi bersih # (`sicabe-core`, isinya hanya deploy/) salah tujuan & membuat salinan kedua # `~/sicabe-doc/deploy`. Nilai harus DITURUNKAN dari lokasi script. dest_probe="$(HOME=/home/u bash -c ' # shellcheck disable=SC1091 source "'"${DEPLOY_DIR}"'/lib/common.sh" >/dev/null 2>&1 || exit 9 for d in /home/u/sicabe-core/deploy /home/u/sicabe-doc/deploy /opt/apps/sicabe-core/deploy; do printf "%s|" "$(sicabe_default_dest "$d")" done' 2>/dev/null)" if [[ "${dest_probe}" == "~/sicabe-core/deploy|~/sicabe-doc/deploy|~/sicabe-core/deploy|" ]]; then ok "--sync: tujuan default mencerminkan nama repo (sicabe-core → ~/sicabe-core/deploy)" else bad "--sync: sicabe_default_dest salah → '${dest_probe}'" fi if ! grep -q 'dest="~/sicabe-doc/deploy"' "${DEPLOY_DIR}/install.sh" \ && ! grep -q 'DEST="~/sicabe-doc/deploy"' "${DEPLOY_DIR}/scripts/check-trino-keystore.sh" \ && grep -q 'sicabe_default_dest' "${DEPLOY_DIR}/install.sh"; then ok "--sync: tidak ada lagi path repo ter-hardcode (~/sicabe-doc/deploy)" else bad "--sync: masih ada path repo ter-hardcode (~/sicabe-doc/deploy)" fi # ─── 0n. Bucket & warehouse Iceberg terkonfigurasi (bukan literal) ─────────── # Bucket = MINIO_BUCKET_ICEBERG_DATA; warehouse = ICEBERG_WAREHOUSE (nama REST # Nessie + prefix path S3, IDENTIK). Client boleh punya nama berbeda → tak boleh # ada literal 'warehouse' / region Nessie hardcoded. _wh_dc="${DEPLOY_DIR}/docker-compose.yml"; _wh_ed="${DEPLOY_DIR}/config/environment.defaults.sh" _wh_ip="${DEPLOY_DIR}/config/trino/catalog/iceberg.properties"; _wh_bs="${DEPLOY_DIR}/bootstrap.sh" _wh_se="${DEPLOY_DIR}/scripts/seed-starrocks.sh"; _wh_v2="${DEPLOY_DIR}/scripts/verify-phase2-storage.sh" _wh_ins="${DEPLOY_DIR}/install.sh" wh_bad="" have() { grep -qF "$2" "$1" 2>/dev/null || wh_bad="${wh_bad} $(basename "$1"):$3"; } lack() { grep -qF "$2" "$1" 2>/dev/null && wh_bad="${wh_bad} $(basename "$1"):$3"; } have "${_wh_ed}" 'export ICEBERG_WAREHOUSE=' "env-default" have "${_wh_dc}" 'NESSIE_CATALOG_DEFAULT_WAREHOUSE: "${ICEBERG_WAREHOUSE:-warehouse}"' "nessie-name" have "${_wh_dc}" 's3://${MINIO_BUCKET_ICEBERG_DATA:-iceberg-data}/${ICEBERG_WAREHOUSE:-warehouse}' "nessie-loc" have "${_wh_dc}" 'DEFAULT_OPTIONS_REGION: "${S3_REGION:-us-east-1}"' "nessie-region" have "${_wh_dc}" 'ICEBERG_WAREHOUSE: ${ICEBERG_WAREHOUSE:-warehouse}' "trino-env" have "${_wh_ip}" '${ENV:ICEBERG_WAREHOUSE}' "trino-catalog" have "${_wh_bs}" "'iceberg.catalog.warehouse' = '\${ICEBERG_WAREHOUSE:-warehouse}'" "sr-catalog" have "${_wh_bs}" "'iceberg.catalog.warehouse'='\${ICEBERG_WAREHOUSE:-warehouse}'" "sr-catalog2" have "${_wh_bs}" 'rest_warehouse: ${ICEBERG_WAREHOUSE:-warehouse}' "sling" have "${_wh_v2}" 'WAREHOUSE="${ICEBERG_WAREHOUSE:-warehouse}"' "gate-def" have "${_wh_v2}" 's3://${BUCKET}/${WAREHOUSE}' "gate-expect" have "${_wh_ins}" 'MINIO_BUCKET_ICEBERG_DATA=iceberg-data' "env-bucket" have "${_wh_ins}" 'ICEBERG_WAREHOUSE=warehouse' "env-warehouse" lack "${_wh_bs}" "'iceberg.catalog.warehouse' = 'warehouse'" "sr-literal" lack "${_wh_bs}" "'iceberg.catalog.warehouse'='warehouse'" "sr-literal2" lack "${_wh_bs}" 'rest_warehouse: warehouse' "sling-literal" lack "${_wh_se}" "'iceberg.catalog.warehouse' = 'warehouse'" "seed-literal" if [[ -z "${wh_bad}" ]]; then ok "storage: bucket+warehouse terkonfigurasi (nama & prefix), region Nessie = S3_REGION" else bad "storage: konfigurasi bucket/warehouse belum lengkap →${wh_bad}" fi # ─── 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 ":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 # `::` 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://: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, "") for name in mapping: s = re.sub(r"(?", 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"(?", 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://