"""Host, boot, and process-start fencing evidence for retirement safety (#980). Review 657 B2/B3 established that a local ``os.kill(pid, 0)`` probe is not, on its own, evidence that a *particular registered worker* is gone: * **Host ambiguity.** A registry row written on host A records pid 1234. Probing pid 1234 on host B answers a question nobody asked. "Not running here" is not "not running". * **PID reuse.** Pid 1234 may be alive and belong to an unrelated process that the kernel handed the number to after the original exited. The naive probe reads that as "the worker is live" (safe, over-preserving) — but the converse matters too: evidence captured about pid 1234 at time T must not be honoured at time T+n if the process behind that number changed in between. * **Boot boundaries.** Every pid from a previous boot is conclusively gone, and pid numbers restart, so a recorded pid is only comparable to a live pid when both belong to the same boot. This module supplies the three pieces of evidence that turn a bare pid into a statement about one specific process: ``host_id`` Which machine the pid belongs to. ``boot_id`` Which boot of that machine the pid belongs to. Pids are only comparable within a single boot. ``process_start_time`` Which *incarnation* of that pid number. Two processes on the same host and boot sharing a pid number cannot share a start time, so comparing start times defeats reuse. Every probe here is read-only, never raises, and returns ``None`` when the evidence cannot be established. ``None`` means *unknown*, and the retirement conjunction is required to treat unknown as "preserve", never as "safe". """ from __future__ import annotations import os import platform import subprocess # --- Host identity -------------------------------------------------------- def current_host_id() -> str | None: """Stable identifier for the machine this process runs on. Deliberately the kernel node name rather than anything network-derived: it does not change when an interface goes down or a VPN reassigns an address, and retirement must not become unsafe because DNS moved. """ try: node = (platform.node() or "").strip() except Exception: return None return node or None # --- Boot identity -------------------------------------------------------- def _linux_boot_id() -> str | None: try: with open("/proc/sys/kernel/random/boot_id", encoding="utf-8") as handle: value = handle.read().strip() except Exception: return None return value or None def _darwin_boot_id() -> str | None: """macOS boot identity, derived from ``kern.boottime``. ``sysctl`` prints e.g. ``{ sec = 1785400000, usec = 123456 } Wed Jul 30 ...``. Only the integer seconds are kept: the trailing human-readable date is locale-dependent and would make the identifier unstable across environments for the very same boot. """ try: completed = subprocess.run( ["/usr/sbin/sysctl", "-n", "kern.boottime"], capture_output=True, text=True, timeout=5, check=False, ) except Exception: return None if completed.returncode != 0: return None text = (completed.stdout or "").strip() marker = "sec = " start = text.find(marker) if start < 0: return None tail = text[start + len(marker) :] digits = "" for char in tail: if char.isdigit(): digits += char else: break return f"boot-{digits}" if digits else None def current_boot_id() -> str | None: """Identifier for the current boot, or ``None`` when it cannot be proven.""" system = "" try: system = (platform.system() or "").strip().lower() except Exception: system = "" if system == "linux": return _linux_boot_id() if system == "darwin": return _darwin_boot_id() # An unrecognised platform yields no boot evidence rather than a guess. return None # --- Process start time --------------------------------------------------- def _linux_process_start_time(pid: int) -> str | None: """Field 22 of ``/proc//stat`` — start time in clock ticks since boot. The executable name in field 2 is parenthesised and may itself contain spaces and parentheses, so the fields are located from the *last* ``)`` rather than by splitting the whole line. """ try: with open(f"/proc/{pid}/stat", encoding="utf-8") as handle: raw = handle.read() except Exception: return None close = raw.rfind(")") if close < 0: return None fields = raw[close + 1 :].split() # After the ')' the next field is state (field 3), so field 22 is index 19. if len(fields) < 20: return None value = fields[19].strip() return f"ticks-{value}" if value else None def _darwin_process_start_time(pid: int) -> str | None: """macOS process start, from ``ps -o lstart=``. ``lstart`` is the absolute wall-clock start of that pid's current incarnation. Two processes reusing one pid number report different values, which is exactly the discrimination reuse detection needs. """ try: completed = subprocess.run( ["/bin/ps", "-o", "lstart=", "-p", str(int(pid))], capture_output=True, text=True, timeout=5, check=False, ) except Exception: return None if completed.returncode != 0: return None value = " ".join((completed.stdout or "").split()) return f"lstart-{value}" if value else None def process_start_time(pid: int | None) -> str | None: """Start-time token for *pid*'s current incarnation, or ``None``. ``None`` is returned both when the pid does not exist and when the platform cannot answer. Callers must not read either case as evidence of death — a dead pid is established by the liveness probe, and this value only ever *withdraws* a retirement that pid-level evidence would otherwise allow. """ if pid is None: return None try: numeric = int(pid) except (TypeError, ValueError): return None if numeric <= 0: return None system = "" try: system = (platform.system() or "").strip().lower() except Exception: system = "" if system == "linux": return _linux_process_start_time(numeric) if system == "darwin": return _darwin_process_start_time(numeric) return None def current_process_fencing(pid: int | None = None) -> dict[str, str | None]: """The full fencing triple for *pid* (defaults to this process).""" target = os.getpid() if pid is None else pid return { "host_id": current_host_id(), "boot_id": current_boot_id(), "process_start_time": process_start_time(target), }