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:
Robert McMahon 2026-04-10 18:45:18 -07:00
parent 4ad0b4b249
commit 14ba9783fe
5 changed files with 102 additions and 2 deletions

View File

@ -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 Fedoras **`20-systemd-ssh-proxy.conf`**). FiWiControls 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`**:

View File

@ -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()

16
tests/conftest.py Normal file
View File

@ -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))

17
tests/fixtures/ssh_config_minimal vendored Normal file
View File

@ -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

View File

@ -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."""