FiWiManager/fiwi/fiwi_relay/host.py

518 lines
18 KiB
Python

"""
Fi-Wi **relay** to a hub host: same bootstrap **over SSH** (Fedora → Pi) or **locally** (on the Pi).
* ``local=False`` (default): ``ssh user@host bash -lc ''`` using ``FIWI_REMOTE_SCRIPT`` for ``cd``.
* ``local=True``: ``bash -lc ''`` on this machine using :func:`fiwi.paths.base_dir` as the repo root.
The CLI picks **local** automatically when the configured ``user@host`` matches this machine
(:meth:`ssh_target_points_here`); use ``--remote`` to force SSH, ``--local`` to force local.
"""
from __future__ import annotations
import os
import shlex
import subprocess
import sys
from dataclasses import dataclass, replace
def _ssh_target_user_host(ssh_target: str) -> tuple[str, str]:
if "@" not in ssh_target:
raise ValueError("expected user@host")
u, h = ssh_target.rsplit("@", 1)
u, h = u.strip(), h.strip()
if not u or not h:
raise ValueError("expected user@host")
return u, h
def _local_ipv4_addresses() -> set[str]:
import socket
out: set[str] = {"127.0.0.1"}
try:
for nm in (socket.gethostname(), socket.getfqdn()):
try:
_, _, ips = socket.gethostbyname_ex(nm)
out.update(ips)
except OSError:
pass
except OSError:
pass
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.2)
try:
s.connect(("8.8.8.8", 80))
out.add(s.getsockname()[0])
except OSError:
pass
finally:
s.close()
except OSError:
pass
return out
def ssh_target_points_here(ssh_target: str) -> bool:
"""
True if ``ssh_target`` refers to **this** OS user and host (hostname, FQDN short name, or IPv4).
Used to skip SSH when the configured hub is the machine running Fi-Wi.
"""
import getpass
import socket
try:
cfg_user, cfg_host = _ssh_target_user_host(ssh_target)
except ValueError:
return False
if getpass.getuser() != cfg_user:
return False
h = cfg_host.lower()
if h in ("localhost", "127.0.0.1", "::1", "[::1]"):
return True
hn = socket.gethostname().lower()
if h == hn:
return True
fq = socket.getfqdn().lower()
if h == fq:
return True
if "." in fq and h == fq.split(".", 1)[0]:
return True
local_ips = _local_ipv4_addresses()
if h in local_ips:
return True
try:
for ai in socket.getaddrinfo(cfg_host, None, socket.AF_INET, socket.SOCK_STREAM):
if ai[4][0] in local_ips:
return True
except OSError:
pass
return False
def _default_label(ssh_target: str) -> str:
host = ssh_target.split("@", 1)[-1] if "@" in ssh_target else ssh_target
return host.replace(".", "_")
def _bash_cd_remote_repo(remote_script: str) -> str:
"""
``cd`` to the parent of ``FIWI_REMOTE_SCRIPT`` on the remote (``bash -lc``).
``~/`` becomes ``$HOME/`` so tilde expands reliably.
"""
s = (remote_script or "").strip()
if "/" not in s:
return 'echo "Invalid FIWI_REMOTE_SCRIPT"; exit 1'
parent = s.rsplit("/", 1)[0]
if parent.startswith("~/"):
return f'cd "$HOME/{parent[2:]}"'
return f"cd {shlex.quote(parent)}"
def suggested_remote_venv_python(remote_script: str) -> str:
"""
Typical ``remote_python`` after :meth:`FiWiRelay.setup`: ``env/bin/python3`` next to ``fiwi.py``.
Uses the same parent directory as ``FIWI_REMOTE_SCRIPT`` (repo root on the hub).
"""
s = (remote_script or "").strip()
if "/" not in s:
return "~/env/bin/python3"
parent = s.rsplit("/", 1)[0]
return f"{parent}/env/bin/python3"
def _remote_readiness_bash_inner(cfg) -> str:
"""Single ``bash -lc`` script: markers for :func:`parse_remote_readiness_stdout`."""
from fiwi.ssh import SshNode
script = (cfg.script or "").strip()
py = SshNode.remote_path_bash_word(cfg.python)
cd = _bash_cd_remote_repo(cfg.script)
if not script:
return (
"echo __FIWI_CD__=skip; echo __FIWI_SCRIPT__=missing; echo __FIWI_VENV__=no; "
"echo __FIWI_BRAINSTEM__=fail; echo __FIWI_BRAINSTEM_VENV__=na; "
"echo __FIWI_HOSTNAME__=; echo __FIWI_UNAME__=; echo __FIWI_PY__="
)
return (
"set +e; "
+ cd
+ " && { "
+ 'echo __FIWI_HOSTNAME__="$(hostname 2>/dev/null)"; '
+ 'echo __FIWI_UNAME__="$(uname -sm 2>/dev/null)"; '
+ f"echo __FIWI_SCRIPT__=$(test -f {script} && echo ok || echo missing); "
+ "echo __FIWI_VENV__=$(test -d env && echo yes || echo no); "
+ f"echo __FIWI_BRAINSTEM__=$({py} -c 'import brainstem' 2>/dev/null && echo ok || echo fail); "
+ "echo __FIWI_BRAINSTEM_VENV__=$(if test -x env/bin/python3; then "
+ "env/bin/python3 -c 'import brainstem' 2>/dev/null && echo ok || echo fail; else echo na; fi); "
+ f"PY_CMD={py}; "
+ "echo __FIWI_PY__=$($PY_CMD -c 'import sys; print(sys.executable)'); "
+ "echo __FIWI_CD__=ok; "
+ "} || { echo __FIWI_CD__=fail; echo __FIWI_HOSTNAME__=; echo __FIWI_UNAME__=; "
+ "echo __FIWI_SCRIPT__=missing; echo __FIWI_VENV__=no; echo __FIWI_BRAINSTEM__=fail; "
+ "echo __FIWI_BRAINSTEM_VENV__=na; echo __FIWI_PY__=; exit 0; }"
)
def parse_remote_readiness_stdout(stdout: str) -> dict[str, str]:
"""
Parse ``__FIWI_*__=…`` lines emitted by :func:`probe_remote_hub_readiness`.
Unknown or duplicate keys: last line wins.
"""
out: dict[str, str] = {}
for ln in (stdout or "").splitlines():
ln = ln.strip()
if "=" not in ln or not ln.startswith("__FIWI_"):
continue
key, _, rest = ln.partition("=")
if key in (
"__FIWI_CD__",
"__FIWI_SCRIPT__",
"__FIWI_VENV__",
"__FIWI_BRAINSTEM__",
"__FIWI_BRAINSTEM_VENV__",
"__FIWI_HOSTNAME__",
"__FIWI_UNAME__",
"__FIWI_PY__",
):
out[key] = rest.strip()
return out
@dataclass(frozen=True)
class RemoteReadiness:
"""
Result of :func:`probe_remote_hub_readiness` — whether a hub host can run Fi-Wi (BrainStem, paths).
Use :meth:`suggests_fiwi_relay_setup` to decide if ``python -m fiwi.fiwi_relay`` (or
:meth:`FiWiRelay.setup`) is likely needed: configured ``FIWI_REMOTE_PYTHON`` cannot import
``brainstem`` after ``cd`` to the repo from ``FIWI_REMOTE_SCRIPT``.
"""
ssh_connected: bool
timed_out: bool
ssh_exit_code: int
cd_ok: bool
script_present: bool
venv_present: bool
brainstem_import_ok: bool
brainstem_ok_in_venv: bool
hostname: str
uname: str
remote_python_executable: str
raw_stdout: str
raw_stderr: str
@property
def suggests_remote_python_venv(self) -> bool:
"""
True when ``brainstem`` imports in ``env/bin/python3`` but not in ``FIWI_REMOTE_PYTHON``.
Fix: set ``remote_python`` in INI (see :func:`suggested_remote_venv_python`).
"""
if not self.ssh_connected or self.timed_out:
return False
if not self.cd_ok or not self.script_present:
return False
if self.brainstem_import_ok:
return False
return self.brainstem_ok_in_venv
@property
def suggests_fiwi_relay_setup(self) -> bool:
"""
True when the hub still needs a venv / pip install (``brainstem`` missing even from
``env/bin/python3``). If :meth:`suggests_remote_python_venv` is true, relay setup already
ran — update ``remote_python`` instead.
"""
if not self.ssh_connected or self.timed_out:
return False
if not self.cd_ok or not self.script_present:
return False
if self.brainstem_import_ok:
return False
if self.brainstem_ok_in_venv:
return False
return True
def probe_remote_hub_readiness(ssh_target: str, *, timeout: float = 45.0) -> RemoteReadiness:
"""
One SSH session: hostname, repo ``cd``, ``fiwi.py`` present, ``env/`` exists, ``import brainstem``
using merged ``FIWI_REMOTE_PYTHON``.
Requires prior :func:`fiwi.paths.configure` (and ``FIWI_CONFIG`` if you use INI profiles).
"""
from fiwi.ssh import SshNode, SshNodeConfig
cfg = SshNodeConfig.load()
inner = _remote_readiness_bash_inner(cfg)
argv = [cfg.ssh_bin, *cfg.ssh_extra_argv, ssh_target, SshNode.remote_bash_lc_ssh_token(inner)]
empty = RemoteReadiness(
ssh_connected=False,
timed_out=False,
ssh_exit_code=-1,
cd_ok=False,
script_present=False,
venv_present=False,
brainstem_import_ok=False,
brainstem_ok_in_venv=False,
hostname="",
uname="",
remote_python_executable="",
raw_stdout="",
raw_stderr="",
)
try:
proc = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
errors="replace",
)
except subprocess.TimeoutExpired:
return replace(
empty,
timed_out=True,
raw_stderr="ssh timed out",
)
except OSError as exc:
return replace(
empty,
raw_stderr=str(exc),
)
out = proc.stdout or ""
err = proc.stderr or ""
rc = proc.returncode if proc.returncode is not None else 1
d = parse_remote_readiness_stdout(out)
ssh_connected = rc == 0
cd_mark = d.get("__FIWI_CD__", "")
cd_ok = cd_mark == "ok"
script_present = d.get("__FIWI_SCRIPT__", "") == "ok"
venv_present = d.get("__FIWI_VENV__", "") == "yes"
brainstem_import_ok = d.get("__FIWI_BRAINSTEM__", "") == "ok"
bv = d.get("__FIWI_BRAINSTEM_VENV__", "")
brainstem_ok_in_venv = bv == "ok"
return RemoteReadiness(
ssh_connected=ssh_connected,
timed_out=False,
ssh_exit_code=rc,
cd_ok=cd_ok,
script_present=script_present,
venv_present=venv_present,
brainstem_import_ok=brainstem_import_ok,
brainstem_ok_in_venv=brainstem_ok_in_venv,
hostname=d.get("__FIWI_HOSTNAME__", ""),
uname=d.get("__FIWI_UNAME__", ""),
remote_python_executable=d.get("__FIWI_PY__", ""),
raw_stdout=out,
raw_stderr=err,
)
@dataclass
class FiWiRelay:
"""
Relay to a Fi-Wi **hub host** (e.g. ``name="rpi5"``, ``ssh_target="user@host"``).
Use :meth:`from_config` after :func:`fiwi.paths.configure` so ``FIWI_*`` and INI are merged.
"""
name: str
ssh_target: str
@staticmethod
def ssh_target_points_here(ssh_target: str) -> bool:
"""See :func:`ssh_target_points_here`."""
return ssh_target_points_here(ssh_target)
def __post_init__(self) -> None:
t = (self.ssh_target or "").strip()
if not t or "@" not in t:
raise ValueError("ssh_target must be non-empty user@host")
object.__setattr__(self, "ssh_target", t)
n = (self.name or "").strip() or _default_label(t)
object.__setattr__(self, "name", n)
@classmethod
def from_config(
cls,
app_dir: str,
*,
ssh_target: str | None = None,
name: str | None = None,
) -> FiWiRelay:
import fiwi.paths as paths_mod
paths_mod.configure(os.path.abspath(app_dir))
from fiwi.ssh import SshNodeConfig
raw = (SshNodeConfig.load().calibrate_remotes or "").strip()
if not raw:
raise ValueError(
"No remote hub configured: set [remote_hubs] hosts or FIWI_REMOTE_HUBS / "
"FIWI_CALIBRATE_REMOTES (and optional [remote_hubs] label)."
)
first = raw.split(",")[0].strip()
tgt = (ssh_target or first).strip()
nm = (name or (os.environ.get("FIWI_REMOTE_HUB_LABEL") or "").strip() or _default_label(tgt)).strip()
return cls(name=nm, ssh_target=tgt)
@classmethod
def for_local_machine(cls, app_dir: str, *, name: str | None = None) -> FiWiRelay:
"""
Hub **is** this machine: no ``[remote_hubs]`` required. Use with :meth:`install_dependencies` / :meth:`setup` and ``local=True``.
``ssh_target`` is set to ``user@hostname`` for logging only; it is not used when ``local=True``.
"""
import getpass
import socket
import fiwi.paths as paths_mod
paths_mod.configure(os.path.abspath(app_dir))
u = getpass.getuser()
host = socket.gethostname()
nm = (name or (os.environ.get("FIWI_REMOTE_HUB_LABEL") or "").strip() or host).strip()
return cls(name=nm, ssh_target=f"{u}@{host}")
@classmethod
def each_from_config(cls, app_dir: str) -> list[FiWiRelay]:
"""One object per merged ``user@host`` (INI ``label`` names the first; extras get ``label_2``, …)."""
import fiwi.paths as paths_mod
paths_mod.configure(os.path.abspath(app_dir))
from fiwi.ssh import SshNodeConfig
raw = (SshNodeConfig.load().calibrate_remotes or "").strip()
if not raw:
return []
default_label = (os.environ.get("FIWI_REMOTE_HUB_LABEL") or "").strip()
out: list[FiWiRelay] = []
j = 0
for part in raw.split(","):
h = part.strip()
if not h:
continue
if j == 0:
nm = default_label or _default_label(h)
elif default_label:
nm = f"{default_label}_{j + 1}"
else:
nm = _default_label(h)
j += 1
out.append(cls(name=nm, ssh_target=h))
return out
def _ssh_argv(self, inner: str) -> list[str]:
from fiwi.ssh import SshNode, SshNodeConfig
cfg = SshNodeConfig.load()
return [
cfg.ssh_bin,
*cfg.ssh_extra_argv,
self.ssh_target,
SshNode.remote_bash_lc_ssh_token(inner),
]
def install_dependencies(
self,
*,
timeout: float = 600.0,
dry_run: bool = False,
local: bool = False,
) -> int:
"""
Ensure ``env/`` venv and ``pip install -r requirements.txt`` under the Fi-Wi repo.
* ``local=False``: run the script on ``ssh_target`` (Fedora → Pi).
* ``local=True``: run on **this** host; repo root is :func:`fiwi.paths.base_dir` (Pi interactive shell).
Returns shell exit code (0 on success).
"""
verify = './env/bin/python3 -c \'import brainstem; print("brainstem OK")\''
if local:
import fiwi.paths as paths_mod
base = paths_mod.base_dir()
cd = f"cd {shlex.quote(base)}"
inner = (
"set -euo pipefail; "
f"{cd}; "
"test -f requirements.txt || { echo 'requirements.txt missing in repo root'; exit 1; }; "
"test -f fiwi.py || { echo 'fiwi.py missing in repo root'; exit 1; }; "
"if [ ! -d env ]; then python3 -m venv env; fi; "
"./env/bin/python3 -m pip install -U pip; "
"./env/bin/python3 -m pip install -r requirements.txt; "
f"{verify}"
)
argv = ["bash", "-lc", inner]
print(
f"[{self.name}] local (this machine): pip install in {base}\n"
f" bash -lc ''",
flush=True,
)
else:
from fiwi.ssh import SshNodeConfig
cfg = SshNodeConfig.load()
script = (cfg.script or "").strip()
cd = _bash_cd_remote_repo(script)
inner = (
"set -euo pipefail; "
f"{cd}; "
"test -f requirements.txt || { echo 'requirements.txt missing in repo root'; exit 1; }; "
"test -f fiwi.py || { echo 'fiwi.py missing in repo root'; exit 1; }; "
"if [ ! -d env ]; then python3 -m venv env; fi; "
"./env/bin/python3 -m pip install -U pip; "
"./env/bin/python3 -m pip install -r requirements.txt; "
f"{verify}"
)
argv = self._ssh_argv(inner)
print(
f'[{self.name}] ssh {self.ssh_target}: pip install (repo from FIWI_REMOTE_SCRIPT)\n '
f"{' '.join(shlex.quote(a) for a in argv[:1])} … bash -lc ''",
flush=True,
)
if dry_run:
print(" (dry-run: not executing)", flush=True)
return 0
try:
proc = subprocess.run(
argv,
stdin=subprocess.DEVNULL,
timeout=timeout,
)
except subprocess.TimeoutExpired:
print(f"[{self.name}] timed out after {timeout}s", file=sys.stderr, flush=True)
return 124
except OSError as exc:
print(f"[{self.name}] {exc}", file=sys.stderr, flush=True)
return 1
code = proc.returncode if proc.returncode is not None else 1
if code == 0 and not dry_run and not local:
from fiwi.ssh import SshNodeConfig
hint = suggested_remote_venv_python(SshNodeConfig.load().script)
print(
f"[{self.name}] Next: set remote_python in [remote_ssh] to {hint!r} on the "
f"workstation INI so SSH uses the venv (brainstem is not on system python3).",
flush=True,
)
return code
install_depends = install_dependencies
def setup(self, **kwargs) -> int:
"""Bootstrap this remote hub (same as :meth:`install_dependencies`)."""
return self.install_dependencies(**kwargs)