chore: Python 3.13, mypy --strict in CI, eight bug fixes, docs

Squashes three changes:

## chore: support Python 3.13 and make mypy --strict pass in CI

Add 3.13 to the CI matrix and classifiers, run mypy in CI, fix the
14 strict errors it reported, and correct the README's minimum Python.

## fix: eight bugs found in a full review, each with a regression test

- channel: run_on_transport lost output sshd sent after the exit status;
  it now reads stdout and stderr to EOF before returning.
- ssh_server: a failed way back (restore or reload) raised out of
  harden_ssh_server after the host was touched; it is now reported in
  the result, with rolled_back False.
- host_status: a newer RPM kernel (RHEL, Fedora) was never seen, because
  its release, not its arch, followed the last "-".
- package_updates: an apt line wrapped at a blank lost the blank, and
  with it the package.
- kernel: an empty payload (gzip or base64 missing) read as a report.
- listening: "[fe80::1]%eth0:546", as ss prints it, kept its brackets.
- ping_sweep: a reply whose "success" was not a dict aborted the sweep.
- lag: a member of unknown speed (-1) lowered the LAG's speed.

## docs: bring the README and docstrings in line with the code

The firewall worked example showed the runtime stubs the README forbids;
the class table missed PhoneDriver and MediaDriver; ConfigLifecycleMixin
is mixed in by drivers, not by a role base; services, mac_acl and
packages named a class, a key and an exception that do not exist.
This commit is contained in:
christianmanivong committed 2026-10-13 01:22:22 +02:00
1 parent e58b65a3db
commit dd8977750d
25 files changed
+163 -52

No files matched your search

+17
View File
@@ -167,3 +167,20 @@ def test_close_closes_the_channel():
ParamikoExecStream(ch).close()
assert ch.closed
def test_run_keeps_output_that_arrives_after_the_exit_status():
"""sshd may send exit-status before the last stdout bytes."""
class EarlyExit(FakeChannel):
def exit_status_ready(self):
return True
def recv_ready(self):
return False
channel = EarlyExit(stdout=[b"head ", b"tail"], stderr=[b"warn"])
result = run_on_transport(FakeTransport(channel), "cmd")
assert result == CommandResult("head tail", "warn", 0)
+7
View File
@@ -131,6 +131,13 @@ class TestKernelRebootPending:
("7.0.14-19-pve", ["7.0.14-19-pve", "7.0.2-6-pve"], None),
# Arch: the running kernel's modules were replaced by the upgrade.
("6.10.5-arch1-1", ["6.10.9-arch1-1"], "6.10.9-arch1-1"),
# RHEL/Fedora: the release changes with every update, the arch does not.
(
"5.14.0-427.13.1.el9_4.x86_64",
["5.14.0-427.13.1.el9_4.x86_64", "5.14.0-503.11.1.el9_5.x86_64"],
"5.14.0-503.11.1.el9_5.x86_64",
),
("6.9.7-200.fc40.x86_64", ["6.9.7-200.fc40.x86_64"], None),
],
)
def test_the_newer_kernel_of_the_running_flavour(self, running, installed, newer):
+8
View File
@@ -85,6 +85,14 @@ class TestParsing:
with pytest.raises(ValueError):
parse_kernel_facts("sh: gzip: not found\n")
def test_an_empty_payload_raises(self):
"""gzip or base64 missing on the host: the frame is there, the report is not."""
with pytest.raises(ValueError):
parse_kernel_facts("KFACTS_BEGIN\nKFACTS_END\n")
empty = base64.b64encode(gzip.compress(b"")).decode()
with pytest.raises(ValueError):
parse_kernel_facts(f"KFACTS_BEGIN\n{empty}\nKFACTS_END\n")
def test_a_damaged_payload_raises(self):
with pytest.raises(ValueError):
parse_kernel_facts("KFACTS_BEGIN\nnot base64 at all!\nKFACTS_END\n")
+9
View File
@@ -74,3 +74,12 @@ def test_does_not_modify_its_input():
add_lag_interfaces(ifaces)
assert list(ifaces) == ["3"]
def test_an_unknown_member_speed_does_not_make_the_lag_negative():
ifaces = {
"3": _port(speed=-1.0, trunk_group="Trk3"),
"4": _port(speed=1000.0, trunk_group="Trk3"),
}
assert add_lag_interfaces(ifaces)["Trk3"]["speed"] == 1000.0
+1
View File
@@ -84,6 +84,7 @@ class TestParsing:
("0.0.0.0%ens18:68", "0.0.0.0", "ens18"),
("[::ffff:127.0.0.1]:8125", "::ffff:127.0.0.1", None),
("[fe80::1%eth0]:546", "fe80::1", "eth0"),
("[fe80::1]%eth0:546", "fe80::1", "eth0"), # what iproute2 ss prints
(":::22", "::", None), # iproute2 4.9 prints IPv6 without brackets
],
)
+10
View File
@@ -81,6 +81,16 @@ class TestAptUpgradable:
"3.0.13-0ubuntu3.5"
)
def test_a_line_wrapped_at_a_blank_keeps_the_blank(self):
wrapped = (
"openssl/noble-updates 3.0.13-0ubuntu3.6\n"
" amd64 [upgradable from: 3.0.13-0ubuntu3.5]\n__APT_RC=0\n"
)
assert _by_name(parse_apt_upgradable(wrapped))["openssl"]["current_version"] == (
"3.0.13-0ubuntu3.5"
)
def test_one_package_for_several_architectures_is_one_entry(self):
multiarch = (
"libc6/noble-updates 2.39-0ubuntu8.5 amd64 [upgradable from: 2.39-0ubuntu8.4]\n"
+7
View File
@@ -219,3 +219,10 @@ def test_ping_sweep_stops_when_stop_requested():
assert result["scanned"] == 1
assert len(driver.calls) == 1
@pytest.mark.parametrize("success", [True, "yes", ["x"]])
def test_a_malformed_success_is_no_answer_not_an_abort(success):
entry = PingSweepMixin._parse_ping_reply("192.0.2.1", {"success": success})
assert entry == {"ip": "192.0.2.1", "alive": False, "rtt_ms": None, "error": "malformed ping reply"}
+14
View File
@@ -335,3 +335,17 @@ def test_restoring_an_earlier_dropin_leaks_nothing_either():
host.files[DROPIN_PATH] = "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
host.harden_ssh_server({"mac": ["hmac-sha1"]}, verify=lambda: False)
assert host.files[DROPIN_PATH] == "# netOrk\nHostKeyAlgorithms ssh-ed25519\n"
def test_mixin_reports_a_failed_way_back_instead_of_raising():
class ReloadFails(FakeHost):
def run_command(self, command, **kwargs):
if "reload" in command or "HUP" in command:
return CommandResult("", "Failed to reload ssh.service", 1)
return super().run_command(command, **kwargs)
host = ReloadFails()
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
assert not result.applied and not result.rolled_back
assert "putting the previous configuration back failed" in result.message