#!/usr/bin/env python3 """preflight.py — verifikasi kesiapan lintas host SEBELUM deploy (Fase 2, R10 §4.7). Pengganti `depends_on` lintas host: generator MEMBUANG `depends_on` yang menyeberang host (Compose tidak bisa menyalakan service di host lain), sehingga tidak ada lagi yang menjamin urutan/ketersediaan antar host. Preflight menutup celah itu dengan memeriksa **dari sisi jaringan**: apakah tiap port yang dibutuhkan benar-benar terjangkau di alamat host pemiliknya. Yang diperiksa: 1. Inventory valid (aturan penempatan R10 §4.3 / R11 §3.2). 2. Port internal yang dibutuhkan lintas host (perhitungan yang SAMA dengan generator — diimpor, bukan disalin) benar-benar terbuka di alamat host pemiliknya. 3. TLS Trino coordinator (`8443`) menyelesaikan handshake. 4. Selisih waktu antar host (header `Date` HTTP) — JWT/`notBefore` sensitif, jadi skew besar = kegagalan login yang sulit dilacak. Pemakaian: preflight.py [--compose docker-compose.yml] [--deploy DIR] [--timeout N] Keluaran mengikuti konvensi gate repo: [PASS]/[FAIL]/[WARN]/[SKIP] + Ringkasan + recap. Exit 1 bila ada FAIL. """ from __future__ import annotations import json import socket import ssl import sys from datetime import datetime, timezone from email.utils import parsedate_to_datetime from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import generate # noqa: E402 import inventory # noqa: E402 GRN = "\033[32m"; RED = "\033[31m"; YLW = "\033[33m"; BLD = "\033[1m"; NC = "\033[0m" class Report: def __init__(self) -> None: self.passed = 0 self.failed = 0 self.warned = 0 self.skipped = 0 self.fail_msgs: list[str] = [] def ok(self, msg: str) -> None: print(f" {GRN}[PASS]{NC} {msg}"); self.passed += 1 def bad(self, msg: str) -> None: print(f" {RED}[FAIL]{NC} {msg}"); self.failed += 1; self.fail_msgs.append(msg) def warn(self, msg: str) -> None: print(f" {YLW}[WARN]{NC} {msg}"); self.warned += 1 def skip(self, msg: str) -> None: print(f" {YLW}[SKIP]{NC} {msg}"); self.skipped += 1 def recap(self) -> int: print() print(f"{BLD}Ringkasan:{NC} {self.passed} PASS · {self.failed} FAIL · " f"{self.warned} WARN · {self.skipped} SKIP") if self.fail_msgs: print(f"\n{BLD}Yang gagal ({len(self.fail_msgs)}):{NC}") for i, m in enumerate(self.fail_msgs, 1): print(f" {i}. {m}") return 1 if self.failed else 0 def tcp_open(host: str, port: int, timeout: float) -> tuple[bool, str]: try: with socket.create_connection((host, port), timeout=timeout): return True, "" except socket.timeout: return False, "timeout" except ConnectionRefusedError: return False, "connection refused (port tertutup)" except OSError as exc: return False, str(exc) def tls_handshake(host: str, port: int, timeout: float) -> tuple[bool, str]: """Handshake TLS tanpa verifikasi sertifikat (deploy ini self-signed). Yang diuji hanya: TLS tersedia dan handshake selesai. Verifikasi nama/host sengaja tidak dilakukan di sini — itu urusan klien masing-masing (mis. OM memakai `verify: /etc/ssl/trino/trino.crt`). """ ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE try: with socket.create_connection((host, port), timeout=timeout) as raw: with ctx.wrap_socket(raw, server_hostname=host) as tls: _ = tls.version() return True, "" except OSError as exc: return False, str(exc) def http_date(host: str, port: int, timeout: float) -> datetime | None: """Ambil header `Date` dari respons HTTP paling sederhana (tanpa dependensi).""" try: with socket.create_connection((host, port), timeout=timeout) as sock: sock.sendall(f"HEAD / HTTP/1.0\r\nHost: {host}\r\n\r\n".encode()) data = sock.recv(2048).decode("latin-1", errors="ignore") except OSError: return None for line in data.splitlines(): if line.lower().startswith("date:"): try: return parsedate_to_datetime(line.split(":", 1)[1].strip()) except (ValueError, TypeError): return None return None def clock_skew(host: str, port: int, timeout: float, report: Report) -> None: remote = http_date(host, port, timeout) if remote is None: return if remote.tzinfo is None: remote = remote.replace(tzinfo=timezone.utc) skew = abs((datetime.now(timezone.utc) - remote).total_seconds()) if skew > 60: report.warn(f"{host}: selisih waktu {skew:.0f}s (>60s) — JWT/OIDC sensitif; " f"pastikan NTP aktif di host itu") else: report.ok(f"{host}: selisih waktu {skew:.0f}s (aman)") def main(argv: list[str]) -> int: if len(argv) < 2: sys.stderr.write(__doc__ or "") return 2 inv_path = argv[1] compose_path = "docker-compose.yml" deploy_dir = Path(".") timeout = 3.0 rest = argv[2:] while rest: flag = rest.pop(0) if flag == "--compose" and rest: compose_path = rest.pop(0) elif flag == "--deploy" and rest: deploy_dir = Path(rest.pop(0)) elif flag == "--timeout" and rest: timeout = float(rest.pop(0)) else: sys.stderr.write(f"argumen tak dikenal: {flag}\n") return 2 report = Report() print(f"{BLD}Preflight lintas host{NC}") print(f" inventory : {inv_path}") print(f" compose : {compose_path}") print(f" {YLW}preflight hanya MEMERIKSA — tidak menyalakan apa pun. Urutan start " f"antar host tetap manual (belum ada orkestrator).{NC}") print() # ── 1. inventory ──────────────────────────────────────────────────────── print(f"{BLD}A. Inventory{NC}") doc = inventory.apply_preset(inventory.load(inv_path)) errors = inventory.validate(doc) if errors: for e in errors: report.bad(f"inventory: {e}") return report.recap() plan = inventory.plan(doc) report.ok(f"inventory valid — preset '{doc.get('preset') or 'none'}' · {len(plan)} host") for host, spec in plan.items(): report.ok(f" {host:9s} {spec['address']:15s} tiers=[{','.join(spec['tiers'])}]") # ── 2. port internal yang dibutuhkan lintas host ───────────────────────── print(f"\n{BLD}B. Port lintas host{NC}") base = generate.load_compose(compose_path) placement = inventory.resolve(doc) needed = generate.required_cross_host(base, deploy_dir, plan, placement) if not needed: report.skip("tidak ada komponen yang dikonsumsi dari host lain") for component in sorted(needed): owners = placement.get(component) or [] ports = generate.INTERNAL_PORTS.get(component) or [] if len(owners) > 1: report.skip(f"{component}: di {len(owners)} host (scale-out) — " f"periksa manual {owners}") continue if not owners: report.bad(f"{component}: tidak ada host pemilik"); continue address = plan[owners[0]]["address"] for port in ports: open_ok, why = tcp_open(address, port, timeout) if open_ok: report.ok(f"{component} {address}:{port} terjangkau") else: report.bad(f"{component} {address}:{port} TIDAK terjangkau ({why})") # ── 3. TLS Trino coordinator ───────────────────────────────────────────── print(f"\n{BLD}C. TLS Trino coordinator{NC}") trino_owners = placement.get("trino-coordinator") or [] if trino_owners: addr = plan[trino_owners[0]]["address"] handshake_ok, why = tls_handshake(addr, 8443, timeout) if handshake_ok: report.ok(f"TLS handshake coordinator {addr}:8443 berhasil") else: report.bad(f"TLS handshake coordinator {addr}:8443 gagal ({why})") clock_skew(addr, 8443, timeout, report) else: report.skip("tidak ada trino-coordinator di inventory") # ── 4. waktu antar host (lewat layanan HTTP) ───────────────────────────── print(f"\n{BLD}D. Waktu antar host{NC}") checked = 0 for component in ("keycloak", "nessie", "opa"): owners = placement.get(component) or [] port = {"keycloak": 8080, "nessie": 19120, "opa": 8181}[component] if len(owners) != 1: continue addr = plan[owners[0]]["address"] if tcp_open(addr, port, timeout)[0]: clock_skew(addr, port, timeout, report) checked += 1 if checked == 0: report.skip("tidak ada layanan HTTP yang terjangkau untuk memeriksa waktu " "(pastikan NTP aktif di semua host)") return report.recap() if __name__ == "__main__": raise SystemExit(main(sys.argv))