Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 4 (#2199). osism/utils/ssh.py (294 LOC) handles SSH known_hosts maintenance for reset/undeploy flows: it ensures the file exists, resolves host identifiers (DNS + NetBox), removes entries via ssh-keygen -R, and creates timestamped backups.
Cohesive enough for a single test module.
Scope
Add tests/unit/utils/test_ssh.py covering all functions in osism/utils/ssh.py.
Test targets
ensure_known_hosts_file(known_hosts_path=KNOWN_HOSTS_PATH) — ssh.py:14
Patch os.path.exists, os.makedirs, os.chmod, and builtins.open. Use tmp_path fixture for a real-filesystem variant only if it simplifies a specific test.
- Directory and file already exist → returns
True, no makedirs/open/chmod calls
- Directory missing →
os.makedirs(dir, mode=0o755, exist_ok=True) invoked
- File missing →
open(path, "a") and os.chmod(path, 0o644) invoked, returns True
os.makedirs raises PermissionError → returns False, error logged
os.makedirs raises OSError → returns False, error logged
open raises Exception (catch-all) → returns False, error logged
- Custom path argument propagates (not just the default
/share/known_hosts)
get_host_identifiers(hostname) — ssh.py:55
Patch socket.gethostbyname and osism.utils.nb (the NetBox handle exposed via osism.utils).
- DNS resolves to a unique IP → returns
[hostname, ip]
- DNS resolves to the hostname itself (already in list) → no duplicate added
socket.gaierror raised → DNS fails silently, debug log, IP not appended
utils.nb is None → NetBox lookup skipped, returns just whatever DNS provided
utils.nb truthy, nb.dcim.devices.get(name=hostname) returns device with primary_ip4="10.0.0.5/24" → IP "10.0.0.5" (prefix stripped) appended; device.primary_ip4 access logged
nb.dcim.devices.get returns None → no IP appended
nb.dcim.devices.get raises → debug log, no IP appended (no propagation)
- Same IP from DNS and NetBox → only one copy in result
- Returned list always starts with the hostname
remove_known_hosts_entries(hostname, known_hosts_path=KNOWN_HOSTS_PATH) — ssh.py:98
Patch os.path.exists, subprocess.run, and get_host_identifiers.
- Empty / whitespace-only hostname → returns
False, warning logged, no subprocess call
known_hosts_path does not exist → returns True (debug log; nothing to clean)
get_host_identifiers returns [] → returns False, warning logged
get_host_identifiers raises → returns False, error logged
- Single identifier,
subprocess.run(["ssh-keygen", "-R", id, "-f", path], ...) returns returncode=0 with stderr containing "updated" → entry counted as removed, debug log, returns True
- Stderr contains the identifier substring (case-insensitive) → also counted as removed
- Stderr neither → counted as
"No SSH known_hosts entries found", still returns True
returncode != 0 → warning logged, but loop continues; per-identifier success not flipped to False
subprocess.run raises TimeoutExpired → error logged, success=False
subprocess.run raises CalledProcessError → error logged, success=False
subprocess.run raises generic Exception → error logged, success=False
- Multiple identifiers, one succeeds and one times out →
success=False
- Empty/whitespace identifier in the list → skipped (debug log), no subprocess call
- Final
entries_removed > 0 → info log "Successfully cleaned ..."; otherwise debug log
backup_known_hosts(known_hosts_path=KNOWN_HOSTS_PATH) — ssh.py:195
Patch os.path.exists, os.access, os.path.dirname, os.path.getsize, shutil.copy2, and datetime.datetime.now (or accept the timestamp variability).
- Empty path → warning logged, returns
None
- Path does not exist → debug log, returns
None
- File not readable (
os.R_OK false) → warning logged, returns None
- Backup directory missing → warning logged, returns
None
- Backup directory not writable → warning logged, returns
None
- Happy path →
shutil.copy2(src, backup_path) called; backup_path matches f"{src}.backup_<timestamp>"; returns the backup path
- After copy,
os.path.exists(backup) and os.path.getsize(backup) > 0 evaluated → both must be truthy or returns None
shutil.copy2 raises PermissionError → warning logged, returns None
shutil.copy2 raises OSError → warning logged, returns None
shutil.copy2 raises generic Exception → warning logged, returns None
cleanup_ssh_known_hosts_for_node(hostname, create_backup=True) — ssh.py:263
Patch backup_known_hosts and remove_known_hosts_entries.
create_backup=True and backup succeeds → debug log, then remove_known_hosts_entries called; returns its result
create_backup=True and backup returns None → no debug log, but cleanup still runs
create_backup=False → backup_known_hosts not called
remove_known_hosts_entries returns False → returns False
remove_known_hosts_entries raises → caught, error logged, returns False
- Always uses
KNOWN_HOSTS_PATH (custom path not exposed in this wrapper)
Mocking hints
- For
subprocess.run mocks, build the return as MagicMock(returncode=0, stderr="...updated...", stdout="").
- The test for the empty/whitespace identifier path is easiest if you patch
get_host_identifiers to return ["valid-host", "", " "] directly.
osism.utils.nb is exposed via the lazy __getattr__ in osism/utils/__init__.py. Patch it with mocker.patch("osism.utils.ssh.utils.nb", ...) (the import in ssh.py is from osism import utils, then utils.nb).
- For
backup_known_hosts, freeze the timestamp via mocker.patch("osism.utils.ssh.datetime.datetime") and set now.return_value.strftime.return_value="20260427_120000", or assert the path matches a regex.
Definition of Done
Dependencies
Background
Follow-up to #2192 (foundation) and PR #2193 (pytest + Zuul infrastructure). Part of Tier 4 (#2199).
osism/utils/ssh.py(294 LOC) handles SSH known_hosts maintenance for reset/undeploy flows: it ensures the file exists, resolves host identifiers (DNS + NetBox), removes entries viassh-keygen -R, and creates timestamped backups.Cohesive enough for a single test module.
Scope
Add
tests/unit/utils/test_ssh.pycovering all functions inosism/utils/ssh.py.Test targets
ensure_known_hosts_file(known_hosts_path=KNOWN_HOSTS_PATH)—ssh.py:14Patch
os.path.exists,os.makedirs,os.chmod, andbuiltins.open. Usetmp_pathfixture for a real-filesystem variant only if it simplifies a specific test.True, nomakedirs/open/chmodcallsos.makedirs(dir, mode=0o755, exist_ok=True)invokedopen(path, "a")andos.chmod(path, 0o644)invoked, returnsTrueos.makedirsraisesPermissionError→ returnsFalse, error loggedos.makedirsraisesOSError→ returnsFalse, error loggedopenraisesException(catch-all) → returnsFalse, error logged/share/known_hosts)get_host_identifiers(hostname)—ssh.py:55Patch
socket.gethostbynameandosism.utils.nb(the NetBox handle exposed viaosism.utils).[hostname, ip]socket.gaierrorraised → DNS fails silently, debug log, IP not appendedutils.nbisNone→ NetBox lookup skipped, returns just whatever DNS providedutils.nbtruthy,nb.dcim.devices.get(name=hostname)returns device withprimary_ip4="10.0.0.5/24"→ IP"10.0.0.5"(prefix stripped) appended;device.primary_ip4access loggednb.dcim.devices.getreturnsNone→ no IP appendednb.dcim.devices.getraises → debug log, no IP appended (no propagation)remove_known_hosts_entries(hostname, known_hosts_path=KNOWN_HOSTS_PATH)—ssh.py:98Patch
os.path.exists,subprocess.run, andget_host_identifiers.False, warning logged, no subprocess callknown_hosts_pathdoes not exist → returnsTrue(debug log; nothing to clean)get_host_identifiersreturns[]→ returnsFalse, warning loggedget_host_identifiersraises → returnsFalse, error loggedsubprocess.run(["ssh-keygen", "-R", id, "-f", path], ...)returnsreturncode=0with stderr containing"updated"→ entry counted as removed, debug log, returnsTrue"No SSH known_hosts entries found", still returnsTruereturncode != 0→ warning logged, but loop continues; per-identifier success not flipped toFalsesubprocess.runraisesTimeoutExpired→ error logged,success=Falsesubprocess.runraisesCalledProcessError→ error logged,success=Falsesubprocess.runraises genericException→ error logged,success=Falsesuccess=Falseentries_removed > 0→ info log "Successfully cleaned ..."; otherwise debug logbackup_known_hosts(known_hosts_path=KNOWN_HOSTS_PATH)—ssh.py:195Patch
os.path.exists,os.access,os.path.dirname,os.path.getsize,shutil.copy2, anddatetime.datetime.now(or accept the timestamp variability).NoneNoneos.R_OKfalse) → warning logged, returnsNoneNoneNoneshutil.copy2(src, backup_path)called;backup_pathmatchesf"{src}.backup_<timestamp>"; returns the backup pathos.path.exists(backup) and os.path.getsize(backup) > 0evaluated → both must be truthy or returnsNoneshutil.copy2raisesPermissionError→ warning logged, returnsNoneshutil.copy2raisesOSError→ warning logged, returnsNoneshutil.copy2raises genericException→ warning logged, returnsNonecleanup_ssh_known_hosts_for_node(hostname, create_backup=True)—ssh.py:263Patch
backup_known_hostsandremove_known_hosts_entries.create_backup=Trueand backup succeeds → debug log, thenremove_known_hosts_entriescalled; returns its resultcreate_backup=Trueand backup returnsNone→ no debug log, but cleanup still runscreate_backup=False→backup_known_hostsnot calledremove_known_hosts_entriesreturnsFalse→ returnsFalseremove_known_hosts_entriesraises → caught, error logged, returnsFalseKNOWN_HOSTS_PATH(custom path not exposed in this wrapper)Mocking hints
subprocess.runmocks, build the return asMagicMock(returncode=0, stderr="...updated...", stdout="").get_host_identifiersto return["valid-host", "", " "]directly.osism.utils.nbis exposed via the lazy__getattr__inosism/utils/__init__.py. Patch it withmocker.patch("osism.utils.ssh.utils.nb", ...)(the import inssh.pyisfrom osism import utils, thenutils.nb).backup_known_hosts, freeze the timestamp viamocker.patch("osism.utils.ssh.datetime.datetime")and setnow.return_value.strftime.return_value="20260427_120000", or assert the path matches a regex.Definition of Done
tests/unit/utils/test_ssh.pycreatedpytest --cov=osism.utils.sshshows ≥ 95 %pipenv run pytest tests/unit/utils/test_ssh.pypasses locallyflake8,mypy,python-blackremain greenpython-osism-unit-testspassesDependencies