Skip to content

Unit tests for osism/utils/ssh.py #2231

@berendt

Description

@berendt

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=Falsebackup_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

  • tests/unit/utils/test_ssh.py created
  • All listed cases covered
  • pytest --cov=osism.utils.ssh shows ≥ 95 %
  • pipenv run pytest tests/unit/utils/test_ssh.py passes locally
  • flake8, mypy, python-black remain green
  • Zuul job python-osism-unit-tests passes

Dependencies

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions