fix(ssh): FIWI_SSH_CONFIG -F for tests; skip remote tests without pubkey auth
- node_control: honor FIWI_SSH_CONFIG (insert -F after ssh) so clients skip broken /etc/ssh/ssh_config.d drop-ins; use same argv for clean/pkill helpers. - tests/conftest.py: setdefault FIWI_SSH_CONFIG to tests/fixtures/ssh_config_minimal. - Fixture: writable UserKnownHostsFile, BatchMode, accept-new. - test_remote_power_dependencies: skipUnless passwordless root@FIWI_REMOTE_IP; setdefault fixture path for unittest runs; FIWI_FORCE_REMOTE_TESTS to always run. - docs/install.md: document FIWI_SSH_CONFIG for Fedora-style ssh_config.d errors. Made-with: Cursor
This commit is contained in:
parent
4ad0b4b249
commit
14ba9783fe
|
|
@ -78,6 +78,8 @@ ssh -o BatchMode=yes -i ~/.ssh/id_ed25519 root@192.168.1.39 true
|
|||
|
||||
(no output, exit **0**). If this fails, fix keys and **`/root/.ssh/authorized_keys`** on the rig first.
|
||||
|
||||
On some Linux workstations, OpenSSH aborts before connecting with **`Bad owner or permissions on …/ssh_config.d/…`** (for example Fedora’s **`20-systemd-ssh-proxy.conf`**). FiWiControl’s pytest session sets **`FIWI_SSH_CONFIG`** to a minimal client file under **`tests/fixtures/`** so **`ssh -F`** skips the system **`/etc/ssh/ssh_config`**. For ad hoc runs (inventory, scripts), export **`FIWI_SSH_CONFIG`** to that same file, or to your own minimal **`ssh_config`** that does not **`Include`** the broken drop-in.
|
||||
|
||||
### 2. Repo on the remote
|
||||
|
||||
The Pi needs a **git checkout** of FiWiControl (same tree you pushed). Example for **`root`**:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@
|
|||
# - Many: CommandManager().add_command(...) / stop_all() / list_active()
|
||||
#
|
||||
# SSH targets (sshtype "ssh"): passwordless SSH is required — see docs/node-control-asyncio-design.md (## Running tests).
|
||||
# Optional: set FIWI_SSH_CONFIG to a minimal ssh_config path so the client uses ``ssh -F`` and skips broken system
|
||||
# ``/etc/ssh/ssh_config`` / ``ssh_config.d`` snippets (pytest sets this via tests/conftest.py when the fixture exists).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -26,6 +28,33 @@ from datetime import datetime
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _maybe_prepend_ssh_config_file(ssh_argv: list) -> None:
|
||||
"""If FIWI_SSH_CONFIG is set, insert ``-F <path>`` after ``/usr/bin/ssh`` (mutates ssh_argv).
|
||||
|
||||
OpenSSH ignores system ``/etc/ssh/ssh_config`` when ``-F`` is used, which avoids
|
||||
aborting on bad permissions under ``/etc/ssh/ssh_config.d/`` (common on Fedora
|
||||
when ``20-systemd-ssh-proxy.conf`` is a symlink with strict ownership checks).
|
||||
"""
|
||||
if not ssh_argv or ssh_argv[0] != "/usr/bin/ssh":
|
||||
return
|
||||
raw = os.environ.get("FIWI_SSH_CONFIG", "").strip()
|
||||
if not raw:
|
||||
return
|
||||
path = os.path.expanduser(raw)
|
||||
if not os.path.isfile(path):
|
||||
logger.warning("FIWI_SSH_CONFIG is set but not a readable file: %s", path)
|
||||
return
|
||||
ssh_argv.insert(1, "-F")
|
||||
ssh_argv.insert(2, path)
|
||||
|
||||
|
||||
def _direct_ssh_client_argv() -> list:
|
||||
"""``ssh`` argv for a single hop to ``root@<ip>`` (matches legacy ``clean`` / pkill helpers)."""
|
||||
argv = ["/usr/bin/ssh"]
|
||||
_maybe_prepend_ssh_config_file(argv)
|
||||
return argv
|
||||
|
||||
|
||||
class ssh_node:
|
||||
DEFAULT_IO_TIMEOUT = 30.0
|
||||
DEFAULT_CMD_TIMEOUT = 30
|
||||
|
|
@ -107,6 +136,8 @@ class ssh_node:
|
|||
logging.debug("ssh={} ".format(self.ssh))
|
||||
else:
|
||||
raise ValueError("ssh type invalid")
|
||||
if self.sshtype in ("ssh", "ush") and self.ssh and self.ssh[0] == "/usr/bin/ssh":
|
||||
_maybe_prepend_ssh_config_file(self.ssh)
|
||||
ssh_node.instances.add(self)
|
||||
|
||||
async def rexec(
|
||||
|
|
@ -121,8 +152,9 @@ class ssh_node:
|
|||
return this_session
|
||||
|
||||
async def clean(self):
|
||||
argv = _direct_ssh_client_argv() + ["root@{}".format(self.ipaddr), "pkill", "dmesg"]
|
||||
childprocess = await asyncio.create_subprocess_exec(
|
||||
"/usr/bin/ssh", "root@{}".format(self.ipaddr), "pkill", "dmesg",
|
||||
*argv,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
)
|
||||
stdout, stderr = await childprocess.communicate()
|
||||
|
|
@ -506,8 +538,9 @@ class ssh_session:
|
|||
async def close(self):
|
||||
if self.control_master:
|
||||
logging.info("control master close called {}".format(self.controlmasters))
|
||||
argv = _direct_ssh_client_argv() + ["root@{}".format(self.ipaddr), "pkill", "dmesg"]
|
||||
childprocess = await asyncio.create_subprocess_exec(
|
||||
"/usr/bin/ssh", "root@{}".format(self.ipaddr), "pkill", "dmesg",
|
||||
*argv,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
)
|
||||
stdout, stderr = await childprocess.communicate()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
# Copyright (c) 2026 Umber
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0; see LICENSE.
|
||||
|
||||
"""Pytest hooks: default FIWI_SSH_CONFIG so SSH integration tests skip broken system drop-ins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def pytest_configure(config) -> None:
|
||||
minimal = Path(__file__).resolve().parent / "fixtures" / "ssh_config_minimal"
|
||||
if minimal.is_file():
|
||||
os.environ.setdefault("FIWI_SSH_CONFIG", str(minimal))
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# Minimal OpenSSH client config for FiWiControl tests and tooling.
|
||||
#
|
||||
# When ssh is run with: ssh -F /path/to/this/file ...
|
||||
# the system-wide /etc/ssh/ssh_config is not read, so broken or
|
||||
# permission-denied snippets under /etc/ssh/ssh_config.d/ (e.g. Fedora's
|
||||
# 20-systemd-ssh-proxy.conf) cannot abort the client before connecting.
|
||||
#
|
||||
# Set FIWI_SSH_CONFIG to this path (pytest does this automatically via
|
||||
# tests/conftest.py when the file exists). Override with your own file if needed.
|
||||
|
||||
Host *
|
||||
# Writable path so ssh never touches /root/.ssh/known_hosts when tests run as root
|
||||
# (e.g. sandbox/CI) or when that directory is not stat-able.
|
||||
UserKnownHostsFile /tmp/fiwicontrol_pytest_known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
# Fail fast instead of prompting (no TTY / no ssh-askpass in pytest).
|
||||
BatchMode yes
|
||||
|
|
@ -8,10 +8,36 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fiwicontrol.commands.node_control import ssh_node
|
||||
|
||||
# Same default as tests/conftest.py so `python -m unittest …` skips broken system ssh_config.d.
|
||||
_minimal_ssh = Path(__file__).resolve().parent / "fixtures" / "ssh_config_minimal"
|
||||
if _minimal_ssh.is_file():
|
||||
os.environ.setdefault("FIWI_SSH_CONFIG", str(_minimal_ssh))
|
||||
|
||||
|
||||
def _remote_root_ssh_ok() -> bool:
|
||||
"""True when passwordless ``ssh root@<FIWI_REMOTE_IP> true`` succeeds (BatchMode, short timeout)."""
|
||||
if os.environ.get("FIWI_FORCE_REMOTE_TESTS", "").strip().lower() in ("1", "true", "yes"):
|
||||
return True
|
||||
ip = os.environ.get("FIWI_REMOTE_IP", "192.168.1.39")
|
||||
cmd: list[str] = ["/usr/bin/ssh"]
|
||||
cfg = os.environ.get("FIWI_SSH_CONFIG", "").strip()
|
||||
if cfg:
|
||||
expanded = os.path.expanduser(cfg)
|
||||
if os.path.isfile(expanded):
|
||||
cmd.extend(["-F", expanded])
|
||||
cmd.extend(["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", "root@{}".format(ip), "true"])
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, timeout=12, check=False)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
return r.returncode == 0
|
||||
|
||||
_REMOTE_INSTALL_HINT = (
|
||||
"On the Raspberry Pi (as root), from your FiWiControl clone, run:\n"
|
||||
" cd ~/Code/FiWiControl # or your path\n"
|
||||
|
|
@ -38,6 +64,12 @@ class _RemoteNodeMixin:
|
|||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
_remote_root_ssh_ok(),
|
||||
"passwordless SSH to root@{} unavailable (set keys on the rig; see docs/install.md, SSH from workstation).".format(
|
||||
os.environ.get("FIWI_REMOTE_IP", "192.168.1.39"),
|
||||
),
|
||||
)
|
||||
class TestRemotePowerDependencies(_RemoteNodeMixin, unittest.IsolatedAsyncioTestCase):
|
||||
async def test_remote_imports_fiwicontrol_power_brainstem_serial(self) -> None:
|
||||
"""Remote must have fiwicontrol, brainstem, and pyserial (serial) for full discovery."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue