from __future__ import annotations import json import os from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any def _utc_now() -> datetime: return datetime.now(timezone.utc) def _parse_utc(ts: str) -> datetime: # Accept ISO8601 with trailing Z s = ts.strip() if s.endswith("Z"): s = s[:-1] + "+00:00" return datetime.fromisoformat(s).astimezone(timezone.utc) def _iso_z(dt: datetime) -> str: return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @dataclass(frozen=True) class HeadRecord: cluster_name: str head_ip: str gcs_port: int dashboard_port: int job_server_url: str updated_at: str expires_at: str @staticmethod def from_dict(d: dict[str, Any]) -> "HeadRecord": return HeadRecord( cluster_name=str(d["cluster_name"]), head_ip=str(d["head_ip"]), gcs_port=int(d.get("gcs_port", 6379)), dashboard_port=int(d.get("dashboard_port", 8265)), job_server_url=str(d.get("job_server_url") or f"http://{d['head_ip']}:{int(d.get('dashboard_port', 8265))}"), updated_at=str(d["updated_at"]), expires_at=str(d["expires_at"]), ) def to_dict(self) -> dict[str, Any]: return { "cluster_name": self.cluster_name, "head_ip": self.head_ip, "gcs_port": self.gcs_port, "dashboard_port": self.dashboard_port, "job_server_url": self.job_server_url, "updated_at": self.updated_at, "expires_at": self.expires_at, } def head_addr(self) -> str: return f"{self.head_ip}:{self.gcs_port}" def build_head_record( *, cluster_name: str, head_ip: str, gcs_port: int = 6379, dashboard_port: int = 8265, ttl_s: int = 60, now: datetime | None = None, ) -> HeadRecord: now_dt = now or _utc_now() expires = now_dt + timedelta(seconds=int(ttl_s)) updated_at = _iso_z(now_dt) expires_at = _iso_z(expires) return HeadRecord( cluster_name=cluster_name, head_ip=head_ip, gcs_port=int(gcs_port), dashboard_port=int(dashboard_port), job_server_url=f"http://{head_ip}:{int(dashboard_port)}", updated_at=updated_at, expires_at=expires_at, ) def load_head_record(path: str, *, now: datetime | None = None) -> HeadRecord | None: p = Path(path) if not p.exists(): return None try: obj = json.loads(p.read_text(encoding="utf-8")) except Exception: return None if not isinstance(obj, dict): return None try: rec = HeadRecord.from_dict(obj) expires = _parse_utc(rec.expires_at) except Exception: return None now_dt = now or _utc_now() if expires <= now_dt: return None return rec def write_head_record_atomic(path: str, rec: HeadRecord) -> None: p = Path(path) os.makedirs(p.parent, exist_ok=True) tmp = p.with_suffix(p.suffix + ".tmp") tmp.write_text(json.dumps(rec.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8") tmp.replace(p)