first commit
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
#!/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))
|
||||
Reference in New Issue
Block a user