feat!: settle the open review questions; release 4.7.0

## feat!: settle the open review questions on roles, diffs, SSH and SNMP

- roles: a driver that sets ROLE itself replaces the role it inherits,
  so a Windows driver on OSDriver is "windows", not also "linux".
- os: OSDriver's account listing is get_os_users; NAPALM's get_users has
  another shape and a runtime stub, so hasattr could not tell.
- README: NAPALM's own getters must come from the driver or a mixin
  listed before the first role base; its stubs win the MRO otherwise.
- firewall/DHCP diffs: an empty key, a key given twice, or one more than
  one live entry holds raises ValueError instead of overwriting by guess;
  live entries without a key are never matched.
- ssh_server: only host key algorithms the host has a key for count as
  staying; when the keys cannot be read, the host key list is not
  touched. sshd_config and its copy keep their file mode (GNU and BSD
  stat), 644 only for a new file.
- SNMP: 64-bit ifXTable octets and ifHighSpeed, with the 32-bit counters
  as fallback for agents without ifXTable.
- pyproject: declare typing-extensions, which models.py imports.

BREAKING CHANGE: OSDriver.get_users is now get_os_users; diffs raise
ValueError on keys that cannot name one entry; plan_ssh_hardening
removes no host key algorithm without host_keys.

## fix: tell "could not read" and "does not install" apart in update and VIP readers

- package_updates: the dnf security reader records dnf's exit status and
  raises when it is missing or non-zero, as the apt reader does; a failed
  dnf no longer reads as "no security advisories".
- host_status: an enabled dnf-automatic.timer counts as auto updates only
  with apply_updates = yes (dnf5's automatic.conf wins over dnf4's);
  dnf-automatic-install.timer always does. Unattended-upgrades counts only
  while the program is installed, not from a conffile left behind.
- virtual_ips: a host with systemctl but without systemd as PID 1 reads as
  "did not look" (None), not as "has none". keepalived blocks whose brace
  is on the next line are read.

BREAKING CHANGE: parse_dnf_security expects DNF_SECURITY_COMMAND's
__DNF_RC marker and raises ValueError without it.
This commit is contained in:
christianmanivong committed 2026-10-13 06:58:40 +02:00
1 parent e509445572
commit 155b5bbbe4
21 files changed
+701 -74

No files matched your search

