518 lines
18 KiB
Python
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)
|