fix: report which services are enabled, and whether an action worked

get_services() read list-unit-files' second column, which since systemd 245
is followed by a preset column -- "enabled  enabled" never equalled
"enabled", so every service on every node was reported disabled (#6).
manage_service() ended in "|| true" and returned success whatever happened.

Both now come from napalm-device-types' SystemdServicesMixin (2.2.0): the
enabled state is UnitFileState from systemctl show, MainPID comes along in
the same round trip, and an action reports systemctl's exit status. The
driver keeps only _run_service_command(), its exec path as root. The exit
status marker keeps the output non-empty, so _exec_ssh_command's SSH fallback
on an empty API answer can no longer run an action twice.

The listing no longer includes templates, static unit files and aliases that
are not loaded (on a PVE 9 node: 280 entries -> 172).

Closes #6
This commit is contained in:
Christian Manivong
2026-10-05 13:12:14 +02:00
parent ea74e84387
commit 6bf1736619
4 changed files with 83 additions and 71 deletions
+6 -69
View File
@@ -346,79 +346,16 @@ class ProxmoxSystemMixin:
return warnings
# ------------------------------------------------------------------ #
# Services (systemd)
# Services (systemd, through napalm-device-types' SystemdServicesMixin)
# ------------------------------------------------------------------ #
def get_services(self) -> list[_JsonDict]:
"""Return systemd services with running and enabled state.
def _run_service_command(self, command: str, *, privileged: bool, timeout: int) -> str:
"""The transport for ``SystemdServicesMixin``: the exec path, as root.
Uses two ``systemctl`` invocations combined in a single SSH command:
- ``list-unit-files`` for the static enabled/disabled state
- ``list-units`` for the live running state
*privileged* needs nothing more on a node the driver reaches as root, and
the exec path keeps its own timeout.
"""
raw = self._exec_ssh_command(
"{ systemctl list-unit-files --type=service --no-pager --no-legend --full 2>/dev/null;"
" echo '---UNITS---';"
" systemctl list-units --type=service --all --no-pager --no-legend --full 2>/dev/null;"
" } || true"
)
# Parse enabled state from list-unit-files
enabled_map: dict[str, bool] = {}
section = "files"
for line in raw.splitlines():
if line.strip() == "---UNITS---":
section = "units"
continue
parts = line.strip().split(None, 1)
if len(parts) < 1:
continue
unit = parts[0].lstrip("\u25cf").strip()
if not unit.endswith(".service"):
continue
name = unit[: -len(".service")]
if section == "files":
state = parts[1].strip() if len(parts) > 1 else ""
enabled_map[name] = state in ("enabled", "enabled-runtime", "static")
# Parse running state from list-units
running_map: dict[str, bool] = {}
section = "files"
for line in raw.splitlines():
if line.strip() == "---UNITS---":
section = "units"
continue
if section != "units":
continue
parts = line.strip().lstrip("\u25cf").strip().split(None, 4)
if len(parts) < 4:
continue
unit = parts[0]
if not unit.endswith(".service"):
continue
name = unit[: -len(".service")]
sub_state = parts[3]
running_map[name] = sub_state == "running"
all_names = sorted(set(enabled_map) | set(running_map))
return [
{
"name": name,
"running": running_map.get(name, False),
"enabled": enabled_map.get(name, False),
"pid": 0,
}
for name in all_names
]
def manage_service(self, name: str, action: str) -> _JsonDict:
"""Start / stop / restart / enable / disable a systemd service."""
if not re.match(r'^[a-zA-Z0-9_\-\.@]+$', name):
raise ValueError(f"Invalid service name: {name!r}")
if action not in ('start', 'stop', 'restart', 'enable', 'disable'):
raise ValueError(f"Invalid action: {action!r}")
output = self._exec_ssh_command(f"systemctl {action} {name}.service 2>&1 || true")
return {"success": True, "output": output}
return str(self._exec_ssh_command(command))
# ------------------------------------------------------------------ #
# Available updates