234 lines
9.1 KiB
Python
234 lines
9.1 KiB
Python
#!/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 <cluster.yml> [--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))
|