+22
View File
@@ -206,3 +206,25 @@ class TestApplyReservationSet:
assert driver.commits == 0
assert driver.applied == []
assert lines == ["[commit] no changes"]
class TestKeysThatCannotNameOneReservation:
def test_a_live_reservation_without_mac_is_never_matched(self):
driver = _FakeDhcpServer([_reservation(uuid="b", mac="")])
diff = driver.diff_dhcp_reservations([_reservation()])
assert diff == {"add": [_reservation()], "update": []}
@pytest.mark.parametrize(
"live, desired",
[
([], [_reservation(mac="")]),
([], [_reservation(mac="AA-BB-CC-DD-EE-02"), _reservation(mac="aabb.ccdd.ee02")]),
([_reservation(uuid="a"), _reservation(uuid="b")], [_reservation()]),
],
ids=["empty desired", "desired twice", "two live share it"],
)
def test_raises(self, live, desired):
with pytest.raises(ValueError):
_FakeDhcpServer(live).diff_dhcp_reservations(desired)
+10
View File
@@ -226,3 +226,13 @@ def test_device_specific_methods_are_absent_until_implemented(method: str) -> No
def test_calling_a_missing_device_method_fails_loudly() -> None:
with pytest.raises(AttributeError):
_BareDriver().get_dhcp_subnets()
@pytest.mark.parametrize(
"desired",
[[_subnet(subnet="")], [_subnet(), _subnet(subnet=" 10.10.20.0/24 ")]],
ids=["empty", "twice"],
)
def test_a_subnet_that_cannot_name_one_raises(desired):
with pytest.raises(ValueError):
_FakeDhcpServer([]).diff_dhcp_subnets(desired)
+27
View File
@@ -169,3 +169,30 @@ class TestApplyFirewallRuleset:
assert driver.applied == []
assert driver.committed is True
class TestKeysThatCannotNameOneRule:
"""An empty or shared description must never overwrite a rule by guess."""
def test_a_live_rule_without_description_is_never_matched(self):
driver = _FakeFirewall([_rule(uuid="auto1", description="", action="block")])
diff = driver.diff_firewall_rules([_rule()])
assert diff == {"add": [_rule()], "update": []}
@pytest.mark.parametrize(
"live, desired",
[
([], [_rule(description="")]),
([], [_rule(), _rule(action="block")]),
([_rule(uuid="u1"), _rule(uuid="u2")], [_rule()]),
],
ids=["empty desired", "desired twice", "two live rules share it"],
)
def test_raises(self, live, desired):
driver = _FakeFirewall(live)
with pytest.raises(ValueError):
driver.diff_firewall_rules(desired)
assert driver.applied == []
+57 -3
View File
@@ -11,6 +11,7 @@ The fixtures are the real states of six hosts on netOrk's test server.
from __future__ import annotations
import os
import re
import subprocess
@@ -35,7 +36,9 @@ def _wire(
periodic=None,
stamp=None,
timer="enabled",
unattended=True,
dnf_timers=("not-found", "not-found"),
dnf_conf=(),
dpkg=None,
journal=(),
busy=False,
@@ -47,7 +50,10 @@ def _wire(
lines += ["[needs-restarting]", str(needs_restarting)]
lines += ["[kernel]", running, "[modules]", *modules]
if periodic is not None:
lines += ["[apt-config]", *periodic, "[upgrade-stamp]", *([str(stamp)] if stamp else [])]
lines += ["[apt-config]", *periodic]
if unattended:
lines.append("[unattended-upgrade]")
lines += ["[upgrade-stamp]", *([str(stamp)] if stamp else [])]
if dpkg is not None:
if busy:
lines.append("[dpkg-busy]")
@@ -57,6 +63,8 @@ def _wire(
f"apt-daily-upgrade.timer {timer}",
f"dnf-automatic.timer {dnf_timers[0]}",
f"dnf-automatic-install.timer {dnf_timers[1]}",
"[dnf-automatic]",
*dnf_conf,
"HSTAT_END",
]
return "\n".join(lines) + "\n"
@@ -162,11 +170,57 @@ class TestAutoUpdates:
assert status["auto_updates"] is False
def test_dnf_automatic(self):
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found")))
def test_removed_but_not_purged_does_not_patch_itself(self):
"""``apt remove unattended-upgrades`` leaves its conffile, and its "1", behind."""
status = parse_host_status(_wire(periodic=UNATTENDED, unattended=False))
assert status["auto_updates"] is False
def test_dnf_automatic_install_timer_always_installs(self):
status = parse_host_status(_wire(dnf_timers=("not-found", "enabled")))
assert status["auto_updates"] is True
def test_dnf_automatic_with_apply_updates(self):
conf = ["apply_updates = yes"]
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
assert status["auto_updates"] is True
@pytest.mark.parametrize(
"conf", [[], ["apply_updates = no"], ["apply_updates=yes", "apply_updates = False"]]
)
def test_dnf_automatic_by_default_only_downloads(self, conf):
"""dnf-automatic.timer installs only with apply_updates; the default is no.
dnf5's file comes last and wins."""
status = parse_host_status(_wire(dnf_timers=("enabled", "not-found"), dnf_conf=conf))
assert status["auto_updates"] is False
def test_the_command_reads_apply_updates_of_dnf4_then_dnf5(self):
conf = "/etc/dnf/automatic.conf /etc/dnf/dnf5-plugins/automatic.conf"
assert conf in HOST_STATUS_COMMAND
@pytest.mark.parametrize("installed", [True, False])
def test_the_command_asks_whether_unattended_upgrade_is_installed(self, tmp_path, installed):
"""Run for real, with a stand-in apt-config that still says "1"."""
if os.path.exists("/usr/bin/unattended-upgrade"):
pytest.skip("this host has unattended-upgrade installed")
apt_config = tmp_path / "apt-config"
apt_config.write_text("#!/bin/sh\necho 'APT::Periodic::Unattended-Upgrade \"1\";'\n")
stubs = [apt_config]
if installed:
stubs.append(tmp_path / "unattended-upgrade")
stubs[-1].write_text("#!/bin/sh\nexit 0\n")
for stub in stubs:
stub.chmod(0o755)
env = {**os.environ, "PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
out = subprocess.run(
["sh", "-c", HOST_STATUS_COMMAND], capture_output=True, text=True, env=env
).stdout
assert ("[unattended-upgrade]" in out) is installed
def test_neither_apt_nor_dnf_is_unknown(self):
assert parse_host_status(_wire())["auto_updates"] is None
+30 -1
View File
@@ -11,10 +11,14 @@ Fixture lines are from real hosts (Ubuntu 24.04, Debian 13 / OMV, Proxmox VE 9).
from __future__ import annotations
import os
import subprocess
import pytest
from napalm_device_types.package_updates import (
APT_UPGRADABLE_COMMAND,
DNF_SECURITY_COMMAND,
nevra_name,
parse_apt_upgradable,
parse_dnf_security,
@@ -139,13 +143,38 @@ class TestDnfSecurity:
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-libs-1:3.1.4-2.fc40.x86_64
FEDORA-2024-1a2b3c4d5e Important/Sec. openssl-1:3.1.4-2.fc40.x86_64
RLSA-2024:1234 Moderate/Sec. kernel-core-5.14.0-427.13.1.el9_4.x86_64
__DNF_RC=0
"""
def test_the_names_of_packages_with_a_security_advisory(self):
assert parse_dnf_security(self.ADVISORIES) == {"openssl-libs", "openssl", "kernel-core"}
def test_nothing_is_an_empty_set(self):
assert parse_dnf_security("") == set()
assert parse_dnf_security("__DNF_RC=0\n") == set()
def test_a_failed_dnf_raises_instead_of_reading_as_no_advisories(self):
"""dnf that could not reach its repositories prints nothing to stdout;
every pending update would have become not security."""
with pytest.raises(ValueError, match="1"):
parse_dnf_security("__DNF_RC=1\n")
def test_output_cut_short_raises(self):
with pytest.raises(ValueError, match="no exit status"):
parse_dnf_security(self.ADVISORIES.replace("__DNF_RC=0\n", ""))
def test_the_command_reports_dnfs_exit_status_not_cats(self, tmp_path):
"""A stand-in dnf that fails quietly, as one without its repositories does."""
dnf = tmp_path / "dnf"
dnf.write_text("#!/bin/sh\nexit 1\n")
dnf.chmod(0o755)
env = {"PATH": f"{tmp_path}:{os.environ.get('PATH', '/usr/bin:/bin')}"}
out = subprocess.run(
["sh", "-c", DNF_SECURITY_COMMAND], capture_output=True, text=True, env=env
).stdout
assert out == "__DNF_RC=1\n"
with pytest.raises(ValueError, match="exit status 1"):
parse_dnf_security(out)
@pytest.mark.parametrize(
("nevra", "name"),
+13
View File
@@ -141,6 +141,19 @@ class TestRoleIntrospection:
assert primary_role_of(Nas) == "storage"
assert primary_role_of(Host) == "linux"
def test_a_driver_that_sets_its_own_role_replaces_the_inherited_one(self):
"""A Windows host is an OS driver too, but not a "linux" one."""
class Windows(OSDriver):
ROLE = "windows"
class Nas(StorageDriver, Windows):
pass
assert primary_role_of(Windows) == "windows"
assert [r.__name__ for r in roles_of(Windows)] == ["Windows"]
assert [r.__name__ for r in roles_of(Nas)] == ["StorageDriver", "Windows"]
def test_driver_without_a_role_has_none(self):
class Bare(DeviceTypeDriver):
pass
+132 -3
View File
@@ -19,7 +19,9 @@ from napalm_device_types.ssh_server import (
MAIN_CONFIG,
OpenSshServerMixin,
has_dropin_include,
host_key_algorithms,
parse_sshd_effective,
parse_sshd_hostkeys,
plan_ssh_hardening,
render_dropin,
render_main_config,
@@ -32,8 +34,26 @@ hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256
ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes256-gcm@openssh.com
macs umac-64-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha1,hmac-sha2-256
passwordauthentication no
hostkey /etc/ssh/ssh_host_ecdsa_key
hostkey /etc/ssh/ssh_host_ed25519_key
hostkey /etc/ssh/ssh_host_rsa_key
"""
#: What ``ssh-keygen -l`` prints per host key type.
KEYGEN = {
"/etc/ssh/ssh_host_ecdsa_key": "256 SHA256:aaaa root@host (ECDSA)",
"/etc/ssh/ssh_host_ed25519_key": "256 SHA256:bbbb root@host (ED25519)",
"/etc/ssh/ssh_host_rsa_key": "3072 SHA256:cccc root@host (RSA)",
}
#: The lock-out case: sshd offers ECDSA, sk- and certificate algorithms, but the
#: host has only an ed25519 and an RSA key.
SSHD_T_FEW_KEYS = SSHD_T.replace(
"hostkeyalgorithms ssh-ed25519,ecdsa-sha2-nistp256,rsa-sha2-512,rsa-sha2-256",
"hostkeyalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,"
"ssh-ed25519,ecdsa-sha2-nistp256,sk-ssh-ed25519@openssh.com,rsa-sha2-512,rsa-sha2-256",
).replace("hostkey /etc/ssh/ssh_host_ecdsa_key\n", "")
PARAMIKO = {
"kex": ["curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256"],
"hostkey": ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512"],
@@ -112,6 +132,58 @@ def test_plan_without_a_client_list_only_guards_the_last_algorithm():
assert plan.keep["kex"] == ["sntrup761x25519-sha512@openssh.com", "curve25519-sha256"]
def test_plan_counts_only_host_key_algorithms_the_host_has_a_key_for():
"""Without ed25519 and RSA, only ECDSA, sk- and cert algorithms stay: offered,
but the host has no key for them, so nobody would get in."""
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
plan = plan_ssh_hardening(
eff,
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
)
assert "ssh-ed25519" in plan.keep["hostkey"]
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "last")]
assert plan.remove["hostkey"] == ["rsa-sha2-512", "rsa-sha2-256"]
def test_plan_client_guard_counts_only_usable_host_keys():
eff = parse_sshd_effective(SSHD_T_FEW_KEYS)
plan = plan_ssh_hardening(
eff,
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]},
client=PARAMIKO,
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
)
# paramiko speaks ecdsa-sha2-nistp256, but the host has no such key.
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ssh-ed25519", "client")]
def test_plan_removes_a_host_key_algorithm_when_a_usable_one_stays():
plan = plan_ssh_hardening(
parse_sshd_effective(SSHD_T_FEW_KEYS),
{"hostkey": ["rsa-sha2-256"]},
host_keys={"ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa"},
)
assert plan.remove == {"hostkey": ["rsa-sha2-256"]}
assert plan.refused == []
def test_plan_leaves_host_keys_alone_when_the_keys_are_unknown():
plan = plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"hostkey": ["ecdsa-sha2-nistp256"]})
assert plan.changed_classes == []
assert [(r.algorithm, r.reason) for r in plan.refused] == [("ecdsa-sha2-nistp256", "host_keys_unknown")]
def test_host_key_algorithms_from_ssh_keygen():
assert host_key_algorithms("\n".join(KEYGEN.values())) == {
"ecdsa-sha2-nistp256", "ssh-ed25519", "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa",
}
assert host_key_algorithms("521 SHA256:x no comment (ECDSA)") == {"ecdsa-sha2-nistp521"}
assert host_key_algorithms("") is None
assert host_key_algorithms("garbage") is None
assert parse_sshd_hostkeys(SSHD_T)[0] == "/etc/ssh/ssh_host_ecdsa_key"
def test_plan_rejects_an_unknown_class():
with pytest.raises(ValueError):
plan_ssh_hardening(parse_sshd_effective(SSHD_T), {"compression": ["zlib"]})
@@ -161,9 +233,14 @@ _DIRECTIVE = {"kexalgorithms": "kex", "hostkeyalgorithms": "hostkey", "ciphers":
class FakeHost(OpenSshServerMixin):
"""A host with files, an sshd that reads them first-match-wins, and a log of what ran."""
def __init__(self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n") -> None:
def __init__(self, main: str = "Include /etc/ssh/sshd_config.d/*.conf\nPort 22\n",
sshd_t: str = SSHD_T) -> None:
self.files: Dict[str, str] = {MAIN_CONFIG: main}
self.defaults = parse_sshd_effective(SSHD_T)
self.modes: Dict[str, str] = {MAIN_CONFIG: "644"}
self.sshd_t = sshd_t
self.defaults = parse_sshd_effective(sshd_t)
self.keys = {p: KEYGEN[p] for p in parse_sshd_hostkeys(sshd_t)}
self.keygen_ok = True
self.ran: List[str] = []
self.syntax_ok = True
self.reloads = 0
@@ -204,16 +281,24 @@ class FakeHost(OpenSshServerMixin):
self.files[path] = (stdin or b"").decode()
return CommandResult(path, "", 0)
assert privileged, command
m = re.search(r"install -m 644 -- '?([^' ]+)'? '?([^' ]+)'?", command)
m = re.search(r'install -m "\$m" -- \'?([^\' ]+)\'? \'?([^\' ]+)\'?', command)
if m:
src, tmp = m.group(1), m.group(2)
target = re.search(r"mv -f -- '?[^' ]+'? '?([^' ;]+)'?", command).group(1)
ref = re.search(r"r='?([^' ;]+)'?; m=\$\(stat -c %a", command).group(1)
self.modes[target] = self.modes.get(ref, "644") if ref in self.files else "644"
self.files[target] = self.files.pop(src)
assert tmp.endswith(".netork-tmp")
return CommandResult("", "", 0)
if "ssh-keygen -l" in command:
if not self.keygen_ok:
return CommandResult("", "Permission denied", 1)
paths = re.search(r"for k in (.*?); do", command).group(1).replace("'", "").split()
return CommandResult("".join(self.keys[p] + "\n" for p in paths if p in self.keys), "", 0)
if command.startswith("sshd -T") or "sshd -T" in command:
eff = self._effective()
out = "".join(f"{d} {','.join(eff[k])}\n" for d, k in _DIRECTIVE.items())
out += "".join(line + "\n" for line in self.sshd_t.splitlines() if line.startswith("hostkey "))
return CommandResult(out, "", 0)
if "sshd -t" in command:
return CommandResult("", "" if self.syntax_ok else "bad line 3", 0 if self.syntax_ok else 255)
@@ -226,6 +311,7 @@ class FakeHost(OpenSshServerMixin):
m = re.match(r"rm -f -- '?([^' ]+)'?$", command)
if m:
self.files.pop(m.group(1), None)
self.modes.pop(m.group(1), None)
return CommandResult("", "", 0)
if "reload" in command or "HUP" in command:
self.reloads += 1
@@ -349,3 +435,46 @@ def test_mixin_reports_a_failed_way_back_instead_of_raising():
assert not result.applied and not result.rolled_back
assert "putting the previous configuration back failed" in result.message
def test_mixin_refuses_to_lock_out_a_host_with_only_ed25519_and_rsa_keys():
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
lines: List[str] = []
result = host.harden_ssh_server(
{"hostkey": ["ssh-ed25519", "rsa-sha2-256", "rsa-sha2-512"]}, log=lines.append
)
assert result.applied
assert "ssh-ed25519" in host._effective()["hostkey"]
assert any("Keeping ssh-ed25519" in line for line in lines)
def test_mixin_removes_a_host_key_algorithm_when_a_usable_one_stays():
host = FakeHost(sshd_t=SSHD_T_FEW_KEYS)
result = host.harden_ssh_server({"hostkey": ["rsa-sha2-256", "rsa-sha2-512"]})
assert result.applied and result.plan.refused == []
eff = host._effective()["hostkey"]
assert "rsa-sha2-256" not in eff and "ssh-ed25519" in eff
def test_mixin_leaves_host_keys_alone_when_it_cannot_read_them():
host = FakeHost()
host.keygen_ok = False
result = host.harden_ssh_server({"hostkey": ["ecdsa-sha2-nistp256"]})
assert not result.applied and host.reloads == 0
assert DROPIN_PATH not in host.files
assert [r.reason for r in result.plan.refused] == ["host_keys_unknown"]
def test_a_0600_sshd_config_stays_0600_and_so_does_its_copy():
host = FakeHost(main="Port 22\nMACs hmac-sha1,hmac-sha2-256\n")
host.modes[MAIN_CONFIG] = "600"
result = host.harden_ssh_server({"mac": ["hmac-sha1"]})
assert result.applied
assert host.modes[MAIN_CONFIG] == "600"
assert host.modes[result.backup] == "600"
def test_a_new_dropin_gets_644():
host = FakeHost()
host.harden_ssh_server({"mac": ["hmac-sha1"]})
assert host.modes[DROPIN_PATH] == "644"
+53
View File
@@ -0,0 +1,53 @@
"""SNMP interface counters: 64-bit where the agent has ifXTable, 32-bit where not."""
from __future__ import annotations
import asyncio
from typing import Dict, Optional
from napalm_device_types._ucd_metrics import (
OID_IF_DESCR,
OID_IF_HC_IN_OCT,
OID_IF_HC_OUT_OCT,
OID_IF_HIGH_SPEED,
OID_IF_IN_OCT,
OID_IF_OUT_OCT,
OID_IF_SPEED,
collect_ucd_metrics,
)
LEGACY = {
OID_IF_DESCR: {"1": "eth0"},
OID_IF_SPEED: {"1": "4294967295"},
OID_IF_IN_OCT: {"1": "100"},
OID_IF_OUT_OCT: {"1": "200"},
}
def _collect(tables: Dict[str, Dict[str, str]]) -> dict:
async def get(oid: str) -> Optional[str]:
return None
async def walk(oid: str) -> Dict[str, str]:
return tables.get(oid, {})
return asyncio.run(collect_ucd_metrics(get, walk))
def test_the_64_bit_counters_and_high_speed_win():
tables = {
**LEGACY,
OID_IF_HC_IN_OCT: {"1": "8589934592"},
OID_IF_HC_OUT_OCT: {"1": "17179869184"},
OID_IF_HIGH_SPEED: {"1": "10000"},
}
eth0 = _collect(tables)["interfaces"]["eth0"]
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (8589934592, 17179869184, 10000)
def test_an_agent_without_ifxtable_falls_back_to_the_32_bit_counters():
eth0 = _collect(LEGACY)["interfaces"]["eth0"]
assert (eth0["rx_bytes"], eth0["tx_bytes"], eth0["speed_mbps"]) == (100, 200, 4294)
+49 -2
View File
@@ -365,6 +365,9 @@ class _Host:
self.bin.mkdir()
self.state = root / "systemd"
self.state.mkdir()
#: Stands in for /run/systemd/system, there while systemd is PID 1.
self.booted = root / "run-systemd-system"
self.booted.mkdir()
(self.state / "units").write_text("")
stub = self.bin / "systemctl"
stub.write_text(
@@ -392,11 +395,15 @@ class _Host:
path.write_text(content)
return path
def run(self, *, systemctl: bool = True) -> str:
def run(self, *, systemctl: bool = True, booted: bool = True) -> str:
if not systemctl:
(self.bin / "systemctl").unlink()
if not booted:
self.booted.rmdir()
assert VIRTUAL_IPS_COMMAND.count("/run/systemd/system") == 1
command = VIRTUAL_IPS_COMMAND.replace("/run/systemd/system", str(self.booted))
return subprocess.run(
["/bin/sh", "-c", VIRTUAL_IPS_COMMAND],
["/bin/sh", "-c", command],
capture_output=True,
text=True,
env={"PATH": str(self.bin)},
@@ -552,6 +559,46 @@ class TestTheCommandOnAHost:
"keepalived": None,
}
def test_systemctl_without_systemd_running_is_not_looked_at(self, host):
"""A container that carries systemctl but runs something else as PID 1:
systemctl lists no units there, which is not "none declared"."""
config = host.file(
"etc/keepalived/keepalived.conf", "vrrp_instance a { virtual_ipaddress { 192.0.2.4 } }"
)
host.unit("keepalived.service", "ActiveState=active", _exec_start("/k", "-f", str(config)))
assert parse_virtual_ips(host.run(booted=False)) == {
"vip_manager": None,
"keepalived": None,
}
def test_braces_on_their_own_line(self, host):
"""keepalived takes ``vrrp_instance VI_2`` and its ``{`` on separate lines,
the same for every block; the secrets inside stay on the host."""
config = host.file(
"etc/keepalived/keepalived.conf",
"vrrp_instance VI_1 {\n interface eth0\n"
" virtual_ipaddress {\n 192.0.2.50\n }\n}\n"
"vrrp_instance VI_2\n{\n interface eth1\n virtual_router_id 53\n"
f" authentication\n {{\n auth_type PASS\n auth_pass {SECRET}\n }}\n"
" virtual_ipaddress\n\n {\n 192.0.2.60/24\n }\n"
f" notify_master /etc/keepalived/master.sh {SECRET}\n}}\n",
)
host.unit(
"keepalived.service",
"ActiveState=active",
_exec_start("/usr/sbin/keepalived", "-f", str(config)),
)
out = host.run()
assert SECRET not in out
assert [(e["instance"], e["address"], e["prefix_length"], e["interface"], e["group_key"])
for e in parse_virtual_ips(out)["keepalived"]] == [
("VI_1", "192.0.2.50", 32, "eth0", None),
("VI_2", "192.0.2.60", 24, "eth1", "vrid:53"),
]
class TestTheCommand:
def test_the_frame_is_not_in_the_command_itself(self):