#!/usr/bin/env python3 """generate.py — render artefak deploy per host dari docker-compose.yml + cluster.yml. Fase 1 R10 §4.5 (**Varian A: render, bukan overlay asli Compose**). Alasan: merge overlay tidak dapat MENGHAPUS service, sedangkan kita perlu menyisakan hanya service milik satu host. Yang dihasilkan `generated/host-/`: 1. `docker-compose.yml` — hanya service milik host itu. 2. `.env` — endpoint menunjuk ALAMAT HOST pemilik komponen (B1) + seluruh isi `.env` sumber (rahasia wajib identik lintas host, S1 R10 §4.12). 3. `config/` — hasil `render-config.sh` (hostname container → alamat host), karena berkas di `config/**` tidak dapat melakukan ekspansi env saat runtime. `depends_on` lintas host DIBUANG (penggantinya `preflight`, Fase 2); intra-host dipertahankan supaya urutan + healthcheck tetap berlaku di dalam satu host. Hasil di `generated/` bersifat ARTEFAK (gitignored) — jangan disunting tangan. Perintah: generate.py render """ from __future__ import annotations import json import shutil import subprocess import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import inventory # noqa: E402 (modul sebelah, sengaja) try: import yaml except ImportError: # pragma: no cover sys.stderr.write("PyYAML wajib tersedia\n") raise SystemExit(2) # Variabel endpoint (B1) → komponen pemiliknya. Dipakai untuk mengarahkan # container ke ALAMAT HOST, bukan nama container (nama container hanya resolve # di host yang sama). ENDPOINT_VARS = { "POSTGRES_HOST": "postgres", "MINIO_HOST": "minio", "KEYCLOAK_HOST": "keycloak", "OPENMETADATA_HOST": "openmetadata-server", "AIRFLOW_WEBSERVER_HOST": "airflow-webserver", "STARROCKS_FE_HOST": "starrocks-fe", "STARROCKS_CN_HOST": "starrocks-cn", "OPENSEARCH_HOST": "openmetadata-opensearch", # Var SISI-HOST yang belum ada padanannya, dipakai `scripts/verify-phase*` (mis. Trino # & Grafana hanya punya *_PUBLIC_URL). Nilainya = alamat host pemilik. CATATAN: # STARROCKS_HOST/AIRFLOW_HOST sengaja TIDAK ditulis di sini — skrip memakainya sebagai # penanda komponen EXTERNAL (default `localhost`); mengisinya dengan alamat host akan # salah menandai komponen lokal sebagai external. "TRINO_HOST": "trino-coordinator", # Dipakai dbt (profiles.yml: env_var DBT_TRINO_HOST) — lintas-host butuh ALAMAT IP. "DBT_TRINO_HOST": "trino-coordinator", "GRAFANA_HOST": "grafana", "LDAP_HOST": "openldap", "NESSIE_HOST": "nessie", } # Sebagian konsumen memakai bentuk URL (bukan `*_HOST`), dan berkas config yang # dibaca container TIDAK dapat ekspansi env → URL-nya harus ikut diarahkan di sini, # kalau tidak Trino/Sling/StarRocks lintas host akan mencari nama container yang # hanya ada di host lain. (Komponen pemilik tetap diambil dari inventory.) URL_ENDPOINT_VARS = { "S3_ENDPOINT": ("minio", "http://{addr}:{port}"), "NESSIE_URL": ("nessie", "http://{addr}:{port}"), "LDAP_URL": ("openldap", "ldap://{addr}:{port}"), "OPA_URL": ("opa", "http://{addr}:{port}"), } # Port container untuk var URL di atas — bisa ditimpa di .env (mis. layanan EXISTING # berport non-standar). Default = port standar → perilaku lama tidak berubah. URL_ENDPOINT_PORTS = { "S3_ENDPOINT": ("MINIO_INTERNAL_PORT", 9000), "NESSIE_URL": ("NESSIE_INTERNAL_PORT", 19120), "LDAP_URL": ("OPENLDAP_INTERNAL_PORT", 389), "OPA_URL": ("OPA_INTERNAL_PORT", 8181), } # URL BROWSER-FACING saat tls.mode = none (tanpa proxy/TLS): dialamatkan ke HOST yang # MENJALANKAN komponen, bukan ke localhost maupun ke IP host lokal. Nilainya diambil # dari host pemilik sehingga SAMA di semua bundle — wajib, karena `iss`/issuer OIDC # harus satu nilai; kalau tiap host menulis alamatnya sendiri, validator akan tolak. # Port di sini mengikuti skema 12xxx (sama seperti URL_ENDPOINT_VARS di bawah). PUBLIC_URL_ENDPOINTS = { "KEYCLOAK_PUBLIC_URL": ("keycloak", "http://{addr}:12080"), "GRAFANA_PUBLIC_URL": ("grafana", "http://{addr}:12300"), "OPENMETADATA_PUBLIC_URL": ("openmetadata-server", "http://{addr}:12085"), "AIRFLOW_PUBLIC_URL": ("airflow-webserver", "http://{addr}:12082"), "TRINO_PUBLIC_URL": ("trino-coordinator", "https://{addr}:12443"), "MINIO_CONSOLE_PUBLIC_URL": ("minio", "http://{addr}:12901"), } # SENGAJA TIDAK diarahkan ke IP: KEYCLOAK_URL / TRINO_URL dan var `*_URL` lain yang # bersifat identitas (issuer OIDC harus stabil — lihat R10 §4.10.1: pakai DNS # internal, bukan IP). Itu milik operator dan harus sudah benar di `cluster.yml` # (domain) / `.env`. # Hostname container yang dikenali render-config.sh → nama komponen inventory. RENDER_HOSTS = ( "postgres", "minio", "nessie", "keycloak", "openldap", "opa", "trino-coordinator", "trino-worker", "starrocks-fe", "starrocks-cn", "airflow-webserver", "openmetadata-server", "openmetadata-opensearch", "prometheus", "grafana", "dremio", "statsd-exporter", "trino-jmx-exporter", ) # Catatan: co-location ditangani render_config_map() — komponen SE-HOST TIDAK ditulis ke # alamat host (biar tetap nama container, satu jaringan Docker). Maka daftar ini boleh # lengkap; yang penting hostname-nya ada di KNOWN_HOSTS render-config.sh. # node_exporter = PRA-SYARAT HOST (dipasang di luar installer, port 9100). Semua # host di cluster.yml dianggap menjalankannya; generate.py menulis daftar target # file_sd (job `node`) ke bundle host pemilik Prometheus. NODE_EXPORTER_PORT = 9100 def load_compose(path: str) -> dict: with open(path, encoding="utf-8") as fh: return yaml.safe_load(fh) or {} def keep_only(services: dict, wanted: set[str]) -> dict: return {name: spec for name, spec in services.items() if name in wanted} def prune_depends_on(services: dict) -> list[str]: """Buang `depends_on` lintas host; kembalikan daftar yang dibuang.""" dropped: list[str] = [] for name, spec in services.items(): deps = spec.get("depends_on") if deps is None: continue if isinstance(deps, dict): gone = set(deps) - set(services) kept: object = {k: v for k, v in deps.items() if k in services} else: gone = set(deps) - set(services) kept = [d for d in deps if d in services] dropped += [f"{name} → {g}" for g in sorted(gone)] if kept: spec["depends_on"] = kept else: spec.pop("depends_on", None) return dropped def used_volumes(services: dict) -> set[str]: used: set[str] = set() for spec in services.values(): for vol in spec.get("volumes", []) or []: if isinstance(vol, str): src = vol.split(":", 1)[0] elif isinstance(vol, dict): src = vol.get("source", "") else: continue if src and not src.startswith((".", "/", "~")): used.add(src) return used def build_compose(base: dict, wanted: set[str]) -> tuple[dict, list[str]]: services = keep_only(base["services"], wanted) dropped = prune_depends_on(services) declared = base.get("volumes") or {} out: dict = {"services": services} if base.get("name"): out["name"] = base["name"] out["volumes"] = {n: s for n, s in declared.items() if n in used_volumes(services)} for key in ("networks", "configs", "secrets"): if key in base: out[key] = base[key] return out, dropped def write_compose(path: Path, doc: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as fh: fh.write("# DIBANGKITKAN OTOMATIS — jangan disunting tangan.\n") fh.write("# Sumber: docker-compose.yml + cluster.yml · regenerasi: ./install.sh --generate\n") yaml.safe_dump(doc, fh, sort_keys=False, default_flow_style=False, allow_unicode=True) def _is_topology_var(line: str) -> bool: """Baris .env yang menjadi milik INVENTORY (bukan isi .env sumber). Kalau tidak disaring, `.env` sumber ikut menyalin `EXTERNAL_SERVICES`/ `*_HOST` milik single-host → di dotenv yang MENANG adalah yang TERAKHIR, sehingga nilai dari inventory bisa hilang diam-diam (bug yang ditemukan saat uji: `EXTERNAL_SERVICES` muncul dua kali). """ key = line.split("=", 1)[0].strip() owned = { "EXTERNAL_SERVICES", "LOCAL_SERVICES", "KEYCLOAK_SSL_REQUIRED", "KC_PROXY_HEADERS", "TRINO_PROCESS_FORWARDED", "CLUSTER_DOMAIN", "TRAEFIK_BIND_ADDR", *ENDPOINT_VARS, *URL_ENDPOINT_VARS, *inventory.PUBLIC_URL_SERVICES, # KEYCLOAK_PUBLIC_URL, GRAFANA_PUBLIC_URL, … } return key in owned or key.startswith("SICABE_HOST_") def render_env(base_env: Path, out_env: Path, host: str, spec: dict, endpoints: list[str]) -> None: lines: list[str] = [] if base_env.is_file(): for line in base_env.read_text(encoding="utf-8").splitlines(): if _is_topology_var(line): continue # ditentukan inventory, bukan .env sumber lines.append(line) else: lines.append("# (tidak ada .env sumber — rahasia wajib dilengkapi manual)") lines += [ "", "# ── Ditambahkan generator multi-host (Fase 1) — variabel topologi ──", f"SICABE_HOST_NAME={host}", f"SICABE_HOST_ADDRESS={spec['address']}", f"SICABE_HOST_TIERS={','.join(spec['tiers'])}", ] lines += endpoints out_env.parent.mkdir(parents=True, exist_ok=True) out_env.write_text("\n".join(lines) + "\n", encoding="utf-8") # Port KONTAINER yang harus terjangkau lintas host (Fase 2). Variabel `*_HOST` # hanya memuat NAMA host (tanpa port), jadi port diambil dari referensi compose = # port container (5432, 8080, …). Selama port container itu tidak terbuka di alamat # host, container di host lain tidak bisa menjangkau — jadi generator menambahkannya. # # Dikunci ke ALAMAT HOST (mis. `10.20.0.12:5432:5432`), bukan `0.0.0.0`, supaya hanya # terjangkau lewat NIC privat host itu (R10 §4.8). Ini SENGAJA hanya di artefak # multi-host: instalasi single-host tidak pernah membuka port internal ini, dan # `docker-compose.yml` tetap byte-identik (invarian Fase 0 terjaga). # Eksposur untuk manusia/klien tetap memakai port host `12xxx` yang sudah ada. INTERNAL_PORTS: dict[str, list[int]] = { "postgres": [5432], "minio": [9000], "nessie": [19120], "keycloak": [8080], "openldap": [389], "opa": [8181], # 8080 = worker mendaftar (discovery) · 8443 = klien lintas host (OM ingestion, # task dbt, native DQ) — keduanya dibutuhkan begitu coordinator & worker beda host. "trino-coordinator": [8080, 8443], "starrocks-fe": [9030, 8030], "starrocks-cn": [9050, 8040, 8060], "openmetadata-opensearch": [9200], "openmetadata-server": [8585], "airflow-webserver": [8080], # Sidecar JMX di host `coord`: Prometheus (di host `governance`) men-scrape 9404 # LINTAS host → portnya WAJIB diterbitkan di alamat host pemilik. "trino-jmx-exporter": [9404], } def _reachable_on_host_port(entry, cport: int, address: str) -> bool: """Apakah `entry` benar-benar membuka port container `cport` DI alamat host? Jebakan yang sempat lolos: `12080:8080` memetakan port host 12080 → container 8080. Itu TIDAK membuat port 8080 terjangkau di host, sehingga container di host lain tetap tidak bisa menjangkau. Yang dihitung hanya pemetaan yang port host-nya SAMA dengan port container (mis. `addr:8080:8080`). """ if isinstance(entry, str): parts = entry.split(":") if len(parts) == 2: host_ip, host_port = None, parts[0] elif len(parts) == 3: host_ip, host_port = parts[0], parts[1] else: return False if host_port != str(cport): return False return host_ip in (None, "", address) if isinstance(entry, dict): published = entry.get("published") return ( entry.get("target") == cport and str(published) == str(cport) and entry.get("host_ip") in (None, "", address) ) return False def tier_implied_components(placement: dict, plan: dict) -> set[str]: """Komponen yang PASTI dikonsumsi lintas host walau tidak terlihat di env/config. `trino-coordinator` selalu dipakai host bertier `pipeline` (task dbt Airflow) dan `governance` (ingestion OpenMetadata). Referensinya tidak dapat ditemukan dengan pemindaian env/config karena `ingest-trino.yaml` **disalin ke container saat runtime** (docker cp), bukan di-mount — celah ini ditemukan saat menguji preflight. """ needed: set[str] = set() owners = placement.get("trino-coordinator") or [] if not owners: return needed owner = owners[0] for host, spec in plan.items(): if host == owner: continue if {"pipeline", "governance"} & set(spec["tiers"]): needed.add("trino-coordinator") break return needed def config_referenced_components(deploy_dir: Path, base: dict, plan: dict, placement: dict, known: set[str]) -> set[str]: """Komponen yang dirujuk dari file CONFIG yang di-mount oleh service di host lain. Deteksi berbasis environment saja tidak cukup: Trino tidak memakai `*_HOST` untuk OAuth2, melainkan `config/trino/config-coordinator.properties` (`http://keycloak:8080/...`). Tanpa pemindaian ini, port Keycloak tidak dibuka dan Trino di host lain gagal mengambil JWKS. """ import re needed: set[str] = set() for host, spec in plan.items(): for svc in spec["services"]: for vol in base["services"].get(svc, {}).get("volumes") or []: src = vol.split(":", 1)[0] if isinstance(vol, str) else vol.get("source", "") if not src.startswith("./config/"): continue path = deploy_dir / src[2:] files = list(path.rglob("*")) if path.is_dir() else [path] for f in files: if not f.is_file(): continue try: text = f.read_text(encoding="utf-8", errors="ignore") except OSError: continue for comp in known: if re.search(r"(? set[str]: """Komponen yang PUNYA konsumen di host lain → perlu port internal terbuka. Tanpa perhitungan ini generator membuka port untuk semua komponen, dan di blueprint a3 itu langsung menabrak: `keycloak` dan `airflow-webserver` sama-sama meminta `:8080` (dua service, satu port host) → `compose up` gagal 'port is already allocated'. Padahal `airflow-webserver` hanya dikonsumsi OpenMetadata yang berada di host yang sama → tidak perlu port host sama sekali. Bonus keamanan: port internal yang tidak dibutuhkan tidak pernah dibuka. """ var_to_component = {v: c for v, c in ENDPOINT_VARS.items()} var_to_component.update({v: c for v, (c, _t) in URL_ENDPOINT_VARS.items()}) needed: set[str] = set() for host, spec in plan.items(): for svc in spec["services"]: source = base["services"].get(svc, {}) text = json.dumps(source.get("environment") or {}) for var, component in var_to_component.items(): if var not in text: continue owners = placement.get(component) or [] if owners and host not in owners: needed.add(component) return needed def required_cross_host(base: dict, deploy_dir: Path, plan: dict, placement: dict) -> set[str]: """Gabungan SEMUA alasan sebuah komponen butuh port lintas host. Satu fungsi supaya generator dan preflight TIDAK bisa berbeda pendapat — memisahkan perhitungan ini sempat membuat preflight melewatkan trino-coordinator begitu aturan baru ditambahkan ke generator. """ known = set(base["services"]) & set(INTERNAL_PORTS) return ( cross_host_components(base, placement, plan) | config_referenced_components(deploy_dir, base, plan, placement, known) | tier_implied_components(placement, plan) ) def add_internal_ports(services: dict, address: str, needed: set[str]) -> list[str]: """Tambahkan binding port internal ke alamat host; kembalikan yang ditambahkan.""" added: list[str] = [] for name, spec in services.items(): if name not in needed: continue want = INTERNAL_PORTS.get(name) if not want: continue ports = spec.setdefault("ports", []) for cport in want: if any(_reachable_on_host_port(e, cport, address) for e in ports): continue ports.append(f"{address}:{cport}:{cport}") added.append(f"{name}:{cport}") return added def component_placements(doc: dict) -> dict[str, list[str]]: return inventory.resolve(doc) def extra_bind_dirs(base: dict) -> list[str]: """Bind-mount relatif selain `config/` yang harus ikut disalin ke tiap host. Contoh: `./scripts/airflow:/opt/airflow/scripts:ro`. Tanpa disalin, direktori generated tidak mandiri — Docker akan membuat direktori KOSONG di host tujuan (skrip Airflow hilang diam-diam). Ditemukan oleh test-generate.sh. """ found: set[str] = set() for spec in base["services"].values(): for vol in spec.get("volumes", []) or []: if isinstance(vol, str): src = vol.split(":", 1)[0] elif isinstance(vol, dict): src = vol.get("source", "") else: continue if src.startswith("./") and not src.startswith("./config/"): found.add(src[2:]) return sorted(found) def copy_extra_binds(deploy_dir: Path, host_dir: Path, paths: list[str]) -> list[str]: """Salin bind relatif non-config (mis. `scripts/airflow`) ke direktori host.""" import shutil copied: list[str] = [] for rel in paths: src = deploy_dir / rel if not src.exists(): continue dst = host_dir / rel dst.parent.mkdir(parents=True, exist_ok=True) if src.is_dir(): shutil.copytree(src, dst, dirs_exist_ok=True) else: shutil.copy2(src, dst) copied.append(rel) return copied def endpoint_lines(doc: dict, host: str, plan: dict) -> list[str]: """Baris `VAR=alamat|nama` untuk host ini. SE-HOST → **nama service** (satu jaringan Docker). Port container TIDAK diterbitkan di host (mis. OM 8585), jadi menulis alamat host justru "connection refused" (regresi: Airflow/OpenLineage → `http://:8585/api`). Beda host → alamat host pemilik (port internal diterbitkan generator untuk itu). """ placement = component_placements(doc) lines: list[str] = [] for var, component in ENDPOINT_VARS.items(): owners = placement.get(component) if not owners: continue if host in owners: lines.append(f"{var}={component}") # SE-HOST → nama service else: lines.append(f"{var}={plan[owners[0]]['address']}") # LINTAS host → alamat disabled = [n for n, v in inventory.enabled_components(doc).items() if v is False] if disabled: # Komponen `enabled: false` = tidak dijalankan di host mana pun → installer # harus melewatinya (install.sh: EXTERNAL_SERVICES menang atas auto-deteksi). lines.append('EXTERNAL_SERVICES="%s"' % " ".join(disabled)) return lines def public_url_lines(doc: dict, plan: dict | None = None) -> list[str]: """Baris `*_PUBLIC_URL=...` — URL yang dilihat BROWSER (issuer OIDC, redirect, UI), bukan lalu lintas container. tls.mode != none : `https://